本節是以 Golang 上游 4b654c0eeca65ffc6588ffd9c99387a7e48002c1 為基準做的實驗
予焦啦!昨日打通了 throw
,所以之後我們可以獲得更清楚的訊息。但根本問題是,記憶體管理抽象層的未實作。我們並不適合直接跳入實作的部分,因為 Hoddarla/ethanol 的狀況相當特殊,我們應該全盤考慮之後再決定怎麼做。
我們一開始豪爽地為了 ethanol 設置了基底位址,在 0xffffffc000000000
。這個數字來自 sv39
定址模式的限制,那就是 VA[63:39]
必須都與 VA[38]
相同這個條件,再加上我們讓其他 VA[37:0]
都為 0。
記憶體映像即是,一個作業系統,在偌大的可用的虛擬位址空間之中,如何規劃每個區塊的用途。我們現在在一個起始階段,所以大可以參考一下其他系統組合如何做。老樣子,我們觀察一下 Linux 怎麼安排的。
相關的程式碼可以參考這個。其實就是,從 KERNEL_LINK_ADDRESS
之後,許多不同的區塊以之為基準開始加上一個一個區塊大小(_SIZE
的那些數值),而形成的不同記憶體部位。總和起來,參考這個圖例更簡單一些。
0x0 - 0x40_0000_0000
:使用者空間的虛擬位址0x40_0000_0000 - 0xffff_ffc0_0000_0000
:根據 sv39
限制,不能使用的區域。0xffff_ffc0_0000_0000 -
:Linux 根據不同的用途,切分成大小不同的幾塊分別做使用。並且我們要記得,這只是虛擬位址,實質上要使用的話,我們仍然要經歷前兩日的建立頁表步驟,才能夠對應到真正的物理位址。而物理位址該怎麼對應到這些虛擬位址呢?以 Linux 來講,除了直接映射(direct map)的區塊之外,其餘的區塊很有可能都是一頁一頁對應,物理上也不連續的。
問題是,我們該怎麼分配這個對應?前兩日的 0xffffffc000[0:1]00000
到 0x80[2:3]00000
還算是一個直接的中型頁面分配,但物理位址遠比虛擬位址有限,而且隨着不同的硬體還可能會變動範圍。所以在我們真正解決 ethanol 的記憶體映像分配問題之前,我們必須先來研究一下怎麼能夠讓系統在執行期取得實體記憶體大小資訊。
這個機制,當然就是先前也提及過的裝置樹(DTB)。筆者並不打算在這個階段認真實作一個完整的裝置樹剖析器(device tree parser),而是會先採用快速暴力的方式,取得需要的資訊就走。
本節沒有詳細說明的資訊,可以參考這篇非常詳盡、甚至還有規格的說明文。
在 1.1 的前期分析時,我們就曾經印出 QEMU 模擬器傳給系統的裝置樹資訊了。但 OpenSBI 或 ethanol 核心在執行期能夠取得的是已經編譯過的二進位格式。因此我們必須知道如何解析才行。
筆者現在只對記憶體資訊有需求,也就是起始位址和大小。這些資訊在裝置樹的 memory
結點當中:
memory@80000000 {
device_type = "memory";
reg = <0x00 0x80000000 0x00 0x20000000>;
};
這代表起始位址在 0x80000000
,大小為 512MB。至於兩個數字前置的 0x00
為何需要,是因為這裡想要表達兩個(起始位置與大小)64 個位元的數字。裝置樹規格定義了一些方法讓系統可以描述他們想要呈現數字的方式,而 QEMU 模擬器合成的裝置樹當中,是以 32 位元為一個數字的單位,所以要表達這兩個數字的話,儘管它們高位元的前半部都沒有值,也還是得各放兩組 32 位元數字。
若要試圖直接取得這兩個資訊,相關的內容位址如下:
0x82200000
:這是 OpenSBI 預設從 a1
暫存器給予下一級系統軟體的裝置樹所在位址。當然,是實體位址。以下使用 DTB_BASE
代稱。DTB_BASE[0:4]
(第 0 個位元組到第 4 個,以下同):格式識別用的模式,這個內容必須是 0xd00dfeed
,才會被當作裝置樹二進位檔來解析。看起來很奇特,但其實這是檔案格式裡面慣用的 MAGIC
欄位。我們這裡就直接跳過這個檢查吧。DTB_BASE[4:8]
:這個裝置樹二進檔的大小,以位元組記。需注意的是,橫跨多個位元組的數字,在裝置樹的慣例裡面都是採取高位數在前的格式(big endian)。我們實際上也不見得需要這個數字。DTB_BASE[8:12]
:裝置區的偏移量。我們需要這個,因為我們要能夠找到 memory
節點。DTB_BASE[12:16]
:特徵名稱(attribute name)的偏移量。我們需要這個,因為我們要能夠找到 memory
節點中的 reg
特徵。\0
字元分隔開的一連串字串。這些字串本身在特徵名稱區內的偏移量,會被用來當作裝置區裡面的參照。後續小節會有實例。由於預設的 0x82200000
不在我們已經配置的範圍,且由於我們還沒有一套很完善的記憶體管理機制(只有第一個中型頁面的 2MB 屬於有建立頁表的範圍),我們其實不適合處理這個實體位址。所以我們先把它擺到整個 ethanol 系統映像的尾端,這麼一來,當虛擬位址生效時,我們便也可以從虛擬位址的部分繼續存取裝置樹資訊。
+TEXT setupFDT(SB),NOSPLIT|NOFRAME,$0
+ // Move DTB from T0 to T1, totally T2 bytes
+ MOV A1, T0
+ MOV $runtime·end(SB), T1
+ LBU 4(T0), T2
+ SLL $8, T2, T2
+ LBU 5(T0), T3
+ ADD T2, T3, T2
+ SLL $8, T2, T2
+ LBU 6(T0), T3
+ ADD T2, T3, T2
+ SLL $8, T2, T2
+ LBU 7(T0), T3
+ ADD T2, T3, T2
+
+move_dtb:
+ LBU 0(T0), T4
+ SB T4, 0(T1)
+ ADD $1, T0, T0
+ ADD $1, T1, T1
+ ADD $-1, T2, T2
+ BGT T2, ZERO, move_dtb
+
+ RET
+
TEXT _rt0_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
- MOV $0x48, A0
- MOV $1, A7
- MOV $0, A6
- ECALL
+ CALL setupFDT(SB)
MOV $runtime·bss(SB), T0
砍掉一開始的
H
字元,是因為這個其實現在已經不需要了。
若要在一般 Golang 檔案裡面存取裝置樹,有很多種作法,其中最直接的是利用內建的 string
型別。為此,我們可以在 setupFDT
中新增:
+TEXT setupFDT(SB),NOSPLIT|NOFRAME,$0
+ // Move DTB from T0 to T1, totally T2 bytes
+ MOV A1, T0
+ MOV $runtime·end(SB), T1
...
+ ADD T2, T3, T2
+ // format FDT as string part 1: address and length
+ MOV $runtime·fdt(SB), T3
+ MOV T1, 0(T3)
+ MOV T2, 8(T3)
Golang 的 string
型別包含位址與長度資訊,這裡位址給了裝置樹的新起點,長度則給算好的長度。但因為這裡新起點仍然是物理位址,必須要在啟動完虛擬記憶體之後加上偏移量(我們先前將虛擬位址到物理位址的偏移量儲存在 AddrOffset
變數之中):
TEXT _rt1_riscv64_opensbi(SB),NOSPLIT|NOFRAME,$0
// Trap vector for debugging
MOV $early_halt(SB), A0
CSRRW CSR_STVEC, A0, X0
+
+ // format FDT as string part2: relocate address
+ MOV $runtime·fdt(SB), T3
+ MOV 0(T3), T1
+ MOV $runtime·AddrOffset(SB), T2
+ LD 0(T2), T2
+ ADD T1, T2, T2
+ MOV T2, 0(T3)
而這個 fdt
變數本身,我們可以在 osinit
中宣告,位在 src/runtime/os_opensbi.go
裡面:
+ var fdt string
func osinit() {
ncpu = 1
getg().m.procid = 2
physPageSize = 4096
+ for i := 0; i < len(fdt); i++ {
+ print(fdt[i], "\n")
+ }
}
這當然只是範例的使用方式而不是正式的系統行為,但可以展示這麼做能夠將整個裝置樹當作內建的字串來存取。
重寫 osinit
存取的部分:
func osinit() {
...
memBase, memSize := ethanol.GetMemoryInfo(fdt)
print("Memory Base:", memBase, "\n")
print("Memory Size:", memSize, "\n")
}
然後,在 runtime/ethanol
組件當中實作這個 GetMemoryInfo
函式:
// We don't have any advanced features yet, so
// hardcoded to search for "memory" and "reg"
func GetMemoryInfo(fdt string) (uint, uint) {
nodeOffset := getWord(fdt, 8)
strOffset := getWord(fdt, 12)
// find "reg" directly
regStrOffset, found := getStrOffset(fdt, "reg", strOffset, getWord(fdt, 32))
if !found {
return memoryNotFound()
}
裝置樹格式當中的兩個主要部分,分別是節點區域和字串區域。這個函數一開始先分別取得兩者的偏移量,分別位在第 8 與 12 個位元組的標頭內位址。這裡略去 getWord
函數的實作細節,因為只是單純的字串處理。
初始化在這個早期的階段,沒有太多進階功能,甚至也包含
string
組件的存取。但所幸 Golang 本身的基本語法就足堪使用。
然後透過 getStrOffset
函數,取得 reg
字串在字串區域中的偏移量。如前所述,這個偏移量會在節點區域中被參照。這樣設計的目的是,裝置樹中的多個節點內部,往往會定義類似的性質。這些性質的字串若是各自存在節點區域內,每個字串很容易就會超過 4 個位元組,而這本身已經可以表示相當大範圍的整數了!
因此,將所有的性質字串都藏在另外一個區域中,再使用偏移量去表示它們,就可以大幅降低空間用量。reg
性質是 memory
節點必備,用來表示位址與大小的一個性質,所以這裡先搜尋之。
// TODO: abstract this better later. Technically it is wrong.
getReg := false
for i := nodeOffset; i < nodeOffset+getWord(fdt, 36); i += 4 {
if getWord(fdt, i) == FDT_BEGIN_NODE {
i += 4
if fdtsubstr(fdt, "memory", i) {
for fdt[i] != 0 {
i++
}
i = i / 4 * 4
getReg = true
}
} else if getWord(fdt, i) == FDT_PROP && getReg {
if getWord(fdt, i+8) == regStrOffset {
return getWord(fdt, i+16), getWord(fdt, i+24)
}
}
}
return memoryNotFound()
要實作一個完整的裝置樹格式剖析器(parser)的話,非得用上包含堆疊(stack)資料結構(最簡單的方式是遞迴)才行,但這裡我們追求簡單暴力,所以只要看到有名為 memory
的節點,就開始正式剖析該節點內的性質。只有看到 reg
的偏移量出現了才去取得。
如同註解說的,這個技術上來說是錯誤的剖析。不只是整個裝置樹結構,還有找到 reg
偏移量之後,回傳的兩個數字。這兩個數字都只來自 4 個位元組,但實際上,整個 reg
性質包含 16 個位元組,記憶體位址和大小分別以 8 個位元組表示。但我們已經知道在 QEMU 這個模式之中,前 4 個位元組都是零,所以就先跳過了。
以下實驗可以在今日更新的 Hoddarla repo 取得。
Boot HART MIDELEG : 0x0000000000000222
Boot HART MEDELEG : 0x000000000000a109
Memory Base:2147483648
Memory Size:536870912
fatal error: runtime: cannot allocate memory
runtime stack:
runtime.throw({0xffffffc000064339, 0x1f})
...
大成功!
予焦啦!今天運用了兩個階段的手續,確保裝置樹內容能夠在虛擬記憶體位址啟用之後使用。並且運用了 Golang string 型別的知識,讓剖析(暴力版的)裝置樹成為不到一百行 Golang 能夠解決的事情。如此一來,我們就已經取得所有的實體位址資訊了。再回顧一次,我們之所以不直接實作記憶體管理,是因為如果連物理位址空間都無法掌握的話,以虛擬位址瞎轉根本談不上什麼管理。所謂掌握,當然也至少要頭尾都知道才行。所以我們才先來這裡解決裝置樹的部份。
明日,讓我們繼續面對 Golang 記憶體管理抽象層的問題。各位讀者,我們明天再會!