大家好,我是微微笑的蝸牛,??。
這是虛擬機系列的最后一篇文章,終于要寫完了??。前五篇文章可點擊下方鏈接進行查看。
今天主要介紹 LC-3 匯編格式、匯編器、如何加載可執行文件。
匯編示例
我們先來看一段 LC-3 下實際的匯編代碼:
.ORIG x3000
LEA R0, HELLO_STR
PUTs
HALT
HELLO_STR .STRINGZ "Hello World!"
.END
一眼看上去,是不是跟我們之前介紹的指令類似?實際上,每種指令有著它自己的助記符,大部分指令的助記符就是它本身。
比如 LEA 指令,對應助記符 LEA。HALT 原本是 TRAP 指令中定義的一個系統調用,這里給它設定了單獨的助記符 HALT。PUTs 也是。
固定格式
它有著固定格式,開頭 .ORIG
和結尾 .END
。
.ORIG
后面跟的地址 0x3000
,指定了程序的裝載地址,表示程序加載進內存后,會放到 0x3000 起始地址處。但這個地址也不能隨意指定,因為在 LC-3 中 0x0000-0x2fff
是系統內核區,程序禁止訪問。
.END
沒啥作用,僅作為結束標識。
匯編解析
先看第一條匯編,LEA R0, HELLO_STR
。
LEA,前面我們也介紹過了,用來取地址。HELLO_STR
是一個標簽,繼續往后看,會發現它定義了一個字符串,"Hello World!"。
那么這行代碼的意思是,將 "Hello World!" 字符串的地址放入 R0 中。
PUTs,取出 R0 中的字符串地址,將字符串輸出到屏幕。這里,也就是將 "Hello World!" 打印出來。它其實也就是 TRAP 指令中的一個系統調用,用于打印字符串。
HALT,退出程序。
以上幾行匯編的功能,千言萬語匯成一句話:將 "Hello World!" 打印到屏幕。
相比起來,寫匯編還是要比起手寫指令簡單高效的多。至少我們不需要計算出指令最終的數值表示了。手寫指令可謂是錯之毫厘,謬以千里,得非常細心謹慎,容不得半點差錯。
數據定義
之前我們在手寫指令時,都是在內存區預先構造好數據,提供給指令使用。但在具體運用時,我們不大可能像這樣操作。
那 LC-3 中有沒有提供像高級語言一樣,能直接定義數據的寫法呢。當然有啦,它分為字符串和數值定義。
字符串
字符串的定義,在上邊我們其實已經接觸過了。看看如下栗子。
HELLO_STR .STRINGZ "Hello World!"
HELLO_STR
表示標簽,.STRINGZ
表示字符串類型,后面跟著真正的字符串數據。
它的含義是,在當前內存地址處,存儲一個字符串 "Hello World!"。
還是拿這個匯編實例來說:
.ORIG x3000
LEA R0, HELLO_STR
PUTs
HALT
HELLO_STR .STRINGZ "Hello World!"
.END
HELLO_STR 前面有 3 條指令,它表示從第 4 個存儲單元開始,寫入字符串 "Hello World!"。注意末尾有空結束字符。
一個字符占兩字節。
整段匯編的內存布局如下:
數值
數值的定義,與字符串定義類似。同樣是標簽形式,只是類型不同,為 .FILL
,最后跟上具體數值。如下所示:
WIDTH .FILL x4000
它表示在當前內存地址中,存入 0x4000 這個數值。
注意,匯編中不能寫成 0x4000,只能是 x4000。
假設還是上邊那段匯編,只是將字符串定義換成數值定義,此時的內存布局如下:
標簽
標簽可以用來定義數據(上邊我們剛介紹的),也可以用來定義函數。比如下面這段代碼:
WELCOME
LEA R0, WELCOME_MESSAGE
PUTs
WELCOME_MESSAGE .STRINGZ "Welcome to LC3 Rogue."
WELCOME
定義了一個函數,WELCOME_MESSAGE
定義了一個字符串。
指令中是沒有標簽這個說法的,它只是用在匯編中,方便書寫和閱讀。在將匯編代碼轉換為指令時,匯編器會按照指令定義,將標簽轉換為具體的參數。
在了解標簽含義,數據定義,指令定義后,我們就可寫出更加豐富的匯編代碼了。
若想了解更多匯編寫法,可參考實例。
不過,這個實例涉及到了鍵盤相關操作,我們并沒有在虛擬機中實現。若有興趣,可自己參照原有實現補充完成。
匯編器
當我們寫了匯編代碼后,如何將其轉換為二進制指令呢?
別著急,lc3tool 提供了配套的匯編器,可通過命令將匯編轉換成指令,生成可執行文件。下面,我們來介紹一下。
安裝
下載 lc3tool 源碼編譯安裝。下載鏈接可在文末查看。
下載完成后,按如下步驟操作,注意將安裝路徑替換為自己本機路徑。
// 解壓
unzip lc3tools_v12.zip
// 進入目錄
cd lc3tools
// 將路徑替換成要安裝的本機路徑
./configure --installdir /path/to/install/dir
// 編譯
make
make install
編譯完成后,在命令行輸入 lc3as,如果看到如下輸出,表示安裝成功。
另外,建議將 lc3as 路徑添加到 PATH 中。這樣就不用每次都跑到 lc3tools 目錄下執行命令,一勞永逸。
我使用的是 zsh,編輯 ~/.zshrc
,添加要導出的路徑。
export LC3TOOL_HOME="/Users/liusilan/Downloads/package/lc3tools"
export PATH=$LC3TOOL_HOME:$PATH
最后執行 source ~/.zshrc
,使更改生效。
轉換
lc3as 工具準備好之后,我們先寫段簡單的代碼轉換一下。
- 將以下匯編代碼存儲為 test.asm。
.ORIG x3000
LEA R0, HELLO_STR
PUTs
HALT
HELLO_STR .STRINGZ "Hello World!"
WIDTH .FILL x4000
.END
這段匯編包括代碼和數據,數據部分定義了字符串 HELLO_STR
和數值 WIDTH
。
- 使用如下命令將其轉換為指令。
lc3as test.asm
若轉換成功,控制臺會輸出 0 errors,同時在同級目錄下會生成 test.obj 可執行文件。雖說后綴 .obj 是目標文件的意思,但這里我們就將其稱為可執行文件吧。
文件內容
接著,我們來看看 test.obj 中的內容。可見,匯編代碼都被轉換成了指令。
3000 e002 f022 f025 0048 0065 006c 006c
006f 0020 0057 006f 0072 006c 0064 0021
0000 4000
在上面的指令中,喵一眼就能看到我們熟知的 f025
,也就是 HALT,程序停止指令。
我們再來詳細介紹一下文件內容的構成。
- 載入地址
文件的頭兩個字節是 0x3000,也就是 .ORIG
指定的地址。那我們可以得知,.ORIG
這個標識的作用就是將程序載入地址寫入到文件開頭兩字節。
另外,還需注意一點,這里生成文件的數據存儲方式是大端字節序,高位在低地址,低位在高地址,符合人類閱讀習慣。
為什么說是大端序?再簡單解釋一下。
因為文件數據存儲是從低地址到高地址,而文件中寫入的內容是 0x3000,那么表明 30 存儲在低地址,00 存儲在高地址。
而 0x3000 這個數值,30 是高位,00 是低位。再根據上一段的分析,也就對應著高位 30 在低地址,低位 00 在高地址,所以是大端存儲。
如果換作是小端存儲,那么文件內容應該是 0030,大家若有疑問可以實踐一下。
- 指令
再來看 e002
這條指令,它對應著 LEA R0, HELLO_STR
這條匯編代碼。我們來詳細分析一下,指令是如何生成的。
首先我們回顧一下 LEA 的指令格式:
操作碼是 1110;第一個參數是 R0,下標為 0,用三位表示是 000;第二個參數是相對于 PC 的偏移量。
那偏移量如何計算呢?看如下分析,就能明白了。
-
LEA
是第一條指令,假設它在地址x
處,PC
指向下一條指令,值為x+1
。 -
HELLO_STR
在地址x+3
處,因為它與 LEA 相差 3 個存儲單元。 - 偏移量為
(x+3)-(x+1)=2
。第二個參數 pc_offset 用 9 位表示為:000000010
。
這是常規計算偏移量的方式。其實,大可不必這么復雜,在算出兩者相差多少個存儲單元后,再減一即可。
最后,將操作碼、兩個操作數拼起來形成完整指令,得到:1110000000000010
,轉換為十六進制表示:e002
。??,正好可以對應得上。
f022,可以自己試著分析一下。
f025,程序退出指令,不必多說。
- 數據
接下來,就是我們定義的數據。
0048,剛好是字符 'H' 的 ASIIC 碼表示。那么,我們可以猜到,從它開始,存儲的是字符串 "Hello World!",對應著 0048 ~ 0000 這一段數據。
最后的 4000,也就是我們定義的數值 WIDTH
。
復雜些的栗子
在上面的栗子中,代碼和數據看似是分開的,其實是可混合在一起的,這取決于匯編如何編寫。
下面這個例子,定義了 MAIN 和 FUNC 標簽,表示函數。MAIN 中有代碼和數據,FUNC 中只有代碼。那么轉換之后,數據仍在兩段代碼之間,還是按照原有順序。
.ORIG x3000 ; OS is <3000
MAIN
LEA R0, HELLO_STR
PUTs
JSR FUNC
HELLO_STR .STRINGZ "Hello World!"
WIDTH .FILL x4000
FUNC
LD R5, WIDTH
ADD R5, R5, #1
HALT
.END
轉換后的指令和數據如下:
3000 e002 f022 480e 0048 0065 006c 006c
006f 0020 0057 006f 0072 006c 0064 0021
0000 4000 2bfe 1b61 f025
不難看出,最后三條指令,2bfe 1b61 f025
,剛好對應著 FUNC 標簽中的代碼。前面的 4000 則是數據 WIDTH,再往前,就是字符串 "Hello World!",這也就印證了代碼和數據是混合的。
另外,標簽不會進行轉換,因為它只是為了方便編寫代碼,沒有真正含義。但它會被用來計算偏移量,可認為它代表著函數第一條指令的地址,或者是數據的地址。
舉個栗子。JSR FUNC
,表示跳轉到函數 FUNC 去執行。這里假設指令從地址 0 開始存放。
- JSR 是第三條指令,地址為 2,那么 PC = 3。
- FUNC 的地址,也就是第一條指令 LD 的地址,為 17。
- 由此得出的偏移量為 17 - 3 = 14。
-
480e
是轉換后指令的十六進制表示,最后 11 位是偏移,數值是 e,剛好等于 14。
至于 FUNC 的地址 17 是怎么計算出來的,可看下圖,也就是依照 FUNC 前面的指令和數據所占空間來推導。
因此,匯編中有效轉換部分是代碼和數據定義,并且是按順序轉換,代碼和數據的相對位置不變。也可以認為去除標簽后,將代碼和數據按序依次放到內存中。
加載可執行文件
既然使用工具生成了可執行文件,那就只剩最后一步,將其加載到內存,運行程序。可執行文件的路徑可通過參數指定。
不過,有幾個問題需要處理:
- 指令和數據存儲是大端序,而我們的機器是小端序,這就涉及到大端轉小端的問題。
- 文件前兩個字節是程序載入地址,需要把它提取出來。
- 由于指定了載入地址,而內存空間有限,那么可讀入的最大指令數是有限制的。
大端轉小端
大端和小端的順序恰好是相反的,因此,只需將高 8 位 和低 8 位交換即可。
uint16_t swap16(uint16_t x)
{
// 兩個字節交換
return (x << 8) | (x >> 8);
}
提取載入地址
載入地址在開頭兩字節,先從文件中讀取兩字節數據,然后轉換為小端表示。
// 載入地址
uint16_t origin;
// 讀取 2 字節
fread(&origin, sizeof(origin), 1, file);
// 大端轉小端
origin = swap16(origin);
讀取指令和數據到內存
由于載入地址的指定,可讀取最大指令數有限。這里簡單處理:
最大讀取指令數 = 最大內存空間 - 載入地址
a. 根據載入地址,計算出可讀取最大指令數。
uint16_t max_read = UINT16_MAX - origin;
b. 將指針指向載入地址:
uint16_t *p = mem + origin;
c. 讀取指令到載入地址:
size_t read = fread(p, sizeof(uint16_t), max_read, file);
d. 指令逐條轉換為小端序:
// 大端轉小端
while (read-- > 0)
{
*p = swap16(*p);
++p;
}
這樣,可執行文件的處理與載入就完成了。
指令執行
現在指令和數據已經放入內存,一切準備就緒。那就開始執行指令吧~
不過,別忘了將 PC 的初始值修改為載入地址喲。這樣,程序才會從第一條指令開始執行。
// 設置初始值
PC = origin;
再啰嗦一下載入地址。假設載入地址 origin = 0x3000。這時,內存布局和 PC 指向如下所示,載入地址處存放第一條指令。
最后,運行虛擬機,參數帶上可執行文件路徑,程序就能跑起來啦。
gcc -o vm_lc_3_all vm_lc_3_all.c
./vm_lc_3_all test.obj
從此,我們從手工勞作時代跨越到了機器時代,可以更加歡快的寫代碼了~
LC-3 實現的完整代碼可點此查看。
總結
至此,虛擬機系列就落下帷幕,全部完結,總共六篇文章。
從一個最小虛擬機開始介紹,到完成 LC-3 這個更加貼近真實意義的虛擬機。一步步從簡單,到復雜,由淺入深,慢慢掀開了虛擬機的神秘面紗。當然,現代虛擬機要復雜的多,這里我們主要是講述它的大體思想。
同時,這也是我自主學習過程的記錄。通過思考與實踐,慢慢發現原來虛擬機并沒有那么難。不過話又說回來,手寫指令,著實讓我深深的體會到了老一輩程序員的痛苦。相比起來,現在的程序員簡直幸福得不要不要的。
另外,這個系列雖已完結,但是我還會繼續學習,輸出其他系列,比如《動手寫 shell》、《動手寫瀏覽器渲染引擎》等等,歡迎關注~