main 函数开始前的那些事儿
在 C/C++ 程序中,main 函数是程序的入口点。然而,在 main 函数开始执行之前,实际上发生了许多重要的初始化工作。本文将探讨这些初始化过程以及它们对程序运行的影响。
首先,我们要记住,main 函数不是程序的入口点,而是程序的起点。操作系统在加载程序时,会先执行一些底层的初始化代码,这些代码通常由编译器和运行时库提供。
shell 执行程序替换
不论我们执行什么程序,都是通过 shell 来完成的,因此,当我们执行某个可执行程序时,本质上是通过 shell 的 exec 系列函数来实现的。exec 函数族会替换当前进程的映像为指定的可执行文件,并传递参数和环境变量。这里我们打个比方,我们运行如下程序:
#include <iostream>
int global_val = 1;
int main() {
std::cout << "hello world" << std::endl;
return 0;
}
shell 会调用系统调用:
execve("./hello", argv, envp);
进程替换后,旧进程的用户态代码、堆、栈全部都会被替换为新程序的内容,唯一不变的是进程的 PID。不熟悉进程替换的可以关注:进程替换详解
内核装载 ELF
什么是 ELF?
ELF(Executable and Linkable Format)是 Linux/Unix 下“程序和库的标准结构说明书”,它告诉链接器和内核三件最重要的事情:
- 代码和数据在文件中的位置
- 程序的入口点
- 运行时还需要哪些共享库(所谓共享库指的就是动态链接库)
在汇编阶段,汇编代码会被转换为机器指令,而此时就已然生成了ELF 文件,但此时的 ELF 文件还不是最终的可执行文件,我们称之为:ELF relocatable file,即可重定位文件。
- 什么是可重定位文件?
- 可重定位文件是指那些不能独立运行的文件,它们需要被链接器处理,链接器会将这些文件中的代码和数据段合并,并解决符号引用,最终生成一个可执行文件或共享库。
- 这么说可能还是太书面化了,我们可以这么理解:可重定位指的就是各个部分知道了各自的内部布局,但还不知道整体的布局。而我们的链接阶段之所以被称为链接,正是因为它将各个部分链接在一起,形成了整体的布局。
因此,承接上文,在链接阶段,链接器会将各个可重定位文件链接在一起,生成最终的可执行文件,我们称之为:ELF executable file,即可执行文件。
我们查看一下上面程序生成的可执行文件的 ELF 头信息:
╭─ljx@VM-16-15-debian ~/linux_review/main
╰─➤ readelf -h hello 1 ↵
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 (Position-Independent Executable file)
Machine: Advanced Micro Devices X86-64
Version: 0x1
Entry point address: 0x1080
Start of program headers: 64 (bytes into file)
Start of section headers: 14608 (bytes into file)
Flags: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 13
Size of section headers: 64 (bytes)
Number of section headers: 31
Section header string table index: 30
可以看到:Entry point address:程序的入口点地址为 0x1080,这意味着当内核加载这个可执行文件时,会从这个地址开始执行代码。当然,这个入口点地址是相对于加载地址的偏移。不到运行时我们永远都无法知道这个地址的真实值。这是因为现代操作系统通常会启用地址空间布局随机化(ASLR),以提高安全性。除此之外,如果我们将地址设置为绝对值,运行阶段加载动态库就会出现问题,因为动态库的加载地址是不可预测的。
这个地址不是 main 函数的地址,而是一个由编译器和运行时库生成的启动代码的地址,我们称之为 _start,这段代码负责进行必要的初始化工作,然后才会调用 main 函数。
在这个阶段,内核做了如下关键工作:
1.映射内存
内核会根据 ELF 文件中的程序头表(Program Header Table)将代码段、数据段、堆栈等映射到进程的虚拟地址空间中。包括将动态链接器 ld-linux.so 映射到内存中,以便后续加载共享库。
2.设置栈初始内容
栈顶长这样:(由高到低–压栈)
|----------------|
| argc |
| argv[0] |
| argv[1] |
| ... |
| NULL |
| envp[0] |
| envp[1] |
| ... |
| NULL |
| AUXV | ← AT_PHDR / AT_ENTRY / AT_RANDOM 等
|----------------|
这里 argc 是命令行参数的个数,argv 是参数数组,envp 是环境变量数组,AUXV 是辅助向量,包含了一些与程序运行相关的信息。这里我们可以看到一处细节,那就是 argv 和 envp 数组的结尾都有一个 NULL 指针,表示数组的结束。我们之前在给 main 函数传递参数时传递到最后需要一个 NULL 指针,在这里就体现出来了。
CPU 跳到 _start 执行
上面所做的一起都是为 CPU 进入 _start 函数做准备工作,接下来,CPU 会跳转到 _start 函数的地址开始执行。
什么是 _start?
_start 是程序的真正入口点,其由 链接器 指定,如上面所说,该地址是相对于加载地址的偏移。_start 函数的主要职责是进行必要的初始化工作,其被定义在 C 运行时库中,通常位于 crt0.o 文件中。我们通过如下指令查看:
╭─ljx@VM-16-15-debian ~/linux_review/main
╰─➤ objdump -d hello | grep _start
1004: 48 8b 05 c5 2f 00 00 mov 0x2fc5(%rip),%rax # 3fd0 <__gmon_start__@Base>
0000000000001080 <_start>:
109b: ff 15 1f 2f 00 00 call *0x2f1f(%rip) # 3fc0 <__libc_start_main@GLIBC_2.34>
可以看到,_start 函数调用了 __libc_start_main 函数,这是 C 运行时库中的一个重要函数,负责完成更多的初始化工作,然后才会调用 main 函数。
_start 是汇编,不是 C/C++
在 x86-64 架构下,_start 函数通常是用汇编语言编写的,因为它需要直接与硬件交互,进行低级别的初始化工作。下面是一个典型的 _start 函数的汇编实现示例:
_start:
xor %rbp, %rbp
mov %rsp, %rdi # 把栈指针传给 libc
call __libc_start_main
hlt
这个示例展示了 _start 函数的基本结构。它首先清除基指针寄存器 %rbp,然后将栈指针 %rsp 的值传递给 __libc_start_main 函数,最后调用该函数。
__libc_start_main 做了什么
这是整个 C/C++ 程序初始化过程中最重要的一个函数。它负责完成以下关键任务:
int __libc_start_main(
int (*main)(int,char**,char**),
int argc,
char **argv,
void (*init)(),
void (*fini)(),
void (*rtld_fini)(),
void *stack_end
);
先做些什么?
1.初始化 libc
- TLS(线程局部存储)初始化
- errno 初始化
- malloc 初始化
- locale(本地化)初始化
2.调用全局构造函数
- .init 函数指针参数指向的函数会被调用,这个函数负责调用所有全局对象的构造函数,确保在进入
main函数之前,所有全局对象都已正确初始化。 - .init_array(C++ 全局对象构造)
3.注册析构函数
- atexit 函数会注册所有全局对象的析构函数,这些析构函数会在程序退出时被调用,确保资源的正确释放。
- .fini_array(C++ 全局对象析构)
终于等到 main 函数
此时,一切就绪,整个世界都准备好了,__libc_start_main 函数最后会调用 main 函数,传递 argc、argv 和 envp 参数:
exit(main(argc, argv, envp));
main 会被当做成一个普通的函数来调用,main 函数执行完毕后,返回值会被传递给 exit 函数,进行程序的退出处理。
从而我们在外面通过 echo $? 可以获取到 main 函数的返回值。
这样一来,所有的知识点是不是就全部都串起来了?
main 返回后发生了什么?
exit() 做了什么?
这里我们也就知道了 exit() 做了什么:
- 调用
atexit注册的所有析构函数,释放资源 - 调用
.fini_array中的函数,进行更多的清理工作(如 C++ 全局对象的析构) - 刷新
stdio buffer,确保所有输出都被写入
_exit() – 最终系统调用做了什么
exit() 函数最终会调用 _exit() 系统调用,告诉内核当前进程已经结束,内核会进行以下工作:
- 释放进程占用的所有资源,包括内存、文件描述符等
- 将进程状态设置为“已终止”,以便父进程可以通过
wait()系列函数获取子进程的退出状态 - 通知父进程当前进程已经结束
这样一来,我们就能够理解整个程序从启动到结束的完整过程了。下面是一个时序图,帮助大家更好地理解:
sequenceDiagram
participant Shell
participant Kernel
participant "ELF Loader"
participant "_start (汇编)"
participant "__libc_start_main"
participant "main 函数"
participant "exit()"
participant "_exit() 系统调用"
Note over Shell: 用户执行程序命令
Shell->>Kernel: 调用 execve() 系统调用
Kernel->>"ELF Loader": 解析 ELF 文件
"ELF Loader"->>Kernel: 映射内存(代码段/数据段/堆栈)
Kernel->>"ELF Loader": 设置栈初始内容(argc/argv/envp/AUXV)
"ELF Loader"->>"_start (汇编)": 跳转到程序入口点
"_start (汇编)"->>"__libc_start_main": 调用初始化函数
Note over "__libc_start_main": 初始化 libc<br/>- TLS 初始化<br/>- malloc 初始化等
Note over "__libc_start_main": 调用全局构造函数<br/>(.init/.init_array)
Note over "__libc_start_main": 注册全局析构函数<br/>(atexit/.fini_array)
"__libc_start_main"->>"main 函数": 调用 main(argc, argv, envp)
"main 函数"->>"exit()": 执行完毕返回状态码
Note over "exit()": 调用 atexit 注册的析构函数<br/>刷新 stdio 缓冲区
"exit()"->>"_exit() 系统调用": 触发最终退出
"_exit() 系统调用"->>Kernel: 通知进程终止
Note over Kernel: 释放进程资源<br/>设置终止状态<br/>通知父进程