PHP7 Kernel - AST to Opcodes Compilation Process

The previous section briefly introduced the process of converting PHP code into an Abstract Syntax Tree (AST). This section discusses the process of converting the AST into zend_op_array, which is the final product of the compilation phase and the input for the execution phase. The subsequent content will mainly focus on zend_op_array. The AST parsing process determines the variables defined in the current script and assigns them sequential numbers. These values are accessed using these numbers during execution. Additionally, it stores literal values such as variable initial values, function/class/constant names, etc., in zend_op_array.literals. These literals also have unique identifiers. Therefore, the execution process involves calling different C functions based on each instruction and processing these values using variable, literal, and temporary variable identifiers.

We first examine the structure of zend_op_array to understand several key points, and then look at the process of compiling the AST into zend_op_array.

3.1.2.1 zend_op_array Data Structure

The main PHP script generates a zend_op_array, and each function is compiled into an independent zend_op_array. From a binary program perspective, the zend_op_array contains all the stack information for the current scope. Function calls essentially involve switching between different zend_op_arrays.

struct _zend_op_array {
    //common fields used for quick access to opcodes for regular functions or class member methods
    ...

    uint32_t *refcount;

    uint32_t this_var;

    uint32_t last;
    //opcode instruction array
    zend_op *opcodes;

    //Number of variables defined in PHP code: variables with op_type IS_CV, excluding IS_TMP_VAR and IS_VAR
    //This value starts at 0 before compilation, and increases by 1 when a new variable is found
    int last_var;
    //Number of temporary variables: variables with op_type IS_TMP_VAR or IS_VAR
    uint32_t T;
    //Array of PHP variable names
    zend_string **vars; //This array is used during AST compilation along with last_var to determine the numbering of each variable, a very important step
    ...

    //Static variable symbol table: declared with static
    HashTable *static_variables;
    ...

    //Number of literals
    int last_literal; 
    //Array of literals (constants), these are values defined in the PHP code
    zval *literals;

    //Runtime cache array size
    int  cache_size;
    //Runtime cache, used to cache some znode_op for fast data retrieval, discussed separately later
    void **run_time_cache;

    void *reserved[ZEND_MAX_RESERVED_RESOURCES];
};

zend_op_array.opcodes points to the instruction list. The structure of each instruction is as follows:

struct _zend_op {
    const void *handler; //Instruction execution handler
    znode_op op1;   //Operand 1
    znode_op op2;   //Operand 2
    znode_op result; //Result
    uint32_t extended_value; 
    uint32_t lineno; 
    zend_uchar opcode;  //Opcode instruction
    zend_uchar op1_type; //Type of operand 1
    zend_uchar op2_type; //Type of operand 2
    zend_uchar result_type; //Type of result
};

//Operand structure
typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num; /*  Needs to be signed */
    uint32_t      jmp_offset;
} znode_op;

The fields of opcode are explained below.

3.1.2.1.1 handler

Each opcode has a corresponding C-language handler. All handlers for opcodes are defined in zend_vm_def.h. It is worth noting that this file is not used during compilation because there are three different ways to provide the handling process for opcodes: CALL, SWITCH, GOTO. The default method is CALL. What does this mean?

Each opcode represents some specific processing operation. How is this provided? One way is to encapsulate the work for each opcode into a function, and then the executor can loop through and execute it, which is the way the CALL mode works. Another way is to separate the handling of all opcodes by C language labels and then jump to the corresponding position for processing during execution, which is the way the GOTO mode works. Finally, another way is to write all handling methods under a switch statement and execute the specific operation according to the case of the opcode, which is the way the SWITCH mode works.

Assuming the opcode array looks like this:

int op_array[] = {
    opcode_1,
    opcode_2,
    opcode_3,
    ...,
};

The workflow for each mode is similar to this:

//CALL mode
void opcode_1_handler() {...}

void opcode_2_handler() {...}
...

void execute(int []op_array)
{
    void *opcode_handler_list[] = {&opcode_1_handler, &opcode_2_handler, ...};

    while(1){
        void handler = opcode_handler_list[op_array[i]];
        handler(); //call handler
        i++;
    }
}

//GOTO mode
void execute(int []op_array)
{
    while(1){
        goto opcode_xx_handler_label;
    }

opcode_1_handler_label:
    ...

opcode_2_handler_label:
    ...
...
}

//SWITCH mode
void execute(int []op_array)
{
    while(1){
        switch(op_array[i]){
            case opcode_1:
                ...
            case opcode_2:
                ...
            ...
        }

        i++;
    }
}

The efficiency of the three modes differs, with GOTO being the fastest. How to choose other modes? After downloading the PHP source code, do not compile directly. In the Zend directory, there is a file: zend_vm_gen.php. Before compiling PHP, run: php zend_vm_gen.php --with-vm-kind=CALL|SWITCH|GOTO. This script will regenerate: zend_vm_opcodes.h, zend_vm_opcodes.c, and zend_vm_execute.h to overwrite the original ones, and then compile PHP.

The analysis process uses the default mode CALL, where the handler for each opcode is a function pointer. How is the handler for the opcode indexed during compilation?

Each opcode has a unique number, and the handler can be set based on the op_type. Therefore, each opcode instruction can have up to 20 (25 minus 5 duplicates) corresponding handler functions. All handlers are defined in an array zend_opcode_handlers in order of the opcode number. Each 25 entries correspond to the same opcode. If the handler for the corresponding op_type is available, it can be set to empty:

//zend_vm_execute.h
void zend_init_opcodes_handlers(void)
{
    static const void *labels[] = {
        ZEND_NOP_SPEC_HANDLER,
        ZEND_NOP_SPEC_HANDLER,
        ...
    };
    zend_opcode_handlers = labels;
}

Indexing algorithm:

//zend_vm_execute.h
static const void *zend_vm_get_opcode_handler(zend_uchar opcode, const zend_op* op)
{
        //Because op_type is a multiple of 2, so it is converted to 0-4
        static const int zend_vm_decode[] = {
            _UNUSED_CODE, /* 0              */
            _CONST_CODE,  /* 1 = IS_CONST   */
            _TMP_CODE,    /* 2 = IS_TMP_VAR */
            _UNUSED_CODE, /* 3              */
            _VAR_CODE,    /* 4 = IS_VAR     */
            _UNUSED_CODE, /* 5              */
            _UNUSED_CODE, /* 6              */
            _UNUSED_CODE, /* 7              */
            _UNUSED_CODE, /* 8 = IS_UNUSED  */
            _UNUSED_CODE, /* 9              */
            _UNUSED_CODE, /* 10             */
            _UNUSED_CODE, /* 11             */
            _UNUSED_CODE, /* 12             */
            _UNUSED_CODE, /* 13             */
            _UNUSED_CODE, /* 14             */
            _UNUSED_CODE, /* 15             */
            _CV_CODE      /* 16 = IS_CV     */
        };
        //Get the handler based on op1_type, op2_type, and opcode
        return zend_opcode_handlers[opcode * 25 + zend_vm_decode[op->op1_type] * 5 + zend_vm_decode[op->op2_type]];
}

ZEND_API void zend_vm_set_opcode_handler(zend_op* op)
{
    //Setting the handler for the zend_op, this operation is completed during compilation
    op->handler = zend_vm_get_opcode_handler(zend_user_opcodes[op->opcode], op);
}

#define _CONST_CODE  0
#define _TMP_CODE    1
#define _VAR_CODE    2
#define _UNUSED_CODE 3
#define _CV_CODE     4
Operand (znode_op)

The operand type is actually a 32-bit integer, which is mainly used to store some variable index positions, numerical records, etc.

typedef union _znode_op {
    uint32_t      constant;
    uint32_t      var;
    uint32_t      num;
    uint32_t      opline_num; /*  Needs to be signed */
    uint32_t      jmp_offset;
} znode_op;

Each opcode has two operands (not necessarily both used), and the operands record key information for the current instruction, which can be used for storing and accessing variables. For example, in an assignment statement: "$a = 45;", the two operands record the storage location of "$a" and "45". During execution, the value "45" is retrieved from op2 and assigned to "$a", whose location is obtained from op1. Of course, operands are not always used this way. This is just the case for assignments. Other operations have different usages, such as passing parameters in function calls, where op1 records which parameter is passed, op2 records the storage location of the parameter, and result records the storage location of the function's received parameters.

3.1.2.1.3 Operand Type (op_type)

Each operation has five different types:

#define IS_CONST    (1<<0)  //1
#define IS_TMP_VAR  (1<<1)  //2
#define IS_VAR      (1<<2)  //4
#define IS_UNUSED   (1<<3)  //8
#define IS_CV       (1<<4)  //16
  • IS_CONST: Literal, values that are determined at compile time and do not change, such as: $a = "hello~", where the string "hello~" is a constant.
  • IS_TMP_VAR: Temporary variable, such as: $a = "hello~" . time(), where the value of "hello~" . time() is of type IS_TMP_VAR. Similarly, $a = "123" + $b, where the result of "123" + $b is also of type IS_TMP_VAR. From these examples, we can infer that temporary variables are often intermediate values generated during execution. Since they are generated on the fly, assigning IS_TMP_VAR to IS_CV variables does not increase their reference count.
  • IS_VAR: PHP variable, which might seem like a variable defined in the PHP script, but it's not. Here, the meaning of a PHP variable can be understood as variables that are not explicitly defined in the PHP script, not directly defined in the code via $var_name. The most common example of this type is the return value of a PHP function. For instance, $a[0] array, the value it retrieves is IS_VAR, and so is $$a.
  • IS_UNUSED: Indicates that the operand is not used.
  • IS_CV: PHP script variable, i.e., variables defined in the script via $var_name. These variables are determined during compilation, so they are compile-time variables.

The result_type has another type EXT_TYPE_UNUSED (1<<5) which is used when the result is not used. The difference between IS_UNUSED and EXT_TYPE_UNUSED is that IS_UNUSED indicates that the return value of this operation is meaningless (or simply that there is no return value), while EXT_TYPE_UNUSED means that there is a return value but it is not used, such as when a function's return value is not received.

3.1.2.1.4 Literals and Variables Storage

Let's think about how a C program reads and writes literals and variables.

#include <stdio.h>
int main()
{
    char *name = "pangudashu";

    printf("%s\n", name);
    return 0;
}

We know that the pointer name is allocated on the stack, and "pangudashu" is allocated in the constant area. Where is the variable name stored?

In fact, C does not store variable names. During compilation, variable names are replaced by offset representations: ebp - offset or esp + offset. Let's convert the above code to assembly:

.LC0:
    .string "pangudashu"
    .text
    .globl  main
    .type   main, @function
main:
.LFB0:
    pushq   %rbp
    movq    %rsp, %rbp
    subq    $16, %rsp
    movq    $.LC0, -8(%rbp)
    movq    -8(%rbp), %rax
    movq    %rax, %rdi
    call    puts
    movl    $0, %eax
    leave

We can see movq $.LC0, -8(%rbp), and -8(%rbp) is the name variable.

Although PHP code is not directly compiled into machine code, the design of compilation and execution is consistent with C programs, with a constant area, variables accessed by offset, and a virtual execution stack.

Values that are determined at compile time and do not change are called literals, also known as constants (IS_CONST). These values are already allocated zval during compilation and stored in the zend_op_array->literals array (corresponding to the constent storage area in C programs). They are accessed by (_zend_op_array->literals + offset). For example:

<?php
$a = 56;
$b = "hello";

56 is retrieved via (zval*)(_zend_op_array->literals + 0), and hello via (zval*)(_zend_op_array->literals + 16). The actual read and write operations of variables will be analyzed in detail during the execution phase, and here we only analyze the compilation phase operations.

3.1.2.2 AST -> zend_op_array

We have introduced the structure of zend_op_array. Now let's go back to the process after the syntax parsing (zendparse()):

ZEND_API zend_op_array *compile_file(zend_file_handle *file_handle, int type)
{
    zend_op_array *op_array = NULL; //Compiled opcodes
    ...

    if (open_file_for_scanning(file_handle)==FAILURE) {//File opening failed
        ...
    } else {
        zend_bool original_in_compilation = CG(in_compilation);
        CG(in_compilation) = 1;

        CG(ast) = NULL;
        CG(ast_arena) = zend_arena_create(1024 * 32);
        if (!zendparse()) { //Syntax parsing
            zval retval_zv;
            zend_file_context original_file_context; //Save the original zend_file_context
            zend_oparray_context original_oparray_context; //Save the original zend_oparray_context, used during compilation to record the total size of the opcodes, vars, etc. in the current zend_op_array
            zend_op_array *original_active_op_array = CG(active_op_array);
            op_array = emalloc(sizeof(zend_op_array)); //Allocate zend_op_array structure
            init_op_array(op_array, ZEND_USER_FUNCTION, INITIAL_OP_ARRAY_SIZE);//Initialize op_array
            CG(active_op_array) = op_array; //Set the currently compiling op_array to this
            ZVAL_LONG(&retval_zv, 1);

            if (zend_ast_process) {
                zend_ast_process(CG(ast));
            }

            zend_file_context_begin(&original_file_context); //Initialize CG(file_context)
            zend_oparray_context_begin(&original_oparray_context); //Initialize CG(context)
            zend_compile_top_stmt(CG(ast)); //AST -> zend_op_array compilation process
            zend_emit_final_return(&retval_zv); //Set the final return value
            op_array->line_start = 1;
            op_array->line_end = CG(zend_lineno);
            pass_two(op_array);
            zend_oparray_context_end(&original_oparray_context);
            zend_file_context_end(&original_file_context);

            CG(active_op_array) = original_active_op_array;
        }
        ...
    }
    ...

    return op_array;
}

The compile_file() operation has several steps to save the original values because this function is not executed only once during the execution of a PHP script. The main script is executed first, and include, require will also call it, so the current values need to be saved first and restored after execution.

The AST -> zend_op_array compilation is completed in zend_compile_top_stmt(), which is the main entry point and is called recursively multiple times:

//zend_compile.c
void zend_compile_top_stmt(zend_ast *ast)
{
    if (!ast) {
        return;
    }

    if (ast->kind == ZEND_AST_STMT_LIST) { //The first time it comes in, it must be this type
        zend_ast_list *list = zend_ast_get_list(ast);
        uint32_t i;
        for (i = 0; i < list->children; ++i) {
            zend_compile_top_stmt(list->child[i]);//Each child statement is independent, compile recursively
        }
        return;
    }

    //Compilation entry for each statement
    zend_compile_stmt(ast);

    if (ast->kind != ZEND_AST_NAMESPACE && ast->kind != ZEND_AST_HALT_COMPILER) {
        zend_verify_namespace();
    }
    //Handling for function and class types, a very important step, which will be detailed in the chapter analyzing function and class implementations
    if (ast->kind == ZEND_AST_FUNC_DECL || ast->kind == ZEND_AST_CLASS) {
        CG(zend_lineno) = ((zend_ast_decl *) ast)->end_lineno;
        zend_do_early_binding(); //Very important!!!
    }
}

First, starting from the root node of the AST, the root node type is ZEND_AST_STMT_LIST, which indicates that the current node has multiple independent nodes. Each child is a node generated from an independent statement, so they are compiled sequentially until reaching an effective node (non-ZEND_AST_STMT_LIST node), and then call zend_compile_stmt to compile the current node:

void zend_compile_stmt(zend_ast *ast)
{
    CG(zend_lineno) = ast->lineno;

    switch (ast->kind) {
        case xxx:
            ...
        break;
        case ZEND_AST_ECHO:
            zend_compile_echo(ast);
            break;
        ...
        default:
        {
            znode result;
            zend_compile_expr(&result, ast);
            zend_do_free(&result);
        }
    }

    if (FC(declarables).ticks && !zend_is_unticked_stmt(ast)) {
        zend_emit_tick();
    }
}

It mainly processes different node types (kind). We will not explain the processing of each type in detail, but instead look at the specific processing of a few types based on the example from the previous section.

$a = 123;
$b = "hi~";

echo $a,$b;

The AST generated by zendparse() is as follows:

The process is quite complex, and some functions are called recursively multiple times. We follow the example step by step to see, and if you are familiar with the implementation of various PHP syntax, it will be easier to understand the entire AST compilation process.

(1) First, starting from the root node, it has 3 children, the first node is of type ZEND_AST_ASSIGN, and in zend_compile_stmt(), it goes to the default branch.

(2) The ZEND_AST_ASSIGN type is processed by zend_compile_expr():

void zend_compile_expr(znode *result, zend_ast *ast)
{
    CG(zend_lineno) = zend_ast_get_lineno(ast);
    switch (ast->kind) {
        case ZEND_AST_ZVAL:
            ZVAL_COPY(&result->u.constant, zend_ast_get_zval(ast));
            result->op_type = IS_CONST;
            return;
        case ZEND_AST_VAR:
            zend_compile_var(result, ast, BP_VAR_R);
            return;
        case ZEND_AST_ASSIGN:
            zend_compile_assign(result, ast);
            return;
        ...
    }
}

Continue into zend_compile_assign():

void zend_compile_assign(znode *result, zend_ast *ast)
{
    zend_ast *var_ast = ast->child[0]; //Variable name
    zend_ast *expr_ast = ast->child[1];//Value expression of the variable

    znode var_node, expr_node;
    zend_op *opline;
    uint32_t offset;

    if (is_this_fetch(var_ast)) { //Check if the variable name is this, variable name cannot be this
        zend_error_noreturn(E_COMPILE_ERROR, "Cannot re-assign $this");
    }

    //For example, writing my_function() = 123; which is assigning the return value of the function as the variable name will cause an error
    zend_ensure_writable_variable(var_ast);

    switch (var_ast->kind) {
        case ZEND_AST_VAR:
        case ZEND_AST_STATIC_PROP:
            offset = zend_delayed_compile_begin();
            zend_delayed_compile_var(&var_node, var_ast, BP_VAR_W); //Generate the znode for the variable name, which is temporarily used here, so it is directly allocated on the stack
            zend_compile_expr(&expr_node, expr_ast); //Recursively compile the value expression of the variable, ultimately obtaining a ZEND_AST_ZVAL node
            zend_delayed_compile_end(offset);
            zend_emit_op(result, ZEND_ASSIGN, &var_node, &expr_node); //Generate an op
            return;
        ...
    }
}

There are three key operations in this place:

Step 1: The assignment operation has two parts: the variable name and the variable value. So first, handle the variable name. When introducing zend_op_array, it was mentioned that each PHP variable has a number. The reading and writing of variables are done using this number. This number is generated in this step.

In the middle process, we won't go into details. Here, we focus on the process of generating the variable number. This process is simple. Every time a new variable is found, it traverses the zend_op_array.vars array to check if this variable already exists. If not, it is stored in vars, and subsequent uses of variables use the index in this array. For example, when defining for the first time: $a = 123;, the variable $a is numbered 0, and when using again: echo $a;, it will traverse vars, find it already exists, and directly use its index to operate on $a.

static int lookup_cv(zend_op_array *op_array, zend_string* name)
{
    int i = 0;
    zend_ulong hash_value = zend_string_hash_val(name);

    //Traverse op_array.vars to check if this variable already exists
    while (i < op_array->last_var) {
        if (ZSTR_VAL(op_array->vars[i]) == ZSTR_VAL(name) ||
                (ZSTR_H(op_array->vars[i]) == hash_value &&
                 ZSTR_LEN(op_array->vars[i]) == ZSTR_LEN(name) &&
                 memcmp(ZSTR_VAL(op_array->vars[i]), ZSTR_VAL(name), ZSTR_LEN(name)) == 0)) {
            zend_string_release(name);
            return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i);
        }
        i++;
    }
    //This is a new variable
    i = op_array->last_var;
    op_array->last_var++;
    if (op_array->last_var > CG(context).vars_size) {
        CG(context).vars_size += 16; /* FIXME */
        op_array->vars = erealloc(op_array->vars, CG(context).vars_size * sizeof(zend_string*));//Expand vars
    }

    op_array->vars[i] = zend_new_interned_string(name);
    return (int)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, i); //When passing NULL, it returns 96 + i*sizeof(zval)
}

Note: Here, the variable numbering starts from 0, 1, 2, 3... and increments sequentially, but in practice, it is not directly used as this index, but rather converted into an offset. This is handled by the ZEND_CALL_VAR_NUM macro, so the variable offset is actually 96, 112, 128..., incrementing. This 96 is set based on the size of zend_execute_data (the value may differ on different platforms), and this structure will be detailed in the next article on the zend execution process.

#define ZEND_CALL_FRAME_SLOT 
    ((int)((ZEND_MM_ALIGNED_SIZE(sizeof(zend_execute_data)) + ZEND_MM_ALIGNED_SIZE(sizeof(zval)) - 1) / ZEND_MM_ALIGNED_SIZE(sizeof(zval))))

#define ZEND_CALL_VAR_NUM(call, n) 
    (((zval*)(call)) + (ZEND_CALL_FRAME_SLOT + ((int)(n))))

Step 2: Compile the value expression of the variable, wich again calls zend_compile_expr() to compile, and in the example, the expr_ast.kind is ZEND_AST_ZVAL:

void zend_compile_expr(znode *result, zend_ast *ast)
{
    switch (ast->kind) {
        case ZEND_AST_ZVAL:
            ZVAL_COPY(&result->u.constant, zend_ast_get_zval(ast)); //Copy the variable value to znode.u.constant
            result->op_type = IS_CONST; //Type is IS_CONST, this value will be stored in zend_op_array.literals later
            return;
        ...
    }
}

Step 3: The first two steps have already generated the op1 and op2 for the variable assignment. The next step is to generate the opcode based on these two values.

static zend_op *zend_emit_op(znode *result, zend_uchar opcode, znode *op1, znode *op2)
{
    zend_op *opline = get_next_op(CG(active_op_array)); //Generate a new instruction in the current zend_op_array
    opline->opcode = opcode;

    //Copy the contents of op1 and op2 into the zend_op and set op_type
    //If znode.op_type == IS_CONST, the value in znode.u.contstant will be moved to zend_op_array.literals
    if (op1 == NULL) {
        SET_UNUSED(opline->op1);
    } else {
        SET_NODE(opline->op1, op1);
    }

    if (op2 == NULL) {
        SET_UNUSED(opline->op2);
    } else {
        SET_NODE(opline->op2, op2);
    }

    //If this instruction has a return value, it is treated like a variable and assigned a number (this number is used to index local variables when allocating them later)
    if (result) {
        zend_make_var_result(result, opline);
    }
    return opline;
}

static inline void zend_make_var_result(znode *result, zend_op *opline)
{
    opline->result_type = IS_VAR; //Return value type is fixed to IS_VAR
    opline->result.var = get_temporary_variable(CG(active_op_array)); //Assign a number to the return value, this number is recorded in the temporary variable T, as mentioned earlier in the introduction of zend_op_array, T and last_var are different
    GET_NODE(result, opline->result);
}

At this point, the first assignment statement in the example is compiled. The second assignment is the same as the above process, and we directly look at the last output statement.

(3) The compilation of the echo statement: echo $a,$b; is actually compiled into multiple echos in the compiled syntax tree, so the usage in the example is equivalent to: echo $a; echo $b;, and we analyze one of them.

zend_compile_stmt() first finds the node type as ZEND_AST_STMT_LIST, then calls zend_compile_stmt_list() to compile the child, and the specific process is as shown in the following diagram:

Finally, the generation of zend_op is as follows:

void zend_compile_echo(zend_ast *ast)
{        
    zend_op *opline;
    zend_ast *expr_ast = ast->child[0];

    znode expr_node;
    zend_compile_expr(&expr_node, expr_ast);

    opline = zend_emit_op(NULL, ZEND_ECHO, &expr_node, NULL);//Generate a new opcode
    opline->extended_value = 0;
} 

Finally, after zend_compile_top_stmt() compiles, the entire compilation process is basically complete, and the structure of CG(active_op_array) is as shown in the following figure, but there is also a pass_two() process afterwards.

ZEND_API int pass_two(zend_op_array *op_array)
{
    zend_op *opline, *end;

    if (!ZEND_USER_CODE(op_array->type)) {
        return 0;
    }

    //Reset some values of CG(context), temporarily ignored
    ...

    opline = op_array->opcodes;
    end = opline + op_array->last;
    while (opline < end) {
        switch(opline->opcode){
            //Here, some operations are handled specifically, and we will see them when encountering situations
            ...
        }
        //Convert IS_CONST to memory offset, same as IS_CV processing
        //So here, it actually converts 0, 1, 2... to 16, 32, 48... (i.e., number * sizeof(zval))
        if (opline->op1_type == IS_CONST) {
            ZEND_PASS_TWO_UPDATE_CONSTANT(op_array, opline->op1);
        } else if (opline->op1_type & (IS_VAR|IS_TMP_VAR)) {
            //Same as above, but the starting value is after IS_CV
            opline->op1.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, op_array->last_var + opline->op1.var);
        }
        //Same as op1
        if (opline->op2_type == IS_CONST) {
            ZEND_PASS_TWO_UPDATE_CONSTANT(op_array, opline->op2);
        } else if (opline->op2_type & (IS_VAR|IS_TMP_VAR)) {
            opline->op2.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, op_array->last_var + opline->op2.var);
        }
        //Same as op1/2
        if (opline->result_type & (IS_VAR|IS_TMP_VAR)) {
            opline->result.var = (uint32_t)(zend_intptr_t)ZEND_CALL_VAR_NUM(NULL, op_array->last_var + opline->result.var);
        }
        //Set the handler for this opcode
        ZEND_VM_SET_OPCODE_HANDLER(opline);
        opline++;
    }

    //Mark that this op_array has been processed
    op_array->fn_flags |= ZEND_ACC_DONE_PASS_TWO;
    return 0;
}

Ignoring special opcode processing, pass_two() has two important operations:

  • (1) Convert IS_CONST, IS_VAR, IS_TMP_VAR type operands and return values to memory offsets, similar to the handling of IS_CV variables. For IS_CONST type, the starting value is 0 and increases sequentially by sizeof(zval). For IS_VAR and IS_TMP_VAR, the only difference is that the initial value follows IS_CV. Simply put, PHP variables are arranged first, followed by intermediate values, return values, etc.
  • (2) Another importent operation is to set the handler for each instruction, which was previously introduced in Section 3.1.2.1.1 handler

After pass_two() processing, the form of opcodes is as follows:

Summary:

At this point, the entire PHP compilation phase is completed. The final result is the zend_op_array, and the most core operation is the compilation of the AST. Those interested can try writing more examples to see the processing methods for different node types.

Another key operation during compilation is determining the memory numbers for variables, intermediate values, temporary values, return values, and literals. This part is very important and will also be used when introducing the execution process.

Tags: PHP compiler AST Opcodes Zend Engine

Posted on Thu, 08 Oct 2026 16:13:47 +0000 by lee2732