进程启停

Posted by fjw on January 13, 2026

启动

C++ 进程的启动(以现代 Linux x86_64 + glibc + g++ 为例)本质上和 C 程序几乎完全一样,因为 C++ 的运行时模型是建立在 C 之上的。主要区别只在于全局对象/静态对象的构造(constructor)和析构(destructor)环节。

一个普通 C++ 可执行文件从 execve 到进入 main 的详细流程(主流 glibc 2.3x/2.4x 行为)。

1. 用户态发起 → 内核收到 execve 系统调用

// 比如你在 shell 里敲
./myprogram arg1 arg2
// shell 最终调用
execve("./myprogram", {"./myprogram", "arg1", "arg2", NULL}, environ);

内核进入 do_execve() → do_execveat_common() → 各种格式检查 → 最终走到 ELF 加载器 load_elf_binary()。

2. 内核加载 ELF 文件的主要动作(简化)

顺序 内核动作 关键影响
1 丢弃旧地址空间 exec_mmap() → 原进程映像彻底消失
2 读取 ELF header 检查魔数、架构、入口点 e_entry
3 读取 program headers (PT_LOAD 等) 决定要映射哪些段
4 映射 PT_LOAD 段 代码 rx、数据 rw、bss(零初始化)
5 如果有 PT_INTERP 读取动态链接器路径(通常 /lib64/ld-linux-x86-64.so.2)
6 映射动态链接器本身 ld.so 也被当作一个 ELF 加载进来
7 准备新栈 放 argc、argv、envp、auxv(非常重要)
8 设置入口点 如果有解释器 → ld.so 的入口点;否则程序自己的 e_entry
9 返回用户态 此时 RIP = ld.so 的 _start(动态链接最常见情况)

静态链接的 C++ 程序会直接跳到自己程序的 _start。

动态链接(99% 的情况)会先跳到 ld.so 的 _start

3. 动态链接器(ld-linux-x86-64.so.2)接管

ld.so 做的第一批事(非常早期,用户代码还没跑):

  1. 读取栈上的 auxv(辅助向量),里面有非常关键的信息:
    • AT_ENTRY(真正程序的入口点地址)
    • AT_PHDR(程序的 program header 表位置)
    • AT_CLKTCK、AT_PAGESZ、AT_RANDOM 等
  2. 进行自身重定位(ld.so 自己也要重定位)
  3. 加载所有依赖的共享库(.so 文件)
  4. 主程序所有已加载的库执行重定位(REL/RELA)
  5. 调用所有已加载模块的 .init_array(包括 ld.so 自己的初始化函数)
  6. 最后跳到主程序的 _start(从 auxv 的 AT_ENTRY 得到)

4. 主程序的 _start(通常由 crt1.o / crti.o 提供)

现代 g++ 链接的 _start 大致长这样(汇编简化版):

1
_start:    xor     ebp, ebp           ; 有的版本清 frame pointer    pop     rsi                ; argc    mov     rdx, rsp           ; argv(栈上紧跟 argc 后面)    and     rsp, -16           ; 栈 16 字节对齐(x86_64 System V ABI 要求)    push    rax                ; 占位,保持对齐    push    rsp                ; 栈地址(__libc_start_main 的第 7 个参数)    lea     r8,  [rel __libc_csu_fini]    lea     rcx, [rel __libc_csu_init]    lea     rdi, [rel main]    ; main 地址    call    __libc_start_main  ; 永远不返回    hlt                        ; 万一返回了就死机

5. __libc_start_main(glibc 核心启动函数)

这是 glibc 里最关键的一环,大致顺序:

  1. 保存 main、init、fini、rtld_fini 等函数指针
  2. 注册 atexit 析构(主要是 rtld 的析构)
  3. 初始化 glibc 自身(__libc_init_first 等)
  4. 调用 __libc_csu_init(C++ 关键!)
  5. 调用你写的 main(argc, argv, envp)
  6. main 返回后 → exit(main 的返回值)

6. __libc_csu_init(C++ 全局/静态对象构造的关键)

// 简化伪码(来自 glibc csu/elf-init.c)
void __libc_csu_init(...) {
    _init();                     // .init 段(老式方式,很少用)
    
    // 调用 .init_array 中的所有函数(最重要!)
    for (每个函数指针 in __init_array_start 到 __init_array_end) {
        (*函数指针)(argc, argv, envp);   // 通常只传一个参数或无参
    }
}

这里就是 C++ 全局对象 / 静态对象构造函数被调用的地方 (编译器把所有全局/命名空间/函数内 static 对象的构造都放进 .init_array)

7. 进入 main() → 用户代码开始执行

到这里才真正开始执行你写的 int main()。

8. 程序退出时的对称流程

  • main 返回 → __libc_start_main 接手
  • 调用 __libc_csu_fini → 执行 .fini_array 中的析构函数(全局/静态对象的析构)
  • 调用 atexit 注册的函数
  • 调用 _fini(老式)
  • 最终调用 exit_group() /_exit 系统调用

总结流程

1
2
3
4
5
6
7
8
9
10
11
12
13
shell → execve()
  ↓
内核 load_elf_binary()
  ↓                     动态链接
ld.so _start → 重定位所有 .so → 调用所有 .init_array(ld.so 自己的)
  ↓
主程序 _start(crtbegin / crt1.o)
  ↓
__libc_start_main
  ↓
__libc_csu_init → 调用所有 .init_array(C++ 全局构造!)
  ↓
你的 main()

快速对照表

问题 答案
C++ 进程从哪开始执行? 动态链接:ld.so → 程序 _start → __libc_start_main → main
全局对象什么时候构造? __libc_csu_init 里 .init_array(在 main 之前)
静态链接的 C++ 程序跳过 ld.so 吗? 是,直接从程序自己的 _start 开始
-static 链接会影响启动顺序吗? 不会改变顺序,但 ld.so 阶段被跳过
用 new/delete 的全局对象呢? 构造在 .init_array,析构在 .fini_array 或 atexit 机制
能绕过 glibc 启动代码自己写 _start 吗? 可以,但非常不推荐(会失去栈对齐、构造/析构、std::cin 等初始化)

停止

一个 C++ 进程(Linux x86_64 + glibc + g++/clang,2025–2026 年主流环境)在正常退出时(return main / std::exit / std::quick_exit / 线程全部结束等),会执行一系列清理动作。这些动作由 C 运行时(主要是 glibc)和 C++ 运行时(libstdc++)共同负责。

下面按实际执行顺序(从用户代码退出点开始)详细说明进程退出时通常会干什么(正常终止路径)。

1. main() 返回 或 调用 std::exit(status)

  • 返回 main() 等价于 std::exit(main 的返回值)(C++ 标准保证)
  • 显式调用 std::exit(status) 也一样

进入 __libc_start_main 的退出分支(或直接调用 exit)

2. glibc exit() 的主要清理流程(简化顺序)

glibc 的 exit() 大致做这些事(参考 glibc stdlib/exit.c 和 libc-start.c):

顺序 动作 谁负责 关键细节 / 注意事项
1 标记进程正在退出(设置标志) glibc 内部 防止重入 exit
2 调用所有注册的 atexit / on_exit / __cxa_atexit 处理函数(逆序,LIFO) glibc __run_exit_handlers 包括 C++ 全局/静态析构、用户 atexit
3 冲洗所有标准流(stdin/stdout/stderr) glibc fflush(NULL) 语义
4 关闭所有打开的文件流(fcloseall) glibc 包括用户 fopen 的流
5 调用 _dl_fini(动态链接器 fini) glibc → ld.so 共享库的 .fini_array / 析构
6 最终调用 _exit(status) 系统调用 glibc 内核接管,进程彻底终止

最关键的部分:第 2 步的 exit handlers 处理。

3. exit handlers 的详细执行顺序

C++ 把全局/静态/线程局部对象析构也注册到这个机制里(通过 __cxa_atexit)。

实际顺序(主流 glibc + libstdc++ 行为):

  1. 用户通过 std::atexit / std::at_quick_exit 注册的函数(逆注册顺序)
    • 先注册的后调用(LIFO)
  2. C++ 全局/命名空间/函数内 static 对象的析构(~Xxx())
    • 析构顺序 ≈ 构造的逆序(大致,标准只部分保证)
    • 通过 __cxa_atexit 注册,带__dso_handle(支持 dlclose 时单独析构)
  3. 线程局部存储(thread_local)变量的析构(当前线程的)
    • 每个线程独立,通过 __cxa_thread_atexit
  4. 共享库的 .fini_array 函数(动态链接器调用)
    • 每个已加载的 .so 的 fini 函数(包括 libc 自己的)
  5. 其他 glibc 内部清理(如 malloc 统计、locale 析构等)

一句话记忆顺序(最常见情况):

1
2
3
4
5
6
7
8
9
10
11
12
13
main 返回 / std::exit()
  ↓
atexit 注册的函数(用户自定义,LIFO)
  ↓
C++ 全局/静态对象析构(~Global(),大致逆构造序)
  ↓
thread_local 变量析构(当前线程)
  ↓
共享库 fini / _dl_fini
  ↓
fflush(NULL) + fcloseall
  ↓
_exit() 系统调用 → 进程结束

4. 特殊情况对比表

退出方式 调用全局/静态析构? 调用 atexit 回调? 冲洗/关闭流? 线程局部析构? 备注
return main() / std::exit() 是(当前线程) 正常 C++ 退出
std::quick_exit() 否(只 at_quick_exit) 快速退出,不析构
_exit(status) / exit_group() 内核直接杀进程
abort() 核心转储,SIGABRT
线程全部结束(main 线程退出) 是(如果 main 退出) pthread_exit 不等同进程退出
dlclose() 一个共享库 是(该库的静态对象) 是(该库注册的) 通过 __cxa_finalize

5. 常见问题

  • 析构没执行 → 用 _exit() / quick_exit() / 异常没捕获 + std::terminate / kill -9 等暴力方式
  • 析构顺序不可靠 → 不同翻译单元间的全局对象构造/析构顺序未定义(经典“static initialization order fiasco”),退出时同理
  • 想完全禁用退出时析构 → 编译时加 -fno-use-cxa-atexit(不标准)或链接时避免注册,或用 quick_exit
  • 性能敏感场景 → 很多人故意避免全局对象(或用 trivial destructor),或用 -Wl,–no-as-needed 控制,或直接 _exit(0) 绕过
  • 调试技巧 → 在 gdb 里设断点到 __run_exit_handlers /__cxa_finalize /_dl_fini,就能看到退出时真正调了什么

其他

链接选项加上,避免plt延迟绑定,第一次调用时产生延迟(代价启动时间变长)

1
-z now