mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1608 字
4 分钟
Linux 进程启动过程:从 fork 到 exec
2023-08-04

前言#

当你在终端输入 ./hello 运行一个程序时,Linux 内核做了哪些事情?一个进程是如何从无到有被创建出来的?本文深入剖析 Linux 进程启动的完整链路,从 fork 系统调用到程序真正执行,揭示进程管理背后的内核秘密。

进程启动全链路#

flowchart TB A["用户执行 ./hello"] --> B["Shell fork 子进程"] B --> C["子进程 exec ./hello"] C --> D["内核加载 ELF 文件"] D --> E["解析 ELF Header"] E --> F["映射 .text 段"] F --> G["映射 .data/.bss 段"] G --> H["设置堆和栈"] H --> I["加载动态链接器 ld.so"] I --> J["解析动态依赖"] J --> K["重定位 GOT/PLT"] K --> L["跳转到 _start"] L --> M["调用 __libc_start_main"] M --> N["执行 main()"] N --> O["exit() 返回"] style A fill:#9f9,stroke:#333 style O fill:#f96,stroke:#333

一、进程描述符#

1.1 task_struct 结构#

Linux 内核通过 task_struct 描述一个进程(或线程)。这是内核中最核心的数据结构之一。

// Linux 内核进程描述符(简化)
// 源码: https://github.com/torvalds/linux/blob/master/include/linux/sched.h
struct task_struct {
unsigned int state; // 进程状态
int prio; // 动态优先级
int static_prio; // 静态优先级
pid_t pid; // 进程 ID
pid_t tgid; // 线程组 ID
struct mm_struct *mm; // 内存描述符
struct files_struct *files; // 打开的文件
struct signal_struct *signal; // 信号
struct sched_entity se; // 调度实体
const struct sched_class *sched_class; // 调度类
struct thread_info thread_info; // 线程信息
// ... 几百个字段
};

1.2 进程状态#

stateDiagram-v2 [*] --> TASK_RUNNING: fork / wake_up TASK_RUNNING --> TASK_INTERRUPTIBLE: 等待事件(可中断) TASK_RUNNING --> TASK_UNINTERRUPTIBLE: 等待事件(不可中断) TASK_INTERRUPTIBLE --> TASK_RUNNING: 信号 / 事件就绪 TASK_UNINTERRUPTIBLE --> TASK_RUNNING: 事件就绪 TASK_RUNNING --> TASK_STOPPED: SIGSTOP / ptrace TASK_STOPPED --> TASK_RUNNING: SIGCONT TASK_RUNNING --> EXIT_ZOMBIE: exit EXIT_ZOMBIE --> EXIT_DEAD: waitpid 回收 EXIT_DEAD --> [*]
状态说明
TASK_RUNNING正在运行或就绪等待 CPU
TASK_INTERRUPTIBLE可中断睡眠,可被信号唤醒
TASK_UNINTERRUPTIBLE不可中断睡眠,只能等事件就绪
TASK_STOPPED停止运行
EXIT_ZOMBIE僵尸状态,等待父进程回收
EXIT_DEAD最终状态,资源已回收

1.3 进程在内核中的组织#

每个进程在内核中有两块核心内存:
+-------------------+ 低地址
| 用户态栈 |
+-------------------+
| ... |
+-------------------+
| 用户空间 | 0x0000000000000000 ~ 0x00007FFFFFFFFFFF
| (代码/数据/堆/栈) |
+-------------------+ 0x00007FFFFFFFFFFF (TASK_SIZE)
| 不可访问区 |
+-------------------+
| 内核空间 | 0xFFFF800000000000 (x86_64)
| +---------------+ |
| | 内核栈 (8KB) | |
| | thread_info | |
| +---------------+ |
| | task_struct | |
| | (slab 分配) | |
| +---------------+ |
+-------------------+

二、fork 系统调用#

2.1 fork 执行流程#

sequenceDiagram participant U as 用户态 participant S as 系统调用入口 participant K as kernel/fork.c participant D as dup_mm participant Sched as 调度器 U->>S: fork() / clone() S->>K: sys_fork / sys_clone K->>K: copy_process() K->>K: dup_task_struct() 分配 task_struct K->>K: copy_thread() 设置寄存器 K->>D: dup_mm() 复制内存空间 D->>D: 设置页表为只读 (COW) K->>K: copy_files() 复制文件描述符 K->>K: copy_signal() 复制信号处理 K->>K: sched_fork() 初始化调度信息 K->>Sched: wake_up_new_task() 唤醒新进程 Sched-->>U: 父进程返回子进程 PID Note over U: 子进程返回 0

2.2 写时复制 (Copy-on-Write)#

fork 时并不真正复制父进程的内存,而是让父子进程共享同一块物理内存,页表标记为只读。只有当某一方尝试写入时,才触发缺页异常,内核此时才真正复制该页。

sequenceDiagram participant P as 父进程页表 participant M as 物理内存 participant C as 子进程页表 Note over P,C: fork 后: 共享物理页,页表标记只读 P ->>M: 映射 (只读) C ->>M: 映射 (只读) Note over P: 父进程写入页面 P ->>P: 触发 Page Fault P ->>M: 复制该页到新物理页 P ->>P: 更新页表为可写 Note over C: 子进程仍指向原物理页
fork 后的内存布局:
父进程页表 物理内存 子进程页表
+----------+ +----------+ +----------+
| PTE (RO) |------->| Page A |<-------| PTE (RO) |
+----------+ +----------+ +----------+
| PTE (RO) |------->| Page B |<-------| PTE (RO) |
+----------+ +----------+ +----------+
| PTE (RO) |------->| Page C |<-------| PTE (RO) |
+----------+ +----------+ +----------+
父进程写入 Page B 后:
父进程页表 物理内存 子进程页表
+----------+ +----------+ +----------+
| PTE (RW) |------->| Page A |<-------| PTE (RO) |
+----------+ +----------+ +----------+
| PTE (RW) |---+ | Page B |<-------| PTE (RO) |
+----------+ +--->| Page B' | +----------+
| PTE (RW) |------->| Page C |<-------| PTE (RO) |
+----------+ +----------+ +----------+

2.3 fork 内核源码#

// fork 核心实现
// 源码: https://github.com/torvalds/linux/blob/master/kernel/fork.c
// sys_fork 的实际实现
SYSCALL_DEFINE0(fork)
{
return kernel_clone(clone_args);
}
// 更通用的 clone 实现
static __latent_entropy struct task_struct *copy_process(
struct pid *pid, int trace, int node,
struct kernel_clone_args *args)
{
struct task_struct *p;
// 1. 分配 task_struct
p = dup_task_struct(current, node);
if (!p)
return ERR_PTR(-ENOMEM);
// 2. 复制进程上下文
retval = copy_creds(p, clone_flags); // 复制凭证
retval = copy_mm(clone_flags, p); // 复制内存空间 (COW)
retval = copy_namespaces(clone_flags, p); // 复制命名空间
retval = copy_files(clone_flags, p); // 复制文件描述符
retval = copy_fs(clone_flags, p); // 复制 fs 信息
retval = copy_sighand(clone_flags, p); // 复制信号处理
retval = copy_signal(clone_flags, p); // 复制信号
retval = copy_thread(p, args); // 复制线程上下文
// 3. 设置 PID
p->pid = pid_nr(pid);
// 4. 初始化调度信息
retval = sched_fork(clone_flags, p);
return p;
}

2.4 clone 的灵活创建#

Linux 的 forkvforkpthread_create 底层都调用 clone 系统调用,通过参数控制共享程度:

// 不同创建方式的 clone flags
// fork: clone(SIGCHLD, 0)
// vfork: clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0)
// thread: clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, ...)
// 创建线程 (共享地址空间、文件、信号处理)
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, stack_ptr);
// 创建进程 (什么都不共享)
clone(SIGCHLD, 0);
clone flag作用forkvforkpthread
CLONE_VM共享内存空间
CLONE_FS共享 fs_info
CLONE_FILES共享文件描述符表
CLONE_SIGHAND共享信号处理
CLONE_VFORK父进程等待子进程 exec
CLONE_THREAD放入同一线程组

三、exec 系统调用#

3.1 exec 执行流程#

flowchart TB A["execve('./hello', argv, envp)"] --> B[检查文件权限] B --> C{是否是脚本?} C -->|是| D[读取 shebang<br/>用解释器替换] C -->|否| E[读取 ELF Header] E --> F[验证 ELF 魔数<br/>7f 45 4c 46] F --> G[解析 Program Headers] G --> H[释放旧内存空间<br/>munmap] H --> I[建立新的内存映射] I --> J[映射 .text 段<br/>代码段] J --> K[映射 .data/.bss 段<br/>数据段] K --> L[设置堆 (brk)] L --> M[设置栈 (stack)] M --> N[映射 ld.so<br/>动态链接器] N --> O[设置入口点<br/>e_entry / ld.so] O --> P[跳转到入口执行]

3.2 ELF 文件格式#

ELF (Executable and Linkable Format) 是 Linux 下可执行文件和共享库的标准格式。

ELF 文件结构:
+-------------------+
| ELF Header | 魔数: 7f 45 4c 46 (DEL E L F)
| e_type: ET_EXEC | 类型: 可执行文件
| e_entry: 0x400xxx | 入口点地址
| e_phoff: 64 | Program Header 偏移
| e_shoff: xxx | Section Header 偏移
+-------------------+
| Program Header | 描述运行时的段 (Segment)
| PT_LOAD (.text) | 可加载段: 代码
| PT_LOAD (.data) | 可加载段: 数据
| PT_INTERP | 动态链接器路径
| PT_DYNAMIC | 动态链接信息
| PT_STACK | 栈标志
+-------------------+
| .text | 代码段 (机器指令)
| .rodata | 只读数据 (字符串常量等)
| .data | 已初始化数据
| .bss | 未初始化数据 (不占文件空间)
| .dynamic | 动态链接信息
| .dynsym | 动态符号表
| .strtab | 字符串表
+-------------------+
| Section Header | 描述链接时的节 (Section)
+-------------------+
# 查看 ELF 文件信息
readelf -h ./hello # ELF Header
readelf -l ./hello # Program Headers
readelf -S ./hello # Section Headers
readelf -d ./hello # Dynamic Section
file ./hello # 文件类型
objdump -d ./hello # 反汇编

3.3 exec 内核源码#

// execve 系统调用入口
// 源码: https://github.com/torvalds/linux/blob/master/fs/exec.c
SYSCALL_DEFINE3(execve, const char __user *, filename,
const char __user *const __user *, argv,
const char __user *const __user *, envp)
{
return do_execve(getname(filename), argv, envp);
}
// exec 核心实现
static int exec_binprm(struct linux_binprm *bprm)
{
// 搜索注册的二进制格式处理器
// ELF -> load_elf_binary()
// 脚本 -> load_script()
// a.out -> load_aout_binary()
ret = search_binary_handler(bprm);
return ret;
}
// ELF 加载器
// 源码: https://github.com/torvalds/linux/blob/master/fs/binfmt_elf.c
static int load_elf_binary(struct linux_binprm *bprm)
{
// 1. 读取并验证 ELF Header
retval = elf_read(&loc->elf_ex, sizeof(loc->elf_ex), bprm);
// 2. 找到 PT_INTERP 段,获取动态链接器路径
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
if (elf_ppnt->p_type == PT_INTERP) {
// 读取 ld.so 路径
retval = elf_read(&elf_interpreter, ...);
}
}
// 3. 释放旧地址空间
retval = begin_new_exec(bprm); // flush_old_exec 内部
// 4. 映射各 PT_LOAD 段
for (i = 0; i < loc->elf_ex.e_phnum; i++) {
if (elf_ppnt->p_type != PT_LOAD)
continue;
// mmap 映射到进程地址空间
elf_map(bprm->file, load_addr + elf_ppnt->p_vaddr,
elf_ppnt, elf_prot, elf_flags, total_size);
}
// 5. 设置入口点
if (elf_interpreter) {
// 动态链接程序: 入口 = ld.so
elf_entry = load_elf_interp(&loc->interp_elf_ex, ...);
} else {
// 静态链接程序: 入口 = e_entry
elf_entry = loc->elf_ex.e_entry;
}
return 0;
}

四、动态链接#

4.1 动态链接流程#

sequenceDiagram participant K as 内核 (exec) participant L as ld.so participant A as app (hello) participant S as libc.so K->>K: 加载 ELF,发现 PT_INTERP K->>L: mmap 加载 ld.so K->>L: 跳转到 ld.so 入口 L->>L: 自身初始化 L->>A: 读取 .dynamic 段 L->>L: 收集依赖库列表 (DT_NEEDED) L->>S: mmap 加载 libc.so L->>L: 符号解析 (relocation) L->>L: 填充 GOT 表 L->>L: 设置 PLT 跳转 L->>A: 调用 .init / .init_array L->>A: 跳转到 e_entry (_start) A->>A: __libc_start_main → main()

4.2 GOT 与 PLT#

GOT (Global Offset Table):存储外部符号的实际地址。PLT (Procedure Linkage Table):跳转桩代码,实现延迟绑定。

延迟绑定 (Lazy Binding) 机制:
第一次调用 printf:
程序代码 PLT GOT
+------------------+ +------------------+ +------------------+
| call printf@plt |-->| printf@plt: | | [printf@got] |
| | | jmp *[printf@got]|-->| 0x0 (未解析) |
| | | push reloc_index| +------------------+
| | | jmp PLT[0] | |
+------------------+ +------------------+ v
ld.so 动态解析
printf 的实际地址
填入 GOT 表
第二次调用 printf:
程序代码 PLT GOT
+------------------+ +------------------+ +------------------+
| call printf@plt |-->| printf@plt: | | [printf@got] |
| | | jmp *[printf@got]|-->| printf 实际地址 |---> printf()
+------------------+ +------------------+ +------------------+
# 查看 GOT/PLT 信息
objdump -d -j .plt ./hello # 查看 PLT
objdump -d -j .got ./hello # 查看 GOT
readelf -r ./hello # 查看重定位条目
readelf --dyn-syms ./hello # 查看动态符号
# 查看动态依赖
ldd ./hello
# 输出:
# linux-vdso.so.1 (0x00007ff...)
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2

4.3 动态链接器源码#

// glibc 动态链接器入口
// 源码: https://github.com/bminor/glibc/blob/master/elf/rtld.c
// ld.so 的真正入口 (_start → _dl_start → _dl_start_final)
void _dl_start_final(void *arg)
{
// 1. 自身重定位 (自举)
// 2. 加载依赖库
// 3. 执行重定位
// 4. 初始化各库
// 5. 跳转到程序入口
_dl_init(main_map, argc, argv, envp);
entry = _dl_sysdep_start(main_map, ...);
// 跳转到程序 _start
}

五、进程内存布局#

5.1 完整内存映射#

进程虚拟地址空间布局 (x86_64):
高地址 0xFFFFFFFFFFFFFFFF
+-------------------+
| 内核空间 | 不可访问 (用户态)
| (所有进程共享) |
+-------------------+ 0x00007FFFFFFFFFFF (TASK_SIZE)
| |
| 栈 (Stack) | ↓ 向低地址增长
| |
+-------------------+
| ... |
+-------------------+
| 内存映射区 (mmap) | 文件映射、共享库
| ld.so / libc.so | ↑ 向高地址增长
+-------------------+
| ... |
+-------------------+
| 堆 (Heap) | ↑ 向高地址增长 (brk/sbrk)
| |
+-------------------+
| BSS 段 | 未初始化的全局/静态变量 (零填充)
+-------------------+
| Data 段 | 已初始化的全局/静态变量
+-------------------+
| Text 段 | 代码 (只读、可执行)
+-------------------+
| ELF Header 等 |
+-------------------+ 0x400000 (典型加载地址)
| 不可访问区 |
+-------------------+ 0x0
低地址

5.2 查看进程内存映射#

# 查看进程内存映射
cat /proc/<pid>/maps
# 或
pmap -x <pid>
# 示例输出:
# 地址范围 权限 偏移 设备 inode 路径
# 00400000-00401000 r--p 00000000 08:01 12345 /home/user/hello
# 00401000-00402000 r-xp 00001000 08:01 12345 /home/user/hello (.text)
# 00402000-00403000 r--p 00002000 08:01 12345 /home/user/hello (.rodata)
# 00403000-00404000 rw-p 00003000 08:01 12345 /home/user/hello (.data)
# 7f0000000-7f0020000 r-xp 00000000 ... /lib/x86_64/libc.so.6
# 7ffff7a00000-... rw-p ... 堆
# 7ffffffde000-... rw-p ... 栈
# 查看进程详细信息
cat /proc/<pid>/status
cat /proc/<pid>/statm # 内存使用 (页数)

5.3 内核中的内存管理#

// 进程内存描述符
// 源码: https://github.com/torvalds/linux/blob/master/include/linux/mm_types.h
struct mm_struct {
pgd_t *pgd; // 页全局目录
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆
unsigned long start_stack; // 栈
unsigned long mmap_base; // mmap 区基址
struct vm_area_struct *mmap; // VMA 链表
struct rb_root mm_rb; // VMA 红黑树
// ...
};
// 虚拟内存区域
struct vm_area_struct {
unsigned long vm_start; // 起始地址
unsigned long vm_end; // 结束地址
struct vm_area_struct *vm_next; // 链表
struct rb_node vm_rb; // 红黑树节点
struct mm_struct *vm_mm; // 所属 mm_struct
unsigned long vm_flags; // 权限标志
const struct vm_operations_struct *vm_ops; // 操作函数
};

六、文件描述符#

6.1 文件描述符继承#

fork 后子进程继承父进程的文件描述符表,共享文件偏移量。

flowchart TB subgraph fork 前 A[父进程 fd 表] A1[fd 0: stdin] A2[fd 1: stdout] A3[fd 2: stderr] A4[fd 3: file.txt] end subgraph fork 后 B[父进程 fd 表] B1[fd 0: stdin] B2[fd 1: stdout] B3[fd 2: stderr] B4[fd 3: file.txt] C[子进程 fd 表] C1[fd 0: stdin] C2[fd 1: stdout] C3[fd 2: stderr] C4[fd 3: file.txt] end B4 -.->|共享 offset| D[file.txt] C4 -.->|共享 offset| D

6.2 CLOEXEC 标志#

exec 替换进程映像时,默认继承所有打开的文件描述符。FD_CLOEXEC 标志可以让 fd 在 exec 时自动关闭。

// 设置 CLOEXEC
// 源码: https://github.com/torvalds/linux/blob/master/fs/file.c
// 打开文件时设置 (原子操作)
int fd = open("file.txt", O_RDONLY | O_CLOEXEC);
// 或在已有的 fd 上设置
fcntl(fd, F_SETFD, FD_CLOEXEC);
// 为什么需要 CLOEXEC?
// 1. 安全: 防止子进程意外访问父进程的文件
// 2. 资源泄漏: exec 后 fd 不再使用但不关闭
// 3. 多线程安全: 传统 SETFD + exec 之间有竞态条件
// pipe + CLOEXEC 示例
int pipefd[2];
pipe2(pipefd, O_CLOEXEC); // 原子设置 CLOEXEC
// fork 后子进程
if (fork() == 0) {
// 关闭读端
close(pipefd[0]);
// 重定向 stdout 到管道写端
// pipefd[1] 没有 CLOEXEC,可以 exec 后使用
dup2(pipefd[1], STDOUT_FILENO);
execlp("ls", "ls", NULL);
}

6.3 标准 I/O 重定向#

Shell 中管道和重定向的本质就是操作文件描述符:

# 重定向原理
# ls > output.txt 等价于:
# 1. fork
# 2. 子进程中: open("output.txt") → 得到 fd 3
# 3. dup2(3, 1) → fd 1 (stdout) 指向 output.txt
# 4. close(3)
# 5. exec("ls")
// 标准重定向实现
void redirect_stdout(const char *filename) {
int fd = open(filename, O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO); // STDOUT_FILENO = 1
close(fd);
}
// 管道实现
void pipe_example() {
int pipefd[2];
pipe(pipefd);
if (fork() == 0) {
close(pipefd[0]); // 关闭读端
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
execlp("ls", "ls", NULL);
}
close(pipefd[1]); // 关闭写端
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
execlp("wc", "wc", "-l", NULL);
}

七、进程调度#

7.1 CFS (完全公平调度器)#

CFS 是 Linux 默认的进程调度器,核心思想是给每个进程公平的 CPU 时间。

// CFS 调度器核心数据结构
// 源码: https://github.com/torvalds/linux/blob/master/kernel/sched/fair.c
// 调度实体
struct sched_entity {
struct load_weight load; // 权重
struct rb_node run_node; // 红黑树节点
unsigned int on_rq; // 是否在运行队列
u64 vruntime; // 虚拟运行时间
u64 sum_exec_runtime; // 累计执行时间
};
// CFS 运行队列 (红黑树,按 vruntime 排序)
struct cfs_rq {
struct rb_root_cached tasks_timeline; // 红黑树根
struct sched_entity *curr; // 当前运行实体
unsigned int nr_running; // 运行中进程数
u64 min_vruntime; // 最小 vruntime
};
CFS 红黑树 (按 vruntime 排序):
vruntime=50
/ \
vruntime=20 vruntime=80
/ \ / \
vr=10 vr=35 vr=65 vr=120
下一个调度的进程
(vruntime 最小)

7.2 调度策略#

调度策略类型优先级范围说明
SCHED_NORMAL普通进程100-139CFS 调度,nice 值映射
SCHED_BATCH普通进程100-139批处理,不交互
SCHED_IDLE普通进程最低极低优先级
SCHED_FIFO实时进程0-99先进先出,不主动让出
SCHED_RR实时进程0-99时间片轮转
SCHED_DEADLINE实时进程-截止时间调度 (EDF)
# 查看进程调度策略和优先级
chrt -p <pid>
# 设置进程调度策略
chrt -f 10 ./realtime_app # SCHED_FIFO, 优先级 10
chrt -r 50 ./realtime_app # SCHED_RR, 优先级 50
chrt -o 0 ./normal_app # SCHED_NORMAL
# nice 值 (影响普通进程优先级)
nice -n 10 ./low_priority_app # nice 值 +10
renice -n -5 -p <pid> # 提高优先级

7.3 进程调度流程#

sequenceDiagram participant T as Timer 中断 participant S as scheduler() participant C as CFS participant N as next 进程 participant P as prev 进程 T->>S: 时钟中断触发 S->>P: 更新 prev 的 vruntime S->>C: 从红黑树找最小 vruntime C->>S: 返回 next 进程 S->>S: context_switch() S->>N: 切换到 next 进程执行 Note over P,N: 保存/恢复寄存器,切换地址空间

八、进程终止#

8.1 exit 流程#

flowchart TB A["exit(status)"] --> B["调用 atexit 处理函数"] B --> C["flush stdio 缓冲区"] C --> D["调用 _exit()"] D --> E["释放资源"] E --> F[释放 mm_struct<br/>取消内存映射] F --> G[关闭文件描述符] G --> H[释放信号处理] H --> I[设置退出码<br/>EXIT_ZOMBIE] I --> J[通知父进程<br/>SIGCHLD] J --> K[调度其他进程] K --> L{父进程 waitpid?} L -->|是| M[读取退出码<br/>释放 task_struct<br/>EXIT_DEAD] L -->|否| N[成为僵尸进程<br/>等待被回收]

8.2 exit 内核源码#

// 进程退出实现
// 源码: https://github.com/torvalds/linux/blob/master/kernel/exit.c
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
// 1. 检查退出合法性
if (unlikely(in_interrupt()))
panic("Aiee, killing interrupt handler!");
// 2. 设置退出码
tsk->exit_code = code;
// 3. 释放资源
exit_signals(tsk); // 退出信号处理
exit_mm(tsk); // 释放内存空间
exit_sem(tsk); // 释放信号量
exit_shm(tsk); // 释放共享内存
exit_files(tsk); // 关闭文件描述符
exit_fs(tsk); // 释放 fs 信息
exit_task_namespaces(tsk); // 释放命名空间
// 4. 设置僵尸状态
tsk->state = TASK_DEAD;
tsk->exit_state = EXIT_ZOMBIE;
// 5. 通知父进程
do_notify_parent(tsk, tsk->exit_signal);
// 6. 重新调度
do_task_dead();
}

8.3 僵尸进程与孤儿进程#

僵尸进程 (Zombie):
子进程已退出,但父进程未调用 waitpid 回收
task_struct 仍保留在内核中,占用 PID 资源
+-----------+ +-----------+
| 父进程 | | 子进程 (Z) |
| 活跃 | SIGCHLD | task_struct|
| 没有wait |<---------| 保留 |
+-----------+ +-----------+
危害: PID 耗尽,无法创建新进程
解决: 父进程调用 wait/waitpid
或 kill 父进程让 init 回收
孤儿进程 (Orphan):
父进程先于子进程退出
子进程被 init (PID 1) 或 subreaper 接管
+-----------+
| 父进程 | exit
+-----------+ ↓
init (PID 1) 接管
+-----------+
| 子进程 |
| 继续运行 |
+-----------+
# 查找僵尸进程
ps aux | grep 'Z'
# 或
ps -eo pid,ppid,stat,cmd | grep 'Z'
# 消除僵尸进程:
# 1. 让父进程调用 wait()
# 2. kill 父进程,init 接管并回收
# 3. 使用 SIGCHLD 信号处理 (SA_NOCLDSTOP|SA_NOCLDWAIT)
# 设置 subreaper (PR_SET_CHILD_SUBREAPER)
# systemd / Docker 常用此机制
prctl(PR_SET_CHILD_SUBREAPER, 1);

8.4 waitpid 内核实现#

// waitpid 系统调用
// 源码: https://github.com/torvalds/linux/blob/master/kernel/exit.c
SYSCALL_DEFINE4(wait4, pid_t, pid, int __user *, stat_addr,
int, options, struct rusage __user *, ru)
{
return kernel_wait4(pid, stat_addr, options, ru);
}
static long kernel_wait4(pid_t pid, int __user *stat_addr,
int options, struct rusage __user *ru)
{
struct wait_opts wo;
// 遍历子进程
for_each_thread(current, p) {
// 检查子进程状态
if (p->exit_state == EXIT_ZOMBIE) {
// 找到僵尸子进程
// 读取退出码
// 释放 task_struct
// 返回子进程 PID
}
}
// 如果设置了 WNOHANG,立即返回
if (options & WNOHANG)
return 0;
// 否则阻塞等待子进程退出
return do_wait_thread(wo);
}

九、实战:跟踪进程启动#

9.1 strace 跟踪#

# 跟踪进程启动全过程
strace -f -o trace.log ./hello
# 关键输出分析
# -f: 跟踪子进程
# -o: 输出到文件
# 典型 strace 输出:
execve("./hello", ["./hello"], 0x7ffd... /* 50 vars */) = 0
brk(NULL) = 0x5600000
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f...
access("/etc/ld.so.preload", R_OK) = -1 ENOENT
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_size=89012, ...}) = 0
mmap(NULL, 89012, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f...
close(3) = 0
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\x7fELF\x02\x01\x01\x03", 832) = 832
mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f...
mmap(NULL, 18325696, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f...
mprotect(0x7f...24000, 2097152, PROT_NONE) = 0
mmap(0x7f...42000, 1552384, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x24000) = 0x7f...
mmap(0x7f...99000, 303104, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x199000) = 0x7f...
close(3) = 0
mprotect(0x7f...99000, 24576, PROT_READ) = 0
set_tid_address(0x7f...) = 12345
set_robust_list(0x7f..., 24) = 0
rt_sigaction(SIGPIPE, {sa_handler=SIG_IGN}, ...) = 0
write(1, "Hello, World!\n", 14) = 14
exit_group(0) = ?
+++ exited with 0 +++

9.2 ltrace 跟踪库调用#

# 跟踪库函数调用
ltrace ./hello
# 典型输出:
__libc_start_main(0x560c..., 1, 0x7ffd..., 0x560c... <incomplete ...>
puts("Hello, World!") = 14
+++ exited (status 0) +++

9.3 /proc 文件系统查看#

# 启动一个后台进程
./hello &
PID=$!
# 查看进程信息
cat /proc/$PID/status | head -20
# Name: hello
# State: S (sleeping)
# Tgid: 12345
# Pid: 12345
# PPid: 1234
# Uid: 1000 1000 1000 1000
# Gid: 1000 1000 1000 1000
# 查看内存映射
cat /proc/$PID/maps
# 查看命令行
cat /proc/$PID/cmdline | tr '\0' ' '
# 查看打开的文件
ls -la /proc/$PID/fd/

9.4 从 fork 到 exec 的 Shell 模拟#

// 简化版 Shell: fork + exec + wait
// 模拟 Shell 执行命令的过程
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
char *argv[] = {"/bin/ls", "-la", "/tmp", NULL};
char *envp[] = {NULL};
pid_t pid = fork(); // 1. fork 创建子进程
if (pid == 0) {
// 子进程
// 2. exec 替换进程映像
execve("/bin/ls", argv, envp);
// 如果 exec 成功,下面的代码不会执行
perror("execve failed");
_exit(1);
} else if (pid > 0) {
// 父进程
int status;
// 3. wait 等待子进程
waitpid(pid, &status, 0);
printf("Child exited with status: %d\n", WEXITSTATUS(status));
} else {
perror("fork failed");
}
return 0;
}

十、从 _start 到 main#

10.1 C 程序的启动序列#

exec 加载程序后,入口点并不是 main,而是 _start

flowchart TB A["内核跳转到 _start<br/>(ELF e_entry)"] --> B["_start<br/>(汇编入口)"] B --> C["__libc_start_main<br/>(glibc)"] C --> D["__libc_init_first<br/>初始化"] D --> E["__cxa_atexit<br/>注册 atexit 函数"] E --> F["__libc_csu_init<br/>调用 .init_array"] F --> G["main(argc, argv, envp)"] G --> H["exit(main_return_value)"] H --> I["调用 atexit 处理函数"] I --> J["_exit(status)"]
; _start 的汇编实现 (x86_64)
; 源码: https://github.com/bminor/glibc/blob/master/sysdeps/x86_64/start.S
_start:
; 清空 rbp
xor ebp, ebp
; 提取 argc (栈顶)
mov r9, rsp ; 保存栈指针 (用于对齐)
pop rsi ; argc → rsi
mov rdx, rsp ; argv → rdx
; 对齐栈
and rsp, -16
; 调用 __libc_start_main
; 参数: main, argc, argv, init, fini, rtld_fini, stack_end
call __libc_start_main
; 如果 __libc_start_main 返回 (不应该)
hlt

10.2 初始化顺序#

C 程序初始化和终止的完整顺序:
初始化:
1. 内核映射 ELF 段
2. ld.so 加载动态库,执行重定位
3. 各 .init 段按依赖顺序执行
4. .init_array 中的函数指针(按注册顺序)
5. C++ 全局对象构造函数
6. main()
终止:
7. main() 返回 / exit() 调用
8. atexit() 注册的函数(按相反顺序)
9. .fini_array 中的函数指针
10. C++ 全局对象析构函数
11. 各 .fini 段
12. _exit() 系统调用

常见问题#

Q1: fork 和 vfork 有什么区别?#

vfork 创建子进程时共享父进程的地址空间(不执行 COW),子进程必须立即调用 exec_exit,不能修改数据或返回。vfork 保证子进程先运行,避免了 COW 的开销,适合 fork 后立即 exec 的场景。现代 Linux 中 fork 已经足够高效(得益于 COW),vfork 的优势不大,不推荐使用。

Q2: exec 会继承哪些属性?#

exec 替换进程映像,但部分属性会保留:PID 和 PPID、进程优先级 (nice)、文件描述符(没有 CLOEXEC 的)、当前工作目录、根目录、环境变量(除非显式传入新 envp)、信号掩码、资源限制 (rlimit)。不保留的:代码和数据(被替换)、信号处理函数(恢复为默认)、内存映射(munmap)、atexit 注册的函数。

Q3: 为什么需要 COW?#

如果没有 COW,fork 时必须复制父进程的所有内存页。假设父进程使用了 1GB 内存,fork 就需要额外分配 1GB 并复制数据,即使子进程根本不会修改这些数据。COW 让父子进程先共享物理页,只在真正写入时才复制被修改的页。实践表明,fork 后大多数页面都不会被修改。

Q4: 僵尸进程有什么危害?#

僵尸进程已经释放了大部分资源,只保留 task_struct 和少量内核栈信息。但它们占用了 PID 资源。Linux 的 PID 数量有限(默认 32768),如果大量僵尸进程不被回收,会导致无法创建新进程。解决办法是确保父进程正确调用 wait/waitpid,或者使用 SIGCHLD 信号的 SA_NOCLDWAIT 标志。

Q5: 如何调试进程启动问题?#

使用 strace -f 跟踪系统调用,可以看到 fork、exec、mmap 等完整过程。如果 exec 失败,strace 会显示具体错误码。ltrace 可以跟踪库函数调用。LD_DEBUG 环境变量可以打印动态链接的详细信息:LD_DEBUG=all ./hello。对于更底层的问题,可以使用 ftraceperf 跟踪内核函数调用。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Linux 进程启动过程:从 fork 到 exec
https://blog.souloss.com/posts/principles/linux-process-startup-process/
作者
Souloss
发布于
2023-08-04
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时