第一章:程序加载与执行 —— 从 fork()main()

本章核心问题:用户在终端敲下 ./a.out 并按下回车,到 main() 函数的第一行代码被执行,中间发生了什么?

前置认知:你在 Shell 中输入的每一个外部命令,Shell 都会先 fork() 一个子进程,再在子进程中 execve() 加载目标程序。fork()execve() 是两个独立的系统调用,缺一不可。


一、Shell 解析与 fork() —— 复制进程描述符

用户在 Shell 中输入 ./a.out,Shell 解析命令行,识别出这是一个外部命令(非 cdexport 等内建命令)。

Shell 调用 fork() —— 它不是一个直接的系统调用名,在 Linux 中 fork()clone() 系统调用的一个特化版本(glibc 的 fork() 封装最终调用 clone(SIGCHLD, 0))。

内核入口entry_SYSCALL_64(x86_64)→ sys_clonedo_fork_do_forkcopy_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_structdup_task_struct(current)从内核内存池(kmem_cache)分配新 task_structmemcpy 复制父进程内容,然后重置特有字段(如 pid 置为 0,待分配)task_struct 在内存中,大部分字段值与父进程相同
分配新 PIDalloc_pid()在 PID 命名空间中分配唯一编号,写入 task_struct->pidtgid全局 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 filef_pos(文件读写位置)被共享——这就是父子进程同时读写同一文件时互相干扰的原因
复制文件系统上下文copy_fs(current)复制 struct fs_structget_fs_struct 增加引用计数父子进程共享当前目录和根目录
复制信号处理copy_sighand(current)复制或共享 struct sighand_structtask_struct->sighand 默认复制,除非指定 CLONE_SIGHAND(线程)则共享共享意味着信号处理函数被所有线程共享
初始化调度实体sched_fork(current, p)重置 sched_entityvruntime 为父进程的 vruntime(略微偏移以防饿死),on_rq = 0(不在就绪队列中),sum_exec_runtime = 0子进程的 CFS 红黑树节点尚未插入任何 CPU 的 rq->cfs 红黑树,待 wake_up_new_task 时插入
建立父子关系链表操作p->parent = currentlist_add_tail(&p->sibling, &current->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_64sys_execvedo_execvedo_execveat_common

do_execveat_common(int fd, struct filename *pathname, ...)

  1. 调用 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)。
  2. 分配 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
        // ...
    };
  3. prepare_bprm():将 argvenvp 从用户态拷贝到内核态,计算总大小,分配用户态栈空间(临时),将参数压入栈。

  4. 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 文件的 fmtelf_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。若不匹配,返回 -ENOEXECexecve 返回 “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_flagsVM_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_slowpathexit_to_user_mode_prepareSYSCALL_EXIT_TO_USER_MODEsysretq 指令。

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 可执行文件,它被内核加载并映射到进程地址空间,入口点 _startsysretq 直接跳转。

ld.so 的核心任务

  1. 解析自身的 _DYNAMIC 段(包含 .dynamic 节),找到目标应用程序的依赖库列表(DT_NEEDED 条目,如 libc.so.6)。

  2. 对于每个依赖库,调用 open() / mmap() 系统调用(注意,此时 openmmap 仍然是系统调用,ld.so 需要调用内核)将其加载到进程地址空间中,创建新的 VMA。

  3. 重定位(Relocation):

    • 遍历 REL / RELA 段(重定位表)。
    • GOT(全局偏移表) 修正:将库函数地址写入 GOT 表项。
    • PLT(过程链接表) 修正:确保延迟绑定(Lazy Binding)时能正确跳转。
  4. 将所有未解析的符号(应用程序引用的外部函数)与共享库提供的符号进行绑定。ld.so 使用 strcmp 对字符串进行符号查找(复杂度 O(n)),在现代实现中已优化为哈希表查找(DT_GNU_HASH)。

  5. 设置 TLS(线程局部存储)。

  6. 将控制权转移给应用程序的入口点(_start,实际是 __libc_start_main,由 glibc 提供)。


六、__libc_start_mainmain()

应用程序的 _start 符号通常由 glibc 的 crt1.o 提供,指向 __libc_start_main(位于 csu/libc-start.c)。

__libc_start_main 执行流程

  1. 设置 __cxa_atexit() 以调用全局对象的析构函数(C++ 等)。
  2. 初始化 stdin/stdout/stderrFILE * 结构,用户态缓冲)。
  3. 建立 environ 指针指向环境变量。
  4. 调用用户的 main(int argc, char *argv[], char *envp[])
  5. main() 返回后,__libc_start_main 调用 exit() 系统调用(exit_group,终止进程)。

七、退出 —— do_exit

exit(status) 触发系统调用 exit_group(),内核执行 do_exit()

  1. 设置进程状态为 TASK_DEAD,释放 mm_structmmput,当引用计数归零时释放所有 VMA 和物理页)。
  2. 释放 files_struct(关闭所有文件描述符,f_count--)。
  3. 发送 SIGCHLD 给父进程(通知父进程子进程已终止)。
  4. 进程变为僵尸状态(EXIT_ZOMBIE),保留 task_struct 供父进程 wait() 读取退出码。
  5. 父进程调用 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_binaryelf_map()do_mmap()创建新的 vm_area_struct,插入 mm->mm_rb 红黑树页表项未分配物理页,缺页中断(do_page_fault)将在程序开始执行时触发
load_elf_binarystart_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_structmm_struct → VMA 红黑树 → pt_regs 的逐层构建和替换。任何一个环节的失败(ELF 魔数错误、PT_INTERP 路径无效、mmap 地址冲突),都会在 strace 返回错误码时被你提前定位,因为你知道它卡在了哪个数据结构上。