golang-arch
写 Go 的人大多停在"写代码 → go build → 跑起来"这一层。但真要追问"go build 到底产出了什么?二进制里塞了哪些东西?程序被 OS 拉起来之后,CPU 第一条指令落在哪?goroutine 又是怎么’凭空’跑起来的?"——能讲清楚的人并不多。
这篇文章把 Go 从源码到执行的全链路拆开:编译格式 → 二进制格式 → 运行时格式 → 二进制执行 → 底层机器架构,配上 mermaid 图,尽量讲到底层细节而不是停在概念。
全局视图:从源码到 CPU
先给一张全景图,把后面要讲的每个环节定位清楚:
flowchart LR
SRC[".go 源码"] ==> COMPILE["编译期"]
COMPILE --> FRONT["前端检查<br/>词法 / 语法 / 类型"]
COMPILE --> SSA["SSA 优化<br/>中间表示 / 降级"]
COMPILE --> OBJ["机器码生成<br/>Plan9 Object (.o)"]
FRONT ==> LINK["链接期<br/>归档 / 重定位 / 去重"]
SSA ==> LINK
OBJ ==> LINK
LINK ==> BIN["ELF / Mach-O / PE<br/>可执行二进制"] ==> RUNTIME["运行期"]
RUNTIME --> LOADER["OS 加载器<br/>execve / dyld"]
RUNTIME --> RT0["runtime 入口<br/>_rt0 -> rt0_go"]
RUNTIME --> GMP["GMP 调度器<br/>goroutine 跑起来"]
LOADER ==> MAIN["main.main<br/>用户代码"]
RT0 ==> MAIN
GMP ==> MAIN
classDef source fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef compile fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px;
classDef link fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px;
classDef run fill:#f4ecff,stroke:#8f5ad8,color:#2d1b45,stroke-width:1.5px;
classDef final fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
class SRC source;
class COMPILE,FRONT,SSA,OBJ compile;
class LINK,BIN link;
class RUNTIME,LOADER,RT0,GMP run;
class MAIN final;下面每一节对应图里的一段。
编译格式: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。
flowchart LR
FRONTEND["编译器前端<br/>cmd/compile/internal/*"]
FRONTEND --> SCAN["scanner<br/>词法分析"]
FRONTEND --> PARSE["parser<br/>生成 AST"]
FRONTEND --> TYPE["typecheck<br/>类型检查 / 逃逸分析"]
SCAN ==> WALK["walk<br/>高级语法降成底层 IR"]
PARSE ==> WALK
TYPE ==> WALK
WALK ==> SSAROOT["SSA 后端<br/>cmd/compile/internal/ssa"]
SSAROOT --> BUILD["SSA 生成"]
SSAROOT --> PASS["优化 pass<br/>死代码 / 常量折叠 / BCE"]
SSAROOT --> LOWER["lower<br/>SSA -> 机器指令"]
BUILD ==> ASM["cmd/asm<br/>Plan9 Object (.o)"]
PASS ==> ASM
LOWER ==> ASM
classDef frontend fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef bridge fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:2px;
classDef ssa fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px;
classDef output fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
class FRONTEND,SCAN,PARSE,TYPE frontend;
class WALK bridge;
class SSAROOT,BUILD,PASS,LOWER ssa;
class ASM output;几个值得记住的点:
- 逃逸分析(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,做符号解析、重定位、去重、生成调试信息,最后吐出对应平台的可执行文件。
1 | |
这也是为什么 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 再把它翻译成目标平台格式。
flowchart LR
SRC[".go / .s"] --> COMPILE["gc / cmd/asm<br/>只懂 Plan 9 object 一种格式"]
COMPILE --> OBJ["Plan 9 object (.o)<br/>Go 工具链内部统一中间表示<br/>符号 / 机器码 / 重定位"]
OBJ --> LINK["cmd/link<br/>归档 / 重定位 / 去重<br/>+ 文件格式生成"]
LINK --> ELF["ELF (amd64 Linux)<br/>OS 加载器要求的外部格式"]
ELF --> OS["execve 内核只认 ELF"]
classDef src fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef inner fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px;
classDef link fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px;
classDef out fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
class SRC src;
class COMPILE,OBJ inner;
class LINK link;
class ELF,OS out;这种分层带来的好处,正好回答“为什么不直接产 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 外部能加载的格式”:
1 | |
一句话概括这个分工:Plan 9 object 是给 Go 自己的链接器看的,ELF 是给 OS 内核看的。中间多一层转换看似冗余,换来的是编译器后端的极简与跨平台一致——这正是 Plan 9 工具链“小而正交”的哲学在 Go 里的延续。也正因如此,Go 才能在没有引入 LLVM 的情况下,靠自家一套工具链就覆盖几十种 OS/arch 组合。
编译模式:internal vs external
flowchart TB
MODE["Go 链接模式"]
INTERNAL["内部链接<br/>gc 生成 .o -> cmd/link 自己完成重定位<br/>纯 Go 二进制,静态链接 runtime"]
EXTERNAL["外部链接<br/>gc 生成 .o -> cmd/link 预链接 -> gcc / clang / ld<br/>含 cgo 的二进制,依赖宿主 libc"]
MODE --> INTERNAL
MODE --> EXTERNAL
classDef internal fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef external fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px;
classDef result fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
class MODE result;
class INTERNAL internal;
class EXTERNAL 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 正常运转的关键:
flowchart TB
BIN["Go 可执行文件<br/>ELF / Mach-O / PE"]
subgraph Native["平台通用段"]
direction TB
TEXT["代码<br/>.text"] --> RODATA["只读数据<br/>.rodata"] --> DATA["全局数据<br/>.data / .bss"]
end
subgraph Metadata["Go 元信息"]
direction TB
PCLN[".gopclntab<br/>PC -> 函数 / 文件 / 行号"] --> SYMTAB[".symtab / .gosymtab<br/>符号信息"] --> TYPELINKS[".typelinks<br/>类型描述符索引"]
end
subgraph RuntimeSec["runtime 目录"]
direction TB
MODULE["moduledata<br/>串起元信息和 GC 表"] --> BUILDINFO[".buildinfo<br/>Go 版本 / tags / ldflags"] --> NOPTR[".noptrdata / .noptrbss<br/>无需 GC 扫描的数据"]
end
BIN ==> TEXT
BIN ==> PCLN
BIN ==> MODULE
classDef bin fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
classDef native fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px;
classDef meta fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef runtime fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px;
class BIN bin;
class TEXT,RODATA,DATA native;
class PCLN,SYMTAB,TYPELINKS meta;
class MODULE,BUILDINFO,NOPTR 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 函数。
flowchart LR
subgraph C["C 程序"]
CC["用户代码<br/>read(fd, buf, n)"] --> LIBC["libc.so<br/>read wrapper"] --> INS_C["syscall 指令"]
end
subgraph GO["Go 程序"]
GC["用户代码<br/>fd.Read(buf)"] --> RT["runtime 汇编 stub<br/>runtime·sys_read"] --> INS_G["syscall 指令"]
end
INS_C ==> K["内核<br/>sys_read"]
INS_G ==> K
classDef user fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px
classDef libc fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px
classDef rt fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px
classDef kernel fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px
class CC,GC user
class LIBC libc
class RT,INS_C,INS_G rt
class K kernel所以一个纯 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 过去。
flowchart LR
A["内核 execve 时<br/>映射 vDSO + 留 auxv"] ==> B["runtime 启动时<br/>按 ELF 解析 vDSO<br/>拿到符号地址"] ==> C["runtime.nanotime<br/>call vDSO 读 rdtsc<br/>不陷入内核"]
classDef os fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px
classDef rt fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px
classDef out fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px
class A os
class B rt
class C out
classDef os fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px
classDef rt fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px
classDef out fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px
class A,B,C os
class D,E,F rt
class G out这是"没有动态库的动态行为"最精妙的一例:整个解析 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
%%{init: {"flowchart": {"nodeSpacing": 18, "rankSpacing": 18}}}%%
flowchart LR
G["G<br/>goroutine<br/>用户态执行单元"] --> P["P<br/>processor<br/>运行 G 的许可证"]
P --> M["M<br/>machine<br/>OS 线程"]
P --> RUNQ["本地 runq<br/>待运行 G 队列"]
M -. "阻塞 syscall 时<br/>P 让出给其他 M" .-> P
classDef g fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:2px;
classDef p fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:2px;
classDef m fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:2px;
classDef queue fill:#f4ecff,stroke:#8f5ad8,color:#2d1b45,stroke-width:1.5px;
class G g;
class P p;
class M m;
class RUNQ queue;- 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 跑起来。
sequenceDiagram
autonumber
participant OS as OS 内核 / 加载器
participant RT0 as _rt0 汇编入口
participant RT as runtime.rt0_go
participant SCHED as GMP 调度器
participant MAIN as main.main
OS->>RT0: execve 加载 ELF,跳到入口地址
RT0->>RT: 设置 m0/g0 栈与 TLS,调用 rt0_go
RT->>RT: osinit / schedinit<br/>创建 GOMAXPROCS 个 P
RT->>SCHED: newproc(runtime.main)<br/>mstart(m0 -> P0)
Note over SCHED: runtime.main 内部<br/>启动 sysmon / GC,执行 init()
SCHED->>MAIN: 调度执行用户 main.main
MAIN-->>SCHED: main.main 返回
SCHED-->>OS: exit(0)逐段拆解:
- 加载与入口。
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:
flowchart TB
subgraph Old["Go 1.16<br/>栈式 ABI"]
direction LR
O1["参数压栈"] --> O2["返回值在栈上"] --> O3["调用边界<br/>多次内存读写"]
end
subgraph New["Go 1.17+<br/>regabi"]
direction LR
N1["整型参数优先走寄存器<br/>AX / BX / CX / DI / SI / R8-R11"] --> N2["浮点参数走寄存器<br/>X0-X14"] --> N3["溢出才压栈<br/>返回值优先走寄存器"]
end
Old == "更低调用开销<br/>性能约 +5% / 二进制约 -2%" ==> New
classDef old fill:#f4f5f7,stroke:#8993a4,color:#253858,stroke-width:1.5px;
classDef new fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:2px;
class O1,O2,O3 old;
class N1,N2,N3 new;带来的变化:
- 性能:调用开销下降,密集调用的程序整体约 +5%。
- 与 C 互操作更顺:regabi 把 Go 的调用约定拉近了 System V AMD64 ABI,cgo 转换层更薄。
- 反射/汇编需要重写:任何直接操作 Go 函数参数布局的代码(含 unsafe)都受影响,这也是当时一堆汇编库要改的原因。
寄存器分配上,Go 保留
R14作为 goroutine 寄存器(指向当前 G)、R15作为平台相关,DX在某些路径临时用。这让"取当前 G"变成一条 mov 指令,而不是查 TLS。
goroutine 栈切换:保存现场的本质
OS 线程切换靠内核保存/恢复所有寄存器;goroutine 切换是用户态自己做的,只保存调用者约定要保存的寄存器 + 栈指针,开销低一个数量级。
%%{init: {"flowchart": {"useMaxWidth": false, "nodeSpacing": 28, "rankSpacing": 38}}}%%
flowchart LR
A["G_A 正在 M 上运行"] --> B{"需要让出?<br/>chan 阻塞 / 时间片到 / syscall"}
B -- "否" --> H["继续运行 G_A"]
B -- "是" --> C["mcall<br/>保存 G_A 现场<br/>PC / SP / 寄存器"]
C ==> D["切到 g0 系统栈"]
D --> E["调度器<br/>选择 G_B"]
E ==> F["gogo<br/>恢复 G_B 现场<br/>SP / PC / 寄存器"]
F --> G["G_B 继续运行"]
classDef running fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef decision fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:2px;
classDef runtime fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:2px;
classDef final fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
class A,H running;
class B decision;
class C,D,E,F runtime;
class G final;切换的核心是 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):
flowchart LR
subgraph Before["syscall 前"]
G["G 绑定在<br/>M1 + P0 上运行"]
end
subgraph Kernel["进入内核"]
S["M1 发起阻塞 syscall<br/>线程睡眠"]
end
subgraph Scheduler2["调度器接管"]
H["P0 从 M1 解绑<br/>交给 M2"]
Q["M2 + P0<br/>继续执行队列里的 G"]
end
subgraph Return["syscall 返回"]
R["M1 醒来<br/>有 P 则继续,无 P 则空闲 / 挂回"]
end
G ==> S
S ==> H
H --> Q
S -. "syscall 完成" .-> R
classDef before fill:#e8f7f0,stroke:#22a06b,color:#153b2b,stroke-width:1.5px;
classDef kernel fill:#ffeceb,stroke:#d04437,color:#4a1712,stroke-width:2px;
classDef sched fill:#eef4ff,stroke:#4777d9,color:#172b4d,stroke-width:1.5px;
classDef ret fill:#fff3d6,stroke:#d48b00,color:#3d2b00,stroke-width:1.5px;
class G before;
class S kernel;
class H,Q sched;
class R ret;这就是"阻塞 syscall 不会冻住其他 goroutine"的机制:M 进内核阻塞,P 被让给别的 M。网络 IO 更特殊——走 epoll 的网络轮询器(netpoller),连 M 都不阻塞,G 直接被挂到 netpoll 上,就绪后再由 sysmon 唤醒。
函数栈帧与帧指针
regabi 之后,Go 默认重新启用帧指针(frame pointer),在 RBP 维护栈帧链。这让 perf、dtrace、火焰图工具能直接采到 Go 程序的调用栈,不必依赖 Go 自己的 pprof——这是 Go 在可观测性上向系统工具靠拢的重要一步。
每个函数栈帧大致结构(逻辑视角):
1 | |
其中: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 与调度器的协作)


