本節是以 Golang 上游 1a708bcf1d17171056a42ec1597ca8848c854d2a 為基準做的實驗。
予焦啦!回顧昨日,最終我們能夠透過
$ GOOS=opensbi GOARCH=riscv64 go build ../../ethanol/ethanol.go
這般非常相似於一般 Golang 開發應用程式的用法,產生一個還只是空殼的 opensbi/riscv64
系統組合的可執行檔。
接下來的目標是,確認這個剛編出來的東西真的能夠被當作一個作業系統映像檔嗎?它如何能夠作為 RISC-V 系統開機的一部分?筆者也會在今日檢驗的過程中回顧一些相關的軟體。
-T
與 -R
的控制ecall
指令我們可以透過 GNU binutils 工具包當中的 readelf 工具觀察這個產出的可執行檔:
$ riscv64-buildroot-linux-musl-readelf -h hw
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0x64418
...
readelf 認識它,就表示它至少還算是個正常可辨識的 ELF 檔。然而看到這個進入點位址(Entry point address),就有點不太對了。
以作業系統的慣例來講,核心的可執行區段位置通常是位在所有可用的虛擬空間的最高位的部分。若是 64 位元系統,以 RISC-V Linux 的 sv39 組態(將於日後介紹虛擬記憶體的篇章詳談)來講的話,核心的位置會從 0xffffffe000000000
起始,可參考arch/riscv/Kconfig
。
有一個因素是,核心就可以簡單地將使用者程式配置在低位,且可以一路往高位延展。至於具體來說為什麼這會成為一種慣例,我認為這篇是很值得參考的資料。
話說回來,這裡看到的進入點位置是 0x64418
,當然是非常低的。若使用 readelf
工具近一步檢驗各個 ELF 區段的位置,也可以發現它們都在很低的位置。這也當然,畢竟我們並沒有特別針對這個系統組合做些甚麼特別的處理。RISC-V Linux 中具體的參照,在 arch/riscv/kernel/vmlinux.ld.S
檔案當中,Linux 便會指定連結器將程式碼區段從前段中提及的高位置開始擺放。
那麼,該怎麼樣修改?Golang 是否也有 C 語言生態系裡面的連結器腳本(linker script)之類的文件,可以供開發者手動調整可執行檔中的區段位址呢?在這之前,我們先繼續引用原本的 readelf 工具觀察,這個預設的區段排列長成什麼樣子:
$ riscv64-buildroot-linux-musl-readelf -S hw
There are 23 section headers, starting at offset 0x158:
Section Headers:
[Nr] Name Type Address Offset
Size EntSize Flags Link Info Align
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 0000000000011000 00001000
000000000005405c 0000000000000000 AX 0 0 8
[ 2] .rodata PROGBITS 0000000000070000 00060000
000000000001e8d5 0000000000000000 A 0 0 32
[ 3] .shstrtab STRTAB 0000000000000000 0007e8e0
000000000000017a 0000000000000000 0 0 1
...
第零個區段必須是空的,似乎是連結器背後的一些歷史因素導致的。
因此關鍵就在於回答這兩個問題:Golang 開發者能夠更動 .text 區段的開頭,從 0x11000 變成某個看起來更氣派的高位址嗎?如何做到?
比較粗略一點講的話,大家都稱呼 gcc
為編譯器(compiler),因為它能夠將人們餵給它的可讀的程式碼轉換成可執行檔。但嚴謹來講,它只是一個編譯驅動(compiler driver),負責把整個流程管理好。所謂整個流程,是因為其中牽涉到的元件至少有以下數個:
其中,若要調整程式碼區段,那麼開發者必須調整第 4 個步驟中,多餵一個連結器腳本,去指定區段的位址甚至符號(變數、函數等等)的位址與對齊關係(alignment,比方說有些地方開發者希望可以 8 個位元組為單位排列所有內容,有些地方 2 個位元組為單位即可)。
那麼以 Golang 來說,我們目前為止相當於只觀測到 go build
作為編譯驅動器的功能。只要能夠先特定出連結器的階段,或許就能夠掌握到線索了。以這個為契機,還是好好閱讀一下文件吧:
$ go build help
...
非常簡潔的描述 build 的行為,值得一看。
...
-a
force rebuilding of packages that are already up-to-date.
譯:即使已經存在,也強迫重新編譯。
-x
print the commands.
譯:印出執行的指令。
-work
print the name of the temporary work directory and
do not delete it when exiting.
譯:印出暫存的工作目錄,並保存所有中間產物。
...
-asmflags '[pattern=]arg list'
arguments to pass on each go tool asm invocation.
譯:要餵給 Golang 工具 asm 的參數。
-gcflags '[pattern=]arg list'
arguments to pass on each go tool compile invocation.
譯:要餵給 Golang 工具 compile 的參數。
-ldflags '[pattern=]arg list'
arguments to pass on each go tool link invocation.
譯:要餵給 Golang 工具 link 的參數。
前半節的指令說明可以幫助我們一窺 go build
作為編譯驅動器展開整體流程之後的結果。有興趣的讀者不妨直接執行 go build -work -a -x
試試!可以觀測到每一個組件(像是昨日看到的 runtime
或 os
等等)先被個別編譯之後,最後才呼叫 link
工具產出執行檔:
...
/home/noner/FOSS/hoddarla/ithome/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=s6qzkJHXEbb67EhxxAGl/2SEhUWfSojBvAPlQVFt-/VZzZk01lY2DDxHbprbWq/s6qzkJHXEbb67EhxxAGl -extld=gcc $WORK/b001/_pkg_.a
/home/noner/FOSS/hoddarla/ithome/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal
cp $WORK/b001/exe/a.out hw
其中並沒有類似 C 語言裡面的連結器腳本。importcfg.link
看起來很可疑,但內容也僅是先前的編譯產物而已。但我們從文件當中可以得到別的靈感,也就是對應到 C 語言的編譯流程的每個階段(除了前置處理),都有可以額外傳入參數的部分。所以我們可以鎖定連結器,閱讀它的文件,赫然可見:
-T address
set text segment address (default -1)
所以 -T
看起來就是我們要的東西了!但一如往常的事情不會那麼順利,比方說筆者做的以下兩個實驗都吃鱉:
-T 0xffffff8000000000
企圖一步到位,結果會回報編譯錯誤$ GOOS=opensbi GOARCH=riscv64 go build -ldflags='-T 0xffffff8000000000' ethanol/ethanol.go
# command-line-arguments
invalid value "0xffffff8000000000" for flag -T: value out of range
-T 0x12000
小幅調整,結果雖然可以編譯出產物,看起來卻是壞掉的 ELF$ riscv64-buildroot-linux-musl-objdump -d hw
riscv64-buildroot-linux-musl-objdump: hw: file format not recognized
-T
與 -R
所以,深入瞭解 -T
參數是勢在必行了。我們可以開啟 src/cmd/link/internal/ld/main.go
檔案,並且在全域變數中找到 -T
的定義:
FlagRound = flag.Int("R", -1, "set address rounding `quantum`")
FlagTextAddr = flag.Int64("T", -1, "set text segment `address`")
這個東西,定義成 Int64
型別的話,那當然 0xffffff8000000000
的賦值就不會成功了,畢竟首位元為 1,這個值就是無號整數才能夠容納的了。這是第一個實驗失敗的原因,可以理解。如果要騙過它去真的使用這個高位位址,還是可以手動轉換 2 的補數,也就是 -T -0x8000000000
,結果編譯可以成功,但 ELF 本身仍然有點毀損
$ riscv64-buildroot-linux-musl-readelf -h hw
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
Class: ELF64
Data: 2's complement, little endian
Version: 1 (current)
OS/ABI: UNIX - System V
ABI Version: 0
Type: EXEC (Executable file)
Machine: RISC-V
Version: 0x1
Entry point address: 0xffffff8000053418
Start of program headers: 64 (bytes into file)
Start of section headers: 344 (bytes into file)
Flags: 0x4, double-float ABI
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 5
Size of section headers: 64 (bytes)
Number of section headers: 23
Section header string table index: 3
readelf: Error: the PHDR segment is not covered by a LOAD segment
筆者找了一段時間,才鎖定實驗一與 ELF 毀損的現象,原因可能出在 -R
這個連結器參數上。若我們檢索昨日的改動,可以看到在 src/cmd/link/internal/riscv64/obj.go
檔案的 archinit
函式裡,初始化了 -T
和 -R
分別對應到的參數:
func archinit(ctxt *ld.Link) {
switch ctxt.HeadType {
case objabi.Hlinux, objabi.Hopensbi:
ld.Elfinit(ctxt)
ld.HEADR = ld.ELFRESERVE
if *ld.FlagTextAddr == -1 {
*ld.FlagTextAddr = 0x10000 + int64(ld.HEADR)
}
if *ld.FlagRound == -1 {
*ld.FlagRound = 0x10000
}
...
這就解釋了為什麼預設的程式碼區段會從 0x11000
開始:因為通常 ELF 檔頭的大小(ld.ELFRESERVE
和 ld.HEADER
)是 0x1000
。Round 在這裡是進位的基準,設置成0x10000
代表有一些以這個值為單位的運算。這可以解釋為什麼單純設置 -T 0x12000
不成功,因為扣除 ELF 檔頭之後,程式碼區段的位址變成 0x11000
開始,但連結器針對 ELF 內其他部分的位址計算就因此沒有辦法整除 0x10000 而導致問題了。
所以,這個階段,我們可以先採用 -R 0x1000 -T -0x7ffffff000
,來讓這個可執行檔的程式碼區段從 0xffffff8000001000
開始,並且也不會弄亂對齊:
$ riscv64-buildroot-linux-musl-readelf -l hw
Elf file type is EXEC (Executable file)
Entry point 0xffffff8000054418
There are 5 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
PHDR 0x0000000000000040 0xffffff8000000040 0xffffff8000000040
0x0000000000000118 0x0000000000000118 R 0x1000
NOTE 0x0000000000000f9c 0xffffff8000000f9c 0xffffff8000000f9c
0x0000000000000064 0x0000000000000064 R 0x4
LOAD 0x0000000000000000 0xffffff8000000000 0xffffff8000000000
0x000000000005505c 0x000000000005505c R E 0x1000
LOAD 0x0000000000056000 0xffffff8000056000 0xffffff8000056000
0x0000000000051e18 0x0000000000051e18 R 0x1000
LOAD 0x00000000000a8000 0xffffff80000a8000 0xffffff80000a8000
0x0000000000002c60 0x0000000000032280 RW 0x1000
...
其實,在 ELF 的術語裡面,有分 section 和 segment,前者是給連結器在連結期使用、後者則是載入器(loader)在載入期使用的。顯然這裡的對齊,是針對後者。可參考筆者在先前鐵人賽的拙作。
很抱歉這裡兩者中文直譯可能都會有「區段」的困擾性。也有些簡體書籍應該是以區塊和區段作為區分手法,但仍然拙劣。只能慨嘆如今人們的翻譯能力完全不如 19 世紀的日本漢學家翻譯出「社會」、「經濟」、「政治」等造語。資訊時代的漢語智能,幾乎可以說是流失殆盡的。
誠然,我們目前唯一的核心檔案 ethanol/ethanol.go
是一個最基本的 Hello World,但筆者並沒有天真到認為這個執行檔能夠渡過 Golang 設計給使用者空間執行期初始化並最後引用到 fmt
組件,並成功呼叫 fmt.Println
函式,過程中會發生什麼問題還很難說。我們現在能夠比較確定的東西,其實只有整個可執行檔的進入點位在 _rt0_riscv64_opensbi
而已。
因此我們在這裡(src/runtime/rt0_opensbi_riscv64.s
)插入一些程式碼吧!像是學 python 的時候插 print
函式來學習最基本的追蹤程式碼方法一樣,我們這裡加入:
TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
+ MOV $0x48, A0
+ MOV $1, A7
+ MOV $0, A6
+ ECALL
MOV 0(X2), A0 // argc
ADD $8, X2, A1 // argv
JMP main(SB)
這區區 4 行其貌不揚的組合語言,何以能夠支援印出訊息這樣複雜的功能?我們必須先瞭解以下概念:
.s
檔)與 GNU 工具預設的組合語言語法不一樣,最大的差異在於順序。這段組合語言碼編譯完成後使用 objdump
反組譯的結果為:ffffff8000054418: 0480051b addiw a0,zero,72
ffffff800005441c: 0010089b addiw a7,zero,1
ffffff8000054420: 0000081b sext.w a6,zero
ffffff8000054424: 00000073 ecall
a0
、a6
、a7
在這裡是暫存器,當然是運算的目標對象(destination),一開始的三行都只有賦值的效果,所以只有常數(immediate)作為運算的來源(source)的模式,但方向截然不同:Golang 是目標在後,整行的語義感覺像是從左到右延展,反過來 GNU 則是預設從右到左進行。當然,我們之後還會看到更複雜的組合語言指令,包含三個運算元(operand)也是常有的,屆時的「方向性」可能就不是單純賦值這麼直接,但大致上目標運算元的位置決定了兩者間最大的差異。又,MOV
並不是一個 RISC-V 真正定義的組合語言助憶符(mnemonic),而是 Golang 提供的跨架構共用助憶符之一,可以用來代表資料移動在各種單位之間(暫存器、記憶體位址)的指令。
2. ECALL
是 RISC-V Evironment Call 的意思。這個指令會觸發例外(exception),分成使用者模式環境呼叫例外(Environment-Call-from-U-mode)、作業系統模式環境呼叫例外(Environment-Call-from-S-mode)、機器模式環境呼叫例外(Environment-Call-from-M-mode)。這裡將會觸發的是作業系統模式環境呼叫例外(Environment-Call-from-S-mode),因為我們預期這個可執行檔是與 Linux 這類的作業系統映像檔同樣位階的系統。
3. 轉換權限等級(privileged mode)。以結果來說,這裡的 ECALL
將會造成權限等級轉換回到 M-mode 的 OpenSBI,由 OpenSBI 來服務這個呼叫。
4. 這個呼叫對應到的部分是,OpenSBI 中 lib/sbi/sbi_ecall_legacy.c
檔案內的 sbi_ecall_legacy_handler
函式裡的 SBI_EXT_0_1_CONSOLE_PUTCHAR
條件。OpenSBI 所支援的環境呼叫規格書在此處可參照。
a6
暫存器代表該呼叫所屬於的集合。這裡令為 0 值,代表的是傳統(legacy)集合,實際上已經不建議使用。現在的 S-mode 系統軟體實踐都會避免使用傳統集合的環境呼叫,我們這裡只是暫且挪用其方便性。a7
暫存器代表該呼叫在該集合中的序號。這裡令為 1,代表在 控制台(console)印出一個字元。a0
暫存器內的 ASCII 碼。這裡的 0x48
,取的是代表 Hoddarla 的 H
字元。給對於 OpenSBI 有一定了解,較進階的讀者:以下描述的是 Jump mode 的預設行為。本節想要描述的技術重點是如何跳躍到 S-mode 進入點,但如果單純修改 OpenSBI 跳躍位址,或者直接使用 dynamic mode 控制,都是可以考慮的方案。
回顧 Linux 的啟動流程(假設所有軟體都已經被載入在對的地方)作為對照的話,會是這個樣子:
0x80000000
0x80200000
並切換為作業系統模式0x80200000
,繼續執行下去。(若只考慮無壓縮格式,Linux 的輸出物有兩種,一種是 ELF 檔,通常檔名為 vmlinux
,一種是將 ELF 檔頭去掉且增加未初始化變數空間的 Image
檔,通常是透過 objcopy
工具獲得的,詳情可參照 Linux 的 Makefile。)pc
會從 0x802000XX
轉變為0xffffffe0000000XX
。但回到 ethanol 映像檔,我們還有進入點的問題沒有處理。Golang 連結器給我們的產物裡面,程式碼區段的開頭是其他組件所在的位址,而非像 Linux 一樣,去除 ELF 檔頭之後也能夠直接擺在記憶體裡面執行。也就是說,我們仍然需要一個機制來幫助我們從 OpenSBI 的出口 0x80200000
過渡到對應到 0xffffff8000053418
這個虛擬記憶體位址的實體記憶體位址。
幾經考慮之後(詳情可以參照 ethanol/goto
資料夾下的檔案),筆者決定導入一個小程式序列在 0x80200000
,它負責跳到 ethanol 映像檔真正的進入點。所以整個流程是
PA VA
+-------------+ 0x80200000
| goto |
+-------------+ 0x80201000 +------------+ 0xffffff80_00001000
| hoddarla | | ELF |
| kernel ELF | | Header |
+ --- + 0x80202000 +------------+ 0xffffff80_00002000
| hoddarla | | ELF |
| kernel code | | .text |
...
| Entry | 0x80255418 | Entry | 0xffffff80_00055418
...
由圖可見,真正的進入點位址(-T
參數)重新調整為 0xffffff8000002000
,這是因為這麼一來,在低位的 20 個位元就可以在實體記憶體與虛擬記憶體位址之間保持一致。
goto
的實作細節這裡就不贅述,只是很簡單的位址計算而已。當編譯完成 ethanol 映像檔之後,使用 readelf
工具取得進入點的虛擬記憶體位址,然後拆解這個位址以合成 goto.s
檔案,再將之製成一個跳躍用微小程式序列,好讓它能夠跳躍到真正的進入點去。
由於我們在進入點處插入了能夠印出 H
字元的程式碼,因此我們預期的是,QEMU 模擬器啟動一個模擬的 RISC-V 系統,CPU 開始執行 OpenSBI 的部分。等到 OpenSBI 啟動完成,跳躍並轉換權限等級到映像檔進入點。然後印出 H
字元之後,Golang 繼續進行原本的流程,結果可能踩到某些預期的錯誤,系統因此進入錯誤的狀態,卡住或是亂跑之類的。
可以存取 github 以進行以下實驗。
$ make clean && make
GOOS=opensbi GOARCH=riscv64 go build -ldflags='-R 0x1000 -T -0x7fffffe000' ethanol/ethanol.go
make -C goto
make[2]: 進入目錄「/home/noner/FOSS/hoddarla/ithome/ethanol/goto」
./patch.sh
riscv64-buildroot-linux-musl-as goto.s -o goto.o
riscv64-buildroot-linux-musl-ld -T ld.script goto.o -o goto
riscv64-buildroot-linux-musl-objcopy -O binary goto goto.bin
make[2]: 離開目錄「/home/noner/FOSS/hoddarla/ithome/ethanol/goto」
make[1]: 離開目錄「/home/noner/FOSS/hoddarla/ithome/ethanol」
qemu-system-riscv64 \
-smp 4 \
-M virt \
-m 256M \
-nographic \
-bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
-device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
-device loader,file=ethanol/hw,addr=0x80201000,force-raw=on
OpenSBI v0.9
____ _____ ____ _____
/ __ \ / ____| _ \_ _|
| | | |_ __ ___ _ __ | (___ | |_) || |
| | | | '_ \ / _ \ '_ \ \___ \| _ < | |
| |__| | |_) | __/ | | |____) | |_) || |_
\____/| .__/ \___|_| |_|_____/|____/_____|
| |
|_|
Platform Name : riscv-virtio,qemu
Platform Features : timer,mfdeleg
Platform HART Count : 4
Firmware Base : 0x80000000
Firmware Size : 124 KB
Runtime SBI Version : 0.3
Domain0 Name : root
Domain0 Boot HART : 0
Domain0 HARTs : 0*,1*,2*,3*
Domain0 Region00 : 0x0000000080000000-0x000000008001ffff ()
Domain0 Region01 : 0x0000000000000000-0xffffffffffffffff (R,W,X)
Domain0 Next Address : 0x0000000080200000
Domain0 Next Arg1 : 0x0000000082200000
Domain0 Next Mode : S-mode
Domain0 SysReset : yes
Boot HART ID : 0
Boot HART Domain : root
Boot HART ISA : rv64imafdcsu
Boot HART Features : scounteren,mcounteren,time
Boot HART PMP Count : 16
Boot HART PMP Granularity : 4
Boot HART PMP Address Bits: 54
Boot HART MHPM Count : 0
Boot HART MHPM Count : 0
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000a109
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH
HHHHHHHHHHHHHHHHHHQEMU: Terminated
什麼?結果 H
字元就這樣一直印出?
予焦啦!本章的實際產物可分為三個部分:
src/runtime/rt0_opensbi_riscv64.s
當中新增了 4 行組合語言,代表一個在控制台印出單一字元的 SBI 呼叫。然而,懸而未決的問題是,最後觀察到的洗頻現象。我們的如意算盤明明是安插一個字元輸出,而後繼續往後走向 Golang 的執行期初始化流程才對。欲知如何,請待下回分解。