尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32H573实战:判断当前是否运行在ST System Bootloader

STM32H573实战:判断当前是否运行在ST System Bootloader 做STM32H573的固件升级模块时遇到一个非常实际的需求怎么在代码里判断当前设备是不是跑在ST System Bootloader里。这个判断看似简单真做起来有不少细节从启动流程到TrustZone属性都要捋一遍。这篇文章把我测试过的方案、踩过的坑和最终的代码实现整理出来给需要做类似判断的朋友一个参考。先说结论最靠谱的检测思路是组合“向量表偏移寄存器(VTOR) 程序计数器(PC) 启动引脚/选项字节”三层判断单靠某一个手段都容易误判。为什么是这个组合每层判断在什么场景下会失效下面展开讲。1. 先说清楚这个检测需求到底在解决什么问题1.1 什么场景下会用到这个判断我在做的项目里H573这颗芯片同时承担了两个角色一是作为主应用处理器跑业务逻辑二是作为产线烧录和现场升级的入口设备。因为H573本身就带一个出厂固化的ST System Bootloader支持USART、I2C、SPI、USB、FDCAN多种外设升级方式所以产线初期直接用它来灌固件省掉了自己写烧录引导的环节。问题很快就来了产线上有台设备需要返工操作员把BOOT引脚拉高后设备进入了ST System Bootloader灌完固件之后没断电直接把BOOT引脚拉低设备复位的瞬间跑的还是ST Bootloader这时候设备里已经有一套完整的应用程序代码了。如果应用代码在启动早期不检查自己是不是被ST Bootloader引导的就会出现“应用以为自己正常启动了实际上外设初始化和时钟配置根本还没执行”的错乱状态。还有一种更常见的场景产品支持现场升级用户通过ST官方工具或者自研上位机把应用固件通过系统Bootloader刷进Flash刷完设备需要自动跳到应用。这种场景下如果应用内部没有环境检测万一跳转时序不对设备就卡在Bootloader里等待请求表现出的症状就是“设备连不上、好像死机了”。1.2 检测对象究竟是什么这里要明确一点我们要检测的“ST System Bootloader”和用户自己写的Bootloader是两码事。ST System Bootloader是芯片出厂固化在ROM里的一小段代码用户删不掉、改不了只能通过启动配置进入它。用户自研Bootloader一般放在Flash空间通过复位向量或跳转指令进入。这两种Bootloader在检测方式上有本质区别。检测用户自研Bootloader很简单比如在RAM里约定一个魔数地址Bootloader跳转前写入魔数应用起来后检查魔数判断“我是被Bootloader拉起来的”还是“我是冷启动直接进的”。但ST System Bootloader那段代码在ROM里你没有机会在它跳转前执行自己的代码也不能篡改它的运行逻辑所以需要借助芯片自身的寄存器状态来推断。我一开觉得这很麻烦后来捋清楚了H573的启动链路之后发现其实有至少四种方式可以判断当前环境只是每种方式都有各自的适用范围和坑。2. STM32H573 的启动链路把检测对象搞明白2.1 上电复位后到底发生了什么STM32H573是Cortex-M33内核带TrustZone安全扩展。它的复位流程比早期的STM32F1/F4要复杂不少简单说分三个阶段第一个阶段是硬件复位。芯片上电、掉电复位或者外部复位引脚拉低都算硬件复位。复位释放之后CPU从0x00000000地址取向量表但ST在H5系列上做了一个特殊设计启动地址的映射会根据选项字节和引脚状态动态调整。也就是说0x00000000这个位置实际指向哪里是由启动配置决定的。第二个阶段是RSSRoot Security Services介入。RSS是ST在H5系列上新增的安全根服务代码也固化在ROM里。它负责校验安全启动状态、检查选项字节中的安全配置、决定是把控制权交给ST System Bootloader还是直接跳转到用户Flash中的应用代码。第三个阶段才是真正的代码执行。如果启动配置指向系统BootloaderCPU会跑到系统Bootloader的ROM地址运行等待外部升级命令如果指向用户Flash则从0x08000000开始跑用户的复位向量。关键在于RSS和系统Bootloader的地址都位于0x0FF00000附近的ROM区域而用户应用在0x08000000开始的Flash区域。这两个区域的地址范围差异很大这是后面做检测的物理基础。2.2 系统Bootloader在内存中的具体位置H573的系统Bootloader地址范围以ST官方参考手册和AN2606为准大致落在0x0FF00000到0x0FF03FFF这段区域容量16KB左右。不过这个地址范围在不同批次、不同型号的H5系列芯片上可能有细微调整所以写代码时不要把这个地址写死在宏里最好通过链接脚本或者配置头文件统一管理。我一开始犯过一个错想当然地认为系统Bootloader的地址和F系列一样放在0x1FFF0000区域结果检测代码跑起来怎么都不对。后来查了AN2606才发现H5系列把它挪到了0x0FF00000。这个细节如果不看书真的会卡很久。另外由于TrustZone的存在这段ROM区域的访问权限是secure属性的。也就是说系统Bootloader运行在TrustZone的secure世界。如果用户应用跑在non-secure世界并且SAUSecurity Attribution Unit配置把0x0FF00000区域标记为secure那non-secure代码直接读取该区域的地址是会被硬件阻止的轻则读到全F重则触发总线错误或HardFault。这条坑我在调试的时候踩得很深。后面在HTM里做secure/non-secure的工程分区时由于SAU默认配置的问题non-secure侧读0x0FF00000附近的地址直接HardFault。后来我把检测逻辑放在secure侧的初始化代码里才彻底避开。2.3 “正在运行”和“曾经进入过”是两个维度很多人在做检测的时候会把两个问题混在一起一个是“当前CPU正在执行哪段代码”另一个是“当前设备是不是被Bootloader触发后落入应用的”。前者是运行时状态用VTOR和PC判断最直接后者是历史状态需要靠复位标志、RAM魔数或者引脚状态反推。比如设备上电时BOOT0引脚是高电平系统Bootloader执行起来然后你给它发了一个“跳转到用户应用”的命令Bootloader在跳转前会把用户应用的复位向量地址和初始栈指针设置好然后跳过去。此时应用里读到VTOR可能还是用户Flash的地址因为Bootloader跳转前会把它改掉PC也已经在用户Flash区域内了但这次启动的“起因”确实是系统Bootloader。反过来如果设备直接冷启动进入用户应用VTOR和PC同样在用户Flash区域看起来和上一种情况一模一样但两种场景对整个系统的意义完全不同。所以在做检测模块设计的时候先想清楚你要判断的到底是“当前在哪跑”还是“从哪里来”。这个定位决定你需要实现哪几个检测函数。我的做法是两者都做一个返回当前运行环境一个返回启动来源组合起来用。3. 四种主流检测方案横评3.1 方案一查VTOR向量表偏移寄存器这个方案的核心是读取Cortex-M33内核系统控制块中的VTOR寄存器它记录了当前正在使用的中断向量表地址。如果当前跑的是系统BootloaderVTOR十有八九指向系统Bootloader的ROM区域如果跑的是应用则指向0x08000000或应用链接脚本里指定的向量表地址。#include stm32h573xx.h #define BOOTLOADER_ADDR_START 0x0FF00000u #define BOOTLOADER_ADDR_END 0x0FF03FFFu uint8_t check_is_system_bootloader(void) { uint32_t vtor SCB-VTOR 0xFFFFFFF0u; if ((vtor BOOTLOADER_ADDR_START) (vtor BOOTLOADER_ADDR_END)) { return 1u; } return 0u; }优点很明显代码简单不依赖外设初始化在任何位置调用都能得到结果而且只要系统Bootloader本身没有移动VTOR结果就绝对可靠。但有两个坑需要注意。第一个坑Cortex-M33上VTOR只有在特权模式且非FPU访问受限状态下才能正常读写。如果你在用户应用的某个非特权线程里检测VTOR可能读不到或者读到0。第二个坑系统Bootloader早期代码有可能会临时把VTOR设置到其他位置比如RAM区域用于快速响应外设中断。如果恰好在你检测的时刻VTOR指向了RAM就会误判为“不在Bootloader中”。我在调试FDCAN升级时遇到过一次Bootloader在等待CAN数据时会临时把向量表挪到RAM持续时间很短但恰好被我的检测代码撞上了。结论VTOR适合做第一层快速判断但不要作为唯一判据。3.2 方案二读PC指针落在哪个区间另一个思路是直接读取当前程序计数器PC的值看它落在哪个地址区间。Cortex-M33没有直接读取PC的专用指令但可以通过内联汇编获得当前PC的近似值__attribute__((always_inline)) static inline uint32_t get_current_pc(void) { uint32_t pc; __asm volatile (mov %0, pc : r (pc) : : ); return pc; } uint8_t check_pc_in_bootloader(void) { uint32_t pc get_current_pc(); if ((pc BOOTLOADER_ADDR_START) (pc BOOTLOADER_ADDR_END)) { return 1u; } return 0u; }这里有一点需要说明在Thumb指令集下mov r0, pc读到的PC值实际上是当前指令地址加4流水线效应但这并不影响我们做大范围地址区间的判断误差只有几个字节。这个方案在逻辑上很直接实际使用中却有几个限制。首先是编译器优化问题。如果你没有把获取PC的代码封装成独立函数而是直接写在某个大函数里优化后的代码可能把你的检测语句挪到别的地址导致“在Bootloader中调用检测函数”时PC反而不落在Bootloader区间。其次代码重定位的影响。如果你把应用代码的一部分拷贝到RAM中执行也就是所谓的“从RAM运行”那么这段检测代码的PC值会落在RAM区域而不是Flash中的应用区间。我用这个方案检测时就因为代码在RAM中执行导致误判还以为是检测逻辑写错了。另外TrustZone下secure和non-secure代码混跑PC的地址空间属性不同裸的判断区间可能被绕过。比如在non-secure侧PC可能落在0x0F000000以下的安全区域或非安全区域地址范围和系统Bootloader的0x0FF00000没有重叠但读PC的值受优化影响很大。整体来看PC方案适合作为辅助手段用它可以确认“当前执行的代码在哪段区域”但它不太适合作为一个面向未来的、健壮的检测方案。3.3 方案三复位标志与跳转魔数第三种方案从“从哪里来”这个维度检测不直接看当前PC而是通过芯片的复位原因标志和RAM中的魔数标记来反推启动流程。STM32H5系列的RCC外设里有复位状态寄存器RCC_RSR记录了上一次复位的来源比如上电复位、外部引脚复位、看门狗复位、软件复位等。系统Bootloader运行过程中如果要跳转到用户应用通常会执行一次软复位然后让用户代码从复位向量开始跑。这样在应用里读RCC_RSR就能看到“软件复位”相关的标志。uint8_t check_reset_source_is_soft(void) { if ((RCC-RSR RCC_RSR_SFTRST) ! 0u) { return 1u; } return 0u; }但这个方案有个明显问题软件复位标志不只出现在系统Bootloader跳转时。我们自己的用户Bootloader在做OTA升级时也会用软复位跳转到新固件。如果业务代码里还有其他地方触发软复位就区分不出来了。更麻烦的是系统Bootloader跳转应用的方式不一定是软复位。部分版本的ST Bootloader会直接修改MSP和PC跳过去不产生新的复位事件这种情况下复位标志就完全不体现了。所以我在实际项目里只把复位标志当作一个辅助信息不直接依赖它判断。RAM魔数方案是我比较推荐的一个补充手段思路很简单在设备冷启动进入用户自定义Bootloader时Bootloader在跳转应用前往RAM里一个固定地址写一个特定值应用启动后去检查这个RAM地址的值是否等于约定的魔数如果相等说明是被Bootloader拉起来的。不过这个方案只适用于你自己写的Bootloader因为ST System Bootloader不会在你指定的RAM地址写魔数你也改不了它的跳转代码。所以对“检测是否在ST System Bootloader”这个需求来说RAM魔数本身没法直接判断但它可以和PC/VTOR方案配合用来区分“是不是走完了一次完整的Bootloader加载流程”。3.4 方案四选项字节与BOOT引脚电平这个方案主要看启动配置是四个方案里唯一能做到“预测性”判断的。它关注的不是当前代码跑在哪而是“芯片的启动配置是否指向系统Bootloader”。STM32H5系列支持通过选项字节和BOOT0/BOOT1引脚组合来控制启动地址。你可以在应用里读取选项字节中的启动配置位再结合BOOT引脚当前的输入状态判断如果现在复位设备会从哪里启动。uint8_t check_boot_config_select_bootloader(void) { FLASH_OBProgramInitTypeDef ob_config; if (HAL_FLASHEx_OBGetConfig(ob_config) ! HAL_OK) { return 0u; } /* nBOOT1位 BOOT0引脚电平均为1通常表示进入系统Bootloader */ if ((ob_config.BOOT1 OB_BOOT1_SYSTEM) (READ_BIT(GPIOA-IDR, GPIO_PIN_15) ! 0u)) { return 1u; } return 0u; }这个方案的优势是它不依赖当前已经跑起来的状态而是告诉你“下一次复位会跑到哪里”非常适合产线检测场景。设备上电前先检查BOOT引脚电平是否符合预期再决定是否要跳到升级模式。缺点也很明显电路设计上BOOT引脚的接法和复用功能会影响读取有些产品把BOOT引脚复用作普通GPIO上电瞬间的电平不可控读出来的结果没有意义。另外选项字节可能因为配置了其他功能而被误读需要对照参考手册仔细核对。3.5 我推荐的组合四个方案单独用各有死角。我在项目里最终用的是三层组合逻辑第一层用选项字节和BOOT引脚判断“如果现在复位是否会进入系统Bootloader”这决定系统的默认启动策略。第二层在应用启动早期用VTOR判断“当前向量表在哪个区域”如果VTOR落在系统Bootloader的ROM地址说明代码就是从Bootloader跳过来或者在Bootloader环境中被调用的。第三层才是可选的PC区间判断用来排查代码重定位和异常分支带来的误判。三层逻辑写成一个状态机返回枚举类型而不是简单的bool这样上层可以根据不同状态执行不同策略比如“检测到Bootloader环境但用户代码已经完整就直接跑应用”或者“检测到Bootloader环境但用户代码为空则等待升级指令”。4. 工程实现一个能直接抄的检测模块4.1 头文件定义先定义一个启动环境的枚举把可能的状态都列清楚/* boot_env_detect.h */ #ifndef BOOT_ENV_DETECT_H #define BOOT_ENV_DETECT_H #include stm32h573xx.h typedef enum { BOOT_ENV_UNKNOWN 0, BOOT_ENV_USER_APP, /* 用户应用环境 */ BOOT_ENV_SYSTEM_BOOTLOADER, /* ST系统Bootloader环境 */ BOOT_ENV_USER_BOOTLOADER, /* 用户自研Bootloader环境 */ } boot_env_t; typedef enum { BOOT_SOURCE_COLD_START 0, /* 冷启动进入 */ BOOT_SOURCE_BOOTLOADER_JUMP, /* 从Bootloader跳转进入 */ BOOT_SOURCE_UNKNOWN, /* 未知来源 */ } boot_source_t; boot_env_t boot_env_get_environment(void); boot_source_t boot_env_get_source(void); uint8_t boot_env_is_system_bootloader(void); #endif这里把环境判断和来源判断分开是为了让上层灵活组合。比如在OTA升级场景里我希望知道“当前系统Bootloader环境但应用由跳转进入”这样我的升级状态机可以区分“是否需要重新擦写应用”和“是否需要通知用户升级成功”。4.2 检测函数实现看具体的函数实现。这个实现兼顾了简单和健壮适合直接移植到现有工程。/* boot_env_detect.c */ #include boot_env_detect.h #define BOOTLOADER_ADDR_START 0x0FF00000u #define BOOTLOADER_ADDR_END 0x0FF03FFFu #define USER_APP_ADDR_START 0x08000000u #define USER_APP_ADDR_END 0x080FFFFFu #define BOOT_MAGIC_ADDR 0x20000000u #define BOOT_MAGIC_VALUE 0xB007C0DEu __attribute__((always_inline)) static inline uint32_t get_current_pc(void) { uint32_t pc; __asm volatile (mov %0, pc : r (pc) : : ); return pc; } boot_env_t boot_env_get_environment(void) { uint32_t vtor SCB-VTOR 0xFFFFFFF0u; if ((vtor BOOTLOADER_ADDR_START) (vtor BOOTLOADER_ADDR_END)) { return BOOT_ENV_SYSTEM_BOOTLOADER; } if ((vtor USER_APP_ADDR_START) (vtor USER_APP_ADDR_END)) { return BOOT_ENV_USER_APP; } uint32_t pc get_current_pc(); if ((pc BOOTLOADER_ADDR_START) (pc BOOTLOADER_ADDR_END)) { return BOOT_ENV_SYSTEM_BOOTLOADER; } return BOOT_ENV_UNKNOWN; } boot_source_t boot_env_get_source(void) { uint32_t magic 0; /* 检查RAM区域是否可读TrustZone下non-secure访问secure地址会出错 */ if ((SCB-NSACR (1u 0)) ! 0u) { /* 如果RAM属性为non-secure可以安全读取否则跳过 */ magic *(volatile uint32_t *)BOOT_MAGIC_ADDR; } if (magic BOOT_MAGIC_VALUE) { /* 魔数匹配说明从用户Bootloader跳转而来 */ return BOOT_SOURCE_BOOTLOADER_JUMP; } /* 如果软复位标志存在说明可能经历过了一次软件复位跳转 */ if ((RCC-RSR RCC_RSR_SFTRST) ! 0u) { uint32_t vtor SCB-VTOR 0xFFFFFFF0u; if (vtor ! 0u) { return BOOT_SOURCE_BOOTLOADER_JUMP; } } return BOOT_SOURCE_COLD_START; } uint8_t boot_env_is_system_bootloader(void) { return (boot_env_get_environment() BOOT_ENV_SYSTEM_BOOTLOADER) ? 1u : 0u; }这里有几个细节值得多说一句。第一VTOR读的时候我把低4位清掉了因为向量表对齐要求是32字节对齐低4位在Cortex-M33上一定是0防止某次硬件异常把低位置1导致误判。第二PC读取函数加了always_inline避免编译器把这段代码拆到其他地方执行保证检测时PC确实反映当前指令流的位置。实际测试中如果不开这个属性优化等级开到-O2以上时PC值可能偏离预期那个’坑’非常隐蔽。第三RAM魔数读取前我检查了NSACR。因为H573的RAM默认可能是secure或者non-secure如果应用跑在non-secure侧尝试读取一个secure属性的RAM地址后果不可预期。与其让代码HardFault不如先判断属性再决定是否读取。这在带TrustZone的芯片上是个通用技巧。4.3 在main启动早期调用检测函数最好放在系统初始化之前调用至少要在外设初始化之前否则一旦外设配置失败或者外部晶振起振异常检测结果就失去参考价值。我习惯在main函数最开头放一个检测打印int main(void) { boot_env_t env boot_env_get_environment(); boot_source_t src boot_env_get_source(); /* 先初始化调试串口方便输出 */ debug_uart_init(); debug_printf(boot env: %d, source: %d\r\n, env, src); if (env BOOT_ENV_SYSTEM_BOOTLOADER) { /* 当前在系统Bootloader环境 */ handle_system_bootloader_entry(); } /* 正常启动应用 */ SystemInit(); bsp_init(); ... }需要注意的是如果当前环境是系统Bootloader那外设的时钟配置可能还是Bootloader设置的默认值和你应用的期望不一致。所以在检测到Bootloader环境后不要马上跑应用的外设初始化流程先把RCC复位一遍重新配置时钟树。否则大概率出现“串口波特率不对”或者“外设时钟参数错误”的诡异问题。4.4 TrustZone安全状态下的处理如果你的工程开了TrustZone把应用拆成secure和non-secure两个工程那检测模块的放置位置更要小心。我建议把检测函数放在secure侧的早期初始化代码里因为secure侧拥有最高访问权限可以看所有地址空间的状态检测结果最完整。检测完成后把结果通过IPC机制或者共享内存告诉non-secure侧。当然也可以做成一个secure call servicenon-secure侧在需要的时候调用安全侧的检测服务。如果检测模块在non-secure侧使用有两个坑第一个坑是SAU默认配置。H573上电后SAU的默认配置可能把所有地址都标记为secure。如果non-secure侧的代码去读secure地址直接总线错误。所以在non-secure检测前必须确认SAU配置已经正确划分了安全边界。第二个坑是IP保护。系统Bootloader的ROM区域被ST设定为securenon-secure代码通过硬件总线读取该区域的地址值是允许的但通过TrustZone的secure call去读取某些受保护的寄存器可能会被阻止。具体访问规则要在参考手册的TrustZone章节里确认不同型号略有差异。5. 调试中的坑与实测经验5.1 复位标志为什么不靠谱项目调试初期我试图单纯依赖复位标志判断是否从Bootloader跳转过来结果在系统Bootloader里触发跳转后应用里读到的RCC_RSR有时候是软复位标志有时候是上电复位标志还有一次是两个标志同时出现。反复查看AN2606和H5参考手册之后发现ST在不同版本的系统Bootloader里跳转方式有细微差别有的版本用软复位跳出有的版本直接跳PC。这导致复位标志的方案完全不稳定。后来我把这个判断从“主判据”降级为“辅助参考”只在VTOR判断不能确定时才去看它。5.2 调试器一暂停PC就变了还有一个印象深刻的坑。用ST-Link在线调试时只要我在检测函数处打个断点PC的值就会停在断点对应的位置而不是函数实际调用的位置。如果断点恰好打在系统Bootloader区域检测结果就会瞬变。因为这个原因我把PC检测逻辑从单点判断改成了结合VTOR的双重判断才彻底解决。如果是在现场排查问题建议不要用在线调试器改用串口日志或者其他非侵入式方式观察打印结果避免调试器自身影响检测逻辑。5.3 缓存和安全属性导致跳转后检测异常H573内核有I-Cache和D-Cache跳转时如果缓存没有及时清理应用第一次访问的地址可能还是缓存里的旧内容。我在做OTA升级时应用固件更新完系统Bootloader跳转后应用里第一次读VTOR竟然读到的是旧固件向量表地址后来加了SCB_InvalidateICache()和SCB_CleanDCache()才恢复正常。这个现象虽然不属于检测逻辑本身但它是跳转链路上的常见并发症容易让人误以为检测模块写错了。排查的时候先清缓存再跑检测能省去很多冤枉时间。5.4 实测状态速查表我把H573上实际测试得出的状态组合整理成了下面的表方便做判断时快速对照场景VTORPCBOOT引脚RCC_RSR软复位标志推荐判断结果冷启动进入应用0x08000000附近Flash区域低无用户应用环境系统Bootloader正常运行0x0FF00000区域ROM区域高无系统Bootloader环境系统Bootloader跳转后进入应用0x08000000附近Flash区域高或有或无应用环境但来源为DCM跳转用户Bootloader跳转到应用0x08000000附近Flash区域低有应用环境来源为用户Bootloader从这张表也能看出来单看任何一列都可能出现歧义组合起来看就清晰很多。这也是我反复强调组合判断的原因。6. 这个检测模块还能怎么扩展检测函数本身虽然短但它支撑起来的能力边界很大。比如在做产线自动化测试时上位机需要知道设备当前是不是处于可升级状态。如果设备已经跑在系统Bootloader里上位机可以直接发升级命令不用先尝试用应用层协议沟通。检测模块作为出厂固件的一部分上报给上位机“当前环境”让整个产线交互逻辑简单很多。再比如做IAP升级时如果用户的应用自己实现了OTA升级过程中需要确认“旧固件是否还在”、“跳转目标是否有效”检测函数可以作为跳转前的最后一道保险。如果检测发现目标环境异常就不执行跳转避免设备变砖。还有一个可以扩展的方向是结合H573的安全特性。既然芯片带TrustZone可以把检测结果放进secure内存里用安全服务的方式向non-secure侧提供检测接口。这样即使non-secure侧程序被攻破也无法伪造“我处于系统Bootloader环境”的检测结果安全性更有保障。代码我用的是STM32CubeH5标准库里面的RCC和FLASH结构体定义在不同版本里略有差异但核心寄存器和位域名基本一致。如果你用的HAL库版本比较新只需要对照头文件里的结构体名稍作修改就能编译过。整套检测模块写下来代码量不大思路也不复杂真正花时间的是把启动过程的每个细节都验证一遍。如果你在H573或者其他H5系列芯片上碰到类似的检测需求可以先从VTOR入手快速跑通再逐步加上PC和引脚判断。别一上来就追求全面先解决可观测性再补健壮性基本就不会卡住。
返回列表