本節是以 Golang 上游 ee91bb83198f61aa8f26c3100ca7558d302c0a98 為基準做的實驗。
回顧昨日,我們發現過去一直未知的錯誤竟然就發生在下一行。那是一個以 sp
暫存器為基礎的讀取,結果造成了讀取錯誤。
我們得想辦法繞過這個錯誤才行。
TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
MOV $0x48, A0
MOV $1, A7
MOV $0, A6
ECALL
MOV $early_halt(SB), A0
CSRRW CSR_STVEC, A0, X0
MOV 0(X2), A0 // argc
ADD $8, X2, A1 // argv
JMP main(SB)
讀者可能會疑惑,為何昨天反組譯時,錯誤指令的存取基準是
sp
,這裡卻是X2
?因為 RISC-V 可以將 32 個通用暫存器稱作X0~X31
,也能夠依照它們的用途來稱呼。事實上,X2
就是sp
,代表堆疊指標。
這裡的註解 argc
和 argv
,是作業系統通常已經為使用者應用程式準備好的參數。argc
代表的是命令列參數的個數,而 argv
是每個參數的內容各自為何,通常型別是字串的陣列。
之所以他們的存取都與堆疊指標有關,是因為通常來講,作業系統幫應用程式做初始化,當然是把這些東西存放在可以取得的地方,堆疊(stack)就是最直接了當的位置。
但既然 opensbi/riscv64
作為一個可以用來寫作業系統的系統組合,那麼這裡這兩個參數就顯然不合時宜。這是我們現在可以直接拿掉這兩行的理由。但有另外兩組問題,是我們將它拿掉之前必須想想的。
以下兩個小節分別探討這兩組問題,也以 RISC-V Linux 的啟動程式碼 作為範例來研究一下(由於 Linux 核心支援多種組態,不可能全部列舉,這裡只以執行在作業系統模式底下、支援 MMU 的組態為例)。
省略 OpenSBI 的細節不談,事實上,作業系統(如 Linux)接棒之後,只有拿到兩個參數:
A0
:當前執行的 CPU 的編號A1
:所運行的硬體的裝置描述樹(DTB,Device Tree Blob)OpenSBI 運行在機器模式,它可以存取 mhartid
狀態暫存器以獲得該硬體核心的編號(hart ID)。這個值會置放在 A0
傳給作業系統模式。又,如果 Linux 運行在機器模式,由於沒有 OpenSBI 或是其他韌體能夠給予這個代表硬體核心編號的值,就會自己讀取。
但是,一般作業系統模式的 Linux,對於 CPU 這個資源,會使用另外的 CPU ID 來作編號。所以嚴格來說,在多核心(SMP)系統裡面,只有開機核心(boot CPU)的 hart ID 才比較有意義。開機核心是能夠閃過兩種關卡還能夠抵達開機核心初始化流程的核心:一種是,OpenSBI 或是其他機器模式韌體可能只會放行一個核心進入作業系統模式;另一種是,在 Linux 最一開始,有個樂透機制,會只允許一個核心繼續運行,其他則跳轉到 .Lsecondary_start
,等待開機核心為它們設置運行條件。開機核心會先在最一開始的這一段組語中設置 boot_cpu_hartid
變數,並在稍後將 CPU ID 會登記為 0。更稍微後期一點,開機核心將會為所有 CPU 設置 CPU ID 與 hart ID 的對應關係。其餘核心在後期的開機流程當中,也僅僅是憑藉著自己存放在 A0
的 hart ID 來對照出已經由開機核心設定好的 CPU ID 而已。
由於運行多核心 ethanol 且能夠運用 Golang 執行期的做法還是很後面的事,這裡我們就先不理會 A0
存放的 hart ID 資訊了。
可參考這份很詳盡的文章。
裝置描述樹是為了讓 Linux 這樣的作業系統,能夠從一份文件的資訊當中得知當前運行的硬體平臺底下存在哪些裝置,那些裝置的各式各樣資訊等等。即使是我們現在使用 QEMU 當作模擬器,也有這個資訊可供存取,
$ qemu-system-riscv64 -M virt --machine dumpdtb=/tmp/dtb
qemu-system-riscv64: info: dtb dumped to /tmp/dtb. Exiting.
這個 dtb
格式檔案是一種二進位檔,已經是從裝置描述樹的原始碼編譯而成的產物。要解析這個檔案,我們需要 dtc
工具,
$ dtc -I dtb -O dts /tmp/dtb -o /tmp/dts
/tmp/dts: Warning (simple_bus_reg): /soc/poweroff: missing or empty reg/ranges property
/tmp/dts: Warning (simple_bus_reg): /soc/reboot: missing or empty reg/ranges property
其內容節錄如下:
/dts-v1/;
/ {
#address-cells = <0x02>;
#size-cells = <0x02>;
compatible = "riscv-virtio";
model = "riscv-virtio,qemu";
fw-cfg@10100000 {
dma-coherent;
reg = <0x00 0x10100000 0x00 0x18>;
compatible = "qemu,fw-cfg-mmio";
};
...
memory@80000000 {
device_type = "memory";
reg = <0x00 0x80000000 0x00 0x8000000>;
};
cpus {
#address-cells = <0x01>;
#size-cells = <0x00>;
timebase-frequency = <0x989680>;
cpu@0 {
phandle = <0x01>;
device_type = "cpu";
reg = <0x00>;
...
rtc@101000 {
interrupts = <0x0b>;
interrupt-parent = <0x03>;
reg = <0x00 0x101000 0x00 0x1000>;
compatible = "google,goldfish-rtc";
};
uart@10000000 {
interrupts = <0x0a>;
interrupt-parent = <0x03>;
clock-frequency = <0x384000>;
reg = <0x00 0x10000000 0x00 0x100>;
compatible = "ns16550a";
};
...
其中除了 CPU 和某些特殊的記憶體區段之外,大致上的基本單位就是像這裡的 rtc
和 uart
一樣,它們是周邊裝置,@
後面的數字代表它們的基底位址(base address),通常它們會有一些界面與功能是相對於這個基底,讓系統軟體能夠控制。
像 Linux 之類的作業系統,在開機過程中的某個階段,會拆解傳入的 DTB,並根據相容字串(compatible
)的內容來啟用對應的驅動程式。
Linux 在早期階段將這個位址從 A1
保存下來的方法,是在準備設定虛擬記憶體的轉換之前,將傳入的 DTB 位址(此時還是物理位址,當然,因為 OpenSBI 不使用虛擬位址)換算成未來的虛擬位址。
至於 ethanol 現在的狀態,我們距離使用周邊裝置也還很遙遠,所以這個值也可以之後再處理即可。
相較於此時被筆者延宕處理的硬體核心編號(A0
)與裝置描述樹(A1
)的資訊,堆疊指標是我們無法迴避的項目。最根本的原因是因為暫存器速度快、距離 CPU 的運算核心近,而且數量非常稀少,無論如何不可能只靠暫存器完成所有的功能,因此勢必需要記憶體。
存取記憶體的方法,當然也不能隨便亂決定,而是應該有規則可循,如此一來,高階語言的編譯器在編譯成一個一個函式的時候,進入與離開的條件才能在呼叫程式(caller)與被呼叫程式(callee)之間建立一致的協定。所謂函數的進入條件,正式的名稱是序幕(prologue);離開,則名為結尾(epilogue)。這樣的規則,算作是呼叫慣例(calling convention)的一部分。
比方說昨日介紹的
dump
函式。事實上它沒有什麼呼叫慣例可言,它在裡面使用了許多暫存器,但都沒有進行保存。對於呼叫端來講,很有可能日後會造成不必要的困擾。
這裡的描述可能會讓讀者誤解為函數的參數勢必要使用堆疊來傳遞,雖然目前 Golang 確實如此,但實際並非普世皆然。以一般 C 語言編譯器處理函數的方法來講,大部分的函數參數都會使用
a0~a7
作為參數暫存器,依序傳入。這裡強調的是序幕與結尾對於堆疊的需求,因為暫存器不足的時候總得有個地方能夠存放原先的資料。
Linux 會在啟動虛擬記憶體之後,從預先定義的結構體去設置堆疊指標。
我們可以觀察幾個例子,比方說 fmt.init
函式:
ffffff800008ded0 <fmt.init>:
ffffff800008ded0: 010db503 ld a0,16(s11)
ffffff800008ded4: 00256863 bltu a0,sp,ffffff800008dee4 <fmt.init+0x14>
ffffff800008ded8: fffd8f97 auipc t6,0xfffd8
ffffff800008dedc: cd8f82e7 jalr t0,-808(t6) # ffffff8000065bb0 <runtime.morestack_noctxt>
ffffff800008dee0: ff1ff06f j ffffff800008ded0 <fmt.init>
ffffff800008dee4: fe113023 sd ra,-32(sp)
ffffff800008dee8: fe010113 addi sp,sp,-32
這個函式也是理所當然的使用堆疊指標。最一開始,首先是從一個結構(s11
)裡面存取(ld
)一個邊界值(a0
),然後比較(bltu
)堆疊指標與這個邊界。
如果沒有超出,就能夠跳到正常的函式執行部分(...dee4
),而這裡也是直接使用堆疊,將回傳位址(ra
)存入(sd
)堆疊(sp
)上的某個更低的位址,然後將堆疊指標調整(addi ... -32
)到該處。
如果超出的話,則會執行一個函式呼叫,名為 runtime.morestack_noctxt
,我們可以粗略地理解為,它會設法取得更多可用的空間以作為堆疊使用。
回傳到前一個函式的時候,總得讓前一個函式可以繼續執行下去。目前 Golang 在 RISC-V 的支援應該還只能算很初步,所以通常結尾也是將堆疊指標歸位,並提取回傳指標而已。一樣取 fmt.init
的例子的話:
ffffff800008dfb4: 00013083 ld ra,0(sp)
ffffff800008dfb8: 02010113 addi sp,sp,32
ffffff800008dfbc: 00008067 ret
我們當初是從 linux/riscv64
系統組合複製過來的,在 _rt0_riscv64_linux
早早數行就直接使用了 X2
暫存器來取得命令列參數。由此我們可知,這完全是仰賴底下的作業系統的幫助才能夠這麼做。opensbi/riscv64
現在會遇到問題,就是因爲 OpenSBI 初始化了之後,跳到作業系統模式之前,也不會特地為了它去清除自己暫存器的狀態,因此是保留了最後跳躍之前的堆疊指標狀態。
所以,正常來講,給使用者應用程式使用的 Golang 執行期,是不需要煩惱如何初始化堆疊指標這件事情的。但顯然 ethanol 應該更考慮 Linux 的使用方法才是。
但是,筆者也打算等到虛擬記憶體啟動之後,再來考慮如何設置。
筆者感謝友人阿 Jay 詳細的審稿與提問,這裡就用以回應釋疑。
他的問題是:「hw.go 在 OpenSBI 所扮演的角色具體來說是什麼?是系統 boot 起來的 image 嗎?應該不只是可以在 riscv64 的 OpenSBI OS 上執行的程式吧?」
正式回答之前,筆者得先澄清先前的一個主要編輯錯誤:hw.go
檔案其實不該正式存在。在 Hoddarla 的 github 上面,都已經正名成為 ethanol/ethanol.go
檔案。雖然其內容仍然是最基本的 Hello World 程式。無論如何,要回答這個檔案本身到底有何意義,筆者必須先補充先前潦草的語義才行;我想,輔佐以一些圖像,或許有助於理解其中關鍵。
首先還是得回到一般的構圖:作業系統提供諸般系統呼叫與服務,而使用者空間程式使用那些服務。
+----------+
| program |
+----------+
-------------- system call interface
+----------+
| OS |
+----------+
但由於過去數十年之前就已經有無數系統軟體巨人為今日的軟體世界打好基礎,因此我們就算自稱軟體工程師,也鮮少從頭開始寫,而是會利用許多既有的框架與工具。C 語言工程師從 main 函數開始寫,實際上,在作業系統交付執行權給使用者程式,到 main 函數執行之前統稱為 C 語言執行期(C runtime)的部分也幫忙做了不少事情。Golang 的場合也是相同。也就是說我們大致上可以有這個稍微完整一點的構圖:
+---------------+
| Go program |
+------------+ |
| Go runtime | |
+------------+--+
------------------- system call interface
+---------------+
| OS |
+---------------+
所謂 Go program 是指一般 Golang 程式設計師所撰寫的部分,從 package main
的所有東西到所有它所使用的相依函式庫。這些東西,都還是需要 Golang 執行期設置垃圾回收(garbage collection)以及共常式排程(Goroutine scheduling)等機制。再擴充一步,符合一般 Unix-like RISC-V 系統的構圖:
+---------------+
| Program |
+---------------+
------------------- system call interface
+---------------+
| Linux |
+---------------+
=================== supervisor binary interface
+---------------+
| OpenSBI |
+---------------+
但 OpenSBI 是貼近硬體的韌體層,確實它負責提供一些服務給 Linux,但這之間的依賴性與服務的方便性遠遠不及 Linux 透過系統呼叫層提供給使用者程式的各種服務。所以當我們觀察 Hoddarla 想要達成的目標時,就會變成這樣的構圖(Linux 放在旁邊供參照,且略去使用者空間層):
| +---------------+
+-------+ | | Go program |
| Linux | | +------------+ |
+-------+ | | Go runtime | |
| +------------+--+
============================ supervisor binary interface
+---------------+
| OpenSBI |
+---------------+
所以可以開始來回答筆者友人的問題了:
ethanol.go 在 OpenSBI 所扮演的角色具體來說是什麼?
就相當於上圖 Go program
的部分。
是系統 boot 起來的 image 嗎?
整個第零章相當於是讓上圖右上的整個方格能夠建立起來成為一個可執行檔。但是要調整成能夠和 OpenSBI 協同合作、利用 RISC-V 核心功能、甚至日後提供使用者空間各種服務,Golang 執行期也是不可忽略的一大項目。事實上,本系列文將會只有極小的篇幅與執行期完全無關。
應該不只是可以在 riscv64 的 OpenSBI OS 上執行的程式吧?
OpenSBI 只能算是運行在機器模式(machine mode)的韌體。承上,ethanol.go 基本上現在的意義只是讓右上方格能夠成型。就像是沒有 main 函數的 C 程式將會無法編譯完成一個可執行檔一樣,ethanol.go 裡面哪怕是只有一個基本的 Hello World,也正扮演這個角色。
今天主要是瀏覽些 Linux 程式碼,作為接下來的參考。但目前為止幾個資訊我們都還沒有打算利用或是處理:
明天開始我們試著利用 GDB 除錯器,看看能夠走到哪裡吧。各位讀者,我們明日再會!