golang-arch
写 Go 的人大多停在"写代码 → go build → 跑起来"这一层。但真要追问"go build 到底产出了什么?二进制里塞了哪些东西?程序被 OS 拉起来之后,CPU 第一条指令落在哪?goroutine 又是怎么’凭空’跑起来的?"——能讲清楚的人并不多。
这篇文章把 Go 从源码到执行的全链路拆开:编译格式 → 二进制格式 → 运行时格式 → 二进制执行 → 底层机器架构,尽量讲到底层细节而不是停在概念。
全局视图:从源码到 CPU
先给一张全景图,把后面要讲的每个环节定位清楚:
下面每一节对应图里的一段。
编译格式: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。
几个值得记住的点:
- 逃逸分析(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 的"编译"和"链接"在工具链里是两个独立步骤: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 二进制不依赖 libc(Linux 上直接走 syscall),这是 Go "静态编译、单文件部署"的来源。一旦开了 cgo,就要拉进宿主 libc,二进制也就不再完全静态了。
二进制格式:Go 把什么塞进了 ELF
容器:ELF / Mach-O / PE
Go 二进制的外壳就是目标平台的标准可执行格式:
| 平台 | 格式 | 入口符号 |
|---|---|---|
| Linux | ELF | _rt0_amd64_linux |
| macOS | Mach-O | _rt0_amd64_darwin |
| Windows | PE | _rt0_amd64_windows |
| 其他 | 对应平台格式 | 对应 _rt0_* |
但和 C 程序不同,Go 二进制里除了用户代码,还内嵌了完整的 runtime(调度器、GC、栈管理、syscall 封装……)。这就是 Go 二进制通常比等价 C 程序大很多的原因。
Go 专有的段(section)
readelf -S 一个 Go 二进制,会看到一堆 Go 自定义的段,这些是 runtime 正常运转的关键:
逐个说人话:
.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 | |
几行汇编干完四件事:搬参数进寄存器 → 填 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 函数。
所以一个纯 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 过去。
这是"没有动态库的动态行为"最精妙的一例:整个解析 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(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把 ELF 的.text映射到进程地址空间,按 ELF header 里的 entry point 跳转。Go 把这个入口设成平台对应的_rt0_amd64_*,是一段极短的汇编。 _rt0:这段汇编干两件事——把argc/argv/envp从寄存器/栈上规整好;为m0、g0准备好栈,设置好 TLS(线程局部存储)让"当前 G、当前 M"能被快速取到。然后跳到runtime.rt0_go。rt0_go:runtime 的 C/Go 初始化主流程。osinit拿 CPU 数、内存大小;schedinit初始化调度器、创建GOMAXPROCS个 P;接着用newproc创建第一个 G,它的执行体是runtime.main。mstart:让m0进入调度循环。m0绑定P0,从队列里抓 G 执行。runtime.main:这个 G 里干一堆收尾工作——启动sysmon(独立 M,不需 P,负责抢占、netpoll、强制 GC)、初始化 GC、执行所有包的init()函数,最后才调用用户写的main.main。- 退出:
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:
带来的变化:
- 性能:调用开销下降,密集调用的程序整体约 +5%。
- 与 C 互操作更顺:regabi 把 Go 的调用约定拉近了 System V AMD64 ABI,cgo 转换层更薄。
- 反射/汇编需要重写:任何直接操作 Go 函数参数布局的代码(含 unsafe)都受影响,这也是当时一堆汇编库要改的原因。
寄存器分配上,Go 保留
R14作为 goroutine 寄存器(指向当前 G)、R15作为平台相关,DX在某些路径临时用。这让"取当前 G"变成一条 mov 指令,而不是查 TLS。
goroutine 栈切换:保存现场的本质
OS 线程切换靠内核保存/恢复所有寄存器;goroutine 切换是用户态自己做的,只保存调用者约定要保存的寄存器 + 栈指针,开销低一个数量级。
切换的核心是 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):
这就是"阻塞 syscall 不会冻住其他 goroutine"的机制:M 进内核阻塞,P 被让给别的 M。网络 IO 更特殊——走 epoll 的网络轮询器(netpoller),连 M 都不阻塞,G 直接被挂到 netpoll 上,就绪后再由 sysmon 唤醒。
函数栈帧与帧指针
regabi 之后,Go 默认重新启用帧指针(frame pointer),在 RBP 维护栈帧链。这让 perf、dtrace、火焰图工具能直接采到 Go 程序的调用栈,不必依赖 Go 自己的 pprof——这是 Go 在可观测性上向系统工具靠拢的重要一步。
每个函数栈帧大致结构(逻辑视角):
其中: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 优化的全部 passruntime源码: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 与调度器的协作)






