PHP Executes Bytecode – Deepdive

pow-tac1 pts0 comments

How PHP executes bytecode

All of these tracks lead to the same station. What differs is how fast the points hand a train on.

The last time I looked closely at how PHP executes bytecode, the engine had one handler per opcode and those handlers were written by hand. Specialised handlers arrived with PHP 5.1 in November 2005, and two of the five dispatch models in this article did not exist yet either. Curious how these dispatch models differ and what use cases they solve, I dug in. In this article I want to share with you what I learned along the way.

What follows is a tour of the part of the Zend Engine that actually runs your code. It touches C and the machine underneath, so I will explain the background as we go. You do not need to know either to follow along.

From PHP code to opcodes

PHP does not run your source code. It compiles it into an intermediate representation made of instructions that the engine calls opcodes. A compiled script is an array of such instructions, and a component called the executor walks that array and performs one instruction after the other.

We can look at that array. OPcache brings a debug facility for this, controlled by the opcache.opt_debug_level setting. Its value is a bit mask: 0x10000 prints the bytecode as the compiler produced it, 0x20000 prints it again after the optimiser has been over it. Take the smallest program I can think of:

declare(strict_types=1);<br>print 2 + 3;

OPcache has to be loaded and enabled for the command line, which makes for a wordy invocation:

$ php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 program.php

If you would rather not deal with OPcache at all, phpdbg -p -r program.php prints a very similar listing. Either way, this is what the compiler produced:

$_main:<br>; (lines=2, args=0, vars=0, tmps=0)<br>; (before optimizer)<br>0000 ECHO int(5)<br>0001 RETURN int(1)

There is no addition here. The compiler evaluated 2 + 3 while compiling and put the result into the instruction. That is a compiler concern and not our topic today, but it is a useful reminder that the executor only ever sees what survived compilation. To get an actual addition, we have to hide the operands from the compiler:

declare(strict_types=1);<br>$a = 2;<br>$b = 3;

print $a + $b;

$_main:<br>; (lines=5, args=0, vars=2, tmps=3)<br>; (before optimizer)<br>0000 ASSIGN CV0($a) int(2)<br>0001 ASSIGN CV1($b) int(3)<br>0002 T4 = ADD CV0($a) CV1($b)<br>0003 ECHO T4<br>0004 RETURN int(1)

Five instructions. Each has an address, an operation, up to two operands, and possibly a result. CV0 and CV1 are compiled variables: slots for $a and $b that the compiler reserved in the call frame, addressed by number rather than by name. T4 is a temporary that only exists between the ADD and the ECHO.

In C, one such instruction is a struct named _zend_op, declared in Zend/zend_compile.h. A struct in C is what a class without methods would be in PHP: a fixed set of named fields:

struct _zend_op {<br>zend_vm_opcode_handler_t handler;<br>znode_op op1;<br>znode_op op2;<br>znode_op result;<br>uint32_t extended_value;<br>uint32_t lineno;<br>uint8_t opcode; /* Opcodes defined in Zend/zend_vm_opcodes.h */<br>uint8_t op1_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */<br>uint8_t op2_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */<br>uint8_t result_type; /* IS_UNUSED, IS_CONST, IS_TMP_VAR, IS_VAR, IS_CV */<br>};

Two of these fields matter for what follows.

opcode is a single byte, so there can be at most 256 different operations, and PHP 8.6 uses 212 of them. It is the number behind the names you see in an opcode dump: ADD, ECHO, ASSIGN, RETURN.

handler is the field the executor actually uses. While a script is being compiled, every instruction gets a handler assigned that says how this instruction is to be performed. At runtime the executor goes straight to handler and, apart from a handful of special cases, never looks at opcode at all. That field is mostly there for the optimiser, for the JIT, and for tools that inspect the bytecode.

The executor goes straight to handler. The opcode field is read by the optimiser, by the JIT and by tools such as phpdbg.

What a handler actually is, whether a function to call, a number to switch on, or an address to jump to, is the question this whole article revolves around.

What an executor has to do

Stripped of everything else, an executor keeps track of two things:

opline points to the instruction that is currently being performed. It is the equivalent of a bookmark, and it is what a CPU would call an instruction pointer.

execute_data points to the current call frame: the block of memory that holds the arguments, the compiled variables and the temporaries of the function that is currently running. CV0 in the dump above means "slot 0 in execute_data".

With those two, the job is a loop: look at opline, do what it says, move opline forward, repeat. Moving forward is spelled out in a macro in Zend/zend_execute.c; a macro in C is a piece of text that the compiler...

handler executor instruction opcache compiler opcode

Related Articles