PHP 提供的钩子

PHP 和 Zend Engine 为扩展提供了许多不同的钩子,允许扩展开发人员以 PHP 用户空间无法使用的方式控制 PHP 运行时。

PHP 和 Zend 引擎为扩展提供了许多不同的钩子,允许扩展开发者以 PHP 用户域无法提供的方式控制 PHP 运行时。

本章将介绍各种钩子以及从扩展挂钩到这些钩子的常见用例。

挂钩 PHP 函数的一般模式是扩展覆盖 PHP 内核提供的函数指针。然后,扩展函数通常会执行自己的工作并调用原始的 PHP 核心函数。使用这种模式,不同的扩展可以覆盖同一个钩子,而不会造成冲突。

执行函数的钩子

用户空间和内部函数的执行由 Zend 引擎内的两个函数处理,您可以用自己的实现替换它们。覆盖此钩子的扩展的主要用途是通用函数级剖析、调试和面向方面的编程。

Zend/zend_execute.h 中定义的钩子:

ZEND_API extern void (*zend_execute_ex)(zend_execute_data *execute_data);
ZEND_API extern void (*zend_execute_internal)(zend_execute_data *execute_data, zval *return_value);
c

如果要覆盖这些函数指针,则必须在 MINIT 中进行,因为 Zend 引擎内部的其他决定都是基于是否覆盖指针这一事实提前做出的。

通常的覆盖模式是这样的:

static void (*original_zend_execute_ex) (zend_execute_data *execute_data);
static void (*original_zend_execute_internal) (zend_execute_data *execute_data, zval *return_value);
void my_execute_internal(zend_execute_data *execute_data, zval *return_value);
void my_execute_ex (zend_execute_data *execute_data);

PHP_MINIT_FUNCTION(my_extension)
{
    REGISTER_INI_ENTRIES();

    original_zend_execute_internal = zend_execute_internal;
    zend_execute_internal = my_execute_internal;

    original_zend_execute_ex = zend_execute_ex;
    zend_execute_ex = my_execute_ex;

    return SUCCESS;
}

PHP_MSHUTDOWN_FUNCTION(my_extension)
{
    zend_execute_internal = original_zend_execute_internal;
    zend_execute_ex = original_zend_execute_ex;

    return SUCCESS;
}
c

覆盖 zend_execute_ex 的一个缺点是,它会改变 Zend 虚拟机运行时行为,使用递归而不是在不离开解释器循环的情况下处理调用。此外,没有覆盖 zend_execute_ex 的 PHP 引擎也可以生成更优化的函数调用操作码。

这些钩子对性能非常敏感,具体取决于包装原始函数的代码的复杂性。

重写内部函数

覆盖执行(execute)钩子可以记录扩展的每次函数调用,也可以覆盖用户态、核心和扩展函数(和方法)的单个函数指针。如果扩展程序只需要访问特定的内部函数调用,这样做的性能特性要好得多:

#if PHP_VERSION_ID < 70200
typedef void (*zif_handler)(INTERNAL_FUNCTION_PARAMETERS);
#endif
zif_handler original_handler_var_dump;

ZEND_NAMED_FUNCTION(my_overwrite_var_dump)
{
    // if we want to call the original function
    original_handler_var_dump(INTERNAL_FUNCTION_PARAM_PASSTHRU);
}

PHP_MINIT_FUNCTION(my_extension)
{
    // If the ZEND_TSRMLS_CACHE_UPDATE() is in RINIT, move it
    // to MINIT to ensure access to the compiler globals
#if defined(COMPILE_DL_MY_EXTENSION) && defined(ZTS)
    ZEND_TSRMLS_CACHE_UPDATE();
#endif

    zend_function *original;

    original = zend_hash_str_find_ptr(CG(function_table), "var_dump", sizeof("var_dump")-1);

    if (original != NULL) {
        original_handler_var_dump = original->internal_function.handler;
        original->internal_function.handler = my_overwrite_var_dump;
    }
}
c

当覆盖类方法时,可以在 zend_class_entry 上找到函数表:

zend_class_entry *ce = zend_hash_str_find_ptr(CG(class_table), "PDO", sizeof("PDO")-1);
if (ce != NULL) {
    original = zend_hash_str_find_ptr(&ce->function_table, "exec", sizeof("exec")-1);

    if (original != NULL) {
        original_handler_pdo_exec = original->internal_function.handler;
        original->internal_function.handler = my_overwrite_pdo_exec;
    }
}
c

修改抽象语法树(AST)

当 PHP 7 编译 PHP 代码时,它会将其转换为抽象语法树 (AST),然后最终生成持久保存在 opcache 中的操作码。zend_ast_process 钩子会为每个编译的脚本调用,并允许您在解析和创建 AST 后修改 AST。

这是最复杂的钩子之一,因为它需要完全理解 AST 的可能性。在这里创建无效的 AST 可能会导致奇怪的行为或崩溃。

最好查看使用此钩子的示例扩展:

编译脚本文件钩子

每当用户脚本调用 include/require 或它们的对应函数 include_once/require_once 时,PHP 核心就会调用指针 zend_compile_file 处的函数来处理该请求。参数是一个文件句柄,结果是 zend_op_array。:

zend_op_array *my_extension_compile_file(zend_file_handle *file_handle, int type);
c

PHP 核心中有两个扩展实现了此钩子:dtraceopcache

  • 如果您使用环境变量 USE_ZEND_DTRACE 启动 PHP 脚本并使用带有 dtrace 支持的 PHP,则将使用 Zend/zend_dtrace.c 中的 dtrace_compile_file

  • Opcache 将 op 数组存储在共享内存中以获得更好的性能,这样每当编译脚本时,其最终 op 数组都会从缓存中提供,而不会重新编译。您可以在 ext/opcache/ZendAccelerator.c 中找到此实现。

  • 名为 compile_file 的默认实现是 Zend/zend_language_scanner.l 中扫描器代码的一部分。

实现此钩子的用例是 Opcode 加速(Opcode Accelerating)、PHP 代码加密/解密、调试或分析。

您可以在执行 PHP 进程时随时替换此钩子,替换后编译的所有 PHP 脚本都将由您对钩子的实现处理。

始终调用原始函数指针非常重要,否则 PHP 无法再编译脚本,opcache 将不再工作。

此处的扩展覆盖顺序也很重要,因为您需要知道是否要在 opcache 之前或之后注册钩子,因为如果 opcache 在其共享内存缓存中找到操作码数组条目,它不会调用原始函数指针。 Opcache 将其钩子注册为启动后钩子,该钩子在扩展的 minit 阶段之后运行,因此默认情况下,当脚本被缓存时,您的钩子将不再被调用。

调用错误处理程序时发出通知

与 PHP 用户空间的 set_error_handler() 函数类似,扩展可以通过实现 zend_error_cb 钩子将自身注册为错误处理程序:

ZEND_API void (*zend_error_cb)(int type, const char *error_filename, const uint32_t error_lineno, const char *format, va_list args);
c

type 变量对应于 PHP 用户空间中也可用的 E_* 错误常量。

PHP 核心和用户空间错误处理程序之间的关系很复杂:

  1. 如果没有注册用户空间错误处理程序,则始终调用 zend_error_cb

  2. 如果注册了用户空间错误处理程序,则对于 E_ERRORE_PARSEE_CORE_ERRORE_CORE_WARNINGE_COMPILE_ERRORE_COMPILE_WARNING 的所有错误,始终调用 zend_error_cb 钩子。

  3. 对于所有其他错误,只有当用户空间处理程序失败或返回 false 时才会调用 zend_error_cb

此外,由于 Xdebug 自身的实现方式非常复杂,因此它在覆盖错误处理程序时不会调用之前注册的内部处理程序。

因此,覆盖此钩子并不可靠。

同样,覆盖时应尊重原始处理程序,除非你想完全替换它:

void (*original_zend_error_cb)(int type, const char *error_filename, const uint error_lineno, const char *format, va_list args);

void my_error_cb(int type, const char *error_filename, const uint error_lineno, const char *format, va_list args)
{
    // my special error handling here

    original_zend_error_cb(type, error_filename, error_lineno, format, args);
}

PHP_MINIT_FUNCTION(my_extension)
{
    original_zend_error_cb = zend_error_cb;
    zend_error_cb = my_error_cb;

    RETURN SUCCESS;
}

PHP_MSHUTDOWN(my_extension)
{
    zend_error_cb = original_zend_error_cb;
}
c

此钩子主要用于为异常跟踪或应用程序性能管理软件实现中央异常跟踪。

抛出异常时发出通知

每当 PHP Core 或用户代码抛出异常时,都会以异常作为参数调用 zend_throw_exception_hook

此钩子的签名相当简单:

void my_throw_exception_hook(zval *exception)
{
    if (original_zend_throw_exception_hook != NULL) {
        original_zend_throw_exception_hook(exception);
    }
}
c

此钩子没有默认实现,如果没有被扩展覆盖则指向 NULL

static void (*original_zend_throw_exception_hook)(zval *ex);
void my_throw_exception_hook(zval *exception);

PHP_MINIT_FUNCTION(my_extension)
{
    original_zend_throw_exception_hook = zend_throw_exception_hook;
    zend_throw_exception_hook = my_throw_exception_hook;

    return SUCCESS;
}
c

如果您实现此钩子,请注意无论是否捕获异常,都会调用此钩子。暂时将异常存储在此处,然后将其与错误处理程序钩子的实现相结合,以检查异常是否未被捕获并导致脚本停止,这仍然很有用。

实现此钩子的用例包括调试、日志记录和异常跟踪。

eval()钩子

PHP 的 eval 不是内部函数,而是一种特殊的语言构造。因此,您无法通过 zend_execute_internal 或覆盖其函数指针来挂载它。

挂载 eval 的用例并不多,您可以将其用于分析或出于安全目的。如果您更改其行为,请注意其他扩展可能需要 eval。一个例子是 Xdebug,它使用它来执行断点条件。

extern ZEND_API zend_op_array *(*zend_compile_string)(zval *source_string, char *filename);
c

垃圾收集器钩子

当调用 gc_collect_cycles() 时,PHP 的垃圾回收器会被显式触发;当可回收对象的数量达到某个阈值时,垃圾回收器也会被引擎本身隐式触发。

为了了解垃圾回收器的工作原理或了解其性能,你可以覆盖执行垃圾回收操作的函数指针钩子。理论上,你可以在这里实现自己的垃圾回收算法,但考虑到可能还需要对引擎进行其他改动,这可能不太可行。

int (*original_gc_collect_cycles)(void);

int my_gc_collect_cycles(void)
{
    original_gc_collect_cycles();
}

PHP_MINIT_FUNCTION(my_extension)
{
    original_gc_collect_cycles = gc_collect_cycles;
    gc_collect_cycles = my_gc_collect_cycles;

    return SUCCESS;
}
c

重写中断处理程序

当执行器全局 EG(vm_interrupt) 设置为 1 时,会调用一次中断处理程序。在执行用户空间代码期间,会在常规检查点检查此操作。引擎使用此钩子通过信号处理程序实现 PHP 执行超时,该信号处理程序在达到超时持续时间后将中断设置为 1

这有助于将信号处理推迟到运行时执行的后期阶段,此时清理或实现自己的超时处理会更安全。通过设置此钩子,您不会意外禁用 PHP 的超时检查,因为它具有比 zend_interrupt_function 的任何覆盖优先级更高的自定义处理。

ZEND_API void (*original_interrupt_function)(zend_execute_data *execute_data);

void my_interrupt_function(zend_execute_data *execute_data)
{
    if (original_interrupt_function != NULL) {
        original_interrupt_function(execute_data);
    }
}

PHP_MINIT_FUNCTION(my_extension)
{
    original_interrupt_function = zend_interrupt_function;
    zend_interrupt_function = my_interrupt_function;

    return SUCCESS;
}
c

替换操作码处理程序

可以覆盖 Zend 引擎对操作码的单独处理程序。这对于忽略 @ 操作符或计算每个操作码的执行频率可能很有用。引擎的应用程序接口(API)只能为每个操作码定义一个扩展处理程序,因此,作为扩展作者,必须注意其他扩展已经设置的处理程序。

引擎中的基本 API 包括:

void zend_set_user_opcode_handler(int opcode, user_opcode_handler_t handler);
user_opcode_handler_t zend_get_user_opcode_handler(int opcode);
c

user_opcode_handler_t 是一个函数指针,每个处理程序都有以下签名(在 PHP 8 中,此签名正在发生变化,并且会根据是否启用特定 GCC 功能而有所不同。):

int my_handler(zend_execute_data *execute_data);
c

处理程序的返回值很重要,并且定义了一些有意义的常量:

ZEND_USER_OPCODE_CONTINUE

执行下一个操作码

ZEND_USER_OPCODE_RETURN

从执行器退出(从函数返回)

ZEND_USER_OPCODE_DISPATCH

调用原始操作码处理程序

ZEND_USER_OPCODE_ENTER

进入新的 op_array 而不进行递归

ZEND_USER_OPCODE_LEAVE

返回到同一执行器中调用 op_array

在下面的示例中,我们将覆盖用于实现 @ 运算符的 ZEND_BEGIN_SILENCEZEND_END_SILENCE 操作码。基于扩展全局变量 (no_silence),它将跳过操作码或让引擎执行其正常行为:

ZEND_BEGIN_MODULE_GLOBALS(my_extension)
    int                   no_silence;
    user_opcode_handler_t original_begin_silence_handler;
    user_opcode_handler_t original_end_silence_handler
ZEND_END_MODULE_GLOBALS(my_extension)

static int silence_handler(zend_execute_data *execute_data)
{
    if (MYEXTG(no_silence)) {
        execute_data->opline++;
        return ZEND_USER_OPCODE_CONTINUE;
    }

    /* We select the handler depending on which opcode this handler is called *for* */
    if (execute_data->opline == ZEND_BEGIN_SILENCE) {
        /* Only call the original handler if it wasn't NULL */
        if (MYEXTG(original_begin_silence_handler)(execute_data)) {
            return MYEXTG(original_begin_silence_handler)(execute_data);
        }
    } else {
        if (MYEXTG(original_end_silence_handler)(execute_data)) {
            return MYEXTG(original_end_silence_handler)(execute_data);
        }
    }

    /* If the original handler was NULL, instruct the VM to do whatever it needs to */
    return ZEND_USER_OPCODE_DISPATCH;
}

PHP_MINIT_FUNCTION(my_extension)
{
    MYEXTG(original_begin_silence_handler) = zend_get_user_opcode_handler(ZEND_BEGIN_SILENCE);
    MYEXTG(original_end_silence_handler) = zend_get_user_opcode_handler(ZEND_END_SILENCE);
    zend_set_user_opcode_handler(ZEND_BEGIN_SILENCE, silence_handler);
    zend_set_user_opcode_handler(ZEND_END_SILENCE, silence_handler);

    return SUCCESS;
}

PHP_MSHUTDOWN_FUNCTION(my_extension)
{
    zend_set_user_opcode_handler(ZEND_BEGIN_SILENCE, MYEXTG(original_begin_silence_handler));
    zend_set_user_opcode_handler(ZEND_END_SILENCE, MYEXTG(original_end_silence_handler));

    return SUCCESS;
}
c