首页 / 国际 / 正文

stack overflow at line(万字长文详解 Go 程序是怎样跑起来的?- CSDN 博文精选)

放大字体  缩小字体 来源:芦根的作用 2026-04-17 16:43  浏览次数:8

责编 | 屠敏

刚开始写这篇文章的时候,目标非常大,想要探索 Go 程序的一生:编码、编译、汇编、链接、运行、退出。它的每一步具体如何进行,力图弄清 Go 程序的这一生。

在开始本文之前,先推荐一位头条大佬的博客——《面向信仰编程》,他的 Go 编译系列文章,非常有深度,直接深入编译器源代码,我是看了很多遍了。博客链接可以从参考资料里获取。

下面是文章的目录:

我们从一个 HelloWorld 的例子开始:

package main 
import "fmt"
funcmain{
fmt.Println("hello world")
}

用 vim 打开 hello.go 文件,在命令行模式下,输入命令:

:%!xxd

最左边的一列代表地址值,中间一列代表文本对应的 ASCII 字符,最右边的列就是我们的代码。再在终端里执行 man ascii:

当然,更深入地看,计算机中的所有数据,像磁盘文件、网络中的数据其实都是一串比特位组成,取决于如何看待它。在不同的情景下,一个相同的字节序列可能表示成一个整数、浮点数、字符串或者是机器指令。

Go 程序并不能直接运行,每条 Go 语句必须转化为一系列的低级机器语言指令,将这些指令打包到一起,并以二进制磁盘文件的形式存储起来,也就是可执行目标文件。

完成以上各个阶段的就是 Go 编译系统。你肯定知道大名鼎鼎的 GCC(GNU Compile Collection),中文名为 GNU 编译器套装,它支持像 C,C++,Java,Python,Objective-C,Ada,Fortran,Pascal,能够为很多不同的机器生成机器码。

在接下来的文章里,我们将探索 编译和 运行的过程。

Go 源码里的编译器源码位于 src/cmd/compile 路径下,链接器源码位于 src/cmd/link 路径下。

我比较喜欢用 IDE(集成开发环境)来写代码, Go 源码用的 Goland,有时候直接点击 IDE 菜单栏里的“运行”按钮,程序就跑起来了。这实际上隐含了编译和链接的过程,我们通常将编译和链接合并到一起的过程称为构建(Build)。

之后,汇编器会将汇编代码转变成机器可以执行的指令。由于每一条汇编语句几乎都与一条机器指令相对应,所以只是一个简单的一一对应,比较简单,没有语法、语义分析,也没有优化这些步骤。

词法分析

当把所有的二进制位都对应成了 ASCII 码字符后,我们就能看到有意义的字符串。它可能是关键字,例如:package;可能是字符串,例如:“Hello World”。

我们来看一下维基百科上给出的定义:

.go 文件被输入到扫描器(Scanner),它使用一种类似于 有限状态机的算法,将源代码的字符系列分割成一系列的记号(Token)。

例如,对于如下的代码:

slice[i] = i * (2 + 6)

上面的例子源自《程序员的自我修养》,主要讲解编译、链接相关的内容,很精彩,推荐研读。

感受一下:

var tokstrings = [...]string{ 
// source control
_EOF: "EOF",
// names and literals
_Name: "name",
_Literal: "literal",
// operators and operations
_Operator: "op",
_AssignOp: "op=",
_IncOp: "opop",
_Assign: "=",
_Define: ":=",
_Arrow: "<-",
_Star: "*",
// delimitors
_Lparen: "(",
_Lbrack: "[",
_Lbrace: "{",
_Rparen: ")",
_Rbrack: "]",
_Rbrace: "}",
_Comma: ",",
_Semi: ";",
_colon: ":",
_Dot: ".",
_DotDotDot: "...",
// keywords
_Break: "break",
_Case: "case",
_Chan: "chan",
_Const: "const",
_Continue: "continue",
_Default: "default",
_Defer: "defer",
_Else: "else",
_Fallthrough: "fallthrough",
_For: "for",
_Func: "func",
_Go: "go",
_Goto: "goto",
_If: "if",
_import: "import",
_Interface: "interface",
_Map: "map",
_Package: "package",
_Range: "range",
_Return: "return",
_Select: "select",
_Struct: "struct",
_Switch: "switch",
_Type: "type",
_Var: "var",
}

而扫描器的路径是:

src/cmd/compile/internal/syntax/scanner.go

代码的主要逻辑就是通过 c:=s.getr 获取下一个未被解析的字符,并且会跳过之后的空格、回车、换行、tab 字符,然后进入一个大的 switch-case 语句,匹配各种不同的情形,最终可以解析出一个 Token,并且把相关的行、列数字记录下来,这样就完成一次解析过程。

语法分析

比如最开始的那个例子, slice[i]=i*(2+6),得到的一棵语法树如下:

语法分析的过程可以检测一些形式上的错误,例如:括号是否缺少一半, + 号表达式缺少一个操作数等。

语义分析

编译期所能检查的是静态语义,可以认为这是在“代码”阶段,包括变量类型的匹配、转换等。例如,将一个浮点值赋给一个指针变量的时候,明显的类型不匹配,就会报编译错误。而对于运行期间才会出现的错误:不小心除了一个 0 ,语义分析是没办法检测的。

Go 语言编译器在这一阶段检查常量、类型、函数声明以及变量赋值语句的类型,然后检查哈希中键的类型。实现类型检查的函数通常都是几千行的巨型 switch/case 语句。

在这个过程中也可能会对抽象语法树进行改写,这不仅能够去除一些不会被执行的代码对编译进行优化提高执行效率,而且也会修改 make、new 等关键字对应节点的操作类型。

中间代码生成

前面词法分析、语法分析、语义分析等都属于编译器前端,之后的阶段属于编译器后端。

中间代码一般和目标机器以及运行时环境无关,它有几种常见的形式:三地址码、P-代码。例如,最基本的 三地址码是这样的:

x = y op z

前面我们举的例子可以写成如下的形式:

t1 = 2 + 6 
t2 = i * t1
slice[i] = t2

Go 语言的中间代码表示形式为 SSA(Static Single-Assignment,静态单赋值),之所以称之为单赋值,是因为每个名字在 SSA 中仅被赋值一次。

在生成中间代码之前,会对抽象语法树中节点的一些元素进行替换。这里引用《面向信仰编程》编译原理相关博客里的一张图:

Go 语言的主程序在执行时会调用 runtime 中的函数,也就是说关键字和内置函数的功能其实是由语言的编译器和运行时共同完成的。

目标代码生成与优化

为了榨干机器的每一滴油水,目标代码优化器会对一些指令进行优化,例如使用移位指令代替乘法指令等。

链接过程

链接过程就是要把编译器生成的一个个目标文件链接成可执行文件。最终得到的文件是分成各种段的,比如数据段、代码段、BSS段等等,运行时会被装载到内存中。各个段具有不同的读写、执行属性,保护了程序的安全运行。

Go 程序启动

-gcflags"-N -l" 是为了关闭编译器优化和函数内联,防止后面在设置断点的时候找不到相对应的代码位置。

进入 gdb 调试模式,执行 info files,得到可执行文件的文件头,列出了各种段:

这就是 Go 程序的入口地址,我是在 linux 上运行的,所以入口文件为 src/runtime/rt0_linux_amd64.s,runtime 目录下有各种不同名称的程序入口文件,支持各种操作系统和架构,代码为:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8 
LEAQ 8(SP), SI // argv
MOVQ 0(SP), DI // argc
MOVQ $main(SB), AX
JMP AX

继续跳转到 runtime·rt0_go(SB),位置:/usr/local/go/src/runtime/asm_amd64.s,代码:

TEXT runtime·rt0_go(SB),NOSPLIT,$0 
// 省略很多 CPU 相关的特性标志位检查的代码
// 主要是看不懂,^_^
// ………………………………
// 下面是最后调用的一些函数,比较重要
// 初始化执行文件的绝对路径
CALL runtime·args(SB)
// 初始化 CPU 个数和内存页大小
CALL runtime·osinit(SB)
// 初始化命令行参数、环境变量、gc、栈空间、内存管理、所有 P 实例、HASH算法等
CALL runtime·schedinit(SB)
// 要在 main goroutine 上运行的函数
MOVQ $runtime·mainPC(SB), AX // entry
PUSHQ AX
PUSHQ $0 // arg size
// 新建一个 goroutine,该 goroutine 绑定 runtime.main,放在 P 的本地队列,等待调度
CALL runtime·newproc(SB)
POPQ AX
POPQ AX
// 启动M,开始调度goroutine
CALL runtime·mstart(SB)
MOVL $0xf1, 0xf1 // crash
RET
DATA runtime·mainPC+0(SB)/8,$runtime·main(SB)
GLOBL runtime·mainPC(SB),RODATA,$8

检查运行平台的CPU,设置好程序运行需要相关标志。

  • runtime.args、runtime.osinit、runtime.schedinit 三个方法做好程序运行需要的各种变量与调度器。

  • runtime.mstart开始goroutine的调度。

  • main 函数里执行的一些重要的操作包括:新建一个线程执行 sysmon 函数,定期垃圾回收和调度抢占;启动 gc;执行所有的 init 函数等等。

    当 main 函数执行结束之后,会执行 exit(0) 来退出进程。若执行 exit(0) 后,进程没有退出,main 函数最后的代码会一直访问非法地址:

    exit(0) 
    for {
    var x *int32
    *x = 0
    }

    关于程序退出这一段的阐述来自群聊《golang runtime 阅读》,又是一个高阶的读源码的组织,Github 主页见参考资料。

    GoRoot 和 GoPath

    bin 目录下面:

    Go 工具目录如下,其中比较重要的有编译器 compile,链接器 link:

    src 存放源文件,pkg 存放源文件编译后的库文件,后缀为 .a;bin 则存放可执行文件。

    直接在终端执行:

    go

    和编译相关的命令主要是:

    go build 
    go install
    go run

    go build 用来编译指定 packages 里的源码文件以及它们的依赖包,编译的时候会到 $GoPath/src/package 路径下寻找源码文件。go build 还可以直接编译指定的源码文件,并且可以同时指定多个。

    -o 只能在编译单个包的时候出现,它指定输出的可执行文件的名字。

    至于 build flags 参数, build,clean,get,install,list,run,test 这些命令会共用一套:

    命令源码文件:是 Go 程序的入口,包含 func main 函数,且第一行用 packagemain 声明属于 main 包。

  • 测试源码文件:以 _test.go 为后缀的文件,用于测试程序的功能和性能。

  • 我们通过一个很简单的例子来演示 go build 命令。我用 Goland 新建了一个 hello-world 项目(为了展示引用自定义的包,和之前的 hello-world 程序不同),项目的结构如下:

    中间是 main.go 的源码,引用了两个包,一个是标准库的 fmt;一个是 util 包,util 的导入路径是 util。所谓的导入路径是指相对于 Go 的源码目录 $GoRoot/src 或者 $GoPath/src 的下的子路径。例如 main 包里引用的 fmt 的源码路径是 /usr/local/go/src/fmt,而 util 的源码路径是 /Users/qcrao/hello-world/src/util,正好我们设置的 GoPath = /Users/qcrao/hello-world。

    在 src 目录下,直接执行 go build 命令,在同级目录生成了一个可执行文件,文件名为 src,使用 ./src 命令直接执行,输出:

    hello world! 
    Local IP: 192.168.1.3

    这样,在 bin 目录下会生成一个可执行文件,运行结果和上面的 src 一样。

    编译程序会去 $GoPath/src 路径找 util 包(其实是找文件夹)。还可以在 ./src/util 目录下直接执行 go build 编译。

    go build 命令在编译只包含库源码文件的代码包(或者同时编译多个代码包)时,只会做检查性的编译,而不会输出任何结果文件。

    -v 会打印所编译过的包名字, -x 打印编译期间所执行的命令, -work 打印编译期间生成的临时文件路径,并且编译完成之后不会被删除。

    从结果来看,图中用箭头标注了本次编译过程涉及 2 个包:util,command-line-arguments。第二个包比较诡异,源码里根本就没有这个名字好吗?其实这是 go build 命令检测到 [packages] 处填的是一个 .go 文件,因此创建了一个虚拟的包:command-line-arguments。

    另外,第一行显示了编译过程中的工作目录,此目录的文件结构是:

    整体来看, go build 在执行时,会先递归寻找 main.go 所依赖的包,以及依赖的依赖,直至最底层的包。这里可以是深度优先遍历也可以是宽度优先遍历。如果发现有循环依赖,就会直接退出,这也是经常会发生的循环引用编译错误。

    这里,引用郝林老师几年前在 github 上发表的 go 命令教程,可以从参考资料找到原文地址。

    执行 go build 命令的计算机如果拥有多个逻辑 CPU 核心,那么编译代码包的顺序可能会存在一些不确定性。但是,它一定会满足这样的约束条件:依赖代码包 -> 当前代码包 -> 触发代码包。

    到这里,你一定会发现,对于 hello-wrold 文件夹下的 pkg 目录好像一直没有涉及到。

    前面我们提到过,在 go build 命令里加上 -i 参数会安装这些库文件编译的包,也就是这些 .a 文件会放到 pkg 目录下。

    darwin_amd64 表示的是:

    • GOOS 是 Go 所在的操作系统类型,GOARCH 是 Go 所在的计算架构。

    • 生成了 util.a 文件后,再次编译的时候,就不会再重新编译 util.go 文件,加快了编译速度。

      hello-world 这个项目的代码已经上传到了 github 项目 Go-Questions,这个项目由问题导入,企图串连 Go 的所有知识点,正在完善,期待你的 star。地址见参考资料【Go-Questions hello-world项目】。

      go install 用于编译并安装指定的代码包及它们的依赖包。相比 go build,它只是多了一个“安装编译后的结果文件到指定目录”的步骤。

      两者都会在根目录下新建一个 pkg 目录,并且生成一个 util.a 文件。

      所以,运行 go install 命令,库源码包对应的 .a 文件会被放置到 pkg 目录下,命令源码包生成的可执行文件会被放到 GOBIN 目录。

      go run

      在 hello-world 项目的根目录,执行 go run 命令:

      go run -x -work src/main.go

      从上图中可以看到,仍然是先编译,再连接,最后直接执行,并打印出了执行结果。

      main 就是最终生成的可执行文件。

      这次的话题太大了,困难重重。从编译原理到 go 启动时的流程,到 go 命令原理,每个话题单独抽出来都可以写很多。

      参考资料

      • 【面向信仰编程 编译过程概述】https://draveness.me/golang-compile-intro

      • 【Go-Questions hello-world项目】https://github.com/qcrao/Go-Questions/tree/master/examples/hello-world

      • 【vim 以 16 进制文本】https://www.cnblogs.com/meibenjin/archive/2012/12/06/2806396.html

      • 【Go 命令执行过程】https://github.com/hyper0x/gocommandtutorial

      • 【曹大博客 golang 与 ast】http://xargin.com/ast/

      • 【Gopath Explained】https://flaviocopes.com/go-gopath/

      • 【讨论】https://stackoverflow.com/questions/7970390/what-should-be-the-values-of-gopath-and-goroot

      • 【Go package 的探索】https://mp.weixin.qq.com/s/OizVLXfZ6EC1jI-NL7HqeA

      • 【Go modules】https://www.melvinvivas.com/go-version-1-11-modules/

      • 【编译、链接过程链接】https://mikespook.com/2013/11/%E7%BF%BB%E8%AF%91-go-build-%E5%91%BD%E4%BB%A4%E6%98%AF%E5%A6%82%E4%BD%95%E5%B7%A5%E4%BD%9C%E7%9A%84%EF%BC%9F/

      • 【Go 编译过程系列文章】https://www.ctolib.com/topics-3724.html

      • 【golang 启动流程】https://blog.iceinto.com/posts/go/start/

      • 【探索 goroutine 的创建】http://cbsheng.github.io/posts/%E6%8E%A2%E7%B4%A2goroutine%E7%9A%84%E5%88%9B%E5%BB%BA/

      版权声明:本文为CSDN博主「qcrao」的原创文章。

      【END】

      1024程序员节如期而至,CSDN Blink小姐姐的关爱来了!

      扫描领取小姐姐的专属福利!

      程序员邂逅鼓励师的正确姿势!扫描前往福利现场>>

    打赏
    0相关评论
    热门搜索排行
    精彩图片
    友情链接
    声明:本站信息均由用户注册后自行发布,本站不承担任何法律责任。如有侵权请告知立立即做删除处理。
    违法不良信息举报邮箱:115904045
    头条快讯网 版权所有
    中国互联网举报中心