我们做嵌入式开发的,几乎每天都在跟启动代码打交道,但说句实话,很多人包括我自己,在很长一段时间里对“代码到底怎么从复位向量一路跑到用户 main 函数”这件事,心里是没底的。直到有一次要调一块 RT-Thread 板子,用户写的 main 函数第一行还没执行,系统却已经打印出 RT-Thread 版本号、完成了板级初始化、甚至跑起了线程调度,这才逼着我把 RT-Thread 代码启动过程彻底啃了一遍,也终于把$Sub$$$main和$Super$$$main这两个看起来像乱码、实际上非常精妙的东西弄明白了。
如果你正在学 RT-Thread,或者想搞清楚 Keil MDK 工程里main函数之前到底发生了什么,这篇文章应该可以帮你省下不少弯路。我会从芯片上电那一刻开始,把启动文件、C 运行时初始化、RT-Thread 接管、线程调度启动这一整条链路完整拆开,再重点讲讲$Sub$$$main与$Super$$$main是什么、为什么这么设计、实际源码里怎么用的,以及我踩过的一些坑。
1. 从复位到 main:RT-Thread 代码启动过程全景拆解
1.1 上电后芯片先做了什么
不管是 STM32 还是其他 Cortex-M 内核芯片,上电复位后,CPU 的硬件逻辑会做一件非常固定的事:从向量表的首地址读取初始栈顶指针 MSR 值,从向量表偏移 4 字节的位置读取复位异常处理函数的地址,然后跳转过去执行。
你打开任何一个 STM32 工程的启动文件startup_stm32xxxx.s,开头一定是一段这样的内容:
; 栈大小定义 Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN=3 Stack_Mem SPACE Stack_Size __initial_sp ; 向量表 AREA RESET, DATA, READONLY EXPORT __Vectors __Vectors DCD __initial_sp ; Top of Stack DCD Reset_Handler ; Reset Handler DCD NMI_Handler ; NMI Handler DCD HardFault_Handler ; Hard Fault Handler ...向量表的第一个元素不是Reset_Handler,而是__initial_sp。这个细节很多人写裸机程序时不会在意,但在 RT-Thread 这种需要管理系统栈和多线程栈的 RTOS 里,初始栈指针的值会直接影响启动阶段能否安全调用 C 函数。
Reset_Handler通常做这些事情:
- 把向量表地址写入 VTOR 寄存器(如果芯片支持);
- 调用
SystemInit配置时钟; - 跳转到
__main(注意,是两个下划线开头的__main,不是我们写的main)。
需要特别注意的是:Reset_Handler本身还是汇编代码,栈环境虽然已经就绪(MSP 已经指向__initial_sp),但 C 语言中全局变量、静态变量的初始化还没做。要进入 C 世界,必须借助编译器提供的 C 运行时库入口。
1.2 __main 与 C 运行时初始化
这里有一个很多新手会绕晕的“双 main 陷阱”:__main是 ARMCC/AC6 编译器提供的一个 C 运行时初始化入口标签,跟用户写的main函数完全是两码事。它是用汇编写的,主要完成三件事:
- 根据分散加载文件(scatter file)的描述,把加载域中 RW 段(已初始化全局变量)的数据从 Flash 拷到 RAM 的指定执行域;
- 把 ZI 段(未初始化全局变量、静态变量)清零;
- 初始化 C 库运行环境,调用
__rt_lib_init,然后最终跳转到main函数。
这个过程的必要性不用多说,如果没有 RW 段搬移和 ZI 段清零,你定义一个全局变量int flag = 1;,上电后它可能根本不在预定的 RAM 地址,或者初值是一团随机数据。
关键点来了:__main最终“跳转”到 main 函数。但在 RT-Thread 的 Keil MDK 工程里,链接器并不会把__main的跳转目标解析成用户写的那个main,而是解析成了另一个叫$Sub$$$main的函数。这就是整个启动过程最核心的“偷换入口”动作。
1.3 RT-Thread 在哪个节点“接管”
RT-Thread 在 ARMCC 编译环境下,会在board.c或components.c中定义一个特殊函数:
#if defined(__ARMCC_VERSION) int $Sub$$$main(void) { rtthread_startup(); return 0; } #endif这段代码一出现在编译单元里,链接器就会把所有对main的引用全部重定向到$Sub$$$main。于是实际执行流变成了这样:
Reset_Handler -> __main(C 运行时初始化,复制 RW、清零 ZI) -> $Sub$$$main(RT-Thread 接管) -> rtthread_startup() -> 系统初始化、创建 main 线程 -> 启动调度器(不再返回)也就是说,从系统上电到用户 main 函数,真正执行的是“复位向量 -> 启动文件 -> C 运行时 -> $Sub$$$main -> rtthread_startup”这条链,用户写的 main 函数被延后到了一个由 RT-Thread 创建并调度的线程内部。
如果你是第一次接触这个流程,建议先在调试器里把断点打在最开始的位置,然后单步跑一遍,你会发现__main跳转的入口确实不在用户 main 第一行。
2. $Sub$$$main 与 $Super$$$main 机制原理
2.1 ARMCC 特殊符号约定:$Sub 与 $Super 到底是什么
$Sub$$$main这种写法初看像语法错误,实际上它是 ARM Compiler(ARMCC、ARMCLANG)提供的一个函数替换机制:当你为某个函数foo定义了一个新函数$Sub$$foo时,链接器会自动把整个工程中对foo的所有引用都解析到$Sub$$foo。
同时,编译器会自动生成一个符号$Super$$foo,这个符号指向原始的foo函数实现。也就是说:
$Sub$$foo是“替换后的新函数”,链接器会把原来调用 foo 的地方改为调用它;$Super$$foo是“原始函数的地址”,可在$Sub$$foo内部手动调用它来进入原始的foo。
拿 RT-Thread 里最常见的用法举例:
// 用户写了一个标准 main 函数 int main(void) { // 用户业务代码 } // RT-Thread 定义 $Sub$$$main int $Sub$$$main(void) { rtthread_startup(); // 替用户把 RTOS 初始化完 return 0; // 实际上不会执行到这里,调度器启动后不回返回 } // 在 main 线程入口处需要真正执行用户 main,此时要调用 $Super$$main extern int $Super$$main(void); $Super$$main();这里需要特别注意的是:$Super$$main不是一个实实在在在源文件里定义的函数,它是链接器在你声明了$Sub$$$main之后自动生成的地址标签。所以你不能去看某个 C 文件里有没有写$Super$$main的定义,它属于编译和链接层面的魔法。
2.2 链接器视角下的“偷梁换柱”
为了理解这个机制,不妨跟着链接器的视角走一遍。假如你的工程里没有$Sub$$$main,那么编译出来的目标文件中,所有对main的引用都指向你写的main入口地址。链接时__main直接跳转到该地址,就进入了用户业务代码。
一旦工程中存在$Sub$$$main,链接器会做两件事:
- 把所有未解析的
main引用目标改为$Sub$$$main的地址; - 生成一个不可见的导出符号
$Super$$main,指向你原先main函数的代码地址。
从 ELF/AXF 文件的反汇编里,你可以清晰地看到__main末尾的 BL/BX 指令跳转目标已经变成了$Sub$$$main的地址。我自己在调试时还喜欢做一件事:反汇编后直接搜索$Super$$main,确认入口地址和用户 main 函数第一条指令是否一致,这比翻源码确认有没有宏定义更直接。
这个机制带来的最大好处是:用户源码不用做任何修改,只需要额外增加一个$Sub$$$main定义,就可以在系统初始化阶段插入自己的逻辑。RT-Thread 正是利用了这一点,把完整的 RTOS 初始化代码“挂”在了每个用户工程必然存在的 main 入口之前,不影响用户继续按标准 C 语法编写 main 函数。
2.3 不只是 main:$Sub/$Super 的通用玩法
$Sub和$Super并不只针对 main 函数,理论上你可以对任意普通函数使用。比如你接手了一个老项目,某个核心函数uc_Calculate()有 bug 或需要加打印,但不方便直接改原函数,就可以这样操作:
/* 原函数声明 */ int uc_Calculate(int param); /* 替换函数,在原函数入口前插入日志 */ int $Sub$$uc_Calculate(int param) { printf("uc_Calculate called, param=%d\n", param); return $Super$$uc_Calculate(param); // 调用原函数 }这样所有调用uc_Calculate的地方都会先进入$Sub$$uc_Calculate,打印日志后再进入真正的uc_Calculate。我曾在一些外设驱动库里用过这个办法做参数抓包,不用碰 vendor 提供的库代码,效果非常干净。
不过也要提醒一句:这个机制对函数原型有要求,替换函数和原函数的签名必须一致,否则参数和返回值就对不上了。另外,中断服务函数这类由硬件直接跳转的入口,一般不能用$Sub/$Super完全替代,因为中断向量表地址计算不走普通链接引用流程。
2.4 其他编译器下的平替方案
$Sub/$Super是 ARMCC/ARMCLANG 独有的关键词,如果你把同样的代码移植到 GCC 或 IAR,会直接编译报错。那 RT-Thread 在其它工具链下是怎么处理的?
- GCC:GCC 提供的是链接选项
--wrap,原理很像。你可以在链接参数里加-Wl,--wrap=main,链接器会把对 main 的引用改成__wrap_main,把原始函数地址生成__real_main。开发者只需要实现__wrap_main()并在内部调用__real_main()即可。RT-Thread 在 GCC 工程中,通常直接在启动文件中跳到entry()这个入口函数,entry()内部调用rtthread_startup(),不走main替换这条路。 - IAR:IAR 不支持
$Sub/$Super,也不直接使用--wrap,但它有自己的__low_level_init和改写启动文件的方法。RT-Thread 的 IAR 工程通常会在启动汇编或库配置层面解决入口问题,确保rtthread_startup能被执行。
所以你在 RT-Thread 源码中会频繁看到类似下面的条件编译:
#if defined(__ARMCC_VERSION) extern int $Super$$main(void); $Super$$main(); #elif defined(__ICCARM__) || defined(__GNUC__) main(); #endif理解了编译器差异,跨工具链移植 RT-Thread 时心里就有底了。
3. RT-Thread 源码走读:从 rtthread_startup 到用户 main
3.1 rtthread_startup 的初始化链路
rtthread_startup()是 RT-Thread 启动流程的核心函数,通常定义在components.c中。它的伪代码逻辑如下:
int rtthread_startup(void) { /* 关闭全局中断 */ rt_hw_interrupt_disable(); /* 板级初始化:时钟、内存、串口、GPIO 等 */ rt_hw_board_init(); /* 打印 RT-Thread 版本信息 */ rt_show_version(); /* 系统定时器初始化 */ rt_system_timer_init(); /* 调度器初始化 */ rt_system_scheduler_init(); #ifdef RT_USING_SIGNALS /* 信号相关初始化(可选) */ rt_system_signal_init(); #endif /* 创建 main 线程 */ rt_application_init(); /* 启动调度器,进入多线程模式,不会返回 */ rt_system_scheduler_start(); return 0; }注意第一步是关中断,这一步非常关键。在 RTOS 初始化尚未完成、还没准备好系统性调度之前,如果来了一个中断触发上下文切换或者调用了某个未初始化的服务,轻则行为异常,重则直接 HardFault。关中断保证了初始化操作的原子性,让整个系统处于“单线程裸奔”的安全阶段。
rt_hw_board_init()通常由板级驱动包实现,它的任务不只是配置 PLL 和时钟,还包括初始化系统堆内存、配置调试串口。很多新手在这个阶段容易犯一个错误:板级初始化里过早使用了 RT-Thread 的内存管理函数rt_malloc,但此时内存堆还没初始化好,导致返回空指针。所以我每次给新板子做移植,都会在rt_hw_board_init()里先确认rt_system_heap_init的调用时机。
3.2 板级初始化与组件自动初始化机制
RT-Thread 的代码启动过程里,组件自动初始化机制也是绕不开的一环。它本质上是在链接层面做“函数指针收集”,然后由系统统一调用。宏定义大致如下:
#define INIT_BOARD_EXPORT(fn) \ const init_fn_t __rt_init_fn_##fn \ SECTION(".rti_fn." #fn) = fn链接脚本里会定义这样的段区域:
__rt_init_start = .; KEEP(*(SORT_BY_NAME(.rti_fn*))) __rt_init_end = .;rt_components_board_init()会遍历__rt_init_start到__rt_init_end之间的函数指针,依次执行。这样,一个外设驱动只要在代码里写好初始化函数并用INIT_BOARD_EXPORT导出,就能在系统启动阶段自动被调用,不需要用户手工一个个调用。
这个机制和$Sub/$Super有一点相似:都是在编译/链接阶段做文章,把一些原本需要手工维护的“调用关系”自动化了。区别在于,$Sub/$Super是针对单个函数的精确覆盖,自动初始化则是基于段的批量分发。两者互相配合,让 RT-Thread 的启动流程既清晰又灵活动态。
3.3 main 线程创建与调度器启动
rt_application_init()是启动 main 线程的关键函数。它做的是通过 RT-Thread 的线程创建接口生成一个名为main的动态线程:
void rt_application_init(void) { rt_thread_t tid; tid = rt_thread_create("main", main_thread_entry, RT_NULL, RT_MAIN_THREAD_STACK_SIZE, RT_MAIN_THREAD_PRIORITY, 20); RT_ASSERT(tid != RT_NULL); rt_thread_startup(tid); }这里涉及两个宏:RT_MAIN_THREAD_STACK_SIZE和RT_MAIN_THREAD_PRIORITY。前者默认通常是 2048 或 4096,后者默认是 10。优先级数值越小越优先,所以 main 线程在系统里的优先级不算最低也不算最高,属于“中等偏上”。
系统随后调用rt_system_scheduler_start()启动调度器,它会设置 SysTick 定时器和 PendSV 异常,然后触发第一次上下文切换。从这一刻起,CPU 的控制权完全交给了 RT-Thread 调度器,再也不会回到原来的裸机main流程。调度器会从就绪队列中选择优先级最高的线程开始执行,如果此时没有更高优先级的线程,那么通常是main线程抢到首次执行机会。
3.4 用户 main 是如何被安全接管的
main 线程的入口函数是main_thread_entry,它的大致逻辑如下:
static void main_thread_entry(void *parameter) { #ifdef RT_USING_COMPONENTS_INIT /* 组件初始化:执行 INIT_COMPONENT_EXPORT、INIT_ENV_EXPORT 等自动初始化导出的函数 */ rt_components_init(); #endif /* 调用用户真正的 main 函数 */ extern int main(void); #ifdef __ARMCC_VERSION extern int $Super$$main(void); $Super$$main(); #else main(); #endif }你仔细看这段代码会发现,main被调用时已经是 RTOS 环境中可被调度的线程上下文,而不是传统的裸机 main。因此:
- 用户可以在 main 里放心调用
rt_thread_mdelay、rt_sem_take等阻塞型 API,因为线程调度器已经在运行; - 用户不能直接在 main 里做死等循环,否则会卡住调度器,其他低优先级线程可能长期得不到执行;
- 如果用户 main 直接
return,main 线程并不代表系统退出,RT-Thread 会继续调度其余线程,用户可能需要额外处理,比如删除 main 线程或进入空闲钩子。
这里我还想多说一句:很多初学者会疑惑,既然main已经是普通线程了,为什么还要保留它的名称和入口?实际上这是 RT-Thread 在“嵌入式习惯”和“RTOS 规范”之间做的一个平衡。很多从裸机转过来的开发者,第一个想法就是“我熟悉 main 函数”,RT-Thread 直接把 main 变成线程,既兼容了这个习惯,又避免了用户一脸懵地去找一个不存在的“启动点”。
4. 启动阶段常见故障与排查技巧
4.1 发现 $Sub$$$main 没起作用
我接过一个用户的 Keil 工程,代码里明明定义了$Sub$$$main,但单步调试发现__main直接跳进了用户 main,RT-Thread 完全没有启动。排查下来,原因是工程里那段$Sub$$$main被#if defined(__ARMCC_VERSION)包住了,而用户当时用的编译器版本比较旧,宏名不匹配,导致代码被预处理器整体跳过了。
排查方法很简单:在$Sub$$$main的第一行打上断点,如果复位后断点没被触发,说明链接器没有把 main 入口替换过来。这时候优先检查三点:
- 代码是否真的参与编译(条件编译宏是否匹配);
- 是否因为拼写问题,比如把
$Sub$$$main写成了$Sub$$main,少了一个$; - 当前编译器是否真的是 ARMCC/ARMCLANG,如果用 GCC 或 IAR 编译,这套写法不会生效。
4.2 调度器启动就死机
rt_system_scheduler_start()是启动过程中“有去无回”的一步,也是最容易出问题的位置之一。如果死在那里,通常是以下原因:
- SysTick 或 PendSV 异常被全局屏蔽或优先级配置异常,导致线程切换无法触发;
- 某个线程的入口函数的栈设置有问题,导致第一次上下文切换就崩;
- 板级初始化阶段把中断优先级分组配置改掉了,而 RT-Thread 依赖的临界区实现依赖 BASEPRI 或 PRIMASK 行为。
我自己的习惯是:先把rt_hw_board_init()里的外设部分简化到最小规模,串口、GPIO 保持可用即可,等系统能跑起来再逐步添加外设驱动。这样能快速缩小问题范围。
4.3 HardFault 定位三板斧
启动阶段遇 HardFault 太常见了。我的做法分三步:
- 在
HardFault_Handler里打一个永久断点; - 看 LR 寄存器,如果 LR 的值里 bit 2 为 1,说明是从线程模式进入异常,可以结合线程栈内容推断调用链;
- 看当前 MSP/PSP 指向的栈内存,往上翻找 PC 和 LR 的压栈现场,还原崩溃前的最后一条指令。
如果是 main 线程栈溢出导致的 HardFault,我通常先把RT_MAIN_THREAD_STACK_SIZE临时调大一倍,再继续排查具体是哪段业务代码吃栈。问题确认后,再恢复合适的大小,避免长期把系统内存浪费在过大的栈上。
4.4 调试断点与工具链技巧
调试 RT-Thread 启动流程时,有几个断点位置特别推荐:$Sub$$$main、rtthread_startup、rt_hw_board_init和rt_system_scheduler_start。从$Sub$$$main第一行开始单步,能非常直观地看到初始化顺序,也能确认“用户 main 之前的系统准备”到底做了多少工作。
另一个实用技巧是在链接器生成的 map 文件里搜索main和$Super$$main的地址值。如果 map 文件里出现了$Sub$$$main且没有报错,但地址和预期不符,多半是符号冲突或链接脚本段对齐问题。我每次改完启动相关代码,都会顺手看一眼 map 文件里的这几行,确认没有意外。
4.5 多编译器移植的坑
从 Keil 工程移植到 GCC 或从 GCC 移植到 Keil,启动流程是最容易翻车的地方。移植时不只是把源文件加进工程那么简单,还需要:
- 检查是否还残留
$Sub$$$main相关代码,GCC 下应该改用entry入口或--wrap=main; - 检查链接脚本是否包含 RT-Thread 自动初始化段(
.rti_fn等); - 检查启动文件是否调用了
SystemInit以及是否跳转到了正确的 C 入口。
我自己经历过一次最尴尬的移植问题:把源码从 Keil 搬到一个使用 GCC 的 IDE 里,结果工程里保留了$Sub$$$main,而 GCC 把$当成了普通标识符字符,编译直接报错,浪费了将近一个下午才定位到问题。所以说,跟编译器相关的魔法代码,务必带上严格的工具链条件编译,懒不得。
5. 最后再分享一个小技巧
把上面这些内容消化之后,最后再分享一个我个人比较常用的调试手段。由于$Sub$$$main是在__main之后、用户业务逻辑之前执行的,它天然是一个“观察点”。如果你需要在 RTOS 接管之前查看某些硬件状态或者验证时钟配置,可以把断点打在$Sub$$$main的第一行,也可以临时在里面加几条裸寄存器读写代码,看看芯片外设是否已经按预期完成了初始化。
这套方法不仅适用于 RT-Thread,任何基于 ARMCC 的裸机或 RTOS 工程,都可以用$Sub/$Super做类似的事。我还在一些量产项目里用$Sub机制给加密库加过运行日志,几乎没有侵入原有代码。掌握了它,以后面对启动异常、函数替换、运行插桩这类需求,你会多一个很趁手的工具。