PHP JIT 技术详解 - 知乎


本站和网页 https://zhuanlan.zhihu.com/p/331777202 的作者无关,不对其内容负责。快照谨为网络故障时之索引,不代表被搜索网站的即时页面。

PHP JIT 技术详解 - 知乎首发于PHP底层内核无障碍写文章登录/注册PHP JIT 技术详解rhett码农79 人赞同了该文章为什么写这篇文章去年做PHP webshell检测的时候,无可避免的对PHP的内核进行了一些研究,主要是它的编译器和虚拟机,当时大概的结论是PHP的设计和和实现都非常的中规中矩,算是PL领域经典技术的忠实应用。这样带来的好处是,代码读起来很轻松,读的时候有重温老技术的感觉。坏处就是太中规中矩了没有亮点。在zend(php的vm)之上,我们做了不少工作,比如污点追踪,别人家大多是基于parser的数据流分析,而我们则是直接做到了zend vm这层。各种webshell的解密脱壳你会发现,做到zend这一层优势是很明显的。最近呢,PHP8终于发布了,看了下新闻,主要是两部分的进展。安全性有了很大的增强。我本来最关注这部分,但是还没抽出时间来看。喊了好几年的JIT机制终于发布了。在看官方介绍PPT的时候,有这样一页,这说的是整个的JIT技术是一个核心人员开发的,没有第二个backup了,所以他们感到很忧虑。在我看来,JIT这种技术在编译领域是相对easy的,怎么会只有一个人 really understand呢,所以这激发了我的兴趣将PHP的JIT技术搞清楚。从这样一个很单纯的动机出发,结果一分析起来就从下午熬到后半夜了。今天是周末我把结论整理出来,于是就有了这篇文章。背景知识先谈几个我对所谓背景知识的看法:搞懂一个机制,往往不是只懂对应学科的知识就够的。比如很多人觉得编程语言是语言设计的事,编译器是生成代码的事,虚拟机是执行环境的事。实际上,编程语言,编译器,高级语言虚拟机本质上是一个技术而不是三个技术。所以,你看XXX之父往往是他同时设计了这门语言,给它写了编译器,还开发了虚拟机。这三门技术本来就是要融合在一起的。一门语言成功与否,纯技术层面的事情其实不重要。重要的是语言本身是否对用户(程序员)足够友好,写起来是不是很简单,是不是有高的开发效率(尽管高的开发效率往往意味着低的执行效率)是否有丰富和广泛的库支持所以,你看PHP到今天才搞出来JIT,而Python连个影子都还没有…技术拆解废话了不少,现在到PHP JIT技术的实际拆解阶段了。分成如下几个部分JIT的框架指令生成JIT在zend中如何集成,包含trace和编译,以及执行流的控制JIT具体function的代码解读JIT的框架所谓JIT是just in time的意思,它是相对于AOT(ahead of time)来说的。脚本语言一般是运行在它的VM里,大体逻辑是这样的(直接iPad画图了,偷个懒)php源码经过词法语法分析后生成AST,代码生成器遍历AST生成opcode序列,至此编译器前段的工作结束。经过加载器的launch后,代码在VM里运行起来。这是脚本语言最常规的执行方式,这样做的好处是,写PHP代码的时候完全不用关心平台相关的逻辑,只面向vm就好。坏处也是显然的,就是code不是直接run在CPU上,而是被虚拟机解释执行,效率自然是很低的。当脚本语言既想获得这种简单便利的好处,又想追求执行效率的时候,JIT就派上用场了。JIT介入后,流程变成了这样子有了jit之后,可以按照一定的策略,比如hot trace,选择将部分的opcode序列编译成machine code,比如X86的指令,直接运行在物理CPU上。这样的话,执行效率自然就上来了。指令生成指令生成,是编译器后端的主要工作。这部分工作的难度和复杂度都是很高的,可以说编译器之间的PK主要就是PK这部分。而jit编译呢,因为是在脚本程序执行的过程间,根据一些热点函数或者热点片段进行的有选择的编译,再加上脚本代码一般本身的规模并不大,经常是一些短平快的逻辑,所以jit编译从来不会追求编译效果的最佳化,而是要在编译成本和收益之间找到好的平衡点。所以,jit引擎需要解决好如下几个问题:找到哪些代码是需要进行jit处理的。选择的标准是,执行频率高,性能敏感,编译后效率提升高。Jit本身的成本可控。编译这种事,其实是CPU消耗型的任务,自身成本不能忽视。跟VM进行融合。原来的opcode在执行的时候,每个opcode对应zend虚拟机里一个函数级的handler,trace逻辑如何加进来?当机器指令生成后,原来的虚拟机内指令处理逻辑如何进行替换?zend是一个栈式虚拟机的设计,每个函数会生成一个op_array数组,同时会构造独有的frame来执行,那么jit编译后的代码,要如何跟它进行参数传递和结果带出?这些都是很明显要解决的问题。当然,还有无数细节问题要解决。需要说明的是,这里不会对指令生成进行太细致的分析。理由在于,JIT机制,不限于PHP,生成的指令,质量都是很差的。甚至可以说,跟工业级的AOT编译器来对比,比如GCC、LLVM,脚本语言的code generator都是玩具一样的存在。这里有个很好的例子来说明这一点。Webkit 内核里有段时间jit是借助llvm实现的,但是呢,llvm生成的代码质量虽然很高,但是它的消耗也太大,所以webkit又专门开发了B3引擎。他们的解释是这样的While LLVM is an excellent optimizer, it isn’t specifically designed for the optimization challenges of dynamic languages like JavaScript. We felt that we could get a bigger speed-up if we combined the best of the FTL architecture with a new compiler backend that incorporated what we learned from using LLVM but was tuned specifically for our needs.说白了就是我前面讲第二点,找到好的平衡。OK,现在具体来看下PHP JIT的x86指令是怎么生成的。PHP JIT的指令生成部分,是借助了一个叫DynASM的项目,这个项目是设计来实现LuaJit的,去官网一看心就凉了。这个确实太sorry了。DynASM是配合Lua使用的,首先是搞了一个巨大的X86指令模板,然后通过minilua和dynasm.lua文件,对模板代码文件(这里是zend_jit_x86.dasc)进行处理,生成最终要用的zend_jit_x86.c。这个zend_jit_x86.c 会被zend编译链接进来,作为自己的code generator。也就是说,zend_jit_x86.dasc 是模板文件,zend根据自己的需求进行修改,但是这只是中间文件,zend_jit_x86.c 是生成出来的文件,最终被集成进去。(这种模式很常见了,LLVM支持一个新的arch,也是编辑描述文件即可)这个生成的zend_jit_x86.c 文件有将近5万行代码,这也不稀罕了,毕竟X86的复杂度在那儿摆着呢。然而,这也才只是覆盖了常见指令集和AVX指令集。来看一个具体的例子,我们拿最简单的数值计算类指令来看。Zend里的+-*/指令,原来是ZEND_ADD,ZEND_SUB等,新的定义如下这个被解释成LONG_OP里的寄存器+地址操作,那LONG_OP又是怎么定义的呢这里已经依稀可见X86的指令了。如你所见,这里的寄存器操作和内存操作都还有所抽象,Lua做的事就是模板展开和实例化,经过这步处理后就是最终的X86汇编。像R0,R1都是它的伪寄存器,在LLVM的IR技术里,也是先使用伪寄存器,然后再通过寄存器分配算法给到真实的寄存器。多说一句,Zend使用的是linear scan算法进行寄存器分配,这种算法属于入门级的,基本够用,没有先进性可言。同样的,Zend里的很多其它opcode也要定义对应的模板函数,这里不再列举。工作量还是巨大的。后面我会给出具体的例子,看X86指令生成的代码是怎样的。这部分的介绍先到此为止。JIT的集成和实现JIT机制,是被集成在opcache这个PHP扩展里,opcache这个扩展在PHP早先的版本里已经具备,主要是对编译后的opcode进行缓存,缓存放在共享内存里,可以被跨进程的使用。后面我将通过把整个JIT工作的过程在debugger里观测一遍的方式来展开。在这之前我先得写一段PHP代码来做测试。测试的代码很简单,写个add函数对变量加1,循环执行5万次,所以这个函数的执行肯定是hot的,理论上就应该被jit化。在opcache这个扩展初始化的时候,jit引擎也随之初始化,这里不展开。在开启JIT的情况下,原始op_array生成之后,zend_jit_op_array函数开始执行,这代表着jit开始干活了。这个函数会在原始的op_array中插入trace逻辑。以zend_jit_setup_hot_trace_counters函数为例,逻辑如下:遍历opcode指令序列,划分基本块,构建CFG。基本块(basic block)数组化,为后续trace做准备,注意trace是基于block的,这很合理。分析CFG,构建dominator tree分析CFG,识别其中的循环逻辑。我们知道,循环loop总是性能上需要特殊关注的部分,何况人家还有一个trace策略就叫 hot loop trace。如果需要trace loop,那么将循环开始的opcode的处理逻辑进行替换如果需要trace函数,那么建立function级别的trace如上,替换对应的opcode的handler,实现了用带trace逻辑的函数替换原来的vm opcode处理函数。Trace永远都是第一步,毕竟有了trace的信息才能决定哪部分代码被jit编译。到目前为止,还是在编译和代码生成阶段做准备。总结下来就是一句话,编译PHP原代码到opcode,分析指令构建出CFG,在关注的地方插入trace handler,监控后续的执行。画个图如下:第一阶段完成后,生成的opcode已经带上trace逻辑了,现在是时候看第二阶段也就是执行阶段的逻辑了。我们知道,所有虚拟机的执行引擎基本都是这样的写法pc = xxx;
while (*pc != exit)
ins = fetch(pc);
decode(ins);
exec(ins);
pc++;
}Zend也不例外,它的执行函数是 ZEND_API void zend_execute(zend_op_array *op_array, zval *return_value) 这个函数的主要逻辑就是上述的 while大循环,取指,译指,执行(类似接,化,发)。如下在没有JIT的时候,这个while循环就是取每个opcode,opcode就是各种ZEND_XXX,根据不同的ZEND_XXX跳去执行对应的 ZEND_XXX_HANDLER。但是前面讲过了,这里的一些opcode的handler已经替换成了 trace_xxx,看这个stack信息,执行已经来到了zend_jit_trace_execute代码在有trace监控的情况下跑上几步,如果JIT的策略生效了,比如满足它认为的hot条件了,那么JIT编译就该上场了。同样,JIT生成的 machine code也是放共享内存的。(所以把JIT整个放opcache扩展里就很合理了)这里要提一个安全的问题。因为JIT生成的代码是直接放内存里的,所以这片内存是可写的。同时因为它是代码所以这片内存又必须是可执行的。如果同时可写又可执行,那就很危险了。正确的做法是,生成代码的时候把内存改成可读可写不可执行,代码生成结束后改成可读可执行。这部分PHP的处理是没问题的。逻辑如下zend_jit_unprotect
jit_compile
zend_jit_protectOK,我们继续。逻辑跑到 zend_jit_trace,这里要真正生成X86代码了。逻辑很复杂,我简单抽取如下:build一个frame,这显然是为执行做准备了。调用dasm_init,这是zend_jit_x86.c里的代码,为代码生成做准备。调用zend_jit_prologue构建函数头,我们知道一个函数往往包含一个prologue和一个epilog。呵呵,熟悉的场景又再现了。然后是一个很壮观的 while 循环(跟前面提到的zend_execute有一拼),遍历了一遍要处理的指令序列,按需调用zend_jit_xxx 为其生成X86指令。调用dasm_link_and_encode函数,封装出最后的可执行X86代码。到这一步,最复杂的部分已经完成了。这部分其实很有难度,为了支持调试,似乎还构建了符号表。截个图纪念一下,这个handler就是历尽千辛万苦生成出来的X86代码。所以看清楚了,zend_jit_trace函数返回来一个handler,这个是生成的X86代码!外层函数呢,替换opcode原本的handler到这个新的handler,就实现了对应opcode的vm执行到本地CPU执行的替换。可以说是举重若轻!同样截图留念(我为了截图效果,折叠了不少代码)文章写到这里PHP JIT的整个工作流基本介绍完了(我也从白天写到晚上了)。但是我们终究要看看这个handler到底如何执行的才过瘾。如下图,左边是用vscode+ssh进行debug时,zend jit自己dump出来的代码,右边是直接用gdb跟踪到handler里面打印的代码,代码是一致的。而这句cmp $0xc350, 0x60(%r14) 其实就是我的PHP测试代码里的 for ($i=0; $i<50000; $i++) 这个循环。这个handler的汇编代码还是很多的,原因有两个。首先,DynASM这种代码生成器,生成的代码是基于模板映射的,质量本身就不够高。其次,生成的汇编代码里面还包含了一些wrapper逻辑。不过话说回来,经过JIT之后代码终究是跑在CPU上的,比跑在VM里还是快了很多。X86 code跑完之后,最终又回到了 zend_jit_trace_exit,重新接受zend的管控。以上,是一个PHP源码编译到opcode,构建和分析CFG,加入trace逻辑,拉起运行,再到触发JIT编译,生成X86代码,替换原来的执行流,本地执行,最后再回到zend虚拟机的过程。很复杂,所以我的分析过程也是一气呵成不能中断。(题图正是我在酒店分析到很晚时候拍的照片)此时再画一个图的话是这样的最后总结下我这篇文章,完整的分析了一遍Zend JIT的代码生成,trace逻辑添加,opcode处理逻辑替换,X86代码执行以及如何再让执行流和控制流回到Zend中。到目前为止还Google 不到这种介绍,所以一定程度上也算是对PHP JIT的技术揭秘,希望大家喜欢。发布于 2020-12-05 19:24编译器即时编译(JIT)PHP 开发​赞同 79​​5 条评论​分享​喜欢​收藏​申请转载​文章被以下专栏收录PHP底层内核深入浅出PHP底层内核