写 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
源码 ──gc──> Plan9 object (.o, 在 GOCACHE) ──link──> ELF/Mach-O/PE 可执行文件

这也是为什么 Go 的"编译"和"链接"在工具链里是两个独立步骤:go build -x 能看到完整的 compilelink 命令行。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/compilecmd/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,于是 runtimereflectsync/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
2
3
.go ─gc/asm> Plan 9 object (.o) ─cmd/link─> ELF (amd64 Linux)
↑ Go 内部统一格式 ↑ OS 加载器要求的格式
编译器/汇编器只懂这个 内核只认这个

一句话概括这个分工: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 二进制的外壳就是目标平台的标准可执行格式:

平台格式入口符号
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 正常运转的关键:

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 nmgo 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 函数。

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 这篇。这里只保留它和“机器架构”的关系。

二进制执行:从 execvemain.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)

逐段拆解:

  1. 加载与入口execve 把 ELF 的 .text 映射到进程地址空间,按 ELF header 里的 entry point 跳转。Go 把这个入口设成平台对应的 _rt0_amd64_*,是一段极短的汇编。
  2. _rt0:这段汇编干两件事——把 argc/argv/envp 从寄存器/栈上规整好;为 m0g0 准备好栈,设置好 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.mainexit(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.gogoruntime.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 维护栈帧链。这让 perfdtrace、火焰图工具能直接采到 Go 程序的调用栈,不必依赖 Go 自己的 pprof——这是 Go 在可观测性上向系统工具靠拢的重要一步。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
High address
+----------------------+
| incoming args | mostly in registers with regabi
+----------------------+
| return PC | pushed by call
+----------------------+
| old RBP | frame-pointer chain
+----------------------+
| locals + GC bitmap | stack slots scanned by GC
+----------------------+
| outgoing args |
+----------------------+ <- SP
Low address

其中:incoming args 在 regabi 下多数走寄存器;return PCcall 压入;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.sgogo/mcall/rt0_go
  • go tool objdump / go tool nm —— 直接看自己二进制的反汇编和符号
  • 同系列:golang-gmp(GMP 调度细节)、golang-channel(channel 与调度器的协作)