Read OSS

ソースからオペコードへ:PHPのレキサー、パーサー、AST、そしてコンパイラ

上級

前提知識

  • 第1・2回:アーキテクチャの全体像と zval・データ構造の知識
  • コンパイラ理論の基礎(レキサー、パーサー、ASTの概念)
  • ビットフィールドフラグと列挙型の理解

ソースからオペコードへ:PHPのレキサー、パーサー、AST、そしてコンパイラ

前回までの記事では、php-src のアーキテクチャ全体を俯瞰し、コアデータ構造を詳しく解説しました。今回は、PHP ソースファイルが実行可能なオペコードへ変換されるパイプラインを、データの流れに沿って追っていきます。このパイプラインは、PHP が requireinclude、あるいはメインスクリプトを処理するたびに実行されます — 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_FUNCTIONT_CLASST_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_DECLZEND_AST_CLASSZEND_AST_METHOD
リストノード 可変長 ZEND_AST_STMT_LISTZEND_AST_ARG_LISTZEND_AST_EXPR_LIST
子0個のノード 0 (現在は存在しない — リテラルは ZEND_AST_ZVAL で表現)
子1個のノード 1 ZEND_AST_VARZEND_AST_RETURNZEND_AST_UNARY_PLUS
子2個のノード 2 ZEND_AST_ASSIGNZEND_AST_BINARY_OPZEND_AST_WHILE
子3個のノード 3 ZEND_AST_CONDITIONAL(三項演算子)、ZEND_AST_FOR
子4個のノード 4 ZEND_AST_IF_ELEMZEND_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 に対するジャンプターゲット、変数割り当て)

式のコンパイル中に生じる中間結果には znodeZend/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_ADDZEND_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 の初期化子
  • filenameline_startline_end: ソース上の位置情報
  • num_argsrequired_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_arrayzend_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_INTERFACEZEND_ACC_PUBLIC は同じビット(1 << 0)を使い、ZEND_ACC_TRAITZEND_ACC_PROTECTED1 << 1)とビットを共有しています。クラス宣言がインターフェースであると同時にメソッドになることはないため、フラグの解釈はクラスエントリに対するものか関数・プロパティに対するものかによって決まります。コンパイラは組み合わせの妥当性を検証し(abstract final は不正など)、クラス・メソッドの宣言コンパイル時にこれらのフラグを設定します。

ヒント: GDB でフラグの値をデバッグするときは、fn_flagsce->ce_flags を16進数にキャストして ZEND_ACC_* の一覧と照合しましょう。たとえば 0x00000051 という値は PUBLIC | STATIC | ABSTRACT(0x01 + 0x10 + 0x40)を意味します。ビット位置はバージョンによって異なる場合があるので、必ずヘッダファイルを確認してください。

次回予告

ソーステキストからコンパイル済みオペコードへの旅路を追ってきました。第4回ではいよいよ仮想マシン本体に踏み込みます。123,000行にも及ぶ型特化ハンドラを生成するユニークなテンプレートベースのコード生成システム、5つのディスパッチモード(新たな TAILCALL モードを含む)、パフォーマンスのためのグローバルレジスタピン留め、そして実行前にオペコードを変換する SSA ベースのオプティマイザを解説します。今回解説した zend_op フォーマットと zend_op_array 構造体が、VMへの直接の入力となります。