PHP FFI(Foreign Function Interface)是用于加载共享库、调用 C 函数、访问 C 数据结构的 PHP 扩展。无需自己编写 PHP 扩展,就能从 PHP 直接使用已有的 C API。
PHP 官方文档将 FFI 说明为一项低级且危险的功能。它是仅适合那些既理解 C 又理解目标库 API 的开发者使用的功能。
FFI 不是被安全抽象过的普通 PHP API。误用指针、所有权、释放函数会导致崩溃或内存破坏。
FFI 也不是「让程序更快的功能」。PHP 官方文档同样指出,FFI 的数据结构访问比原生 PHP 数组或对象更慢。选择 FFI 的理由并非速度,而是希望从 PHP 使用已有的 C 库。
FFI 可用的环境与限制
PHP 官方文档中,启用 FFI 扩展的方法如下。
- 编译 PHP 时带上
--with-ffi
- 在 Windows 上,通过
php.ini 启用 php_ffi.dll
- 通过
php.ini 的 ffi.enable 控制是否允许使用
ffi.enable 有以下 3 个值:
| 设置值 | 含义 |
|---|
true | 启用 FFI API |
false | 禁用 FFI API |
preload | 只允许 CLI SAPI 与预加载文件中使用 FFI API |
在 Web 服务器环境中,通常会设置 ffi.enable=false 或 ffi.enable=preload。包括 Laravel Cloud 在内的托管环境,也不能保证在常规 Web 应用中都能使用 FFI。
因此,即使要在 Laravel 应用中使用 FFI,也应优先考虑 CLI 工具或批处理场景。以 FPM 或 Apache mod_php 常态启用为前提并不安全。
基本用法
PHP FFI 的基本 API 有 FFI::cdef()、FFI::new()、FFI::addr()、FFI::string() 四个。
<?php
$ffi = FFI::cdef(<<<'CDEF'
typedef unsigned int time_t;
typedef unsigned int suseconds_t;
struct timeval {
time_t tv_sec;
suseconds_t tv_usec;
};
struct timezone {
int tz_minuteswest;
int tz_dsttime;
};
int gettimeofday(struct timeval *tv, struct timezone *tz);
CDEF, 'libc.so.6');
$tv = $ffi->new('struct timeval');
$tz = $ffi->new('struct timezone');
$ffi->gettimeofday(FFI::addr($tv), FFI::addr($tz));
echo $tv->tv_sec.PHP_EOL;
这个例子中要点如下:
FFI::cdef() 从 C 声明字符串和共享库名创建 FFI 对象
$ffi->new('struct timeval') 分配 C 数据结构
FFI::addr($tv) 用来构造 struct timeval * 这样的指针参数
- 可以像
$tv->tv_sec 一样访问结构体字段
对于接收字符串或二进制的 API,使用 FFI::string()。
<?php
$jsonPtr = $ffi->new('char*');
// 假设 C 侧向 $jsonPtr 写入结果指针
$result = $ffi->some_function(FFI::addr($jsonPtr));
if ($result !== 0) {
throw new RuntimeException('C API call failed.');
}
$json = FFI::string($jsonPtr);
由 FFI::new() 分配的内存通常会通过 PHP 的引用计数释放。而 C 库返回的指针则可能需要该库专用的 free 函数释放。请针对每个 API 明确所有权归属。
加载头文件
使用 FFI::load() 可以从 C 头文件读入声明。头文件中可以写入 FFI_SCOPE 与 FFI_LIB。
#define FFI_SCOPE "mylib"
#define FFI_LIB "/absolute/path/to/libmylib.so"
typedef struct MyContext MyContext;
MyContext *mylib_new(void);
void mylib_delete(MyContext *context);
<?php
FFI::load(__DIR__.'/mylib.h');
$ffi = FFI::scope('mylib');
$context = $ffi->mylib_new();
不过,PHP 官方文档说明,无论是 FFI::cdef() 还是 FFI::load(),都不能使用普通的 C 预处理指令。#include、#define、条件编译无法直接传入。
因此,实际项目中经常选择以下两种方式之一:
简单的 API 直接写在 `FFI::cdef()` 中
若依赖的类型和函数很少,只在 PHP 侧嵌入必要的声明。
复杂 API 则准备一份预处理过的头文件
从原始头文件中剥离宏与条件编译,另存为专供 FFI 使用的头文件。
实际用例:VOICEVOX Core for PHP
VOICEVOX Core for PHP 是从 Pure PHP 使用 VOICEVOX CORE 的 C 动态库的实例。这里汇集了 FFI 的实用模式,比抽象说明更容易理解。
1. 持有为 FFI 整理过的头文件
原始的 VOICEVOX Core 头文件不会直接使用,而是把面向 FFI 的声明抽取到 headers/voicevox_core_ffi.h。这里明确了不透明指针、结构体与 free 函数。
typedef struct VoicevoxSynthesizer VoicevoxSynthesizer;
typedef struct VoicevoxInitializeOptions {
int32_t acceleration_mode;
uint16_t cpu_num_threads;
} VoicevoxInitializeOptions;
int32_t voicevox_synthesizer_new(
const struct VoicevoxOnnxruntime *onnxruntime,
const struct OpenJtalkRc *open_jtalk,
struct VoicevoxInitializeOptions options,
struct VoicevoxSynthesizer **out_synthesizer
);
void voicevox_synthesizer_delete(struct VoicevoxSynthesizer *synthesizer);
void voicevox_json_free(char *json);
void voicevox_wav_free(uint8_t *wav);
这种设计的关键在于:不向 PHP 侧暴露 C 的细节内部结构,而是收敛到不透明句柄与函数调用。PHP 侧要处理的面积变小,包装类的组织也更清晰。
2. 将 FFI::cdef() 集中在一处
src/VoicevoxFFI.php 将头文件读取与库路径解析集中在一处。
<?php
class VoicevoxFFI
{
private static ?FFI $ffi = null;
public static function getInstance(): FFI
{
return self::$ffi ??= FFI::cdef(
file_get_contents(__DIR__.'/../headers/voicevox_core_ffi.h'),
self::getLibraryPath(),
);
}
public static function getLibraryPath(): string
{
if ($path = getenv('VOICEVOX_CORE_LIB_PATH')) {
return $path;
}
return match (PHP_OS_FAMILY) {
'Darwin' => 'libvoicevox_core.dylib',
'Windows' => 'voicevox_core.dll',
default => 'libvoicevox_core.so',
};
}
}
这样一来,你的应用侧就不必直接调用 FFI::cdef()。库路径的差异也可以通过环境变量与 OS 判断来吸收。
3. 用对象包装 out parameter
src/Synthesizer.php 的构造函数分配 struct VoicevoxSynthesizer*,并将其地址传给 voicevox_synthesizer_new()。
<?php
$options = $this->ffi->voicevox_make_default_initialize_options();
$options->acceleration_mode = $accelerationMode->value;
$options->cpu_num_threads = $cpuNumThreads;
$ptr = $this->ffi->new('struct VoicevoxSynthesizer*');
$result = $this->ffi->voicevox_synthesizer_new(
$onnxruntime->handle(),
$openJtalk->handle(),
$options,
FFI::addr($ptr),
);
$this->handle = $ptr;
这是在 PHP 中接收 C API 中 Type **out_value 的基本模式:
- 用
new('struct VoicevoxSynthesizer*') 创建指针变量
- 用
FFI::addr($ptr) 传入 Type **
- 将得到的句柄保存到 PHP 对象的属性中
4. C 返回的内存拷贝到 PHP 字符串后立即释放
VOICEVOX Core for PHP 在收到 JSON 字符串或 WAV 二进制时,会先用 FFI::string() 拷贝为 PHP 字符串,然后再调用 C 侧的 free 函数。
<?php
$jsonPtr = $this->ffi->voicevox_synthesizer_create_metas_json($this->handle);
$json = FFI::string($jsonPtr);
$this->ffi->voicevox_json_free($jsonPtr);
return $json;
<?php
$wavSize = $this->ffi->new('uint64_t');
$wavPtr = $this->ffi->new('uint8_t*');
$result = $this->ffi->voicevox_synthesizer_tts(
$this->handle,
$text,
$styleId,
$options,
FFI::addr($wavSize),
FFI::addr($wavPtr),
);
$wav = FFI::string($wavPtr, (int) $wavSize->cdata);
$this->ffi->voicevox_wav_free($wavPtr);
return $wav;
这种「拷贝后立即释放」的流程是 FFI 中最重要的实现模式之一。不将 C 侧缓冲区混入 PHP 的生命周期,能极大简化所有权管理。
注意事项与最佳实践
| 视角 | 实践要点 |
|---|
| 运行环境 | 优先按 CLI 场景设计 |
| 头文件管理 | 不直接使用原头文件,而是另行管理面向 FFI 的声明 |
| 库路径 | 可通过绝对路径或环境变量切换 |
| 内存管理 | 使用 FFI::string() 拷贝到 PHP,务必调用库专用释放函数 |
| 包装设计 | 不要把原生 CData 扩散到整个应用,收敛到专用类中 |
| 兼容性 | 一起确认 PHP 版本与目标库的 ABI 变更 |
将 FFI 直接混入 Laravel 应用的请求/响应循环,会让环境差异与故障定位骤然变难。请先在 CLI 命令或队列外的一次性进程中让它跑通。
FFI 适用的场景是:已有稳定的 C API,但又不至于要专门写 PHP 扩展。反过来,若想在通用 Web 托管环境中运行,或者可以完全用 PHP 完成的处理,就不适合使用 FFI。
使用 PHP FFI,你可以从 Pure PHP 调用已有的 C 库。但 FFI 是一项低级且危险的机制,也未必在 Web 服务器上可用。
从 VOICEVOX Core for PHP 的实现可以看到,实践中要点有 4 条:
- 为 FFI 单独准备头文件
- 将
FFI::cdef() 与库路径解析集中在一处
- 用专用类包装不透明指针
- C 返回的内存先拷贝到 PHP,再用专用函数释放
结合 VOICEVOX Core for PHP 的页面一起阅读,可以在 FFI 的概念与实际包设计之间来回印证,加深理解。