10、C库的初始化:_main与__libc_init_array的作用
好,咱们接着聊启动流程。前面几章我们把硬件初始化、数据段搬运、BSS段清零都讲完了。这时候,芯片已经能正常访问RAM了,全局变量也有了初始值。但还差一步——C库的初始化。
说白了,这时候你的C语言环境还是个“半成品”。像malloc、printf这些函数,背后依赖一堆内部数据结构。你不初始化它们,一调用就崩。我早年做STM32项目时就吃过这个亏——printf死活不出东西,查了半天,原来是_newlib_reent结构体没初始化。
10.1 为什么需要C库初始化?
你想想看,C标准库不是凭空就能用的。它内部维护了很多全局状态:
- 堆管理器:malloc/free需要知道堆的起始地址和大小
- 线程本地存储:_impure_ptr指向当前线程的reent结构体
- 文件系统:stdin/stdout/stderr的缓冲区
- 信号处理:默认信号处理函数表
- locale设置:字符集、数字格式等
这些玩意儿,必须在main函数被调用之前准备好。否则你连个printf都跑不起来。
核心原则:C库初始化是连接硬件初始化和应用程序之间的桥梁。它把裸机环境“包装”成符合C标准要求的运行时环境。
10.2 _main函数:入口背后的入口
很多开发者以为reset_handler直接跳转到main()。其实不是。真正的流程是:
reset_handler → _start → _main → __libc_init_array → main()
这个_main函数,是编译器提供的内部函数。它负责三件事:
- 调用__libc_init_array:初始化C库
- 调用硬件初始化函数:比如SystemInit()
- 调用main():最终跳转到用户程序
我习惯把_main理解成“启动调度员”。它自己不干具体活,但确保所有准备工作按顺序完成。
来看一个典型的_main实现(简化版):
void _main(void)
{
// 1. 初始化C库
__libc_init_array();
// 2. 调用硬件初始化(如果有)
if (__hardware_init_hook)
__hardware_init_hook();
// 3. 调用用户main
main();
// 4. main返回后进入死循环
while(1);
}
小技巧:有些链接脚本允许你通过弱符号覆盖_main。如果你需要插入自定义初始化逻辑,可以自己实现一个_main,但记得保留对__libc_init_array的调用。
10.3 __libc_init_array:C库的“构造函数”
这个函数名字有点怪,但它的作用很直接——遍历并调用所有“初始化函数数组”。
在GCC工具链中,链接器会收集两类函数指针:
| 段名 | 内容 | 调用时机 |
|---|---|---|
| .init_array | 全局C++构造函数、__attribute__((constructor))函数 | main之前 |
| .fini_array | 全局C++析构函数、__attribute__((destructor))函数 | main返回后 |
| .preinit_array | 早期初始化函数(极少用) | 比.init_array更早 |
__libc_init_array的伪代码大概长这样:
void __libc_init_array(void)
{
// 获取.init_array的起始和结束地址
// 这些地址由链接脚本提供
extern void (*__init_array_start)(void);
extern void (*__init_array_end)(void);
size_t count = &__init_array_end - &__init_array_start;
// 按顺序调用每个初始化函数
for (size_t i = 0; i < count; i++) {
__init_array_start[i]();
}
}
嗯,这里要注意:调用顺序是正向的。也就是说,你在代码里先写的constructor函数,会先被调用。而.fini_array是反向调用的,先注册的后析构。
10.4 一个实际例子:newlib的初始化
我用newlib比较多,就拿它举例。newlib的初始化主要做这几件事:
// newlib内部初始化函数
void __libc_init_array(void)
{
// 1. 初始化impure_data
_REENT_INIT_PTR(&impure_data);
_impure_ptr = &impure_data;
// 2. 调用.init_array中的函数
// ...(遍历调用)
// 3. 初始化堆
__malloc_lock_init();
}
其中_impure_ptr是个关键角色。它指向一个struct _reent结构体,里面存着每个线程的errno、标准流、malloc锁等。在单线程环境下,它指向全局的impure_data。
我曾经踩过的坑:在FreeRTOS任务里直接调用printf,结果程序卡死。后来发现是因为每个任务没有独立的_impure_ptr。解决办法是在任务切换时更新_impure_ptr,或者使用newlib的线程安全版本(--enable-newlib-reent-small)。
10.5 链接脚本中的关键符号
__libc_init_array能正常工作,离不开链接脚本提供的符号。你需要在链接脚本里定义:
SECTIONS
{
.init_array :
{
__init_array_start = .;
KEEP (*(SORT_BY_INIT_PRIORITY(.init_array.*)))
KEEP (*(.init_array))
__init_array_end = .;
} > FLASH
.fini_array :
{
__fini_array_start = .;
KEEP (*(SORT_BY_INIT_PRIORITY(.fini_array.*)))
KEEP (*(.fini_array))
__fini_array_end = .;
} > FLASH
}
注意那个SORT_BY_INIT_PRIORITY。GCC允许你给构造函数指定优先级:
void __attribute__((constructor(101))) my_early_init(void);
void __attribute__((constructor(200))) my_late_init(void);
优先级数字越小,越早被调用。这个机制在驱动初始化时特别有用——你可以控制外设的初始化顺序。
10.6 流程图:从复位到main的完整路径
下面这张图,把整个启动流程串起来了。我建议你仔细看看,尤其是_main和__libc_init_array的位置。
10.7 常见问题与避坑
问题1:__libc_init_array没被调用
检查链接脚本是否包含了.init_array段。有些精简版链接脚本会漏掉这个段,导致构造函数不执行。
问题2:printf输出乱码
通常是_impure_ptr没初始化。检查newlib的reent结构体是否被正确设置。我建议在__libc_init_array里加个断点,确认它确实被执行了。
问题3:堆操作崩溃
malloc/free依赖堆边界变量(__heap_start、__heap_end)。如果链接脚本没定义这些符号,堆管理器就不知道从哪里分配内存。
调试建议:在__libc_init_array前后各放一个GPIO翻转,用示波器看时间差。如果翻转间隔异常,说明初始化函数里有死循环或异常。
10.8 小结
__libc_init_array和_main,是启动代码里最容易被忽视的两个环节。很多人以为只要把堆栈指针设好、数据段搬完就能跑main了。其实不然——C库的初始化,决定了你的printf能不能用、malloc会不会崩、全局对象的构造函数会不会执行。
我个人习惯在移植新平台时,先写一个只调用printf的“hello world”。如果连这个都跑不通,那八成是C库初始化出了问题。这时候别急着调业务代码,回头检查__libc_init_array的调用链,往往能快速定位问题。
公众号:蓝海资料掘金营,微信deep3321