RT-Thread启动流程详解:从复位向量到$Sub$$main再到main的完整链路
发布时间:2026/10/6 8:55:45 作者:尧图编辑部 阅读量:1,286

很多做嵌入式的小伙伴最开始用RT-Thread时都会有一个疑惑明明我在Keil里建工程时选的入口是main()为什么程序跑起来后第一个停在的地方不是main而是board.c里那个长得像乱码的$Sub$$main顺着这个$Sub$$main往下追又会看到rtthread_startup()然后是调度器启动、main线程……这篇文章就把RT-Thread在ARM Compiler工具链下从复位开始到用户main的完整启动链路拆一遍重点讲清$Sub$$main和$Super$$main这对编译器机制到底是怎么工作的以及为什么RT-Thread偏偏选中它来接管启动过程。不管你是刚从裸机项目迁移过来的新手还是已经跑了两年RT-Thread想搞清楚底层细节的老人这篇应该都能给你一点启发。1. 从Reset_Handler到main()程序执行的第一行代码不在main里1.1 上电那一刻CPU只认向量表Cortex-M上电后CPU做的第一件事是读0x00000000处的初始栈指针再读0x00000004处的复位中断向量Reset_Handler。这是ARM硬件规定好的行为和你的工程用什么编译器、用没用RTOS没有任何关系。向量表第一项是初始SP第二项是复位向量这个布局是固定的。以RT-Thread最常见的STM32 BSP为例startup_stm32f103xe.s里向量表长这样__Vectors DCD __initial_sp DCD Reset_HandlerReset_Handler的前几行通常是Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP这里有两个关键跳转先调SystemInit再进__main。SystemInit是CMSIS规定的系统初始化函数主要做时钟树配置、Flash延时设置这些跟C语言运行环境无关的硬件动作。到了__main这一步就开始进入ARM C库的领地了。需要提醒的是如果用过一些精简启动文件可能会看到Reset_Handler直接跳__main而不是先调SystemInit这种写法不标准会导致进main之后时钟还是默认的HSI后面的串口波特率和延时全部对不上属于典型的坑。1.2 __main到底替你干了多少活__main不是你的main()它是ARM C库提供的启动入口干的是C运行时初始化。主要工作包括把RW段已初始化全局变量从Flash拷贝到RAM把ZI段未初始化全局变量清零初始化栈指针、堆空间调用__rt_entry进入C库的运行时环境。换句话说在__main执行完之前你的全局变量还是随机值堆不能malloc栈虽然硬件上能用但C库的上下文还没准备好。这些工作全部完成之后__rt_entry才会调用main()把控制权转到你的代码。所以裸机工程里那个经典的问句为什么main()不是程序起点答案就在这main()之前有芯片级初始化SystemInit、有C运行库初始化__main它们共同构成了程序的真正冷启动链路。1.3 问题来了RT-Thread的内核初始化往哪里放RT-Thread是一个RTOS它有自己的对象管理系统、调度器、定时器、内存堆。这些组件必须在用户能调用API之前初始化完毕。如果把这些初始化塞进用户main()的第一行那也说得通但RT-Thread面向的是大量BSP和不同开发者的工程不可能要求每个人都记得在main开头调用rtthread_startup()。更重要的是main线程机制要求调度器先行启动之后main才能在一个受管理的线程上下文中执行。所以最干净的做法是在main()真正执行之前插入一个内核启动步骤而这个插入点最好不动启动文件、不动C库、也不改动用户main的签名。这一步就催生了ARM编译器提供的一对特殊符号——$Sub$$main和$Super$$main的用武之地。在继续往下之前你可以先带着一个疑问去读后面的内容为什么RT-Thread宁可搬出这对看起来有点奇怪的符号也不直接用最粗暴的在启动文件里跳转rtthread_startup方案2. 编译器在背地里偷梁换柱$Sub$$main/$Super$$main机制拆解2.1 它到底是什么原理$Sub$$main不是RT-Thread发明的而是ARM CompilerARMCC和ARMCLANG的链接器提供的一种函数补丁机制。这里要澄清一个误区它不是运行时hook不是通过函数指针跳转而是发生在链接阶段的符号重定向。当你定义了一个函数名字叫$Sub$$main链接器就会把原本要调用main()的地方重定向为调用$Sub$$main。同时原始的main()被保存下来以$Super$$main这个名字对外可见。整个过程可以理解为链接器在符号表里动了手脚而不是在二进制里插入跳板指令。这也是它比运行时hook可靠的原因——你不需要担心函数指针被意外覆盖、不需要考虑调用时机、更不需要改造启动文件。再给你看一个最朴素的用法先在main之前做点事再调用原始mainextern int $Super$$main(void); int $Sub$$main(void) { init_peripheral(); /* 你的前置初始化 */ return $Super$$main(); /* 调用真正的main */ }这段代码在ARMCC/ARMCLANG下编译链接后C库初始化完会先进入$Sub$$maininit_peripheral执行完再调$Super$$main进入原始main。如果确实什么事都不用做只是想把原来的main原封不动拎出来跑也可以不定义$Sub$$main程序行为照旧链接器不会强制做任何替换。$Sub$$main真正起作用的前提是你明确给出了这个符号定义。2.2 不是只对main有效它是一套通用补丁机制这套机制可以作用在任意普通函数上。比如你想给一个外部库的foo()打补丁定义一个$Sub$$foo()所有对foo()的外部调用就会自动落到$Sub$$foo()原foo()则以$Super$$foo()的形式保留。这在不需要源码修改的情况下给第三方库函数插入前置/后置逻辑特别好用。对带参数的函数$Sub$$甚至可以在调用$Super$$前改写参数值extern int $Super$$foo(int a, int b); int $Sub$$foo(int a, int b) { if (a 0) a 0; /* 参数修正 */ return $Super$$foo(a, b); }不过现实里用得最多的还是main和__main这两个入口级函数。我自己之前在一个项目里用$Sub$$HardFault_Handler做过故障现场捕获在进HardFault前先把几个关键寄存器和栈内容搬到SRAM尾部调试效率提升很大。这说明这套机制不只是给RT-Thread用的所有ARMCC/ARMCLANG工程都可以当作轻量级补丁方案使用。2.3 为什么不推荐用weak符号或constructor来替代有些同学会问用__attribute__((constructor))也能在main之前执行代码或者用weak符号覆盖main不也行吗先说weak符号。它处理的是这个符号存不存在如果你在库里强定义一个main又在用户代码里weak一个main链接时不会报重定义但两个同名函数最后只会保留一个实体不存在补丁原函数再调用原函数的概念。你没法在补丁里拿到原始main的地址也就没法做到我先初始化RTOS之后再回到用户main这种逻辑。再说constructor。GCC的__attribute__((constructor))确实能在main之前执行但多个constructor函数之间的执行顺序依赖链接顺序非常脆弱而且它是GCC系的扩展ARMCC/ARMCLANG就不一样可移植性成问题。更重要的是constructor执行时C运行时已经初始化了不假但它无法帮你保存并恢复原始入口的调用关系。$Sub$$/$Super$$是链接器级别的正规机制对编译器来说就是一个符号替换规则可靠且可预期这正是RT-Thread选择它的原因。3. RT-Thread一步步接管启动过程rtthread_startup全流程3.1 入口代码就三行但别小看它在RT-Thread的MDK工程里bsp的board.c或startup.c中通常写着#if defined(__CC_ARM) || defined(__ARMCC_VERSION) extern int main(void); int $Sub$$main(void) { rtthread_startup(); return 0; } #endif如果你在调试时全速跑第一个用户断点经常停在这里而不是main()。$Sub$$main里面只做了一件事调用rtthread_startup()。rtthread_startup返回后这个函数返回0但事实上它很少有机会回来。因为rtthread_startup内部的最后一步是启动调度器调度器一启动CPU就再也不按顺序返回了。3.2 rtthread_startup()内部到底按什么顺序做事rtthread_startup是RT-Thread内核初始化的总入口位于components.c。我把关键步骤按真实顺序列出来版本不同命名略有差异但流程基本一致int rtthread_startup(void) { rt_hw_interrupt_disable(); /* 先关中断初始化阶段不允许被调度 */ rt_hw_board_init(); /* 板级初始化时钟、内存、串口、控制台 */ rt_show_version(); /* 打印版本信息 */ rt_system_timer_init(); /* 系统定时器初始化 */ rt_system_heap_init( (void *)HEAP_BEGIN, /* 堆区起点 */ (void *)HEAP_END); /* 堆区终点 */ rt_system_scheduler_init(); /* 调度器初始化 */ rt_application_init(); /* 创建main线程 */ rt_system_timer_thread_init(); /* 定时器线程初始化 */ rt_thread_idle_init(); /* 空闲线程初始化 */ rt_system_scheduler_start(); /* 启动调度器这里不会返回 */ return 0; }每个阶段为什么必须在这个顺序都是有原因的rt_hw_board_init要最先做因为后面的printf、内存分配、线程创建都依赖它把时钟和串口弄好堆初始化必须在创建线程之前因为创建线程要动态分配TCB和栈空间调度器初始化又必须在创建线程之前因为rt_thread_create要把线程挂到就绪队列里去就绪队列结构和调度器初始化强相关而定时器线程和空闲线程都必须在调度器启动之前创建完成。如果你把它当成一个简单的初始化列表背很容易漏掉其中的依赖关系真正移植时就会出各种莫名其妙的崩溃。3.3 调度器启动之后main在哪执行rt_system_scheduler_start()是整个启动过程的分水岭。它做了两件事把当前控制流切换到当前优先级最高的线程然后打开中断。RT-Thread的main线程是开发者最先创建的入口是main_thread_entry优先级默认RT_MAIN_THREAD_PRIORITY一般比空闲线程高。调度器一启动大概率第一个运行的就是它。main_thread_entry内部最终会调用main()。这里是最容易让人困惑的地方如果main()已经被$Sub$$main重定向主线程里再调用main()不是会再次进入$Sub$$main吗实际编译运行的结果是当链接器发现$Sub$$main作为补丁存在时它在普通源代码中对main()的引用会匹配到$Super$$main也就是最初的那个main实体。用调试器在main_thread_entry里打断点单步进入你会发现它跳到的代码地址正好和.map文件里$Super$$main的地址一致。RT-Thread没有丢mainmain也没有被递归进入。整个执行路径可以拉成一条线复位向量Reset_HandlerSystemInit__mainC库初始化__rt_entry$Sub$$mainRT-Thread接管点rtthread_startuprt_system_scheduler_startmain线程$Super$$main真正的用户main这套路径在RT-Thread的MDK工程里是通用的不管用的是STM32还是其他Cortex-M平台步骤大同小异。如果你在调试器里把PC指针停在$Sub$$main然后看Call Stack会发现下层是__rt_entry而不是__main因为__main已经把活干完交出去了。4. 不是只有ARMCC有这条路跨工具链方案对比4.1 GCC下的常见做法$Sub$$/$Super$$这套机制只在ARM的编译工具链里定义GCC不认。RT-Thread在GCC BSP里的做法通常是直接改启动汇编。启动文件里不再是跳__main而是跳entry或直接跳rtthread_startup。Reset_Handler: ldr r0, SystemInit blx r0 ldr r0, rtthread_startup bx r0但要特别注意GCC下没有__main帮你搬数据段、清BSS段所以Reset_Handler前面必须有对应的段初始化逻辑比如在汇编里完成.data复制和.bss清零或者依赖链接脚本和crt0来初始化。RT-Thread的GCC BSP会把这段逻辑写在startup汇编里这样跳到rtthread_startup时C环境已经可用。如果你是从MDK工程平移到GCC最容易犯的错就是直接照搬board.c的$Sub$$main然后在GCC下编译发现链接报undefined symbol因为GCC的链接器根本不认识$Sub$$main这种符号。4.2 IAR下的做法IAR下RT-Thread一般也不走main前的自定义入口而是通过修改启动文件或者在初始化阶段借助IAR的__low_level_init()函数。__low_level_init会在数据段初始化之前被调用如果返回0可以跳过段初始化通常你希望返回1让标准C环境初始化正常进行。因为__low_level_init执行时机比$Sub$$main更早串口、堆这些依赖C运行时的东西还不能用RT-Thread通常不把整个内核启动塞进__low_level_init还是走启动汇编到rtthread_startup的路线。具体到你手里的BSP最好直接看工程里的startup文件确认Reset_Handler到底跳到哪里。4.3 为什么ARMCC方案最有工程优势从可移植性角度讲$Sub$$main这种方案最优雅的一点是无需修改启动汇编无需担心crt0的差异C库已经把运行时环境准备好$Sub$$main执行的时机正好是环境可用、业务未开始的黄金空档。所以只要工具链是ARMCC或ARMCLANGRT-Thread都会优先选择它。我整理了一个对比表适合在选型时快速看维度ARMCC/ARMCLANGGCCIARmain前插入初始化$Sub$$main链接器符号替换修改启动汇编__low_level_init/启动汇编是否需要懂汇编不需要需要部分需要插入时机C库初始化完成后视启动代码安排可早可晚对原main的保存通过$Super$$main无无BSP维护成本低中中5. 实战验证与容易踩的坑5.1 用map文件验证$Sub$$main确实接管了main想验证$Sub$$main确实接管了main不用打一堆断点编译完看map文件就一目了然。在MDK生成的.map文件里搜main你会看到类似这样的条目main 0x08000123 Code ... app.o $Sub$$main 0x08004567 Code ... board.o $Super$$main 0x08000123 Code ... app.o$Super$$main的地址和原始main的地址完全相同$Sub$$main是独立的新函数。如果反汇编__rt_entry还能看到对main的调用地址实际指向了$Sub$$main。这就是编译阶段偷梁换柱的铁证。有些工程里map文件太长可以直接在交叉引用列表里搜$Sub$$很快能找到所有被打了补丁的函数。5.2 为什么你在$Sub$$main里看不到$Super$$main调用这是新手最容易懵的点。直觉上$Sub$$main里应该写return $Super$$main()但RT-Thread的$Sub$$main只调rtthread_startup()。原因是调度器一旦启动控制流不会再回到$Sub$$main这里了原main由main线程去调用。所以RT-Thread的这套用法属于完全接管方式跟前面示例里的先补丁再返回不一样。理解这一点就不会在调试时反复怀疑RT-Thread是否丢掉了main。真要在$Sub$$main里看到$Super$$main的踪迹唯一的办法是等main线程被调度后再看调用来源。5.3 应用代码里调用main要注意二义性如果你在别的地方比如某个线程里手动调用了main()要当心链接器对main的解析规则。建议这种情况下显式声明并调用$Super$$mainextern int $Super$$main(void); /* 在某个线程里重新进入用户main */ $Super$$main();不要在业务线程里直接调用main()尤其是你已经依赖RT-Thread接管了启动流程时再次进入main的逻辑容易造成资源重复初始化。这里我吃过一次亏想用按键触发系统复位图省事直接调main()结果跑了一遍rtthread_startup调度器被二次启动系统直接hardfault。后来改成NVIC_SystemReset()复位干净利落。5.4 调度器启动之前的操作受限在$Sub$$main到rt_system_scheduler_start之间的这段代码里系统还处于单核裸奔状态很多RTOS API是不能用的。rt_thread_delay、rt_sem_take这类依赖调度器和时钟的API在调度器启动前要么返回错误要么行为不完整。原因是此时系统节拍定时器还没跑起来就绪队列也没有被真正调度。我在实际移植时踩过这个坑想在rt_hw_board_init后面加个延时做外围器件上电等待顺手调了rt_thread_mdelay结果系统直接卡住了。正确做法是先用rt_hw_board_init或CMSIS提供的Delay实现原始的忙等待延时等调度器起来后再切换到RT-Thread的延时API。5.5 调试main线程优先级与调度顺序main线程默认优先级是RT_MAIN_THREAD_PRIORITY通常设为10空闲线程是31数字越小优先级越高。所以调度器启动后如果不考虑其他更高优先级线程第一个运行的就是main线程。但如果你在rtthread_startup之前创建了别的线程或定时器线程提前就绪调度顺序就会变。排查为什么我的main迟迟不跑时先看一眼优先级和就绪队列往往比怀疑$Sub$$main被跳过更有效。我曾经在一台板子上把main线程优先级改成了20结果一个后台数据采集线程优先级是12每次开机先跑采集线程等到Tick轮转才轮到main日志输出顺序完全反了排查了很久才意识到是优先级配置问题。6. 一点个人经验对我自己来说真正理解$Sub$$main的过程是整个RT-Thread启动链路的转折点。之前遇到程序为什么没有按我想的运行这类问题我只会堆断点理清这段链路之后调试时脑子里会有一条完整执行路径哪一步在哪一层执行、哪一步之后线程才真正被调度、哪些API在哪个阶段可用。如果你也想动手确认一遍建议打开map文件搜$Super$$main再在$Sub$$main和main_thread_entry里各打断点跑一次比自己死记流程有用得多。另外迁移到GCC平台时一定记得检查启动汇编不要拿ARMCC的经验直接套用。RT-Thread的启动设计并不复杂但它把编译器机制、内核初始化和线程调度串在了一条链上理解了这条链后面看RT-Thread的线程切换、中断管理都会顺手很多。