1. 程式人生 > >用 JavaScript 寫一個 X86 模擬器

用 JavaScript 寫一個 X86 模擬器

來龍去脈

像一些沒有計算機背景的人一樣,我總是想要正確地瞭解底層是如何工作的,並在上面花費了大量的精力。

在學習過程中,我得到《從頭開始學習程式設計》這本書,但一直都沒有讀,直到在一次 飛往巴西的 11 個小時航班上,我才去閱讀它,看上去它是一個不錯的開始。

我很喜歡這本書,但是其中的例子都是用 Linux x86 GNU 彙編寫的,而我卻在一臺 64 位 Max OS X上……我掙扎了一小下想要搞清楚在 i386 和 x86_64上,彙編器和聯結器、標記和語法都有什麼不同,但沒有網路還是沒有搞定……

「原來在 i386 上系統呼叫 exit 函式的返回值是被壓棧了,而不是放在 %ebx 暫存器裡;x86_64 上,系統呼叫的編號以 0x2000000 作為偏移;暫存器不同和你必須使用 syscall ,而不是 int $0x80…… 如果你剛開始接觸彙編,所有這些都相當不容易。」

我嘗試把它放到一邊,卻忍不住一直在想它,所以我開始寫了一個很假的 x86 解析器,在電池耗盡前,我有足夠時間做這些事情(飛行中不能上網是在太慘了)。

數個月後在切爾西球場,Facebook 舉辦了一場黑客馬拉松。我想實現一個 x86 模擬器一定很酷,而且可以搞懂一個二進位制是如何執行的。

說服人們參與這個專案並不容易,我花了一些時間證明我並沒有瘋掉,我很清楚編寫一個正確的模擬器有多難,我只想要編寫一個非常簡單的玩一玩。但最後 Uri Baghin 答應入夥。

我們的目標

我們最初的目標是執行最簡單的 x86 程式:退出且程式碼是 0:

Shell
123456789 # program.s.section__TEXT,__text.globlstartstart:mov$0x1,%eaxpush$0x0call _syscall_syscall:int$0x80

在 Mac OS X上,上面程式碼上是這樣編譯的:

Shell
12 $as-static-arch i386-oprogram.oprogram.s$ld-static-arch i386-oprogram program.o

為了驗證它,你可以用如下命令執行它並且檢查退出程式碼:

Shell
12 $./program$echo$?

所以我們決定把這個問題一分為二:找到二進位制檔案中實際的彙編指令和執行它們。

Mach-O 二進位制格式

二進位制檔案中不僅包含彙編程式碼,還包含佈局資訊、支援的體系結構、它們應該如何被載入到記憶體和二進位制中存在的符號資訊。

注:我推薦使用 MachOView ,可以把二進位制的佈局更好地視覺化,在研究二進位制檔案的時候相當有用。

為了找到二進位制檔案裡面的彙編程式碼,我們需要閱讀一些載入的命令,特別是 LC_SEGMENT 命令,它們有如何把二進位制的段對映到虛擬記憶體的資訊。

LC_SEGMENT 載入命令有這些欄位:

  • 命令(這個例子就是 LC_SEGMENT)
  • 命令的大小
  • 段的名稱 (比如:__TEXT)
  • 虛擬記憶體地址 (段應該被複制到虛擬記憶體的位置)
  • 虛擬記憶體大小(段使用的虛擬記憶體大小)
  • 檔案偏移 (段在二進位制檔案的位置)
  • 檔案大小(段在二進位制檔案中的大小)
  • 最大虛擬記憶體保護(虛擬記憶體允許的最大保護級別)
  • 最初的虛擬記憶體保護(虛擬記憶體的初始保護級別)
  • 節的數目(段裡節的數目)
  • 標記(可以在 mach-o/loader.h 中 segment_command 結構後面找到可能的標記)

我們現在可以忽略虛擬記憶體保護和標記,只要把從檔案偏移到檔案偏移加上檔案大小這部分內容複製到虛擬記憶體的相應區域,即從虛擬記憶體地址到虛擬記憶體地址加上虛擬記憶體大小的這段區域。

接下來我們需要 LC_UNIXTHREAD 載入命令。它告訴我們這個程式中主執行緒的最初執行狀態。

這裡重要的一點是 %eip 的初始值(指令指標),它告訴第一個程式指令在虛擬記憶體中的位置,也就是真實的程式碼從哪裡開始(注意:在同樣的示例程式碼中,我們使用一個叫做 PC 的全域性變數而不是模擬一個暫存器)。

二進位制的執行

好了,現在我們已經把程式程式碼對映到虛擬記憶體中,有一個指標指向程式碼開始的位置,我們只需要執行它。有件事讓這更加有趣,就是 x86 指令的長度是不同的,我們必須要先閱讀操作碼(opcode)搞清楚這條指令會佔用多少位元組。

為了保持簡單,我用 objdump 反編譯了這個生成的二進位制檔案,  objdump 命令列介面(CLI,Command Line Interface)通過安裝 binutils 包就可以得到。它看上去像這樣:

Shell
123456789101112 $brew install binutils# if you don't have it installed yet$gobjdump-dprogramDisassembly of section.text:00001ff2<start>:1ff2:b801000000mov$0x1,%eax1ff7:6a00push$0x01ff9:e800000000call1ffe<_syscall>00001ffe<_syscall>:1ffe:cd80int$0x80

因為我不準備實現完整功能的模擬器,我只是查看了一些必要操作碼的語法,有一個不錯的 x86 參考資料可以在 http://ref.x86asm.net/coder.html 上找到。

我建立了一個操作碼到函式的對映表(用簡單的 JavaScript 物件實現的)。如果操作碼有需要,函式會負責讀取更多的資料,例如:

JavaScript
12345 varFunctions={};Functions[0x6a]=function(){// Push one bytepush(read(1));};

如果操作碼實際上是操作碼的字首,那我們必須要讀取下一個位元組,這才是真正的操作碼:

JavaScript
12345678910111213141516 varFn0x0f={// jge0x8d:function(){vardist=read(4);if(!NG){PC+=dist;}},// ...};Functions[0x0f]=function(){// Call the actual function inside the prefixvarfn=read(1);Fn0x0f[fn]();};

我們最後要處理的就是系統呼叫,我們必須要真實地模擬它,因為我們有另外一個對映表把系統呼叫的編號對映到函式上:

JavaScript
123456 varSyscalls={};Syscalls[0x01]=function(){// Fake exit, since there's no OSconsole.log('Program returned %s',Stack[Registers[ESP+1]]);PC=-1;// Mark the program as ended by setting the program counter to -1};

為了載入二進位制,我們需要使用 File API。有一個 html 頁面作為入口點,把二進位制檔案作為一個單獨的輸入,輸出會顯示在控制檯上。

示例程式碼

這次黑客馬拉松的最終程式碼可以在這個 gist 上找到,包含 3 個檔案:載入程式(mach-o.js)、虛擬碼邏輯(x86.js)和 作為入口點的 index.html。程式碼可以執行基本的彙編和 C 程式。(小編注:考慮到有些朋友需要梯子,我把示例程式碼都補在本文末尾)

注意:不能執行 libc,所以要使用 -static -nostdlib 來編譯 C 程式並提供一個客製化的裝載引導。

請記住這些程式碼相當簡單,它只是在一場黑客活動中編寫的,所以在可讀性上沒有花費很多時間(關注重要的事情)而且我們後邊也不會在上面迭代了。

執行用 clang -O3 引數編譯 C 編寫的 fibonacci(40) 程式,執行時間只花費了不到 9 分 47 秒。

編輯:閱讀了 HackerNews 上的這個評論後,我用 Unit8Array 替換了 DataView,現在 fibonacci(40)耗時已經降到 1 分53 秒,比 Perl 和 ruby 1.8 都要快,比較結果看這裡 :)

==========

作為入口點的 index.html

XHTML
1234567891011 <!DOCTYPE html><html><head><title></title>  <script type="text/javascript"src="x86.js"></script>  <script type="text/javascript"src="mach-o.js"></script></head><body><input id="binary"type="file"onchange="loadBinary(this.files[0])"></body></html>

載入程式(mach-o.js)

JavaScript
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394 'use strict';varLC_SEGMENT=0x00000001;varLC_UNIXTHREAD=0x00000005;varMemory=newArrayBuffer(1048576);varRegisters=newUint32Array(8);varPC=-1;functionloadBinary(file){varreader=newFileReader();reader.addEventListener("loadend",function(){readBinary(newUint32Array(reader.result));});reader.readAsArrayBuffer(file);}functionreadBinary(buffer){varheader=buffer.subarray(0,7);if(header[0]!==0xfeedface){thrownewError("Invalid Mach magic");}// var cpuType = header[1];// var cpuSubtype = header[2];// var fileType = header[3];varnumCommands=header[4];// var commandsSize = header[5];// var flags = header[6];varcurrentCommandStart=7;for(vari=0;i<numCommands;i++){varcommandSize=buffer[currentCommandStart+1];varcommandEnd=currentCommandStart+commandSize/4;varcommandBuffer=buffer.subarray(currentCommandStart,commandEnd);handleCommand(buffer,commandBuffer);currentCommandStart=commandEnd;};if(PC!==-1){run(Memory,Registers,PC);}PC=-1;}functionhandleCommand(buffer,commandBuffer){varcommand=commandBuffer[0];switch(command){case