mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1568 字
5 分钟
Go 可执行文件深度解析:ELF 结构与 runtime 嵌入
2023-01-07

当你执行 go build 后得到一个二进制文件,你有没有想过:这个文件里到底装了什么?为什么一个简单的 Hello World 就有 1.2MB?runtime 是怎么被”塞”进去的?操作系统又是如何识别并加载它的?

本文将带你打开这个”黑盒”,从 ELF 文件格式出发,逐层揭示 Go 可执行文件的内部结构,让你对”Go 程序到底是什么”有一个彻底的认知。

本文要点#

  • ELF(Executable and Linkable Format)文件格式基础
  • Go 可执行文件的段(Segment)布局
  • 符号表与 Go runtime 符号
  • runtime 是如何被打包进可执行文件的
  • 为什么 Go 二进制文件这么大?
  • 使用 readelfobjdumpgo tool objdump 分析 Go 二进制
  • 程序加载:从 execve 到入口点
  • Go 1.20+ 的 PGO 优化对 ELF 的影响

ELF 文件格式基础#

ELF(Executable and Linkable Format)是 Linux 上可执行文件、目标文件和共享库的标准格式。理解 ELF 是理解 Go 程序如何被操作系统加载的前提。

ELF 的两种视角#

graph LR subgraph "链接视角(Section)" S1[".text — 代码"] S2[".data — 已初始化数据"] S3[".bss — 未初始化数据"] S4[".rodata — 只读数据"] S5[".symtab — 符号表"] end subgraph "执行视角(Segment)" T1["LOAD R+X — 代码段"] T2["LOAD R+W — 数据段"] T3["LOAD R — 只读段"] end S1 --> T1 S2 --> T2 S3 --> T2 S4 --> T3 style T1 fill:#4CAF50,color:#fff style T2 fill:#FF9800,color:#fff style T3 fill:#2196F3,color:#fff
  • 链接视角:编译器和链接器使用 Section(节)来组织文件内容
  • 执行视角:操作系统加载器使用 Segment(段)来映射到虚拟内存

ELF 文件头#

# 查看 Go 二进制的 ELF 头
$ readelf -h hello
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00 (ELF, 64-bit, little-endian)
Class: ELF64
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x452720
Number of program headers: 7
Number of section headers: 36

关键信息:

  • Entry point address:程序的入口地址,即 _rt0_amd64_linux 的地址
  • Program headers:告诉操作系统如何将文件映射到内存
  • Section headers:告诉链接器文件的组织结构

Go 可执行文件的段布局#

实战:分析一个 Go 二进制#

# 编译一个简单的 Go 程序
$ cat > main.go << 'EOF'
package main
import "fmt"
func main() {
fmt.Println("Hello, ELF!")
}
EOF
$ go build -o hello main.go
# 查看文件大小
$ ls -lh hello
-rwxr-xr-x 1 user user 1.2M hello
# 查看段(Program Headers)
$ readelf -l hello

段(Program Headers)详解#

Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000040 0x0000000000400040 0x0000000000400040 0x0001f8 0x0001f8 R 0x8
INTERP 0x000238 0x0000000000400238 0x0000000000400238 0x00001c 0x00001c R 0x4
LOAD 0x000000 0x0000000000400000 0x0000000000400000 0x0b8e14 0x0b8e14 R E 0x1000 ← 代码段
LOAD 0x0b8e18 0x00000000004b9e18 0x00000000004b9e18 0x00a5a0 0x00a5a0 R 0x1000 ← 只读数据
LOAD 0x0c3380 0x00000000004c4380 0x00000000004c4380 0x00c4a0 0x014e10 RW 0x1000 ← 数据段
DYNAMIC 0x0c3380 0x00000000004c4380 0x00000000004c4380 0x0001e0 0x0001e0 RW 0x8
TLS 0x0cf820 0x00000000004d0820 0x00000000004d0820 0x000008 0x000010 RW 0x8
graph TD subgraph "Go 二进制内存布局" A["0x400000<br/>代码段 (.text)<br/>R+E — 只读+可执行<br/>包含:runtime + 用户代码"] B["0x4b9e18<br/>只读数据 (.rodata)<br/>R — 只读<br/>包含:字符串常量、类型信息"] C["0x4c4380<br/>数据段 (.data + .bss)<br/>R+W — 可读写<br/>包含:全局变量、g0 栈"] D["TLS<br/>线程本地存储<br/>包含:g 指针(TLS 存储当前 g)"] end A --> B --> C --> D style A fill:#4CAF50,color:#fff style B fill:#2196F3,color:#fff style C fill:#FF9800,color:#fff

各段内容详解#

权限内容Go 特有
.textR+E机器指令(runtime + 用户代码)runtime 函数占大部分
.rodataR只读数据(常量、类型元数据)Go 类型描述符、itab 表
.dataR+W已初始化全局变量runtime 全局状态
.bssR+W未初始化全局变量g0 栈、m0 结构
.symtab符号表所有 Go 函数符号
.pclntabRPC-行号映射表Go 独有的栈跟踪支持
TLSR+W线程本地存储存储当前 goroutine 指针

符号表与 Go runtime 符号#

查看符号表#

# 查看所有符号(输出很长)
$ readelf -s hello | head -50
# 只看 runtime 相关符号
$ readelf -s hello | grep "runtime\." | head -20
# 统计各包的符号数量
$ readelf -s hello | grep -oP '\w+\.\w+' | sort | uniq -c | sort -rn | head -20

Go 符号命名规则#

Go 的符号名经过 name mangling,规则为 包路径.函数名

runtime.main → runtime 的 main 函数
runtime.gopark → goroutine 挂起
runtime.mallocgc → 内存分配
main.main → 用户的 main 函数
fmt.Println → fmt 包的 Println
runtime/internal/atomic.Xadd → 内部原子操作

关键 runtime 符号#

符号作用源码位置
runtime._rt0_amd64_linux程序入口runtime/rt0_linux_amd64.s
runtime.mainruntime 主函数runtime/proc.go
runtime.schedule调度器核心runtime/proc.go
runtime.mallocgc内存分配runtime/malloc.go
runtime.gcStartGC 启动runtime/mgc.go

runtime 如何被打包进可执行文件#

这是理解”Go 二进制为什么这么大”的关键。

编译与链接过程#

flowchart TD A["用户代码<br/>main.go"] --> B["Go 编译器<br/>cmd/compile"] B --> C["用户目标文件<br/>main.o"] D["runtime 源码<br/>runtime/*.go"] --> E["Go 编译器<br/>cmd/compile"] E --> F["runtime 目标文件<br/>runtime.a"] C --> G["链接器<br/>cmd/link"] F --> G G --> H["最终可执行文件<br/>hello (ELF)"] style H fill:#4CAF50,color:#fff

runtime 包含的内容#

runtime 不仅仅是一个”库”,它包含了运行一个 Go 程序所需的全部基础设施

组件代码量(约)功能
调度器~3000 行GMP 模型、调度循环
内存分配器~2000 行mcache/mcentral/mheap
GC~4000 行三色标记、写屏障
栈管理~1500 行栈增长/收缩、栈拷贝
channel~600 行hchan 实现
map~800 行hmap 实现
interface~400 行itab 缓存
defer/panic~500 行_defer 链表
netpoll~500 行epoll/kqueue 集成
类型元数据~N 行反射、类型描述符

为什么 Hello World 有 1.2MB?#

$ cat > tiny.go << 'EOF'
package main
func main() {
println("hi")
}
EOF
$ go build -o tiny tiny.go
$ ls -lh tiny
-rwxr-xr-x 1 user user 1.2M tiny
# 用 -ldflags="-s -w" 去除符号表和调试信息
$ go build -ldflags="-s -w" -o tiny-stripped tiny.go
$ ls -lh tiny-stripped
-rwxr-xr-x 1 user user 876K tiny-stripped

1.2MB 的构成:

组件大小(约)占比
runtime 代码~600KB50%
类型元数据 + pclntab~300KB25%
标准库(fmt 等)~200KB17%
用户代码~10KB1%
ELF 头 + 段头~10KB1%

runtime 占了一半以上,这就是 Go “自带电池”(batteries included)的代价。

使用 Go 工具分析二进制#

go tool objdump:反汇编#

# 反汇编 main.main 函数
$ go tool objdump -s "main.main" hello
TEXT main.main(SB) /tmp/main.go
main.go:5 0x4a1c60 MOVQ (TLS), CX
main.go:5 0x4a1c69 CMPQ 0x10(CX), SP
main.go:5 0x4a1c6d JBE 0x4a1d06
main.go:5 0x4a1c73 SUBQ $0x28, SP
main.go:5 0x4a1c77 MOVQ BP, 0x20(SP)
main.go:5 0x4a1c7c LEAQ 0x20(SP), BP
...

go tool nm:符号表#

# 查看符号地址
$ go tool nm hello | grep "runtime.main"
428160 T runtime.main
428160 T runtime.main.f
# T = Text段(代码),D = Data段,B = BSS段

go tool compile -S:查看汇编输出#

# 编译时输出汇编
$ go tool compile -S main.go

程序加载:从 execve 到入口点#

当你在 shell 中输入 ./hello 时,发生了什么?

flowchart TD A["Shell: fork + execve"] --> B["内核:解析 ELF 头"] B --> C["内核:映射段到虚拟内存"] C --> D["内核:设置栈(argc, argv, envp)"] D --> E["内核:跳转到入口点 _start"] E --> F["_start → __libc_start_main<br/>(Go 不使用 libc)"] F --> G["_rt0_amd64_linux<br/>(Go 真正的入口)"] G --> H["runtime.rt0_go<br/>(汇编:设置 g0, m0)"] H --> I["runtime.main<br/>(启动调度器、GC)"] I --> J["main.main<br/>(用户代码)"] style G fill:#4CAF50,color:#fff style J fill:#FF9800,color:#fff

入口点详解#

Go 程序的入口点不是 main.main,而是 _rt0_amd64_linux

src/runtime/rt0_linux_amd64.s
TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
JMP _rt0_amd64(SB)
TEXT _rt0_amd64(SB),NOSPLIT,$-8
MOVQ 0(SP), DI // argc
LEAQ 8(SP), SI // argv
JMP main(SB) // 跳转到 runtime.main 的汇编入口

这段汇编只做一件事:把 argcargv 传给 runtime,然后跳转到 runtime 的初始化流程。

减小 Go 二进制大小#

编译选项#

# 去除符号表和调试信息
$ go build -ldflags="-s -w" -o hello main.go
# -s: 去除符号表
# -w: 去除 DWARF 调试信息
# 使用 UPX 压缩(慎用,影响启动速度)
$ upx --best hello

各方法效果对比#

方法大小效果
默认1.2MB
-ldflags=“-s -w”876KB-27%
UPX 压缩~400KB-67%(但启动慢)
不用 fmt,用 println876KB → 680KB-22%

常见问题 FAQ#

Q1:Go 二进制为什么比 C 的 Hello World 大这么多?#

C 的 Hello World 依赖 libc 动态链接,runtime 代码在共享库中,不占二进制大小。Go 是静态链接,runtime 全部打包进二进制。如果 C 也静态链接 libc,大小也不小。

Q2:Go 的 ELF 和 C 的 ELF 有什么区别?#

主要区别:(1) Go 不使用 libc,入口点直接是 _rt0_amd64_linux;(2) Go 有 .pclntab 段用于栈跟踪;(3) Go 有 .gopclntab 用于 PC 到行号的映射;(4) Go 的 TLS 段存储当前 goroutine 指针。

Q3:可以用 strip 命令去除 Go 符号吗?#

可以,但不推荐。strip 会移除 .pclntab,导致 panic 时无法打印栈跟踪。应该用 go build -ldflags="-s -w" 代替,它正确处理 Go 特有的段。

Q4:Go 支持动态链接吗?#

Go 默认静态链接。从 Go 1.5 开始支持 cgo 的动态链接(链接 C 共享库),但纯 Go 代码始终静态链接。Go 1.21+ 的 plugin 模式支持动态加载,但有诸多限制。

Q5:如何查看 Go 二进制中包含哪些包?#

$ go version -m hello
# 输出所有依赖模块及其版本

小结#

  1. Go 二进制是标准 ELF 格式,但包含 Go 特有的段(.pclntab.gopclntab
  2. runtime 占二进制大小的 50%+,这是 Go 静态链接的代价
  3. 程序入口不是 main.main,而是 _rt0_amd64_linuxruntime.rt0_goruntime.mainmain.main
  4. 符号表包含所有 Go 函数,命名规则为 包路径.函数名
  5. 减小二进制的最佳方式-ldflags="-s -w",不要用 strip

理解 ELF 结构是理解 Go 程序从”文件”变成”进程”的。下一篇文章深入 defer/panic/recover 的底层实现——这三个语言特性看似简单,但底层机制远比想象中复杂。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Go 可执行文件深度解析:ELF 结构与 runtime 嵌入
https://blog.souloss.com/posts/golang/go-elf/
作者
Souloss
发布于
2023-01-07
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时