在 Linux 系统中,进程的创建与程序替换是操作系统最核心的功能之一。无论是启动一个 Shell 命令,还是运行一个后台服务,背后都离不开 fork、execve 以及 clone 等系统调用的协同工作。本文将深入内核源码层面,剖析从进程复制到程序替换的完整链条,揭示那些隐藏在 task_struct、写时复制、文件描述符表背后的关键细节。
一、进程创建的起点:fork 与 clone
在用户空间,我们通常使用 fork() 创建子进程,或者使用 vfork()、clone() 实现更细粒度的控制。但在内核中,它们最终都汇聚到同一个核心函数:kernel_clone()(旧版本中为 do_fork())。
1.1 从系统调用到 kernel_clone
当用户调用 fork() 时,glibc 会触发 sys_fork,其定义大致如下:
SYSCALL_DEFINE0(fork)
{
return kernel_clone(&(struct kernel_clone_args){
.flags = SIGCHLD,
.exit_signal = SIGCHLD,
});
}
clone 则允许传递更多标志,如 CLONE_VM、CLONE_FILES、CLONE_THREAD 等,从而控制父子进程之间共享哪些资源。kernel_clone 接收一个 kernel_clone_args 结构,其中包含了栈指针、标志位、父子进程的 tid 指针等。
1.2 复制进程:copy_process 的繁重工作
kernel_clone 的核心是调用 copy_process()。这个函数完成了新进程描述符的创建与初始化,主要步骤包括:
- 检查标志合法性:例如
CLONE_THREAD必须与CLONE_SIGHAND一起使用。 - 分配 task_struct:通过
dup_task_struct()复制当前进程的task_struct,并分配内核栈。此时子进程的task_struct是父进程的完整副本。 - 初始化各类资源:
- 调用
copy_mm()复制或共享内存描述符mm_struct。对于普通fork,使用写时复制(COW)机制,仅复制页表,并不复制物理页面。 - 调用
copy_files()复制文件描述符表。默认情况下,子进程获得父进程文件描述符的副本,指向相同的struct file,因此共享文件偏移量。 - 调用
copy_fs()复制文件系统信息(根目录、当前工作目录等)。 - 调用
copy_sighand()和copy_signal()设置信号处理函数和信号队列。 - 调用
copy_namespaces()处理命名空间,这是容器技术的基石。
- 调用
- 分配 PID:通过
alloc_pid()从 PID 命名空间中分配唯一的进程 ID。 - 设置进程关系:将子进程加入父进程的子进程链表,并设置
parent、real_parent等指针。
完成 copy_process 后,子进程已经具备了运行的全部条件,但尚未被唤醒。kernel_clone 随后调用 wake_up_new_task() 将子进程放入调度队列,等待 CPU 调度。
1.3 写时复制(COW)的精妙之处
fork 的高效很大程度上归功于 COW。父进程和子进程最初共享所有物理页面,页表项被标记为只读。当任一进程尝试写入时,会触发缺页异常,内核此时才复制该页面。这种惰性复制避免了大量不必要的内存拷贝,使得 fork 在多数场景下非常迅速。
但 COW 并非没有代价。如果子进程立即调用 execve,那么之前复制的页表项几乎全部会被丢弃,造成浪费。这正是 vfork 和 posix_spawn 存在的理由——它们通过共享地址空间或直接使用 clone 加 exec 来优化这一场景。
二、程序替换:execve 的内核之旅
fork 创建了父进程的副本,但通常我们更希望子进程运行一个全新的程序。这时就需要 execve 系统调用。execve 会替换当前进程的用户空间代码和数据,但保留进程 ID、打开的文件描述符、信号处理方式等。
2.1 用户空间入口与参数准备
execve 的原型是:
int execve(const char *filename, char *const argv[], char *const envp[]);
内核入口 do_execveat_common 首先会检查文件路径,然后打开可执行文件,读取文件头以识别格式。Linux 支持多种可执行格式,最常见的是 ELF,此外还有脚本文件(以 #! 开头)。
2.2 加载 ELF 映像:load_elf_binary
对于 ELF 文件,内核调用 load_elf_binary(),这是整个替换过程中最复杂的部分。它主要完成:
- 读取 ELF 头:验证魔数、架构、类型(ET_EXEC 或 ET_DYN)。
- 解析程序头表:遍历每个
PT_LOAD段,确定需要映射的虚拟地址范围、文件偏移和权限。 - 清空旧地址空间:调用
exec_mmap()释放旧的内存描述符,并创建一个新的mm_struct。这一步会断开与旧程序的所有内存关联。 - 映射新段:使用
vm_mmap()将 ELF 段映射到用户空间。对于.bss段(未初始化数据),会映射匿名零页。 - 设置栈和参数:在用户栈顶压入
argc、argv、envp以及辅助向量(auxv),其中 auxv 包含 AT_ENTRY(程序入口地址)、AT_PHDR 等关键信息。 - 处理解释器:如果 ELF 指定了动态链接器(如
/lib64/ld-linux-x86-64.so.2),内核会先加载动态链接器,并将控制权交给它,由它完成共享库的加载和重定位。
2.3 脚本文件的特殊处理
如果文件以 #! 开头,内核会识别为脚本,并读取第一行获取解释器路径。然后,它会构造一个新的参数列表,将解释器路径作为 argv[0],原脚本路径作为 argv[1],再调用 execve 加载解释器。这个过程可以递归,但内核限制了递归深度,防止无限循环。
2.4 文件描述符与信号的处理
execve 会保留打开的文件描述符,除非设置了 FD_CLOEXEC 标志。这个标志告诉内核在 exec 时关闭该描述符,这是防止文件描述符泄漏到新程序的重要机制。
信号处理方面,被设置为捕获的信号会重置为默认行为,因为旧的处理函数地址在新程序中已无意义。被忽略的信号保持不变,而阻塞的信号掩码也会保留。
2.5 成功执行后的返回
当 load_elf_binary 完成所有映射和栈设置后,它会修改内核栈上的 pt_regs,将指令指针(IP)设置为 ELF 入口地址,栈指针(SP)指向新的用户栈。当 execve 系统调用返回时,进程不会返回到用户空间的旧代码,而是直接从新程序的入口开始执行。这就是为什么 execve 成功时“永不返回”的原因。
三、fork + execve 的经典组合与优化
传统的 fork + execve 模式虽然清晰,但存在性能问题:fork 复制了父进程的页表,而 execve 立即丢弃它们。为此,Linux 提供了多种优化:
- vfork:子进程与父进程共享地址空间,父进程阻塞直到子进程调用
execve或退出。这避免了页表复制,但限制极多。 - posix_spawn:glibc 对
fork+exec的封装,在支持CLONE_VM和CLONE_VFORK的平台上使用clone实现高效启动。 - clone3 + CLONE_VM:现代容器运行时常用
clone直接创建新进程并立即exec,减少中间开销。
四、总结
从 fork 到 execve,Linux 内核展现了一套精妙而复杂的机制。fork 通过写时复制快速复制进程,execve 则彻底替换用户空间映像,同时保留进程身份和关键资源。理解 copy_process 中的资源复制策略、load_elf_binary 的加载流程,以及 FD_CLOEXEC、信号重置等细节,不仅有助于编写更高效的系统程序,也是深入理解 Linux 进程模型的关键。
下一次当你在终端输入 ls 时,不妨想想:这背后是 Shell 调用 fork 创建子进程,子进程再调用 execve 加载 /bin/ls,而内核则在毫秒之间完成了页表切换、ELF 解析和栈重建。这正是操作系统最迷人的地方。
未经允许不得转载:任鹏个人博客 » 从 fork 到 execve:Linux 进程创建与程序替换的内核细节


朋友圈点赞图在线生成源码