启动过程
264.2.15启动过程
源码边界说明: 本节没有新增或修改应用层业务代码,只阅读 STM32 启动汇编、CMSIS
SystemInit()和 RT-Thread 内核components.c。按照项目要求,不把这些启动文件与内核库函数复制到文首冒充业务源码;正文只提取课程实际查看的调用关系,并以配套12TIMER工程源码逐项校正。
一、单片机启动过程
程序员把业务写在 main() 中,不代表 CPU 上电后的第一条指令就是 main()。从复位到用户代码之间,至少要完成启动存储区选择、取得初始栈顶与复位入口、初始化 C 运行环境以及系统/内核初始化。
对本课程 STM32F103 + GCC + RT-Thread 5.0.2 工程,主链路是:
上电或复位
↓
采样 BOOT0/BOOT1,选择启动存储区并映射到 0x00000000
↓
Cortex-M3 从向量表取初始 MSP 和 Reset_Handler 地址
↓
Reset_Handler:复制 .data、清零 .bss、调用 SystemInit()
↓
entry()
↓
rtthread_startup():板级与内核初始化、创建系统线程
↓
启动调度器
↓
main_thread_entry():先初始化组件,再调用用户 main()
所以“裸机运行 main 函数、RTOS 运行 main 线程”可作为入门记忆,但更准确的说法是:两者都先执行芯片复位和 C 运行环境初始化;裸机启动代码最终调用用户 main(),本 RT-Thread 工程则先初始化内核并创建一个名为 main 的线程,该线程再调用用户 main()。
1.1 裸机的启动过程
裸机工程没有线程调度器,但也不是从业务 main() 直接开始。典型流程为:
- 复位硬件根据 BOOT 配置选择启动映射。
- Cortex-M3 读取向量表前两个 32 位字,装入主栈指针 MSP 和程序计数器 PC。
- 执行
Reset_Handler。 - 建立 C 语言运行环境:复制已初始化数据、清零未初始化数据,执行必要的系统和运行库初始化。
- 调用用户
main()。
课程把中间阶段统称为 bootloader,便于口头讲解但技术上不准确。启动文件、芯片固化 Bootloader 和用户自定义 Bootloader 是三个不同概念,不能混为一谈。
1.1.1 芯片内部引导
STM32F103 在复位后采样 BOOT1/BOOT0,并把选中的存储区映射到 Cortex-M3 固定的启动地址 0x00000000:
| BOOT1 | BOOT0 | 启动区 | 典型用途 |
|---|---|---|---|
| 任意 | 0 | Main Flash | 正常运行已经烧入片内 Flash 的应用 |
| 0 | 1 | System Memory | 运行 ST 出厂固化的系统 Bootloader,用 USART 等接口重新编程 |
| 1 | 1 | Embedded SRAM | 从 SRAM 启动,主要用于特殊调试或下载场景 |
因此,课程开发板把两个跳帽都接到 GND 时,确实是 BOOT1=0、BOOT0=0,从主 Flash 启动;但决定主 Flash 模式的关键是 BOOT0=0,BOOT1 此时无关,不是必须两个都为 0。
启动跳帽的硬件含义也要理解:
- 跳帽把 BOOT 引脚稳定连接到 3.3 V 或 GND,分别形成逻辑 1 或 0。
- 输入不能悬空,否则上电时可能受噪声影响而误选启动模式。
- BOOT 值在复位后的时钟边沿被锁存,修改跳帽后通常需要重新复位才会生效。
- 正常开发一般保持 BOOT0 为 0;只有需要进入 ROM Bootloader 时才临时改为系统存储器启动。
“芯片内部有一段代码去加载 HEX 文件”并不准确。HEX 是下载工具在电脑侧使用的带地址文本格式;烧录时,下载器解析其中的地址和数据,把机器码写入 MCU Flash。正常从主 Flash 启动时,代码已经存放在片内 Flash,CPU 直接取指执行,并不会在每次上电时重新解析或加载 HEX。
BOOT 选择完成后,硬件从启动映射的 0x00000000 读取初始 MSP,从 0x00000004 读取复位向量并开始执行。这就是“上电后第一条软件流程”真正接入工程镜像的位置。
从 System Memory 启动时,运行的是 ST 在芯片生产阶段写入 ROM 的 系统 Bootloader,可通过芯片支持的串行接口接收并写入用户程序;从 SRAM 启动时,SRAM 断电即失,且应用还需正确准备向量表和入口,不能把它理解成普通永久启动方式。
1.1.2 Bootloader
先把三个名词分清:
| 名称 | 所在位置 | 是否必然存在 | 作用 |
|---|---|---|---|
| STM32 系统 Bootloader | 芯片 System Memory,ST 出厂固化 | 芯片提供,但只有选中该启动模式才运行 | 通过指定通信接口下载/更新程序 |
| 用户自定义 Bootloader | 用户 Flash 中预留区域 | 不一定,本课程未编写 | 校验、选择、升级并跳转到应用镜像 |
启动文件 startup_*.S | 当前应用镜像 | 普通 Cortex-M 工程通常需要 | 提供向量表和 Reset_Handler,建立 C 运行环境 |
课程打开的 startup_stm32f103xe.S 属于第三类,即启动文件,不是一个独立 Bootloader。正常主 Flash 启动且没有自定义 Bootloader 时,CPU 会直接从应用向量表进入这个文件的 Reset_Handler。
向量表的前几项形如:
第 0 项:_estack → 复位后装入 MSP 的初始栈顶地址
第 1 项:Reset_Handler → 复位后装入 PC 的入口地址
第 2 项:NMI_Handler → NMI 异常处理入口
第 3 项:HardFault_Handler → HardFault 异常处理入口
……
所以“启动代码先初始化 PC”不够准确:Cortex-M3 复位硬件会自动从向量表装入 MSP 和 PC;启动汇编负责提供这些表项以及后续初始化指令。
本课程 GCC 工程的 Reset_Handler 实际执行顺序是:
- 把 Flash 中的
.data初始值复制到 SRAM。 - 把 SRAM 中的
.bss区域清零。 - 调用 CMSIS
SystemInit()。 - 调用 RT-Thread 的
entry()。 - 若
entry()意外返回,则执行bx lr;正常情况下启动调度后不会回到这里。
为什么要处理 .data 和 .bss:
- 已赋非零初值的全局/静态变量运行时位于 RAM,但初始内容保存在 Flash 镜像的加载区,上电后必须复制。
- 未显式初始化或初始化为 0 的全局/静态变量位于
.bss,镜像只记录范围,不保存一大段零,启动时统一清零。 - 局部自动变量以后在线程或主程序栈上创建,不属于这两步的固定全局区初始化。
课程把 SystemInit() 概括为“配置系统时钟”。在本工程中还要更精确:CMSIS 的 SystemInit() 主要把 RCC 恢复到复位默认状态、开启 HSI,并设置向量表地址 SCB->VTOR;上一节配置的 HSE 8 MHz + PLL 72 MHz 是稍后在 rt_hw_board_init() 内经 clk_init() 调用 CubeMX 的 SystemClock_Config() 才完成。不能把这两个函数当成同一个时钟配置阶段。
课程裸机示例中看到的 LDR、BLX 可这样理解:
LDR r0, =函数名:把函数地址装入寄存器r0。BLX r0:跳到r0指向的函数,同时把返回地址保存到 LR,并根据目标地址处理指令状态。BL 函数名:直接进行带返回地址的相对分支。BX r0:跳到寄存器给出的地址,不保存新的返回地址。
不同工具链生成的启动文件写法会不同。本课程当前 GCC 文件直接使用 bl SystemInit 和 bl entry,不要把另一个 Keil 启动文件里的寄存器跳转逐字套到它身上。
启动文件头部虽然保留了“跳到 C 库 main”的通用说明,但实际指令已经被 RT-Thread 工程修改为 bl entry;判断行为应看真正参与构建的指令和链接映射,而不是只看模板注释。本文件中的 __libc_init_array 调用还被注释掉,这对使用 C++ 全局构造函数的工程尤其值得检查,本节纯 C 业务不受此类构造函数影响。
1.2 RT-Thread的启动过程
本工程采用 GNU GCC,配套源码与链接映射给出的实际调用链为:
Reset_Handler
├─ 初始化 .data / .bss
├─ SystemInit()
└─ entry()
└─ rtthread_startup()
├─ rt_hw_interrupt_disable()
├─ rt_hw_board_init()
├─ rt_show_version()
├─ rt_system_timer_init()
├─ rt_system_scheduler_init()
├─ rt_application_init() → 创建并启动 main 线程
├─ rt_system_timer_thread_init() → 创建并启动软定时器线程
├─ rt_thread_idle_init() → 创建并启动空闲线程
└─ rt_system_scheduler_start()
└─ main_thread_entry()
├─ rt_components_init()
└─ main()
entry() 是 GCC 条件分支中定义的一个很薄的 C 入口,只调用 rtthread_startup()。它可以类比为“接入 RTOS 初始化的入口”,但不是用户业务 main(),也不是独立 Bootloader。
rtthread_startup() 先屏蔽中断,使内核对象和调度器尚未准备好时不会被普通中断路径打乱。这里的原则是“早期初始化受控”,不是所有 BSP 内部永远不得暂时调整中断状态;本工程的板级代码会按自己的时钟和外设初始化需要管理 PRIMASK,完成后再进入统一调度阶段。
各步骤作用如下:
| 步骤 | 本工程中的主要工作 |
|---|---|
rt_hw_board_init() | 初始化系统堆、时钟、SysTick、引脚和串口,设置控制台并执行板级自动初始化项 |
rt_show_version() | 打印 RT-Thread 版本横幅 |
rt_system_timer_init() | 初始化硬定时器链表 |
rt_system_scheduler_init() | 初始化就绪队列、优先级表等调度数据 |
rt_application_init() | 创建名为 main 的线程并放入就绪队列 |
rt_system_timer_thread_init() | 在启用软定时器时创建系统 timer 线程 |
rt_thread_idle_init() | 创建最低优先级空闲线程,保证无业务可运行时仍有线程执行 |
rt_system_scheduler_start() | 选择最高优先级就绪线程,完成第一次上下文切换 |
课程口误把 idle thread 说成“空闲中断”,随后已经自行纠正。空闲线程是普通系统线程,不是 NVIC 中断;它只在没有更高优先级就绪线程时运行,常承担回收已退出动态线程资源和执行空闲钩子等工作。
本工程启用了动态堆,rt_application_init() 用 rt_thread_create() 创建 main 线程。配置和源码可确认:
- 线程名为
main; - 入口为
main_thread_entry(); - 栈大小为 2048 字节;
- 优先级为 10;
- 初始时间片为 20 tick。
它随后调用 rt_thread_startup(),这里只是把 main 线程变为就绪;在 rt_system_scheduler_start() 之前,用户 main() 还没有执行。
调度器启动后会选择最高优先级就绪线程。软定时器线程的优先级为 4,高于 main 的 10,它可能先运行并在没有软定时器可处理时挂起;随后 main 线程运行 main_thread_entry()。因此,“主线程、定时器线程、空闲线程都开始运行”应理解为它们开始由调度器管理,而不是三个线程在单核 CPU 上真正同时执行。
main_thread_entry() 先调用 rt_components_init(),遍历设备、组件、环境和应用层自动初始化表,然后才调用用户 main()。这意味着某些驱动或组件即使没有在 main() 中手工调用,也可能已由 INIT_DEVICE_EXPORT、INIT_COMPONENT_EXPORT 等宏注册并自动完成初始化。
由 INIT_BOARD_EXPORT 注册的板级项目更早在 rt_hw_board_init() 中执行,此时调度器尚未启动;其余组件初始化主要在 main 线程上下文中执行。理解这两个阶段,有助于判断初始化函数能否阻塞、是否能使用线程调度,以及为什么某些设备在进入用户 main() 前已经可用。
若用户 main() 返回,main_thread_entry() 也会返回,随后 main 线程按线程退出流程结束;它不会像传统裸机 main() 那样天然成为永久循环。因此 RT-Thread 示例通常在 main() 中维持业务循环、创建其他长期线程,或在初始化完成后允许 main 线程退出并由其他线程继续工作。
1.3 内核基础
课程最后打开 RT-Thread 官方文档的“内核基础 → 启动流程”,并介绍不同工具链、RT-Thread Studio 与 Env/SCons。这里最重要的不是记住某个 IDE 按钮,而是认识到:芯片复位流程相同,编译器运行库与 RT-Thread 接入入口可能不同。
1.3.1 RT-Thread启动流程
原稿把“Q 版本”与 sub_main 记录成一个独立版本,这是 OCR 和听写错误。课程实际讲的是 Keil/MDK 的链接器替代符号 $Sub$main 与 $Super$main。
RT-Thread 5.0.2 components.c 针对不同工具链设置了不同入口:
| 工具链 | RT-Thread 接入方式 | 用户 main() 如何被调用 |
|---|---|---|
| GNU GCC / RT-Thread Studio | 启动汇编调用 entry(),entry() 调用 rtthread_startup() | main_thread_entry() 直接调用 main() |
| ARMCC / Keil MDK | 链接器用 $Sub$main() 在原 main 前插入 rtthread_startup() | main 线程中调用 $Super$main(),即原用户 main |
| IAR | 通过 __low_level_init() 接入初始化 | main 线程中调用用户 main() |
这些符号不是可随意互换的普通命名风格,而是各工具链启动库和链接器约定。分析自己的工程时,应先确认真实编译器、实际加入构建的启动文件和链接参数,再沿调用关系跟踪。
官方文档常以 MDK 为例展示 $Sub$/$Super$,而课程配套 12TIMER 是 GCC 工程,实际启动文件明确 bl entry。二者最终目的相同:在用户 main() 前完成 rtthread_startup(),但中间路径不同。
1.3.2 RT-Thread官网
课程通过官网“文档中心 → 内核 → 内核基础 → 启动流程”查阅说明。学习启动代码时,官方文档适合建立总体框架,当前工程源码与 .map 文件则用来确认版本和工具链的具体实现。
比起只搜索零散博客,更可靠的核对顺序是:
- 查看项目
rtconfig.h,确认 RT-Thread 版本、用户 main、堆、软定时器等配置。 - 查看真实参与构建的
startup_*.S。 - 跟踪
components.c中与当前编译器匹配的条件分支。 - 用链接映射确认
entry、rtthread_startup、main_thread_entry和用户main最终来自哪个目标文件。 - 再用官方文档补足总体机制。
官网还提供源码和各开发板 BSP。下载示例前应确认芯片型号、开发板版本和工具链,不要把另一个 BSP 的启动文件或链接脚本直接替换到当前工程。
1.3.3 准备工作
原稿中的“正电原子 NANO”应为课程展示的“正点原子 NANO”,“q5/KO5”应为 Keil MDK5,“ir”应为 IAR。
一个可运行的 BSP 通常包含:
- 芯片启动文件与 CMSIS 设备支持;
- 链接脚本或 scatter 文件,规定 Flash/RAM 布局和入口;
- 板级时钟、堆、SysTick、UART 等驱动;
- RT-Thread 配置文件;
- 对应工具链的工程模板或 SCons 构建描述。
快速上手文档中的“获取源码、打开工程、编译、下载、观察串口”只是外层操作。遇到启动失败时,应进一步检查 BOOT 跳帽、下载地址、向量表、链接地址、栈顶、时钟和控制台,而不是只重复点击下载按钮。
1.3.4 RT-Thread简介
RT-Thread 文档中心把内核、组件、驱动框架和工具分开讲解。本节关注的“内核基础”连接了三层:
芯片与启动文件
↓
RT-Thread 内核初始化、系统线程和调度器
↓
用户 main() 与应用组件
以后阅读设备驱动、自动初始化或软件包时,可以先问“它在启动链的哪个阶段被创建或调用”,这样比只记函数名更容易定位初始化顺序问题。
1.3.5 RT-Thread内核基础
课程提到的 GCC、MDK、IAR 并不是三个完全同类的名字:
| 名称 | 更准确的定位 | 本节相关文件/机制 |
|---|---|---|
| GNU GCC | 编译器工具链 | GCC 汇编启动文件、链接脚本、entry() |
| Keil MDK | 集成开发环境及 Arm 工具链 | ARMCC 链接器的 $Sub$/$Super$ 机制 |
| IAR Embedded Workbench | 集成开发环境及 IAR 工具链 | __low_level_init() 等 IAR 启动约定 |
| RT-Thread Studio | RT-Thread 官方 IDE,可使用 GNU 工具链 | 本课程配套工程使用方式 |
课程中关于某个商业 IDE “会被淘汰”的说法属于个人判断,不是启动原理。工程选型应依据芯片/BSP 支持、现有代码、调试器、编译器认证、团队流程和许可证等实际条件。本节需要掌握的是不同工具链的入口差异,而不是断言某一个工具一定优于另一个。
1.3.6 RT-Thread文档中心
原稿“阿迪斯 studio”应为 RT-Thread Studio。它把工程创建、图形配置、编译、下载和调试集成在图形界面中;课程此前使用的 RT-Thread Settings、CubeMX Settings 就属于这种工作流。
除 Studio 外,RT-Thread 还提供 Env 命令行环境和 SCons 构建系统。两条路线管理的仍是同类源码、BSP 和配置,只是入口不同:Studio 偏图形化,Env/SCons 更便于脚本化构建、生成其他 IDE 工程和管理软件包。
1.3.7 开发工具
原稿中的 “EAV” 应为 Env,“SCON” 应为 SCons。Env 不是 Linux,也不是编译器本身,而是 RT-Thread 提供的命令行辅助环境,集成配置、构建和软件包管理工具;界面和命令使用方式与常见终端相似。
常见命令含义为:
| 命令 | 作用 |
|---|---|
menuconfig | 图形化裁剪内核、组件和软件包,并生成配置 |
scons | 按当前 BSP 的 SCons 描述构建工程;ARM BSP 通常默认使用随 Env 提供的 GCC |
scons -c | 清理 SCons 构建产物 |
scons --target=mdk5 | 生成 Keil MDK5 工程文件,之后由 MDK 编译/调试 |
scons --target=iar | 生成 IAR 工程文件 |
pkgs --update 等 | 下载或更新已经选择的软件包 |
scons --target=mdk5 的主要作用是生成 MDK 工程,不是直接等同于“用 MDK 编译完成”。若直接执行 scons,则由当前 CROSS_TOOL 和 EXEC_PATH 决定真实工具链。
初学阶段不必同时掌握所有 IDE,但应知道构建系统最终要完成的事情相同:选择源文件、设置宏和头文件路径、编译、链接、按链接脚本布置镜像并生成可下载文件。掌握本节启动链后,无论换 Studio、Keil、IAR 还是命令行,都能从入口和映射关系判断程序实际从哪里开始。
核对依据: 本节启动链与具体函数顺序取自课程视频、完整转录及配套
12TIMER的 GCC 启动文件、system_stm32f1xx.c、链接映射、board.c和 RT-Thread 5.0.2components.c;BOOT 模式、启动地址取值与系统 Bootloader 已按 ST 官方 RM0008 参考手册核对;RT-Thread 多工具链入口和内核初始化顺序参考官方内核基础文档,Env 与命令含义参考官方 Env 文档和 SCons 文档。