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、脚本等)到内存,替换当前进程的地址空间。
  • 内核操作:
    1. 解析可执行文件:
      • 检查文件格式(通过 binfmt 模块,如 binfmt_elf)。
      • 读取 ELF 头,验证架构兼容性。
    2. 设置新地址空间:
      • 代码段(.text):映射到内存,权限为 R-X。
      • 数据段(.data.bss):映射到内存,权限为 RW-。
      • 堆(brk)、栈(用户栈)初始化。
    3. 构建用户态栈:
      • 压入参数(argv)、环境变量(envp)、辅助向量(auxv)。
      • 辅助向量包含关键信息:AT_ENTRY(程序入口 _start)、AT_PHDR(程序头表地址)等。
    4. 设置寄存器状态:
      • 指令指针(RIP/EIP)指向入口地址 _start
      • 栈指针(RSP/ESP)指向用户栈顶。
(3) 用户态启动流程
  • 入口点 _start(由汇编实现):
    • 从栈中读取 argcargvenvp
    • 调用 __libc_start_main
  • __libc_start_main(glibc):
    • 初始化线程本地存储(TLS)。
    • 调用全局构造函数(.init_array)。
    • 调用用户 main() 函数。
    • 处理 main() 返回值,调用 exit()

二、进程退出(Termination)

1. 正常退出

  • 用户态入口:exit()(glibc)或 _exit()(直接系统调用)。
  • 内核操作:
    1. 释放进程资源:
      • 关闭所有打开的文件描述符。
      • 释放内存页(代码、数据、堆、栈等)。
      • 释放进程描述符 task_struct
    2. 通知父进程:
      • 发送 SIGCHLD 信号给父进程。
      • 将退出状态码存入进程描述符(供 wait() 读取)。
    3. 处理孤儿进程:
      • 若父进程已退出,由 init 进程(PID 1)接管子进程。

2. 异常退出

  • 信号(Signal)触发:如 SIGSEGV(段错误)、SIGKILL(强制终止)。
  • 内核操作:
    • 生成核心转储(若配置允许)。
    • 执行与正常退出类似的资源回收流程。

3. exit() vs _exit()

函数行为
exit() glibc 函数,执行清理:刷新缓冲区、调用 atexit() 注册的函数、析构函数。
_exit() 系统调用,直接终止进程,跳过用户态清理。

三、关键数据结构与内核机制

1. 进程描述符(task_struct

  • 存储位置:内核栈底部(x86-64 为 current 宏指向当前进程的 task_struct)。
  • 关键字段:
    • pidppid:进程 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() 加载动态链接程序时,内核将控制权先交给动态链接器。
  • 操作流程:
    1. 加载依赖的共享库(.so 文件)。
    2. 重定位符号(地址绑定)。
    3. 将控制权转交到程序的 _start

2. 核心转储(Core Dump)

  • 触发条件:进程因信号(如 SIGSEGV)崩溃,且系统配置允许生成核心文件。
  • 文件内容:进程崩溃时的内存映像、寄存器状态等,用于调试。

3. 进程间通信(IPC)与退出

  • 管道、套接字:进程退出时,内核会自动关闭这些资源。
  • 共享内存:需显式释放(通过 shmdt() 或进程终止后由系统回收)。

五、代码示例与工具验证

1. 跟踪进程启动系统调用

strace -f -e trace=execve,fork,clone,execve bash -c "ls"
 
  • 输出:显示 forkexecve 的调用顺序和参数。

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() 性能,通过动态链接支持模块化程序,通过信号机制处理异步事件。
posted @ 2025-02-19 09:57  墨尔基阿德斯  阅读(333)  评论(0)    收藏  举报