
做嵌入式开发的朋友天天跟RT-Thread、代码启动过程这些概念打交道却在遇到板子跑不起来时常常一头雾水。启动代码里那两行不起眼的符号——$Sub$$main和$Super$$main——往往就是问题所在。如果你也有过这种经历程序明明编译过了下载后却没有任何输出或者知道自己写的main里该做初始化但RTOS就是没跑起来又或者想在main之前插入一段自己的逻辑却无处下手那这篇文章就是给你写的。我会从CPU上电复位开始把RT-Thread的启动链路完整捋一遍重点讲清楚$Sub$$main和$Super$$main这套ARM编译器符号扩展机制到底解决了什么问题、在RT-Thread里是怎么用的、以及移植和调试时最容易踩的坑。这篇文章适合正在学RT-Thread的嵌入式新人也适合所有想系统搞清楚“程序入口到底是谁”的开发者。1. 先把RT-Thread启动过程的主链路看明白1.1 从复位向量到Reset_Handler板子一上电到底在干嘛以最常用的Cortex-M内核为例。芯片上电复位后CPU从地址0x00000000取出初始栈指针MSP从0x00000004取出复位向量地址然后跳转过去执行。这句看起来很基础但它是整个启动过程的源头后面所有逻辑都是从这里展开的。这个复位向量一般定义在启动汇编文件里比如startup_stm32f103xe.s、startup_gd32f450.s文件名根据芯片厂家略有不同。文件里的关键逻辑大致是这样AREA RESET, DATA, READONLY EXPORT __Vectors EXPORT __Vectors_End EXPORT __Vectors_Size __Vectors DCD __initial_sp ; 栈顶地址 DCD Reset_Handler ; 复位向量 ; ... 其他中断向量 AREA |.text|, CODE, READONLY Reset_Handler PROC EXPORT Reset_Handler [WEAK] IMPORT SystemInit IMPORT __main LDR R0, SystemInit BLX R0 LDR R0, __main BX R0 ENDP注意两个关键动作先调用SystemInit配置系统时钟然后跳转到__main。这里有个容易混淆的地方——__main不是我们写的那个main函数它是ARM C运行时库C Library的初始化入口标号。如果你把__main和main当成一回事后面看RT-Thread的启动代码就会一直转不过弯来。1.2 C库初始化与数据段搬移谁来替你把全局变量准备好进入__main之后C库会帮你干几件脏活累活。首先是scatterload分散加载过程把代码和只读数据从Flash的加载域Load Region复制到SRAM的运行域Execution Region把未初始化的全局变量清零ZI段清零然后建立堆栈和C运行环境最后才跳转到main函数。这个过程对于RTOS来说尤其重要。RT-Thread的很多全局对象比如线程控制块、定时器链表、内存堆管理结构都是定义在全局变量里的。如果BSS段没有被清零内核初始化的时候读到的就是一堆随机值系统跑起来必然出问题。所以RT-Thread依赖C库启动代码做好这些基础工作而不是自己从头再来一遍。在Keil MDK里__main最终会去调用main。但这里有一个关键点链接器在处理main符号时如果发现工程里定义了$Sub$$main它就会把原本要跳转到main的调用改成跳转到$Sub$$main。这个机制就是RT-Thread截住启动流程的办法也是整篇文章的主角。1.3 真正的main函数入口为什么RT-Thread能截住它我们平时写的用户main函数在RT-Thread工程里并不是第一个被C库调用的那个main。RT-Thread通过$Sub$$main这个符号在真正的main之前插入了一大段内核初始化代码。等内核初始化完毕、调度器跑起来之后你写的main才以“主线程入口”的身份被执行。这里要强调一个概念RT-Thread的“main线程”和“main函数”是两个层面的东西。main函数本身是C语言的程序入口而RT-Thread的main线程是调度器创建的第一个用户线程。$Sub$$main就是它们之间的桥梁——先把控制权交给内核等内核把线程、定时器、调度器都准备好了再回到用户main继续执行用户逻辑。2.$Sub$$main与$Super$$mainARM编译器给的“插线板”2.1 ARM编译器提供的符号替身机制$Sub$$和$Super$$是ARM RealView编译器armcc以及后来的ARM Compiler 6armclang里一套非常实用的符号扩展机制。它的作用很简单在不修改原函数代码、不修改调用方代码的前提下给一个已有函数插入前置逻辑。举个例子。假设库里有个函数foo()而你希望在每次调用foo()之前先执行一段自己的代码。传统做法可能是封装一个新的函数my_foo()把所有调用foo()的地方都改成my_foo()这样工作量很大而且容易漏。有了$Sub$$机制你只需要定义一个$Sub$$foo()函数void $Sub$$foo(void) { // 在foo之前执行的逻辑 $Super$$foo(); // 调用原始的foo }链接器会自动把所有对foo()的调用改成对$Sub$$foo()的调用。如果想调用原来的foo()就用$Super$$foo()。这个机制特别适合做“函数级插桩”也特别适合RT-Thread这种需要“劫持”程序入口的RTOS。注意一点这两个符号不是C语言标准里的内容而是ARM编译器特有的扩展。所以这套代码只能在ARM Compiler环境下编译换到GCC工具链就不适用了。这个差异在第四部分再展开讲。2.2 RT-Thread用这套机制解决了什么问题回到RT-Thread的场景。RT-Thread在启动阶段要做的事情很多初始化板级硬件、初始化内存堆、初始化系统定时器、初始化调度器、创建main线程、启动调度。这些步骤必须在你写的用户main执行之前全部完成。如果不用$Sub$$main你有两个选择。第一个选择是修改启动汇编文件让Reset_Handler直接跳到RT-Thread的启动函数跑完再进用户main。这么做也能实现目标但代价是每个工程都要改启动文件而且不同芯片启动文件写法还不一样移植性很差。第二个选择是让用户自己在main函数第一行手动调用rtthread_startup()这样倒是简单但容易忘而且对用户不友好——RT-Thread的目标是让你“拿到工程就能跑”。$Sub$$main的优势恰恰在这它是链接层面的处理用户代码零侵入。你不需要改启动汇编不需要改用户main只需要在系统库文件里编译一个$Sub$$main函数RT-Thread的初始化就自动插入到了C库调用main之前。RT-Thread里标准的写法是这样的一般在components.c或对应的board文件里int $Sub$$main(void) { rtthread_startup(); return 0; }这个rtthread_startup()不会返回它做完所有初始化后直接启动调度器控制权就交给RTOS了。2.3 如果不用这套机制你还能怎么处理$Sub$$main不是唯一的方案每个工具链都有自己的“入口接管”手段。GCC环境下RT-Thread常见做法是提供entry符号启动汇编里显式跳转过去再由entry调用rtthread_startup。还有一些做OTA的工程会直接在启动文件里加一个跳板函数先校验固件再决定跳转到哪个应用入口。方式方法各有不同但核心思路一致把RTOS初始化动作插入到用户代码运行之前。理解了这一点你就能看懂不同工具链、不同BSP里那些五花八门的启动代码了。重要的是抓住“谁先执行、谁后执行、控制权如何交接”这条主线。3. 从main到RT-Thread内核跑起来的完整细节3.1 rtthread_startup()里发生了什么$Sub$$main里那句rtthread_startup()是整个内核启动的“总指挥”。不同版本、不同BSP里这个函数的实现位置会有差异但核心流程基本一致。我在实际项目里见过最典型的实现如下void rtthread_startup(void) { rt_hw_interrupt_disable(); rt_hw_board_init(); rt_show_version(); rt_system_timer_init(); rt_system_heap_init(); rt_system_scheduler_init(); rt_application_init(); rt_system_scheduler_start(); }第一步先把全局中断关掉防止初始化还没完成就被某个外设中断打断。这个细节很重要如果中断处理函数里访问了尚未初始化的内核对象系统会直接崩溃。做底层移植的时候这条“初始化期间关闭中断”的纪律一定要守住。rt_hw_board_init()是板级初始化不同的开发板千差万别。它的职责包括初始化系统时钟、配置串口作为调试输出、初始化随机数发生器、设置main线程的栈空间地址等。很多人在这个函数里增加自己的一键外设初始化比如点亮LED、初始化I2C总线这是可以的但要注意别放太多耗时操作毕竟这会拖慢整个系统启动速度。3.2 硬件初始化、堆初始化与调度器初始化rt_system_timer_init()负责初始化系统定时器相关的链表和数据结构为后面的tick中断做准备。rt_system_heap_init()则设置动态内存堆。这个堆的起始地址和大小一般由BSP在board.h或board.c里定义不同的芯片差异很大。举个例子有的芯片内部SRAM只有64KB你给堆分配了32KB剩下的供全局变量和线程栈使用如果全局变量一多链接时会直接报内存溢出而堆太小创建线程时动态申请TCB和控制块就会失败。rt_system_scheduler_init()做的事情相对简单清空就绪优先级表、初始化线程等待队列、把调度器相关变量置零。虽然看起来只是“初始化数据”但这一步没做好的话后面创建线程和任务切换都会出问题。这几个初始化之间没有严格依赖关系但顺序上建议保持RT-Thread官方给出的排列不要随便调换否则排查问题时会增加无谓的复杂度。3.3 main线程的创建与RT_MAIN_THREAD_STACK_SIZErt_application_init()是创建main线程的地方main线程是所有用户代码的宿主线程。它在内核初始化完成后被创建优先级通常是RT_THREAD_PRIORITY_MAX - 3也就是一个比较低的优先级只比空闲线程高。线程入口是main_thread_entry()栈大小由RT_MAIN_THREAD_STACK_SIZE控制。这个宏定义在rtconfig.h里默认值因BSP而异常见的是2048或4096。千万别小看main线程的栈大小它是启动类问题的高发区。你写的用户main里如果定义了一个大数组或者调用了printf、浮点格式化之类的函数栈消耗都会明显增加。如果RT_MAIN_THREAD_STACK_SIZE设得太小main线程栈溢出轻则函数返回时跳飞到随机地址重则直接进HardFault。我做过一个项目原本main线程栈用2048字节跑了几个月都没事后来加了一个调试用的char dump_buf[1024]局部数组系统频繁死机。用list_thread查看main线程栈最大使用率发现已经超过95%。把RT_MAIN_THREAD_STACK_SIZE改成4096后问题彻底消失。所以建议把这个宏当成关键配置参数对待而不是一个“先设个默认值再说”的选项。3.4 调度器启动后main线程入口的执行路径rt_system_scheduler_start()是启动流程里最后一个函数而且它不会返回。它内部会从就绪队列里选出优先级最高的线程恢复它的上下文然后整条启动链路的控制权就交给了调度器。第一个被调度执行的通常是main线程如果它优先级最高main线程入口main_thread_entry()会做两件事先调用rt_thread_mdelay(50)之类的延时让系统tick稳定跑起来再调用main_entry()。main_entry这个名字很多初学者看到会懵它其实就是你写的用户main经过编译后生成的目标符号。也就是说你熟悉的那个main函数终于在这一刻被执行了——但它已经运行在一个由RT-Thread创建和维护的线程上下文里了。此时你可以自由创建线程、使用信号量、申请动态内存因为整个RTOS已经完整跑起来了。梳理一下完整的调用链上电复位 - Reset_HandlerReset_Handler - SystemInit配置时钟SystemInit - __mainC库初始化数据段搬移、BSS清零__main - $Sub$$main链接器符号改写$Sub$$main - rtthread_startuprtthread_startup - rt_application_init创建main线程rtthread_startup - rt_system_scheduler_start启动调度调度器 - main_thread_entry - main_entry用户main这条链路是RT-Thread在ARM MDK环境下最标准的启动路径你只要顺着它排查绝大多数启动问题都能定位到具体环节。4. 实际调试中遇到的坑与排查技巧4.1 链接报Undefined symbol $Super$$main怎么处理我在论坛上见过不少新手问这个报错Undefined symbol $Super$$main (referred from xxx.o)。出现这个问题的原因很直接——你写了$Sub$$main在里面又用了$Super$$main但没有正确声明它。C代码里如果想要调用原始main必须先声明extern int $Super$$main(void);注意这个声明里的符号要原样写$的数量、大小写都要跟编译器约定完全一致。实际项目中我还见过一种情况有人把extern int $Super$$main(void);写在了某个头文件里但这个头文件被多个.c包含其中某个编译单元里又定义了一个同名函数冲突后报错。建议把$Sub$$main和相关的extern声明集中放在同一个.c文件里避免在多个编译单元间扩散。还有一个小提示$Super$$main只能在$Sub$$main内部调用写在别的地方调用的语义会很奇怪也容易出问题。如果工程里确实需要在别的位置触发“从启点重新进入用户main”不如用看门狗复位或者系统软复位不要硬绕这个弯。4.2 板子启动后直接进HardFault从哪查这是嵌入式调试里的老难题。配合RT-Thread的启动链路我的排查顺序是这样的先看$Sub$$main有没有被执行。在$Sub$$main函数入口打断点如果断点没触发说明链接器没有把对main的调用改到$Sub$$main上这时要检查是不是用了GCC工具链因为GCC不认这套符号扩展或者检查是不是启动了MicroLIB在某些MDK版本下MicroLIB对入口的处理会和armcc标准库有细微差别。再看rtthread_startup里执行到哪一步崩溃。用单步调试确认是在rt_hw_board_init、rt_system_heap_init还是调度器启动时出的问题。这个阶段的HardFault最常见原因是时钟配置错误、堆地址越界、中断向量表没有正确拷贝到SRAM这几种。最后确认栈是否溢出。进入HardFault后查看MSP或PSP的值估算栈指针是否超出了线程栈范围。如果使用了list_thread直接看main线程栈空间剩余量最直观。4.3 换用GCC工具链时这套机制还适用吗不适用。$Sub$$$main这套是ARM编译器特有的GCC下根本没有这两个符号。所以RT-Thread针对不同工具链提供了不同的启动入口实现。GCC的BSP里启动汇编文件通常会直接跳转到entry函数而非跳转到__mainReset_Handler: ldr r0, SystemInit blx r0 ldr r0, entry bx r0这里的entry在RT-Thread源码里也是直接调用rtthread_startup的。所以你在GCC工程里搜不到$Sub$$main不要慌这是正常现象。同理如果你拿到手的是一个MDK工程却用GCC工具链重新编译那启动路径就变了务必检查启动汇编文件用的是哪套约定。我在把MDK工程移植到GCC例如用VSCodearm-none-eabi-gcc编译RT-Thread或者用RT-Thread Studio的GCC工具链时遇到过多次因为$Sub$$main残留导致链接失败的情况。排查时先确认启动汇编里的入口符号和C代码里的入口函数是匹配的再找别的方向。4.4 Keil MDK下微库与入口不要搞混的几个问题MicroLIB是ARM编译器提供的精简C库很多初学者为了减小代码体积会勾选它。但对于RT-Thread这种依赖完整C库初始化的RTOS来说MicroLIB会带来一些隐性差异。有些MDK版本里MicroLIB对__main和main的处理路径与标准C库不完全一样可能让你对$Sub$$main插桩的预期变得不可靠。我遇到过一次诡异现象同一个RT-Thread工程勾选MicroLIB后完全正常不勾选MicroLIB反而编译报错好像某个printf相关的符号找不到。后来才发现是代码里用了某个依赖标准库特性的函数而标准库模式下某些多线程相关的符号需要额外配置。建议在启动阶段尽量不要开MicroLIB先把系统跑通再根据Flash占用情况决定是否优化库。真要用MicroLIB至少在$Sub$$main和$Sub$$main入口上做一个快速验证确保启动路径没有被库的行为影响。另外一个常见问题是重复定义main。RT-Thread的GCC和MDK模板里用户main函数的定义会经过特殊处理有的BSP里直接定义了int main(void)有的则定义int main_entry(void)还有的用宏把main重命名了。如果你在工程里同时看到了用户自己的main定义和RT-Thread库里的main相关入口先检查是不是多文件重复定义。这种事在把多个示例工程合并时特别容易出现。最后再分享一个我个人的排查习惯。每次拿到一块要移植RT-Thread的新板子我都会在启动文件里设三个断点Reset_Handler第一行、__main入口、$Sub$$main入口。上电后依次确认三个断点都能正常命中。如果断点停在Reset_Handler但进不了__main优先怀疑SystemInit里的时钟配置把CPU频率设置得异常低了如果进了__main但没进$Sub$$main优先怀疑链接脚本或工具链版本。这套套路帮我快速定位过不少启动问题你也可以试试。