Zend Engineを読む② | 文字列が出力されるまで

プログラミング言語の記事のアイキャッチ C言語

はじめに

今回の記事は前回の続きになります。よければ、前回も見ていただけると嬉しいです。

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-src
Contribute 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の解析をしていくので、よければ次回も見ていただけると嬉しいです。最後まで見ていただきありがとうございました。

コメント

タイトルとURLをコピーしました