本節是以 Golang 上游 8854368cb076ea9a2b71c8b3c8f675a8e19b751c 為基準做的實驗
予焦啦!經過了第零章確保開發工具、第一章起步除錯、第二章的基本記憶體機制,到目前為止也來到了這次鐵人賽的中點;考量到目前的狀態,筆者不打算進入第三章,而是以斷章來稱呼這中間的過渡期,就算是沒有中場休息的休息吧!
無論如何,截至昨天為止,記憶體看起來滿足了 Golang 的競技場、元資料(metadata)、heap 的配置,也安然度過包含了許多記憶體初始化部分的 mcommoninit
函數了。但最後,我們是卡在 goargs
函數。筆者作為一個母語是 C 且時常使用命令列程式的人,顧名思義的直覺是這一定和命令列參數有關。如果是一般的 Golang 命令列程式的話,它們在啟用之時,作業系統會妥善地服務它們,將來自使用者的命令列參數擺放在它們執行時可以存取到的位址,以及整理好環境變數讓程式得以取用。但如果是自己將要成為作業系統映像的 ethanol,顯然就沒有如此妥善的機制了。
以上面的直覺為方向,今天就再度來試著渡過這個部分吧。
先以 linux/riscv64 系統組合為例,首先是入口之後不久就設定的兩個值:
TEXT _rt0_riscv64_linux(SB),NOSPLIT|NOFRAME,$0
MOV 0(X2), A0 // argc
ADD $8, X2, A1 // argv
至於為什麼一開始進來就在堆疊指標有這些資料,就是前情提要所指,所謂作業系統的妥貼服務。之後進到 src/runtime/asm_riscv.s
的 rt0_go
函數,
// func rt0_go()
TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
// X2 = stack; A0 = argc; A1 = argv
ADD $-24, X2
MOV A0, 8(X2) // argc
MOV A1, 16(X2) // argv
這裡,兩組資料從暫存器進入到堆疊上,就是準備要由其他函數來呼叫了。之後的流程可以參考拙作。
goargs
附近func argv_index(argv **byte, i int32) *byte {
return *(**byte)(add(unsafe.Pointer(argv), uintptr(i)*goarch.PtrSize))
}
func args(c int32, v **byte) {
argc = c
argv = v
sysargs(c, v)
}
func goargs() {
if GOOS == "windows" {
return
}
argslice = make([]string, argc)
for i := int32(0); i < argc; i++ {
argslice[i] = gostringnocopy(argv_index(argv, i))
}
}
args
函數即是在前小節承接堆疊內變數的函數。它初始化全域變數 argc
與 argv
,但 Golang 不像 C 語言那樣將這兩個值暴露給 imt main(int argc, char *argv[]
,而是在之後經過 goargs
函數來將另外一個全域變數 argslice
做成字串切片(slice,很抱歉我知道這翻譯很爛),並讓類似 C 的位元陣列指標,能夠使用 Golang 的方式表示。
這個之後正常運行的話,會作為 os
套件包的 Args
變數,供命令列程式提取參數使用。範例可參考這個。
中間省略的細節在
src/runtime/runtime.go
裡面,透過go:linkname
這個編譯器指令,將argslice
轉手做成另一個字串切片的函數,連結到os
套件包裡面,並在os
的init
函數使用來設置Args
。所以許多 Golang 的命令列程式指南裡面都會提到,要引用os
套件並使用os.Args
來取得命令列參數,也就是相當於 C 語言 main 函數的char *argv[]
參數的效果。
作業系統映像,當然就沒有所謂的命令列參數,但以使用慣例來講,還是有核心參數(kernel argument, 或 boot argument)之類的,傳遞資料給核心的方式存在。往往是透過啟動載入器傳遞,或是我們今天的狀況,若是在 QEMU 模擬器給了啟動參數(-append
):
$ qemu-system-riscv64 \
-smp 4 \
-M virt,dumpdtb=dtb \
-m 512M \
-nographic \
-bios ../misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
-kernel ethanol/goto/goto.bin \
-device loader,file=../ethanol/hw,addr=0x80201000,force-raw=on \
-append "arg1 arg2 arg3 arg4"
執行之後,傳遞到系統的裝置樹裡面,就有這樣的資訊:
chosen {
bootargs = "arg1 arg2 arg3 arg4";
stdout-path = "/soc/uart@10000000";
};
所以,我們可以再使用裝置樹,將這些資訊撈出來,然後設法走回原本的流程,讓它能夠自然建立。
以之前使用裝置樹的經驗,我們知道它早已作為字串型別建立起來了。但是,障礙在於,Golang 期待 argv
這個全域變數的型別為 **byte
(以下稱為雙重 byte 指標)。
在一整個裝置樹字串裡面,我們當然可以找到 chosen
節點裡面的 bootargs
性質的值的位置,也就是我們真正感興趣的部分。但這只會是一個起始位址。所有我們想要取得的參數可以在那個位址開始的區域內找到,並且以空格間隔開來。在裝置樹中的,只是一群字串而已。
但一般來說,C 和 Golang 執行期期待的是已經格式化過的字串陣列(char *argv[]
) 或是雙重的位元組指標(argv **byte
),並且每個字串遵守 C 語言傳統,以 NULL(\0
)結尾。在這個情況下,argv
這個陣列之內必須收集所有的字串的起始位址,使得 argv[0]
到 argv[argc-1]
之間的每一個位址都可以被解讀成一個 C 傳統字串。
也就是說,我們還得想辦法將前者轉換成後者。
argv
格式化在 args
函數當中,全域變數 argc
與 argv
即被賦值。然而,我們先前出問題的 goargs
才是真正將 C 傳統的字串陣列轉換為 Golang 較容易使用的字串切片之所在。關鍵在於,我們整個第二章打通了的執行期記憶體初始化要是尚未安頓好,許多原先的 Golang 寫法根本無法使用。
在兩個函數之間,我們還有一個作業系統相依(OS-dependent)的函數 osinit
,先前我們已經在這裡安插過裝置樹的第一個使用者 GetMemoryInfo
,取得了實體記憶體的位置與大小資訊。現在,我們也可以在這個階段處理參數的格式化。
func osinit() {
ncpu = 1
@@ -121,6 +122,8 @@ func osinit() {
// For the memory map of ethanol, check
// src/runtime/ethanol/README.
baseInit()
+ argc = ethanol.GetArgc()
+ print("argc: ", argc, "\n")
}
這裡名為 GetArgc
,因為我們會走訪裝置樹裡面的 bootargs
那一段,數一數到底有幾個。這個實作在 src/runtime/ethanol/fdt.go
裡面:
+func GetArgc() int32 {
+ nodeOffset := getWord(fdt, 8)
+ strOffset := getWord(fdt, 12)
+ // find "bootargs" directly
+ regStrOffset, found := getStrOffset(fdt, "bootargs", strOffset, getWord(fdt, 32))
+ if !found {
+ return argsNotFound()
+ }
+
+ var l uint
+ var off uint
+ getReg := false
+ for i := nodeOffset; i < nodeOffset+getWord(fdt, 36); i += 4 {
+ if getWord(fdt, i) == FDT_BEGIN_NODE {
+ i += 4
+ if fdtsubstr(fdt, "chosen", i) {
前面的邏輯與 GetMemoryInfo
前面完全一樣,都是先在字串區撈出所欲搜尋的屬性的位址,然後定位出要搜尋的節點。之後,
+ if getWord(fdt, i+8) == regStrOffset {
+ l = getWord(fdt, i+4)
+ off = i + 12
+ break
...
+ if l > 1 {
+ return setArgv(fdt, off) + 1
+ }
+ return argsNotFound()
+}
l
從 4 個偏移量的位址取得整個 bootargs
的大小之後,我們在最後有一個基本判斷,那就是 l
必須大於 1。這是因為,通常如果是不給 append
參數而啟動 QEMU,那 bootargs
仍然會佔有一個空字元。若是運行在沒有 bootargs
的組態之下的話,l
就會是 0。
所以,非普通(non-trivial)的狀況,呼叫到 setArgv
,這又是為何?這是因為,筆者打算,就是因為需要從頭到尾掃過一次參數區,所以我們更能夠趁機整理出一個字串陣列。演算法也很簡單,就是每次找到空格,就將它取代成空字元,並且記錄下它之後的字元的位址。作為特例,第一個參數一開始就紀錄位址。這樣一來,最後還會缺一個參數計數,因為最後一個參數是以空字元結尾而非空格;這也就是最後一個加一的結算。
setArgv
的實作在 src/runtime/ethanol/misc.s
:
+// func setArgv(s string, off uint) uint32
+TEXT runtime∕ethanol·setArgv(SB), NOSPLIT|NOFRAME, $0-28
+ MOV s+0(FP), A0
+ MOV off+16(FP), A1
+ ADD A0, A1, A1
+
+ // get argv
+ MOV $runtime·argv(SB), A0
+ MOV 0(A0), A0
...
第一個參數是字串,以 Golang 的 ABI 而言,字串佔去兩個指標的長度,所以這裡只取第一個(0(FP)
)指標代表該字串的位址。取得偏移量之後加上原本字串內容的位址之後,就是 bootargs
的內容了。然後再取得 argv
全域變數,就算是準備好了。
+ MOV $0, A2
+ MOV $0x20, A3
+setarg:
+ MOV A1, 0(A0)
+ ADD $8, A0, A0
+findnull:
+ MOVBU 0(A1), A5
+ BEQ ZERO, A5, endarg
+ BNE A3, A5, skip
+ MOVB ZERO, 0(A1)
+ ADD $1, A2, A2
+ ADD $1, A1, A1
+ JMP setarg
+skip:
+ ADD $1, A1, A1
+ JMP findnull
+endarg:
A2
是作為 argc
的計數使用的,一開始清空;A3
則是一個常數,儲存的 0x20
是空格的 ASCII 碼編號,用來方便比對用的。
setarg
就是在 argv
所在的位置寫下當前的參數的位址,並且將 argv
的陣列索引向前推進一個指標的大小,在這裡是 8 個位元組。
findnull
會一個一個檢驗當前的參數的內容的字元。要是已經是空字元了的話,就走到最後去,因為整個參數區已經被走訪完畢。如果不是,則比對是否為空格。若是空格,則將之寫成空字元,且紀錄 argc
計數。若否,則單純繼續推進。
+
+ MOVW A2, ret+24(FP)
+ RET
回傳值的寫回,這也就不必多說了。
這裡是從 osinit
呼叫進來,heap 或是競技場之類的 Golang 記憶體管理都還沒初始化,所以也沒辦法用很動態的方式直接生一塊出來用。又,我也不想要在資料區內再準備一塊專門給 argv
用的指標陣列,因為那麼一來我還得限定參數上限。
所以這裡我打算挪用虛擬位址 0xffffffc000000000-0xffffffc000002000
。確實這一塊正在被當作早期堆疊使用,但是堆疊是由高位往低位倒退的。如果是從頭開始放 argv
的話,只要兩個都不要長得太多,也就不至於相撞了。
再次地,這個的確也是不永續的技術債。
只要稍微調整一下就能夠支援環境變數了。我們的目標是像:
diff --git a/Makefile b/Makefile
index 571dbba..6fb48b1 100644
--- a/Makefile
+++ b/Makefile
@@ -7,8 +7,10 @@ run:
-m 512M \
-nographic \
-bios misc/opensbi/build/platform/generic/firmware/fw_jump.bin \
- -device loader,file=ethanol/goto/goto.bin,addr=0x80200000 \
+ -kernel ethanol/goto/goto.bin \
+ -append "ethanol arg1 arg2 env1=1 env2=abc" \
-device loader,file=ethanol/hw,addr=0x80201000,force-raw=on $(EXTRA_FLAGS)
這樣給定的命令列,有兩個參數,類似兩個環境變數的東西。至於環境變數通常是怎麼取得,可以觀察 goenvs_unix
函數
func goenvs_unix() {
// TODO(austin): ppc64 in dynamic linking mode doesn't
// guarantee env[] will immediately follow argv. Might cause
// problems.
n := int32(0)
for argv_index(argv, argc+1+n) != nil {
n++
}
envs = make([]string, n)
for i := int32(0); i < n; i++ {
envs[i] = gostring(argv_index(argv, argc+1+i))
print("envp[", i, "] = ", envs[i], "\n") // 這是筆者安插的,僅是為了觀察用
}
}
實際上,以一般使用者環境的期待來講,在大部分的類 Unix 作業系統之下,環境變數的位置在命令列參數之後相隔一個空字元。所以我們可以稍加修改先前的程式流程,改成讓它在迴圈裡面掃過字元的時候,觀察是否含有等號(ASCII 0x3d
):
index 75d01cc75e..fc76d8a217 100644
--- a/src/runtime/ethanol/misc.s
+++ b/src/runtime/ethanol/misc.s
@@ -24,21 +24,49 @@ TEXT runtime∕ethanol·setArgv(SB), NOSPLIT|NOFRAME, $0-28
MOV $0, A2
MOV $0x20, A3
+ MOV $0x3d, A4
setarg:
MOV A1, 0(A0)
ADD $8, A0, A0
findnull:
MOVBU 0(A1), A5
- BEQ ZERO, A5, endarg
+ BEQ ZERO, A5, end
+ BEQ A4, A5, env
以 env
符號標誌另外一個基本上一樣的階段,但是當前的參數段應該要挪到 argv
陣列內的後一格,且以一個空指標區隔參數與環境變數:
-endarg:
- MOVW A2, ret+24(FP)
+ // if fdt[i] == '='
+env:
+ MOV A2, A6
+ MOV -8(A0), A2
+ MOV ZERO, -8(A0)
+ MOV A2, 0(A0)
+ ADD $8, A0, A0
+ JMP findnull2
+setenv:
+ MOV A1, 0(A0)
+ ADD $8, A0, A0
+findnull2:
+ MOVBU 0(A1), A5
+ BEQ ZERO, A5, end
+ BNE A3, A5, skip2
+ // if fdt[i] == ' '
+ MOVB ZERO, 0(A1)
+ ADD $1, A1, A1
+ JMP setenv
+skip2:
+ // fdt[i] character is normal
+ ADD $1, A1, A1
+ JMP findnull2
+end:
+
+ MOVW A6, ret+24(FP)
RET
稍加擴充之後 setArgv
就可以也在掃過一遍裝置樹區塊之時,一併把環境變數也處理好了。
可以在今日上傳的 Hoddarla repo 執行以下實驗。
在 src/runtime/runtime1.go
裡面的 goargs
與 goenvs_unix
函數當中插入對應的印出訊息並試跑,得:
...
Reserve: 0x10000 bytes, at 0x0 but at 0xffffffc500010000
Map: 0x10000 bytes, at 0xffffffc500010000
argv[0] = ethanol
argv[1] = arg1
argv[2] = arg2
envp[0] = env1=1
envp[1] = env2=abc
panic: newosproc: not implemented
fatal error: panic on system stack
...
goroutine 1 [running]:
runtime.systemstack_switch()
I000000000000000f
0000000000000000
ffffffc00002b6c0
出現了新的錯誤、錯在新的位置,這些挑戰就留待下回分曉吧。
予焦啦!今天其實沒做什麼特別的事情,就是單純把原本在記憶體當中平鋪直敘的、代表命令列參數與環境變數的字元們,整理成符合 Golang 執行期所期待的結構,並讓 Golang 原有的函數能夠將它們轉換成字串切片,之後能夠更方便使用。各位讀者,我們明日再會!