跳转到主要内容

前言

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.iniffi.enable 控制是否允许使用
ffi.enable 有以下 3 个值:
设置值含义
true启用 FFI API
false禁用 FFI API
preload只允许 CLI SAPI 与预加载文件中使用 FFI API
在 Web 服务器环境中,通常会设置 ffi.enable=falseffi.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_SCOPEFFI_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、条件编译无法直接传入。 因此,实际项目中经常选择以下两种方式之一:
1

简单的 API 直接写在 `FFI::cdef()` 中

若依赖的类型和函数很少,只在 PHP 侧嵌入必要的声明。
2

复杂 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 条:
  1. 为 FFI 单独准备头文件
  2. FFI::cdef() 与库路径解析集中在一处
  3. 用专用类包装不透明指针
  4. C 返回的内存先拷贝到 PHP,再用专用函数释放
结合 VOICEVOX Core for PHP 的页面一起阅读,可以在 FFI 的概念与实际包设计之间来回印证,加深理解。
最后修改于 2026年7月13日