#PHPFFI实战:在PHP里直接调用C函数(libc/自定义so与内存对齐)我第一次在PHP里调C函数的时候,满脑子都是「胶水层」的幻想——以为FFI就是个跨语言的透明胶水,PHP这边写个函数名,那边…
PHP FFI 实战:在 PHP 里直接调用 C 函数(libc/自定义 so 与内存对齐)
PHP FFI 实战:在 PHP 里直接调用 C 函数(libc/自定义 so 与内存对齐)
我第一次在 PHP 里调 C 函数的时候,满脑子都是「胶水层」的幻想——以为 FFI 就是个跨语言的透明胶水,PHP 这边写个函数名,那边 C 的库就自动接上了,性能瓶颈迎刃而解。结果上线前本地测得好好的,pow(2, 10) 返回 1024,一丢到 PHP-FPM 环境直接甩脸:FFI not enabled in this build(或者干脆Class"FFI" not found),白忙活半天。后来才明白:FFI 根本不是什么胶水,它就是一扇直接通往 C 世界的门,门后面是 C 的一整套冷冰冰的规则——内存怎么排、谁负责释放、ABI 怎么对齐——你不过去守规矩,C 就让你进程直接 segfault 静默退出。
到底怎么用才不踩坑?核心就三点:第一,配置和编译支持双到位,不是开个开关就完事;第二,先用 libc 这种系统库把链路跑通,别一上来就编译自己的 so;第三,永远按 C 的规则管理内存,FFI 不给你任何运行时类型安全检查。 下面我把这三点连同结构体对齐、CData 生命周期、自定义 so 预加载、ABI 匹配和 segfault 排查,一个一个拆开讲清楚,代码全部能直接复制跑。
先泼盆冷水:FFI 不是银弹,想清楚再上
说这些不是劝退,而是怕你跟我当年一样脑子一热就上。我见过太多人听说「PHP 能调 C 了」就兴奋地把所有热点函数全改写成 FFI 调用,结果性能没涨多少,崩溃倒翻了好几倍。FFI 真正划算的场景其实很窄:要么是某段纯数值计算(矩阵、加密、编解码)PHP 确实慢得离谱;要么是你已经有现成的、验证过的 C 库(比如图像、压缩、音视频),不想用 PHP 重写一篇。除此之外,绝大多数「我觉得这里能优化」的地方,老老实实用 PHP 写、或者把 IO 等待并发掉(想想协程那套思路),收益都比冒险调 C 来得稳。
另一个得泼冷水的点是心智成本。你引入 FFI 的同时,也把 C 的全部负担——内存泄漏、野指针、段错误、ABI 碎片化——一起引入了。PHP 之所以让人舒服,很大程度上是因为它替你管了内存、帮你抛异常。FFI 把这些保护罩全拆了。所以我的判断标准很简单:如果这个优化不值得你为之承担进程崩溃的风险,那就别用 FFI。它不是让你写得更爽,是让你写得更小心。
一、FFI 不是开关,是「配置项 + 编译支持」双到位
很多人以为 PHP 7.4 之后 FFI 是默认开的,其实大错特错。PHP 虽然从 7.4 起内置了 FFI 扩展,但默认只在 CLI 的 preload模式下放行,Web 环境(Nginx + PHP-FPM、Apache mod_php)一律默认禁用。我当年就是改完php.ini 没重启 FPM,CLI 下 php -r 能跑,以为万事大吉,结果 Web 请求里死活调不通,查了半天才反应过来:CLI 能跑 ≠ Web 能用,这是两个完全独立的 SAPI 进程。
必须确认的几件事,按顺序来:
php.ini里extension=ffi这一行取消注释(Windows 是extension=php_ffi.dll);- 设
ffi.enable = true,不能留默认的"preload"——"preload"只在 CLI 下生效; - 如果你用 Docker 或 Alpine 这种精简镜像,编译 PHP 时得带
--enable-ffi,运行时还得装libffi-dev,否则扩展根本编译不进去; - 改完一定重启 PHP 进程(FPM 是
service php-fpm restart或发SIGUSR2),然后验证。
验证这一步千万别省,一行命令见真章:
php<?php// 先确认 FFI 扩展真的加载了,别上来就 cdef 然后一脸懵var_dump(extension_loaded('ffi')); // 期望 bool(true)// 再验证能调通系统库:直接 cdef 取 libc 的 getpid,返回当前进程 PID$libc = FFI::cdef("int getpid();","libc.so.6");var_dump($libc->getpid()); // 正整数,比如 int(12345)
返回正整数才算通。如果这一步就报错Failed to load libary,八成是库路径问题或者架构不匹配(x86_64 的 PHP 去 load 一个 x86 的 DLL,必炸)。验证通过,再往下走。

二、先用 libc 跑通基础链路:FFI::cdef 加载 printf / pow / strlen
别一上来就编译自己的 C 库,那是在给自己加难度。先用 libc(Linux 下是 libc.so.6,macOS 是 libc.dylib,Windows 是 msvcrt.dll)验证基础链路,因为系统库就在那,不用你编译。
FFI::cdef()的第一个参数是纯 C 声明字符串,第二个参数是要加载的共享库路径。注意一个坑:FFI不解析头文件,它只认你手写的纯 C 函数签名。所以 printf这种可变参数,你得老老实实写出const char *format, ...,而且... 是否支持取决于目标库的 ABI(libc 支持,但你自己写的 so 多半不支持可变参数,别想当然)。
php<?php// 加载 libc(Linux 下是 libc.so.6;macOS 是 libSystem.dylib;Windows 用 msvcrt.dll)$libc = FFI::cdef(" int printf(const char *format, ...); // 可变参数,C 声明里用 ... 表示 double pow(double x, double y); // 标准幂运算,2 的 10 次方这种 int strlen(const char *s); // 经典 C 字符串长度","libc.so.6");// printf 是可变参数,FFI 不解析头文件,只认纯 C 声明,所以这里直接传$libc->printf("hello from C: 2^10 = %.0f\n", $libc->pow(2, 10)); // 输出 1024// strlen 吃的是 C 字符串指针,后面章节讲怎么造这种指针
这里有个心态上的转变:你写的printf 调用,本质上已经不是 PHP 函数了,它是 libc 里那段编译好的机器码,PHP 只是帮你把参数按 C 的调用约定(cdecl)压进栈、跳过去执行、再把返回值搬回来。参数类型、指针符号、调用约定,必须和 libc 的真实 ABI 一字不差,否则不是结果错乱,就是直接崩。
还有个实用建议:手写的 C 声明最好直接从系统头文件里抄,而不是凭记忆拼。比如 /usr/include/stdio.h里printf的原型,把宏、#include、注释删掉,只留纯函数签名。自己瞎写一个漏了const 或指针符号,libffi 不会替你发现,运行时传参错位直接崩。至于 ... 可变参数,libc 支持,但你要是拿去调自己写的 .so,大概率不支持——C 的可变参数依赖调用约定和栈帧布局,自定义库没按这套约定编译,强行传 ...十有八九段错误。所以新手阶段,先把可变参数函数当禁区,等吃透调用约定再说。
三、FFI::new 分配 C 变量与数组,FFI::string 读写字符串
光调系统函数不算完,真正干活时你得在 PHP 侧造出 C 能认的内存。FFI::new() 就是干这个的——它在 C 的堆上分配一块连续内存,返回一个 FFI\CData对象。注意:PHP 数组和 C 数组完全是两码事。PHP 数组是散列的 zval 结构,C 数组是内存里挨着的字节。你想给 C 函数传 int[8],就得用FFI::new("int[8]"),然后用[]下标逐个赋值,直接传$phpArray 过去 C 层读到的会是一堆乱七八糟的地址。
为什么不能直接传 PHP 数组?因为两者的内存布局天差地别。PHP 数组底层是 HashTable,每个元素包着zval,还带引用计数、类型标记,零散地堆在内存里;C 数组则是从某个地址开始、元素一个挨一个、每个正好占 sizeof(类型)字节。你把$phpArray丢给一个声明成int * 的 C 参数,C 函数会把它当成「指向 int 的指针」,顺着地址往后读连续字节——读到的全是 PHP 内部结构的残骸,不是你的数据。这也是为什么 FFI::new 分配的才是「真·C 数组」,它保证内存连续、类型确定,C 侧才能正确遍历。记住这条铁律:跨 FFI 边界传数组,永远用 FFI::new 现造,别拿 PHP 数组糊弄。
字符串更隐蔽。PHP 字符串变量不是 C 字符串(C 字符串以 \0 结尾、是一段连续字节),所以 FFI 传参时要么依赖隐式转换(仅对 const char*且调用期间有效),要么老老实实手动分配。我建议永远手动分配,心里有底:
php<?php$libc = FFI::cdef(" int strlen(const char *s);","libc.so.6");// 老规矩:先把要传的字符串变成真正的 C 内存,而不是 PHP 字符串变量$cHello = FFI::new("char[8]"); // 分配 8 字节 C 内存FFI::memcpy($cHello,"php ffi\0", 8); // 把字节拷进去,记得手动留 \0 结尾echo $libc->strlen($cHello); // 输出 7,因为 \0 不算长度// 反过来:FFI::string 从 C 内存读字节,变成 PHP 字符串(这是「读」,不是「写」)echo FFI::string($cHello, 7); // 输出"php ffi"
FFI::string($ptr, $n) 是从 C 侧读n 个字节回 PHP;而往 C 侧写字符串,靠的是FFI::memcpy($dest, $src, $n)。这对操作方向相反,别记反。顺带一提,凡是涉及中文或特殊字符的路径、文件名,进 C 之前最好用 mb_convert_encoding(..., 'UTF-8') 预处理,否则 C 层按字节读出来就是乱码——这一点和 前端国际化实战:Intl API 与 i18n 架构(数字/日期/货币与动态切换)里讲的多字节文本处理是同一个坑的不同侧面。
再补一个数组的例子,这是后面给自定义 so 传参数的基本功:
php<?php// 分配一个长度为 8 的 int 数组(C 的连续内存,不是 PHP 数组)$arr = FFI::new("int[8]");for ($i = 0; $i < 8; $i++) { $arr[$i] = $i * 2; // 用 [] 下标访问数组元素,和 C 语法一致}var_dump($arr[3]); // int(6)// FFI::string 读 C 内存:从指针读出指定字节数,返回 PHP 字符串$buf = FFI::new("char[6]");FFI::memcpy($buf,"hello\0", 6);echo FFI::string($buf, 5); // 输出"hello"
四、结构体和内存对齐:FFI::sizeof / alignof(最容易 segfault 的地方)
FFI 操作的是裸内存,这里有个让人头大的点:PHP 变量赋值不等于内存拷贝。$a = FFI::new("int[8]"); $b = $a; 这行代码只是复制了 PHP 对象引用,两个变量指向同一块 C 内存,$b改了$a也跟着变,不是memcpy。想真正复制内存,得FFI::memcpy($dest, $src, FFI::sizeof($src))。
更要命的是结构体的内存对齐。C 编译器为了访问效率,会在结构体成员之间插入填充字节(padding),绝不是你把各成员大小加起来那么简单。比如下面这个结构体,你以为 char(1) + int(4) + double(8) = 13 字节?错,真实尺寸通常是 16 或 24,因为 int 要按 4 字节对齐、double 要按 8 字节对齐,前面会塞填充位。自己猜尺寸去 FFI::new 一块内存,越界就是 segfault。
具体怎么填,我给你演算一遍Demo 的布局:假设起始地址是 0,char c 占 0(1 字节);接下来要放 int i,它要求地址是 4 的倍数,所以 1~3 这三个字节被白白填成 padding,i 落在 4~7;再放 double d,要求 8 的倍数,而 8 正好满足,于是 d 落在 8~15。总尺寸 = 16 字节,对齐值 = 8(取最大的 double)。如果你的编译器开了特殊对齐选项(比如#pragma pack(1)),那又会变成紧凑的 13 字节——所以永远用FFI::sizeof实测,别信自己的演算。还有个工程小技巧:结构体里字段顺序越乱,padding 越浪费,把大对齐要求的字段(double、long、int64)往前放,反而能省下不少内存,这对频繁分配的结构体尤其可观。
php<?php$ffi = FFI::cdef(" typedef struct { char c; // 1 字节,但后面会填 3 或 7 字节对齐 int i; // 4 字节,必须落在 4 的倍数地址 double d; // 8 字节,必须落在 8 的倍数地址 } Demo;");// 直接用 FFI 静态方法看真实尺寸与对齐,别凭感觉猜$demo = $ffi->new("Demo");echo FFI::sizeof($demo); // 通常是 16 或 24,绝不是 13echo FFI::alignof($demo); // 通常是 8(取最大成员的对齐要求)
FFI::sizeof()和FFI::alignof()是你的两个保命工具,传一个FFI\CData 实例进去就能拿到真实数字。凡是涉及结构体、联合体、按字节偏移访问字段的地方,先用这两个方法核验,比出事之后对着 core dump 发呆强一百倍。
结构体指针传进 C 函数时还有个坑:必须传地址,用 FFI::addr($struct),而不是直接把结构体变量丢进去。直接传结构体变量,C 函数拿到的是值拷贝还是野指针全看运气,绝大多数情况是直接崩。这跟 [[PHP 8 Attributes 注解实战:用 #[Route] 取代注释解析(反射读取与路由映射)]] 里强调的「声明即契约」是一个道理——你给 C 的每一字节都得是它预期的形状。
五、CData 生命周期:owned 与 persistent
FFI::new() 有两个隐藏参数,绝大多数教程一笔带过,但这是生产环境 OOM 和野指针的根源:FFI::new($type, $owned = true, $persistent = false)。
$owned = true(默认):这块内存由 PHP 的 GC 托管,CData 对象被回收时自动free,省心。但代价是请求结束 PHP 就回收,你不能把这种内存的指针长期交给 C 层缓存——下次访问就是野指针。$owned = false:PHP 不会自动回收,必须你手动FFI::free(),否则永不释放,在 CLI 长进程或常驻 Worker 里极易堆积成 OOM。$persistent = true:跨请求存活(借助 OPcache 预加载的共享内存),适合做全局缓存,但释放时机更要小心,且只能free一次。
php<?php$libc = FFI::cdef("void free(void *ptr);","libc.so.6");// owned=false 的内存,PHP 不会自动回收,必须手动 FFI::free,否则泄漏$buf = FFI::new("char[64]", false);$buf[0] = 65; // 'A'FFI::free($buf); // 手动释放,且只能 free 一次,重复 free 直接 crash// persistent=true 跨请求存活,适合做全局缓存,但也要配对释放$cache = FFI::new("int[1024]", false, true);$cache[0] = 99;
一个真实工程感受:在PHP Swoole 协程实战:把串行的 IO 等待变成并发(go/Channel/WaitGroup) 那种常驻内存的 Worker 里用 FFI,内存所有权比在 FPM 里危险十倍——FPM 好歹每个请求结束进程就清场,Worker 是跑几个月不重启的,一个忘了 free的owned=false 块,累积几千次就是稳定 OOM。所以我的铁律是:非必要不碰owned=false,一旦碰了,立刻在旁边写注释标明谁负责释放。
还要提醒一句:FFI\CData不支持unset(),也别对它clone,这些操作要么报错要么产生你意想不到的引用关系。想知道一个指针是不是空,用FFI::isNull($cdata)判断,比瞎猜安全。
再补一个隐蔽的雷:owned=true 的 CData,一旦你把它的地址传给 C 函数、而 C 函数把这个指针存起来打算以后用(比如塞进全局链表),危险就来了——PHP 请求结束、对象被 GC,那块内存随之被 free,C 那边还攥着旧地址,下次访问就是野指针段错误。所以「C 层会不会长期持有这块内存」是决定 owned取值的关键问题,答不上来就选false + 手动 free,宁可麻烦也别崩。这也是为什么我不建议在 FFI 上偷懒:省下的那行 free,往往要用一次线上事故来还。
六、调用自定义 .so:FFI::load 头文件 + FFI::scope 预加载
讲到这儿都是玩系统库,真正爽的是调你自己的 C 代码编译成的 .so。但有个天大的性能坑:FFI::cdef() 解析 C 声明、加载 so 是重操作,耗时毫秒级。你要是把它写进 HTTP 请求循环里,每次请求都重新解析一遍,那性能还不如纯 PHP,等于自废武功。
正确姿势是预加载(preload):把 C 函数声明写进一个 .h头文件,文件顶部加#define FFI_SCOPE"mymath"声明作用域;然后在opcache.preload指定的脚本里调用一次FFI::load("math.h");之后每个请求用FFI::scope("mymath")直接拿已经缓存好的绑定,跳过全部初始化开销。
先写头文件math.h:
c// math.h —— 自定义 C 库的头文件#define FFI_SCOPE"mymath" // 关键:声明作用域,预加载后靠它取回绑定double pow2(double x); // 求平方double sum(double *arr, int len); // 数组求和
然后 PHP 侧分两步走:
php<?php// 步骤 1:在 opcache.preload 指定的脚本里预加载(整个进程生命周期只做一次)FFI::load(__DIR__ ."/math.h");// 步骤 2:运行时直接拿已加载的 scope,毫秒级复用,不重新解析头文件$math = FFI::scope("mymath");echo $math->pow2(9); // 输出 81// sum 需要连续 C 数组,别直接传 PHP 数组,老规矩手动分配$arr = FFI::new("double[3]");$arr[0] = 1; $arr[1] = 2; $arr[2] = 3;echo $math->sum($arr, 3); // 输出 6.0
这里 scope 必须对齐:头文件里 #define FFI_SCOPE"mymath",运行时FFI::scope("mymath") 才能拿到。默认 scope 是 "C",但你FFI::load() 不会自动注册到默认 scope,除非头文件显式声明。多个头文件可以共用同一个 scope,但符号不能重复,否则预加载直接失败。实测下来,百万元素数组求和,不预加载可能比原生array_sum() 还慢,预加载之后纯数值累加能快 3–5 倍——分水岭就在这。
顺手把预加载的配置说清楚,免得你卡在这一步。php.ini里要设opcache.preload=/path/preload.php和opcache.preload_user=www-data(用户要对得上),preload.php里就一行FFI::load(__DIR__ . '/math.h');。注意预加载脚本运行在指定用户下,权限不对会静默失败——我踩过这个坑,日志里一个字没提,只是运行时FFI::scope 永远返回 null,排查到怀疑人生。还有,预加载的 .so 路径最好写绝对路径,相对路径在 FPM 的工作目录下解析,容易找不到。配好之后用 php -i | grep opcache.preload确认生效,再压测,别凭感觉。
七、ABI 匹配与 segfault 排查
FFI 最反人类的一点是:它不提供任何运行时类型安全检查。函数签名写错、指针越界、结构体对齐偏差,全都不会抛Exception,而是让整个 worker 进程静默退出。所以 ABI 匹配是生死线,几条必须记牢:
- 类型宽度要对:
double *在 64 位系统占 8 字节、int占 4 字节。C 函数要size_t时,PHP 侧的int直接传会被截断高位,必须FFI::cast('size_t', $len)显式转换。 - 架构要匹配:x86_64 的 PHP 不能
loadx86 的 DLL/so,这是undefined symbol和wrong ELF class的常见来源。 - 调用约定要一致:Windows 下很多 API 是
__stdcall,声明时得带上,否则栈错乱必崩。
php<?php$libc = FFI::cdef(" void *memset(void *s, int c, size_t n); size_t strlen(const char *s);","libc.so.6");$buf = FFI::new("char[16]");// C 函数要 size_t,PHP int 直接传会被截断高位 —— 必须显式 cast$libc->memset($buf, 0, FFI::cast("size_t", 16));// 调试三板斧:typeof 看类型、sizeof 看尺寸、var_dump 看地址var_dump(FFI::typeof($buf)->getName()); // 输出"char[16]"var_dump(FFI::sizeof($buf)); // int(16)var_dump($buf); // FFI\CData 对象,带真实内存地址
调试 segfault 的顺序我一般是这样:先用 var_dump($cdata)看地址和类型还在不在,再用FFI::typeof()和FFI::sizeof() 核验尺寸,最后才去对照 C 头文件查签名。比纯靠猜快得多。
说个我真实踩过的坑。有次调一个自己写的.so,函数签名是int parse(char *buf, size_t len),我图省事直接FFI::cdef时把size_t写成了int,本地小数据测试一切正常,一上生产处理几 MB 的报文就崩。查了两天,最后用 FFI::typeof($buf)->getName()确认指针类型没错,再用FFI::sizeof比对参数才发现宽度对不上——int 在 64 位下是 4 字节,C 函数却按 8 字节的 size_t去读长度,高位直接截断,读到的len是个天文数字,越界访问当场段错误。改成FFI::cast('size_t', $len)之后,世界清净了。这事儿给我的教训是:凡是通过 FFI 跨边界传的整数,宽度一律显式对齐,别指望 PHP 的 int 和 C 的 int 是一回事,二者在 64 位平台上经常不是同一个宽度。
还有一个生产环境的「黑科技」陷阱必须单独说:动态库热更新别信「改完就生效」。FFI::load()加载的.so 被操作系统缓存,你替换磁盘上的文件,PHP 进程用的还是旧版本内存镜像。强行 unset($ffi)+gc_collect_cycles() 想卸载 so,90% 场景下只是延迟崩溃,因为 dlclose() 引用计数没归零。真实可行的只有两条路:要么用临时路径复制新 so(比如 /tmp/math_v2_".time().".so)再用新路径FFI::cdef(..., $new_path)加载;要么进程级滚动重启——这在BroadcastChannel 实战:同源多标签页实时通信(登录态同步与状态广播)里聊多标签页状态同步时也强调过,进程级的边界,靠应用层技巧绕不过去,老老实实重启最稳。
八、结语:FFI 是把双刃剑,按 C 的规则来才安全
把上面八节串起来看,FFI 的本质就一句话:它不是魔法加速器,它是把 C 的确定性代价——内存管理、类型安全、ABI 对齐——原封不动地移交给了 PHP 开发者。一个没对齐的double *指针,或一次忘记FFI::free() 的手动分配,就能让整个 worker 静默退出,这点甚至比写 PHP 扩展还危险,因为扩展至少编译期帮你查类型。
所以我的实操清单,建议直接贴在显示器边上:
- 配置
ffi.enable = true并重启 Web 进程,CLI 能跑不代表 Web 能用; - 先用
libc的printf/pow/strlen跑通FFI::cdef基础链路; - 所有传给 C 的数组、字符串,一律
FFI::new+FFI::memcpy手动造,别直接丢 PHP 变量; - 碰结构体先
FFI::sizeof/FFI::alignof核验尺寸,传结构体用FFI::addr; - 自定义 so 必须
FFI::load头文件 +FFI::scope预加载,cdef绝不进请求循环; owned=false的内存手动FFI::free且只 free 一次,常驻 Worker 里尤其小心;- ABI 宽度、架构、调用约定三者全对齐,类型转换用
FFI::cast。
最后啰嗦一句关于「值不值」:FFI 带来的性能收益,得用数据说话,别凭体感。我一般先用纯 PHP 跑一遍基准(比如 array_sum vs FFI sum),确认差距值得承担崩溃风险才上。很多时候你会发现,瓶颈根本不在 CPU 计算,而在 IO、序列化、或者 N+1 查询——这些 FFI 帮不上忙,反而把 IO 等待并发掉的思路才是正解。FFI 是手术刀,不是锤子,别拿它砸所有钉子。
如果你追求的是把纯计算甩给原生、榨干 CPU,那 FFI 配合 WebGPU 计算着色器实战:用 WGSL 在浏览器里跑并行计算(矩阵乘法与工作组) 里那种「把重活交给更底层的执行单元」的思路是一致的——核心都是认清边界、把对的地方交给对的工具。FFI 不是胶水,是门;门后面是 C 的地盘,按它的规矩来,它才给你性能;不按规矩,它就给你 segfault。
标签:PHP FFI, Foreign Function Interface, 调用C, libc, 内存对齐描述:PHP 7.4+ 的 FFI 扩展让你在 PHP 里直接调用 C 函数,但它是门而非胶水——必须按 C 的规则管理内存与 ABI。本文用可复制的 PHP 代码讲透 FFI::cdef 加载 libc、FFI::new 分配 C 变量与数组、FFI::string 读写字符串、结构体与内存对齐(sizeof/alignof)、CData 生命周期(owned/persistent)、自定义 .so 的 FFI::load+FFI::scope 预加载,以及 ABI 匹配与 segfault 排查,帮你避开生产环境的崩溃陷阱。

发表评论发布评论后审核成功,即可显示在列表中...