Linux 系统中,进程的启动和退出
在 Linux 系统中,进程的启动和退出涉及 用户空间(glibc、程序代码) 和 内核空间(系统调用、进程管理) 的协同工作。以下从系统级视角深入解析进程生命周期的完整流程,覆盖内核机制和用户态行为。
一、进程启动(Creation)
1. 创建新进程的两种方式
fork()
:复制当前进程(父进程),生成子进程(共享代码段,写时复制数据段)。execve()
:加载新程序到内存,替换当前进程的代码段和数据段。
2. 进程启动的完整流程
父进程调用 fork() → 内核创建子进程 → 子进程调用 execve() → 内核加载新程序 → 用户态入口 _start → __libc_start_main → main()
(1) fork()
系统调用
- 内核操作:
- 复制父进程的
task_struct
(进程描述符),生成子进程。 - 复制内存页表(写时复制,COW)。
- 分配新的 PID 和内核栈。
- 复制父进程的
- 返回值:
- 父进程:返回子进程 PID。
- 子进程:返回 0。
(2) execve()
系统调用
- 作用:加载可执行文件(ELF、脚本等)到内存,替换当前进程的地址空间。
- 内核操作:
- 解析可执行文件:
- 检查文件格式(通过
binfmt
模块,如binfmt_elf
)。 - 读取 ELF 头,验证架构兼容性。
- 检查文件格式(通过
- 设置新地址空间:
- 代码段(
.text
):映射到内存,权限为 R-X。 - 数据段(
.data
、.bss
):映射到内存,权限为 RW-。 - 堆(
brk
)、栈(用户栈)初始化。
- 代码段(
- 构建用户态栈:
- 压入参数(
argv
)、环境变量(envp
)、辅助向量(auxv
)。 - 辅助向量包含关键信息:
AT_ENTRY
(程序入口_start
)、AT_PHDR
(程序头表地址)等。
- 压入参数(
- 设置寄存器状态:
- 指令指针(RIP/EIP)指向入口地址
_start
。 - 栈指针(RSP/ESP)指向用户栈顶。
- 指令指针(RIP/EIP)指向入口地址
- 解析可执行文件:
(3) 用户态启动流程
- 入口点
_start
(由汇编实现):- 从栈中读取
argc
、argv
、envp
。 - 调用
__libc_start_main
。
- 从栈中读取
__libc_start_main
(glibc):- 初始化线程本地存储(TLS)。
- 调用全局构造函数(
.init_array
)。 - 调用用户
main()
函数。 - 处理
main()
返回值,调用exit()
。
二、进程退出(Termination)
1. 正常退出
- 用户态入口:
exit()
(glibc)或_exit()
(直接系统调用)。 - 内核操作:
- 释放进程资源:
- 关闭所有打开的文件描述符。
- 释放内存页(代码、数据、堆、栈等)。
- 释放进程描述符
task_struct
。
- 通知父进程:
- 发送
SIGCHLD
信号给父进程。 - 将退出状态码存入进程描述符(供
wait()
读取)。
- 发送
- 处理孤儿进程:
- 若父进程已退出,由
init
进程(PID 1)接管子进程。
- 若父进程已退出,由
- 释放进程资源:
2. 异常退出
- 信号(Signal)触发:如
SIGSEGV
(段错误)、SIGKILL
(强制终止)。 - 内核操作:
- 生成核心转储(若配置允许)。
- 执行与正常退出类似的资源回收流程。
3. exit()
vs _exit()
函数 | 行为 |
---|---|
exit() |
glibc 函数,执行清理:刷新缓冲区、调用 atexit() 注册的函数、析构函数。 |
_exit() |
系统调用,直接终止进程,跳过用户态清理。 |
三、关键数据结构与内核机制
1. 进程描述符(task_struct
)
- 存储位置:内核栈底部(x86-64 为
current
宏指向当前进程的task_struct
)。 - 关键字段:
pid
、ppid
:进程 ID 和父进程 ID。mm_struct
:内存管理信息(地址空间、页表等)。files_struct
:打开的文件描述符表。exit_code
:退出状态码。signal
:挂起的信号。
2. 地址空间管理(mm_struct
)
- 代码段:只读,映射 ELF 的
.text
节。 - 数据段:可读写,映射
.data
(已初始化全局变量)和.bss
(未初始化全局变量)。 - 堆:动态内存分配(
brk
/sbrk
或malloc
)。 - 栈:用户栈,用于函数调用和局部变量。
3. 进程树与信号
- 父子关系:通过
fork()
创建的进程形成树状结构。 - 僵尸进程:子进程退出后,若父进程未调用
wait()
,其task_struct
会残留,直到父进程回收。 SIGCHLD
:子进程退出时,内核向父进程发送此信号。
四、高级主题
1. 动态链接(ld.so
)
- 介入时机:在
execve()
加载动态链接程序时,内核将控制权先交给动态链接器。 - 操作流程:
- 加载依赖的共享库(
.so
文件)。 - 重定位符号(地址绑定)。
- 将控制权转交到程序的
_start
。
- 加载依赖的共享库(
2. 核心转储(Core Dump)
- 触发条件:进程因信号(如
SIGSEGV
)崩溃,且系统配置允许生成核心文件。 - 文件内容:进程崩溃时的内存映像、寄存器状态等,用于调试。
3. 进程间通信(IPC)与退出
- 管道、套接字:进程退出时,内核会自动关闭这些资源。
- 共享内存:需显式释放(通过
shmdt()
或进程终止后由系统回收)。
五、代码示例与工具验证
1. 跟踪进程启动系统调用
strace -f -e trace=execve,fork,clone,execve bash -c "ls"
- 输出:显示
fork
、execve
的调用顺序和参数。
2. 查看进程地址空间
cat /proc/<pid>/maps
- 输出:显示进程的内存映射(代码段、堆、栈、共享库等)。
3. 观察僵尸进程
#include <unistd.h>
int main() {
if (fork() == 0) { // 子进程立即退出
return 0;
} else { // 父进程不调用 wait()
sleep(60);
}
return 0;
}
- 查看僵尸进程:
ps aux | grep Z
六、总结
- 进程启动:通过
fork
+execve
实现,内核负责资源分配和程序加载,用户态从_start
开始执行。 - 进程退出:用户态通过
exit()
或_exit()
触发,内核回收资源并通知父进程。 - 设计哲学:通过 COW 机制优化
fork()
性能,通过动态链接支持模块化程序,通过信号机制处理异步事件。