对话:程序装载 - 从文件到进程
前置知识: [02-linking.md] 难度: ⭐⭐⭐ 预计时间: 50分钟 关键词: 装载器, 内存映射, 动态链接, GOT/PLT, ELF
引入场景
你有没有想过,双击一个可执行文件后,操作系统做了什么让它”活”起来?编译链接后的可执行文件只是磁盘上的一堆二进制数据,如何变成一个运行的进程?
对话探索
问题1:可执行文件在磁盘上,进程在内存中运行。操作系统是把整个文件复制到内存吗?
💭 思考方向:
- 一个可执行文件可能几百MB,全部复制到内存?
- 如果同时运行100个程序呢?
- 程序可能只用到部分代码(如错误处理分支很少执行)
📖 参考答案: 不是全部复制!操作系统使用**内存映射(Memory Mapping)**技术:
- 虚拟内存映射:创建进程的虚拟地址空间,建立虚拟地址到文件的映射关系
- 按需加载(Lazy Loading):只有真正访问某页代码/数据时,才从磁盘加载到物理内存
- 页表机制:MMU通过页表将虚拟地址转换为物理地址
虚拟地址空间 物理内存 磁盘文件
┌────────────┐ ┌──────┐ ┌──────────┐
│ 代码段 │─── 映射 ───→│ 页框1 │←─ 加载 ─│ .text │
│ (未访问部分) │─── 映射 ──→ │ (空) │ │ (等待) │
│ 数据段 │─── 映射 ───→│ 页框3 │←─ 加载 ─│ .data │
└────────────┘ └──────┘ └──────────┘
按需加载的过程:
- CPU访问虚拟地址 → 页表查找
- 如果对应物理页不存在 → 缺页异常(Page Fault)
- 操作系统从磁盘读入该页到物理内存
- 更新页表,重新执行指令
这意味着一个1GB的程序,如果只用了10MB的代码,物理内存只需10MB。
问题2:程序运行时可能依赖很多库(如libc)。这些库的代码在哪里?每个进程都要复制一份吗?
💭 思考方向:
- 静态链接 vs 动态链接的区别
.so/.dll是什么文件- 多个进程如何共享库代码
📖 参考答案:
动态链接库(.so/.dll)在运行时加载:
静态链接(Go默认):把库代码拷贝到可执行文件
- 优点:独立运行,无依赖
- 缺点:文件大,无法共享内存
动态链接:运行时才加载库
- 优点:节省磁盘和内存,库升级无需重新编译
- 缺点:启动稍慢,依赖管理复杂
共享机制:多个进程映射同一个.so文件到各自虚拟地址空间,但物理内存只有一份
进程A虚拟空间 物理内存 进程B虚拟空间
┌────────────┐ ┌──────────┐ ┌────────────┐
│ 自己的代码 │ │ │ │ 自己的代码 │
├────────────┤ │ │ ├────────────┤
│ libc.so │─────→│ libc.so │←─────│ libc.so │
│ 0x7f000000 │ │ 物理页 │ │ 0x7f100000 │
└────────────┘ └──────────┘ └────────────┘
虚拟地址不同 唯一一份 虚拟地址不同
问题3:动态链接时,程序如何找到库函数的地址?编译时地址还不确定啊!
💭 思考方向:
- 编译时无法知道
printf在内存的哪里 - 不同进程加载同一个.so到不同虚拟地址
- 如何实现”延迟绑定”?
📖 参考答案: 通过**GOT(Global Offset Table)和PLT(Procedure Linkage Table)**机制:
首次调用 printf:
┌─────────┐ ┌─────────┐ ┌──────────────┐ ┌─────────┐
│ 代码: │ ──→ │ PLT桩: │ ──→ │ 动态链接器: │ ──→ │ 解析地址 │
│ call PLT │ │ 跳GOT→ │ │ 查找printf │ │ 写入GOT │
└─────────┘ └─────────┘ └──────────────┘ └─────────┘
后续调用 printf:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 代码: │ ──→ │ PLT桩: │ ──→ │ printf │ (直接从GOT读取,不再解析)
│ call PLT │ │ 跳GOT→ │ │ 真实代码 │
└─────────┘ └─────────┘ └─────────┘
- 编译时:调用外部函数的地方生成PLT桩代码
- 首次调用:PLT跳转到动态链接器,解析函数真实地址,写入GOT
- 后续调用:直接从GOT读取地址,无需再解析
这就是延迟绑定(Lazy Binding)——只在真正调用时才解析地址。
问题4:装载器的完整工作流程是什么?从exec系统调用开始。
💭 思考方向:
exec()之后,操作系统做了什么?- 进程的入口是main吗?
- 堆和栈是谁创建的?
📖 参考答案:
exec("./program") 系统调用
│
▼
1. 读取ELF头 ──→ 确认文件类型、架构、入口点
│
▼
2. 创建虚拟地址空间 ──→ 创建新的页表
│
▼
3. 映射文件段
├─ .text(代码段)→ 只读+可执行
├─ .data(数据段)→ 读写
└─ .bss(未初始化)→ 读写(全零)
│
▼
4. 设置栈 ──→ 在高地址处分配栈空间
│ 把命令行参数和环境变量压入栈
▼
5. 如果有动态链接 ──→ 映射动态链接器(ld-linux.so)
│
▼
6. 跳转到入口点
├─ 静态链接 → 跳转到 _start
└─ 动态链接 → 跳转到动态链接器入口
→ 加载所有.so
→ 跳转到 _start
│
▼
7. _start → __libc_start_main → main()
(Go: _rt0_amd64_linux → runtime.main → main.main)
注意:程序的入口不是main! 是 _start(C)或 _rt0_amd64_linux(Go),它们先初始化运行时环境,然后才调用你写的 main()。
Go语言实践
package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}# 查看Go程序的链接方式
$ go build -o hello main.go
$ ldd hello
# 输出:not a dynamic executable(Go默认静态链接)
# 使用CGO后会动态链接
$ CGO_ENABLED=1 go build -o hello_cgo main.go
$ ldd hello_cgo
# 可能看到 libc.so, libpthread.so 等依赖
# 查看ELF文件头
$ readelf -h hello
# 可以看到: Entry point address(入口地址)
# 查看程序头(描述了要加载的段)
$ readelf -l hello
# 你会看到 LOAD 类型的段——这就是需要映射到内存的部分
# 查看段信息
$ readelf -S hello | head -30
# .text, .data, .rodata 等段
# 查看Go程序的入口
$ readelf -h hello | grep Entry
# 然后用 objdump 查看入口处的代码
$ go tool objdump hello | grep "_rt0" | head -5深入细节
ELF文件格式
ELF(Executable and Linkable Format)是Linux下的可执行文件格式:
┌────────────────────────┐
│ ELF Header │ 魔数、类型、架构、入口点
├────────────────────────┤
│ Program Header Table │ 告诉装载器:加载哪些段、加载到哪里
│ LOAD: .text │ 代码段,权限: r-x
│ LOAD: .data │ 数据段,权限: rw-
│ INTERP: /lib/ld... │ 动态链接器路径
│ ... │
├────────────────────────┤
│ .text (代码) │
│ .rodata (只读数据) │
│ .data (已初始化数据) │
│ .bss (未初始化数据) │
│ .symtab (符号表) │
│ .strtab (字符串表) │
│ ... │
├────────────────────────┤
│ Section Header Table │ 告诉链接器:各个节的信息
└────────────────────────┘
两个视图:
- 运行视图(Program Headers):装载器用,关心”段”
- 链接视图(Section Headers):链接器用,关心”节”
Go程序的装载特点
- 静态链接:不需要动态链接器,装载过程更简单
- 运行时初始化:Go的入口先初始化 goroutine 调度器、GC、内存分配器
- 大栈空间:Go的goroutine使用连续栈,初始栈很小但可以增长
小结
⭐ 核心要点:
- 内存映射 + 按需加载:不是复制整个文件,而是建立映射关系,访问时才真正加载
- 动态链接:通过GOT/PLT实现运行时函数地址解析
- Go的特殊性:默认静态链接,不需要动态链接器,部署简单