JIT 的定义
JIT 是 英文"Just In Time"的缩写,字面上的解释, 仿佛和程序没有太多关联。首先, 我们来给 JIT 下一个定义, 下面一段话非常贴切:
Whenever a program, while running, creates and runs some new executable code which was not part of the program when it was stored on disk, it’s a JIT.
历史上,术语JIT表达过哪些意义呢?幸运的是,卡尔加里大学的 John Aycock 写过一篇关于JIT技术历史的名为 "A Brief History of Just-In-Time"(可以 google 到这篇 PDF 文档)。根据 Aycock 的观点,历史上第一次明确提出在程序执行中生成代码并执行的概念的是麦卡锡(McCarthy's) 上世纪60 年代发表的关于 LISP 的论文。进一步, 汤姆森(Thompson's) 于1968 年关于正则表达式 的论文, 更加明确的提出正则表达式在执行时生成机器码。
詹姆斯高斯林(James Gosling)第一次在关于Java的技术文献中引入JIT,Aycock 认为 James Gosling 于 1990 年从制造领域中引入术语 JIT.
关于JIT的历史介绍点到此为止,如果你想了解更多的详细资料, 可以参考 Aycock 的文献。接下来, 我们将探讨上述定义在实际应用中的意义。
JIT 分成两个不同的阶段:
.阶段1: 在程序执行时生成机器码
.阶段2: 然后执行之
JIT 百分之99的工作在阶段1, 而这也是编译器所做的事情. 著名的编译器gcc 和 clang 转换 c/c++ 源码为机器码,机器码一般会输出到文件, 也有可能会在保持在内存中。 阶段2是本文要讨论的话题.
运行动态生成的机器码
现代操作系统对程序运行时的行为有严格的限制。随着CPU执行从早期的实模式演进到现在的保护模式,操作系统对虚拟内存段设置有不同的权限。在正常情况下,程序可以动态的从堆中申请内存并读写数据,但是不能运行在堆内存中的指令,除非向操作系统申请。
在这里需要指出,指令也是数数据。一个字节流,比如,
unsigned char[] code = {0x48, 0x89, 0xf8};
对它的解释取决于观察者的视角。可以将它视为表示任意事物的数据,也可以看做合法的X86_64机器码的二进制编码,
mov %rdi, %rax
所以,将生成的机器码保存在内存是简单的,但是如何使它运行起来呢?
一些例子
本文接下来包含若干POSIX兼容操作系统的示例代码(特别是linux),其他系统像Windows的代码细节上可能会有所不同,不过思想是一样的。现代的操作系统都有方便的API来做同样的事情。
言归正传,我们动态创建内存中的一个函数并且执行它,该函数应该比较简单,实现C代码如下,
long add4(long num) { return num + 4; }
第一步的尝试,
#include#include #include #include // Allocates RWX memory of given size and returns a pointer to it. On failure, // prints out the error and returns NULL. void* alloc_executable_memory(size_t size) { void* ptr = mmap(0, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr == (void*)-1) { perror("mmap"); return NULL; } return ptr; } void emit_code_into_memory(unsigned char* m) { unsigned char code[] = { 0x48, 0x89, 0xf8, // mov %rdi, %rax 0x48, 0x83, 0xc0, 0x04, // add $4, %rax 0xc3 // ret }; memcpy(m, code, sizeof(code)); } const size_t SIZE = 1024; typedef long (*JittedFunc)(long); // Allocates RWX memory directly. void run_from_rwx() { void* m = alloc_executable_memory(SIZE); emit_code_into_memory(m); JittedFunc func = m; int result = func(2); printf("result = %d\n", result); }
上述的代码主要分为三步:
1. 使用mmap分配可读,可写,可执行的内存块。
2. 将函数add4的机器码复制到分配的内存中。
3. 将该内存转换为函数指针,然后调用之。
请注意,第三步只有在包含有机器码的内存块有可执行权限是才能发生。如果没有设置合适的权限,该调用会导致一个从操作系统返回的运行错误(很可能是段错误),比如当我们使用常规的malloc分配可读可写但不可执行的内存段给m变量时发生。
关于heap, malloc和mmap 的题外话
细心的读者会注意到我前面没说透的话,称mmap分配内存为堆。严格的讲,堆是指代malloc,free等运行时函数使用的内存,与之相对的是编译器隐式分配的栈。
传统上malloc只使用sbrk系统调用来分配内存,现在大多数情况下malloc的实现使用mmap。不同的操作系统之间实现细节会有所不同,通常来说,mmap用来分配大块内存而sbrk分配小块内存,这是一个为了高效在两个内存分配调用之间所做的折中。
因此称mmap分配的内存为堆并不算一个错误,依本人拙见,也是我接下来要谈的。
更多的安全考虑
上面示例的代码有一个问题,它有安全漏洞。原因是RWX(可读可写可执行)的内存块,是易受攻击和利用的天堂。我们对它做一些改进,下面是稍作修改的源码,
// Allocates RW memory of given size and returns a pointer to it. On failure, // prints out the error and returns NULL. Unlike malloc, the memory is allocated // on a page boundary so it's suitable for calling mprotect. void* alloc_writable_memory(size_t size) { void* ptr = mmap(0, size, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (ptr == (void*)-1) { perror("mmap"); return NULL; } return ptr; } // Sets a RX permission on the given memory, which must be page-aligned. Returns // 0 on success. On failure, prints out the error and returns -1. int make_memory_executable(void* m, size_t size) { if (mprotect(m, size, PROT_READ | PROT_EXEC) == -1) { perror("mprotect"); return -1; } return 0; } // Allocates RW memory, emits the code into it and sets it to RX before // executing. void emit_to_rw_run_from_rx() { void* m = alloc_writable_memory(SIZE); emit_code_into_memory(m); make_memory_executable(m, SIZE); JittedFunc func = m; int result = func(2); printf("result = %d\n", result); }
它和前面的代码片段是等效的除了一点,内存分配的时候赋予是RW权限,就像普通的malloc一样。在实际中,我们将机器码写入内存,执行之前使用mprotect将内存块的权限从RW修改RX,可执行但不能写入。这和前面的代码一样,但是程序运行中所在的内存段不允许同时可执行可写。
malloc的情形
那么我们可以在上面的代码片段中使用malloc代替mmap来分配内存吗,毕竟,malloc提供了RW权限。实际上,malloc带来的麻烦胜过它的便利,这是因为内存的权限保护位只能在虚拟内存页的边界设定。因此,使用malloc还要确保分配的内存是页对齐的,否则mprotect很可能因为执行设置比预期要多的内存权限失败。而mmap已经仔细的替我们将分配的内存边界按照页对齐了。
结束
本文开始给出了JIT一个比较高的全局视角,结束的时候用一些代码片段示例了动态生成机器码并写入内存然后执行之。
在这里展现了真实环境下JIT引擎(LLVM, libjit)在内存中生成并执行机器码的情形。LLVM有一个全套的编译器,它能够在运行时将C/C++源码转换为机器码,然后执行。在阶段2,实现相对比较明确,使用了OS良好定义的APIS。而对于阶段1,可能性是没有止境的,这依赖于你所做的应用和要求。