各位编程领域的同仁们,大家好!
今天,我们将一同踏上一段深度探索之旅,去揭开那些隐藏在 main() 函数调用背后,以及全局构造函数执行之前的操作系统级秘密。你或许认为,程序的起点就是 main(),但事实远比这复杂和精妙。我们将从操作系统的角度出发,逐步深入到动态链接器,再到C/C++运行时环境的初始化,最终抵达用户代码的第一行。这不仅仅是一次技术解读,更是一次对计算机系统深层机制的致敬。
第一章:操作系统视角下的进程诞生
一切的开始,源于操作系统。当你在终端敲下程序名并按下回车键,或者双击一个可执行文件时,操作系统便启动了一个全新的“进程”。进程,是程序的一次执行实例,它拥有独立的虚拟地址空间、文件描述符、打开的网络连接等资源。
在类Unix系统(如Linux)中,创建新进程通常涉及两个核心系统调用:fork() 和 execve()。
-
fork():复制进程
fork()系统调用会创建一个当前进程的精确副本。这个新进程被称为子进程,它拥有父进程几乎所有的资源,包括虚拟地址空间、文件描述符等。在fork()返回后,父子进程会从fork()调用点继续执行,但fork()在父进程中返回子进程的PID,在子进程中返回0。 -
execve():加载并执行新程序
execve()系统调用才是真正加载并运行新程序的关键。它会替换当前进程的整个虚拟地址空间,用新的可执行程序的数据和代码填充。这意味着,一旦execve()成功执行,原有的程序代码和数据将不复存在,取而代之的是新程序的映像。execve()的原型如下:int execve(const char *pathname, char *const argv[], char *const envp[]);pathname:指向要执行的程序的路径。argv:一个指向参数字符串数组的指针,这些参数将传递给新程序(即main()函数的argv)。envp:一个指向环境变量字符串数组的指针,这些环境变量将传递给新程序(即main()函数的envp)。
操作系统做了什么?
当
execve()被调用时,内核会执行一系列复杂的操作:- 地址空间重置:内核首先会为新程序构建一个全新的虚拟地址空间。这包括:
- 代码段(.text):从可执行文件中加载程序的机器指令。
- 数据段(.data):加载已初始化的全局变量和静态变量。
- BSS段(.bss):为未初始化的全局变量和静态变量预留空间,这些空间通常在程序启动时被清零。
- 只读数据段(.rodata):加载常量字符串、虚函数表等只读数据。
- 栈(Stack):设置一个初始的栈空间,用于存储局部变量、函数参数和返回地址。
argv和envp数组以及它们的字符串通常也会被放置在这个初始栈的底部。 - 堆(Heap):初始化堆区域,用于动态内存分配(如
malloc或new)。
- 文件描述符继承:通常,打开的文件描述符会从父进程继承到子进程。
- 寄存器初始化:CPU的通用寄存器、程序计数器(Program Counter, PC/RIP)、栈指针(Stack Pointer, SP/RSP)等都会被设置为一个初始状态。其中,程序计数器会被设置为可执行文件的入口点。
- 权限与安全:内核会根据可执行文件的权限、当前用户的权限以及其他安全策略来决定是否允许执行。
可执行文件格式与入口点
在Linux系统中,可执行文件通常采用ELF(Executable and Linkable Format)格式。ELF文件包含多个“段”(segments)和“节”(sections),它们描述了程序的代码、数据、符号表等信息。
ELF头(ELF Header)中有一个关键字段
e_entry,它指示了程序的入口点——即操作系统加载程序后,CPU将开始执行的第一条指令的虚拟地址。$ readelf -h /bin/ls ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: DYN (Shared object file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x41f0 Start of program headers: 64 (bytes into file) Start of section headers: 248488 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 11 Size of section headers: 64 (bytes) Number of section headers: 30 Section header string table index: 29从上面的
readelf -h输出中,我们可以看到Entry point address: 0x41f0。这个地址就是内核将控制权移交的第一个指令位置。对于动态链接的可执行文件,这个入口点通常不是main()函数,而是动态链接器(或其入口代码)。
第二章:动态链接器的舞台
现代操作系统中的大多数程序都不是完全独立的,它们依赖于共享库(Shared Libraries,在Linux中通常是 .so 文件,Windows中是 .dll 文件)。共享库包含可被多个程序共享的代码和数据,这节省了磁盘空间和内存,并方便了软件更新。
动态链接的必要性
- 减少可执行文件大小:程序不需要包含所有它依赖的库代码,只需包含指向这些库的引用。
- 节省内存:多个程序可以共享同一个库在内存中的一份拷贝。
- 方便更新和维护:库可以独立于使用它的程序进行更新,而无需重新编译所有依赖程序。
动态链接器(Dynamic Linker/Loader)
在Linux中,负责处理动态链接的程序是 ld.so(或 ld-linux.so)。当内核加载一个动态链接的可执行文件时,它不会直接将控制权交给程序的 e_entry 字段所指向的地址,而是先将控制权交给 ld.so。
为什么内核知道要先启动 ld.so?因为ELF可执行文件有一个特殊的“程序头”(Program Header)类型 PT_INTERP,它指定了解释器(即动态链接器)的路径。
$ readelf -l /bin/ls | grep "INTERP"
INTERP 0x0000000000000238 0x0000000000000238 0x0000000000000238 0x000021 0x000021 R 0x1
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
这里的输出明确告诉内核,这个程序需要 /lib64/ld-linux-x86-64.so.2 这个解释器来启动。
动态链接器的工作流程
- 自举(Self-Relocation):
ld.so本身也是一个共享库,它首先需要将自己加载到内存并完成自身的重定位。 - 加载依赖库:
ld.so读取主程序和已加载库的DT_NEEDED条目(在ELF的动态段中),这些条目列出了程序直接依赖的其他共享库。然后,它会在预定义的路径(如/lib,/usr/lib,以及LD_LIBRARY_PATH环境变量指定的路径,或ld.so.cache缓存)中搜索并加载这些共享库到进程的虚拟地址空间。 -
符号解析与重定位:这是动态链接器最复杂也是最关键的任务。
- 符号表(Symbol Table):每个可执行文件和共享库都有一个符号表,列出了它定义和引用的函数及变量。
- 重定位表(Relocation Table):包含了需要修改的地址列表,以便在运行时修正这些地址,使其指向正确的内存位置。
- 全局偏移表(Global Offset Table, GOT):GOT 是一个数据结构,用于存储程序中引用的外部函数的实际内存地址。当程序第一次调用一个外部函数时,GOT 中的对应条目会被动态链接器更新为该函数的实际地址。
- 过程链接表(Procedure Linkage Table, PLT):PLT 是一个辅助 GOT 的代码段。它包含了用于调用外部函数的短跳转指令序列。当程序调用一个外部函数时,它首先跳转到 PLT 中的一个条目,PLT 条目会间接地通过 GOT 跳转到实际的函数地址。这种机制允许延迟绑定(Lazy Binding),即只有当函数真正被调用时才进行符号解析和重定位,从而加快程序的启动速度。
ld.so会遍历所有已加载模块的重定位表,将它们内部的符号引用(如对printf函数的调用,或对某个全局变量的访问)解析为实际的内存地址,并更新 GOT 或直接修改代码中的地址。
动态链接器在初始化阶段还会做些什么?
在完成所有的库加载和符号解析后,动态链接器还会执行所有已加载共享库中的初始化函数(constructors)。这些函数通常用于设置库内部的状态,或者注册一些资源。它们在
main()函数被调用之前执行。在ELF文件中,这些初始化函数由DT_INIT或DT_INIT_ARRAY条目指定。$ ldd /bin/ls linux-vdso.so.1 (0x00007ffc64b6e000) libcap.so.2 => /lib64/libcap.so.2 (0x00007f35f377c000) libacl.so.1 => /lib64/libacl.so.1 (0x00007f35f376f000) libc.so.6 => /lib64/libc.so.6 (0x00007f35f359c000) /lib64/ld-linux-x86-64.so.2 (0x00007f35f378a000)ldd命令可以显示一个程序所依赖的所有共享库。可以看到/bin/ls依赖于libcap.so.2,libacl.so.1,libc.so.6等,以及最重要的/lib64/ld-linux-x86-64.so.2。一旦动态链接器完成了所有这些工作,它会将控制权移交给主程序的可执行入口点——通常是C运行时库(CRT)的
_start函数。
第三章:C/C++运行时环境的初始化
在动态链接器完成其使命之后,控制权最终落到了我们程序的主体部分。但即便此时,main() 函数也尚未被调用。在 main() 真正执行之前,C/C++运行时环境(C Runtime Library, CRT)需要进行一系列的初始化工作。
_start:程序的真正起点
可执行文件的 e_entry 字段所指向的地址,对于动态链接程序而言,最终会跳转到 libc(例如 glibc)提供的 _start 汇编函数。_start 是程序启动的第一个C/C++代码之外的入口点。它是一个用汇编语言编写的短函数,主要任务是设置好调用C语言函数所需的栈帧,并将 argc、argv、envp 等参数从内核提供的初始栈状态中提取出来,然后调用 __libc_start_main 函数。
在x86-64 Linux系统上,_start 的大致逻辑如下(简化):
# _start (part of crt1.o from glibc)
.globl _start
_start:
# 栈顶通常存放 argc, 接着是 argv 数组的指针,然后是 envp 数组的指针
# 参数在栈上的布局:
# ...
# envp[N]
# envp[N-1]
# ...
# envp[0]
# NULL (envp 结束标志)
# argv[argc-1]
# argv[argc-2]
# ...
# argv[0]
# NULL (argv 结束标志)
# argc
# ...
movq %rsp, %rdi # 将栈指针 rsp 传递给 _start 的第一个参数 (argc)
# 实际上,rsp 指向 argc 所在的位置
# 以下是调用 __libc_start_main 的参数准备
# __libc_start_main 的原型大致是:
# int __libc_start_main(
# int (*main)(int, char **, char **), // RDI: main 函数地址
# int argc, // RSI: argc
# char **argv, // RDX: argv
# void (*init)(void), // RCX: 初始函数指针 (用于旧式.init节)
# void (*fini)(void), // R8: 结束函数指针 (用于旧式.fini节)
# void (*rtld_fini)(void), // R9: 动态链接器结束函数
# void *stack_end // 栈上的指针,指向 environ 或其他
# );
# 从栈上获取 argc, argv, envp
# 对于 x86-64 ABI,函数的参数通过寄存器传递:RDI, RSI, RDX, RCX, R8, R9
# 假设此时栈顶是 argc,那么 argv 是 &argc + 8 (因为 argc 是 int,占4字节,但栈按8字节对齐)
# envp 紧随 argv 之后
# 获取 argc
movl (%rsp), %esi # 将栈顶的值(argc)移动到 RSI 寄存器
# 获取 argv
leaq 8(%rsp), %rdx # argv 紧跟在 argc 之后,地址是 %rsp + 8。
# 将 argv 的地址放入 RDX 寄存器
# 获取 envp
# envp 在 argv 数组之后,需要遍历 argv 找到其末尾的 NULL,然后 + 8 得到 envp 的起始地址
# 这部分逻辑通常在 __libc_start_main 内部完成,或者由 _start 传递一个指向 envp 附近栈地址的指针
# 简单起见,我们假设 _start 已经找到了 envp 并将其地址放入 RDX 之后的某个寄存器
# 实际上,_start 会做一些循环来找到 envp 的起始地址,然后将其作为参数传递
# 将 main 函数的地址作为第一个参数 (RDI) 传递
leaq main@PLT(%rip), %rdi # 取得 main 函数的地址 (使用PLT来处理可能存在的延迟绑定)
# 准备其他参数 (init, fini, rtld_fini, stack_end)
# 这些通常是编译器生成的一些特殊函数或NULL
movq __libc_csu_init@PLT(%rip), %rcx # 旧式 .init 节的入口点
movq __libc_csu_fini@PLT(%rip), %r8 # 旧式 .fini 节的入口点
movq _dl_fini@PLT(%rip), %r9 # 动态链接器的 fini 函数
# 最后一个参数,stack_end,通常是 %rsp
movq %rsp, 8(%rsp) # 将 %rsp 放入栈上备用,作为 __libc_start_main 的最后一个参数
# 调用 __libc_start_main
call __libc_start_main
# 如果 __libc_start_main 返回,这意味着出现了错误,通常会调用 _exit
hlt # 停止处理器,或跳转到错误处理
__libc_start_main:C运行时环境的核心
__libc_start_main 是C运行时库(如glibc)中的一个关键函数,它是整个C/C++运行时环境的协调者。它被 _start 调用,并负责执行一系列至关重要的初始化任务:
- 处理参数和环境变量:它解析并设置
argc、argv和environ全局变量,这些变量在C程序中随处可见。 - 线程本地存储(TLS)初始化:如果程序使用了线程本地存储(Thread-Local Storage),
__libc_start_main会负责初始化这些数据结构,确保每个线程拥有其独立的TLS变量副本。 - 标准I/O初始化:
stdin、stdout、stderr等标准I/O流会被初始化。 - 动态链接器初始化函数的调用:尽管动态链接器已经在早期阶段调用了一些初始化函数,
__libc_start_main可能会再次协调或确保所有必要的动态链接器相关的初始化函数被执行。 - C++全局/静态构造函数的调用:这是我们关注的重点之一。
__libc_start_main会遍历可执行文件和所有已加载共享库中注册的全局构造函数列表,并依次调用它们。 - 初始化
malloc等内存分配器:设置堆管理器和其他运行时库函数。 - 注册
atexit和on_exit函数:这些函数用于在程序正常退出时执行清理工作。__libc_start_main会注册它自己的退出处理函数,其中就包括调用全局析构函数。 - 调用
main()函数:在所有上述初始化工作完成后,__libc_start_main最终会调用用户的main()函数。 - 处理
main()返回值:当main()函数返回时,__libc_start_main会获取其返回值,并将其作为exit()系统调用的参数,从而终止进程。在调用exit()之前,它还会调用之前注册的atexit和全局析构函数。
静态数据初始化
除了执行代码,运行时环境还需要确保静态存储期(static storage duration)的变量被正确初始化。
.data段:已初始化的全局变量和静态变量,其值直接从可执行文件的.data段加载到内存。这部分工作由内核在execve期间完成。.bss段:未初始化的全局变量和静态变量。__libc_start_main或其内部调用的某个函数会负责将.bss段的内存区域清零。这是因为C/C++标准规定未显式初始化的静态变量应被初始化为零。
通过这张详细的流程图,我们可以看到 main() 仅仅是整个程序启动流程中的一个相对靠后的环节。
| 阶段 | 执行者 | 主要任务 |
|---|---|---|
| 进程创建 | 操作系统内核 | fork()/execve(),创建虚拟地址空间,加载ELF文件头,设置初始栈,将控制权交给动态链接器 |
| 动态链接 | 动态链接器 (ld.so) |
自举,加载依赖共享库,解析符号,重定位,执行所有已加载库的初始化函数(_init_array) |
| C/C++运行时初始化 | C运行时库 (glibc) |
_start 设置栈帧并调用 __libc_start_main。__libc_start_main 处理参数,初始化TLS,调用可执行文件自身的全局构造函数,初始化标准I/O, 最终调用 main()。 |
| 用户代码执行 | 用户程序 | main() 函数开始执行 |
第四章:全局构造函数与析构函数的执行机制
在C++中,全局对象和具有静态存储期(static 关键字修饰的局部变量或全局变量)的对象可以在 main() 函数被调用之前执行它们的构造函数。同样,在程序退出时,它们的析构函数也会被调用。这一机制对于设置程序运行环境、初始化日志系统、注册回调函数等场景至关重要。
为何需要全局构造函数?
C语言中,全局变量的初始化必须是常量表达式。但C++允许全局对象通过构造函数执行任意复杂的初始化逻辑,这可能包括内存分配、文件操作、网络连接等。这些操作必须在任何用户定义的函数(包括 main())开始执行之前完成。
ELF中的构造函数注册
现代ELF格式通过 init_array 和 fini_array 这两个特殊的段来管理全局构造函数和析构函数。
.init_array段:这是一个函数指针数组。编译器会将所有全局对象的构造函数以及用__attribute__((constructor))标记的函数地址放入这个数组。.fini_array段:类似地,这是一个函数指针数组,用于存放全局对象的析构函数和用__attribute__((destructor))标记的函数地址。
还有传统的 .init 和 .fini 段,它们分别包含一个单一的函数(而不是函数数组),用于早期版本的系统。现代系统更倾向于使用 .init_array 和 .fini_array。
执行顺序
全局构造函数的执行顺序是精心设计的:
- 动态链接器 (
ld.so) 调用共享库的构造函数:在将控制权移交给_start之前,ld.so会遍历所有已加载的共享库的.init_array段,并依次调用其中的函数。这意味着,所有依赖库的全局对象会在主程序自身的全局对象之前被构造。 - C运行时库 (
__libc_start_main) 调用主程序的构造函数:在__libc_start_main函数内部,它会遍历主可执行文件自身的.init_array段,并调用其中的函数。
这确保了一个合理的依赖关系:通常,主程序会依赖于共享库的功能,所以库的初始化应该先于主程序的初始化。
示例代码:观察全局构造函数
让我们通过一个C++例子来观察全局构造函数的执行:
#include <iostream>
#include <vector>
// 这是一个全局对象,其构造函数会在 main() 之前执行
class GlobalResource {
public:
GlobalResource() {
std::cout << "[GlobalResource] Constructor called. Initializing a shared resource." << std::endl;
// 模拟一些初始化操作
data.reserve(100);
for (int i = 0; i < 5; ++i) {
data.push_back(i * 10);
}
}
~GlobalResource() {
std::cout << "[GlobalResource] Destructor called. Cleaning up shared resource." << std::endl;
data.clear();
}
void printData() const {
std::cout << "[GlobalResource] Data: ";
for (int val : data) {
std::cout << val << " ";
}
std::cout << std::endl;
}
private:
std::vector<int> data;
};
// 全局对象实例
GlobalResource g_resource;
// 使用 GCC 扩展:__attribute__((constructor)) 和 __attribute__((destructor))
// 这些函数也会被放入 .init_array 和 .fini_array
__attribute__((constructor))
void my_constructor_function() {
std::cout << "[my_constructor_function] Custom constructor function called." << std::endl;
}
__attribute__((destructor))
void my_destructor_function() {
std::cout << "[my_destructor_function] Custom destructor function called." << std::endl;
}
// 一个在函数内部的静态局部对象,它的构造函数在第一次调用该函数时执行
// 但其指针会被注册到 .init_array,只是调用时会有检查
void lazy_init_function() {
static GlobalResource s_lazy_resource; // 静态局部对象
std::cout << "[lazy_init_function] Called." << std::endl;
s_lazy_resource.printData();
}
int main(int argc, char* argv[]) {
std::cout << "--- Entering main() ---" << std::endl;
g_resource.printData();
if (argc > 1 && argv[1][0] == 'l') {
lazy_init_function();
}
std::cout << "--- Exiting main() ---" << std::endl;
return 0;
}
编译并运行上述代码:
$ g++ -o global_ctors global_ctors.cpp
$ ./global_ctors
[GlobalResource] Constructor called. Initializing a shared resource.
[my_constructor_function] Custom constructor function called.
--- Entering main() ---
[GlobalResource] Data: 0 10 20 30 40
--- Exiting main() ---
[my_destructor_function] Custom destructor function called.
[GlobalResource] Destructor called. Cleaning up shared resource.
$ ./global_ctors l
[GlobalResource] Constructor called. Initializing a shared resource.
[my_constructor_function] Custom constructor function called.
--- Entering main() ---
[GlobalResource] Data: 0 10 20 30 40
[GlobalResource] Constructor called. Initializing a shared resource.
[lazy_init_function] Called.
[GlobalResource] Data: 0 10 20 30 40
--- Exiting main() ---
[my_destructor_function] Custom destructor function called.
[GlobalResource] Destructor called. Cleaning up shared resource.
[GlobalResource] Destructor called. Cleaning up shared resource.
从输出中我们可以清晰地看到:
GlobalResource的全局实例g_resource的构造函数在main()之前被调用。my_constructor_function也紧随其后被调用,同样在main()之前。- 当
main()退出时,my_destructor_function和g_resource的析构函数被调用。 - 静态局部对象
s_lazy_resource的构造函数只有在lazy_init_function()第一次被调用时才执行,但其析构函数仍然在程序退出时执行。
如何查看 .init_array 段?
我们可以使用 objdump 或 readelf 命令来查看可执行文件中的 .init_array 段。
$ objdump -s -j .init_array global_ctors
global_ctors: file format elf64-x86-64
Contents of section .init_array:
4010a0 00000000 00000000 e0140000 00000000 ................
4010b0 e0140000 00000000 50120000 00000000 ........P.......
这里的地址 0x4010a0 和 0x4010b0 处存储的就是函数指针。
如果配合 readelf -r global_ctors 或 readelf -s global_ctors,我们可以找到这些地址对应的符号,从而确认哪些函数被注册为构造函数。
例如,通过 readelf -s global_ctors | grep my_constructor_function 可以找到 my_constructor_function 的地址,然后与 objdump 输出比对。
$ readelf -s global_ctors | grep "my_constructor_function"
20: 00000000004012e0 119 FUNC GLOBAL DEFAULT 13 my_constructor_function
可以看到 my_constructor_function 的地址是 0x4012e0。
objdump 输出的 e0140000 00000000 反过来是 0x00000000000014e0,这与 0x4012e0 并不完全匹配。这是因为 objdump -s 显示的是原始的字节序列,需要考虑小端序以及地址的基准。在实际解析时,动态链接器会处理这些地址。e0140000 是低32位,00000000 是高32位,组合起来是 0x00000000000014e0。如果可执行文件加载的基址是 0x400000,那么实际地址就是 0x400000 + 0x14e0 = 0x4014e0。这里需要注意的是,objdump 显示的地址是文件内的偏移,而不是最终的虚拟地址。readelf -s 显示的地址是相对程序基址的偏移(对于PIE可执行文件)。
全局析构函数的执行
全局析构函数(以及 .fini_array 中的函数)的执行是由 __libc_start_main 注册的一个 atexit 函数来负责的。当 main() 函数返回或 exit() 被调用时,这个 atexit 函数会被执行,它会遍历 .fini_array 段,并依次调用其中的函数,以确保所有全局对象都被正确地销毁。
第五章:进入用户代码:main() 的调用
经过漫长而精密的初始化过程,系统终于准备就绪,可以安全地将控制权移交给用户编写的 main() 函数。
main() 函数的签名
main() 函数的典型签名如下:
int main(int argc, char *argv[])
或
int main(int argc, char *argv[], char *envp[])
argc(argument count):整数,表示命令行参数的数量。argv(argument vector):一个指向字符串数组的指针,每个字符串代表一个命令行参数。argv[0]通常是程序的名称。envp(environment pointer):一个指向字符串数组的指针,每个字符串代表一个环境变量(例如PATH,HOME)。
这些参数都是由 __libc_start_main 从程序启动时内核在栈上放置的数据中解析出来的,并作为参数传递给 main()。
栈帧的建立
当 __libc_start_main 调用 main() 时,会为 main() 函数在栈上建立一个新的栈帧。这个栈帧包含了 main() 的局部变量、参数 argc、argv(以及 envp)的存储位置,以及返回地址(即 __libc_start_main 中调用 main() 之后的指令地址)。
main() 返回之后
当 main() 函数执行完毕并返回一个整数值时,控制权会回到 __libc_start_main。__libc_start_main 会获取 main() 的返回值,并将其作为 exit() 系统调用的参数。
exit() 系统调用会执行以下操作:
- 调用所有通过
atexit()注册的函数。这其中包括C运行时库注册的用于调用全局析构函数和.fini_array中函数的处理程序。 - 刷新所有打开的I/O流。
- 关闭所有文件描述符。
- 释放程序占用的所有资源。
- 最终,通过内核将
main()的返回值(即程序的退出状态码)传递给父进程(通常是 shell),并终止当前进程。
第六章:一个简化流程总览
我们已经深入探讨了从操作系统启动到 main() 执行的整个复杂过程。现在,让我们用一个简化的流程图来回顾一下这个旅程:
| 阶段 | 关键参与者 | 主要操作 |
|---|---|---|
| 1. 程序启动请求 | 用户/父进程 | 用户在Shell中输入命令,Shell调用 fork() 创建子进程。 |
| 2. 替换进程映像 | 操作系统内核 | 子进程调用 execve()。内核清空子进程地址空间,加载ELF文件,设置 argc/argv/envp 到栈,将程序计数器指向动态链接器 (ld.so) 的入口。 |
| 3. 动态链接 | ld.so |
自举:加载自身并完成重定位。 加载共享库:解析ELF的 PT_INTERP 和 DT_NEEDED,找到并加载所有依赖的共享库。符号解析:通过GOT/PLT解析所有外部符号地址。 库初始化:调用所有已加载共享库的 .init_array 中的构造函数。 |
| 4. C运行时初始化 | glibc (_start) |
入口跳转:ld.so 将控制权交给主程序的 _start 汇编函数。栈帧准备: _start 设置基本的栈帧,提取 argc/argv/envp,然后调用 __libc_start_main。 |
| 5. C运行时核心 | glibc (__libc_start_main) |
环境设置:初始化 malloc,设置线程本地存储(TLS),初始化标准I/O。全局构造函数:调用主程序自身的 .init_array 中的所有全局构造函数。注册退出函数:注册 atexit 函数,包括用于调用全局析构函数的处理程序。调用 main():将控制权移交给用户编写的 main() 函数。 |
| 6. 用户代码执行 | 用户程序 | main() 函数开始执行,完成程序的主要逻辑。 |
| 7. 程序退出 | glibc (exit()) |
main() 返回或调用 exit() 后,控制权回到 __libc_start_main。全局析构函数:调用 .fini_array 中的所有全局析构函数。资源清理:刷新I/O,关闭文件,释放内存。 进程终止:内核最终终止进程,并将退出状态码传递给父进程。 |
实践与调试
理解这些底层机制,最佳方式就是亲自动手实践和调试。
-
readelf工具:readelf -h <executable>:查看ELF头,特别是Entry point address。readelf -l <executable>:查看程序头,特别是PT_INTERP条目,确认动态链接器路径。readelf -S <executable>:查看所有节(sections),找到.init_array和.fini_array。readelf -s <executable>:查看符号表,可以找到_start、main、__libc_start_main等函数的地址。readelf -d <executable>:查看动态段,了解程序依赖的库和动态链接相关信息。
-
objdump工具:objdump -d <executable>:反汇编整个可执行文件。你可以找到_start函数的汇编代码,观察它如何准备参数并调用__libc_start_main。objdump -d -start=_start -stop=__libc_start_main <executable>:可以限定反汇编的范围。objdump -s -j .init_array <executable>:查看.init_array段的原始字节内容,从中解析函数指针。
-
ldd工具:ldd <executable>:列出程序直接和间接依赖的所有共享库。
-
gdb调试器:- 在
gdb中,你可以设置断点来观察执行流程:break _start:在程序的最早C/C++入口点暂停。break __libc_start_main:在C运行时初始化函数处暂停。break main:在用户main()函数的入口处暂停。break my_constructor_function(或全局对象的构造函数):观察全局构造函数的执行。
starti(start and step instruction):开始调试并在第一条指令处暂停。stepi(step instruction):逐指令执行。info registers:查看寄存器状态。x/Nx $rsp:查看栈内容,观察argc、argv、envp的布局。
- 在
-
GCC 编译选项:
-static:静态链接。这将把所有依赖的库(包括glibc)都编译进可执行文件,从而跳过动态链接器的阶段。此时e_entry将直接指向_start。-nostartfiles:不链接标准启动文件(如crt1.o,它包含了_start)。这将导致程序没有_start,无法正常启动。-nodefaultlibs:不链接标准库(如libc.so)。
通过这些工具和实践,你将能够亲眼见证并深入理解我们今天探讨的所有过程,从操作系统的宏观调度到微观的指令执行,一切都将变得更加清晰。
这个从操作系统创建进程,经由动态链接器,再到C/C++运行时环境,最终抵达 main() 函数的旅程,是一个充满精巧设计和协同工作的过程。它揭示了现代计算机系统为了高效、灵活地运行程序所付出的巨大努力。理解这些底层机制,不仅能帮助我们写出更健壮、更高性能的代码,更能加深我们对整个计算生态的认知和敬畏。