> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP FFI

> 解說 PHP FFI 的基本、可用環境，並以 VOICEVOX Core for PHP 為題材說明實用的封裝模式。

## 前言

PHP FFI（Foreign Function Interface）是用來載入共享函式庫、呼叫 C 函式並存取 C 資料結構的 PHP 擴充功能。即便不自行撰寫 PHP 擴充，也能從 PHP 直接使用既有的 C API。

PHP 官方文件將 FFI 描述為低階且危險的功能。它是應該僅由理解 C 及目標函式庫 API 的開發者使用的功能。

<Warning>
  FFI 並非安全抽象化的一般 PHP API。若對指標、擁有權、釋放函式的使用有誤，可能導致當機或記憶體毀損。
</Warning>

FFI 也不是「用來加速的功能」。PHP 官方文件也指出，透過 FFI 存取資料結構會比原生的 PHP 陣列或物件慢。選擇 FFI 的理由不在速度，而在於希望從 PHP 使用既有的 C 函式庫。

## FFI 可用的環境與限制

PHP 官方文件關於啟用 FFI 擴充的方式說明如下。

* 以 `--with-ffi` 建置 PHP
* 於 Windows 中在 `php.ini` 啟用 `php_ffi.dll`
* 以 `php.ini` 的 `ffi.enable` 控制可否使用

`ffi.enable` 可取以下三種值。

| 設定值       | 意義                                    |
| --------- | ------------------------------------- |
| `true`    | 啟用 FFI API                            |
| `false`   | 停用 FFI API                            |
| `preload` | 僅在 CLI SAPI 及已 preload 的檔案中允許 FFI API |

<Info>
  於 Web 伺服器環境中，多半採用 `ffi.enable=false` 或 `ffi.enable=preload`。包含 Laravel Cloud 在內的 Hosting 環境，通常也不保證能以一般 Web 應用程式的方式使用 FFI。
</Info>

因此，即便在 Laravel 應用程式中使用 FFI，也請先考量是否可在 CLI 工具或批次處理中成立。避免以「於 FPM 或 Apache mod\_php 中常時啟用」為前提會較為安全。

## 基本用法

PHP FFI 的核心是 `FFI::cdef()`、`FFI::new()`、`FFI::addr()`、`FFI::string()` 這 4 個。

```php theme={null}
<?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 theme={null}
<?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);
```

<Tip>
  以 `FFI::new()` 配置的記憶體通常會透過 PHP 的參考計數釋放。反之，C 函式庫回傳的指標則可能需以函式庫專用的 `free` 函式來釋放。請針對每個 API 確認擁有權歸屬。
</Tip>

## 讀入標頭檔

使用 `FFI::load()` 可以從 C 標頭檔載入宣告。標頭檔中可以寫 `FFI_SCOPE` 與 `FFI_LIB`。

```c theme={null}
#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 theme={null}
<?php

FFI::load(__DIR__.'/mylib.h');

$ffi = FFI::scope('mylib');
$context = $ffi->mylib_new();
```

不過 PHP 官方文件也指出，`FFI::cdef()` 與 `FFI::load()` 皆無法使用常見的 C 前處理指示。無法直接傳入 `#include`、`#define`、條件式編譯等。

因此於實務中，通常會選擇下列其中一種方式。

<Steps>
  <Step title="若 API 簡單則直接寫在 `FFI::cdef()`">
    若相依的型別或函式較少，僅將所需的宣告嵌入至 PHP 端。
  </Step>

  <Step title="若 API 複雜則準備前處理過的標頭">
    以另一份檔案管理已移除 macro 與條件式編譯、專供 FFI 使用的標頭。
  </Step>
</Steps>

## 實務案例：VOICEVOX Core for PHP

[VOICEVOX Core for PHP](/zh-TW/packages/voicevox-core-php) 是從 Pure PHP 使用 VOICEVOX CORE 之 C 動態函式庫的實例。彙整了 FFI 的實務模式，比抽象說明更容易理解。

### 1. 準備已為 FFI 整理的標頭

VOICEVOX Core 原始標頭並非直接使用，而是將 FFI 用的宣告切出至 `headers/voicevox_core_ffi.h`。這裡明確定義了不透明指標、結構體與 free 函式。

```c theme={null}
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);
```

這種設計的重點在於：不將 C 詳細的內部結構暴露至 PHP 端，而是收斂於不透明 handle 與函式呼叫。PHP 端需處理的面積會變小，包裝類別也會更好整理。

### 2. 將 `FFI::cdef()` 集中於一處

`src/VoicevoxFFI.php` 將標頭讀入與函式庫路徑解析集中於一處。

```php theme={null}
<?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 theme={null}
<?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 **`
* 將取得的 handle 保存於 PHP 物件的屬性

### 4. 將 C 回傳的記憶體複製到 PHP 字串後立即釋放

VOICEVOX Core for PHP 在收到 JSON 字串或 WAV 二進位資料時，會先以 `FFI::string()` 複製到 PHP 字串，接著再呼叫 C 端的 free 函式。

```php theme={null}
<?php

$jsonPtr = $this->ffi->voicevox_synthesizer_create_metas_json($this->handle);

$json = FFI::string($jsonPtr);
$this->ffi->voicevox_json_free($jsonPtr);

return $json;
```

```php theme={null}
<?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 變更                 |

<Warning>
  將 FFI 直接混入 Laravel 應用程式的 request/response 週期，會使環境差異與問題切分突然變得困難。請先讓其於 CLI 指令或非 Queue 的單次執行程序中運作。
</Warning>

FFI 適合已具備穩定 C API、但不至於自行製作 PHP 擴充的情境。反之，若希望在一般 Web Hosting 中運作的功能，或僅以 PHP 即可完成的處理則不適合。

## 總結

透過 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 概念與實際套件設計之間往返理解。


## Related topics

- [VOICEVOX Core for PHP](/zh-TW/packages/voicevox-core-php/index.md)
- [安裝與設定 - VOICEVOX for Laravel](/zh-TW/packages/laravel-voicevox/installation.md)
- [Native 模式:Talk(文字語音合成)- VOICEVOX for Laravel](/zh-TW/packages/laravel-voicevox/native-talk.md)
- [Native 模式:Song(歌聲語音合成)- VOICEVOX for Laravel](/zh-TW/packages/laravel-voicevox/native-song.md)
- [VOICEVOX for Laravel](/zh-TW/packages/laravel-voicevox/index.md)
