main 函数开始前的那些事儿

main 函数开始前的那些事儿

十二月 15, 2025 次阅读

在 C/C++ 程序中,main 函数是程序的入口点。然而,在 main 函数开始执行之前,实际上发生了许多重要的初始化工作。本文将探讨这些初始化过程以及它们对程序运行的影响。

首先,我们要记住,main 函数不是程序的入口点,而是程序的起点。操作系统在加载程序时,会先执行一些底层的初始化代码,这些代码通常由编译器和运行时库提供。

shell 执行程序替换

不论我们执行什么程序,都是通过 shell 来完成的,因此,当我们执行某个可执行程序时,本质上是通过 shellexec 系列函数来实现的。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 是辅助向量,包含了一些与程序运行相关的信息。这里我们可以看到一处细节,那就是 argvenvp 数组的结尾都有一个 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 函数,传递 argcargvenvp 参数:

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/>通知父进程