⭐ 推荐:社区规则条款 V1.0

Debian / Ubuntu

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员
发布于 2026-10-03 04:43 ·66 浏览 ·6 回复
内容摘要

介绍在 Debian/Ubuntu 上从零编译 PHP C 扩展的完整步骤:安装 php-dev 等工具、生成骨架、解释 config.m4、编写 hello.c 函数,并强调 phpize 与运行 PHP 版本必须一致,最终使 php -

照着走一遍,你就能从零编译出一个能被 php -m 认出来的 C 语言扩展,并知道每个文件、每条命令到底在干什么。

第一步:装齐工具链

扩展开发需要编译器 + PHP 的开发头文件 + 构建脚本工具:


apt install php-dev gcc make autoconf

# CentOS / Rocky(以 PHP 8.2 为例)
yum install php-devel gcc make autoconf

装完先确认两件事:

php -v                 # 运行时版本,比如 8.2.15
php-config --version   # 开发包版本,必须和上面一致
php-config --extension-dir   # .so 最终要落到的目录

注意:phpize 的版本和实际运行 PHP 的版本必须严格一致。版本对不上,编译能过、加载必挂。宝塔面板装的 PHP,用 /www/server/php/82/bin/phpize 这种绝对路径,别用系统的。

第二步:生成扩展骨架

PHP 自带一个生成器 ext_skel.php,它躺在 PHP 源码包的 ext/ 目录里。

  • 如果有源码包:cd php-src/ext && php ext_skel.php --ext=hello
  • 发行版的 php-dev 包常常不带这个文件,那就去 php.net 下对应版本的源码包,解压后只取 ext/ext_skel.php 用,不需要编译整个 PHP。

执行后会得到 hello/ 目录,里面最该关注三个文件:

  • config.m4 —— 告诉构建系统怎么编译
  • php_hello.h —— 头文件,声明函数
  • hello.c —— 真正的实现

注意:骨架只是省事,不是必需。手写这三个文件同样能编译,理解结构比会用生成器重要。

第三步:看懂 config.m4

打开 config.m4,核心是这段:

PHP_ARG_ENABLE([hello],
  [whether to enable hello support],
  [AS_HELP_STRING([--enable-hello], [Enable hello support])],
  [no])

if test "$PHP_HELLO" != "no"; then
  PHP_NEW_EXTENSION(hello, hello.c, $ext_shared)
fi

意思很直白:./configure 时传 --enable-hello 才编译,源码文件是 hello.c。如果以后拆成多个 .c 文件,在这里用空格隔开列全即可。

第四步:在 hello.c 里写函数

把 hello.c 里自带的一堆示例函数删掉,只留模块入口,然后加入自己的函数:

ZEND_BEGIN_ARG_INFO_EX(arginfo_hello_greet, 0, 0, 1)
    ZEND_ARG_INFO(0, name)
ZEND_END_ARG_INFO()

PHP_FUNCTION(hello_greet)
{
    char   *name;
    size_t  name_len;

    ZEND_PARSE_PARAMETERS_START(1, 1)
        Z_PARAM_STRING(name, name_len)
    ZEND_PARSE_PARAMETERS_END();

    RETURN_STR(strpprintf(0, "Hello, %s!", name));
}

static const zend_function_entry hello_functions[] = {
    PHP_FE(hello_greet, arginfo_hello_greet)
    PHP_FE_END
};

模块入口结构体里,hello_functions 就是函数注册表,PHP 侧能调到哪些函数全看它。

注意:C 扩展里不要用 malloc / free,要用 emalloc / efree;字符串用 zend_string。用错分配器会导致请求结束时内存统计错乱甚至崩溃。另外,绝不能返回函数内局部数组的指针,必须用 RETURN_STR 这类宏让引擎接管生命周期。

第五步:编译安装

cd hello
phpize
./configure --with-php-config=$(which php-config)
make
sudo make install

make install 会把 hello.so 拷到 php-config --extension-dir 显示的目录。

注意:非 root 环境或宝塔环境可能拷不进去,那就手动把 modules/hello.so 复制过去。改了 C 代码后必须重新 make && make install,重启 PHP 才会生效。

第六步:启用并验证

在 php.ini 末尾加一行:

extension=hello.so

重启 PHP(systemctl restart php-fpm 或面板里重载),然后验证:

php -m | grep hello
php -r 'echo hello_greet("world"), PHP_EOL;'

看到 hello 出现在模块列表、命令行输出 Hello, world!,第一个扩展就跑通了。

注意:如果 php -m 里没有,先看 php -i | grep extension_dir 确认路径,再用 php -d extension=hello.so -v 单独加载,报错信息会直接告诉你符号缺失还是版本不符。

小结

  • 工具链核心是 php-devel + phpize,版本必须和运行环境一致
  • config.m4 决定编译方式,zend_function_entry 决定对外暴露哪些函数
  • 参数解析用 Z_PARAM_* 宏,返回字符串用 RETURN_STR,内存一律走 emalloc 系
  • 流程固定:phpize → configure → make → make install → 改 php.ini → 重启
  • 每改一次代码都要重编译并重启,扩展改动不会热生效
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-685.html
转载请注明出处,版权归原作者所有。

全部回复 6

aixiu
aixiu 正式会员正式会员认证极客认证极客 1楼 2026-10-03 04:50

这篇流程没问题,但第四步那段代码贴到 ZEND_PARSE_PARAMETERS_S 就断了——补上结尾宏、返回值、函数注册表这三处,才能真正被 php -m 认出来。

先把函数写完整:

PHP_FUNCTION(hello_greet)
{
    char   *name;
    size_t  name_len;

    ZEND_PARSE_PARAMETERS_START(1, 1)
        Z_PARAM_STRING(name, name_len)
    ZEND_PARSE_PARAMETERS_END();

    RETURN_STR(strpprintf(0, "Hello, %s", name));
}

然后在 php_hello.h 里声明 PHP_FUNCTION(hello_greet);,并在 hello.c 的 zend_function_entry 表里加一行 PHP_FE(hello_greet, arginfo_hello_greet)——arginfo_* 名字必须和 ZEND_BEGIN_ARG_INFO_EX 的宏名一致,这里最容易拼错,拼错了不报错但反射拿不到参数信息。表尾的 PHP_FE_END 不能少。

编译加载按这个顺序:phpize → ./configure --enable-hello → make → make install。改过 config.m4 一定要先 phpize --clean 再重来,否则旧缓存会骗你。装完在 php.ini 里加 extension=hello.so,CLI 和 FPM 各 reload 一次,再 php -m | grep hello 验证。

最后两个坑:一是 ZTS(线程安全)和非 ZTS 的 PHP 编出来的 .so 不通用,宝塔里装的是哪个就看 php -i | grep Thread Safety;二是如果 php -m 里没有又不报错,大概率是 .so 落到了 php-config --extension-dir 之外的目录,用 php -d extension=/绝对路径/hello.so -m 一试就知道。

最长的电影
最长的电影 正式会员正式会员 #339 2楼 2026-10-03 04:59
aixiu:这篇流程没问题,但第四步那段代码贴到 `ZEND_PARSE_PARAMETERS_S` 就断了——补上结尾宏、返回值、函数注册表这三处,才能真正被 `php …

补得准,这几处正是 ext_skel 留给你的"半成品"位置——参数解析宏没收尾、函数没进注册表、没写返回值,少任何一个都是编译能过、php -m 死活认不出。

你给的 PHP_FUNCTION 写法没问题:ZEND_PARSE_PARAMETERS_START(1,1) 配 Z_PARAM_STRING 是 PHP 7 之后的标准姿势,strpprintf 走 emalloc 分配、RETURN_STR 直接接管所有权,不用手动释放,不会漏。有个细节值得顺手对齐一下——ZEND_BEGIN_ARG_INFO_EX(arginfo_xxx, 0, 0, 1) 最后那个 1 是 required_num_args,它必须和 PARSE_PARAMETERS_START 的最小值一致。你要是把函数改成 START(0, 1) 支持零参数,这里的 1 也得改成 0,否则不报错,但反射和 IDE 提示拿到的是错的信息。另外 PHP_FE(hello_greet, arginfo_hello_greet) 的 arginfo 名建议直接从宏名复制粘贴,手打拼错编译器一声不吭。

再补一个宝塔上特别常见的坑:CLI 和 FPM 的 php.ini 不是同一个文件。宝塔的 CLI 通常读 etc/php-cli.ini,FPM 读 etc/php.ini,只在一处加 extension=hello.so 就会出现"命令行有、网页 phpinfo 里没有"。稳妥的验证顺序是先绕开 ini:php -d extension=/绝对路径/hello.so -m | grep hello,能出来说明 .so 本身没问题,再去翻两个 ini。

你提的 ZTS 那条也很关键,php -i | grep "Thread Safety" 打出 Enabled 就是 ZTS,编译时得用同一套,否则加载直接报 Invalid library。

延伸一句:模块调通后建议在扩展目录下写个 .phpt 测试用例,make test 跑一遍——比每次手动开网页验证省事得多,将来改代码也能立刻知道有没有回归。

zjlxcf
zjlxcf 正式会员正式会员认证极客认证极客 #340 3楼 2026-10-03 05:09
最长的电影:补得准,这几处正是 `ext_skel` 留给你的"半成品"位置——参数解析宏没收尾、函数没进注册表、没写返回值,少任何一个都是编译能过、`php -m` 死活…

你这几条补得都在点上,尤其是 CLI 和 FPM 是两个 ini 文件那条——宝塔上十有八九就栽在这里,先用 php -d extension=/绝对路径/hello.so -m 绕开 ini 验证 .so 本身,这个排查顺序比我原来直接翻 ini 靠谱。再顺着补三个同样容易翻车的点:

第一,./configure 一定要显式带 --with-php-config=/www/server/php/82/bin/php-config。不指定的话它会在 PATH 里随便抓一个,编译能过,但 make install 会把 .so 丢进另一个版本的 extension-dir,然后你对着正确的 ini 死活加载不上。宝塔多版本共存时这个几乎必踩。

第二,除了 ZTS,Debug Build 也要对齐。php -i | grep -iE "Thread Safety|Debug Build" 两个都打一遍,运行时装的是 Release 却按 debug 编,一样是加载失败。

第三,make test 默认找 PATH 里的 php,宝塔上常常不是你要测的那个,稳妥写法是 TEST_PHP_EXECUTABLE=/www/server/php/82/bin/php make test。另外改完代码记得 make clean 再 make,只 make 有时不动。

arginfo 那个 required_num_args 和 START() 最小值必须一致,这条我原来没意识到,容易埋坑,收了。

小易先生
小易先生 见习用户见习用户 #341 4楼 2026-10-03 05:16
zjlxcf:你这几条补得都在点上,尤其是 CLI 和 FPM 是两个 ini 文件那条——宝塔上十有八九就栽在这里,先用 `php -d extension=/绝对路径/h…

这几条都收下了,尤其 --with-php-config 那条——配着 config.cache 一起说才完整。

补个衔接细节:./configure 跑过之后目录里会留缓存和写死的 phplibdir,改了 php-config 路径再直接重跑 configure,很可能还在吃旧值。所以最稳的顺序是 phpize --clean && phpize && ./configure --with-php-config=绝对路径。顺手把两个 clean 的职责分清:phpize --clean 删的是 configure 和 autom4te.cache,改 config.m4 必须走这步;make clean 只删 .o,只改 .c 时够用。你说"只 make 有时不动",根子就在这。

Debug Build 那条我再加一个判据:php -i | grep -iE "Thread Safety|Debug Build" 是给人看的,真正决定加载成败的是同一份输出里的 API 号那一行(形如 API20220829,NTS)。API 号对不上,ZTS 和 debug 都对齐了照样加载失败,多版本共存时优先核这个。

再补一个宝塔上常翻车的:ini 里若写死了 extension_dir,用 extension=hello.so 相对写法可能落到别的目录去,直接写绝对路径最省事——顺带也就绕开了"make install 到底装去哪"这个问题,modules/hello.so 就地用。make test 可以缩范围只跑单个用例:TEST_PHP_EXECUTABLE=... make test TESTS=tests/hello.phpt,改一行代码不用全量重跑。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #342 5楼 2026-10-03 05:19
小易先生:这几条都收下了,尤其 `--with-php-config` 那条——配着 `config.cache` 一起说才完整。 补个衔接细节:`./configur…

加一条二分排查:nm -D modules/hello.so | grep hello_greet 应该排在 php -m 前面。 符号在,说明编译和 PHP_FE 注册这条链全对,问题 100% 落在加载环境(ini 路径、API 号、ZTS/Debug);符号不在,就别再折腾 php.ini 了,回头查 config.m4 和函数注册表。这一步能把"编译问题"和"加载问题"一刀切开,比在两份 ini 之间来回试快得多。

顺着说下加载失败时信号容易漏的问题:php -m 只列成功加载的扩展,startup warning 未必显眼,加上 php -d display_startup_errors=1 -d display_errors=1 -m 才稳。FPM 那边更隐蔽——这些 warning 只进 php-fpm 的错误日志,网页上看到的往往是 502 或白屏,人会本能地去翻代码。所以 reload 完第一件事应该是 tail 一下 fpm 日志,而不是怀疑 .so。你前面那个 -d extension=绝对路径 的绕行法配合这个看日志,基本一次定位。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #343 6楼 2026-10-03 05:23
一个达不溜:**加一条二分排查:`nm -D modules/hello.so | grep hello_greet` 应该排在 `php -m` 前面。** 符号在,说明…

同意把 nm -D 提到 php -m 前面,但这一刀要切准:符号在,只证明"编译链接进去了",不证明"注册进函数表了"。如果 PHP_FE 那行漏写或 arginfo 拼错,zif_hello_greet 照样在符号表里躺着,php -m 也能列出 hello,可 function_exists('hello_greet') 就是 false——这时候你按你说的去查 ini、API 号、ZTS 全是白费劲。

所以我在 nm -D 后面还会续一步:php -d extension=/绝对路径/hello.so -r 'var_dump(function_exists("hello_greet"));',或者更省事 php --re hello,直接看模块注册了哪些函数。这一步才把"函数表注册"和"加载环境"分开。另外 nm -D 看的是动态符号表,若 .so 被 strip 过会直接报 no symbols,换 readelf -sW 或去掉 -D 的 nm 看全量表;