PHP7.4 FFIのソースコードを読んで理解する

2019-10-30
PHP
FFI
PHP7.4

前回は、PHP-FFIを使ってモンテカルロ法による円周率の計算が高速化されることを試しました。今回は、FFIが一体どのようにしてネイティブコードを実行しているのかを見ていきます。

PHP自体の実行内容を追うために、gdbを使います。

調査時に使ったソースコード

ffi.php

1
2
3
<?php
$ffi = FFI::cdef( "double calcPi(int n);", __DIR__."/libmonte.so");
echo sprintf("PI => %f\n", $ffi->calcPi(10000000));

Cの共有ライブラリ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <time.h>

double calcPi(int n);

double calcPi(int n){
int count;
double x,y;
srand(time(NULL));
count = 0;
for(int i = 0; i < n; ++i) {
x = (double)rand() / RAND_MAX;
y = (double)rand() / RAND_MAX;
if( (x * x + y * y) <= 1 ) {
count++;
}
}
return (double) count / n * 4;

デバック実行

php-buildでインストールしたPHP7.4のphp-cliをgdbを使って動かします。下記はgdbを起動すると共に、breakpointを4つ設定しています。最後にffi.phpをコールしてデバッグ実行開始。

1
2
3
4
5
6
7
8
$ sudo gdb --command ~/php-srcs/source/7.4snapshot_debug/.gdbinit ~/.phpenv/versions/7.4snapshot_debug/bin/php
GNU gdb (Ubuntu 8.1-0ubuntu3.1) 8.1.0.20180409-git
Copyright (C) 2018 Free Software Foundation, Inc.
(gdb) b zim_FFI_cdef
(gdb) b zend_ffi_get_func
(gdb) b zif_ffi_trampoline
(gdb) b zend_ffi_cdata_to_zval
(gdb) run ffi.php

実行されるPHP-FFIのソースコード解説

PHPソースコードのext/ffi/ffi.cのソースについてのみ解説します。その他のコードまでは追ってられなかったので…

  1. 共有ライブラリの読込
    ffi.phpの2行目、共有ライブラリを読み込んで、実行したい関数のシグネーチャーを指定しています。
1
ZEND_METHOD(FFI, cdef)

この関数内で、DL_LOADというマクロに共有ライブラリのパスを渡しています。
DL_LOADの中身は実行しているOSにも依るのですが、Unix系であればdlopen、WindowsならばLoadLibraryです。

共有ライブラリの読み込みが終わると、実行したい関数のアドレスを取得しています。

1
addr = DL_FETCH_SYMBOL(handle, ZSTR_VAL(name));

DL_FETCH_SYMBOLマクロに、共有ライブラリのハンドルと関数名を渡しています。マクロの中身は、Unix系の場合はdlsym、Windowsの場合はGetProcAddressです。

  1. 共有ライブラリの関数実行

ffi_prep_cifffi_callの2つのシステムコールを呼ぶことで、ネイティブ関数の実行は完了です。

少し気になるのは、dlopendlsymはWindows版の記述もあったけど、ffi_callにはWindows用の分岐が無いです。Windowsでもffi_callのシステムコールは存在するのでしょうか?それともUnix系オンリーの機能なのか…。

以上で、PHP-FFIのソースコード上の流れも把握できました。実際に複雑な部分はffi_prep_cifする時の引数やら戻り値を動的に作っていく箇所だけで、それ以外は素直なソースコードと思いました。

C言語での実装

さらに腹落ちさせるために、PHP-FFIがやっていることをC言語で書いてみました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <dlfcn.h>
#include <ffi.h>

int main(){
ffi_cif cif;
ffi_type *args[] = {&ffi_type_sint64};
int count = 10000000;
void *values[] = {&count};
double rc;

void *handle;
void *address;

handle = dlopen("./libmonte.so", RTLD_LAZY);
address = dlsym(handle, "calcPi");

ffi_prep_cif(&cif, FFI_DEFAULT_ABI, 1, &ffi_type_double, args);
ffi_call(&cif, address, &rc, values);
printf("Result => %f\n", rc);
}

コンパイルは下記コマンド

1
$ gcc ffi.c -ldl -lffi

順番としては

  1. dlopen 共有ライブラリを開いてハンドルを取得
  2. dlsym 共有ライブラリ内のsymbolのアドレスを取得
  3. ffi_prep_cifを読んでffi_call実行用のffi_cif構造体を作成
  4. ffi_callで共有ライブラリの関数を実行

まとめ

今回の調べものに際して、rubyのFFI周りの記事とかを参照したところ、大体同じような作りになっているようです。常套手段というか、これが普通みたいです。

中身も把握できたので、これで安心して使えますね!

参考リンク

ruby-ffiについてざっくり解説