Gong Yong的Blog

调式PHP源码

我相信任何一个有点追求的PHP程序员都曾经有过阅读PHP源码的冲动,而且我估计这其中的很多人都没有将冲动转化为行动,即使有少部分人行为了,最终也难以维持——原因很简单:代码太多,太复杂了,完全无从下手——当然我也是这大多数人中的一员。实际上有时候我个人觉得从头一行一行地阅读源码未必是有必要的,甚至也未必是可行的。

如果这样的话,我们又怎么样才能知道PHP内部是怎么执行PHP程序的呢?阅读文档(PHP官方也没提供)和别人写的文章(例如我前段时间翻译的:深入理解Zend执行引擎)这只能学到一些理论知识,而且读多了总是会感到有些缺失。程序设计本身就是一件实践性的工作,就像学某个程序设计语言一样,我们只有用这个语言写程序,运行所写的程序后才能掌握这门语言,例如我们学习PHP语言,我们会写上一段PHP代码,然后使用echo/print/var_dump之类的语句或者函数输出代码中的变量,然后才能确认某个函数或者语言特性的作用,这样实践多次之后就可以学会PHP的各种特性,从而达到掌握PHP语言的目的。那么我们是否可以在PHP内部源码中使用C语言中的printf函数(类似PHP中的echo)来输出我们想要检查的变量的值呢?这么做当然是可以的,但太繁琐了,光每次修改源码后重新编译的工作就会把人搞死,而且这种做法也非常业余。专业的做法就是调试PHP内部源码,使用调试工具来单步执行PHP源码,使用调试工具打印我们想查看的变量来确认源码是怎么工作的,这就是这篇文章的主题。

首先声明下这篇文章使用的调试工具是gdb,所有示例都是在类Unix平台下进行的(我是在Mac OS上进行操作的,当然Windows上也可以使用gdb,只是需要一些工具),另外为了讲解方面,我直接使用了命令行,你也可以使用eclipse之类的IDE工具进行调试,当然如果是在Windows上,你也可以使用VS来进行调试,不过貌似在Windows下编译PHP有些特殊,有这个需求的同学请自行搜索。另外这篇文章假设你至少了解一些gdb的基本用法(这篇文章也只用到了一些基本用法),我不会对gdb的使用进行介绍,对于完全不了解或者忘记了怎么使用gdb的同学,我强烈建议你去学习下gdb,如果你掌握了怎么使用gdb进行调试,那么你基本上可以自如地使用任何调式工具。

编译PHP源码

你可以从PHP官网下载PHP源码的压缩包,或者是从git.php.net(或者是github的镜像)的git库clone最新的代码库,然后切换到对应的PHP版本的分支,本文使用的是PHP5.6,你可以使用下面的命令完成这些工作:

~> git clone http://git.php.net/repository/php-src.git
~> cd php-src
~/php-src> git checkout PHP-5.6

如果你是从git库中clone的代码,那么你先要运行下buildconf命令:

~/php-src> ./buildconf 

这个命令会生成configure脚本,从官网下载的源码包中会直接包含这个脚本,如果你执行buildconf出错,那么很可能是因为你的系统中没有autoconf这个工具,你可以使用包安装工具进行安装,例如Ubuntu下的apt-get,或者是Mac OS中的homebrew,关于编译时所需要的依赖包我就不作介绍了,基本上如果编译的时候出错,到网上去搜索出错信息就可以搞清楚你缺少什么依赖包。

如果你已经成功生成了configure脚本文件(或者是使用已包含这个脚本文件的源码包),那就可以开始编译了。为了调式PHP源码,我们的编译会disable所有的扩展(除了一些必须包含的外,这些PHP的编译脚本会自行处理),我们使用下面的命令来完成编译安装的工作,假设安装的路径为$HOME/myphp:

~/php-src> ./configure --disable-all --enable-debug --prefix=$HOME/myphp
~/php-src> make -jN
~/php-src> make install

注意这里的prefix的参数必须为绝对路径,所以你不能写成~/myphp,另外我们这次编译只是为了调式,所以建议一定要设置prefix参数,要不然PHP会被安装到默认路径中,大多数时候是/usr/local/php中,这可能会造成一些没必要的污染。另外我们使用了两个选项,一个是--disable-all,这个表示禁止安装所有扩展(除了一个必须安装的),另外一个就是--enable-debug,这个选项表示以debug模式编译PHP源码,相当于gcc的-g选项,它会把调试信息编译进最终的二进制程序中。

上面的命令make -jN,N表示你的CPU数量(或者是CPU核心的数量),设置了这个参数后就可以使用多个CPU进行并行编译,这可以提高编译效率,尽管这在我们的这个示例中这么做的必要性不大。

ok,如果上面的命令都成功的话,最终用于调式的PHP二进制可以执行程序会安装在~/myphp这个文件夹中。

使用gdb进行调式

我们先看一个简单的示例,在命令行中执行下面的命令:

~/myphp> gdb --args bin/php -r "echo 'hello world';"

如果没有出错的话,你会看到gdb的提示信息,在我的电脑上的输出为:

GNU gdb (GDB) 7.8.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law. Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-apple-darwin14.0.0".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from bin/php...done.
(gdb)

有一点要特别说明下,我们使用gdb调试时把PHP的命令放在gdb的--args选项后面,这表示把-r “echo ‘hello world’;”这个字符串作为你要调试的PHP可执行程序的参数,要不然gdb会把它们当作gdb的参数。

现在你就可以开始调式了,你可以先执行gdb的run命令,它表示运行你要调试的程序,由于现在没有设置任何断点,所以我们的程序会直接运行完并退出,并且会在屏幕上输出hello world这个字符串,具体情况如下:

(gdb) run
Starting program: /Users/gongyong1/myphp/bin/php -r echo\ \'hello\ world\'\;
hello world[Inferior 1 (process 61782) exited normally]

我们现在随便设置一个断点。我们都知道PHP是用C编写的,所以它必然会有一个main函数,那么我们就在main的入口处设置一个断点:

(gdb) break main
Breakpoint 1 at 0x100395230: file sapi/cli/php_cli.c, line 1211.

断点设置成功,由于我们运行的程序是bin/php,这个程序是PHP的cli模式(确切的说是运行的sapi为cli),它的main函数位于sapi/cli/php_cli.c的第1211行。

设置好了断点,你就可以再次使用run命令运行你的程序了:

(gdb) run
Starting program: /Users/gongyong1/myphp/bin/php -r echo\ \'hello\ world\'\;
Breakpoint 1, main (argc=3, argv=0x7fff5fbffab0) at sapi/cli/php_cli.c:1211
1211 int exit_status = SUCCESS;
(gdb)

我们可以看到你的程序在main函数的第一行代码上中止了,你可以使用gdb的next命令单步执行,大概就是下面这个样子:

(gdb) n
1212 int module_started = 0, sapi_started = 0;
(gdb) n
1213 char *php_optarg = NULL;
(gdb) n
1214 int php_optind = 1, use_extended_info = 0;
(gdb) n
1215 char *ini_path_override = NULL;
(gdb) n
1216 char *ini_entries = NULL;
(gdb)

nnext命令的简写形式,我们这里单步执行了5行代码。这个代码不是我们想探索的东西,所以我们使用quit命令退出调试:

(gdb) quit
A debugging session is active.
Inferior 1 [process 61784] will be killed.
Quit anyway? (y or n) y

就这么简单,这样你基本上就可以探索PHP内部的任何特性了。下面我通过一个示例来介绍下如何探索zend的执行引擎,关于zend执行引擎方面的理论知识请参见我上面提到的那篇文章。

==判断

ircmaxell有一篇blog分析了判断一个整数和一个浮点数是否相等(使用==进行判断)时是否进行了zval的类型转换。blog中所使用的示例代码如下:

<?php
$i = 1;
$j = 1.0;
echo $i == $j;

我们知道在PHP内部,上面的代码中的$i$j都会用一个zval的结构体(struct)来表示:

typedef struct _zval_struct {
zvalue_value value;
zend_uint refcount__gc;
zend_uchar type;
zend_uchar is_ref__gc;
} zval;

其中用于保存变量的值的字段是value,它的类型是zvalue_value,这是一个联合体(union):

typedef union _zvalue_value {
long lval;
double dval;
struct {
char *val;
int len;
} str;
HashTable *ht;
zend_object_value obj;
} zvalue_value;

如果变量的类型为整型,则使用lval这个字段存储它的值,如果是浮点型,则使用dval存储它的值。在zval结构体中有一个zend_uchar型的字段type来标识变量的类型,PHP内部提供了一些宏来表示变量的类型:

Type tag	Storage location
IS_NULL none
IS_BOOL long lval
IS_LONG long lval
IS_DOUBLE double dval
IS_STRING struct { char *val; int len; } str
IS_ARRAY HashTable *ht
IS_OBJECT zend_object_value obj
IS_RESOURCE long lval

另外PHP内部还提供了一些函数对zval进行类型转换,这里我们就不列出了,有兴趣的同学可以阅读这篇文章。所以对于判断一个整型和一个浮点型的变量是否相同的情况,PHP可以先进行zval转义,然后再判断,那么PHP是否是这么做的呢?

ircmaxell的blog已经给出了答案,PHP不是这么做的,他先使用vld输出上面代码的OPCode(直接复制自ircmaxell的blog):

line     # *  op           fetch  ext  return  operands
--------------------------------------------------------
3 0 > EXT_STMT
1 ASSIGN !0, 1
4 2 EXT_STMT
3 ASSIGN !1, 1
5 4 EXT_STMT
5 IS_EQUAL ~2 !0, !1
6 ECHO ~2
6 7 > RETURN 1

从中可以看出实现==操作的OPCode是IS_EQUAL,它的两个参数是!0!1,这两个操作数都是CV类型(编译变量,compiled variable,如果你看过我之前的blog,应该会很清楚它的含义)。所以PHP内部会使用ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER这个handler函数来实现这个操作,这个函数位于zend_vm_execute.h中,我们现在来调式这个代码,看看这个函数是怎么执行的。

我们先把上面的PHP代码保存到一个名为equal.php的文件中(我把这段代码再贴一次):

<?php
$i = 1;
$j = 1.0;
echo $i == $j;

然后使用gdb调式:

gdb --args bin/php equal.php

ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER这个函数上设置断点:

(gdb) break ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER
Breakpoint 1 at 0x10031ed8c: file Zend/zend_vm_execute.h, line 39515.

然后使用run命令运行程序,我们看到程序会在断点处中止:

(gdb) run
Starting program: /Users/gongyong1/myphp/bin/php equal.php
Breakpoint 1, ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER (execute_data=0x1006953c8) at Zend/zend_vm_execute.h:39515
39515 USE_OPLINE

39515行是这个函数体中的第一行代码,这个函数的代码是从第39513行开始的,我们可以在gdb中使用list命令显示这个函数名开始的20行代码:

(gdb) list 39513,39533
39513 static int ZEND_FASTCALL ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
39514 {
39515 USE_OPLINE
39516
39517 zval *result = &EX_T(opline->result.var).tmp_var;
39518
39519 SAVE_OPLINE();
39520 ZVAL_BOOL(result, fast_equal_function(result,
39521 _get_zval_ptr_cv_BP_VAR_R(execute_data, opline->op1.var TSRMLS_CC),
39522 _get_zval_ptr_cv_BP_VAR_R(execute_data, opline->op2.var TSRMLS_CC) TSRMLS_CC));
39523
39524
39525 CHECK_EXCEPTION();
39526 ZEND_VM_NEXT_OPCODE();
39527 }
39528
39529 static int ZEND_FASTCALL ZEND_IS_NOT_EQUAL_SPEC_CV_CV_HANDLER(ZEND_OPCODE_HANDLER_ARGS)
39530 {
39531 USE_OPLINE
39532
39533 zval *result = &EX_T(opline->result.var).tmp_var;

ZEND_IS_EQUAL_SPEC_CV_CV_HANDLER这个函数在39527结束,从上面的代码中可以看出真正执行equal操作的是fast_equal_function函数,我们再在这个函数上设置一个断点:

(gdb) break fast_equal_function
Breakpoint 2 at 0x10036ee64: file Zend/zend_operators.h, line 885.

然后再使用gdb的continue命令,这个命令会继续运行程序到下一个断点中止:

(gdb) continue
Continuing.
Breakpoint 2, fast_equal_function (result=0x100695368, op1=0x1006c9470, op2=0x1006c9380) at Zend/zend_operators.h:885
885 if (EXPECTED(Z_TYPE_P(op1) == IS_LONG)) {

完全在我们的预料之中,程序在fast_equal_function这个函数处中止了,这个函数位于Zend/zend_operators.h的883行到900行,我们使用list命令在gdb中把它的代码显示出来:

(gdb) list 883,900
file: "/Users/gongyong1/php-dev/debug/php-src/Zend/zend_operators.h", line number: 883
file: "Zend/zend_operators.h", line number: 883
(gdb)

晕,显示不出来,为什么呢?因为这个函数是inline的,debug信息中并没有这个函数的信息,我们直接从源文件中把它的代码扣出来吧:

883 static zend_always_inline int fast_equal_function(zval *result, zval *op1, zval *op2 TSRMLS_DC)
884 {
885 if (EXPECTED(Z_TYPE_P(op1) == IS_LONG)) {
886 if (EXPECTED(Z_TYPE_P(op2) == IS_LONG)) {
887 return Z_LVAL_P(op1) == Z_LVAL_P(op2);
888 } else if (EXPECTED(Z_TYPE_P(op2) == IS_DOUBLE)) {
889 return ((double)Z_LVAL_P(op1)) == Z_DVAL_P(op2);
890 }
891 } else if (EXPECTED(Z_TYPE_P(op1) == IS_DOUBLE)) {
892 if (EXPECTED(Z_TYPE_P(op2) == IS_DOUBLE)) {
893 return Z_DVAL_P(op1) == Z_DVAL_P(op2);
894 } else if (EXPECTED(Z_TYPE_P(op2) == IS_LONG)) {
895 return Z_DVAL_P(op1) == ((double)Z_LVAL_P(op2));
896 }
897 }
898 compare_function(result, op1, op2 TSRMLS_CC);
899 return Z_LVAL_P(result) == 0;
900 }

我们可以先在gdb中使用print命令查看下op1op2的情况,由于op1op2都是指针,所以需要使用*解指针:

(gdb) print *op1
$3 = {value = {lval = 1, dval = 4.9406564584124654e-324, str = {val = 0x1 <error: Cannot access memory at address 0x1>, len = 1}, ht = 0x1, obj = {handle = 1, handlers = 0x1},
ast = 0x1}, refcount__gc = 1, type = 1 '\001', is_ref__gc = 0 '\000'}
(gdb) print *op2
$4 = {value = {lval = 4607182418800017408, dval = 1, str = {val = 0x3ff0000000000000 <error: Cannot access memory at address 0x3ff0000000000000>, len = 1},
ht = 0x3ff0000000000000, obj = {handle = 0, handlers = 0x1}, ast = 0x3ff0000000000000}, refcount__gc = 1, type = 2 '\002', is_ref__gc = 0 '\000'}

我可以看到op1value.lval=1,而op2value.dval=1,它们的value的其他字段的值都是无效的(联合体只有一个有效字段),而且op1op2type字段的值分别为1和2。我们再来看下fast_equal_function这个函数是怎么执行的,我们使用next命令单步执行这个函数,看看这个过程执行了哪些代码:

885		if (EXPECTED(Z_TYPE_P(op1) == IS_LONG)) {
(gdb) n
886 if (EXPECTED(Z_TYPE_P(op2) == IS_LONG)) {
(gdb) n
888 } else if (EXPECTED(Z_TYPE_P(op2) == IS_DOUBLE)) {
(gdb) n
889 return ((double)Z_LVAL_P(op1)) == Z_DVAL_P(op2);
(gdb) n
900 }
(gdb)

断点是从885行开始,不断使用next命令后,fast_equal_function函数的886行、888行和889行被执行了,由于889行是一个return语句,所以这行代码执行后,这个函数就直接返回了。

简单的分析下可以发现这段代码会先判断op1的类型是否为IS_LONG,这个判断是成功的,然后再判断op2是否为IS_LONG,这个是失败的;然后再判断op2是否为IS_DOUBLE,这个是成功的,此时PHP内部会获取op1的值(就是value.lval),将它强制转换为double类型,然后与op2的值(就是value.dval)进行比较。所以很明显PHP并未使用任何函数对op1op2这两个zval进行类型转换,而是直接对它们的值进行强制类型转换,再比较转换后的结果,并且PHP内部是将long型转换为double型,哪怕==的第一个操作数是long型,而不是把第二个操作数转换为第一个操作数的类型。

总结

在程序设计的世界,如果你想搞清楚某个程序是怎么运行的,你只需要一行一行它的代码,然后观察每行代码执行后所发生的改变。虽然对于大型的程序来说,这个工作会非常繁琐,但是如果你能够有效地使用调式工具,那么这个工作也并非无法完成。可以说只要环境允许,使用现代的调式工具,你可以调式任何程序的代码。

对于PHP程序员而言,了解PHP内部实现是提高PHP技术的一个非常有效的方法,甚至对于任何一个有追求的PHP程序员而言,这都是必经之路,而掌握PHP源码的调式显然会让你的进阶之路更方便快捷。希望这篇文章能够给你一些启示。