写 Go 的人大多停在"写代码 → go build → 跑起来"这一层。但真要追问"go build 到底产出了什么?二进制里塞了哪些东西?程序被 OS 拉起来之后,CPU 第一条指令落在哪?goroutine 又是怎么’凭空’跑起来的?"——能讲清楚的人并不多。

这篇文章把 Go 从源码到执行的全链路拆开:编译格式 → 二进制格式 → 运行时格式 → 二进制执行 → 底层机器架构,尽量讲到底层细节而不是停在概念。

全局视图:从源码到 CPU

先给一张全景图,把后面要讲的每个环节定位清楚:

Go 从源码到 CPU 的全链路:编译、链接、内核加载、runtime 初始化,最后才调度到用户 main.main

下面每一节对应图里的一段。

编译格式:go build 在干什么

编译器架构:gc 不是 GCC

Go 的官方编译器叫 gc(go compiler),跟 GNU 的 GCC 没关系,是 Go 团队自己用 Go 重写的(1.5 之前用 C 写)。它走的是经典的前端 → IR → 后端三段式,但 IR 用的是自家设计的 SSA(Static Single Assignment),不是 LLVM。

注意区分几个名字:cmd/compile 是编译器主体,cmd/link 是链接器,cmd/go 是构建工具(驱动前面两者)。社区还有 gccgo(GCC 后端)和 gollvm(LLVM 后端)两套实现,但绝大多数人用的是 gc。

cmd/compile 的前端、IR 与 SSA 后端:类型检查与逃逸分析决定语义和分配,SSA 完成优化与指令选择

几个值得记住的点:

  • 逃逸分析(escape analysis) 就发生在 typecheck 阶段。它决定一个变量是分配在栈上还是堆上——这正是 Go “能像写脚本一样写代码、又不用手动 free” 的底层支撑。
  • 内联(inlining) 也在前端决策,内联后的函数会生成一份独立的 IR 副本。
  • SSA 是 Go 1.7 引入的,之后所有优化(死代码消除、常量折叠、CSE、边界检查消除等)都统一在 SSA 上做。

编译产物:Plan9 Object 格式

gc 的输出不是直接给操作系统的目标文件(.o),而是 Go 自家继承自 Plan 9 的 object 文件格式。每个 .go 文件编译出一个 .o(中间产物在 $GOCACHE 里),里面装的是符号、机器指令、重定位信息,但用的是 Plan 9 那套符号表示法,不是 ELF 的 .o。

链接器 cmd/link 读取这些 Plan9 object,做符号解析、重定位、去重、生成调试信息,最后吐出对应平台的可执行文件。

Go 源码和汇编统一产出 Plan 9 object,再由 cmd/link 写出 ELF、Mach-O 或 PE

这也是为什么 Go 的"编译"和"链接"在工具链里是两个独立步骤:go build -x 能看到完整的 compile 和 link 命令行。go build 默认内部链接;当用到 cgo 或某些特殊场景时,会调用外部链接器(gcc/clang/ld),即 external linking。

为什么 object 是 Plan 9,最终却是 ELF

以 amd64 Linux 为例,很多人会困惑:目标明明是 ELF 平台,编译器吐出的 .o 为什么不用 ELF 的 relocatable object,而偏偏用 Plan 9 那套老格式?这要回到 Go 工具链的身世。

Go 的核心团队(Rob Pike、Ken Thompson、Russ Cox……)大多来自 Bell Labs 的 Plan 9。Plan 9 工具链(6c/6a/6l,其中 6 代表 amd64、8 代表 386、5 代表 ARM、7 代表 arm64)有一个贯穿始终的设计:一套统一的中间 object 格式 + 一套平台相关的最终可执行格式。编译器和汇编器只负责产出这种统一的 object;而 ELF / Mach-O / PE 这些外部文件格式的生成,全部由链接器 6l 一肩挑。

Go 早期(1.4 及以前)的 cmd/5l/6l/8l 几乎就是 Plan 9 6l 的直接移植,连数字命名都保留着。1.5 编译器用 Go 重写、命令统一成 compile/link 之后,这个“统一 object → 平台格式”的分层却原封不动继承了下来。今天的 cmd/compile、cmd/asm 仍然只产出 Plan 9 object 一种格式,cmd/link 再把它翻译成目标平台格式。

这种分层带来的好处,正好回答“为什么不直接产 ELF 的 .o”:

  • 编译器后端不耦合文件格式。gc 只要会写一种 Plan 9 object,不用为 ELF / Mach-O / PE 各实现一份重定位表、段表生成逻辑——这堆复杂度全部压给链接器,编译器后端因此保持极简。
  • 汇编器和编译器输出统一。cmd/asm(Plan 9 风格汇编器)和 gc 产出同一种 object,于是 runtime、reflect、sync/atomic 这些靠手写汇编实现的包,能和编译产物无缝地一起喂给链接器,不需要两套处理路径。
  • 跨架构符号表示一致。同一个 .go 在 amd64 和 arm64 上编出的 .o,符号表示法完全一致,go tool nm / go tool objdump 这些工具写一份就能处理所有架构。

那为什么最终产物又必须是 ELF?因为这是 OS 加载器认的唯一东西。Linux 的 execve 只会按 ELF header 把段映射进进程地址空间、跳到 entry point,它根本不认识 Plan 9 object。所以链接器的最后一道工序,就是把 Plan 9 object 这个“Go 工具链内部的中间表示”翻译成“OS 外部能加载的格式”:

Plan 9 object 是 Go 内部统一格式——编译器/汇编器只懂这个;ELF 是 OS 加载器要求的格式——内核只认这个。

一句话概括这个分工:Plan 9 object 是给 Go 自己的链接器看的,ELF 是给 OS 内核看的。中间多一层转换看似冗余,换来的是编译器后端的极简与跨平台一致——这正是 Plan 9 工具链“小而正交”的哲学在 Go 里的延续。也正因如此,Go 才能在没有引入 LLVM 的情况下,靠自家一套工具链就覆盖几十种 OS/arch 组合。

编译模式:internal vs external

Go 内部链接与外部链接:纯 Go 通常由 cmd/link 完成,cgo 等场景会交给宿主工具链收尾

纯 Go 二进制不依赖 libc(Linux 上直接走 syscall),这是 Go "静态编译、单文件部署"的来源。一旦开了 cgo,就要拉进宿主 libc,二进制也就不再完全静态了。

二进制格式:Go 把什么塞进了 ELF

容器:ELF / Mach-O / PE

Go 二进制的外壳就是目标平台的标准可执行格式:

平台格式入口符号
LinuxELF_rt0_amd64_linux
macOSMach-O_rt0_amd64_darwin
WindowsPE_rt0_amd64_windows
其他对应平台格式对应 _rt0_*

但和 C 程序不同,Go 二进制里除了用户代码,还内嵌了完整的 runtime(调度器、GC、栈管理、syscall 封装……)。这就是 Go 二进制通常比等价 C 程序大很多的原因。

Go 专有的段(section)

readelf -S 一个 Go 二进制,会看到一堆 Go 自定义的段,这些是 runtime 正常运转的关键:

Go 二进制的代码段、Go 元信息与 moduledata:runtime 如何通过目录自省函数、类型和 GC 数据

逐个说人话:

  • .gopclntab(Go PC-LineNumber Table):最核心的一个。它把每条指令的 PC(程序计数器)映射到所属函数、文件名、行号。runtime 打印 panic 栈、runtime.Caller、pprof 采样全靠它。没有它,panic 就只剩一堆地址。这是 Go 1.2 引入、1.18 重构为分段格式(更紧凑、支持多 module)的。
  • .symtab / .gosymtab:符号表,记录函数、类型的地址。go tool nm、go tool objdump 就读这个。
  • .typelinks:所有类型描述符(runtime._type)的指针偏移索引。反射 reflect.TypeOf、接口断言、GC 扫描都需要类型元信息。
  • moduledata:一个全局结构体,把上面这些段的起止指针、类型表、itab 表、GC bitmap 等串起来,是 runtime 自省自己二进制的"目录"。
  • .buildinfo:构建信息(Go 版本、-ldflags 注入的内容、CGO 设置、build tags),go version -m ./binary 能读出来。

没有动态库的"动态行为"

"静态链接"消除了对 .so 的依赖、也消除了运行时的符号重定位(PLT/GOT 那一套),但**“没有动态库"不等于"没有动态行为”**。一个纯 Go 进程跑起来后,照样要做一堆传统上由 libc / 动态链接器 / dlopen 完成的事——只是 Go 把这些机制全搬进 runtime 自己实现了。下面拆几条最关键的。

绕过 libc,直发 syscall

Linux 上,C 程序调 read() 走的是 libc.so 里的 read wrapper,它再把参数装进寄存器、执行 syscall 指令。Go 跳过这层:runtime 里有手写汇编 stub(runtime·sys_read / runtime·sys_write / ……,见 runtime/sys_linux_amd64.s),直接把 syscall 号放进 AX、参数放进 DI/SI/DX/R10/R8/R9,一条 SYSCALL 陷入内核。

这里说的汇编 stub,是指一段极短的手写汇编,唯一的职责是"摆参数 → 陷入内核 → 处理返回值",本身不含任何业务逻辑。runtime·sys_read 的骨架大致长这样:

1
2
3
4
5
6
7
8
9
10
11
TEXT runtime·sys_read(SB),NOSPLIT,$0
MOVQ 8(SP), DI // fd → 第 1 参寄存器 DI
MOVQ 16(SP), SI // buf → 第 2 参 SI
MOVQ 24(SP), DX // count → 第 3 参 DX
MOVL $(SYS_read), AX // syscall 号 → AX
SYSCALL // 陷入内核
CMPL AX, $0xfffffffffffff001 // 检查是否出错
JCC ok
NEGQ AX // 出错则取正作为 errno
ok:
RET

几行汇编干完四件事:搬参数进寄存器 → 填 syscall 号 → SYSCALL 陷入内核 → 检查返回值。没有函数调用、没有栈帧搭建、没有逻辑分支,纯粹是用户代码与内核之间的一层薄胶水。NOSPLIT 标志告诉编译器"别给我插栈检查"——因为 syscall 不走 Go 的标准 prologue,得直接从调用者栈上取参数。

为什么非得手写汇编、不能让编译器生成?因为 SYSCALL 这条陷入内核的指令是平台相关特权指令(amd64 用 SYSCALL、arm64 用 SVC),Go 的 SSA 后端不会替你生成;而且内核的 syscall ABI 跟 Go 自己的 regabi 不一样——regabi 用 AX/BX/CX... 传参,内核却要求 syscall 号在 AX、参数在 DI/SI/DX/R10/R8/R9。这层"Go 寄存器布局 → 内核寄存器布局"的搬运必须由一段确定的、不依赖编译器优化的代码完成,汇编是唯一可控的方式。

换句话说,Go 的 runtime·sys_read 这个汇编 stub,就是 Go 自己 reimplement 了一份 libc 里的 read wrapper。区别只在于:C 程序这段代码在 libc.so 里(运行时动态加载),Go 把它直接编进了二进制(静态)。所谓"绕过 libc",本质就是用自己手写的汇编 stub 替代了 libc 里那几个 wrapper 函数。

Linux 上 C 经 libc wrapper、Go 经 runtime 汇编 stub 进入同一个 syscall 内核入口的对比

所以一个纯 Go 进程的地址空间里通常没有 libc.so(/proc/<pid>/maps 看不到),只有用户代码 + runtime + 内核。这正是 Go "单文件部署、不挑宿主 libc 版本"的来源。

但这条只在 Linux 成立。macOS 上 Go 反而依赖 libSystem.dylib——因为 Apple 不保证 syscall 号跨版本稳定,Go 从 1.16 起改走 libc 调用(见 runtime/sys_darwin.go)。Windows 同理依赖 kernel32.dll 等。也就是说"纯静态、不碰系统库"是 Linux 这套组合的特产,不是 Go 的普适属性。

vDSO:内核塞进来的"内存动态库"

即便在 Linux 上完全不碰 .so,进程地址空间里仍然有一段"动态库"——只是它不在磁盘上,而是内核在 execve 时直接映射进来的,叫 vDSO(virtual Dynamic Shared Object)。

vDSO 里放的是几个高频又必须精确的时间接口的用户态实现:clock_gettime / gettimeofday / time / getcpu。这些接口若走真 syscall,每次都要陷入内核;内核干脆把一段只读代码页映射进每个进程,让程序像调普通函数一样调它们,内部直接读硬件计数器(如 rdtsc),不进内核。

Go runtime 在启动时会做一件很"动态链接器"的事(runtime/vdso_linux.go):解析内核留在栈上的 ELF auxiliary vector(auxv),找到 vDSO 的加载地址,再按 ELF 格式解析它的段表和符号表,把 __vdso_clock_gettime 这些符号的地址找出来存好。之后 runtime.nanotime 直接 call 过去。

vDSO 的解析链:内核通过 auxv 给出地址,runtime 解析 ELF 符号并缓存高频时间函数入口

这是"没有动态库的动态行为"最精妙的一例:整个解析 ELF、查符号的过程都跑了一遍,但磁盘上压根没有 .so,也没有 ld.so 参与——内核把"动态库"直接喂进了内存,runtime 自己当了链接器。

自管理堆:mmap 之上的动态内存

C 程序的 malloc 来自 libc 的 ptmalloc/jemalloc;Go 没有 libc,自然也没有 malloc。runtime 直接用 mmap(MAP_ANON) 从内核大块拿页(runtime·sys_mmap),然后在上面自己实现 mheap → mcentral → mcache → mspan 的分级分配器。堆是动态的、分配是动态的,但整套机制都在 Go 自己手里,不经过任何动态库。

运行时元信息自省:编译期生成、运行时解析

反射 reflect.TypeOf(x)、接口动态分派、GC 扫描指针——这些"动态"行为靠的不是 dlopen,而是编译期就塞进二进制、运行时再自解析的元信息:

  • .gopclntab 把 PC 映射到函数/行号,runtime.Caller 运行时查它。
  • .typelinks 索引所有类型描述符,反射按类型 ID 查表拿到方法集。
  • 每个栈帧的 GC bitmap 记录哪些 slot 是指针,GC 扫栈时按它走。

这本质上是把"动态类型系统"在编译期物化进了二进制,runtime 启动后通过 moduledata 这张"目录"把它们串起来自省——动态的语义,静态的载体。

信号接管:把异步信号变成 panic

进程跑起来后,runtime 还会动态接管信号:schedinit 阶段调 sigaction 给一堆信号装上自己的 handler。SIGSEGV/SIGFPE 这类同步信号被转成 panic;Go 1.14+ 的异步抢占则借用 SIGURG——sysmon 给长时间运行的 G 发信号,handler 里改写它的栈帧把它从执行中拽下来。这套机制完全是运行时动态建立的,二进制里没有"信号处理"的静态绑定。

真要找 Go 里的"真·动态库",只有一个例外:标准库 plugin 包(plugin.Open),它在 Linux/macOS 上就是 dlopen/dlsym 的薄封装。但它限制很多(只支持部分平台、插件和主程序必须用相同 Go 版本编译、需特殊 build flag),属于刻意保留的逃生口,不是 Go 的常态运行方式。

一句话收束本节:Go 干掉了"动态库"这个载体,但没有干掉动态行为本身——它把 syscall stub、ELF 自解析(vDSO)、内存分配器、类型自省、信号接管这些原本散落在 libc / ld.so / 内核里的动态机制,全部内化成了 runtime 的一部分,编进那一个静态二进制里。

运行时格式:GMP 与 runtime 的内部结构

Go 的"运行时"不是像 JVM 那样独立跑的解释器,而是和用户代码编进同一个二进制的一组函数和数据结构。程序启动时,runtime 先跑,把环境准备好,再把控制权交给 main.main。

三个核心抽象:G / M / P

G、M、P 的绑定关系:M 需持有 P 才能执行 G,P 保存本地运行队列与调度资源

  • G(goroutine):用户态执行单元,轻量,初始栈 2 KB。
  • M(machine):OS 线程,真正在 CPU 上跑指令的实体。
  • P(processor):逻辑处理器,持有“运行 G 的许可证”和本地队列,数量由 GOMAXPROCS 决定。M 必须绑定 P 才能执行 G。

GMP 调度的完整细节(work stealing、sysmon、抢占、网络轮询器、队列机制、runtime 关键数据结构 g0/m0/mheap/GC metadata、goroutine 栈的连续栈与协作式增长)都在同系列 golang-gmp 这篇。这里只保留它和“机器架构”的关系。

二进制执行:从 execve 到 main.main

这是整条链路里最戏剧性的一段:一个静态二进制被内核加载后,怎么"长出"调度器、怎么把第一个 goroutine 跑起来。

从 execve 到用户 main.main 的启动时序:_rt0 和 runtime 初始化先于用户代码

逐段拆解:

  1. 加载与入口。execve 把 ELF 的 .text 映射到进程地址空间,按 ELF header 里的 entry point 跳转。Go 把这个入口设成平台对应的 _rt0_amd64_*,是一段极短的汇编。
  2. _rt0:这段汇编干两件事——把 argc/argv/envp 从寄存器/栈上规整好;为 m0、g0 准备好栈,设置好 TLS(线程局部存储)让"当前 G、当前 M"能被快速取到。然后跳到 runtime.rt0_go。
  3. rt0_go:runtime 的 C/Go 初始化主流程。osinit 拿 CPU 数、内存大小;schedinit 初始化调度器、创建 GOMAXPROCS 个 P;接着用 newproc 创建第一个 G,它的执行体是 runtime.main。
  4. mstart:让 m0 进入调度循环。m0 绑定 P0,从队列里抓 G 执行。
  5. runtime.main:这个 G 里干一堆收尾工作——启动 sysmon(独立 M,不需 P,负责抢占、netpoll、强制 GC)、初始化 GC、执行所有包的 init() 函数,最后才调用用户写的 main.main。
  6. 退出:main.main 返回后,runtime.main 调 exit(0),进程结束。

关键认知:用户写的 main 从来不是进程的真正入口,它只是 runtime 调度起来的第一个普通 goroutine。在 main.main 执行第一条语句之前,runtime 已经把调度器、GC、sysmon 全部跑起来了。

底层机器架构:寄存器 ABI、调用约定、栈切换

最后这一节最贴近"机器"。Go 1.17 引入的 regabi(register-based ABI) 是理解现代 Go 性能和互操作的关键转折。

调用约定:从全栈传参到寄存器传参

1.16 及之前,Go 的函数调用约定是栈式:参数和返回值全压在栈上,函数序言还要保存 frame pointer 之外的额外信息。好处是简单、好做 GC 扫描;坏处是慢——每次调用一堆内存读写。

1.17 开始,amd64 平台率先切到 regabi:

Go 1.16 栈式 ABI 与 Go 1.17 regabi:寄存器优先传递参数,溢出才回落到栈

带来的变化:

  • 性能:调用开销下降,密集调用的程序整体约 +5%。
  • 与 C 互操作更顺:regabi 把 Go 的调用约定拉近了 System V AMD64 ABI,cgo 转换层更薄。
  • 反射/汇编需要重写:任何直接操作 Go 函数参数布局的代码(含 unsafe)都受影响,这也是当时一堆汇编库要改的原因。

寄存器分配上,Go 保留 R14 作为 goroutine 寄存器(指向当前 G)、R15 作为平台相关,DX 在某些路径临时用。这让"取当前 G"变成一条 mov 指令,而不是查 TLS。

goroutine 栈切换:保存现场的本质

OS 线程切换靠内核保存/恢复所有寄存器;goroutine 切换是用户态自己做的,只保存调用者约定要保存的寄存器 + 栈指针,开销低一个数量级。

goroutine 切换由 mcall 保存现场、切到 g0 调度、再由 gogo 恢复目标 G 的现场完成

切换的核心是 runtime.gogo 和 runtime.mcall 这两段汇编:

  • mcall:从用户 G 切到 g0,保存当前 G 的 PC/SP 到 g.sched,加载 g0 的栈。
  • gogo:从 g0 切到目标 G,从 g.sched 恢复 PC/SP/寄存器,最后 ret 直接"掉"进目标 G 的断点。

注意它没有走内核——全程用户态寄存器搬运,这是 goroutine 切换成本远低于线程切换的根本原因(几百 ns vs 几 μs)。

与 OS 的接口:syscall 与线程

Go 在 Linux 上直接发 syscall 指令而不是 call libc。当 goroutine 执行阻塞 syscall(如 read):

阻塞系统调用时,睡眠的是 M;P 会让渡给其他 M 继续调度可运行 goroutine

这就是"阻塞 syscall 不会冻住其他 goroutine"的机制:M 进内核阻塞,P 被让给别的 M。网络 IO 更特殊——走 epoll 的网络轮询器(netpoller),连 M 都不阻塞,G 直接被挂到 netpoll 上,就绪后再由 sysmon 唤醒。

函数栈帧与帧指针

regabi 之后,Go 默认重新启用帧指针(frame pointer),在 RBP 维护栈帧链。这让 perf、dtrace、火焰图工具能直接采到 Go 程序的调用栈,不必依赖 Go 自己的 pprof——这是 Go 在可观测性上向系统工具靠拢的重要一步。

每个函数栈帧大致结构(逻辑视角):

regabi 下 Go 函数栈帧的逻辑布局:返回地址和帧指针链保留在栈上,GC bitmap 让扫描精确定位指针 slot

其中:incoming args 在 regabi 下多数走寄存器;return PC 由 call 压入;old RBP 串起帧指针链;GC bitmap 标记本帧哪些 slot 是指针。

GC bitmap 是关键:每个栈帧里哪些 slot 是指针、哪些是标量,编译器都记录下来,GC 才能精确扫描栈而不误伤指针(不用保守 GC)。

小结:一条贯穿全文的主线

把上面五节压缩成一句话:

Go 的 gc 编译器把源码经过 SSA 优化成机器码,连同 runtime 一起静态链接进一个 ELF/Mach-O/PE 二进制;运行时该二进制被内核加载,从 _rt0 汇编入口进入 rt0_go,初始化 GMP 调度器、GC 和 sysmon,最后调度起第一个 goroutine 去执行用户 main.main;而每个 goroutine 在用户态靠寄存器 ABI + 可增长栈 + 协作式调度高效跑在 OS 线程之上,必要时通过直接 syscall 与内核交互。

理解了这条链路,再去看 panic 栈、pprof、调度延迟、cgo 开销、二进制体积优化(-ldflags "-s -w" 去掉符号表和 DWARF、upx 压缩),就都能落在具体的段和机制上了,而不是停留在"大概是这样"。

延伸阅读

  • cmd/compile 源码:src/cmd/compile/internal/ssa —— SSA 优化的全部 pass
  • runtime 源码:src/runtime/proc.go(调度器)、src/runtime/asm_amd64.s(gogo/mcall/rt0_go)
  • go tool objdump / go tool nm —— 直接看自己二进制的反汇编和符号
  • 同系列:golang-gmp(GMP 调度细节)、golang-channel(channel 与调度器的协作)