/notes/mcu/cortex-m-startup-ytm32-stm32

Firmware//20 分钟

Cortex-M 启动流程:从向量表到 main,兼谈 YTM32 与 STM32

从 Cortex-M 复位取向量的硬件规则出发,明确启动文件、链接器脚本、SystemInit 与 C 运行库各自负责什么,并梳理 YTM32 与 STM32 的零地址映射和调试边界。

Cortex-M启动文件STM32YTM32链接脚本向量表

先给结论:启动顺序由谁规定

下列流程不是 main.c、源文件排列顺序,甚至也不是某个 HAL 函数“自然发生”的结果。它由三个层次共同决定:

  1. Cortex-M 硬件架构规定:复位时从当前映射到 0x00000000 的向量表读取初始 MSP 与复位入口 PC
  2. **启动文件(startup file)**定义:向量表内容、Reset_Handler,以及 Reset_Handler 内部先后调用哪些初始化例程。
  3. 链接器脚本 / scatter file定义:向量表、.text.data.bss、RAM 代码和栈分别位于哪里,.data / RAM 代码的加载镜像从哪里取得。

可以将一份常见 C/C++ Cortex-M 镜像的启动链路写成:

复位事件
  → CPU 从 0x00000000 映射的向量表装载 MSP、PC
  → Reset_Handler(启动文件中定义)
  → 建立最小运行环境 / 可选早期芯片处理
  → 复制已初始化段:Flash LMA → RAM VMA
  → 清零 BSS:RAM → 0
  → 可选:复制或重定向中断向量表至 RAM
  → SystemInit:时钟、电源、芯片基础配置
  → C/C++ 运行库初始化(若工具链和工程使用)
  → main()

上图中的“谁先谁后”最终以项目实际启动文件为准。不同工具链可能把 SystemInit 放在数据初始化前后,也可能由运行库入口先做数据初始化再调 SystemInit。不要只凭某个厂商模板推断所有工程。

复位的固定硬件规则:MSP 与 PC 来自向量表前两项

Cortex-M 复位后并不会把 0x00000000 当作指令执行。它将该地址开始的两个 32-bit 表项当作数据读取:

MSP = *(uint32_t *)0x00000000   // 初始主栈指针
PC  = *(uint32_t *)0x00000004   // Reset_Handler 地址
  • 第 0 项必须是有效的 RAM 栈顶地址,并满足栈对齐要求。
  • 第 1 项是 Reset_Handler 的入口地址;Cortex-M 只执行 Thumb 指令,因此该地址最低位必须为 1
  • 后续项是 NMI_HandlerHardFault_HandlerSysTick_Handler 和外设中断入口。
当前映射到 0x00000000 的向量表

0x00000000  ┌──────────────────────────────┐
            │ 初始 MSP,例如 0x20008000     │ → CPU 装入 MSP
0x00000004  ├──────────────────────────────┤
            │ Reset_Handler | 1             │ → CPU 装入 PC 并开始取指
0x00000008  ├──────────────────────────────┤
            │ NMI_Handler | 1               │
0x0000000C  ├──────────────────────────────┤
            │ HardFault_Handler | 1         │
            └──────────────────────────────┘

这里的 0x0 / 0x4向量表的地址;它们对应的启动存储器是 Flash、BootROM 还是 SRAM,要看芯片当前的启动映射。

STM32 从零地址别名向量表装载 MSP 与 PC,并从 Reset_Handler 开始启动的示意图

图:以常见 STM32 从用户 Flash 启动为例。启动时 0x00000000 区域通常别名映射到用户 Flash 基址 0x08000000。因此 CPU 读取的是 *(uint32_t *)0x00000000(等效于读取 0x08000000 处的第 0 个向量表项)并装载到 MSP,而不是把该数值“压入”MSP;再读取 *(uint32_t *)0x00000004(等效于 0x08000004 的第 1 项)并装载到 PC。图中 0x080001CD 是示例 Reset_Handler 地址;最低位为 1 表示 Thumb 状态,内核实际取指地址为 0x080001CC。Reset_Handler 完成数据段、BSS、系统和运行库初始化后,通常才调用 main();它不是复位后硬件直接跳转到的下一条应用代码。

用图中的地址走一遍,哪些表述需要修正

假设当前确实配置为“从主 Flash 启动”,且图中向量表内容如下:

逻辑访问地址                  物理用户 Flash 中的内容              CPU 动作
0x00000000(别名 0x08000000)  0x20000788                          MSP ← 0x20000788
0x00000004(别名 0x08000004)  0x080001CD                          PC  ← 0x080001CD(Thumb 入口)

需要特别注意以下修正:

  • 不是“执行 0x00000000 / 0x00000004 这两个地址”。它们保存的是数据表项,硬件在复位序列中读取它们。
  • 不是“将栈顶指针压入 MSP”。CPU 是把第 0 项的值直接装入 MSP;此时尚未发生一次正常的入栈操作。
  • 对常见 STM32 用户 Flash 启动,0x00000000 → 0x08000000零地址别名映射。若芯片经 BOOT 配置改从 System Memory 或 SRAM 启动,这个映射关系不同。
  • 0x080001CD 的 bit0 是 Thumb 状态标志,向量表中应为奇数;汇编器 / 调试器显示的实际指令半字对齐地址通常是 0x080001CC。不要将 bit0 当作 Flash 中独立的一字节指令地址。
  • Reset_Handler 不是“执行完成后自然开始 main”。启动文件或运行库要显式完成或调用 .data 复制、.bss 清零、SystemInit、C/C++ 初始化后,才以函数调用方式进入 main();若 main() 返回,嵌入式启动代码通常进入死循环或复位处理,而不是返回给硬件。

启动文件实际负责什么

启动文件通常是 .s / .S 汇编文件,或由工具链提供的启动对象文件。它至少负责:

  • 导出并放置中断向量表(常见段名 .isr_vector)。
  • 在表第 0 项写入栈顶符号,如 _estack
  • 在表第 1 项写入 Reset_Handler
  • 提供弱定义的默认中断处理函数,使用户可以在 C 文件中覆盖具体 IRQ handler。
  • 实现 Reset_Handler,调用数据段初始化、芯片初始化与运行库入口。

典型 GNU 工具链启动代码会表现为以下逻辑,符号名称因链接脚本而异:

Reset_Handler:
    ldr   sp, =_estack              /* 某些启动文件会再次显式设置 SP */

    ldr   r0, =_sdata               /* RAM .data 起点(VMA) */
    ldr   r1, =_edata               /* RAM .data 终点(VMA) */
    ldr   r2, =_sidata              /* Flash .data 镜像(LMA) */
    /* copy [r2, ...) → [r0, r1) */

    ldr   r0, =_sbss
    ldr   r1, =_ebss
    /* zero [r0, r1) */

    bl    SystemInit
    bl    __libc_init_array         /* C++ 构造、部分 libc 初始化;按工程需要 */
    bl    main

loop: b loop

这段只是解释职责,不是可直接复制到所有工程的模板。比如 ARMCC/Keil 通常使用 scatter-loading 机制和 __main;它的 .data 复制、.bss 清零可能由运行库完成,而不是你在 Reset_Handler 中看到一段显式循环。

Flash、RAM 与段:谁运行在哪里,谁需要复制

区域 / 段常见内容加载地址 LMA运行地址 VMA启动动作
向量表初始 MSP、Reset/IRQ 入口Flash / BootROM当前零地址映射对应区域硬件直接读取前两项
.text普通函数、mainFlashFlash通常直接 XIP,不复制
.rodata字符串、const、只读查找表FlashFlash通常不复制
.data 镜像有初值可写变量的初始值Flash复制到 RAM .data
.data有初值全局/静态变量RAM接收 Flash 镜像
.bss初始值为 0 的全局/静态变量不占初始化镜像RAM启动清零
.ramfunc / .code_ram 镜像RAM 执行函数的机器码Flash复制到 RAM 运行区
.ramfunc / .code_ram少数 RAM 执行函数RAM必须先复制再调用
Stack返回地址、局部变量、异常现场RAM由初始 MSP 指向栈顶

普通代码默认在 Flash 就地执行(XIP)。栈存放的是调用与异常现场,不是程序代码。只有链接脚本明确把函数运行地址放进 RAM 的 .ramfunc / .code_ram,并由启动代码完成复制后,该函数才会从 RAM 执行。

为什么 .data.bss 和 RAM 代码必须区别处理

.data:既要可写,也要有掉电后的初始值

static uint32_t baudrate = 115200U;

运行时该变量必须在 RAM 才能修改,但复位后又应恢复 115200。所以固件镜像在 Flash 保存一份初始值,启动时将其复制到 RAM。

.bss:零初始化不值得在 Flash 存一堆 0

static uint8_t rx_buffer[512];

C 语言语义要求其初始为 0。把 512 个零写进 Flash 没有价值,因此链接器只记录 RAM 范围,启动代码清零即可。

.ramfunc:在特定操作期间不能继续从忙碌 Flash 取指

部分 MCU 在擦写内部 Flash 时,受影响 bank 可能无法正常读取指令或读取延迟显著增加。此时 Flash 擦写例程、它调用的关键常量和必要依赖需要放入 RAM;仅把顶层函数标成 RAM 函数并不足够,调用链仍可能从忙碌 Flash 取指。

SystemInit、C 运行库与 main:避免把职责混为一谈

SystemInit 通常来自 CMSIS 或厂商 system_<device>.c,典型职责是:

  • 复位或配置 RCC / 时钟树到一个已知状态。
  • 设置 Flash wait state、时钟源、PLL、总线分频等基础配置。
  • 处理芯片级的 FPU、缓存、TCM、外部存储接口等早期设置(依具体芯片)。
  • 在部分工程中设置 SCB->VTOR 指向应用向量表。

不是 C 运行库,也不保证替你初始化 .data / .bss。C/C++ 运行库初始化则可能包含:

  • C++ 全局 / 静态对象构造函数。
  • atexit、newlib 或半主机等库的必要初始化。
  • 工具链定义的其他 pre-init / init 钩子。

裸机 C 项目若没有 C++ 和复杂 libc,运行库动作很少;但不要因为“我的项目看起来简单”就随意跳过工具链入口,先确认启动文件和链接方式。

YTM32 与 STM32:核心相同,零地址映射必须以具体芯片手册为准

二者只要基于 Cortex-M,复位读取向量表前两项的规则完全相同。常见差异主要在 boot mode、存储器基址、零地址 alias 以及 Flash 控制器行为。

项目YTM32B1LE1x(参考笔记所述)常见 STM32(举例)工程含义
应用 Flash 基址0x00000000常见为 0x08000000不能把某一型号地址硬套到另一型号
复位读取地址0x000000000x00000000Cortex-M 固定规则
零地址映射应用 PFlash 直接映射到零地址由 BOOT 配置映射 Flash、System Memory 或 SRAM决定 CPU 实际从哪张向量表起步
普通代码执行Flash XIPFlash XIP.text 通常不复制
数据段初始化Flash → RAM / BSS 清零Flash → RAM / BSS 清零由启动文件 + 链接布局实现

对 STM32 来说,从用户 Flash 启动时,0x00000000 区域通常是对 0x08000000 用户 Flash 的 alias;从 System Memory 或 SRAM 启动时,零地址会映射到相应启动区。对 YTM32,必须以对应系列参考手册、启动模式和链接文件确认是否真的使用 0x00000000 作为应用 PFlash 基址。

不要用“STM32 都是 0x08000000”或“YTM32 都从 0 开始”这样的绝对表述。启动映射是型号、boot 引脚/option byte 和厂商实现共同决定的。

向量表重定位:什么时候需要放到 RAM

默认情况下,异常和中断入口通过 Flash 向量表找到。以下场景可能需要重定位:

  • Bootloader 跳转至非零地址的应用镜像;应用必须把 SCB->VTOR 指向自己的向量表(内核支持 VTOR 的前提下)。
  • 运行时需要动态修改中断 handler,例如某些 boot / patch / RTOS 场景;通常先复制向量表到 RAM,再改 RAM 表项。
  • 需要区分多个镜像或安全域,且启动阶段选择不同向量表。

示意代码:

#include "core_cm4.h"

#define APP_VECTOR_BASE  (0x08008000UL)

static void app_relocate_vector_table(void)
{
    /* 地址必须满足该内核实现要求的对齐;以芯片 CMSIS 定义为准。 */
    SCB->VTOR = APP_VECTOR_BASE;
    __DSB();
    __ISB();
}

重定位向量表和“把所有代码复制到 RAM”不是一件事;前者只改变异常入口表的位置。

链接器脚本:决定段地址,不决定运行时调用顺序

构建过程可概括为:

.S / .c / .cpp
  → 汇编 / 编译
.o:带有 .text、.data、.bss、.isr_vector 等 section
  → 链接器脚本(GNU ld .ld / ARM scatter .sct/.scf)
ELF / AXF / HEX / BIN:每个 section 获得最终 LMA 与 VMA
  → 烧录
启动存储器中的向量表、机器码、初始化镜像
  • CPU 不识别 .c.S.ld.scf;它只在复位后读取向量表并执行机器码。
  • 链接器放置 section、解析符号、计算 .data 的源/目的范围、生成 RAM 代码的加载镜像。
  • Reset_Handler 内的调用关系(或工具链运行库入口)才决定初始化先后。
  • 汇编中的 DCD 用于在当前位置写入 32-bit 常量,常用来构建向量表;IMPORT 仅声明符号由外部对象或链接器提供,不会自行产生数据。

调试核对清单:从复位停住时该看什么

  1. 看 VTOR / 零地址映射: 当前 CPU 预期的向量表在哪里?Bootloader 是否已切换到应用表?
  2. 看前两项: 向量表第 0 项是否是有效 RAM 栈顶;第 1 项是否是 Thumb 格式的 Reset_Handler 地址。
  3. 在 Reset_Handler 单步: .data 复制源/目的范围是否正确;.bss 是否全部清零。
  4. 核对链接 map: Flash / RAM 容量和起始地址是否与目标具体型号一致;.ramfunc 的 LMA 与 VMA 是否合理。
  5. 检查 SystemInit: 时钟源是否就绪、Flash latency 是否匹配频率、外部晶振失败时是否有可诊断的降级路径。
  6. 检查栈与堆: 栈顶、栈大小、heap 边界是否会与 .bss/.data 相撞;在 HardFault 中读取 MSP/PSP 和栈水位。
  7. 验证 RAM 函数: 复制是否发生在第一次调用前,且关键调用链、常量表或 ISR 不会继续访问忙碌 Flash。

结论

Cortex-M 启动的硬件起点只有两个字:向量表。硬件将它的前两项装入 MSP 和 PC;随后由启动文件中的 Reset_Handler 根据链接器布局建立 C/C++ 运行环境,调用芯片基础初始化和运行库,最后进入 main()

理解这个边界后,YTM32 与 STM32 的启动差异就不再神秘:内核规则一致,真正需要逐型号核对的是零地址映射、Flash/RAM 地址、boot 模式、时钟与 Flash 控制器行为。