はじめに
今回の記事は前回の続きになります。よければ、前回も見ていただけると嬉しいです。
Zend Engineを読む① | php-srcをビルドするPHPの実行エンジンであるZend Engineを読むための準備として、php-srcのビルド方法を解説します。buildconf、configure、makeの役割やデバッグビルドの手順も紹介します。
前回は php-src をビルドして、デバッグを仕込むところまででした。今回から本格的に解析をしていきたいと思います。ただ、解析といっても、歴史が長い言語になるため、一筋縄ではいきません。文字列(STRING TYPE) が出力されるまでのトレースですら、結構苦労したので、備忘録として残しておきます。今回検証に使用した環境は下記になります。
mac 15.4.1(24E263)
プロセッサ 1.4 GHz クアッドコアIntel Core i5
メモリ 8GB
私がzend engine を解析する理由は個人でプログラミング言語(コンパイラ)を自作しているためです。現在は型システムを実装中です。文字列(STRING TYPE)を扱う上で、他の高級言語の文字列の扱い方を参考にするために、解析をします。現在作成中の言語がこちらになります。こちらも見ていただけると嬉しいです。
GitHub – yu-corder/goemon-srcContribute to yu-corder/goemon-src development by creating an account on GitHub.
処理の流れ
実際に文字列の出力までを追いかけたいと思いますが、処理の流れを大まかにですが、おさらいします。前回は起動やエントリポイントにデバッグを仕込んだので、今回はLexer や Parserなどにデバッグを仕込んでいきます。
起動・エントリポイント
↓
Lexer(zend_language_scanner.l):トークン化する
↓
Parser(zend_language_parser.y):AST(抽象構文木)のノードを作る
↓
Compiler(zend_compile.c):AST を走査して Opcode(バイトコード)などを生成する
↓
実行
Lexer
zend_language_scanner.l ですが、3200行ほどあります。全部を読むのは時間がかかるため、ひとまず、文字列の解析をしているところにデバッグを仕込みます。シングルクォーテーションで囲んだ文字列とダブルクォーテーションで囲んだ場合では、解析の場所が異なります。赤文字がScannerに追加したところになります。
<ST_IN_SCRIPTING>b?["] {
int bprefix = (yytext[0] != '"') ? 1 : 0;
while (YYCURSOR < YYLIMIT) {
switch (*YYCURSOR++) {
case '"':
yyleng = YYCURSOR - SCNG(yy_text);
if (EXPECTED(zend_scan_escape_string(zendlval, yytext+bprefix+1, yyleng-bprefix-2, '"') == SUCCESS)
|| !PARSER_MODE()) {
if (Z_TYPE_P(zendlval) == IS_STRING) {
printf("[Debug Lexer] parsed string: %s\n", Z_STRVAL_P(zendlval));
}
RETURN_TOKEN_WITH_VAL(T_CONSTANT_ENCAPSED_STRING);
} else {
RETURN_TOKEN(T_ERROR);
}
....省略
テスト用のphpファイルを作って、実行します。
//test.php
<?php
$a = "hello world!";
echo $a;
test@test php-src % ./sapi/cli/php test.php
[Debug Lexer] parsed string: hello world!
hello world!%
test@test php-src %
実際に解析中の文字列が表示されました。ここの解析では開始の”(ダブルクォーテーション)から終了の”(ダブルクォーテーション)までの長さを計算して、指定範囲のメモリだけを対象に解析・抽出しています。
// ① トークン全体の長さ(バイト数)を計算
yyleng = YYCURSOR - SCNG(yy_text);
// ② クォートの中身だけ(先頭と末尾の " を除いた長さ)を指定してエスケープ解析
zend_scan_escape_string(zendlval, yytext+bprefix+1, yyleng-bprefix-2, '"')
そして最後にT_CONSTANT_ENCAPSED_STRING というトークンの識別番号(整数値)を戻り値として返します。これにより、parser で zendval の格納場所を特定・参照できるようになります。
Parser
Parser(bison)のコードは zend_language_parser.y にあります。こちらは2000行近くあります。
こちらはScannerで解析したトークンを元にASTノードを作成します。(デバッグを仕込むところではすでにAST化は済んでいるようです。)該当のトークンが T_CONSTANT_ENCAPSED_STRING になります。
dereferenceable_scalar:
T_ARRAY '(' array_pair_list ')' { $$ = $3; $$->attr = ZEND_ARRAY_SYNTAX_LONG; }
| '[' array_pair_list ']' { $$ = $2; $$->attr = ZEND_ARRAY_SYNTAX_SHORT; }
| T_CONSTANT_ENCAPSED_STRING {
$$ = $1;
zend_ast *ast = $1;
if (ast && ast->kind == ZEND_AST_ZVAL) {
// ZEND_AST_ZVAL ノードキャストして内部の zval を取り出す
zend_ast_zval *ast_zval = (zend_ast_zval *) ast;
zval *zv = &ast_zval->val;
if (Z_TYPE_P(zv) == IS_STRING) {
printf("[Debug Parser] string: %s\n", Z_STRVAL_P(zv));
}
}
}
| '"' encaps_list '"' { $$ = $2; }
;
ビルドして、実行します。
test@test php-src % ./sapi/cli/php test.php
[Debug Lexer] parsed string: hello world!
[Debug Parser] string: hello world!
hello world!%
Parser内からzendvalの参照ができました。
Compiler
実際のバイトコード生成の箇所にデバッグを仕込もうと思ってたんですが、Lexer時点で文字列をヒープに割り当てます。 そのため、Zend は実行VM(execute) 側でも、文字列を触れます。これは、コンパイラと実行VMと同プロセスであるために可能となります。また、リテラル文字列はAST からOpcode に登録されます。バイトコード生成はリテラルテーブルのポインタが埋め込まれます。
Execute
今回の検証用のスクリプトではecho $a; echo命令で変数の値を表示しているので、最終的にはechoのOpcode の中で出力がされます。赤文字のところにデバッグを仕込みました。CV_HANDLERなのは検証用のスクリプトが変数を出力しているためです。
static ZEND_OPCODE_HANDLER_RET ZEND_OPCODE_HANDLER_FUNC_CCONV ZEND_ECHO_SPEC_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
{
USE_OPLINE
zval *z;
SAVE_OPLINE();
z = EX_VAR(opline->op1.var);
if (Z_TYPE_P(z) == IS_STRING) {
zend_string *str = Z_STR_P(z);
if (Z_TYPE_P(z) == IS_STRING) {
printf("[Debug Execute] echo value: %s\n", Z_STRVAL_P(z));
}
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
}
} else {
zend_string *str = zval_get_string_func(z);
printf("[Debug Execute1] echo value: %s\n", ZSTR_VAL(str));
if (ZSTR_LEN(str) != 0) {
zend_write(ZSTR_VAL(str), ZSTR_LEN(str));
} else if (IS_CV == IS_CV && UNEXPECTED(Z_TYPE_P(z) == IS_UNDEF)) {
ZVAL_UNDEFINED_OP1();
}
zend_string_release_ex(str, 0);
}
ZEND_VM_NEXT_OPCODE_CHECK_EXCEPTION();
}
出力結果がこちらになります。
test@test php-src % ./sapi/cli/php test.php
[Debug Execute] echo value: hello world!
echo の時はこちらの関数を間違いなく通ってますが、最終的にコンソールや、htmlとしてレスポンスを返すのはどこでしょうか?それは、関数の中にある zend_write です。zend.c に定義されていました。
ZEND_API zend_write_func_t zend_write;
CLI やCGI などを切り替えるために関数ポインタになっています。zend_startupの中で、zend_write = utility_functions->write_function; に入れてますね。新しく関数を追加して、zend_write に代入します。
static int debug_zend_write(const char *str, size_t len)
{
if (len > 0 && str != NULL) {
// [Debug zend_write] と一緒に渡された文字数を表示
printf("[Debug zend_write] (len=%zu): %.*s\n", len, (int)len, str);
}
printf("SAIGO?\n");
return 0;
}
void zend_startup(zend_utility_functions *utility_functions) /* {{{ */
{
...省略
zend_write = debug_zend_write;
...省略
}
出力結果ですが、下記のようになりました。これは期待通りの動作ですね。
test@test php-src %
[Debug zend_write] (len=12): hello world!
SAIGO?
test@test php-src %
utility_functions->write_function; の中身はCLI か CGI かによって出力の終端が変わります。今回の例だとCLI実行になるので、下記が出力の終端になります。こちらも実際に実行してみます。
PHP_CLI_API ssize_t sapi_cli_single_write(const char *str, size_t str_length) /* {{{ */
{
ssize_t ret;
if (cli_shell_callbacks.cli_shell_write) {
cli_shell_callbacks.cli_shell_write(str, str_length);
}
#ifdef PHP_WRITE_STDOUT
do {
ret = write(STDOUT_FILENO, str, str_length);
} while (ret <= 0 && (errno == EINTR || (errno == EAGAIN && sapi_cli_select(STDOUT_FILENO))));
#else
ret = fwrite(str, 1, MIN(str_length, 16384), stdout);
if (ret == 0 && ferror(stdout)) {
return -1;
}
#endif
printf("END?\n");
return ret;
}
[Debug Execute] echo value: hello world!
hello world!END?
test@test php-src %
こちらも期待通りの動作です。ここまでが、大まかな出力の流れになります。結論としては、自作言語の参考にはなりませんでした。(設計方針が異なるため)
最後に
今回はPHPで文字列が出力されるまでの流れを検証してみました。Zend の内部は想像以上にコード量が多く、検証に時間を要しました。コンパイラとVMの内部を知らなかった頃の私は echo や出力関数が魔法のように思えてました。蓋を開けてみれば、泥臭いコードの集合(とても美しいです。)ですが。これからも自作言語の開発や、Zendの解析をしていくので、よければ次回も見ていただけると嬉しいです。最後まで見ていただきありがとうございました。

コメント