JIT 简易原理

转载:https://zhuanlan.zhihu.com/p/64713140

 

JIT是“Just In Time”的首字母缩写。每当一个程序在运行时创建并运行一些新的可执行代码,而这些代码在存储于磁盘上时不属于该程序的一部分,它就是一个JIT。

我认为JIT技术在分为两个不同的阶段时更容易解释:

阶段1:在程序运行时创建机器代码。

阶段2:在程序运行时也执行该机器代码。

第1阶段是JITing 99%的挑战所在,但它也是这个过程中不那么神秘的部分,因为这正是编译器所做的。众所周知的编译器,如gcc和clang,将C/C++源代码转换为机器代码。机器代码被发送到输出流中,但它很可能只保存在内存中(实际上,gcc和clang / llvm都有构建块用于将代码保存在内存中以便执行JIT)。第2阶段是我想在本文中关注的内容。

运行动态生成的代码

现代操作系统对于允许程序在运行时执行的操作可以说是非常挑剔。过去“海阔凭鱼跃,天高任鸟飞”的日子随着保护模式的出现而不复存在,保护模式允许操作系统以各种权限对虚拟内存块的使用做出限制。因此,在“普通”代码中,你可以在堆上动态创建新数据,但是你不能在没有操作系统明确允许的情况下从堆中运行其内容。

在这一点上,我希望机器代码只是数据 - 一个字节流,比如:

unsigned char[] code = {0x48, 0x89, 0xf8};

不同的人会有不同的视角,对某些人而言,0x48, 0x89, 0xf8只是一些可以代表任何事物的数据。 对于其他人来说,它是真实有效的机器代码的二进制编码,其对应的x86-64汇编代码如下:

mov %rdi, %rax

将机器代码放入内存是容易的,但是如何让它获得可执行权限,然后运行它呢?

首先我们创建一个函数:

long add4(long num) {
  return num + 4;
}

然后在内存中动态地执行它:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>

// 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);
}

此代码执行的主要3个步骤是:

  1. 使用mmap在堆上分配可读,可写和可执行的内存块。
  2. 将实现add4函数的汇编/机器代码复制到此内存块中。
  3. 将该内存块首地址转换为函数指针,并通过调用这一函数指针来执行此内存块中的代码。

请注意,步骤3能发生是因为包含机器代码的内存块是可执行的,如果没有设置正确的权限,该调用将导致OS的运行时错误(很可能是segmentation fault)。如果我们通过对malloc的常规调用来分配内存块,则会发生这种情况,malloc分配可读写但不可执行的内存。而通过mmap来分配内存块,则可以自行设置该内存块的属性【1】。

安全问题

上面显示的代码其实有一个安全漏洞,那就是它所分配的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权限,因为我们需要将函数的机器代码写入该内存块。然后我们使用mprotect将块的权限从RW更改为RX,使其可执行但不再可写,所以最终效果是一样的,但是在我们的程序执行过程中,没有任何一个时间点,该内存块是同时可写的和可执行的。

本文介绍的这种技术几乎是真正的JIT引擎(例如LLVM和libjit)从内存中发出和运行可执行机器代码的方式,剩下的只是从其他东西合成机器代码的问题。LLVM有一个完整的编译器,所以它实际上可以在运行时将C和C ++代码(通过LLVM IR)转换为机器码,然后执行它。

注【1】:传统上(即很久以前)malloc使用sbrk系统调用,但是现在大多数malloc的实现在很多情况下使用的是mmap,通常mmap用于大块内存,sbrk用于小块内存,这取决于从OS请求更多内存的两种方法的相对效率。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值