启动过程

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

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() 直接开始。典型流程为:

  1. 复位硬件根据 BOOT 配置选择启动映射。
  2. Cortex-M3 读取向量表前两个 32 位字,装入主栈指针 MSP 和程序计数器 PC。
  3. 执行 Reset_Handler。
  4. 建立 C 语言运行环境:复制已初始化数据、清零未初始化数据,执行必要的系统和运行库初始化。
  5. 调用用户 main()。

课程把中间阶段统称为 bootloader,便于口头讲解但技术上不准确。启动文件、芯片固化 Bootloader 和用户自定义 Bootloader 是三个不同概念,不能混为一谈。

1.1.1 芯片内部引导

STM32F103 在复位后采样 BOOT1/BOOT0,并把选中的存储区映射到 Cortex-M3 固定的启动地址 0x00000000:

BOOT1BOOT0启动区典型用途
任意0Main Flash正常运行已经烧入片内 Flash 的应用
01System Memory运行 ST 出厂固化的系统 Bootloader,用 USART 等接口重新编程
11Embedded 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 实际执行顺序是:

  1. 把 Flash 中的 .data 初始值复制到 SRAM。
  2. 把 SRAM 中的 .bss 区域清零。
  3. 调用 CMSIS SystemInit()。
  4. 调用 RT-Thread 的 entry()。
  5. 若 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 文件则用来确认版本和工具链的具体实现。

比起只搜索零散博客,更可靠的核对顺序是:

  1. 查看项目 rtconfig.h,确认 RT-Thread 版本、用户 main、堆、软定时器等配置。
  2. 查看真实参与构建的 startup_*.S。
  3. 跟踪 components.c 中与当前编译器匹配的条件分支。
  4. 用链接映射确认 entry、rtthread_startup、main_thread_entry 和用户 main 最终来自哪个目标文件。
  5. 再用官方文档补足总体机制。

官网还提供源码和各开发板 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 StudioRT-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.2 components.c;BOOT 模式、启动地址取值与系统 Bootloader 已按 ST 官方 RM0008 参考手册核对;RT-Thread 多工具链入口和内核初始化顺序参考官方内核基础文档,Env 与命令含义参考官方 Env 文档和 SCons 文档。