Debian / Ubuntu

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

照着走一遍,你就能从零编译出一个能被 `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` 看全量表;