ARTICLE DETAIL

资讯详情

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

RT-Thread启动流程解析:从$Sub$$main到系统初始化的完整链路

RT-Thread启动流程解析:从$Sub$$main到系统初始化的完整链路 1. 从复位到main嵌入式世界的“幕后英雄”如果你写过单片机程序或者接触过任何基于C语言的嵌入式开发你对main函数一定再熟悉不过了。它是我们认知里程序的“起点”所有业务逻辑的源头。但有没有想过在你按下复位键到main函数的第一行代码执行之前芯片里到底发生了什么你的全局变量为何已经有了初始值堆栈空间又是谁帮你建立好的今天我们就来深挖一下RT-Thread这个国产优秀实时操作系统的启动过程特别是其中两个神秘而关键的“钩子”函数$Sub$$main和$Super$$main。这不仅仅是RT-Thread的专利更是理解ARM Compiler尤其是ARMCC/Keil MDK链接器魔法以及任何RTOS乃至裸机程序启动本质的绝佳窗口。搞懂它你就能真正理解从“硬件复位”到“软件世界”的完整桥梁是如何搭建的也能在需要时定制属于你自己的启动流程。2. 启动流程全景图一段被忽略的“暗时间”在深入那两个符号之前我们必须先建立完整的启动认知。从芯片上电到main函数执行这段被称为“启动引导”的过程是程序能正确运行的基石。它通常由汇编和C语言共同完成可以分为几个清晰的阶段。2.1 第一阶段硬件复位与启动文件芯片复位后硬件会从一个固定的内存地址通常是0x00000000或某个特定偏移开始取指令执行。这个地址存放的是“中断向量表”的起始。向量表的第一项是“初始栈指针MSP”的值第二项就是“复位向量”即程序开始执行的第一条指令地址通常指向一个名为Reset_Handler的汇编函数。Reset_Handler是启动文件如startup_stm32fxxx.s的核心它主要干几件“脏活累活”初始化栈指针SP从向量表第一项加载MSP为C语言运行准备好栈空间。初始化数据段将存储在Flash中的已初始化全局变量.data段的初始值拷贝到RAM中对应的位置。这就是为什么你的int a 100;在main里读出来就是100。清零BSS段将未初始化的全局变量和静态变量.bss段所在的内存区域全部清零。确保int b;的初始值是0。初始化系统时钟调用SystemInit函数配置PLL、设置系统主频。这一步有时会在后面由C函数完成。跳转到C世界最后调用__main注意这不是你的main或直接跳转到main函数。注意这里的__main是编译器库函数如ARM C库的__main它负责完成C运行时环境CRT的初始化包括上面第2、3步以及更复杂的库初始化然后再调用用户的main函数。而有些启动文件会直接跳转到main把初始化工作放在main开头。2.2 第二阶段RT-Thread的接管与系统初始化对于RT-Thread这样的操作系统它需要在你的应用代码main函数运行之前先完成自身的初始化准备好多任务运行的环境。因此RT-Thread巧妙地“劫持”了从启动文件到应用main之间的控制流。它的核心思路是不让启动文件直接调用用户的main而是先调用RT-Thread的启动函数等操作系统内核、调度器、硬件驱动等都就绪后再“回调”用户的main函数。此时用户的main函数在RT-Thread中通常被看作是一个独立的线程或任务的入口。那么这个“劫持”是如何实现的呢这就引出了我们今天的主角$Sub$$main和$Super$$main。它们不是C语言标准而是ARM Compiler工具链armcc, armclang提供的特殊链接器特性。3. 解密$Sub$与$Super$链接器的“钩子”魔法$Sub$$和$Super$$是ARM链接器armlink识别的特殊模式。它们允许开发者在不修改原有库源码或调用方代码的情况下对一个已有的函数特别是库函数进行包装、增强或替换。3.1 $Sub$$function在目标函数“之前”执行当你定义一个名为$Sub$$function的函数时链接器会确保在任何地方调用function时实际调用的是你的$Sub$$function。例如你定义了void $Sub$$main(void) { printf(Before the real main!\n); $Super$$main(); // 调用原始的main函数 }那么所有对main()的调用都会先执行你的$Sub$$main打印一句话然后再通过$Super$$main()去执行原本的main函数。链接器是如何做到的在链接阶段链接器会进行“符号重定位”。当它发现存在$Sub$$main这个符号时它会做两件事将所有对main的引用比如启动文件里的BL main指令重定向到$Sub$$main的地址。将原始的main函数符号“隐藏”或重命名为一个内部名称并通过$Super$$main这个特殊的符号来提供对它的访问入口。3.2 $Super$$function调用“原始”目标函数$Super$$function是一个由链接器自动提供的“指针”或“入口”它指向没有被$Sub$$替换之前的那个原始function。你只能在$Sub$$function函数内部使用$Super$$function()来调用原函数。它的作用就是让你在“包装”了原函数后还能有机会执行原本的功能确保程序主体逻辑不被破坏。这是一种非常经典的“装饰器模式”或“钩子Hook”实现。3.3 一个简单的裸机示例让我们在一个简单的裸机工程中验证这个机制。假设我们使用Keil MDK有一个最简单的main.c#include stdio.h // 1. 定义$Sub$$main钩子函数 void $Sub$$main(void) { // 这部分代码会在“原main”之前执行 printf([Hook] System is booting up...\n); // 初始化一些必须在main之前准备好的硬件比如看门狗、关键外设 // init_critical_hardware(); // 2. 调用原始的main函数 $Super$$main(); // 注意原main函数如果返回代码会走到这里。 // 在嵌入式系统中main通常不应返回。如果返回可以在这里处理。 printf([Hook] main() has returned!\n); while(1); // 陷入循环 } // 3. 这是用户以为的“main”函数 int main(void) { printf([User] Hello, this is the original main!\n); // 用户业务代码 return 0; // 假设返回 }编译链接后运行输出顺序将是[Hook] System is booting up... [User] Hello, this is the original main! [Hook] main() has returned!这个例子清晰地展示了控制流的转移启动文件调用main→ 链接器重定向到$Sub$$main→ 执行钩子代码 → 通过$Super$$main()调用原main→ 原main返回后控制权回到钩子函数。4. RT-Thread如何运用此机制完成启动理解了$Sub$$和$Super$$的原理我们来看RT-Thread的实战代码。以RT-Thread的经典实现为例我们可以在components.c或类似的启动文件中找到如下关键代码/* 通常位于 rtthread_startup() 函数相关的实现中 */ /** * 这是RT-Thread的$Sub$$main函数。 * 它替换了编译器库的__main或直接由启动文件调用的main。 */ void $Sub$$main(void) { /* 1. 关闭全局中断 */ rt_hw_interrupt_disable(); /* 2. 调用RT-Thread的启动函数 */ rtthread_startup(); /* 3. rtthread_startup()理论上不应返回。 如果返回则调用原始的main通常是一个空函数或错误处理 */ $Super$$main(); } /** * RT-Thread完整的启动函数 */ int rtthread_startup(void) { /* 硬件初始化堆栈、时钟、内存保护单元等 */ rt_hw_board_init(); /* 打印RT-Thread版本信息 */ rt_show_version(); /* 初始化系统定时器如SysTick */ rt_system_timer_init(); /* 初始化调度器 */ rt_system_scheduler_init(); /* 初始化应用内存堆 */ #ifdef RT_USING_HEAP rt_system_heap_init((void*)HEAP_BEGIN, (void*)HEAP_END); #endif /* 初始化系统对象如信号量、互斥锁的容器 */ rt_system_object_init(); /* 初始化主板上的设备如UART */ rt_hw_usart_init(); /* 初始化控制台将输出重定向到串口 */ rt_console_set_device(RT_CONSOLE_DEVICE_NAME); /* 初始化组件如FinSH命令行、设备框架 */ rt_components_init(); /* 初始化定时器线程 */ rt_system_timer_thread_init(); /* 初始化空闲线程 */ rt_thread_idle_init(); /* 启动调度器开始多任务运行 */ rt_system_scheduler_start(); /* 调度器启动后不会返回除非发生严重错误 */ return 0; }而用户编写的应用程序入口在RT-Thread中通常被定义为一个线程。例如在main.c中#include rtthread.h /* 用户的“main”函数现在是一个线程入口 */ static void user_main_thread_entry(void *parameter) { /* 这里才是用户以前写在main()里的代码 */ rt_kprintf(User application started.\n); while (1) { /* 你的业务逻辑 */ rt_thread_mdelay(1000); } } /* 线程初始化函数由RT-Thread在components_init阶段自动调用 */ int rt_application_init(void) { rt_thread_t tid; tid rt_thread_create(user_main, user_main_thread_entry, RT_NULL, 2048, 20, 5); if (tid ! RT_NULL) { rt_thread_startup(tid); } return 0; }整个启动链条的梳理芯片复位执行启动文件Reset_Handler。启动文件初始化数据/BSS后调用main函数或__main后再调main。链接器将main的调用重定向到RT-Thread定义的$Sub$$main。$Sub$$main立即关闭中断防止在初始化过程中被中断打断然后调用rtthread_startup()。rtthread_startup()按顺序初始化硬件、内核、组件、设备驱动最后创建空闲线程并启动调度器。调度器开始工作后会执行就绪队列中优先级最高的线程。在初始化阶段rt_application_init()被调用它创建了用户的user_main线程。用户的user_main线程进入就绪状态由调度器调度执行。此时多任务环境已经建立用户的代码开始运行。注意rtthread_startup()中的rt_system_scheduler_start()是永不返回的除非出错。因此$Sub$$main中那行$Super$$main()在正常情况下永远不会被执行到。那么那个“原始的main”$Super$$main()到底是什么在RT-Thread的工程模板里它通常是一个空函数或极简函数仅仅是为了满足链接器的要求防止因找不到main符号而报错。/* 这是一个弱定义的、原始的main函数作为$Super$$main的指向 */ int main(void) { /* 可能什么都不做或者只做一个错误提示 */ // while(1); return 0; }5. 对比分析GCC环境下的启动差异ARMCC/Keil的$Sub$$和$Super$$非常方便那在更流行的GCC如ARM-none-eabi-gcc环境下RT-Thread或其他系统如何实现类似功能呢GCC没有$Sub$$语法但它提供了其他强大的机制5.1 修改启动文件最直接最粗暴也最有效的方法是直接修改启动文件如startup_stm32fxxx.s将最后跳转到main的指令改为跳转到你自己的系统启动函数如rtthread_start。; 原来的代码 ; bl SystemInit ; bl __main ; 或 bl main ; 修改后的代码 bl SystemInit bl rtthread_start ; 直接跳转到RT-Thread入口 ; ; 不再调用main这种方式需要为每个芯片型号维护一个修改过的启动文件不够优雅但绝对可控。5.2 使用链接器脚本与入口点--entry链接器脚本.ld文件可以指定程序的入口点ENTRY。默认入口点是启动文件中的Reset_Handler。我们可以在链接时通过-e参数或修改链接脚本将入口点设置为rtthread_start。这样启动文件执行完后会自动进入rtthread_start完全绕过了main。5.3 利用GCC的构造函数属性.init_arrayGCC允许将函数放到特定的初始化段如.init_array中这些函数会在main函数之前自动执行。RT-Thread的GCC移植版可能会利用这个特性。void __attribute__((constructor)) rt_early_init(void) { // 此函数会在main之前执行 rt_hw_interrupt_disable(); // ... 部分初始化 }但这种方法通常用于补充初始化要完全取代main的控制流仍需结合方法1或2。5.4 提供自己的__main或_start实现在一些裸机GCC环境中main是由_start或__libc_init_array等库函数调用的。我们可以重写这些底层的库函数或链接自己的启动文件从而接管控制权。这需要较深的工具链知识。RT-Thread的GCC适配在实际的RT-Thread GCC移植中如针对STM32的工程通常采用方法1修改启动文件。你可以在bsp/stm32/libraries/HAL_Drivers或bsp/xxx/gcc目录下找到修改过的startup_xxx.s文件其中已将main的调用替换为entry函数的调用而entry函数内部再调用rtthread_startup()。6. 实战启示不止于RT-Thread的编程技巧理解$Sub$$main机制不仅能让你读懂RT-Thread的启动更能提升你的嵌入式系统级编程能力。6.1 应用场景一系统级调试与追踪你可以在产品开发中利用这个钩子函数在不修改业务代码的情况下注入调试代码。void $Sub$$main(void) { /* 记录启动时间戳 */ uint32_t start_tick systick_get(); /* 初始化一个用于记录启动流程的调试缓冲区 */ debug_log_init(); $Super$$main(); // 执行原程序 /* 如果main返回通常不会记录运行时间 */ uint32_t run_time systick_get() - start_tick; debug_log(main ran for %lu ms\n, run_time); }6.2 应用场景二硬件早期初始化与安全加固有些硬件必须在任何C代码包括库初始化运行之前配置。例如配置看门狗、初始化关键电源芯片、设置内存加速模块如Flash加速器ART Accelerator。void $Sub$$main(void) { /* 1. 立即配置独立看门狗防止后续初始化卡死 */ IWDG_Init(500ms); /* 2. 在系统时钟初始化前先配置Flash延迟周期对于高主频芯片至关重要 */ FLASH_SetLatency(FLASH_Latency_5); /* 3. 初始化外部RAM为内存堆做准备 */ extern_ram_init(); /* 4. 现在才调用库的初始化$Super$$main会触发__main进行数据段拷贝等 */ $Super$$main(); }6.3 应用场景三多引导或bootloader交互在带有bootloader的系统中应用程序的main可能被bootloader调用。你可以在$Sub$$main里先检查bootloader传递过来的标志如存放在特定RAM区域决定是执行正常启动、进入固件升级模式还是工厂测试模式。void $Sub$$main(void) { boot_info_t* info (boot_info_t*)BOOT_SHARED_MEM_ADDR; if (info-magic BOOT_MAGIC_NUM) { switch(info-command) { case CMD_APP_RUN: // 正常启动应用 break; case CMD_ENTER_DFU: // 跳转到DFU模式 jump_to_dfu(); break; default: // 未知命令进入安全模式 enter_safe_mode(); } // 清除标志 info-magic 0; } $Super$$main(); }6.4 注意事项与常见坑点编译器依赖$Sub$$和$Super$$是ARM Compiler的特性在IAR、GCC或其他编译器上无效。编写可移植代码时需要用条件编译隔开。#if defined(__CC_ARM) || defined(__CLANG_ARM) // ARMCC or ARMCLANG void $Sub$$main(void) { /* ... */ } #elif defined(__GNUC__) // GCC // 使用GCC的替代方案如修改启动文件或使用constructor #endif栈空间考虑$Sub$$main和$Super$$main共享同一个栈在启动阶段建立的栈。如果你的$Sub$$main函数使用了大量局部变量或调用层次很深要确保启动文件中分配的栈空间足够大否则会导致栈溢出引发难以调试的硬件错误HardFault。初始化顺序在$Sub$$main中调用$Super$$main()之前C运行时环境数据段、BSS段可能还未初始化取决于启动文件是调用__main还是main。因此在$Sub$$main的最开头避免使用已经初始化的全局变量或静态变量它们可能还是垃圾值。最好只进行不依赖这些变量的硬件操作。中断状态如RT-Thread所示在$Sub$$main一开始就关闭全局中断是一个好习惯可以防止在初始化完成前发生中断导致不可预知的行为。在$Super$$main()被调用或在你初始化完中断控制器后再打开中断。7. 从启动过程看RT-Thread的设计哲学通过剖析$Sub$$main这个点我们实际上窥见了RT-Thread乃至许多优秀RTOS的一个重要设计哲学非侵入式与可裁剪性。对用户代码的“零”侵入用户依然可以写一个看似标准的main函数尽管在RT-Thread中它可能是个空函数。RT-Thread通过链接器技巧在背后完成了所有“魔法”用户无需为了适应RTOS而大幅修改原有的编程思维尤其是从裸机过渡时。这与一些需要用户显式调用OSInit()和OSStart()的OS有所不同。明确的初始化阶段划分rtthread_startup()函数像一份清晰的清单罗列了从硬件到软件、从内核到组件的完整初始化步骤。这种模块化的启动过程使得调试、裁剪通过RT_USING_XXX宏都变得非常清晰。你可以精确地知道哪个功能在哪个阶段被初始化。将应用作为线程将用户应用main函数转换为一个线程是RT-Thread作为实时操作系统的核心体现。这意味着用户的代码天生就在多任务环境中可以方便地使用延时、信号量、消息队列等RTOS特性同时也受调度器管理不会独占CPU。理解启动过程是理解一个系统如何“活”起来的第一步。$Sub$$main和$Super$$main这对符号就像一把钥匙打开了RT-Thread启动序列的大门。它不仅仅是ARMCC的一个冷门特性更是一种优雅地接管程序控制流的设计模式。无论你是在深度定制RT-Thread还是在设计自己的嵌入式系统框架亦或是仅仅为了在调试时增加一个全局的跟踪点掌握这个知识点都将让你对程序运行的底层逻辑有更深刻的把握。下次当你看到main函数时不妨想一想在它之前有多少幕后英雄已经默默完成了它们的工作。
返回列表