18、C运行时启动:_start入口,crt0/crt1,main函数调用链

你有没有想过一个问题:我们写的 main() 函数,真的是程序执行的第一个函数吗?

说实话,我早年做嵌入式开发时也这么以为。直到有一次调试一个裸机程序,发现 main() 还没跑,程序就已经在某个神秘地方崩了。那时候我才意识到——在 main() 之前,还有一大摊子事要干。

这摊子事,就是 C 运行时启动。今天我们就把它彻底拆开看看。

程序入口:_start 到底是谁?

你链接一个 C 程序时,链接脚本里通常会指定一个入口点。这个入口点,默认叫 _start

它不是 C 函数,是汇编写的。为什么?因为这时候 C 运行时还没准备好——栈指针没设、BSS 段没清零、全局变量没初始化。你连个函数调用都做不了。

我习惯把 _start 理解为「程序的真正第一口气」。它要做的事,说白了就三件:

  • 设置好硬件环境(栈指针、异常向量等)
  • 准备好 C 运行时(清零 BSS、初始化数据段)
  • 调用 main(),然后把返回值交给 exit()

来看一个典型的 _start 汇编片段(x86-64 Linux 风格):

# _start 入口
.section .text
.globl _start
_start:
    # 清空 ebp(帧指针)
    xorl    %ebp, %ebp

    # 把 argc 和 argv 压栈(由内核提供)
    movl    (%rsp), %edi       # argc
    lea     8(%rsp), %rsi      # argv

    # 调用 __libc_start_main
    call    __libc_start_main

    # 正常情况下不会走到这里
    hlt

嗯,这里有个细节:_start 并不直接调用 main(),而是调用 __libc_start_main。为什么绕一圈?后面会讲。

crt0 / crt1:谁在背后帮你擦屁股?

你每次编译 C 程序时,链接器都会自动塞进来几个目标文件。它们通常叫 crt0.ocrt1.ocrti.ocrtn.o 之类的。

这些文件,就是 C 运行时启动的核心组件。我当年第一次看到 crt0 这个名字时,还以为是「C Runtime Zero」的缩写。后来发现,确实就是这么回事。

它们各自负责什么?我整理了一张表:

文件名 作用
crt0.o 最原始的启动代码,包含 _start,适用于裸机或无 libc 环境
crt1.o 现代 Linux 下常用,包含 _start,会调用 __libc_start_main
crti.o 提供 .init 和 .fini 段的函数头(_init / _fini)
crtn.o 提供 .init 和 .fini 段的函数尾
crtbegin.o / crtend.o C++ 全局构造/析构相关,gcc 特有

说白了,crt0 是最精简的版本,适合嵌入式。而 crt1 是带 libc 支持的版本,适合 Linux 桌面/服务器开发。

一个小技巧: 你可以用 gcc -v 查看链接时到底塞了哪些 crt 文件。我经常用这招来确认当前编译器的启动流程。

main 函数调用链:从 _start 到 main

好,现在我们把整个链条串起来。从 _startmain(),中间到底经历了什么?

我画了一张流程图,帮你理清这个调用链:

_start(汇编入口) 设置栈指针、清零 BSS 调用 __libc_start_main libc 初始化(堆、线程、信号等) main(argc, argv) crt0 / crt1 完成 crt1 调用 libc 入口 __libc_start_main 内部

你看,_start 做完最基础的硬件设置后,就把接力棒交给了 __libc_start_main。这个函数是 libc 提供的,它负责:

  • 初始化堆管理器(malloc 才能用)
  • 注册 atexit() 回调
  • 初始化线程本地存储(TLS)
  • 调用全局构造函数(C++ 的全局对象)
  • 最后才调用你的 main()

main() 返回后,__libc_start_main 还会调用 exit(),把返回值传给操作系统,并执行所有注册的退出函数。

核心要点: main() 不是程序的起点,也不是终点。它只是 C 运行时启动链上的一个普通环节。真正的起点是 _start,真正的终点是 exit()。

避坑指南:我踩过的几个坑

讲几个我实际项目中遇到的坑,希望能帮你少走弯路。

坑一:裸机程序忘记初始化 BSS

我曾经写过一个 STM32 的裸机程序,全局变量默认值是 0,但实际跑起来却是随机值。查了半天,发现是启动代码里忘了清零 BSS 段。嗯,从那以后我每次写链接脚本,都会反复确认 BSS 的处理。

坑二:crt0 和 crt1 混用

有次我把一个 Linux 下的 crt1.o 链接到了嵌入式项目里,结果程序一启动就崩。原因是 crt1 依赖 libc 的初始化,而裸机环境根本没有 libc。说白了,选错 crt 文件,等于让一个婴儿去跑马拉松。

坑三:全局构造函数顺序依赖

C++ 项目中,全局对象的构造顺序在不同编译单元之间是未定义的。我遇到过两个全局对象互相依赖,结果一个构造时另一个还没构造完,直接段错误。解决方案?要么用 std::call_once,要么老老实实重新设计依赖关系。

警告: 不要试图在 main() 之前做太多复杂操作。全局构造函数里调用 malloc?可以,但前提是堆管理器已经初始化好了。而堆管理器的初始化时机,取决于 libc 的实现。不同平台可能不一样。

总结一下

C 运行时启动,说白了就是一段「帮 main() 擦屁股」的代码。它从 _start 开始,经过 crt 文件的初始化,最后才轮到你的 main()

我个人习惯在调试启动问题时,先用 readelf -h 看入口地址,再用 objdump -d 反汇编 _start,一步步跟下去。这个方法虽然原始,但非常有效。

你想想看,理解了这一层,以后遇到「程序还没进 main 就崩了」的问题,是不是心里就有底了?

公众号:蓝海资料掘金营,微信 deep3321