把 PHP 编译成原生二进制:TypePHP 的用法、硬限制与适用场景
一个 PHP 服务上线前通常要处理三件事。OPcache 缓存的是 opcode,请求进来仍然由 Zend VM 解释执行;JIT 在运行时把热点 trace 编译成机器码,进程启动后要跑一段时间才进入稳态,warm-up 期间性能是波动的;部署到机器上的还是 .php 明文,源码保护得另找方案。CLI 工具更直接:每次执行都要重新解析、编译、填 opcode 缓存,短命进程根本吃不到缓存红利。
TypePHP 换了一条路径。它是 AOT 编译器,把 PHP 源码翻译成 C++17 再编译成原生机器码,产物里没有 opcode 解释过程,生成的二进制直接在 CPU 上跑。动态 PHP 值、内部函数、反射、对象元数据通过 PHPX 与 Zend 运行时互操作,用户函数编译后不再以 Zend opcode 执行。
- 仓库:
- 官网:
- 不兼容特性清单:
编译器自身也是 PHP 写的
tpc 编译器完全用 PHP 实现,并且自举:编译器二进制是用 TypePHP 编译自己的 PHP 源码构建出来的,bootstrap 链路是纯 PHP,编译器本身没有 C/C++ glue。
构建流水线是:PHP 源码 + .stub.php 声明 + 可选 C/C++ 源码 → 解析、校验、收集声明 → 函数体与常量降级到 C++17 → 原生编译器配合可复用的 object/PCH 缓存 → 输出可执行文件、PHP 扩展、共享库或 WASI 组件。prepare 阶段构建完整符号模型但不分配 runtime cache ID;常量与声明默认值保留 AST 到 convert 阶段,等所有项目符号已知后再降级。两阶段设计是为了让多文件与自举构建的结果确定。
类型系统上,int/float/bool 直接映射到 C++ 的 int64_t/double/bool;高精度数值走 bigInt(GMP)、decimal(libmpdec,PHPX 内置)、bigFloat(MPFR);容器是 std::array/std::vector/std::map/std::orderedMap,元素类型在编译期确定,README 声称比 PHP 数组快最多 10 倍——这个数字和访问模式强相关,不要当基准用。另有通用方法($s->upper()、$arr->contains())、PHP/C++ 互调、编译期函数 std::any()/std::object()/std::ref()/std::expected() 与 toObject()/toInt()/toString()/toArray()、编译期安全 #[Immutable]、StdList/StdDict 数组形状、代码生成 attribute(#[Getter]/#[Setter]/#[With]/#[Constructor]/#[Printer]/#[Arrayable])。语法层面支持 PHP 8.4 property hooks、asymmetric visibility,以及 PHP 8.5 的 clone() with 和 (void) 丢弃表达式。平台覆盖 Linux/Windows/macOS x64+ARM64、Android/iOS 原生、WASI 0.2 与浏览器 Jco 输出,另有 Python bridge。
依赖与环境
需要 PHP 8.4–8.5 CLI、开发头文件和 php-config;Unix 上构建 binary/共享库还需要对应的 PHP embed 库 libphp.so/libphp.dylib;GCC 9+ 或 Clang,C++17;CMake 3.24+;Composer 2;GMP、MPFR(libmpdec 随 PHPX 自带)。
# Ubuntu/Debian
sudo apt install build-essential cmake pkg-config libgmp-dev libmpfr-dev
# RHEL/CentOS/Fedora
sudo dnf install gcc gcc-c++ cmake pkgconf-pkg-config gmp-devel mpfr-devel
# Arch
sudo pacman -S base-devel cmake pkgconf gmp mpfr
Linux x64 是主开发与全量测试 CI 平台。发布资产按 PHP 8.4 ZTS / 8.5 ZTS 分别构建,文件名会标识完整 PHP 版本与 ZTS ABI;Linux/macOS 用户必须选与宿主 PHP 匹配的构建,选错会在链接阶段失败。Windows 压缩包内含匹配的 PHP/PHPX 运行时与 SDK。不提供 NTS 与 32 位 x86 包。
安装与最小例子
composer require --dev swoole/typephp
vendor/bin/tpc.php project.yml
在源码仓库内则用 bin/tpc.php project.yml。从源码构建:
git clone https://github.com/swoole/typephp.git
cd typephp
composer install
php bin/tpc.php --help
PHPX_HOME 可指向独立的 PHPX checkout;PHP_HOME 指向 PHP embed prefix,其中需含 bin/php-config、PHP 头文件,Unix 上还要有 lib/libphp.so。binary 模式默认使用 Embed SAPI 和宿主 libphp,缺库时交互式构建会提示启用 php-builder,从 php-src 构建私有静态运行时;CLI 与 FPM 目标始终需要 php-builder。
最小可用程序必须定义全局 main():
function main(): void {
echo "Hello World!\n";
var_dump(PHP_VERSION);
var_dump(php_uname());
}
bin/tpc.php hello.php
./hello
main() 可以无参,也可以是 (int $argc, array $argv),必须返回 void。顶层不允许写可执行语句,所有可执行代码都要放进函数或方法里。
三种构建模式
-m/--mode 决定产物形态,bin 是默认值:
bin:可执行文件,需要main(),面向 CLI 工具、长跑服务、独立应用。ext:输出.so/.dll,不需要main(),把编译后的函数和类加载进 PHP SAPI。lib:输出共享库并生成.stub.php,不需要main(),给别的项目复用编译后的 API。
bin/tpc.php app.php -o myapp
bin/tpc.php extension/ -m ext -o my_extension
bin/tpc.php lib/ -m lib -o mylib
project.yml 里值得先看的字段
常用字段包括 name/mode/version/info/php-version(如 "8.5")/optimize(如 2)/job(如 8)/build-dir/cxx-std(如 c++17)。
sources 列表支持条件编译,例如 if: PHP_VERSION_ID >= 80500、if: PHP_OS_FAMILY == "Windows"。embedded-files 把 vendor 打进二进制,未成功编译的 PHP 文件以 OPcache 字节码形式存储,运行时不需要 Composer/vendor/OPcache。objects 让已有的 .o/.obj 直接进链接,TypePHP 不重编译。ignore/include-paths/defines/link-paths/link-libs 按常规含义使用。ext-deps 写入 ZEND_MOD_REQUIRED,缺扩展时 Zend 拒绝加载。
include 可复用公共配置,按顺序应用,当前文件覆盖被包含文件;map 递归合并,list 整体替换;include 可嵌套,但同一 canonical 文件不能在一次活跃 include 链里出现两次。路径相对于最外层 project YAML。CLI 参数覆盖 YAML。
一个容易被忽略的坑:扫描 source 目录会进入符号链接目录。用 Composer path repository 安装的依赖是 symlink,会被当成源码一起编译,必须用 ignore 排除。
--nano:不链接 libphp 的版本
--nano 把 PHP 源码和 PHP Nano、PHPX 源码一起编译,结果不链接 libphp,也不含 Zend opcode 解释器:
./bin/tpc.php --nano examples/hello.php
./hello
代价很明确。Nano 只支持 mode: bin 和 mode: lib,不支持 ext;拒绝 VM 入口 eval/include/include_once/require/require_once 与匿名类;运行时只保留文件流,socket/DNS/网络、远程流、动态 PHP 加载、进程执行都不可用。WASI 是更小的子集,直接调用缺失 API 会在编译期报错。外部命令 API 与反引号语法在 Nano 的能力策略下同样被拒。
不支持的 PHP 语法:先看这几条
TypePHP 有意只支持一个明确、可测试的 PHP 子集,不声称对任意动态 PHP 程序 drop-in 兼容。完整清单在兼容性文档,下面几条是最容易卡住存量项目的。
全局作用域只能是声明。 顶层不许有可执行语句,main() 必须存在且返回 void。光这一条就让绝大多数现成脚本无法直接编译——传统 PHP 脚本的入口逻辑全在顶层。函数内、方法内也不允许再声明函数或具名类。
变量变量不支持。 $$var 直接过不了,靠它做路由分发、模板变量注入的代码需要改写。
闭包不能重绑。 Closure::bind()/bindTo()/call() 均不支持,AOT 代码里的闭包无法重绑到别的对象或类作用域。
生成器不是 Zend Generator。 TypePHP 用 FiberGenerator 替代 Zend Generator,因此不能依赖 instanceof Generator、ReflectionGenerator、Zend Generator 的内部对象布局,异常栈也不保证一致。生成器还不支持按引用返回、按引用 yield、按引用 foreach、按引用参数和可变参数。Fiber 与 Generator 目前都不被 WASI 目标支持。任何在生成器上做类型判断或反射的框架代码都会中招。
引用参数要显式标注。 闭包和箭头函数支持固定按引用参数,但 Closure 调用是动态派发,调用方必须用 std::ref()/toRef() 显式标记。凡是编译期无法确定参数签名的调用(包括变量方法名、nullsafe 调用)都要这么写。这是既定语言设计约束,官方明确不打算做运行时引用推断。
静态推断出类型后不能改。 局部变量一旦被静态推断为某个具体原生类型,同一作用域内不能再赋成不兼容的类型。
固定布局属性的零值不等于未初始化态。 固定布局类型的属性未显式初始化时用该类型的零值,不保留 Zend 的未初始化状态,依赖未初始化态判断的 ?? 等表达式行为可能不同。
其余会踩到的还有:不支持 #[NoDiscard];PHP 8.5 (void) cast 只能用于显式丢弃值,不能出现在赋值、return、参数、条件等值上下文;hooked property 的取值引用暂不支持;Reflection Lazy Objects 不能用于 AOT 类(AOT 类注册为持久 internal class,zend_object_make_lazy() 明确拒绝 internal class),运行时动态加载的 Zend 用户类不受此限;declare(ticks=...) 不支持,declare(encoding=...) 只接受 UTF-8,strict_types=0 被拒(TypePHP 始终严格类型,写 strict_types=1 冗余但可写);非可变参数函数不接受超出签名的参数,func_get_args() 不会隐式放宽签名;match 的 arm 条件里不能嵌套 match;按引用 foreach 的 value 目标只能是变量,foreach list 解构不支持按引用绑定元素;非 int/bool 降级路径上每个非空 switch case 必须以 return/break/continue/exit/throw 结束,不能依赖隐式 fallthrough;foreach 期间禁止对 std::vector/std::map/std::orderedMap 追加、插入、unset 或整体替换;对原生类型变量调用 unset() 不会像标准 PHP 那样删除变量;static::class 不能用在需要编译期常量类名的位置,__CLASS__ 只能在 class 定义内,__TRAIT__ 只能在 trait 定义内;所有源文件必须是 UTF-8。
union/intersection/nullable 在 C++ 里仍然是 mixed/any,但静态分析阶段会用已知表达式类型提前拒绝确定不兼容的参数、返回值、属性赋值;动态值仍保留运行时类型检查。
Native Class 用独立的固定布局对象模型,不能当普通 Zend Object 用,不能作 PHP 数组键/值或任意 mixed 值,动态成员、引用、static/readonly 成员与若干运算符都受限。固定 int/float/bool/string/array 的 Native Class 属性可以直接传给签名完全匹配的引用参数(call-scoped C++ T&,不是 PHP 引用)。
什么场景值得上
CLI 工具是收益最直接的一类:没有 warm-up 成本,启动即全速,交付单个二进制也比要求目标机器装 PHP 和扩展省事。长跑服务——常驻 worker、队列消费者——能拿到确定的编译期优化,不受 JIT trace 收敛过程影响。
数值密集的逻辑值得考虑,int/float 到 int64_t/double 的映射、编译期确定元素类型的容器,都是实打实的收益,前提是代码本身能通过子集检查。
需要交付二进制、不希望源码以明文形式落到客户机器上的场景,AOT 是这几条路线里唯一真正把源码编译掉的方案:OPcache 和 JIT 都留着可逆的字节码,AOT 没有。性能确定性也是同理——编译期全程序优化,不像 OPcache 无类型驱动优化、JIT 只有基于 trace 的有限优化。
什么场景别硬套
现成的 Laravel/Symfony 项目直接拿 tpc 编译基本走不通。全局作用域必须声明式、入口必须 main()、闭包不能重绑、生成器语义不同,这些不是配置能绕过的。真要迁移,等于按子集重写入口层和关键路径。
重度反射、动态调用的代码同样不适合。Closure::bind、变量方法名、变量变量、eval 这类运行时才确定目标的东西,在 AOT 的静态世界里要么被拒,要么退化到动态派发从而拿不到原生调用的收益。
依赖 eval/include 做插件加载或模板渲染的项目,--nano 直接拒绝这些 VM 入口;标准模式下 embedded-files 也只能把未成功编译的 PHP 文件以 OPcache 字节码存进去,仍然是解释执行,等于回到原点。
还有一层工程成本:发布包按 PHP 8.4 ZTS / 8.5 ZTS 分开构建,不提供 NTS 与 32 位 x86,宿主的 PHP 版本、ABI、embed 库必须对上。CI 里维护这套矩阵本身就是工作量。
还没答案的问题
生态层面,PHPX 与 Zend 的互操作边界能覆盖多少常见扩展,.stub.php 只声明外部 C++ 提供的 Zend ABI 符号且其类不能用 #[Native],这条限制在真实项目里会把多少扩展挡在门外,目前没有足够的样本。
--nano 的能力裁剪很彻底,没有 socket/DNS/网络意味着任何要发 HTTP 请求、连数据库的代码都用不了,它的适用范围可能比想象中窄。
最关键的还是生产验证。仓库有大量设计文档(NATIVE_CLASS_OBJECT.md、STD_CONTAINERS.md,以及针对 HHVM/KPHP/Peachpie 的对比评审),架构思路是清楚的,但 2026-05 创建、最近推送 2026-10-28 的项目,在真实负载下的稳定性、长跑服务的内存行为、编译产物在不同内核与 libc 上的表现,都还需要更多团队跑过才能下结论。把它放进生产之前,先用一个无状态的 CLI 工具或内部服务试水,比一上来就动核心业务稳妥。