PHP内核之zend_observer

如果我们要在调用PHP脚本里面的每一个函数前打印一句hello world,大致有以下几种做法。(假设我们的扩展叫做observer

Hook zend_execute_ex

例如,我们可以在PHP的模块初始化之前,进行如下的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void (*old_zend_execute_ex)(zend_execute_data *execute_data);

void observer_execute_ex(zend_execute_data *execute_data)
{
// printf("hello world\n");

old_zend_execute_ex(execute_data);
}

static PHP_MINIT_FUNCTION(observer)
{
old_zend_execute_ex = zend_execute_ex;
zend_execute_ex = observer_execute_ex;
return SUCCESS;
}

但是,这种方式会有堆栈溢出的风险。

我们来看看,假设我们有如下的PHP代码:

1
2
3
4
5
6
7
8
9
10
11
<?php

function foo() {
static $i = 0;
$i++;

var_dump($i);
foo();
}

foo();

在没有hook zend_execute_ex的情况下,执行结果如下:

1
2
3
4
5
6
# 省略其他的打印
int(813536)
int(813537)
int(813538)

Fatal error: Allowed memory size of 134217728 bytes exhausted at Zend/zend_execute.h:208 (tried to allocate 262176 bytes) in /Users/codinghuang/codeDir/cCode/php-src/test.php on line 7

可以看到,这里调用了813538foo函数,然后进程达到PHP内存最大的限制而停下来了。

当我们把PHP的内存限制设置到无限的时候,我们会发现,这个foo函数可以无限的递归下去。为什么可以无限的递归下去呢?因为这种情况下,PHP有一个无限制的PHP调用堆栈。

hookzend_execute_ex的情况下,执行结果如下:

1
2
3
4
5
# 省略其他的打印
int(47597)
int(47598)
int(47599)
[1] 10240 segmentation fault php test.php

为什么这种情况下,就不能无限制的递归下去呢?因为当我们hookzend_execute_ex之后,PHP的函数调用都放在C堆栈上面,也就意味着,受限于ulimit -s的值。我机器默认的堆栈大小如下:

1
2
ulimit -s
8192

这种情况下,我们只有调大系统的堆栈限制,才能解决堆栈被破坏的问题,例如我们扩大一倍的堆栈大小:

1
ulimit -s 16384

再次执行脚本:

1
2
3
4
5
# 省略其他的打印
int(95259)
int(95260)
int(95261)
[1] 18466 segmentation fault php test.php

可以看到,foo函数调用次数基本上是成倍的增加了。

Hook opcode的handler

我们可以在ZendVM执行函数调用的opcode之前,执行一段我们的handler

1
2
3
4
5
6
7
8
9
10
11
12
static int do_ucall_handler(zend_execute_data *execute_data) {
// printf("hello world\n");

return ZEND_USER_OPCODE_DISPATCH;
}

static PHP_MINIT_FUNCTION(observer)
{
zend_set_user_opcode_handler(ZEND_DO_UCALL, do_ucall_handler);
zend_set_user_opcode_handler(ZEND_DO_FCALL_BY_NAME, do_ucall_handler);
return SUCCESS;
}

这个实现方式也是可以无限制的递归调用foo函数的,因为它仅仅用到了PHP栈去实现foo函数的调用。但是这种实现方式有一个问题,就是如何去处理opcode的转发,我们可能会把其他扩展hook的这个opcode对应的handler抹掉,从而导致一些出乎意料的问题。并且,这里还有一个很大的问题就是,和JIT不兼容。使用Swoole的小伙伴们应该知道,JIT刚出来的时候,Swoole下是无法开启JIT的,就是因为SwooleHook了某些opcode导致的。

observer api

这个是新一代的方式,也是目前PHP8推荐的一种方式,这种即没有堆栈问题,也不会影响JIT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
static void observer_begin(zend_execute_data *execute_data) {
printf("hello world\n");
}

static zend_observer_fcall_handlers observer_handler(zend_execute_data *execute_data) {
zend_observer_fcall_handlers handlers = {NULL, NULL};

handlers.begin = observer_begin;
return handlers;
}

static PHP_MINIT_FUNCTION(observer)
{
REGISTER_INI_ENTRIES();
zend_observer_fcall_register(observer_handler);
return SUCCESS;
}