Go 如何實現啓動參數的加載

剛學 Go 的同學一定思考過 Go 程序的啓動過程,關於這個問題可以看饒大的文章 Go 程序是怎樣跑起來的。今天我們將問題縮小,來學習 Go 程序是怎麼加載啓動參數,以及如何進行參數解析。

C 參數解析

學習過 C 語言的童鞋,一定對 argc 和 argv 不會陌生。

C 程序總是從主函數 main 開始執行的,而在帶參數的主函數中,依照慣例,會使用 argc 和 argv 的命名作爲主函數參數。

其中,argc (argument count)代表的是命令行參數個數,argv(argument value) 是用來存放指向參數的指針數組。

#include <stdio.h>

int main(int argc, char *argv[])
{
 printf("argc = %d\n",argc);
 printf("argv[0] = %s, argv[1] = %s, argv[2] = %s \n", argv[0], argv[1], argv[2]);
 return 0;
}

編譯執行以上 C 代碼,得到輸出如下

$ gcc c_main.c -o main
$ ./main foo bar sss ddd
argc = 5
argv[0] = ./main, argv[1] = foo, argv[2] = bar

那在 Go 語言中,又該如何獲取命令行參數呢?

os.Args 加載

同 C 一樣,Go 程序也是從 main 主函數開始(用戶層)執行,但主函數中並沒有定義 argc 和 argv。

我們可以通過 os.Args 函數,獲取命令行參數。

package main

import (
 "fmt"
 "os"
)

func main() {
 for i, v := range os.Args {
  fmt.Printf("arg[%d]: %v\n", i, v)
 }
}

編譯執行 Go 函數

 $ go build main.go
 $ ./main foo bar sss ddd
arg[0]: ./main
arg[1]: foo
arg[2]: bar
arg[3]: sss
arg[4]: ddd

同 C 一樣,第一個參數也是代表可執行文件。

加載實現

下文我們需要展示一些 Go 彙編代碼,爲了方便讀者理解,先通過兩圖瞭解 Go 彙編語言對 CPU 的重新抽象。

X86/AMD64 架構

Go 僞寄存器

Go 彙編爲了簡化彙編代碼的編寫,引入了 PC、FP、SP、SB 四個僞寄存器。

四個僞寄存器加上其它的通用寄存器就是 Go 彙編語言對 CPU 的重新抽象。當然,該抽象的結構也適用於其它非 X86 類型的體系結構。

回到正題,命令行參數的解析過程是程序啓動中的一部分內容。

以 linux amd64 系統爲例,Go 程序的執行入口位於runtime/rt0_linux_amd64.s

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
	JMP	_rt0_amd64(SB)

_rt0_amd64函數實現於 runtime/asm_amd64.s

TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

看到 argc 和 argv 的身影了嗎?在這裏,它們從棧內存分別被加載到了 DI、SI 寄存器。

rt0_go函數完成了 runtime 的所有初始化工作,但我們這裏僅關注 argc 和 argv 的處理過程。

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
	// copy arguments forward on an even stack
	MOVQ	DI, AX		// argc
	MOVQ	SI, BX		// argv
	SUBQ	$(4*8+7), SP		// 2args 2auto
	ANDQ	$~15, SP
	MOVQ	AX, 16(SP)
	MOVQ	BX, 24(SP)
	...
	MOVL	16(SP), AX		// copy argc
	MOVL	AX, 0(SP)
	MOVQ	24(SP), AX		// copy argv
	MOVQ	AX, 8(SP)
	CALL	runtime·args(SB)
	CALL	runtime·osinit(SB)
	CALL	runtime·schedinit(SB)
	...

經過一系列操作之後,argc 和 argv 又被折騰回了棧內存 0(SP)8(SP) 中。

args 函數位於runtime/runtime1.go

var (
 argc int32
 argv **byte
)

func args(c int32, v **byte) {
 argc = c
 argv = v
 sysargs(c, v)
}

在這裏,argc 和 argv 分別被保存至變量runtime.argcruntime.argv

rt0_go函數中調用執行完args函數後,還會執行schedinit

func schedinit() {
  ...
 goargs()
 ...

goargs實現於runtime/runtime1.go

var argslice []string

func goargs() {
 if GOOS == "windows" {
  return
 }
 argslice = make([]string, argc)
 for i := int32(0); i < argc; i++ {
  argslice[i] = gostringnocopy(argv_index(argv, i))
 }
}

該函數的目的是,將指向棧內存的命令行參數字符串指針,封裝成 Go 的 string類型,最終保存於runtime.argslice

這裏有個知識點,Go 是如何將 C 字符串封裝成 Go string 類型的呢?答案就在以下代碼。

func gostringnocopy(str *byte) string {
 ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
 s := *(*string)(unsafe.Pointer(&ss))
 return s
}

func argv_index(argv **byte, i int32) *byte {
 return *(**byte)(add(unsafe.Pointer(argv), uintptr(i)*sys.PtrSize))
}

func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {
 return unsafe.Pointer(uintptr(p) + x)
}

此時,Go 已經將 argc 和 argv 的信息保存至runtime.argslice中,那聰明的你一定能猜到 os.Args 方法就是讀取的該 slice。

os/proc.go中,是它的實現

var Args []string

func init() {
 if runtime.GOOS == "windows" {
  // Initialized in exec_windows.go.
  return
 }
 Args = runtime_args()
}

func runtime_args() []string // in package runtime

runtime_args方法的實現是位於 runtime/runtime.go中的os_runtime_args函數

//go:linkname os_runtime_args os.runtime_args
func os_runtime_args() []string { return append([]string{}, argslice...) }

在這裏實現了runtime.argslice的拷貝。至此,os.Args方法最終成功加載了命令行參數 argv 信息。

總結

本文我們介紹了 Go 可以利用os.Args解析程序啓動時的命令行參數,並學習了它的實現過程。

在加載實現的源碼學習中,我們發現如果從一個點出發,去追溯它的實現原理,這個過程並不複雜,希望童鞋們不要懼怕研究源碼。

os.Args方法將命令行參數存儲在字符串切片中,通過遍歷即可提取它們。但在實際開發中我們一般不會直接使用os.Args方法,因爲 Go 爲我們提供了一個更好用的 flag 包。但鑑於篇幅原因,該部分的內容以後再寫了。

參考

go 語言高級編程 -------- 彙編語言部分學習筆記:https://blog.csdn.net/cyq6239075/article/details/106480140

Go 程序是怎樣跑起來的:https://mp.weixin.qq.com/s/Rewl0DKnq6CY53m5D3G2qw

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0Hgbb-WZwX9HUeY3PiL2mw