第一章:程序加载与执行 —— 从 fork() 到 main()
本章核心问题:用户在终端敲下 ./a.out 并按下回车,到 main() 函数的第一行代码被执行,中间发生了什么?
前置认知:你在 Shell 中输入的每一个外部命令,Shell 都会先 fork() 一个子进程,再在子进程中 execve() 加载目标程序。fork() 和 execve() 是两个独立的系统调用,缺一不可。
一、Shell 解析与 fork() —— 复制进程描述符
用户在 Shell 中输入 ./a.out,Shell 解析命令行,识别出这是一个外部命令(非 cd、export 等内建命令)。
Shell 调用 fork() —— 它不是一个直接的系统调用名,在 Linux 中 fork() 是 clone() 系统调用的一个特化版本(glibc 的 fork() 封装最终调用 clone(SIGCHLD, 0))。
内核入口:entry_SYSCALL_64(x86_64)→ sys_clone → do_fork → _do_fork → copy_process。
1. copy_process() —— 进程描述符的完整复制
copy_process() 是 fork 的核心,返回新进程的 struct task_struct *。它做的不是”浅拷贝”,而是逐层深度复制:
// include/linux/sched.h 中 task_struct 的部分字段
struct task_struct {
// 1. 进程身份
pid_t pid; // 新 PID(由 alloc_pid() 分配)
pid_t tgid; // 线程组 ID(通常同 PID)
struct task_struct *parent; // 指向父进程的 task_struct
struct list_head children; // 子进程链表头
// 2. 内存描述符(关键!)
struct mm_struct *mm; // 进程地址空间
struct mm_struct *active_mm; // 内核线程借用
// 3. 文件系统上下文
struct fs_struct *fs; // 当前根目录、当前工作目录
// 4. 文件描述符表
struct files_struct *files; // 打开的文件
// 5. 调度实体(CFS 红黑树节点)
struct sched_entity se; // 包含 vruntime、权重
// 6. 信号处理
struct signal_struct *signal; // 信号共享结构
struct sighand_struct *sighand; // 信号处理函数表
// 7. 内核栈
struct thread_info thread_info;
void *stack; // 内核态栈
};copy_process() 的逐层复制细节:
| 操作 | 函数 | 做了什么 | 数据结构变化 |
|---|---|---|---|
| 分配新 task_struct | dup_task_struct(current) | 从内核内存池(kmem_cache)分配新 task_struct,memcpy 复制父进程内容,然后重置特有字段(如 pid 置为 0,待分配) | 新 task_struct 在内存中,大部分字段值与父进程相同 |
| 分配新 PID | alloc_pid() | 在 PID 命名空间中分配唯一编号,写入 task_struct->pid 和 tgid | 全局 PID 哈希表 pid_hash[] 增加新条目 |
| 复制内存描述符(写时复制) | dup_mm(current) | 调用 mm_alloc() 分配新 mm_struct。复制 VMA 红黑树(mm->mm_rb),但所有 VMA 的物理页表条目被标记为只读,且物理页引用计数(page->_refcount)增加。页表项(PTE)中的 _PAGE_RW 位被清除 | 父子进程指向同一物理页,但任何一方的写入会触发缺页中断(do_page_fault),在中断中复制物理页(COW) |
| 复制文件描述符表 | dup_fd(current->files, &newfiles) | 复制 struct files_struct。遍历 fdtable,对每个 struct file * 执行 get_file(file)(引用计数 f_count++)。新进程的 fd 指向与父进程完全相同的 struct file 实例 | 父子进程共享 struct file,f_pos(文件读写位置)被共享——这就是父子进程同时读写同一文件时互相干扰的原因 |
| 复制文件系统上下文 | copy_fs(current) | 复制 struct fs_struct,get_fs_struct 增加引用计数 | 父子进程共享当前目录和根目录 |
| 复制信号处理 | copy_sighand(current) | 复制或共享 struct sighand_struct。task_struct->sighand 默认复制,除非指定 CLONE_SIGHAND(线程)则共享 | 共享意味着信号处理函数被所有线程共享 |
| 初始化调度实体 | sched_fork(current, p) | 重置 sched_entity 的 vruntime 为父进程的 vruntime(略微偏移以防饿死),on_rq = 0(不在就绪队列中),sum_exec_runtime = 0 | 子进程的 CFS 红黑树节点尚未插入任何 CPU 的 rq->cfs 红黑树,待 wake_up_new_task 时插入 |
| 建立父子关系 | 链表操作 | p->parent = current,list_add_tail(&p->sibling, ¤t->children) | 父进程的 children 链表尾部增加子进程节点 |
| 添加到进程哈希表 | attach_pid(p, PIDTYPE_PID) | 将新 PID 插入 pid_hash[] 全局哈希表(用于 find_task_by_pid 快速查找) | 全局 pid_hash 新增条目 |
2. 子进程的状态与返回值
copy_process() 返回新 task_struct 指针后,do_fork 调用 wake_up_new_task(p),将子进程插入到某个 CPU 的 CFS 红黑树中(调度器可调度它)。
父子进程同时在 fork() 返回点:
- 父进程
fork()返回子进程的 PID(正数)。 - 子进程
fork()返回 0。
这是由内核在 copy_process 完成后,在子进程的 pt_regs->rax(x86_64 的返回值寄存器)中写入 0,在父进程的 pt_regs->rax 中写入子进程 PID 实现的。
二、execve() 入口 —— 打开目标文件
子进程从 fork() 返回后,Shell(子进程)调用 execve("./a.out", argv, envp)。
glibc 封装:int execve(const char *path, char *const argv[], char *const envp[]) → 触发 syscall 指令(rax=59)。
内核入口:entry_SYSCALL_64 → sys_execve → do_execve → do_execveat_common。
do_execveat_common(int fd, struct filename *pathname, ...)
-
调用
do_open_execat():通过getname(pathname)将用户态路径字符串拷贝到内核态,调用file_open_name打开文件,返回struct file *。- 这个过程触发 VFS:
task_struct->fs->root开始查找路径,通过d_lookup在 dcache 中查找 dentry,若缓存缺失则从磁盘读取 inode,最终返回struct file指针(f_count已 +1)。
- 这个过程触发 VFS:
-
分配
struct linux_binprm(二进制参数结构):struct linux_binprm { struct file *file; // 指向目标可执行文件的 struct file struct vm_area_struct *vma; // 用于参数传递 unsigned long p; // 当前栈指针位置(用户态) char buf[BINPRM_BUF_SIZE]; // 保存前 128 字节用于识别文件类型 struct mm_struct *mm; // execve 执行期间临时使用的 mm_struct // ... }; -
prepare_bprm():将argv和envp从用户态拷贝到内核态,计算总大小,分配用户态栈空间(临时),将参数压入栈。 -
prepare_binprm():读取目标文件的前 128 字节到bprm->buf,用于识别文件类型(ELF 魔数、脚本#!等)。
三、ELF 识别与加载 —— load_elf_binary
内核维护一个 linux_binfmt 链表(ELF、脚本解释器、a.out 等)。对每个 struct file *,内核依次调用 fmt->load_binary(bprm)。
// include/linux/binfmts.h
struct linux_binfmt {
struct list_head lh;
struct module *module;
int (*load_binary)(struct linux_binprm *);
int (*load_shlib)(struct file *);
// ...
};ELF 文件的 fmt 是 elf_format,其 load_binary 指向 load_elf_binary(定义在 fs/binfmt_elf.c)。
1. ELF 头部验证
// 标准 ELF 头 64 位定义
struct elfhdr {
unsigned char e_ident[EI_NIDENT]; // 0x7f 45 4c 46(ELF 魔数)
Elf64_Half e_type; // ET_EXEC(2) 或 ET_DYN(3,位置无关可执行文件)
Elf64_Half e_machine; // EM_X86_64(62)
Elf64_Word e_version;
Elf64_Addr e_entry; // 入口点虚拟地址
Elf64_Off e_phoff; // 程序头表偏移
Elf64_Off e_shoff; // 节头表偏移
Elf64_Word e_phentsize; // 程序头表项大小
Elf64_Word e_phnum; // 程序头表项数量
// ...
};load_elf_binary 首先检查 bprm->buf 中的前 4 字节是否为 0x7f 45 4c 46。若不匹配,返回 -ENOEXEC(execve 返回 “Exec format error”)。
2. 清空旧地址空间(核心步骤!)
重要认知:execve 不保留父进程的任何内存。调用 exec_mmap() 释放旧的 mm_struct:
static int exec_mmap(struct mm_struct *mm) {
struct mm_struct *old_mm = current->mm;
// 释放 old_mm 的 VMA 链表、页表
mmput(old_mm);
// 切换到新的 mm_struct
current->mm = mm;
// 刷新 TLB(快表)
flush_tlb_mm(mm);
return 0;
}此时,进程拥有一个全新的、空的 mm_struct,不包含任何 VMA。
3. 创建新的 VMA 映射 ELF 段
解析 ELF 的程序头表(e_phoff 位置),每个程序头描述一个段(Segment):
struct elf_phdr {
Elf64_Word p_type; // PT_LOAD(1) 可加载段
Elf64_Word p_flags; // PF_R(4) / PF_W(2) / PF_X(1)
Elf64_Off p_offset; // 在文件中的偏移
Elf64_Addr p_vaddr; // 应加载到的虚拟地址
Elf64_Off p_filesz; // 文件中的大小
Elf64_Off p_memsz; // 内存中的大小(p_memsz >= p_filesz,多出的 .bss 归零)
// ...
};遍历所有 PT_LOAD 段,调用 elf_map():
elf_map(bprm->file, vaddr, elf_ppnt, elf_prot, elf_flags, total_size);elf_map() 最终调用 do_mmap(),在当前进程的新 mm_struct 中创建 vm_area_struct(VMA),插入红黑树(mm->mm_rb)和链表(mm->mmap)。
VMA 的创建细节:
struct vm_area_struct {
struct mm_struct *vm_mm; // 指向所属 mm_struct
unsigned long vm_start, vm_end; // 地址范围
struct vm_area_struct *vm_next, *vm_prev; // 链表
struct rb_node vm_rb; // 红黑树节点
unsigned long vm_flags; // VM_READ(1) | VM_WRITE(2) | VM_EXEC(4)
struct file *vm_file; // 指向可执行文件的 struct file(文件映射)
const struct vm_operations_struct *vm_ops; // 缺页处理函数
};.bss 段的处理:当 p_memsz > p_filesz 时,多出的内存区域(全局未初始化变量)在 ELF 文件里没有内容。内核调用 mmap(NULL, zero_size, ...) 映射匿名页,并标记为 VM_ZERO,实际物理页在第一次写入时分配(缺页中断分配零页)。
4. 堆和栈的 VMA 初始化
- 栈 VMA:调用
setup_arg_pages(bprm),在STACK_TOP(通常0x7ffffffff000,x86_64)附近创建 VMA,vm_flags含VM_READ | VM_WRITE | VM_GROWSDOWN(自动向下扩展)。 - 堆 VMA:调用
set_brk(0),mm->start_brk = mm->brk = 0(初始堆位置),后续brk()系统调用扩展堆时会创建新 VMA。
5. 动态链接器(解释器)处理
检查 ELF 头部是否有 PT_INTERP 段(.interp 节):
- 若有(绝大多数动态链接程序),读取该段内容(如
/ld-linux-x86-64.so.2),调用load_elf_interp()将该解释器本身也映射到进程地址空间中(使用MAP_FIXED或根据其自身加载地址)。 - 记录解释器入口点:
elf_entry = interp_elf_ex.e_entry(解释器的_start)。 - 若没有(静态链接程序),
elf_entry = elf_ex.e_entry(程序自身的_start)。
6. 设置返回用户态的入口
调用 start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp):
void start_thread(struct pt_regs *regs, unsigned long new_ip, unsigned long new_sp) {
regs->ip = new_ip; // 指令指针 RIP = 解释器入口 / _start
regs->sp = new_sp; // 栈指针 RSP = 用户态栈顶
regs->cs = __USER_CS; // 用户态代码段选择子
regs->ss = __USER_DS; // 用户态数据段选择子
regs->flags = 0x202; // IF 位置位(允许中断)
}此时,进程的 mm_struct 已完全替换,VMA 红黑树包含:
- 程序的代码段、数据段、
.bss段的 VMA - 解释器(动态链接器)的代码段 VMA
- 栈 VMA(
STACK_TOP附近)
四、返回用户态 —— 第一次切换到用户空间
load_elf_binary 返回 0 给 sys_execve,表示成功。内核执行系统调用返回路径:
entry_SYSCALL_64 的尾声:syscall_return_slowpath → exit_to_user_mode_prepare → SYSCALL_EXIT_TO_USER_MODE → sysretq 指令。
sysretq 的功能:从内核栈恢复用户态寄存器,从 pt_regs->ip 加载 RIP,从 pt_regs->sp 加载 RSP,切换特权级从 Ring 0 降到 Ring 3,跳转到 ld-linux.so 的 _start 入口。
此时,CPU 开始执行动态链接器(/ld-linux-x86-64.so.2)的机器码。
五、动态链接器(ld.so)的工作
动态链接器本身也是一个 ELF 可执行文件,它被内核加载并映射到进程地址空间,入口点 _start 由 sysretq 直接跳转。
ld.so 的核心任务:
-
解析自身的
_DYNAMIC段(包含.dynamic节),找到目标应用程序的依赖库列表(DT_NEEDED条目,如libc.so.6)。 -
对于每个依赖库,调用
open()/mmap()系统调用(注意,此时open和mmap仍然是系统调用,ld.so需要调用内核)将其加载到进程地址空间中,创建新的 VMA。 -
重定位(Relocation):
- 遍历
REL/RELA段(重定位表)。 - GOT(全局偏移表) 修正:将库函数地址写入 GOT 表项。
- PLT(过程链接表) 修正:确保延迟绑定(Lazy Binding)时能正确跳转。
- 遍历
-
将所有未解析的符号(应用程序引用的外部函数)与共享库提供的符号进行绑定。
ld.so使用strcmp对字符串进行符号查找(复杂度 O(n)),在现代实现中已优化为哈希表查找(DT_GNU_HASH)。 -
设置
TLS(线程局部存储)。 -
将控制权转移给应用程序的入口点(
_start,实际是__libc_start_main,由 glibc 提供)。
六、__libc_start_main 与 main()
应用程序的 _start 符号通常由 glibc 的 crt1.o 提供,指向 __libc_start_main(位于 csu/libc-start.c)。
__libc_start_main 执行流程:
- 设置
__cxa_atexit()以调用全局对象的析构函数(C++ 等)。 - 初始化
stdin/stdout/stderr(FILE *结构,用户态缓冲)。 - 建立
environ指针指向环境变量。 - 调用用户的
main(int argc, char *argv[], char *envp[])。 main()返回后,__libc_start_main调用exit()系统调用(exit_group,终止进程)。
七、退出 —— do_exit
exit(status) 触发系统调用 exit_group(),内核执行 do_exit():
- 设置进程状态为
TASK_DEAD,释放mm_struct(mmput,当引用计数归零时释放所有 VMA 和物理页)。 - 释放
files_struct(关闭所有文件描述符,f_count--)。 - 发送
SIGCHLD给父进程(通知父进程子进程已终止)。 - 进程变为僵尸状态(
EXIT_ZOMBIE),保留task_struct供父进程wait()读取退出码。 - 父进程调用
wait()时,task_struct被释放,进程彻底消失。
本章核心因果链总结(数据结构级)
| 用户态动作 | 内核函数 | 修改的核心数据结构 | 预判能力 |
|---|---|---|---|
Shell 调用 fork() | copy_process() | task_struct 复制、mm_struct 复制(COW)、files_struct 复制(引用计数 +1)、children 链表新增 | 看到父子进程共享同一文件偏移(f_pos),知道是 dup_fd 复制了同一 struct file * 指针 |
子进程调用 execve() | exec_mmap() | 释放旧 mm_struct,分配新 mm_struct,VMA 红黑树清空 | execve 后进程的 VMA 完全替换,旧内存全部丢弃 |
load_elf_binary | elf_map() → do_mmap() | 创建新的 vm_area_struct,插入 mm->mm_rb 红黑树 | 页表项未分配物理页,缺页中断(do_page_fault)将在程序开始执行时触发 |
load_elf_binary | start_thread() | 修改 pt_regs->ip 为解释器入口 | 用户态返回后从 ld.so 开始执行,而非应用程序 _start |
ld.so 加载共享库 | open/mmap(系统调用) | 为依赖库创建新的 VMA,修改 GOT/PLT 段(写入符号地址) | strace 中看到 mmap 多次出现,是动态链接器在加载共享库 |
main 返回后 | do_exit() | mmput 释放所有 VMA,task_struct 保留为僵尸状态 | ps 显示 Z 状态,父进程未 wait(),children 链表仍有条目 |
用户敲下回车到 main() 执行的完整路径,本质上是 task_struct → mm_struct → VMA 红黑树 → pt_regs 的逐层构建和替换。任何一个环节的失败(ELF 魔数错误、PT_INTERP 路径无效、mmap 地址冲突),都会在 strace 返回错误码时被你提前定位,因为你知道它卡在了哪个数据结构上。