这个例子展现了简单的JIT(即时编译器)能够多么简单和有趣。JIT这个词让人联想到高深的魔法,只有顶尖的编译器团队才会想到使用。你可能会想到JVM或者.NET这样有数十万行代码的庞大的运行时库。你看不到像"Hello, World!"那样的JIT, 经过简短的代码作些有趣的事情。这篇文章尝试改变这个现状。html
一个JIT和一个调用printf
的程序没有本质的区别,只是JIT产生的是机器代码,而不是像"Hello, World!"这样的消息。确实,像JVM这样的JIT是及其复杂的怪兽,但这是由于他们实施了一个复杂的平台并作了积极的优化。若是作的事情很简单,咱们的程序一样能够很简单。git
实现一个简单的JIT最困难的部分是编写你的目标CPU能够理解的指令。例如在x86-64平台,push rbp
这个指令被编码成0x55
。这样的编码是使人厌烦的,还须要阅读不少CPU手册,因此咱们将跳过这个部分。咱们将使用Mile Pall开发的一个工具DynASM
来完成这个工做。DynASM采用了一个新颖的方式, 让你能够在JIT中混合使用汇编代码和C代码,从而能够用一个很是天然和可读的方式实现JIT。它支持不少CPU架构(如x86, x86-64, PowerPC, MIPS和ARM),因此你不会由于它对硬件的支持而受到限制。DynASM也格外小巧, 其整个运行时库都包含在500行的头文件中。程序员
我应该简要地澄清一下个人术语。我将任何在运行时生成机器代码并执行这些机器代码的程序成为"JIT"。一些做者会在特定的地方使用这个词,认为只有根据须要生成小段机器码的解释器/编译器才叫作JIT。这些人会更宽泛的将运行时生成代码的技术成为动态编译。可是"JIT"是更常见和接受的术语,一般用于不符合 "JIT"最严格定义的地方,如Berkeley Packet Filter JIT。github
不用多说,让咱们来实现咱们的第一个JIT。这个和全部其余的程序都在个人Github仓库jitdemo。代码是Unix风格的,由于咱们使用mmap()
,也须要生成x86-64平台的代码,因此你须要一个支持该平台的处理器和操做系统。我已经测试过它能够在Ubuntu Linux和Mac OS X上使用。编程
在第一个例子中,咱们甚至不须要使用DynASM以保证它足够简单。这个程序在文件jit1.c中。数组
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/mman.h> int main(int argc, char *argv[]) { // Machine code for: // mov eax, 0 // ret unsigned char code[] = {0xb8, 0x00, 0x00, 0x00, 0x00, 0xc3}; if (argc < 2) { fprintf(stderr, "Usage: jit1 <integer>\n"); return 1; } // Overwrite immediate value "0" in the instruction // with the user's value. This will make our code: // mov eax, <user's value> // ret int num = atoi(argv[1]); memcpy(&code[1], &num, 4); // Allocate writable/executable memory. // Note: real programs should not map memory both writable // and executable because it is a security risk. void *mem = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC, MAP_ANON | MAP_PRIVATE, -1, 0); memcpy(mem, code, sizeof(code)); // The function will return the user's value. int (*func)() = mem; return func(); }
或许难以置信,这个33行的程序确实是一个JIT。它动态地生成了一个返回运行时指定的整数值的函数,而后运行它。你能够验证它可以工做。架构
$ ./jit1 42 ; echo $? 42
你应该会注意到我使用mmap()
来分配内存,而不像一般的作法使用malloc()
从堆上获取。这是必须的,由于我须要让得到的内存能够被执行,这样我就能够跳转到这里而不引发程序的崩溃。在大部分系统上栈和堆被配置成不能够执行,由于跳转到栈或堆上意味着有严重的错误发生。更糟糕的是,可执行的栈让hacker更容易利用缓冲区溢出漏洞。所以咱们经过须要避免映射便可写又可执行的内存,你本身的程序也最好遵照这个习惯。这里为了让咱们的第一个程序保持简单,我打破了上面的规则。app
我也没有释放我分配的内存,咱们将尽快解决这个问题。mmap()
有一个相应的函数munmap()
,咱们可使用它释放内存到操做系统。函数
你或许会疑惑为何不调用一个函数更改你经过malloc()
得到的内存的权限。经过彻底不一样的方式得到可执行的内存听起来想是 累赘。其实有一个函数能够更改你得到的内存的权限,叫作mprotect()
。可是内存的权限只之内存页为单位生效,而malloc()
分配的内存只是一个完整内存页的一部分。若是你更改了内存页的权限会影响这个内存页上的其余代码。工具
DynASM是LuaJIT项目中的一部分,但彻底独立于LuaJIT代码,能够单独使用。它由两部分组成:一个预处理器将混合的C /汇编文件(* .dasc)转换为C代码,和一个运行时库来执行必须在运行时执行的工做。
这个设计很棒,解析汇编语言和编码机器指令的复杂代码能够用高级的带有垃圾回收机制的语言(Lua)来编写,并且只在编译时须要,运行时不依赖Lua。大多数DynASM能够用Lua编写,而运行时又不须要依赖于Lua。
做为咱们的第一个DynASM的例子,我写了一个程序生成和上一个例子中一样功能的函数。咱们能够比较两种方式的差别,理解DynASM
为咱们带来了什么。
// DynASM directives. |.arch x64 |.actionlist actions // This define affects "|" DynASM lines. "Dst" must // resolve to a dasm_State** that points to a dasm_State*. #define Dst &state int main(int argc, char *argv[]) { if (argc < 2) { fprintf(stderr, "Usage: jit1 <integer>\n"); return 1; } int num = atoi(argv[1]); dasm_State *state; initjit(&state, actions); // Generate the code. Each line appends to a buffer in // "state", but the code in this buffer is not fully linked // yet because labels can be referenced before they are // defined. // // The run-time value of C variable "num" is substituted // into the immediate value of the instruction. | mov eax, num | ret // Link the code and write it to executable memory. int (*fptr)() = jitcode(&state); // Call the JIT-ted function. int ret = fptr(); assert(num == ret); // Free the machine code. free_jitcode(fptr); return ret; }
这个不是程序的所有内容,在dynasm-driver.c
中定义了初始化DynASM和分配/释放可执行内存的一些辅助功能。这些公共的辅助代码在咱们全部的例子中都是相同的,因此咱们这里省略它。在仓库中它们很直观,也很容易理解。
须要注意的最主要的区别是咱们生成指令的方式。像汇编语言的.S
文件相似,咱们的.dasc
文件包含了汇编语言。以(|
)开头的地方由DynASM来翻译,能够包含汇编指令。相比咱们第一个例子这是一个巨大的进步。特别要注意的是,mov
指令的一个参数引用的是C中的一个变量,DynASM在生成指令时知道如何将参数替换成这个变量。
为了弄清这是如何实现的,咱们看下预处理器生成的jit2.h
(从jit2.dasc生成)。我摘录了有趣的部分,文件的其他部分没有修改。
//|.arch x64 //|.actionlist actions static const unsigned char actions[4] = { 184,237,195,255 }; // [...] //| mov eax, num //| ret dasm_put(Dst, 0, num);
这里咱们看到咱们在.dasc
文件(如今已注释掉)中写入的源代码行以及由它们生成的行。 “action list”是由DynASM预处理器生成的数据缓冲区,它是由DynASM运行时解释的字节码,其中掺杂了你的汇编语言指令的编码和DynASM运行时连接代码、插入运行时参数的方法。 在这种状况下,咱们的actions中的四个字节被解释为:
mov eax [immediate]
指令的第一个字节DASM_IMM_D
, 表示dasm_put
的下一个参数将做为上面mov指令的第二个参数([immediate])的值,补全mov
指令。ret
指令DASM_STOP
,表示编码停止。而后actions
会被实际生成汇编指令的代码引用。以|
开头的指令行会被dasm_pus()
函数替换,dasm_put()
提供了在actions
数组中的偏移和运行时数据到输出中。dasm_put()
会把这些指令(和运行时数据如num)追加到Dst &state
的缓冲区中。如这里的dasm_put(Dst, 0, num)
,Dst
表示state
地址,0
表示在actions
中的偏移,num
被做为mov eax [immediate]
的第二个参数。
咱们最终获得了与第一个示例彻底相同的效果,但此次咱们使用了一种方法,可让咱们利用符号来编写汇编语言。这是一种更好的编程JIT的方法。
咱们的目标是一个最简单的图灵完备的语言,命名为Branf*ck(简称BF)。BF仅用8个指令实现图灵完备(甚至包括I/O)。这些指令能够被认为是另外一种格式的字节码。
没有比咱们最后一个例子更复杂的了,咱们将有一个不到100行代码实现的全功能的JIT(不包括公共的dynasm-driers.c的不到70行)。
#include <stdint.h> |.arch x64 |.actionlist actions | |// Use rbx as our cell pointer. |// Since rbx is a callee-save register, it will be preserved |// across our calls to getchar and putchar. |.define PTR, rbx | |// Macro for calling a function. |// In cases where our target is <=2**32 away we can use |// | call &addr |// But since we don't know if it will be, we use this safe |// sequence instead. |.macro callp, addr | mov64 rax, (uintptr_t)addr | call rax |.endmacro #define Dst &state #define MAX_NESTING 256 void err(const char *msg) { fprintf(stderr, "%s\n", msg); exit(1); } int main(int argc, char *argv[]) { if (argc < 2) err("Usage: jit3 <bf program>"); dasm_State *state; initjit(&state, actions); unsigned int maxpc = 0; int pcstack[MAX_NESTING]; int *top = pcstack, *limit = pcstack + MAX_NESTING; // Function prologue. | push PTR | mov PTR, rdi for (char *p = argv[1]; *p; p++) { switch (*p) { case '>': | inc PTR break; case '<': | dec PTR break; case '+': | inc byte [PTR] break; case '-': | dec byte [PTR] break; case '.': | movzx edi, byte [PTR] | callp putchar break; case ',': | callp getchar | mov byte [PTR], al break; case '[': if (top == limit) err("Nesting too deep."); // Each loop gets two pclabels: at the beginning and end. // We store pclabel offsets in a stack to link the loop // begin and end together. maxpc += 2; *top++ = maxpc; dasm_growpc(&state, maxpc); | cmp byte [PTR], 0 | je =>(maxpc-2) |=>(maxpc-1): break; case ']': if (top == pcstack) err("Unmatched ']'"); top--; | cmp byte [PTR], 0 | jne =>(*top-1) |=>(*top-2): break; } } // Function epilogue. | pop PTR | ret void (*fptr)(char*) = jitcode(&state); char *mem = calloc(30000, 1); fptr(mem); free(mem); free_jitcode(fptr); return 0; }
在这个程序中咱们确实看到dynasm使人眼前一亮的作法。咱们能够混合使用C和汇编实现一个漂亮的、可读的代码生成器。
比较一下前面提到的Berkeley Packet Filter JIT的代码,它的代码生成有相似的结构(一个巨大的switch()
语句,case中使用字节码),可是没有DynASM,代码必须手动去编码指令。包含的符号化的指令只是用做注释,读者只能假定它是正确的。在Linux内核中的arch/x86/net/bpf_jit_comp.c。
switch (filter[i].code) { case BPF_S_ALU_ADD_X: /* A += X; */ seen |= SEEN_XREG; EMIT2(0x01, 0xd8); /* add %ebx,%eax */ break; case BPF_S_ALU_ADD_K: /* A += K; */ if (!K) break; if (is_imm8(K)) EMIT3(0x83, 0xc0, K); /* add imm8,%eax */ else EMIT1_off32(0x05, K); /* add imm32,%eax */ break; case BPF_S_ALU_SUB_X: /* A -= X; */ seen |= SEEN_XREG; EMIT2(0x29, 0xd8); /* sub %ebx,%eax */ break;
这个JIT看起来经过使用DynASM受益不少,但这也有额外的影响。如构建时对Lua的依赖,这对于LInux来讲是没法接受的。若是预处理后的DynASM文件提交到Linux的git仓库中,将能够避免对Lua的依赖,除非JIT被修改了,但这也许仍是超过了Linux的构建系统的标准。
关于咱们的BF JIT有些事情须要解释下,由于相比以前的例子使用了DynASM更多的特性。首先,你会注意到咱们使用了一个.define
指令为rbx
寄存器起了一个别名。这点让咱们能够先指定寄存器的分配,而后再经过符号来使用相应的寄存器。这里须要当心一点: 使用PTR
和rbx
的代码掩盖了他们是同一个寄存器的事实!在我使用的JIT中至少遇到了一次这样棘手的bug。
其次,我使用.macro
定义一个DynASM的宏,一个宏表明DynASM中的一行或多行,使用这个宏的地方会被相应的代码替换。
这里使用的最后一个新特性是pclabels
,DynASM支持三种不一样的标记能够用来做为分支目标。pclabel最灵活,咱们能够在运行时修改它。每一个pclabel用一个无符号整数标记,用来定义标记和跳转到这里。每一个label必须在[0, maxpc)范围内,可是咱们能够调用dasm_groupc()
来增大maxpc。DynASM将pclabels存储在动态数组中,咱们没必要担忧增长太频繁,由于它的大小是以指数方式扩充的。DynASM中的pclables
经过=>labelnum
的方式来定义和引用,labelnum能够是任意的C语言表达式。
我本但愿再提供一个示例: ICFP 2016的JIT,它描述了一个叫作Universal Machine
的虚拟机规范,是由程序员虚构的称为“The Cult of the Bound Variable”的社会使用。这个问题引发了我对虚拟机方面的兴趣,是一个很是有趣的问题,我很是但愿有一天能为它编写一个JIT。
不幸的是,我在这篇文章上面花费了太多的时间,而且遇到了一些障碍。这也将是一个很是复杂的挑战,由于这个虚拟机容许自我修改。BF很容易,由于代码和数据是分开的,不容许执行时修改程序。若是容许自我修改的代码,你须要在有变动时从新生成JIT代码,将新的代码插入到现有的代码序列中将特别困难。确实有办法作到这一点,但这更加复杂,须要另外一篇单独的博客。
因此今天我不会给你一个Universal Machine
的JIT,你已经能够查看使用DynASM的实现。它是用于32位的x86平台上(而不是x86-64),README里也介绍了一些额外的限制,但它能够告诉你自我修改的代码的问题和难处 。
还有更多的DynASM的特性我没有介绍。其中之一就是typemaps
,它可让你使用符号来计算结构体成员的实际地址(如你在寄存器中有一个结构体timeval
的指针,你能够经过TIMEVAL->tv->usec
来计算成员tv_usec
的有效地址)。这让你在汇编中操做C语言的结构体更加简单。
DynASM是一个美丽的做品,可是没有太多的文档–
你必须神机妙算,经过例子来学习。我但愿这篇文章能够下降学习曲线,同时代表JIT也能够有Hello World
这样的程序,经过少许的代码完成有趣和有用的事情。对于合适的人,他们也能够写不少有趣的东西
英文原文地址:这里