Linux 可执行程序运行机制深度解析
本文档从内核实现、二进制格式解析、内存管理等维度,详细剖析 Linux 系统下 ELF 可执行程序的运行机制。
1. 可执行文件格式解析 (ELF)
ELF (Executable and Linkable Format) 是 Linux 的标准二进制格式。它提供了两种视图:链接视图 (用于编译链接)和执行视图(用于加载运行)。
1.1 ELF 核心结构
ELF Header (文件头)
位于文件开头,定义了全局属性。
- 魔数 (Magic) :
7F 45 4c 46(.ELF),用于内核识别文件格式。 - Entry Point : 程序入口虚拟地址 (
_start)。 - Program Header Table Offset: 描述段表在文件中的位置。
Program Header Table (程序头表)
核心作用 : 告诉内核如何将文件映射到内存。
关键字段:
p_type: 段类型。PT_LOAD表示该段需要被加载到内存;PT_INTERP表示动态链接器路径。p_vaddr: 内存中的虚拟地址。p_filesz/p_memsz: 文件中大小与内存中大小(.bss段通常memsz > filesz)。
Section Header Table (节头表)
核心作用: 描述各个节区的属性,主要用于链接期和调试。
- p_type: 节区类型(如 PROGBITS, NOBITS)。
- p_flags: 权限标志(Alloc, Write, Exec)。
1.2 关键节区 (Sections) 详解
| 节区名称 | 说明 | 内存权限 | 加载行为 |
|---|---|---|---|
| .text | 代码段,存放 CPU 机器指令 | R-X (读/执行) | 从文件直接映射 |
| .data | 数据段,存放已初始化的全局/静态变量 | RW- (读/写) | 从文件直接映射 (COW) |
| .bss | Block Started by Symbol,存放未初始化变量 | RW- (读/写) | 匿名映射,初始化为 0 |
| .rodata | 只读数据段,存放字符串常量等 | R-- (只读) | 从文件直接映射 |
| .plt | 过程链接表 (Procedure Linkage Table) | R-X | 用于动态链接延迟绑定 |
| .got | 全局偏移表 (Global Offset Table) | RW- | 存放外部符号的运行时地址 |
2. 内存加载过程
2.1 从磁盘到内存的完整流程
当执行 ./demo 时,内核经过以下步骤将其加载:
- 系统调用 : 用户态
execve-> 内核态sys_execve->do_execveat_common。 - 读取文件头: 内核读取文件前 128 字节,检查魔数。
- 寻找处理函数 :
search_binary_handler发现是 ELF 格式,调用load_elf_binary。 - 处理解释器 : 检查是否存在
PT_INTERP段。若存在(如/lib64/ld-linux-x86-64.so.2),则也需要加载该解释器。 - 映射 Segment :
- 遍历 Program Headers。
- 对
PT_LOAD类型的段,调用elf_map(底层为mmap) 将文件内容映射到虚拟地址空间。 - 注意: 此时并未真正读取物理内存(Lazy Loading),仅建立了虚存映射。
- 初始化栈 : 分配物理页作为用户栈,并将
argc,argv,envp压栈。 - 移交控制权 :
- 动态链接:PC 指向解释器的入口地址。
- 静态链接:PC 指向程序的
e_entry。
2.2 内存空间布局
下图展示了 64 位 Linux 进程的经典内存布局:

2.3 页表机制与地址转换
Linux 使用多级页表(x86_64 通常为 4 级或 5 级)完成虚拟地址 (VA) 到物理地址 (PA) 的转换。
- CR3 寄存器: 存储顶级页表(PGD)的物理基地址。
- MMU 查找: CPU 访问虚拟地址时,MMU 硬件自动遍历 PGD -> PUD -> PMD -> PTE。
- PTE (Page Table Entry): 最终指向物理页帧 (Page Frame),并包含权限位 (R/W, User/Supervisor, NX)。
- TLB (Translation Lookaside Buffer): 缓存最近的转换结果,加速访问。
3. 动态链接过程
3.1 动态链接器 (ld.so) 机制
内核完成映射后,ld.so 接管运行:
- 自举 :
ld.so先重定位自身(因为它本身也是动态库)。 - 加载依赖 : 读取 ELF 的
.dynamic段,递归加载DT_NEEDED标记的所有共享库。 - 符号解析: 解决主程序和共享库之间的符号引用。
3.2 延迟绑定 (Lazy Binding)
Linux 默认不解析所有函数地址,而是推迟到第一次调用时。
原理 (GOT + PLT):
- GOT: 存放绝对地址的数据表。
- PLT: 一小段跳转代码。
调用流程:
- 程序调用
func@plt。 func@plt跳转到GOT[func]。- 首次调用 :
GOT[func]存的是 PLT 的下一条指令地址(即"没找到,回去")。 - PLT 继续执行:压入符号 ID,跳转到
_dl_runtime_resolve。 - 解析 :
ld.so查找func真实地址,填入GOT[func]。 - 后续调用 :
GOT[func]已是真实地址,直接跳转,无额外开销。
4. 进程执行流程
4.1 execve 内部调用链
execve 1. 映射PT_LOAD 2. 加载解释器 3. 设置栈 4. 修改寄存器 返回用户态 用户空间: ./program 系统调用入口 fs/exec.c: do_execve do_execveat_common prepare_binprm
- 读前128字节
- 检查权限 search_binary_handler fs/binfmt_elf.c: load_elf_binary 内存映射 加载 ld.so create_elf_tables start_thread 跳转到 _start

4.2 进程控制块 (PCB) 初始化
在 execve 过程中,当前进程的 task_struct 发生关键变化:
- mm_struct : 释放旧的内存描述符,分配新的
mm。 - comm: 进程名更新为新程序名。
- signal: 重置信号处理函数(因为旧代码已不存在)。
- files : 继承打开的文件描述符(除非设置了
FD_CLOEXEC)。
4.3 调用链分析
- _start (汇编入口): 清零
rbp,从栈获取argc。 - __libc_start_main (glibc):
- 初始化线程环境。
- 调用
.init段和.init_array(全局构造函数)。
- main(): 用户代码。
- exit() : 调用
.fini_array(全局析构),结束进程。
5. 内存管理机制
5.1 缺页异常 (Page Fault)
由于 mmap 是懒加载,程序开始执行时,物理内存中并没有代码。
- CPU 尝试取指,发现页表项无效 (Present=0)。
- 触发缺页异常,陷入内核
do_page_fault。 - 内核检查
vm_area_struct,确认是合法映射。 - 分配物理页 -> 从磁盘读取文件内容 -> 更新页表。
- 恢复执行,CPU 重新取指成功。
5.2 写时复制 (COW)
对于 .data 段,父子进程或多个运行实例初始共享同一物理页(只读)。
- 当进程尝试写数据时,触发异常。
- 内核检测到是 COW 页,复制一份物理页给当前进程,权限改为可写。
- 各进程随后拥有独立的数据副本。
6. 实际案例分析
编写 demo.c 并编译,查看反汇编。
源代码:
c
// 调用动态库函数 free
free(heap_var);
反汇编 (.plt.sec):
asm
0000000000001070 <free@plt>:
1070: f3 0f 1e fa endbr64
1074: f2 ff 25 45 2f 00 00 bnd jmp *0x2f45(%rip) # 3fc0 <free@GLIBC_2.2.5>
107b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
解析:
- 指令
jmp *0x2f45(%rip)是一个间接跳转。 - 目标地址 = 当前指令地址
0x1074+ 指令长度7+ 偏移0x2f45=0x3fc0。 0x3fc0位于.got段。- 运行前 :
0x3fc0处填的是0x107b(下一条指令)。 - 运行后 :
0x3fc0处被ld.so修改为free函数在libc.so中的真实物理内存地址。