做过 STM32 启动流程相关工作的朋友,应该都有过这种经历:一块 STM32H573 板子,上电后行为很怪,你怀疑它压根没跑进自己的 App,而是停在了 ST System Bootloader 里。烧录器连上看不到程序指针,BOOT0 引脚量着又是对的,于是来回复位、插拔线、甚至换芯片,折腾半天。其实 H573 上是有办法在固件里直接判断“当前到底运行在哪段代码”的,而且不只一种。这篇文章我把能落地的检测方法整理了一遍,包括向量表判断、启动源寄存器判断、协议握手判断,还附了可以直接改着用的 C 代码,以及我在 H573 实际项目里踩过的几个坑。
1. 在 H573 上为什么需要搞清楚“当前跑的是谁”
1.1 一个把问题逼出来的现场
我之前调试一块 H573 的工程,App 里做了 IAP 升级功能:需要跳到 ST 出厂自带的 System Bootloader,让产线用串口下载固件。现象是跳转之后串口完全无响应,设备像死了一样。我最初以为是跳转代码写错了,反复检查函数指针、栈对齐、中断失能,都没问题。
后来加了一句日志:在 main 函数最开头打印复位原因和 PC 的大致位置。结果发现,设备根本没有跳进我们写的 App,而是直接从系统 Bootloader 启动的。因为产线在上一轮测试里把选项字节里的启动源改成了 System Memory,而我们的 App 跳转代码又假设“默认从 Main Flash 启动”,两个逻辑互相打架,设备每次上电都卡在 Bootloader 等待握手。从那以后我就在所有 H573 固件里加了一个“当前运行环境检测”,专门回答一个问题:现在运行的是我的 App,还是 ST System Bootloader。
1.2 H573 的启动链与 ST System Bootloader 的触发条件
STM32H573 启动源不是只有 BOOT0 引脚决定的,实际上它的启动路径比老型号复杂不少。H573 属于 STM32H5 系列,复位后进入哪个存储器区域,由 BOOT0 引脚和选项字节 nBOOT0、nBOOT1、nBOOT_SEL 共同决定。简单说:
- nBOOT_SEL 为 0 时,完全看 BOOT0 引脚电平;
- nBOOT_SEL 为 1 时,用户选项字节里的启动配置生效;
- 启动目标可能是 Main Flash、System Memory、SRAM,在启用安全功能时还可能是 Secure Flash 等区域。
其中 System Memory 就是 ST 出厂固化的 System Bootloader 所在区域,在 STM32H573 上起始地址是 0x0FFE0000,大小 64KB 左右。它是一段只读 ROM 代码,上电后通过 UART、SPI、I2C、FDCAN、USB 等接口等待接收数据,目的就是让你在没有烧录器的情况下往 Flash 里下载程序。
问题在于:很多新开发者以为 bootloader 问题是“下载软件才需要考虑的事情”,但实际上只要选项字节被改过、Boot 引脚悬空不稳、或者 App 跳转操作失败,H573 都可能直接跑进 System Bootloader。这个时候如果你不想拆机、不想用烧录器,最好的办法就是让固件自己先认清楚自己所处的环境。
1.3 检测结果能拿来做什么
这个检测不是锦上添花,如果做得好,能在好几个场景里省下大量时间:
- 上电日志直接打印“当前是 Bootloader 环境”或“当前是 App 环境”,调试时一眼看到状态;
- 如果设备意外进入 Bootloader,App 可以通过日志或 LED 提示操作者,避免误认为硬件坏了;
- 在 IAP 流程里,App 可以判断“自己是被用户从 Bootloader 切换回来的”,从而跳过某些初始化;
- 在做产线自动化测试时,通过检测结果决定下一步是发送固件还是进入自检流程。
所以下面是重点:用什么信号来判断。
2. 能暴露运行环境的几个关键信号
2.1 系统存储器地址是天然指纹
最直观的思路是看代码当前运行在哪一段地址空间。STM32H573 的 Main Flash 应用一般在 0x08000000 开始,SRAM 在 0x20000000 附近,而 ST System Bootloader 位于 0x0FFE0000 开始的系统存储器区域。如果 PC、向量表地址或芯片内部某个寄存器指向了 0x0FFE0000 ~ 0x0FFEFFFF 这个范围,那基本可以断定当前运行的是系统 Bootloader。
但这个“地址指纹”不能只靠看 PC 来实现。App 里随便一个函数执行时 PC 当然在 Flash 区域,这没法证明运行环境;真正有意义的是看复位向量表或复位后的执行路径。
2.2 SCB->VTOR:向量表偏移寄存器
Cortex-M33 内核里有一个寄存器叫 VTOR(Vector Table Offset Register),存放当前中断向量表的基地址。大多数 STM32 工程在启动初始化时会把 VTOR 设成 Flash 应用起始地址,例如 0x08000000。而 ST System Bootloader 是独立的固件,它有自己的一套启动流程,不会使用你 App 的向量表。
所以当你读取 SCB->VTOR 时:
- 如果它等于 0x08000000 附近,说明当前大概率跑在 Main Flash App;
- 如果它落在 0x0FFE0000 附近,说明当前很可能是 System Bootloader。
这里要加个“大概率”,因为有些 App 跑着跑着会把向量表改到 SRAM,比如为了做运行时中断向量重映射。另外,Bootloader 本身也不一定设置 VTOR,有些 ROM 代码直接依赖 CPU 复位后的默认值 0。因此 VTOR 适合做第一层快速判断,不能单独作为最终结论。
2.3 RCC->CFGR 里的启动源编码
STM32H5 系列在复位后会记录实际启动源,这个信息在 RCC 的 CFGR 寄存器里,对应一个叫 BOOT_MEM 的位域。通过读取这个字段,可以知道这次上电是从 Main Flash、System Memory 还是其他区域启动的。
我习惯把它当作“当前复位周期启动源”的证明。比如读到 BOOT_MEM 对应 System Memory 的编码,那就说明复位后 CPU 拿到了 System Bootloader 的入口地址。这个值和 VTOR 配合起来,基本能锁定结论。
需要强调:具体位域位置和编码值,不同头文件、不同参考手册可能差一点,写代码时一定要去查你手上的 RM0481 和设备 SVD 文件,不要拿网上老旧 STM32F1/F4 的寄存器定义硬套。
2.4 bootloader 协议握手
还有一个更“行为级”的检测方法:主动向芯片外设发送 ST System Bootloader 的握手命令,看它是否返回 ACK。ST 系统 Bootloader 常见的 USART 协议里,0x7F 是同步命令,如果设备正处于 Bootloader 并成功识别,会回应 0x79。
这个方法的好处是不用关心地址映射、不用访问可能被保护的 System Memory 区域,只要 Bootloader 在用某一个串口等待命令,你发一个字节就能测出来。缺点也很明显:需要占用一个外设接口,而且如果设备当前跑的是 App,这个额外发送可能会被 App 误当成业务数据。
2.5 方法对比
我把三种主流方法列个表,方便你在工程里选型:
| 方法 | 判断依据 | 侵入性 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| VTOR 地址判断 | 向量表基地址范围 | 无,只读寄存器 | 中,App 可能改 VTOR | App 启动早期快速初判 |
| RCC 启动源判断 | BOOT_MEM 位域编码 | 无,只读寄存器 | 高,反映本次复位启动源 | 复位后尽早执行 |
| 协议握手 | 外设总线收到 0x79 ACK | 需占用外设并主动发送 | 高,但可能干扰业务数据 | 无法访问寄存器/安全隔离时 |
实际项目里我建议至少同时用前两个,而不是只依赖某一个。后面代码部分就是按这个组合思路写的。
3. 可落地的检测代码与组合判断
3.1 用 VTOR 判断的代码
先给一个最简单的 VTOR 范围判断。这段代码依赖 CMSIS,可以直接放到 H573 工程里。
#include "stm32h5xx.h" #define ST_SYSMEM_BASE 0x0FFE0000u #define ST_SYSMEM_END 0x0FFEFFFFu static int is_in_st_system_bootloader_by_vtor(void) { uint32_t vtor = SCB->VTOR; if ((vtor >= ST_SYSMEM_BASE) && (vtor <= ST_SYSMEM_END)) { return 1; } return 0; }如果你用的是 STM32CubeMX 生成的代码,通常在 SystemInit 里会给你执行一行SCB->VTOR = FLASH_BASE,所以在 App 启动后 VTOR 大概率等于 0x08000000。如果这个函数返回 1,基本可以肯定当前不是你的 App。
这里有个细节:SCB->VTOR 在复位后可能为 0,而 0 地址在多个启动源之间是重映射的。所以如果你想更严谨一些,当 VTOR 等于 0 时,还应该继续读 RCC 的启动源字段,而不是直接下结论。
3.2 用 RCC 启动源编码判断的代码
下面这段代码是读取 RCC->CFGR 里 BOOT_MEM 的位域,然后判断它是否等于 System Memory 启动编码。注意,我用了宏占位,实际值请根据 RM0481 和你的头文件替换。
/* 请根据实际 SVD 或参考手册确认以下定义 */ #define RCC_CFGR_BOOT_MEM_Pos (26u) #define RCC_CFGR_BOOT_MEM_Msk (0x3u << RCC_CFGR_BOOT_MEM_Pos) #define RCC_CFGR_BOOT_MEM_SYS (0x1u << RCC_CFGR_BOOT_MEM_Pos) static int is_in_st_system_bootloader_by_bootmem(void) { uint32_t boot_mem = (RCC->CFGR & RCC_CFGR_BOOT_MEM_Msk); if (boot_mem == RCC_CFGR_BOOT_MEM_SYS) { return 1; } return 0; }读取时机很关键:这个字段是复位时锁存下来的,虽然名字叫 CFGR,但它不是给你随意拿来存业务状态的地方。我习惯在 main 函数第一行就采样,最多放到启动汇编之后、任何外设初始化之前。如果拖到 HAL 初始化最后再读,某些库代码可能已经改写了 CFGR 的相关位,虽然这种情况不常见,但没必要冒险。
3.3 用 UART 协议探测的代码
当寄存器访问可能被安全机制拦截,或者你想通过“现场探测”确认 bootloader 是否真的在某个串口上等待命令,可以用握手方式。下面用 HAL 库写一个最小例子。
#include "stm32h5xx_hal.h" static int probe_st_bootloader_uart(UART_HandleTypeDef *huart) { uint8_t cmd = 0x7F; uint8_t ack = 0; /* 发送之前记得清空接收缓冲,避免读到脏数据 */ __HAL_UART_CLEAR_OREFLAG(huart); if (HAL_UART_Transmit(huart, &cmd, 1, 50) != HAL_OK) { return 0; } if (HAL_UART_Receive(huart, &ack, 1, 50) != HAL_OK) { return 0; } return (ack == 0x79); }看起来简单,实际使用时要注意几点。第一,Bootloader 的 USART 默认格式通常不是 8-N-1,AN3155 里明确约定是 8 位数据、偶校验、1 个停止位,也就是 8E1。如果你把串口配成了 8N1,很多时候也能通,因为握手字节 0x7F 恰好避开校验位敏感的临界情况,但为了稳妥,建议按照 AN3155 配。第二,H573 的 bootloader 可能支持自动波特率检测,但你要保证起始波特率在一个合理范围,比如 9600 到 115200 之间,过低或过高都容易失败。第三,如果你在 App 里跑这个探测,发送的 0x7F 会被 App 自己的串口接收逻辑当成普通数据,所以这个函数只建议在启动早期或进入 bootloader 管理模式时使用。
3.4 把三者组合成一个函数
单独用一种方法都有盲区,我最终的组合判断逻辑是这样:先看 VTOR,如果命中系统存储器范围,直接返回 Bootloader;如果没命中,再看 RCC 的 BOOT_MEM;如果前两个都不能确认,最后用串口探测兜底。
typedef enum { RUN_ENV_APP_FLASH = 0, RUN_ENV_ST_BOOTLOADER, RUN_ENV_UNKNOWN } run_env_t; run_env_t detect_run_environment(UART_HandleTypeDef *optional_uart) { /* 第一层:向量表地址 */ if (is_in_st_system_bootloader_by_vtor()) { return RUN_ENV_ST_BOOTLOADER; } /* 第二层:复位启动源 */ if (is_in_st_system_bootloader_by_bootmem()) { return RUN_ENV_ST_BOOTLOADER; } /* 第三层:行为探测。如果你不想占用串口,可以跳过这一层 */ if (optional_uart != NULL) { if (probe_st_bootloader_uart(optional_uart)) { return RUN_ENV_ST_BOOTLOADER; } } return RUN_ENV_UNKNOWN; }返回 RUN_ENV_UNKNOWN 不代表不是 Bootloader,它只是说你没拿到足够证据。工程上我通常会把 UNKNOWN 当作 App 处理,因为继续执行业务逻辑总比卡死强,但不做任何会破坏 Bootloader 状态的操作,比如不擦写整个 Flash。
4. 边界情况与我在实际项目里踩过的坑
4.1 VTOR 不一定给你预期的值
我第一次在 H573 上跑 VTOR 判断,就遇到一个恶心问题:设备明明在系统 Bootloader,但 SCB->VTOR 读回来是 0,而不是期待的 0x0FFE0000。原因也很简单,ROM 里的 Bootloader 根本不需要像 App 那样执行 SystemInit 去设置 VTOR,它直接用默认的 0 地址映射。CPU 通过地址重映射把 System Memory 映射到 0 地址,所以向量表实际基地址在逻辑上可以认为是 0。
如果你只写了 3.1 的代码,就会漏判。这也是我坚持要加 RCC BOOT_MEM 判断的原因。反过来,有些 App 跑起来之后把 VTOR 改到 SRAM,比如为了给 USB 或者 RTOS 做向量表重定向,这个时候 VTOR 既不是 0x08000000,也不是 0x0FFE0000,单看它同样没法判断。所以把 VTOR 当作第一层过滤没问题,绝不能当作唯一证据。
4.2 BOOT_MEM 的读取时机比你想的更敏感
BOOT_MEM 记录的是本次复位选择的启动源,但它本质上是个状态寄存器,不是保险箱。HAL 库在时钟初始化过程中,会对 RCC 寄存器做很多配置操作,某些底层代码可能会把 CFGR 重新写入。虽然不是每个工程都会中招,但我在 H573 加 MCO 时钟输出时,调了 RCC 的 MCO 选择位,居然把 BOOT_MEM 字段覆盖掉了。
所以正确姿势是:在 main 函数最开头,甚至是在编译器初始化全局变量之前,先把 RCC->CFGR 和 SCB->VTOR 读出来存到两个静态变量里。后面所有检测逻辑都用这两个快照,而不是运行时反复读寄存器。这一步能避免非常多诡异的“偶尔有效、偶尔无效”现象。
static uint32_t g_boot_cfgr_snapshot; static uint32_t g_vtor_snapshot; void run_env_capture_early(void) { g_boot_cfgr_snapshot = RCC->CFGR; g_vtor_snapshot = SCB->VTOR; }然后所有判断函数都基于快照实现。这个习惯后来帮我排查过好几类诡异问题,强烈推荐。
4.3 TrustZone 和 Secure Manager 会改变访问权限
STM32H573 是带 TrustZone 的型号,如果你启用了 Secure Manager,或者启用了 Secure Firewall,情况会变得复杂。非安全世界的代码去读取 0x0FFE0000 区域的向量表,或者访问某些与安全相关的 RCC 位,可能会直接触发 HardFault。
我遇到过一台启用 Secure Boot 的 H573,App 跑在 Non-Secure 侧,我按老办法在 App 里直接读 0x0FFE0000 的栈顶值做比对,结果一读就进 HardFault。后来排查才发现是 Secure Firewall 把 System Memory 区域设为安全属性了。
应对方案有两个:
- 把检测函数放到 Secure 侧,由 Secure 代码读寄存器,再通过安全 IPC 把结果告诉 Non-Secure App;
- 放弃直接读地址,改用协议握手法,因为 Bootloader 的串口通信本身不要求你直接读 System Memory 地址。
如果你做的项目默认不开 TrustZone,这个坑可能遇不到。但只要产品涉及安全启动、代码保护,就必须把它纳入设计考虑。
4.4 协议探测会遇到的误判
协议握手看起来最无脑,实际坑也不少。H573 的 Bootloader 支持的串口引脚不是随便哪个串口都行,具体哪些 USART、映射到哪些引脚,得看 AN2606。我最初随便选了一个 USART3 去做探测,结果 Bootloader 根本不监听那个串口,发多少次 0x7F 都不会有反应。
后来换到 AN2606 里明确列出的 USART1,才正常。注意,H573 的 Bootloader 文档里给出的引脚映射可能和你在 CubeMX 里默认配置的不一样,要仔细对照表格,尤其是 AF 复用编号。
另一个坑是校准波特率。有些 STM32 的旧 Bootloader 要求主机先按特定波特率发同步字节,然后 Bootloader 自动适配。H573 的 Bootloader 芯片版本比较新,支持自动波特率检测,但如果你发送端的精度太差,或者线缆太长导致信号畸变,握手一样失败。我后来在产线上加了回环测试,先自己发一个字节再接收,确认线路没问题,才把串口探测的误判概率压下去。
另外,探测返回的 0x1F 是 NACK,它代表设备在串口上收到了数据,但数据不是 Bootloader 约定的同步字节,或者当前状态不接受这个命令。如果你收到 0x1F,至少说明这个串口通了,只是协议状态不对,可以继续排查波特率或引脚配置。
5. 把检测逻辑放进工程后怎么用最顺手
5.1 启动日志与运行环境标注
我在 H573 固件的启动早期会做这样一件事:把检测结果拼进日志头,这样无论设备在什么状态下启动,调试串口第一行就会明确告诉你它现在在哪。
[BOOT] run env = ST SYSTEM BOOTLOADER [BOOT] run env = APP FLASH (0x08000000)这行日志价值很大,尤其在远程调试或者产线分析时,不需要猜,不用连烧录器,一条串口线就能定位问题。
5.2 与 IAP 流程配合:备份寄存器记录跳转意图
如果你自己实现了跳转到 ST System Bootloader,比如把函数指针指向 0x0FFE0000 后复位,那么建议在跳转前往备份寄存器里写一个 magic 值。等 Bootloader 完成工作、系统复位回到你的 App 后,启动早期读取备份寄存器,如果 magic 值匹配,就说明“刚才那次启动是从 Bootloader 切换回来的”。
这个技巧和运行环境检测是互补关系:运行环境检测回答“当前在哪”,备份寄存器回答“之前发生了什么”。两个信息合起来,就能设计出非常稳健的 IAP 状态机。
#define MAGIC_JUMP_TO_BL 0xB007B007u void jump_to_system_bootloader(void) { /* 备份寄存器需要使能 PWR 时钟和备份域访问 */ HAL_PWR_EnableBkUpAccess(); WRITE_REG(TAMP->BACKUP_REG[0], MAGIC_JUMP_TO_BL); /* 复位前关闭中断,恢复默认栈指针,跳转 */ __disable_irq(); HAL_NVIC_SystemReset(); }在 App 启动时读这个 magic,如果匹配,就执行升级结果确认、日志标记或回滚逻辑。如果检测到当前处于系统 Bootloader 环境,同时备份寄存器又写过 magic,说明跳转目标正确,只是 Bootloader 还没干完活。
5.3 生产测试和返修场景下的快速定位
产线上最容易出现的一种情况是:板子从上一道工序出来,Boot 引脚或选项字节被改掉,导致整批设备不跑 App。如果没有运行环境检测,产测软件只能傻等超时,然后报“通信失败”。有了检测逻辑后,产测固件可以主动上报一个状态码,比如 0xA5 表示“当前在 Bootloader,请重新烧写选项字节”,0x5A 表示“当前在 App,可以进入自检”。一线工人的处理路径就完全不一样了。
返修场景更实用。维修人员拿到板子,不知道上一手做了什么,接上串口看第一行日志就基本能确定问题范围。这种“减少猜测、让状态自己说话”的做法,对交付和售后都有很大价值。
5.4 建议的工程封装方式
我最终把这段逻辑封装成了一个独立模块,不依赖任何 HAL 外设初始化,只在调用时传入一个可选的串口句柄。
- 模块只向上层提供一个枚举结果;
- 所有寄存器读取全部基于启动早期的快照;
- 串口探测作为一个独立的可选函数,由业务逻辑决定是否调用;
- 模块内不引入任何延时函数,避免阻塞启动流程。
这样封装的好处是,无论你是用裸机、RTOS 还是带 Secure Manager 的复杂工程,都能直接复用。即便将来从 H573 换到其他 Cortex-M33 的 STM32 型号,只需要改几个基地址和寄存器位域,逻辑完全不用重写。
我在实际项目里跑这套检测,最大的感受是:不要指望一个信号就给出百分百确定的答案,把 VTOR、BOOT_MEM、协议握手三个互相独立的信号组合起来,比迷信任何一个都靠谱。H573 的启动链路已经很复杂了,再用单点判断去赌,只会白白消耗调试时间。