14、GCC工具链的启动流程:crt0, crti, crtn, crtbegin, crtend
说到嵌入式C语言的启动流程,很多人第一反应就是那个叫 startup.s 的汇编文件。但如果你用的是GCC工具链,事情其实要复杂一些——也更有意思。
GCC的启动流程里,有几个特殊的文件:crt0、crti、crtn、crtbegin、crtend。它们名字长得像,但分工完全不同。我刚开始接触这些时,也被绕晕过。今天咱们就把它们一个个拆开看。
一句话总结:这些文件共同构成了C运行时环境的初始化与清理框架。它们不是用户写的,而是由链接器自动插入到可执行文件中的。
14.1 启动流程的整体骨架
先看一张图,把整体结构理清楚。
这张图展示的是 .init 段的拼接逻辑。说白了,链接器把 crti.o、crtbegin.o、crtn.o 按顺序拼在一起,形成一个完整的 _init 函数。类似地,.fini 段也由 crti、crtend、crtn 拼接而成。
14.2 crt0 和 crt1:真正的入口
crt0 是 C Runtime 的缩写,数字 0 表示「最原始版本」。它通常由汇编语言编写,完成以下工作:
- 设置栈指针(SP)
- 清零 BSS 段
- 复制数据段(如果需要)
- 调用
_init(全局构造函数) - 调用
main - 调用
exit或_fini(全局析构函数)
后来有了 crt1,它比 crt0 多了一些东西——比如处理 .init 和 .fini 段的调用。我个人习惯在裸机项目里直接用 crt0,因为更可控。
小提示:在 Linux 环境下,你通常看到的是 crt1.o 而不是 crt0.o。这是因为 glibc 需要额外的初始化步骤。但在嵌入式裸机中,crt0 仍然是主流。
14.3 crti 和 crtn:.init 段的「括号」
这两个文件很有意思。它们不包含完整的函数,而是提供函数的「前半段」和「后半段」。
举个例子,crti.o 里可能只有这么几行:
/* crti.s 示例 */
.section .init
.globl _init
_init:
push %ebp
mov %esp, %ebp
/* 这里留空,等待链接器插入其他代码 */
而 crtn.o 里则是:
/* crtn.s 示例 */
.section .init
pop %ebp
ret
链接器把 crti、crtbegin、crtn 按顺序拼起来,就形成了一个完整的 _init 函数。你想想看,这就像把一本书的封面、正文和封底分别印刷,最后装订在一起。
注意:千万不要自己写 _init 函数!如果你在代码里定义了一个叫 _init 的函数,它会和 crti/crtn 里的内容冲突。我曾经踩过这个坑,调试了一整天才发现是符号重复定义。
14.4 crtbegin 和 crtend:构造与析构的调度
crtbegin.o 和 crtend.o 是由 GCC 编译器生成的,不是手写的。它们负责管理全局对象的构造和析构。
具体来说:
- crtbegin.o:包含
__do_global_ctors函数,遍历.ctors段,调用所有全局构造函数。 - crtend.o:包含
__do_global_dtors函数,遍历.dtors段,调用所有全局析构函数。
在 C++ 项目中,这两个文件至关重要。没有它们,全局对象的构造函数永远不会被调用。
/* crtbegin 的伪代码逻辑 */
void __do_global_ctors(void) {
/* 从 .ctors 段末尾开始,向前遍历 */
func_ptr *p = __CTOR_END__;
while (p > __CTOR_LIST__) {
p--;
(*p)(); /* 调用构造函数 */
}
}
14.5 链接顺序的秘密
这些文件在链接时的顺序是固定的。我见过不少新手把链接顺序搞反,结果程序跑飞了。正确的顺序是:
| 顺序 | 文件 | 作用 |
|---|---|---|
| 1 | crt0.o 或 crt1.o | 入口点,硬件初始化 |
| 2 | crti.o | .init 段开头 |
| 3 | crtbegin.o | 全局构造函数 |
| 4 | 你的目标文件 (.o) | 用户代码 |
| 5 | 库文件 (-lc, -lgcc 等) | 标准库和运行时库 |
| 6 | crtend.o | 全局析构函数 |
| 7 | crtn.o | .init 和 .fini 段结尾 |
为什么顺序这么重要?因为链接器在处理 .init 段时,是按照输入文件的顺序拼接的。如果 crtbegin.o 放在 crti.o 前面,那 _init 函数的 prologue(前导代码)就没了,整个函数结构就乱了。
14.6 一个完整的启动流程示例
假设我们有一个简单的 ARM Cortex-M 项目,链接脚本里指定了这些 crt 文件。启动流程大致如下:
- CPU 复位后,从
_start开始执行(在 crt0 中) - 设置栈指针 SP
- 清零 BSS 段
- 调用
_init(由 crti + crtbegin + crtn 拼接而成) - 在
_init内部,__do_global_ctors被调用,执行所有全局构造函数 - 调用
main main返回后,调用_fini,执行全局析构函数- 进入死循环或关机
关键点:如果你在嵌入式项目中不使用这些 crt 文件,那么全局对象的构造函数永远不会被执行。C++ 的全局对象会处于未初始化状态,这会导致非常隐蔽的 bug。
14.7 避坑指南
我这些年做嵌入式项目,遇到过几个和 crt 相关的典型问题:
- 链接错误:undefined reference to `_init' —— 通常是因为没有链接 crti.o 和 crtn.o。检查你的链接脚本和 Makefile。
- 程序在 _init 中死循环 —— 可能是 .init 段被意外修改了。我曾经在链接脚本里把 .init 段放到了错误的地址,导致函数指针指向了垃圾数据。
- 全局对象没有被构造 —— 确认是否链接了 crtbegin.o。在裸机项目中,很多人会忘记加这个文件。
我的建议:如果你刚开始做嵌入式 Linux 或裸机开发,先用 gcc -v 看看你的工具链默认链接了哪些 crt 文件。然后手动写一个最小的启动代码,把每个 crt 文件的作用验证一遍。这样印象会深很多。
嗯,关于 GCC 启动流程中的这几个 crt 文件,核心内容就是这些了。它们虽然名字相似,但分工明确,共同构成了 C/C++ 运行时环境的初始化骨架。理解了这个流程,你就能更好地掌控程序的「从零到一」。