ソースからオペコードへ:PHPのレキサー、パーサー、AST、そしてコンパイラ
前提知識
- ›第1・2回:アーキテクチャの全体像と zval・データ構造の知識
- ›コンパイラ理論の基礎(レキサー、パーサー、ASTの概念)
- ›ビットフィールドフラグと列挙型の理解
ソースからオペコードへ:PHPのレキサー、パーサー、AST、そしてコンパイラ
前回までの記事では、php-src のアーキテクチャ全体を俯瞰し、コアデータ構造を詳しく解説しました。今回は、PHP ソースファイルが実行可能なオペコードへ変換されるパイプラインを、データの流れに沿って追っていきます。このパイプラインは、PHP が require や include、あるいはメインスクリプトを処理するたびに実行されます — OPcache がキャッシュ済みの結果を返す場合を除いて。
コンパイルパイプライン自体の設計は、驚くほどオーソドックスです。レキサーがパーサーにトークンを渡し、パーサーが AST を構築し、コンパイラが AST を走査しながらオペコードのフラット配列を出力する、という流れです。興味深いのはその規模感と実装上の工夫です。パーサーの文法は数百のプロダクションを持ち、AST のノード種別は約130種類、コンパイラはリポジトリ内でも最大級のファイルのひとつです。そのうえで、すべてのリクエストごとに実行できるだけの速度を確保するための実践的な工夫が随所に施されています。
パイプラインの全体像
コンパイルパイプラインは、zend_compile_file() を起点とした4つのステージで構成されています。
flowchart LR
SRC["PHP Source<br/>(.php file)"] --> LEX["Lexer<br/>(re2c)<br/>zend_language_scanner.l"]
LEX --> |"Token stream"| PARSE["Parser<br/>(Bison)<br/>zend_language_parser.y"]
PARSE --> |"AST"| COMP["Compiler<br/>zend_compile.c"]
COMP --> |"zend_op_array"| VM["VM Execution<br/>or OPcache storage"]
OPCACHE["OPcache Hook"] -.-> |"Replaces<br/>zend_compile_file"| SRC
OPCACHE -.-> |"Returns cached"| VM
重要な拡張ポイントは、zend_compile_file がグローバルな関数ポインタである点です。OPcache は MINIT のタイミングでこのポインタを差し替え、コンパイル呼び出しをインターセプトして共有メモリ上のキャッシュ済み op_array を返します。この「フック可能な関数ポインタ」パターンについては、第4回でさらに詳しく取り上げます。
コンパイラの本体は Zend/zend_compile.c に集約されています。10,000行を超えるリポジトリ最大級のソースファイルのひとつです。では、各ステージのデータの流れを追っていきましょう。
re2c レキサー
レキサーは Zend/zend_language_scanner.l に定義されており、re2c の入力ファイルです。flex/lex とは異なり、re2c はテーブルを使わず goto 文と文字比較によって直接コーディングされた DFA を生成します。これにより、テーブル駆動型の方式よりも大幅に高速なスキャナが実現されています。
スキャナはre2cの「コンディション」と呼ばれる複数の状態を持ちます。
| 状態 | 遷移のきっかけ | スキャン対象 |
|---|---|---|
ST_INITIAL / ST_IN_SCRIPTING |
デフォルト | PHPキーワード、演算子、識別子 |
ST_LOOKING_FOR_PROPERTY |
-> または ?-> の後 |
プロパティ・メソッド名 |
ST_DOUBLE_QUOTES |
" |
変数展開を含む文字列の内容 |
ST_HEREDOC |
<<<IDENTIFIER |
変数展開を含むヒアドキュメント本体 |
ST_NOWDOC |
<<<'IDENTIFIER' |
変数展開なしのNowdoc本体 |
ST_BACKQUOTE |
` |
シェル実行の変数展開 |
ST_VAR_OFFSET |
文字列内の $var[ |
文字列補間内の配列オフセット |
ファイル先頭にあるスキャナマクロが re2c とのインターフェースを設定します。SCNG() はスキャナのグローバル変数(現在のバッファ位置、行番号、ファイル名)にアクセスします。.l ファイルの各ルールは T_FUNCTION、T_CLASS、T_VARIABLE などのトークン定数を返し、マッチしたテキストを zval として格納します。
レキサーが複雑になるのは、特に文字列の処理です。ヒアドキュメントとNowdocでは区切り文字の追跡が必要で、ダブルクォート文字列内の変数展開はスキャン状態のネストを伴います。さらに、"$obj->prop" や "{$arr['key']}" といったPHPの柔軟な文字列補間の構文が、複雑な状態遷移を生み出します。
ヒント: 特定のソースファイルに対してPHPがどのようなトークンを生成するかを確認したい場合は、
php -w(空白の除去)を使うか、レキサーをユーザー空間に直接公開するtoken_get_all()関数を活用しましょう。
Bison パーサーと文法
パーサーは Zend/zend_language_parser.y という Bison の文法ファイルに定義されています。生成されるパーサーは LALR(1) パーサーです。トークンを左から右に読み取り、1トークンの先読みを使いながら、プロダクションをボトムアップで還元していきます。
文法中の優先順位宣言は演算子の優先度を定めます。最低優先度(,、yield)から最高優先度(単項演算子、メンバーアクセス)へと並ぶ宣言群です。%left、%right、%nonassoc の各宣言によって、$a + $b * $c や $a ?? $b ?? $c といった式の曖昧さが解消されます。
flowchart TD
TOP["top_statement_list"] --> STMT["statement"]
TOP --> FUNC["function_declaration_statement"]
TOP --> CLASS["class_declaration_statement"]
STMT --> EXPR["expr ';'"]
STMT --> IF["if_statement"]
STMT --> WHILE["while_statement"]
STMT --> FOR["for_statement"]
STMT --> RETURN["T_RETURN expr ';'"]
EXPR --> ASSIGN["variable '=' expr"]
EXPR --> BINARY["expr '+' expr"]
EXPR --> CALL["function_call"]
EXPR --> NEW["T_NEW class_name_reference"]
CLASS --> MEMBERS["class_statement_list"]
MEMBERS --> METHOD["method_declaration"]
MEMBERS --> PROP["property_declaration"]
各Bisonアクション(プロダクションルールの後ろに書かれる { } 内のCコード)は、zend_ast_create* 系の関数を使って AST ノードを構築します。たとえば、加算式 expr '+' expr は2つの子を持つ ZEND_AST_BINARY_OP ノードを生成します。関数宣言では、名前・パラメータ・戻り値の型・本体を子ノードとして持つ ZEND_AST_FUNC_DECL ノードが作られます。
パーサーはオペコードを直接出力しません — それはPHP 5時代のアプローチです。PHP 7以降では、AST を介してパースとコンパイルが分離されており、マルチパス解析やより高度な最適化が可能になっています。
ASTノードの種類と構造
AST ノードシステムは Zend/zend_ast.h に定義されています。ZEND_AST_* のノード種別は約130種類あり、その構造によって次のように分類されます。
| カテゴリ | 子ノード数 | 例 |
|---|---|---|
| 特殊ノード | 可変 | ZEND_AST_ZVAL(リテラル)、ZEND_AST_ZNODE(コンパイラ一時値) |
| 宣言ノード | 固定(4以上) | ZEND_AST_FUNC_DECL、ZEND_AST_CLASS、ZEND_AST_METHOD |
| リストノード | 可変長 | ZEND_AST_STMT_LIST、ZEND_AST_ARG_LIST、ZEND_AST_EXPR_LIST |
| 子0個のノード | 0 | (現在は存在しない — リテラルは ZEND_AST_ZVAL で表現) |
| 子1個のノード | 1 | ZEND_AST_VAR、ZEND_AST_RETURN、ZEND_AST_UNARY_PLUS |
| 子2個のノード | 2 | ZEND_AST_ASSIGN、ZEND_AST_BINARY_OP、ZEND_AST_WHILE |
| 子3個のノード | 3 | ZEND_AST_CONDITIONAL(三項演算子)、ZEND_AST_FOR |
| 子4個のノード | 4 | ZEND_AST_IF_ELEM、ZEND_AST_FOR(init、cond、loop、body) |
基底となる zend_ast 構造体は、kind(ノード種別)と attr(ノード固有のフラグ — たとえば ZEND_AST_BINARY_OP に対する具体的な二項演算子の種類や、メソッド宣言のアクセス修飾子など)を持ちます。具体的な構造体のバリアントは3種類です。
zend_ast_zval:zvalをラップする(リテラル値用)zend_ast_decl: 関数・クラス・メソッドの宣言用。名前・フラグ・ドキュメントコメント・子ASTなどの追加フィールドを持つzend_ast_list: 可変長の子ノード用(文のリスト、引数リストなど)
1〜4個の子ポインタを持つ通常の zend_ast 構造体がそれ以外をすべてカバーします。このような階層設計により、AST のメモリ割り当てが無駄なくコンパクトに保たれています。ほとんどのノードはヘッダと数個のポインタで構成されるだけです。
コンパイラ:ASTからオペコードへ
Zend/zend_compile.c のコンパイラは、シングルパスの再帰的ツリーウォーカーです。中心となるディスパッチ関数は、文の処理なら zend_compile_stmt()、式の処理なら zend_compile_expr() で、ASTノードの種別に応じてスイッチし、各種別に対応した専用コンパイル関数を呼び出します。
flowchart TD
ENTRY["zend_compile_top_stmt()"] --> SWITCH{"node->kind?"}
SWITCH -->|"ZEND_AST_FUNC_DECL"| CF["zend_compile_func_decl()"]
SWITCH -->|"ZEND_AST_CLASS"| CC["zend_compile_class_decl()"]
SWITCH -->|"ZEND_AST_STMT_LIST"| CL["iterate children,<br/>call zend_compile_stmt()"]
SWITCH -->|"ZEND_AST_RETURN"| CR["zend_compile_return()"]
SWITCH -->|"other"| CE["zend_compile_stmt()<br/>→ zend_compile_expr()"]
CE --> EMIT["zend_emit_op()<br/>zend_emit_op_tmp()"]
EMIT --> OP["zend_op appended<br/>to current op_array"]
CF --> NEW_OP["New zend_op_array<br/>for function body"]
CC --> NEW_CE["New zend_class_entry<br/>with method op_arrays"]
コンパイラは Zend/zend_compile.h に定義された2つのコンテキスト構造体を管理します。
zend_file_context: ファイル単位の状態(現在の名前空間、use文によるインポートテーブル)zend_oparray_context: 関数単位の状態(現在の op_array、ループや try-catch に対するジャンプターゲット、変数割り当て)
式のコンパイル中に生じる中間結果には znode(Zend/zend_compile.h に定義)を使います。これは、その結果が定数なのか、一時変数なのか、コンパイル済み変数なのか、あるいは未使用なのかを追跡します。znode は AST のコンパイルとオペコードの出力をつなぐ橋渡し役です。各 zend_compile_expr_* 関数は結果の znode に値を設定し、親の式がそれを被演算子として利用します。
zend_op 命令フォーマット
コンパイルされた各命令は zend_op として表現され、Zend/zend_compile.h に定義されています。
flowchart LR
subgraph zend_op["zend_op struct"]
direction TB
HANDLER["handler: void*<br/>Function pointer to VM handler"]
OP1["op1: znode_op<br/>First operand (32-bit)"]
OP2["op2: znode_op<br/>Second operand (32-bit)"]
RESULT["result: znode_op<br/>Result location (32-bit)"]
EXT["extended_value: uint32_t<br/>Extra data (operator type, flags)"]
LINE["lineno: uint32_t"]
OPCODE["opcode: uint8_t"]
TYPES["op1_type, op2_type, result_type: uint8_t"]
end
各オペランドには型タグがあり、32ビットの znode_op の値をどう解釈するかを決定します。
| オペランド型 | 値 | 意味 |
|---|---|---|
IS_UNUSED |
0 | オペランド未使用(またはジャンプターゲットを保持) |
IS_CONST |
1 (1<<0) |
リテラルテーブルへのインデックス(コンパイル時定数) |
IS_TMP_VAR |
2 (1<<1) |
一時変数スロット(式の中間値) |
IS_VAR |
4 (1<<2) |
変数スロット(参照を保持できる) |
IS_CV |
8 (1<<3) |
Compiled Variable — 名前付きローカル変数($foo) |
extended_value フィールドはオペコードごとに用途が異なります。ZEND_ASSIGN_OP では具体的な演算子(ZEND_ADD、ZEND_SUB など)を、ZEND_FETCH_OBJ ではキャッシュスロットのオフセットを、ZEND_CAST では変換先の型を保持します。
オペコード定数は Zend/zend_vm_opcodes.h に定義されています。算術演算、制御フロー、関数呼び出し、オブジェクト操作、配列操作など、約200種類のオペコードがあります。各オペコードは handler フィールドを持ち、コンパイル時に VM 内の適切な型特化ハンドラへの関数ポインタが設定されます。ハンドラシステムの詳細は第4回で解説します。
zend_op_array と zend_function
コンパイルの出力物は zend_op_array です。これは関数・メソッド・トップレベルスクリプトのコンパイル済み表現であり、Zend/zend_compile.h に定義されています。主な内容は以下の通りです。
opcodes:zend_op命令のフラット配列literals: 定数テーブル(ソース中のリテラル値に対応するzval)vars: コンパイル済み変数名($this、$param1、$localVarなど)arg_info: パラメータの型とデフォルト値の情報try_catch_array: 例外処理の範囲static_variables:static $varの初期化子filename、line_start、line_end: ソース上の位置情報num_args、required_num_args: 引数数の情報
zend_function ユニオン(Zend/zend_compile.h)は、ユーザー定義関数と内部(C)関数を統一的に扱います。
classDiagram
class zend_function {
<<union>>
+uint8_t type
+common: zend_function_common
+op_array: zend_op_array
+internal_function: zend_internal_function
}
class zend_function_common {
+uint8_t type
+uint32_t fn_flags
+zend_string *function_name
+zend_class_entry *scope
+zend_function *prototype
+zend_arg_info *arg_info
+uint32_t num_args
+uint32_t required_num_args
}
class zend_op_array {
+common fields...
+zend_op *opcodes
+zval *literals
+...compiled PHP function
}
class zend_internal_function {
+common fields...
+handler: zif_handler
+...C function
}
zend_function --> zend_function_common
zend_function --> zend_op_array
zend_function --> zend_internal_function
common フィールドは、zend_op_array と zend_internal_function の両方で先頭に同じレイアウトで配置されています。そのため、関数名・フラグ・引数情報だけを必要とするコードは、ユーザー定義関数か内部関数かを意識せずに func->common.* でアクセスできます。
クラス・メソッド・プロパティのフラグ
コンパイルシステムは、クラスとメンバーの修飾子を表すための広範なビットフィールドを使用しており、Zend/zend_compile.h に定義されています。ZEND_ACC_* 定数は複数の役割を担います。
| フラグ | ビット | 16進数値 | 適用対象 |
|---|---|---|---|
ZEND_ACC_PUBLIC |
1 << 0 |
0x00000001 |
メソッド、プロパティ、定数 |
ZEND_ACC_PROTECTED |
1 << 1 |
0x00000002 |
メソッド、プロパティ、定数 |
ZEND_ACC_PRIVATE |
1 << 2 |
0x00000004 |
メソッド、プロパティ、定数 |
ZEND_ACC_STATIC |
1 << 4 |
0x00000010 |
メソッド、プロパティ |
ZEND_ACC_FINAL |
1 << 5 |
0x00000020 |
クラス、メソッド、プロパティ、定数 |
ZEND_ACC_ABSTRACT |
1 << 6 |
0x00000040 |
クラス、メソッド、プロパティ |
ZEND_ACC_READONLY |
1 << 7 |
0x00000080 |
プロパティ |
ZEND_ACC_INTERFACE |
1 << 0 |
0x00000001 |
クラス(PUBLIC とビット位置を共有 — コンテキスト依存!) |
ZEND_ACC_TRAIT |
1 << 1 |
0x00000002 |
クラス(PROTECTED とビット位置を共有 — コンテキスト依存!) |
ZEND_ACC_ENUM |
1 << 28 |
0x10000000 |
クラス |
ビット位置が文脈によって共有されているケースがあります。ZEND_ACC_INTERFACE と ZEND_ACC_PUBLIC は同じビット(1 << 0)を使い、ZEND_ACC_TRAIT は ZEND_ACC_PROTECTED(1 << 1)とビットを共有しています。クラス宣言がインターフェースであると同時にメソッドになることはないため、フラグの解釈はクラスエントリに対するものか関数・プロパティに対するものかによって決まります。コンパイラは組み合わせの妥当性を検証し(abstract final は不正など)、クラス・メソッドの宣言コンパイル時にこれらのフラグを設定します。
ヒント: GDB でフラグの値をデバッグするときは、
fn_flagsやce->ce_flagsを16進数にキャストしてZEND_ACC_*の一覧と照合しましょう。たとえば0x00000051という値はPUBLIC | STATIC | ABSTRACT(0x01 + 0x10 + 0x40)を意味します。ビット位置はバージョンによって異なる場合があるので、必ずヘッダファイルを確認してください。
次回予告
ソーステキストからコンパイル済みオペコードへの旅路を追ってきました。第4回ではいよいよ仮想マシン本体に踏み込みます。123,000行にも及ぶ型特化ハンドラを生成するユニークなテンプレートベースのコード生成システム、5つのディスパッチモード(新たな TAILCALL モードを含む)、パフォーマンスのためのグローバルレジスタピン留め、そして実行前にオペコードを変換する SSA ベースのオプティマイザを解説します。今回解説した zend_op フォーマットと zend_op_array 構造体が、VMへの直接の入力となります。