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請求更多內存的兩種方法的相對效率。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章