ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

RT-Thread启动流程深度拆解:从复位向量到main函数之前

RT-Thread启动流程深度拆解:从复位向量到main函数之前 我们做嵌入式开发的几乎每天都在跟启动代码打交道但说句实话很多人包括我自己在很长一段时间里对“代码到底怎么从复位向量一路跑到用户 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. 从复位到 mainRT-Thread 代码启动过程全景拆解1.1 上电后芯片先做了什么不管是 STM32 还是其他 Cortex-M 内核芯片上电复位后CPU 的硬件逻辑会做一件非常固定的事从向量表的首地址读取初始栈顶指针 MSR 值从向量表偏移 4 字节的位置读取复位异常处理函数的地址然后跳转过去执行。你打开任何一个 STM32 工程的启动文件startup_stm32xxxx.s开头一定是一段这样的内容; 栈大小定义 Stack_Size EQU 0x00000400 AREA STACK, NOINIT, READWRITE, ALIGN3 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 - __mainC 运行时初始化复制 RW、清零 ZI - $Sub$$$mainRT-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 CompilerARMCC、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 在其它工具链下是怎么处理的GCCGCC 提供的是链接选项--wrap原理很像。你可以在链接参数里加-Wl,--wrapmain链接器会把对 main 的引用改成__wrap_main把原始函数地址生成__real_main。开发者只需要实现__wrap_main()并在内部调用__real_main()即可。RT-Thread 在 GCC 工程中通常直接在启动文件中跳到entry()这个入口函数entry()内部调用rtthread_startup()不走main替换这条路。IARIAR 不支持$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 到用户 main3.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 直接returnmain 线程并不代表系统退出RT-Thread 会继续调度其余线程用户可能需要额外处理比如删除 main 线程或进入空闲钩子。这里我还想多说一句很多初学者会疑惑既然main已经是普通线程了为什么还要保留它的名称和入口实际上这是 RT-Thread 在“嵌入式习惯”和“RTOS 规范”之间做的一个平衡。很多从裸机转过来的开发者第一个想法就是“我熟悉 main 函数”RT-Thread 直接把 main 变成线程既兼容了这个习惯又避免了用户一脸懵地去找一个不存在的“启动点”。4. 启动阶段常见故障与排查技巧4.1 发现 $Sub$$$main 没起作用我接过一个用户的 Keil 工程代码里明明定义了$Sub$$$main但单步调试发现__main直接跳进了用户 mainRT-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入口或--wrapmain检查链接脚本是否包含 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机制给加密库加过运行日志几乎没有侵入原有代码。掌握了它以后面对启动异常、函数替换、运行插桩这类需求你会多一个很趁手的工具。
返回列表