歡迎來到JIT的世界: The Joy of Simple JITs

這個例子展現了簡單的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

Hello, JIT World!

不用多說,讓咱們來實現咱們的第一個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()分配的內存只是一個完整內存頁的一部分。若是你更改了內存頁的權限會影響這個內存頁上的其餘代碼。工具

Hello, DynASM World!

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中的四個字節被解釋爲:

  • 184 – x86平臺上執行mov eax [immediate]指令的第一個字節
  • 237 – DynASM的指令DASM_IMM_D, 表示dasm_put的下一個參數將做爲上面mov指令的第二個參數([immediate])的值,補全mov指令。
  • 195 – x86平臺ret指令
  • 255 – DynASM的字節碼指令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的方法。

A Simple JIT for Brainf*ck

咱們的目標是一個最簡單的圖靈完備的語言,命名爲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寄存器起了一個別名。這點讓咱們能夠先指定寄存器的分配,而後再經過符號來使用相應的寄存器。這裏須要當心一點: 使用PTRrbx的代碼掩蓋了他們是同一個寄存器的事實!在我使用的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這樣的程序,經過少許的代碼完成有趣和有用的事情。對於合適的人,他們也能夠寫不少有趣的東西

英文原文地址:這裏

相關文章
相關標籤/搜索