這個系列文:「與妖精共舞:在 RISC-V 平台上使用 go 語言實作 binutils」在上一篇達成了第一個里程碑,目前的軌跡如下:
-h
:展示 ELF 標頭-l
:展示程式標頭-S
:展示區段標頭筆者昨日發文完畢之後,好好的沈澱了一下,思考今天開始這個系列的方向。當初的大規劃是前 20 篇就慢慢實作既有的 GNU binutils 工具程式包,但筆者認為這麼做是不經思考的作法,單純的按表操課,有兩個原因。第一,單就整個工具包,它們的業務輕重就已經嚴重失衡,比方說,strings 的工作(印出 ELF 檔中的可讀字串)和 ld 的工作(將不同的可連結物件檔整合成可執行檔)難道相同嗎?第二,binutils 的關鍵項目是一個名為 libbfd 的函式庫,甚至說整個 GNU binutils-gdb 專案就是一個介在 libbfd 與使用者之間的函式庫操作集合體也不為過;如果這個系列文單以表面的項目當作進度目標,那麼要怎麼分配實作的時間和精神體力給這個函式庫,或是類似這個函式庫的角色呢?
筆者將這個系列文視作一個開端,一個個人理解 ELF 世界、go 語言作為系統軟體工具、以及 RISC-V 作為一個可長可久的平台的初始嘗試;其背後最遙遠的終點是 go-binutils 專案(當然不是此刻的樣貌),它將會結合既有 GNU binutils-gdb 專案與 elfutils 相關專案的功能集合,同時又不受這些專案的歷史所束縛。
大致規劃是按照既有的工具程式來進行,這是對於**傳統(legacy)**作法的支援,但是在筆者的心中,對於 go-binutils 的期許是令之成為一把能夠解決所有 ELF 相關問題的瑞士刀;當然,我們還正在相當於挖掘鐵礦的過程,各位讀者已是見證,如果有興趣的話,也歡迎參與筆者後續的開發工作。
但是我們終究要回過頭來看這系列文的後續該如何處置。其實筆者認為這個脈落已經很明顯了:前述的兩個問題提到業務比重和隱藏的階層(hierarchy),它們其實是一件事,那就是我們必須面對 GNU 他們的設計選項,也就是位居核心的 libbfd 的存在。原名二進位檔描述函式庫(Binary Format Descriptor Library)的 libbfd,提供實體(各個 OS/Arch 組合之下的物件檔內容)到高層介面之間的轉換。筆者無意做出所有的普遍支援,是因為面對傳統的方式應該是尋求壓力點集中突破,而非一開始就要求自己在全部面向取代傳統,而筆者這裡已經選定標的為 Linux/RISC-V (附加:尤其偏好 go 語言產生物而非 GNU 工具鏈產生物)。
所以,我們在接下來的 3~5 天之中,筆者會先以組譯器(assembler)為目標來實作。但醉翁之意尚不在酒,實作組譯器的目的,是因為它將是新的函式庫的第一個應用程式。這個新的函式庫最基礎的功能,就是將一連串的二進位碼當作 RISC-V 的機器碼來處理,並還原成恰當的程式結構。
讀者諸君應該自然產生這些疑問:為什麼需要新的函式庫?既然已經有 riscv 架構的 go 語言移植,那就應該已經有類似的二進位檔處理機制了吧?筆者除了學習動手造輪子之外,另有原因不得不如此。因為對於習慣於 GNU 工具鏈的開發者來說,go 語言的整個程式流程與這個傳統大不相同。
從 go 語言的編譯器文件與組譯器文件可以知道,go 語言的建置二進位檔的流程並不像傳統一般,差異可以在下表中對照:
軟體建構過程中的行動 | GNU 工具鏈的作法 | Go 語言的作法 | 說明 |
---|---|---|---|
編譯檔案成執行檔 | gcc main.c | go build main.go | |
前置處理檔案 | gcc -E | 無相關對應 | 將 # 字號開頭的指令(巨集、引用等)處理完畢。 |
編譯成組合語言 | gcc -S | 編譯成中間碼 | |
將組合語言譯成物件檔 | as | go tool asm | 後者只接受中間碼,等到 code gen 的階段結束之後, 才與底下的架構相連結 |
編譯檔案成物件檔 | gcc -c | go tool compile | 兩種都會產生 *.o 檔案,前者是 ELF 的物件檔,後者是 go 語言的特殊規格。值得注意的是,後者在比較新的版本之後才有, 我們使用的 RISC-V 移植樹中暫時是沒有的。 |
連結物件檔 | ld | go tool link |
從 go 語言的標準工具的演進可以看出官方想要支援自己的一套作法,但是我們現在在 RISC-V 架構上尚未能夠完全享受這些成果。使用 riscv-go 執行 go tool
指令的話,會看見支援很多工具指令,其中甚至也包含了 objdump 和 nm 等我們預計會涵蓋到的部份,但是此刻移植樹尚未支援,因此筆者決定要實作一個函式庫。
有興趣的讀者可以參照
/riscv-go/cmd/compile/internel/riscv
的實作,可以發現 go 語言的編譯器有著非常漂亮的設計。
所以今日開始就是組譯器篇的開端了,代號也叫做 as,尊重 GNU 當初定下的名子。GNU 組譯器所能支援的組合語言檔案支援非常豐富的功能集合,這裡筆者先訂兩個目標,分短期(接下來的兩日)和長期(組譯器篇結束)。
GNU 組譯器所能支援的組合語言檔案支援非常豐富的功能集合,這裡筆者先訂兩個目標,分短期(接下來的兩日)和長期(組譯器篇結束)。短期目標預期支援非常簡單的功能,具體來說是完成一套組合功能。首先,我們有一個簡單的 C 檔案
1 #include<stdio.h>
2
3 int add(int A, int B);
4 int main(){
5 printf("%d\n", add(1, 2));
6 }
其中的 add
函數顯然沒有具體定義,因此拿這個 C 檔案給 gcc 的話一定不能通過連結階段,因為找不到 add
的符號。那麼,我們準備另外一個組合語言檔案,
1 .section .text
2 add:
3 add a0, a1, a0
4 ret
5 .global add
6 .end
這裡論上會把 a0
、a1
兩個暫存器加起來存到 a0
並回傳。因此如果能夠成功連結這個子程式與 main
函式,那麼我們應該就能夠擁有一個會輸出 3 的程式了!使用 GNU 工具鏈的作法,我們可以這樣作:
$ riscv64-unknown-linux-gnu-as add.s -o add
$ riscv64-unknown-linux-gnu-gcc -c main.c -o main.o
$ riscv64-unknown-linux-gnu-ld --sysroot=/home/riscv/sysroot -dynamic-linker /lib/ld-linux-riscv64-lp64d.so.1 add.o main.o -o main /home/riscv/sysroot/usr/lib/crt1.o -lc
然後將產出的 main
執行檔用 riscv64-unknown-linux-gnu-objdump
看一看,或是丟進虛擬環境的根目錄之後重新跑一組 Linux 與 bbl 的編譯流程。筆者測試的結果,這已經是最少量的參數與指令了。前面兩個指令應該都還蠻直覺的,但是連結器的 ld 倒是常常躲在 gcc 之後,所以這裡簡單說明一下。首先,因為這是 cross compilation,我們就算指定動態連結函式庫的路徑給一個可執行檔,那個檔案在自己的環境中恐怕也找不到那個路徑,因此需要指定 sysroot
,並將這個資訊告訴告訴連結器,它才能夠欺騙產出的可執行檔。
這麼說有些抽象,但是到下一個參數 -dynamic-linker
時,各位讀者便能意識到這問題很嚴重。一個虛擬環境的可執行檔所需的動態連結器,怎麼能夠使用開發環境的動態連結器呢?所以,這裡的路徑 /lib/...
其實是連結器拿來欺騙可執行檔用的,而連結器本身必須要知道那個根目錄位在 sysroot
的所在。
接著是兩個我們經手過的物件檔,略過不提。最後這個 crt1.o
裡面有著 C 語言的執行期環境,若是沒有引用的話,main
函式就會拿不到命令列參數以及其他的一些初始化事項了。最後一個 -lc
則表示要連結 libc.so 檔案的意思,動態連結器在執行程式時將會去 -L
後面的路徑尋找。
筆者預計明天著手完成這個項目,現在先來分析這個非常簡單的一個物件檔,明天就可以比較明確地知道該實作哪些功能出來。
add.o
昨日剛完成的 readelf 已經足以勝任接下來的任務:我們要看看 add.o 這個物件檔的檔頭,然後根據檔頭的訊息去查看那些相應的區段的內容有什麼東西。
# /tmp/readelf -h -l -S add.o
ELF File Header:
Class: elf.ELFCLASS64
Data: elf.ELFDATA2LSB
OSABI: elf.ELFOSABI_NONE
ABIVersion: 0
Type: elf.ET_REL
Machine: elf.EM_ALPHA_STD+202
Program Header:
Number Type Flags Offset Address File Size Memory Size Alignment
Section Header:
Number Name Type Flags Address Offset Size Link Info Alignment
0 .shstrtab elf.SHT_STRTAB 0x0 0 c5 44 0 0 0x1
1 .strtab elf.SHT_STRTAB 0x0 0 c0 5 0 0 0x1
2 .symtab elf.SHT_SYMTAB 0x0 0 48 120 5 4 0x8
3 .bss elf.SHT_NOBITS elf.SHF_WRITE+elf.SHF_ALLOC 0 48 0 0 0 0x1
4 .data elf.SHT_PROGBITS elf.SHF_WRITE+elf.SHF_ALLOC 0 48 0 0 0 0x1
5 .text elf.SHT_PROGBITS elf.SHF_ALLOC+elf.SHF_EXECINSTR 0 40 8 0 0 0x4
6 elf.SHT_NULL 0x0 0 0 0 0 0 0x0
噫,為什麼 Machine 那邊竟然是一個什麼 ALPHA_STD+202?這是因為筆者在操作的這個 terminal 剛好路徑沒設好,Makefile 抓到的 GOROOT 環境變數是預設的版本而非我們之前載到 /riscv-go
的版本,因此沒有 RISC-V 移植的資訊。設好 GOROOT 之後,果然那行就會變回:
Machine: elf.EM_RISCV
回題,我們現在看到程式檔頭空無一物,就是沒有的意思,這也符合我們昨天的解析,畢竟物件檔不像動態函式庫和可執行檔那樣有被載入記憶體執行的需求。
來來去去好像都在說這三個:可執行檔、動態物件檔、物件檔,難道 ELF 格式就只包含這三種檔案嗎?並非如此,但以筆者本系列會介紹的範圍而言,只會帶到這三種在軟體生成到執行的過程中會參與的檔案。
和空無一物的程式標頭不同,當我們看向區段標頭時會發現,就連這個只有兩行組語程式碼的小程式也有六個區段檔頭、還加上一個沒有名字的!也就是有六個區段的意思了。一般來說,程式碼會出現在 .text
區段中,這裡我們可以看到它的偏移在 0x40 的位置(64 bytes),總大小有 8 bytes。**難道這代表 RISC-V 的指令一個 4 bytes 嗎?**是的,各位讀者完全沒有猜錯,正是如此,我們明天就不知知道指令的長度,也會知道怎麼轉換指令與二進位編碼了。這當然是最核心的部份,畢竟從檔案中看不出什麼其他的東西。
但是,明明是個非常陽春的組語檔案,為什麼還是被設置了 6 個檔頭呢?筆者由下而上,分別簡單說明:
.data
區段:資料區段。.bss
區段:未初始化的資料區段。同上,這個內容都是空的(大小為 0),且沒有指向任何區段。.symtab
區段:各種 symbol 的對照表,是一組結構,之後會介紹到。.strtab
與 .shstrtab
區段:字串的表格。可以使用 readelf 的功能觀察裡面的內容:$ riscv64-unknown-linux-gnu-readelf -x .strtab ~/add.o
Hex dump of section '.strtab':
0x00000000 00616464 00 .add.
$ riscv64-unknown-linux-gnu-readelf -x .shstrtab ~/add.o
Hex dump of section '.shstrtab':
0x00000000 002e7379 6d746162 002e7374 72746162 ..symtab..strtab
0x00000010 002e7368 73747274 6162002e 74657874 ..shstrtab..text
0x00000020 002e6461 7461002e 62737300 ..data..bss.
可見就如同區段名稱描述的一般,這兩個區段就是字串的對照表。這些對照表很重要,因為他們提供一個空間給其他的區段作參照。比方說 .shstrtab
代表的區段字串對照表,就會被 readelf 工具參照,尤其是在列出區段檔頭時,必須要從這裡提取各區段的名稱。
明天完成短期目標之後,筆者會開始設計與 RISC-V 機器指令有關的函式庫,這個函式庫預計支援所有 64 位元整數基本指令集和壓縮指令集的格式。所提供的 API 將會允許使用者將二進位碼片段轉換成可操作的指令結構。具體的測試內容的話,現在的預定是實作包含多種指令的程式碼片段,將它包成函式庫之後,再由 go 程式來呼叫。