对话:程序装载 - 从文件到进程


前置知识: [02-linking.md] 难度: ⭐⭐⭐ 预计时间: 50分钟 关键词: 装载器, 内存映射, 动态链接, GOT/PLT, ELF

引入场景

你有没有想过,双击一个可执行文件后,操作系统做了什么让它”活”起来?编译链接后的可执行文件只是磁盘上的一堆二进制数据,如何变成一个运行的进程?

对话探索

问题1:可执行文件在磁盘上,进程在内存中运行。操作系统是把整个文件复制到内存吗?

💭 思考方向:

  • 一个可执行文件可能几百MB,全部复制到内存?
  • 如果同时运行100个程序呢?
  • 程序可能只用到部分代码(如错误处理分支很少执行)

📖 参考答案: 不是全部复制!操作系统使用**内存映射(Memory Mapping)**技术:

  1. 虚拟内存映射:创建进程的虚拟地址空间,建立虚拟地址到文件的映射关系
  2. 按需加载(Lazy Loading):只有真正访问某页代码/数据时,才从磁盘加载到物理内存
  3. 页表机制:MMU通过页表将虚拟地址转换为物理地址
虚拟地址空间                  物理内存             磁盘文件
┌────────────┐              ┌──────┐          ┌──────────┐
│ 代码段      │─── 映射 ───→│ 页框1 │←─ 加载 ─│ .text    │
│ (未访问部分) │─── 映射 ──→ │ (空)  │         │ (等待)   │
│ 数据段      │─── 映射 ───→│ 页框3 │←─ 加载 ─│ .data    │
└────────────┘              └──────┘          └──────────┘

按需加载的过程:

  1. CPU访问虚拟地址 → 页表查找
  2. 如果对应物理页不存在 → 缺页异常(Page Fault)
  3. 操作系统从磁盘读入该页到物理内存
  4. 更新页表,重新执行指令

这意味着一个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→  │     │ 真实代码 │
└─────────┘     └─────────┘     └─────────┘
  1. 编译时:调用外部函数的地方生成PLT桩代码
  2. 首次调用:PLT跳转到动态链接器,解析函数真实地址,写入GOT
  3. 后续调用:直接从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程序的装载特点

  1. 静态链接:不需要动态链接器,装载过程更简单
  2. 运行时初始化:Go的入口先初始化 goroutine 调度器、GC、内存分配器
  3. 大栈空间:Go的goroutine使用连续栈,初始栈很小但可以增长

小结

⭐ 核心要点:

  1. 内存映射 + 按需加载:不是复制整个文件,而是建立映射关系,访问时才真正加载
  2. 动态链接:通过GOT/PLT实现运行时函数地址解析
  3. Go的特殊性:默认静态链接,不需要动态链接器,部署简单

关联阅读