予焦啦!参数与环境变数

本节是以 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 原有的函数能够将它们转换成字串切片,之後能够更方便使用。各位读者,我们明日再会!


<<:  [Day25]Primary Arithmetic

>>:  【Day10】Git 版本控制 - 将档案 push 到 GitHub 的懒人包

iOS APP 开发 OC 第十三天,测试网路状态 iOS Reachability

iOS APP 开发 OC 第十三天,测试网路状态 iOS Reachability tags: O...

Day28:阿赖耶识

程序写了老半天,说到底就是为了处理资料。 不管处理逻辑使用了多少技术,到头来终究会得出一个结果,并且...

Day 10 - Algebraic structure

yo, what's up 本章要来介绍 FP 的重要观念,Algebraic structure!...

应用程序快速更新还原,让服务持续运作不中断,公司财源滚滚,老板开心,大家开心

财源滚滚 今日会详细讲解Service和Deployment的功能 Service 在[Day22]...

[Day08] 第八章-Laravel的CRUD操作及一些简单指令

前言 今天会介绍laravel一些简单的指令 以及建立路由还有CRUD方法 跟资料库连线喔!! 目标...