Linux 进程管理深度剖析(一):进程创建 fork/exec/clone 源码分析

基于 Linux 6.4-rc1 源码(commit ac9a78681b92),深入剖析进程创建的内核实现。

进程是操作系统最核心的抽象之一。每次你敲下一条 shell 命令,背后都会发生 fork + exec 这一对经典舞步。然而 “fork 复制父进程、exec 替换镜像” 这句话远未揭示全貌:写时复制如何让 fork 变得极其廉价?clonefork 共享同一条代码路径吗?exec 如何安全地销毁旧地址空间并跳进新程序?本文从 struct task_struct 出发,沿着 fork → clone → exec → exit 的脉络,逐函数拆解内核实现。


一、task_struct:进程的完整画像

每个进程(或线程)在内核中对应一个 struct task_struct,定义于 include/linux/sched.h。它既是调度的基本单元,也是内核追踪进程一切状态的”档案袋”。以下精选最关键的字段并加以注释。

1.1 进程状态

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 87–107 */
#define TASK_RUNNING 0x00000000 /* 正在运行或就绪等待调度 */
#define TASK_INTERRUPTIBLE 0x00000001 /* 可中断睡眠,可被信号唤醒 */
#define TASK_UNINTERRUPTIBLE 0x00000002 /* 不可中断睡眠(D 状态),等待 I/O */
#define __TASK_STOPPED 0x00000004 /* 被 SIGSTOP/SIGTSTP 暂停 */
#define __TASK_TRACED 0x00000008 /* 被 ptrace 追踪 */
#define EXIT_DEAD 0x00000010 /* 正在被回收,即将消失 */
#define EXIT_ZOMBIE 0x00000020 /* 已退出但父进程尚未 wait() */
#define TASK_DEAD 0x00000080 /* 彻底死亡,即将释放 */

task_struct 中保存状态的字段为:

1
2
/* include/linux/sched.h: 747 */
unsigned int __state;

注意前缀双下划线——内核要求通过 READ_ONCE()/WRITE_ONCE() 或专用宏访问它,以防止编译器优化导致的竞态。

1.2 调度相关字段

1
2
3
4
5
6
7
8
9
10
/* include/linux/sched.h: 785–793 */
int prio; /* 动态优先级(受 nice 和 RT boost 影响) */
int static_prio; /* 静态优先级,由 nice 值决定 */
int normal_prio; /* 归一化优先级 */
unsigned int rt_priority; /* 实时优先级(0 = 非 RT) */

struct sched_entity se; /* CFS 调度实体(含 vruntime) */
struct sched_rt_entity rt; /* RT 调度实体 */
struct sched_dl_entity dl; /* Deadline 调度实体 */
const struct sched_class *sched_class; /* 指向所属调度类(fair/rt/dl/idle) */

sched_class 是一个虚函数表指针,实现了策略模式:CFS、RT、Deadline 各自实现 enqueue_taskdequeue_taskpick_next_task 等接口,调度器核心代码通过 sched_class 统一调用,无需 if/else 判断。

1.3 进程标识与亲缘关系

1
2
3
4
5
6
7
8
9
/* include/linux/sched.h: 963–987 */
pid_t pid; /* 进程 ID(线程唯一) */
pid_t tgid; /* 线程组 ID(即主线程 pid,getpid() 返回此值) */

struct task_struct __rcu *real_parent; /* 真实父进程(被 ptrace 时可能与 parent 不同) */
struct task_struct __rcu *parent; /* 当前父进程(SIGCHLD 的接收者) */
struct list_head children; /* 子进程链表头 */
struct list_head sibling; /* 挂入父进程 children 链表的节点 */
struct task_struct *group_leader; /* 线程组 leader(主线程) */

pid vs tgid:POSIX 要求同一进程内所有线程共享 PID,这在内核中通过 tgid 实现。getpid() 返回 tgidgettid() 返回 pidgroup_leader 永远指向主线程。

1.4 内存管理

1
2
3
/* include/linux/sched.h: 872–873 */
struct mm_struct *mm; /* 用户态地址空间(内核线程为 NULL) */
struct mm_struct *active_mm; /* 当前使用的 mm(借用上一进程的 mm 做 lazy TLB) */

内核线程 mm == NULL,但运行时 CPU 的 TLB 仍需要一个 mm,所以借用上一个用户进程的 active_mm,这就是 lazy TLB 优化的来源。

1.5 文件系统与文件描述符

1
2
3
/* include/linux/sched.h: 1087–1090 */
struct fs_struct *fs; /* 当前工作目录、根目录(chdir/chroot 影响此处) */
struct files_struct *files; /* 文件描述符表(引用计数共享) */

fs_structfiles_struct 各自有引用计数,clone(CLONE_FS)clone(CLONE_FILES) 时父子进程共享同一个实例而非复制。

1.6 信号处理

1
2
3
4
5
/* include/linux/sched.h: 1100–1106 */
struct signal_struct *signal; /* 线程组共享的信号信息(pending 队列等) */
struct sighand_struct *sighand; /* 信号处理函数表(可被 CLONE_SIGHAND 共享) */
sigset_t blocked; /* 被屏蔽的信号集合 */
struct sigpending pending; /* 该线程私有的 pending 信号队列 */

1.7 凭证与命名空间

1
2
3
4
5
6
/* include/linux/sched.h: 1057–1060 */
const struct cred __rcu *real_cred; /* 客观凭证(真实 UID/GID,setuid 前的值) */
const struct cred __rcu *cred; /* 有效凭证(权限检查使用此值) */

/* include/linux/sched.h: 1097 */
struct nsproxy *nsproxy; /* 指向命名空间集合(PID/Net/UTS/IPC/Mount/Time NS) */

cred 采用写时复制(COW):setuid() 会分配新的 cred 对象,而非修改现有的,这使得 RCU 读者可以无锁访问。


二、fork() 系统调用:从用户态到 kernel_clone

2.1 调用链全景

1
2
3
4
5
6
7
8
9
用户态: fork()
↓ glibc syscall wrapper
内核态: sys_fork (SYSCALL_DEFINE0)

kernel_clone(args)

copy_process(pid, trace, node, args) ← 核心:创建子进程描述符

wake_up_new_task(p) ← 将子进程加入运行队列

2.2 sys_fork 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/* kernel/fork.c: 3000–3012 */
SYSCALL_DEFINE0(fork)
{
#ifdef CONFIG_MMU
struct kernel_clone_args args = {
.exit_signal = SIGCHLD,
};

return kernel_clone(&args);
#else
/* can not support in nommu mode */
return -EINVAL;
#endif
}

fork 不传任何 clone flags,这意味着子进程拥有独立的 mm、文件描述符表、信号处理器、命名空间——一个完整的独立进程。

2.3 kernel_clone:派发与收尾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
/* kernel/fork.c: 2877–2962(节选) */
pid_t kernel_clone(struct kernel_clone_args *args)
{
u64 clone_flags = args->flags;
struct completion vfork;
struct pid *pid;
struct task_struct *p;
pid_t nr;

/* 决定向 ptracer 报告哪类事件 */
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
trace = PTRACE_EVENT_CLONE;
else
trace = PTRACE_EVENT_FORK;
}

p = copy_process(NULL, trace, NUMA_NO_NODE, args); /* 创建子进程 */
if (IS_ERR(p))
return PTR_ERR(p);

pid = get_task_pid(p, PIDTYPE_PID);
nr = pid_vnr(pid); /* 在当前 PID 命名空间中的虚拟 PID */

if (clone_flags & CLONE_VFORK) {
p->vfork_done = &vfork;
init_completion(&vfork);
get_task_struct(p);
}

wake_up_new_task(p); /* 子进程入队,可以被调度了 */

if (clone_flags & CLONE_VFORK) {
/* 父进程在此阻塞,等待子进程 exec 或 exit */
if (!wait_for_vfork_done(p, &vfork))
ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
}

put_pid(pid);
return nr; /* 父进程返回子进程 PID,子进程返回 0(由 copy_thread 设置) */
}

2.4 copy_process:子进程的诞生

copy_process 是整个 fork 路径最复杂的函数(约 600 行),它按照严格顺序完成以下工作:

第一步:合法性检查

1
2
3
4
5
6
7
8
/* kernel/fork.c: 2263–2302(节选) */
/* CLONE_THREAD 必须同时设置 CLONE_SIGHAND */
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);

/* CLONE_SIGHAND 必须同时设置 CLONE_VM */
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);

这揭示了线程的必要条件:线程 = 共享信号处理器 = 共享地址空间,三者缺一不可。

第二步:dup_task_struct——克隆描述符与内核栈

1
2
/* kernel/fork.c: 2333 */
p = dup_task_struct(current, node);

dup_task_struct 的实现(kernel/fork.c: 1101–1190):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static struct task_struct *dup_task_struct(struct task_struct *orig, int node)
{
struct task_struct *tsk;

tsk = alloc_task_struct_node(node); /* slab 分配 task_struct */
if (!tsk) return NULL;

err = arch_dup_task_struct(tsk, orig); /* memcpy task_struct */
if (err) goto free_tsk;

err = alloc_thread_stack_node(tsk, node); /* 分配独立内核栈(通常 16KB) */
if (err) goto free_tsk;

setup_thread_stack(tsk, orig); /* 复制 thread_info,更新 task 指针 */
clear_tsk_need_resched(tsk);
set_task_stack_end_magic(tsk); /* 在栈底写入魔数,用于溢出检测 */

#ifdef CONFIG_STACKPROTECTOR
tsk->stack_canary = get_random_canary(); /* 新的栈 canary,防止父子共享 */
#endif
refcount_set(&tsk->usage, 1);
return tsk;
}

关键点:此时 task_struct 是父进程的完整拷贝,但内核栈是全新分配的——否则父子进程会使用同一个内核栈,调度时会互相破坏。

第三步:各子系统的选择性复制

1
2
3
4
5
6
7
8
9
10
11
/* kernel/fork.c: 2492–2518 */
retval = copy_files(clone_flags, p, args->no_files);
retval = copy_fs(clone_flags, p);
retval = copy_sighand(clone_flags, p);
retval = copy_signal(clone_flags, p);
retval = copy_mm(clone_flags, p); /* COW 复制地址空间 */
retval = copy_namespaces(clone_flags, p);
retval = copy_thread(p, args); /* 架构相关:设置返回地址和寄存器 */

/* 分配 PID */
pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid, args->set_tid_size);

copy_mm(kernel/fork.c: 1714)的逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm = current->mm;

if (!oldmm) return 0; /* 内核线程无需地址空间 */

if (clone_flags & CLONE_VM) {
mmget(oldmm); /* 线程:直接共享父进程 mm,引用计数 +1 */
mm = oldmm;
} else {
mm = dup_mm(tsk, current->mm); /* 进程:COW 复制整个地址空间 */
if (!mm) return -ENOMEM;
}

tsk->mm = mm;
tsk->active_mm = mm;
return 0;
}

copy_files(kernel/fork.c: 1772):

1
2
3
4
5
6
7
8
9
10
11
12
13
static int copy_files(unsigned long clone_flags, struct task_struct *tsk, int no_files)
{
struct files_struct *oldf = current->files;

if (clone_flags & CLONE_FILES) {
atomic_inc(&oldf->count); /* 线程:共享文件描述符表 */
goto out;
}

newf = dup_fd(oldf, NR_OPEN_MAX, &error); /* 进程:复制 fd 表 */
tsk->files = newf;
...
}

第四步:设置 pid/tgid/group_leader

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 2574–2581 */
p->pid = pid_nr(pid);
if (clone_flags & CLONE_THREAD) {
p->group_leader = current->group_leader; /* 新线程的组长 = 父线程的组长 */
p->tgid = current->tgid; /* 线程组 ID 不变 */
} else {
p->group_leader = p; /* 新进程自己是组长 */
p->tgid = p->pid; /* tgid = 自己的 pid */
}

第五步:加入进程树

1
2
3
4
5
6
7
/* kernel/fork.c: 2638–2648 */
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
p->real_parent = current->real_parent; /* 与父进程平级 */
} else {
p->real_parent = current; /* 普通 fork:父进程为当前进程 */
p->exit_signal = args->exit_signal;
}

2.5 wake_up_new_task:子进程入队

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* kernel/sched/core.c: 4809–4848(节选) */
void wake_up_new_task(struct task_struct *p)
{
struct rq_flags rf;
struct rq *rq;

raw_spin_lock_irqsave(&p->pi_lock, rf.flags);
WRITE_ONCE(p->__state, TASK_RUNNING); /* 设为就绪态 */

#ifdef CONFIG_SMP
/* fork 均衡:选择最优 CPU,避免与父进程竞争同一核 */
__set_task_cpu(p, select_task_rq(p, task_cpu(p), WF_FORK));
#endif
rq = __task_rq_lock(p, &rf);
update_rq_clock(rq);
post_init_entity_util_avg(p); /* 初始化 CFS 调度实体的 util_avg */

activate_task(rq, p, ENQUEUE_NOCLOCK); /* 将子进程加入运行队列 */
trace_sched_wakeup_new(p);
check_preempt_curr(rq, p, WF_FORK); /* 检查是否需要抢占当前任务 */

task_rq_unlock(rq, p, &rf);
}

至此,子进程已就绪,等待调度器选中它上 CPU 运行。


三、clone() 与线程创建

3.1 clone flags 语义

所有标志定义于 include/uapi/linux/sched.h

标志 含义
CLONE_VM 共享地址空间(mm_struct),线程的必要条件
CLONE_FS 共享文件系统信息(cwd、root、umask)
CLONE_FILES 共享文件描述符表
CLONE_SIGHAND 共享信号处理函数表,CLONE_VM 的前提
CLONE_THREAD 加入同一线程组(共享 tgid),CLONE_SIGHAND 的前提
CLONE_NEWNS 新建 Mount 命名空间(容器隔离的基础)
CLONE_NEWPID 新建 PID 命名空间(容器内 PID 从 1 开始)
CLONE_VFORK 父进程挂起,直到子进程 exec 或 exit
CLONE_PIDFD 在父进程返回一个 pidfd(进程文件描述符)

3.2 pthread_create 的内核路径

glibcpthread_create 最终调用:

1
2
3
4
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND |
CLONE_THREAD | CLONE_SYSVSEM | CLONE_SETTLS |
CLONE_PARENT_SETTID | CLONE_CHILD_CLEARTID | SIGCHLD,
child_stack, &parent_tid, tls, &child_tid);

这些 flags 让内核:

  • 共享地址空间(VM)
  • 共享文件和信号处理
  • 加入同一线程组(THREAD)
  • 设置 TLS 段(SETTLS)
  • 在子线程退出时通过 CLONE_CHILD_CLEARTID 向 futex 地址写 0,唤醒等待 pthread_join 的线程

3.3 sys_clone 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* kernel/fork.c: 3045–3062 */
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr,
int __user *, child_tidptr,
unsigned long, tls)
{
struct kernel_clone_args args = {
.flags = (lower_32_bits(clone_flags) & ~CSIGNAL),
.pidfd = parent_tidptr,
.child_tid = child_tidptr,
.parent_tid = parent_tidptr,
.exit_signal = (lower_32_bits(clone_flags) & CSIGNAL),
.stack = newsp,
.tls = tls,
};

return kernel_clone(&args); /* 与 fork 共享同一路径 */
}

forkvforkclone 在内核中最终都调用 kernel_clone,差异仅在传入的 kernel_clone_args 结构体。这是 Linux 内核”一个实现,多个接口”的典型范式。

3.4 copy_thread:fork 返回 0 的秘密

子进程在 fork 后从 ret_from_fork 开始运行,但为何 fork() 在子进程中返回 0?答案在 x86 的 copy_thread

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* arch/x86/kernel/process.c: 137–202(节选) */
int copy_thread(struct task_struct *p, const struct kernel_clone_args *args)
{
struct pt_regs *childregs = task_pt_regs(p);
struct fork_frame *fork_frame = container_of(childregs, struct fork_frame, regs);
struct inactive_task_frame *frame = &fork_frame->frame;

/* 设置子进程从 ret_from_fork 开始执行 */
frame->ret_addr = (unsigned long) ret_from_fork;
p->thread.sp = (unsigned long) fork_frame;

/* 复制父进程的完整寄存器状态 */
*childregs = *current_pt_regs();

/* 关键:将 eax/rax 设为 0,这就是 fork() 在子进程返回 0 的实现 */
childregs->ax = 0;

if (sp)
childregs->sp = sp; /* 线程使用新栈 */
...
}

父进程的 raxkernel_clone 返回子进程 PID,子进程的 rax 被强制置 0。当子进程被调度到 CPU 上,它从 ret_from_fork 恢复执行,iretrax=0 作为系统调用返回值送回用户态——这就是 fork() 在子进程中返回 0 的完整机制。


四、exec() 系统调用:变身新程序

4.1 调用链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
用户态: execve(path, argv, envp)

sys_execve (SYSCALL_DEFINE3)

do_execve → do_execveat_common

alloc_bprm + bprm_mm_init ← 分配 linux_binprm,创建新 mm

bprm_execve

exec_binprm → search_binary_handler ← 遍历 binfmt 链表找加载器

load_elf_binary (fs/binfmt_elf.c) ← ELF 加载器

begin_new_exec ← 不可回头点:替换旧进程上下文

start_thread (arch) ← 设置入口地址,跳入新程序

4.2 do_execveat_common:准备 bprm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* fs/exec.c: 1886–1969(节选) */
static int do_execveat_common(int fd, struct filename *filename,
struct user_arg_ptr argv,
struct user_arg_ptr envp,
int flags)
{
struct linux_binprm *bprm;

bprm = alloc_bprm(fd, filename); /* 分配 linux_binprm,内含新 mm */

bprm->argc = count(argv, MAX_ARG_STRINGS);
bprm->envc = count(envp, MAX_ARG_STRINGS);

bprm_stack_limits(bprm); /* 检查 ARG_MAX 限制 */

/* 将 filename、envp、argv 依次压入新栈(从高地址向低地址) */
copy_string_kernel(bprm->filename, bprm); /* filename */
copy_strings(bprm->envc, envp, bprm); /* 环境变量 */
copy_strings(bprm->argc, argv, bprm); /* 参数 */

retval = bprm_execve(bprm, fd, filename, flags);
...
}

4.3 bprm_mm_init:提前准备新地址空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* fs/exec.c: 364–392 */
static int bprm_mm_init(struct linux_binprm *bprm)
{
struct mm_struct *mm = NULL;

bprm->mm = mm = mm_alloc(); /* 分配全新的 mm_struct */
if (!mm) goto err;

task_lock(current->group_leader);
bprm->rlim_stack = current->signal->rlim[RLIMIT_STACK]; /* 记录栈限制 */
task_unlock(current->group_leader);

err = __bprm_mm_init(bprm); /* 为新栈 mmap 一页临时映射 */
...
}

注意此时旧的 mm 仍然有效——新 mm 在 begin_new_exec 之前不会替换旧的,这样如果 load_elf_binary 失败,进程可以安全回滚。

4.4 search_binary_handler:寻找加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/* fs/exec.c: 1715–1759(节选) */
static int search_binary_handler(struct linux_binprm *bprm)
{
struct linux_binfmt *fmt;

retval = prepare_binprm(bprm); /* 读取文件头 128 字节到 bprm->buf */
retval = security_bprm_check(bprm); /* LSM 安全检查 */

read_lock(&binfmt_lock);
list_for_each_entry(fmt, &formats, lh) { /* 遍历已注册的 binfmt */
if (!try_module_get(fmt->module))
continue;
read_unlock(&binfmt_lock);

retval = fmt->load_binary(bprm); /* 调用对应加载器,如 load_elf_binary */

read_lock(&binfmt_lock);
put_binfmt(fmt);
if (bprm->point_of_no_return || retval != -ENOEXEC) {
read_unlock(&binfmt_lock);
return retval;
}
}
...
}

binfmt 链表按注册顺序排列,典型格式有 ELF(binfmt_elf)、脚本(binfmt_script,处理 #!)、misc(binfmt_misc,处理 .jar/.py 等)。

4.5 load_elf_binary:ELF 加载详解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/* fs/binfmt_elf.c: 823–(节选) */
static int load_elf_binary(struct linux_binprm *bprm)
{
struct elfhdr *elf_ex = (struct elfhdr *)bprm->buf;

/* 魔数检查:\x7fELF */
if (memcmp(elf_ex->e_ident, ELFMAG, SELFMAG) != 0) goto out;
if (elf_ex->e_type != ET_EXEC && elf_ex->e_type != ET_DYN) goto out;

elf_phdata = load_elf_phdrs(elf_ex, bprm->file); /* 读取程序头表 */

/* 扫描程序头,查找 PT_INTERP(动态链接器路径) */
for (i = 0; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type != PT_INTERP) continue;
/* 读取 /lib64/ld-linux-x86-64.so.2 等路径 */
elf_interpreter = kmalloc(elf_ppnt->p_filesz, GFP_KERNEL);
elf_read(bprm->file, elf_interpreter, ...);
interpreter = open_exec(elf_interpreter); /* 打开 ld.so */
break;
}

/* begin_new_exec:不可回头点,替换旧进程上下文 */
retval = begin_new_exec(bprm);
/* 从这里开始,旧地址空间已销毁,只有前进没有后退 */

/* 将 PT_LOAD segment 逐一 mmap 进新地址空间 */
for (i = 0, elf_ppnt = elf_phdata; i < elf_ex->e_phnum; i++, elf_ppnt++) {
if (elf_ppnt->p_type == PT_LOAD) {
/* elf_map() 调用 do_mmap() 建立文件映射 */
...
}
}

/* 若有动态链接器,同样加载 ld.so 的 PT_LOAD */
if (interpreter) {
elf_entry = load_elf_interp(interp_elf_ex, interpreter, ...);
/* 入口地址改为 ld.so 的入口,而非程序自身的 e_entry */
}
...
}

ELF 加载后的栈布局(从高地址到低地址):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
高地址
┌─────────────────────────────────┐
│ NULL(栈结束标记) │
│ envp 字符串数据 │
│ argv 字符串数据 │
│ filename 字符串 │
│ AT_NULL(auxv 结束) │
│ 辅助向量 auxv(AT_PHDR/AT_ENTRY/│
│ AT_PAGESZ/AT_RANDOM…) │
│ NULL(envp 结束) │
│ envp[n-1] ... envp[0] │
│ NULL(argv 结束) │
│ argv[argc-1] ... argv[0] │
│ argc │ ← rsp 初始指向此处
└─────────────────────────────────┘
低地址

auxv(辅助向量)向 ld.so 传递内核信息:AT_PHDR 指向程序头表在内存中的位置,AT_ENTRY 是程序真实入口(ld.so 完成重定位后跳转到此),AT_RANDOM 是 16 字节随机数用于 PIE/ASLR 种子。

4.6 begin_new_exec:不可回头点

begin_new_exec(fs/exec.c: 1244)是 exec 过程的分水岭,在此之后:

  • 调用 exec_mmap(bprm->mm) 将旧 mm 替换为新 mm,旧地址空间被销毁
  • 调用 flush_signal_handlers() 将所有非 SIG_IGN 的信号处理器重置为 SIG_DFL
  • 关闭所有标有 O_CLOEXEC 的文件描述符
  • 更新进程名(comm)为新程序的 basename
  • 若此时发生错误,进程将以 SIGSEGV 终止(因为旧环境已消失)

五、进程退出:do_exit 与僵尸进程

5.1 do_exit 的退出流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* kernel/exit.c: 806–924(节选) */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;

exit_signals(tsk); /* 设置 PF_EXITING 标志,阻止新信号投递 */

group_dead = atomic_dec_and_test(&tsk->signal->live);
/* group_dead = true 说明这是线程组的最后一个线程 */

tsk->exit_code = code;

exit_mm(); /* 解除地址空间:tsk->mm = NULL,mmput() 递减引用计数 */
exit_sem(tsk); /* 释放 System V 信号量 */
exit_shm(tsk); /* 解除共享内存段 */
exit_files(tsk); /* 关闭所有文件描述符(files_struct 引用计数 -1) */
exit_fs(tsk); /* 释放 fs_struct 引用 */
exit_task_namespaces(tsk); /* 释放命名空间引用 */
exit_thread(tsk); /* 释放架构相关资源(如 FPU 状态) */

perf_event_exit_task(tsk);
cgroup_exit(tsk);

exit_notify(tsk, group_dead); /* 通知父进程,进入 ZOMBIE 状态 */

do_task_dead(); /* 调用 schedule() 永不返回 */
}

5.2 exit_mm:地址空间的释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 532–567 */
static void exit_mm(void)
{
struct mm_struct *mm = current->mm;

exit_mm_release(current, mm); /* 清理 futex robust list 等 */
if (!mm) return;

sync_mm_rss(mm);
mmap_read_lock(mm);
mmgrab_lazy_tlb(mm); /* 转换为 lazy TLB 进程 */

task_lock(current);
current->mm = NULL; /* 脱离地址空间 */
enter_lazy_tlb(mm, current); /* 告知 CPU 使用 lazy TLB 模式 */
task_unlock(current);

mmap_read_unlock(mm);
mm_update_next_owner(mm); /* 将 mm->owner 转移给其他共享线程 */
mmput(mm); /* 引用计数 -1;若为 0 则真正释放页表 */
}

mmput() 只是递减引用计数。若进程有共享同一 mm 的线程(线程组),mm 不会被立刻释放,直到最后一个线程退出。

5.3 exit_notify:从 RUNNING 到 ZOMBIE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/* kernel/exit.c: 730–764(节选) */

write_lock_irq(&tasklist_lock);
forget_original_parent(tsk, &dead); /* 将子进程过继给 init 或 subreaper */

tsk->exit_state = EXIT_ZOMBIE; /* 进入僵尸态 */

if (thread_group_leader(tsk)) {
/* 向父进程发送 SIGCHLD(或 exit_signal 指定的信号) */
autoreap = do_notify_parent(tsk, tsk->exit_signal);
}

if (autoreap) {
tsk->exit_state = EXIT_DEAD; /* 父进程忽略 SIGCHLD,直接变 DEAD */
}
write_unlock_irq(&tasklist_lock);

进程在 exit_state = EXIT_ZOMBIE 后:task_struct 仍然存在于内存中,保留 PID、退出码等信息,等待父进程调用 wait() 来回收。这就是”僵尸进程”。

5.4 wait4 与 do_wait:父进程回收子进程

1
2
3
4
5
6
7
8
9
10
11
12
/* kernel/exit.c: 1801–1812 */
SYSCALL_DEFINE4(wait4, pid_t, upid, int __user *, stat_addr,
int, options, struct rusage __user *, ru)
{
struct rusage r;
long err = kernel_wait4(upid, stat_addr, options, ru ? &r : NULL);
if (err > 0) {
if (ru && copy_to_user(ru, &r, sizeof(struct rusage)))
return -EFAULT;
}
return err;
}

kernel_wait4 → do_wait → wait_consider_task:扫描子进程列表,找到 EXIT_ZOMBIE 状态的子进程,收集退出码后调用 release_task() 释放 task_struct,PID 归还 pidmap。

5.5 孤儿进程:find_new_reaper

当父进程先于子进程退出,子进程成为孤儿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/* kernel/exit.c: 618–651 */
static struct task_struct *find_new_reaper(struct task_struct *father,
struct task_struct *child_reaper)
{
/* 优先:父进程线程组内的存活线程 */
thread = find_alive_thread(father);
if (thread) return thread;

/* 其次:祖先中标记了 is_child_subreaper 的进程(如 systemd/docker init) */
if (father->signal->has_child_subreaper) {
for (reaper = father->real_parent; ...; reaper = reaper->real_parent) {
if (reaper->signal->is_child_subreaper) {
thread = find_alive_thread(reaper);
if (thread) return thread;
}
}
}

/* 最终:PID 命名空间的 init 进程(pid=1) */
return child_reaper;
}

这就是为什么容器内 PID 1 必须正确处理 SIGCHLD 并调用 wait()——否则容器内所有孤儿进程都会成为其僵尸子进程,逐渐耗尽内核资源。


六、写时复制(COW):fork 廉价的秘密

6.1 COW 的实现原理

fork 时调用 dup_mm → dup_mmap,后者遍历父进程的所有 VMA(虚拟内存区域),将每个页表项拷贝到子进程,但同时将父子两边的 PTE 都标记为只读并清除 dirty bit。

当任一方尝试写入,CPU 触发缺页异常(Protection Fault),内核的 do_wp_page 函数被调用:

  1. 检查页面的 _mapcount(被多少 PTE 引用)
  2. _mapcount == 1(只有自己引用),直接将 PTE 改回可写(”破坏单映射”)
  3. _mapcount > 1,分配新物理页,拷贝内容,更新 PTE 为新页并设为可写

这样,只有真正被写入的页面才会发生物理复制,大量只读的代码页、rodata 页永远不会被复制。

6.2 fork + exec 的极致优化:vfork

如果紧接着 fork 就要 exec,COW 的页表复制本身也是浪费,vfork 为此而生:

1
2
3
4
5
6
7
8
9
/* kernel/fork.c: 3016–3025 */
SYSCALL_DEFINE0(vfork)
{
struct kernel_clone_args args = {
.flags = CLONE_VFORK | CLONE_VM,
.exit_signal = SIGCHLD,
};
return kernel_clone(&args);
}
  • CLONE_VM:父子共享同一 mm,不复制页表
  • CLONE_VFORK:父进程阻塞在 wait_for_vfork_done() 直到子进程调用 exec_exit

vfork 的约束极为严格:子进程只能调用 exec 系列或 _exit,不得修改任何全局状态,也不得 return 出创建它的函数——因为父子共享栈和 mm,任何写操作都会污染父进程。

6.3 为何 fork + exec 几乎不复制内存

即使不用 vfork,普通 fork 后立即 exec,实际代价也很小:

  1. fork 时仅复制页表结构(4级页表的高层节点),并将所有 PTE 标记为 COW 只读
  2. exec 调用 begin_new_exec → exec_mmap,直接将新 mm 替换旧 mm,mmput(old_mm) 递减引用计数后释放旧页表,所有 COW 只读标记都随之消失
  3. 整个过程:父进程没有任何页面被真正复制,唯一的开销是页表遍历和清理

这就是为什么 shell 每秒可以创建数千个子进程,而内存用量并不会暴增。


七、诊断方法

7.1 /proc/PID/status:进程状态全览

1
2
3
4
5
6
7
8
9
10
11
$ cat /proc/1234/status
Name: nginx
State: S (sleeping)
Tgid: 1234 # 线程组 ID = 主线程 PID
Pid: 1234 # 当前线程 PID
PPid: 1 # 父进程 PID
Threads: 4 # 线程数(CLONE_THREAD 创建的)
VmRSS: 12488 kB # 物理内存使用量(Resident Set Size)
VmSize: 458752 kB # 虚拟内存总量
SigBlk: 0000000000000000 # 被阻塞的信号集(位图,16进制)
SigCgt: 0000000180014a03 # 有自定义处理器的信号集

7.2 /proc/PID/task/:查看所有线程

1
2
3
4
5
6
$ ls /proc/1234/task/
1234 1235 1236 1237 # 每个子目录对应一个线程(tid)

$ cat /proc/1234/task/1235/status | grep -E "^(Pid|Tgid):"
Tgid: 1234 # 所有线程 tgid 相同
Pid: 1235 # 各线程 pid 不同

7.3 pstree -p:可视化进程树

1
2
3
4
5
6
$ pstree -p 1
systemd(1)─┬─sshd(892)───sshd(1041)───bash(1042)───pstree(2300)
├─nginx(1234)─┬─nginx(1235)
│ ├─nginx(1236)
│ └─nginx(1237)
└─...

7.4 strace -f:追踪进程创建系统调用

1
2
3
4
# 追踪 fork/clone/execve(-f 跟踪子进程)
$ strace -f -e trace=fork,clone,clone3,execve ls /tmp
execve("/usr/bin/ls", ["ls", "/tmp"], 0x7ffd.../* 40 vars */) = 0
# 显示实际加载的 ELF 文件和参数

7.5 bpftrace:动态追踪 copy_process

1
2
3
4
5
6
7
# 追踪所有进程创建,打印父子 PID 和进程名
$ bpftrace -e '
kretprobe:copy_process
/retval != 0/ {
printf("fork: ppid=%d pcomm=%s cpid=%d\n",
pid, comm, ((struct task_struct *)retval)->pid);
}'

7.6 perf stat:量化进程创建开销

1
2
3
4
5
6
7
8
9
$ perf stat -e task-clock,context-switches,cpu-migrations,page-faults \
-r 5 bash -c 'for i in $(seq 100); do /bin/true; done'

Performance counter stats for 'bash -c ...' (5 runs):

312.45 msec task-clock # CPU 时间
850 context-switches # 上下文切换(每次 fork/exec 各一次)
2 cpu-migrations # 跨核迁移
14230 page-faults # 缺页(含 COW 触发)

7.7 /proc/PID/maps:观察 COW 页面状态

1
2
3
4
5
6
$ cat /proc/1234/maps | head -5
55a3c2000000-55a3c2001000 r--p 00000000 fd:01 123456 /usr/bin/nginx
55a3c2001000-55a3c2100000 r-xp 00001000 fd:01 123456 /usr/bin/nginx # 代码段只读
55a3c2100000-55a3c2120000 r--p 00100000 fd:01 123456 /usr/bin/nginx # rodata 只读
55a3c2121000-55a3c2122000 rw-p 00120000 fd:01 123456 /usr/bin/nginx # data 可写
7f8a00000000-7f8a00020000 rw-p 00000000 00:00 0 [heap]

r--pr-xp 中的 p(private)表示这是写时复制映射,fork 后父子进程共享这些物理页,直到写入为止。


总结

操作 内核入口 共享资源 代价
fork() kernel_clone({.exit_signal=SIGCHLD}) 无(COW 页表) 页表复制 + 子进程入队
vfork() `kernel_clone({CLONE_VFORK CLONE_VM})` mm 完全共享
pthread_create `clone(CLONE_VM CLONE_THREAD …)`
execve() do_execveat_common 无(全新 mm) ELF 加载 + 页表建立

forkdup_task_struct 的内核栈分配,到 copy_threadchildregs->ax = 0 让子进程”看到” 返回值 0,再到 exec 路径上 begin_new_exec 的不可回头设计,以及 exit_zombie 状态下 task_struct 对父进程的等待——这条完整的进程生命周期展现了 Linux 内核在正确性、性能和可观测性之间精妙的平衡。


参考资料

  • include/linux/sched.h — struct task_struct 定义
  • kernel/fork.c — fork/clone/vfork 实现
  • fs/exec.c — execve 实现
  • fs/binfmt_elf.c — ELF 加载器
  • kernel/exit.c — 进程退出
  • kernel/sched/core.c — wake_up_new_task
  • arch/x86/kernel/process.c — x86 copy_thread
  • include/uapi/linux/sched.h — CLONE_* 标志定义