操作系统是如何支持JIT技术的
JIT 技术简介
所谓 JIT 技术,说的是即时编译技术(英文全称为 Just-in-Time Compilation)。
AI 给出的介绍是这样的:
- 在计算机科学中,JIT 是一种动态编译代码的方法,它在程序执行期间而不是之前进行编译。
- JIT 编译器将源代码或字节码(例如,Java 字节码)编译成本地机器代码,然后直接执行。这种方法结合了编译和解释的优点,既能提高执行速度,又能保持一定的灵活性。
- JIT 编译通常用于优化程序性能,尤其是在动态语言和需要频繁优化的场景下。例如,Java 虚拟机 (JVM) 就使用 JIT 编译器来提高 Java 程序的性能。
总的来说,它能在程序运行的过程中执行一些临时生成的机器码。
JIT 的过程主要分为两个阶段:1. 将源代码或字节码编译成机器码;2. 执行机器码。
其中,第一阶段主要是编译器做的事情,跟属于编译原理那一块的知识,本文暂不关注。我这里想讲第二阶段,介绍这些临时生成的机器码是怎样被执行的、它依赖了操作系统提供的哪些能力。
这个原理确实有点复杂,但用户的代码实现并不需要很复杂,因此我会通过一个小实验来呈现这一过程。
一个普通程序的执行过程
在我们日常生活中,有很多东西都可以被称之为“程序”,比如一段脚本、一个手机 APP、一个网页…
而我这里要讨论是是操作系统视角上的“程序”。对操作系统内核而言,它能直接识别的“程序”只有一种,就是二进制可执行文件。它躺在硬盘中的时候,它是一个二进制可执行文件;当它被内核装载到内存中并开始执行之后,它就是一个进程。
以 Linux 系统为例,它的二进制可执行文件格式叫做 ELF,它的结构是这样子的:

其中,.text 这个段叫做代码段,存储的就是这个程序的机器码。假如你用 C 语言写了一段代码然后把它编译成二进制,那你写的代码逻辑就会被编成机器码然后放在这个段里面。

当系统拉起进程的时候, 系统会根据 ELF 文件中的信息为这个进程分配相应的内存空间,并把相关的段的内容给装载进去,此外还会加上 ELF 文件中没声明的堆空间和栈空间等。
分配之后大概长这样:

当系统要真正开始执行这个程序的时候,它会从 .text 段的某个位置开始执行机器码,然后不停地执行 .text 段中的内容。有时候是顺序执行,有时候是跳转执行,就这么一路执行到程序终止为止。

对一个普通程序而言,哪些机器码可能会被执行,这是在编译的时候就已经定好了的,它所有的机器码都被写在 .text 段里面了。
JIT 技术中机器码的执行过程
但有一种不普通的程序,它能在运行过程中生成一些新的机器码放入它的内存空间中,然后执行这些动态生成的机器码。比如使用了 JIT 技术的程序就会这么做。
这种程序,不仅可以执行 .text 段里面的机器码,它还可以在执行过程中生成一些机器码放入“文件映射与匿名映射区”中,然后将其执行。
为什么要放入这个特殊的段而不是放入更常用的堆空间中呢?因为堆空间是不具备执行权限的,你并不能把函数指针指向堆空间的一个地址然后调用这个函数指针。但“文件映射与匿名映射区”这个段就可以有执行权限,可以这么做。
因此,我们需要用 mmap 这个库函数,从这个段里面划分一段匿名内存出来用于 JIT。

注意,mmap 这个库函数(及其底层的同名 syscall)功能很强大,它有多种使用场景,我们这里只是其中的一种使用场景。
申请到有执行权限的内存之后,我们把机器码放进去,并把一个函数指针指向这个内存地址,这样一来,我们只要调用这个函数指针,就可以执行到这段内存里面的机器码了。

接下来用代码演示一下这个过程。
既然要演示 JIT 执行机器码的过程,那首先得有一串机器码。机器码这种东西纯靠手撕是很难写得出来的,所以我选择用 C 语言写一段代码,让编译器把它编成机器码。
simple_function.c
// 简单的函数,计算1+2并返回结果
int simple_calculation() {
int a = 1;
int b = 2;
return a + b;
}
把它进行编译,查看编出来的机器码是什么样的
# 将C语言代码编成机器码
gcc -c simple_function.c -o simple_function.o
# 查看机器码
objdump -d simple_function.o
查出来的结果是这样的,左边是机器码(以十六进制显示),右边是对应的汇编代码
0000000000000000 <simple_calculation>:
0: f3 0f 1e fa endbr64
4: 55 push %rbp
5: 48 89 e5 mov %rsp,%rbp
8: c7 45 f8 01 00 00 00 movl $0x1,-0x8(%rbp)
f: c7 45 fc 02 00 00 00 movl $0x2,-0x4(%rbp)
16: 8b 55 f8 mov -0x8(%rbp),%edx
19: 8b 45 fc mov -0x4(%rbp),%eax
1c: 01 d0 add %edx,%eax
1e: 5d pop %rbp
1f: c3 ret
现在我们得到了一段可用的机器码,接下来要编写 JIT 的逻辑了。我们需要使用 mmap 申请一段带有执行权限的内存,然后把机器码放进去执行。
jit_executor.c
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
int main() {
// 前面生成出来的机器码
unsigned char code[] = {0xf3, 0x0f, 0x1e, 0xfa, 0x55, 0x48, 0x89, 0xe5,
0xc7, 0x45, 0xf8, 0x01, 0x00, 0x00, 0x00, 0xc7,
0x45, 0xfc, 0x02, 0x00, 0x00, 0x00, 0x8b, 0x55,
0xf8, 0x8b, 0x45, 0xfc, 0x01, 0xd0, 0x5d, 0xc3};
// 分配一块内存,权限为读、写、执行
void *mem = mmap(NULL, sizeof(code), PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) {
perror("mmap");
return 1;
}
// 将机器码复制到分配的内存中
memcpy(mem, code, sizeof(code));
// 将内存地址转换为函数指针并调用
int (*func)() = (int (*)())mem;
int result = func();
// 输出结果
printf("Result: %d\n", result);
// 释放内存
munmap(mem, sizeof(code));
return 0;
}
运行结果如下
root@9066111968ec:~# gcc jit_executor.c -o jit_executor
root@9066111968ec:~# ./jit_executor
Result: 3
root@9066111968ec:~#
JIT 技术执行机器码的时候大致就是这样的一段流程,这个流程不仅是在 Linux 上,在绝大多数 unix-like 系统上都是这么玩的。
顺便一提,操作系统也允许我们把“申请匿名内存”和“加权限”这两个步骤分开来做:我们可以先用 mmap 库函数申请一块没有任何权限内存空间,然后再用 mprotect 库函数将其权限改成 rwx(读、写、执行)。
写成代码就是这样子,它也能实现同样的效果
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>
int main() {
// 前面生成出来的机器码
unsigned char code[] = {0xf3, 0x0f, 0x1e, 0xfa, 0x55, 0x48, 0x89, 0xe5,
0xc7, 0x45, 0xf8, 0x01, 0x00, 0x00, 0x00, 0xc7,
0x45, 0xfc, 0x02, 0x00, 0x00, 0x00, 0x8b, 0x55,
0xf8, 0x8b, 0x45, 0xfc, 0x01, 0xd0, 0x5d, 0xc3};
// 分配一块内存,PROT_NONE 表示没有任何权限
void *mem = mmap(NULL, sizeof(code), PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (mem == MAP_FAILED) {
perror("mmap");
return 1;
}
// 使用 mprotect 将内存区域设置为读、写、执行
if (mprotect(mem, sizeof(code), PROT_READ | PROT_WRITE | PROT_EXEC) == -1) {
perror("mprotect");
munmap(mem, sizeof(code));
return 1;
}
// 将机器码复制到分配的内存中
memcpy(mem, code, sizeof(code));
// 将内存地址转换为函数指针并调用
int (*func)() = (int (*)())mem;
int result = func();
// 输出结果
printf("Result: %d\n", result);
// 释放内存
munmap(mem, sizeof(code));
return 0;
}
JIT 技术带来的安全问题
JIT 技术好用,可以很好地提升解释器的性能,但它也带来了一些安全风险。
JIT 过程中所运行的机器码是来自于外部的(比如从外部脚本转换得来),一旦这个外部来源把控得不好,导致一些恶意代码被加载进来运行,它有可能会越过解释器的一些防护机制,直接危害到系统。因此,一些厂商会在自己的应用或操作系统中对 JIT 做一些限制。
1. 在应用层面做限制的案例
在应用层面,一个典型的例子就是微软的 Edge 浏览器。
Edge 浏览器有一个“增强安全模式”,如果你开了这个模式,它就会禁止浏览器里面的 V8 解释器使用 JIT 技术。
引入这个模式的动机,Edge 团队在博客上是这么解释的:
查看 2019 年之后的 CVE(通用漏洞和暴露)数据,我们发现针对 V8 发布的 CVE 中约有 45% 与 JIT 引擎有关。此外,我们知道攻击者也会利用这些漏洞进行攻击和滥用;Mozilla 的一项分析显示,超过一半的“在野”Chrome 漏洞利用都滥用了 JIT 漏洞
2. 在操作系统层面做限制的案例
在操作系统层面,iOS、macOS、HarmonyOS 等系统也对 JIT 操作有一定限制。
macOS 上面的情况是这样的:
- 在使能了 Hardened Runtime 的情况下(沙盒化应用),一个程序必须申请特定的 entitlement 才能正常使用 JIT 功能(详见这里)。这种情况下,对程序的代码逻辑没要求,无论是直接 mmap 申请 rwx 内存还是先申请无权限内存再用 mprotect 去改,都没问题,只要申请了所需的 entitlement 就能跑通。
- 在未使能 Hardened Runtime 的情况下(非沙盒命令行),不要求一定要申请 entitlement,但要求程序的代码逻辑按照指定的规则编写。程序的代码里面需要先通过 mmap 申请一段带 MAP_JIT 标识的无执行权限的内存,再通过 mprotect 修改成 rwx,才能正常将这段内存用于 JIT 操作。
在 HarmonyOS 上则是这样的:
- 如果你的程序形态是一个鸿蒙 APP,且里面涉及到了 JIT 操作,它就必须要申请这个权限:ohos.permission.kernel.ALLOW_WRITABLE_CODE_MEMORY。并且这个权限被厂商卡得很严,很难申请。在上架应用市场的时候,厂商会重点审视这个权限,如果没有足够重要的理由,一般不会允许带这个权限的应用上架到应用市场。
- 如果你的程序形态不是一个鸿蒙 APP,而是一个二进制(例如 Node.js 这样的),并且以 hnp 包的形态存在,那么它所需的 JIT 权限由拉起它的 APP 来提供,比如 CodeArts IDE 就拥有 JIT 权限,所以我们可以正常在 CodeArts IDE 里面执行 node 命令。
- 如果你的程序形态不是一个鸿蒙 APP,而是一个二进制(例如 Node.js 这样的),并且以自签名二进制的状态存在,而非 hnp 的状态存在,那么它不具备 JIT 权限,我们也没有任何措施能赋予它权限。这个限制在目前最新的 HarmonyOS 6.0 上是无解的。
更多推荐



所有评论(0)