iT邦幫忙

2021 iThome 鐵人賽

DAY 16
1
Software Development

予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索系列 第 16

予焦啦!參數與環境變數

本節是以 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.srt0_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 函數即是在前小節承接堆疊內變數的函數。它初始化全域變數 argcargv,但 Golang 不像 C 語言那樣將這兩個值暴露給 imt main(int argc, char *argv[],而是在之後經過 goargs 函數來將另外一個全域變數 argslice 做成字串切片(slice,很抱歉我知道這翻譯很爛),並讓類似 C 的位元陣列指標,能夠使用 Golang 的方式表示。

這個之後正常運行的話,會作為 os 套件包的 Args 變數,供命令列程式提取參數使用。範例可參考這個

中間省略的細節在 src/runtime/runtime.go 裡面,透過 go:linkname 這個編譯器指令,將 argslice 轉手做成另一個字串切片的函數,連結到 os 套件包裡面,並在 osinit 函數使用來設置 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 指標)。

比較:一群字串 vs. 雙重 byte 指標

在一整個裝置樹字串裡面,我們當然可以找到 chosen 節點裡面的 bootargs 性質的值的位置,也就是我們真正感興趣的部分。但這只會是一個起始位址。所有我們想要取得的參數可以在那個位址開始的區域內找到,並且以空格間隔開來。在裝置樹中的,只是一群字串而已。

但一般來說,C 和 Golang 執行期期待的是已經格式化過的字串陣列(char *argv[]) 或是雙重的位元組指標(argv **byte ),並且每個字串遵守 C 語言傳統,以 NULL(\0)結尾。在這個情況下,argv 這個陣列之內必須收集所有的字串的起始位址,使得 argv[0]argv[argc-1] 之間的每一個位址都可以被解讀成一個 C 傳統字串

也就是說,我們還得想辦法將前者轉換成後者。

argv 格式化

args 函數當中,全域變數 argcargv 即被賦值。然而,我們先前出問題的 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

回傳值的寫回,這也就不必多說了。

小細節:argv 該放哪?

這裡是從 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 裡面的 goargsgoenvs_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 原有的函數能夠將它們轉換成字串切片,之後能夠更方便使用。各位讀者,我們明日再會!


上一篇
予焦啦!Golang 記憶體初始化
下一篇
予焦啦!問題分析
系列文
予焦啦!Hoddarla 專案起步:使用 Golang 撰寫 RISC-V 作業系統的初步探索32

尚未有邦友留言

立即登入留言