资讯动态

STM32 OTA升级实战:CubeMX+VS Code实现Bootloader与App分区管理

发布时间:2026/10/5 1:33:13 来源:尧图企业网站定制
上个月给客户设备做远程升级方案是在现有STM32F407板子上加OTA。客户的要求一句话固件要能在现场通过无线模块下发设备重启后跑新版本升级失败不能变砖。我打开原来的Keil工程翻了半天Bootloader和App分区得靠人记住哪个地址对应哪个固件每来一个新人就要重新讲一遍“哪里能改、哪里不能改”。折腾一阵后我把整个流程换成了CubeMX生成Makefile工程 VS Code一键构建Bootloader一套工程、App一套工程分区全部写在链接脚本和公共头文件里构建、烧录、生成升级包全部变成固定动作。这篇把整个方案掰开揉碎讲清楚Flash分区怎么设计、CubeMX两边工程怎么配置、Bootloader的跳转和Flash编程怎么写、VS Code怎么一键把两个固件都编译出来以及我踩过的几个能让人熬夜的坑。适合正在给STM32设备加OTA功能、或者想在Keil之外找更顺手工具链的嵌入式工程师。1. 从Keil迁到CubeMXVS Code分区这件事不该靠手记1.1 Keil手动分区的隐形成本先说痛点。STM32做OTA最基本的要求就是两个固件共存Bootloader和App。Keil里怎么区分最常见的做法是在Options for Target的Target页面里手改IROM1的起始地址和大小。Bootloader工程把IROM1设成0x08000000、长度0x1000064KBApp工程把IROM1设成0x08010000、长度0x70000448KB。听起来很简单但这件事的可怕之处在于它完全依赖人肉记忆。项目里只要超过两个人维护就会有人问“App起始地址到底是0x08008000还是0x08010000”。更隐蔽的是有些同事改了Target配置不影响编译但生成的固件链接地址错了烧进去之后Bootloader一跳转就废。还有分散加载文件的场景.sct文件里RO Base、RW Base写错一个符号编译不报错运行直接HardFault。这类错误属于“不烧到现场永远发现不了”的典型排查起来极其痛苦。Keil的多Target机制也能缓解比如一个工程里同时维护Bootloader和App两个Target切换Target再编译。但多Target意味着源文件列表、宏定义、地址配置全都交织在一个.uvprojx文件里Git合并时几乎每次都有冲突而且大家根本看不懂diff里那一大段二进制XML在说什么。这些都是隐形成本平时不冒烟赶工时一起爆。1.2 把分区固化成文本才是真正的一键换成CubeMX生成Makefile工程后局面完全变了。CubeMX在Project Manager里选Toolchain为Makefile生成的工程里有一个以芯片型号命名的链接脚本比如STM32F407ZGTX_FLASH.ld里面只干一件事定义内存布局。MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K MEMORY_B1 (rx): ORIGIN 0x60000000, LENGTH 0K }App工程改两行ORIGIN改成0x08010000LENGTH改成448K。就这么简单。但这个.linker script是文本能进Git能被diff谁改了什么地方一清二楚。配合Makefile里可以用变量控制编译参数分区的规则真正“固化”下来了不再存在于任何人的脑子里。CubeMX生成的Makefile本身已经包含了编译、汇编、链接、生成.elf/.hex/.bin的完整规则装好arm-none-eabi-gcc和make之后在VS Code里按一个CtrlShiftB就可以构建。VS Code免费、跨平台Windows、Linux、macOS的工程师拿到同一个仓库执行同一套命令产物完全一致。这比每个人电脑上装什么版本的Keil、有没有激活License要省心太多。我并不是说Keil不行Keil的调试器和ULINK在很多人手里依然是效率最高的组合。但如果你做的是OTA这种天然需要“两个固件、严格分区、频繁烧录”的项目脚本化、可diff、可复现的Makefile工程体系带来的确定性远远超过那点学习成本。2. Flash分区方案设计动手写代码之前先把地址账算清楚2.1 以STM32F407ZGT6为例的分区计算很多人拿到工程第一件事就是写代码结果分区是拍脑袋定的后面全在填坑。分区设计是整个OTA方案的根根错了后面所有代码都要返工。以STM32F407ZGT6为例它有一颗1MB的Flash但注意F4的Flash不是均匀的页而是扇区结构前4个扇区每个16KB扇区0到3扇区4是64KB扇区5到11每个128KB。扇区分布如下扇区起始地址大小用途00x0800000016KBBootloader区10x0800400016KBBootloader区20x0800800016KBBootloader区30x0800C00016KBBootloader区40x0801000064KBApp区起始50x08020000128KBApp区60x08040000128KBApp区70x08060000128KBApp区80x08080000128KB下载暂存区90x080A0000128KB下载暂存区100x080C0000128KB下载暂存区110x080E0000128KB下载暂存区我的分区方案是Bootloader占扇区0到3共64KBApp占扇区4到7从0x08010000到0x0807FFFF共448KB下载暂存区占扇区8到11从0x08080000到0x080FFFFF共512KB。三块区域完全按扇区边界对齐这是一个硬性要求。为什么必须按扇区边界对齐因为F4的Flash擦除最小单位就是扇区。假如你把App起始地址设在0x08018000也就是扇区4的中段那么Bootloader擦升级包暂存区时不会碰到它但一旦需要擦写靠近向量表的区域Flash控制器擦的是整个扇区0x08010000到0x0801FFFF全被抹掉你的App向量表就没了。分区不对齐等于埋了一颗随时会爆的雷。2.2 Bootloader切多小合适功能怎么取舍Bootloader的职责边界必须想清楚它只做通信接收、固件写入、校验、跳转不做任何业务逻辑。不要往里塞RTOS不要跑TCP/IP协议栈不要用复杂的文件系统。我见过有人把Bootloader做成带菜单交互的结果光菜单框架就占了20KB还不算通信协议。Bootloader越简单越容易证明它没有问题。64KB对于Bootloader来说非常宽裕。串口驱动加HAL库加Flash驱动加CRC和协议解析优化后一般30KB以内就能放下。你甚至可以关闭不必要的HAL模块来压缩体积。如果芯片是STM32F103这种小容量型号Bootloader可以压缩到16KB到32KB剩下的空间留给App。我见过一些保守派把F103的Bootloader压在8KB确实能做但每次加一个功能都要精打细算维护负担重。分区大小最怕的不是“浪费”而是“不够”。App区大小则要根据你业务固件的实际体积反推。编完一版Release固件看.elf或.bin的体积再留出两到三倍的冗余这才是一个健康的余量。很多项目早期觉得固件只有100KB分区给到200KB很够用结果三个迭代后客户要求加GUI资源、加字库、加音频App区直接爆掉这时只能改分区方案Bootloader和App一起返工。所以分区时宁可给App多留一点也别抠。2.3 下载暂存区是OTA安全性的底线很多初版OTA方案是Bootloader边收边往App区写固件收完直接跳转。这个方案最大的问题在于如果传输过程中断、断电、或者收到坏包App区可能已经被擦了一半或者写了一半设备直接变砖。唯一的恢复手段是再走一遍升级流程可升级流程本身依赖Bootloader能正常启动如果Bootloader启动后无条件跳转到已经损坏的App那就彻底进入死循环。所以我在分区里单独立了一块下载暂存区。OTA新固件先完整地写入暂存区整包收完后做整体CRC校验确认无误后再考虑如何启用。这样即使下载中途断了App区还是上一版的完整固件设备重启后照常运行老版本只是升级没成功而已。这是OTA方案里最基本的一条安全底线。比暂存区更稳的做法是A/B双备份App区拆成App A和App B两个槽位新固件写入空闲槽位校验后切换启动标志让Bootloader下一次启动到新槽位万一新槽位启动失败还能回滚到旧槽位。代价是Flash占用增加一倍。如果你的产品对可用性要求极高比如医疗设备、工业控制器A/B方案值得考虑。而暂存区方案适合大多数消费类、一般工业类产品成本和安全性之间比较平衡。我在这篇文章的例子里采用暂存区方案并且用一个启动标志位记录“当前该从哪个地址启动”。这个标志可以放在备份寄存器、Flash的最后一个扇区或者外部EEPROM里。我用的是内部Flash最后一页因为STM32的Flash控制器支持对单页的读写不依赖外部器件。3. CubeMX双工程配置Bootloader和App的差异不是随便拍的3.1 为什么是两套工程而不是一套有人问能不能用一套CubeMX工程通过宏切换生成Bootloader和App。理论上可以实际上很痛苦。两个固件的外设配置差异太大Bootloader只需要串口、Flash、一个按键输入和一个状态灯App可能需要ADC、DMA、定时器、SPI、I2C、以太网甚至还有RTOS。把这些都塞进同一个.ioc里靠编译宏裁切外设初始化代码维护一段时间后你会发现改Bootloader的引脚配置可能影响App的生成代码两边牵一发动全身。我的做法是建两个目录bootloader目录放Bootloader的.ioc和工程app目录放App的.ioc和工程。它们共享HAL驱动库和一些公共头文件通过Git子模块或者简单的路径引用保持同步。CubeMX生成代码后在两个目录里各自维护互不干扰。启动文件、链接脚本、构建脚本天然分开这才是“两个固件”该有的物理结构。3.2 时钟和外设两边必须保持一致的关键点Bootloader和App有一个容易忽略的一致性问题时钟配置。Bootloader负责接收固件串口波特率是按某个时钟频率算出来的App跑起来之后也有自己的时钟配置。如果Bootloader把系统时钟配成了168MHzApp里的SystemClock_Config把它换成了另一个频率那都没问题因为App启动时会重新初始化时钟。但反过来如果Bootloader在跳转前没有把外设时钟停干净App初始化时可能读到脏状态。更重要的一致性在于引脚。Bootloader用的串口引脚不能和App里的功能冲突。比如你的OTA下载通道是USART1的PA9、PA10那么App里PA9、PA10也要留给USART1不能拿去当普通GPIO或者复用成别的功能。我之前一个项目就是Bootloader用PA9、PA10下载固件App里为了省引脚把这两个脚改成PWM输出了下载完第一次运行正常用户重启进入Bootloader再升级时引脚模式被App改掉串口直接被拉死升级怎么都进行不下去。CubeMX双工程的配置建议是这样的两边芯片型号一致时钟树尽量一致串口引脚保持一致调试接口SWD保持打开Bootloader里打开USART1、一个按键GPIO、一个LED GPIO开启Flash操作相关配置其余全部关掉App里打开你业务需要的外设USART1保留给OTA通道同时把SWD两个脚保留为调试功能千万不要在App里把PA13、PA14复用成GPIO否则一次固件Download就能断掉你的调试连接。3.3 生成Makefile工程的选项设置CubeMX生成Makefile工程的入口在Project Manager页面Toolchain下拉框选Makefile。这一步生成的文件结构是Core/Src里有main.c、stm32f4xx_hal_msp.c等Core/Inc里有main.hMDK-ARM目录不存在取而代之的是根目录的Makefile和链接脚本。Makefile里已经定义好了所有源文件路径、头文件路径、编译选项以及生成.elf、.hex、.bin的规则。这里要提醒一点CubeMX生成完成后不要手动改Makefile里的编译选项来适配你的特殊需求而是尽量通过CubeMX的Additional Linker Options或预定义宏来实现。比如你要在App工程里定义一个APP_START_ADDR宏可以在CubeMX的Project Manager页面的Linker Settings里加这样下次从.ioc重新生成代码时配置不会被覆盖。把“通过CubeMX配置”作为唯一的配置入口避免生成代码后被手工改乱。3.4 公共头文件把两个工程的地址锚在一起为了不出现“Bootloader里App起始地址是0x08010000App里自己的VTOR却写成0x08008000”这种两边对不上的事故我在两个工程里共用一份flash_map.h所有分区地址和大小都从这里取#ifndef FLASH_MAP_H #define FLASH_MAP_H #define BOOTLOADER_START 0x08000000UL #define BOOTLOADER_SIZE 0x00010000UL /* 64KB */ #define APP_START 0x08010000UL #define APP_SIZE 0x00070000UL /* 448KB */ #define DOWNLOAD_START 0x08080000UL #define DOWNLOAD_SIZE 0x00080000UL /* 512KB */ #define APP_VERSION 1.2.0 #define OTA_MAGIC 0x4F54414DUL /* OTAM */ #endif这份头文件是“唯一事实来源”。链接脚本里的ORIGIN和LENGTH、Bootloader跳转代码里的APP_START、App里的VTOR设置、生成升级包的Python脚本全部从这个文件取值。我见过太多OTA项目死在“地址记忆不一致”上统一从一份文件读这个坑直接消失。4. 链接脚本和中断向量表App“搬家”后的两个致命细节4.1 链接脚本改的是ORIGIN不是LENGTHApp工程生成后链接脚本默认是这样的MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 1024K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }不改的情况下App的代码段从0x08000000开始数据段在0x20000000开始。这个App直接烧在0x08000000是能跑的Bootloader在自己的起始地址0x08000000两个固件没法共存。App工程需要改成MEMORY { FLASH (rx) : ORIGIN 0x08010000, LENGTH 448K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }ORIGIN改成了App区的起始地址0x08010000LENGTH改成448K。RAM的配置不用动因为App还是从0x20000000开始用RAMBootloader跳转前会把栈指针切到App自己向量表里的初始栈指针这个值和链接脚本里的RAM配置是对应的。这里最容易出现的低级错误是只改了LENGTH忘了改ORIGIN或者改完ORIGIN后重新从CubeMX生成代码链接脚本被重置回默认值。所以我在构建脚本里加了一个校验步骤编译完成后检查生成的.elf里的.text段起始地址如果不是0x08010000就立刻报错。这个检查用arm-none-eabi-readelf一条命令就能做arm-none-eabi-readelf -h app/build/app.elf | grep EntryEntry point address如果是0x080000xx说明App链接错位置了不用烧进板子就能提前发现。4.2 中断向量表偏移VTOR必须在“任何中断使能之前”设置App链接到了0x08010000代码能跳到那里执行但还有一个问题Cortex-M3/M4的中断控制器默认从0x08000000读取向量表。App里一旦发生中断比如串口接收、定时器中断CPU会去0x08000000查向量表那里装的是Bootloader的向量表查到的中断服务函数地址指向Bootloader代码区跳过去就是各种莫名其妙的行为大多数情况下是直接HardFault。解决方法就是设置向量表偏移寄存器VTORSCB-VTOR APP_START;这里有一个关键顺序问题。在App的main函数里标准CubeMX生成的代码顺序是这样的int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); /* 你的业务代码 */ }你必须把SCB-VTOR APP_START;放在任何外设中断使能之前。我习惯放在HAL_Init之后、SystemClock_Config之前因为启动文件里的SystemInit在进入main之前已经执行过了它会设置VTOR为0x08000000所以进入main后再覆盖是安全的。但要注意放在HAL_Init后面时HAL_Init里面已经使能了SysTick中断而SysTick中断向量恰恰在向量表里。如果你的Bootloader在跳转前已经关闭了SysTick那么App里HAL_Init重新启动SysTick时VTOR还没设置SysTick第一次中断就会从0x08000000取向量。为了避免这个临界窗口也可以把VTOR设置放在HAL_Init之前但那样又有另一个风险如果HAL_Init内部的任何代码依赖中断又会出问题。综合来看我的建议是启动文件的SystemInit里就加上VTOR偏移。CubeMX生成的system_stm32f4xx.c里的SystemInit函数末尾有一段被注释掉的VECT_TAB_OFFSET配置把宏VECT_TAB_OFFSET设成0x10000也就是64KBApp区偏移然后取消注释#define VECT_TAB_OFFSET 0x10000U这样在进入C环境之前向量表就已经指向0x08010000了从第一行代码开始就是安全的。这个做法唯一要注意的是SystemInit函数是Bootloader和App共用的Bootloader工程里VECT_TAB_OFFSET必须保持0。所以这份文件也建议用两个不同的宏来区分。对于Cortex-M0来说情况不一样M0没有VTOR寄存器需要把向量表复制到RAM里再通过其他方式重映射或者干脆把App固定在0x08000000让Bootloader驻留在RAM里运行。这属于高级玩法一般项目不建议碰。5. Bootloader核心代码跳转、Flash擦写与CRC校验5.1 跳转函数不是简单的函数指针调用网上很多Bootloader跳转代码就三行取栈顶、取复位向量、跳转。实际项目里这三行远远不够至少要加上地址合法性判断、关中断、关外设时钟。#define APP_START_ADDR 0x08010000UL typedef void (*pFunction)(void); void jump_to_app(void) { uint32_t app_sp *(volatile uint32_t *)APP_START_ADDR; uint32_t app_pc *(volatile uint32_t *)(APP_START_ADDR 4U); pFunction app_entry; /* 1. 检查栈顶指针在RAM范围内 */ if ((app_sp 0x20000000U) || (app_sp 0x20020000U)) { return; } /* 2. 检查复位向量指向Flash区 */ if ((app_pc 0x08000000U) || (app_pc 0x08100000U)) { return; } /* 3. 清理运行环境 */ __disable_irq(); SysTick-CTRL 0U; HAL_RCC_DeInit(); HAL_DeInit(); /* 4. 切栈指针并跳转 */ __set_MSP(app_sp); __set_CONTROL(0U); __ISB(); app_entry (pFunction)app_pc; app_entry(); /* 跳转失败不应该返回 */ while (1U) { } }每一段都有实际意义。栈顶检查是为了防止App区是空的或者被擦掉后读出来的栈顶是个随机值导致跳过去后栈指针直接飞掉。复位向量检查确保PC不会跳到RAM或者外设区。关闭全局中断和SysTick是因为Bootloader里的串口中断、SysTick定时器如果在跳转瞬间仍处于使能状态会在App环境里触发一个原本不属于App的中断执行完中断函数后返回地址错乱直接HardFault。__set_MSP(app_sp)把主栈指针切到App向量表定义的初始值App里通过链接脚本保证这个值在RAM内有效。__set_CONTROL(0U)是为了保证在特权模式、使用MSP的情况下跳转而不是带着PSP进入App。5.2 Flash擦写扇区粒度必须心里有数Bootloader把接收到的固件从下载暂存区搬运到App区时需要先擦除App区。F4的Flash擦除API是按扇区的和F1按页擦除完全不一样。static void flash_erase_app_area(void) { uint32_t sector; HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_EOP | FLASH_FLAG_OPERR | FLASH_FLAG_WRPERR | FLASH_FLAG_PGAERR | FLASH_FLAG_PGPERR | FLASH_FLAG_PGSERR); for (sector 4U; sector 7U; sector) { if (FLASH_Erase_Sector(sector, VOLTAGE_RANGE_3) ! HAL_OK) { /* 记录错误停止升级 */ Error_Handler(); } } HAL_FLASH_Lock(); }根据前面的扇区表App区是扇区4、5、6、7从0x08010000到0x0807FFFF。注意扇区4是64KB、扇区5到7是三个128KB总大小正是448KB。Bootloader代码里必须写清楚为什么是这个扇区范围否则一个月后你自己都会忘。写入时用HAL_FLASH_ProgramF4要求64位doubleword编程所以数据必须按8字节对齐处理。实际应用中我从下载暂存区按8字节拷贝出来组成uint64_t再写入避免非对齐访问void flash_write_area(uint32_t dst_addr, const uint8_t *src, uint32_t len) { uint64_t data; HAL_FLASH_Unlock(); for (uint32_t i 0U; i len; i 8U) { memcpy(data, src[i], 8U); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, dst_addr i, data) ! HAL_OK) { Error_Handler(); } } HAL_FLASH_Lock(); }写Flash之前有一个容易被忽略的性能问题擦除一个128KB的扇区在F407上大约需要1秒左右四个扇区就是4秒多。如果升级时用户盯着进度条体验很差。方案一是只擦除实际固件占用的扇区比如新固件只有200KB那就只擦扇区4、5、6三个扇区甚至更少。方案二是用状态机分时擦除在擦除间隙继续响应通信请求。对于大多数场景先算好需要几个扇区再擦能省下不少时间。5.3 固件包格式让Bootloader知道自己在升什么Bootloader收到的不能是赤裸裸的bin文件至少需要一个包头来确认固件身份。我常用的固件包格式是这样typedef struct { uint32_t magic; /* OTA_MAGIC 0x4F54414D */ uint32_t version; /* 固件版本号 */ uint32_t length; /* 固件数据长度 */ uint32_t crc32; /* 固件数据CRC32 */ uint8_t data[0]; /* 固件数据 */ } firmware_pkg_t;Bootloader收到包头后先检查magic不是目标固件就直接拒绝再比较version如果和当前App版本一致可以跳过下载然后是length超过App分区大小直接拒绝等整个数据段收完计算CRC32与包头里的值比对一致才把暂存区数据搬运到App区。这个CRC是整个升级流程的最后一道防线。CRC32的实现可以直接用HAL库里的HAL_CRC_Calculate也可以用纯软件查表法。我建议用软件查表法因为不依赖CRC外设的引脚配置在Bootloader和生成升级包的Python脚本里可以保持一致算法。6. VS Code自动化构建从源码到两个固件只需要按一个键6.1 工具链和插件准备先说结论CubeMX生成的Makefile工程可以直接在VS Code里构建前提是你按顺序装好这几样东西。Windows环境下我建议用GNU Arm Embedded Toolchain下载后把bin目录加进PATH再加上MSYS2或者mingw的make。很多人卡在这一步是因为装好GCC后发现make命令找不到其实是忘了单独装make。Linux下更简单sudo apt install gcc-arm-none-eabi make openocd。VS Code里必装的扩展是C/Cms-vscode.cpptools它提供IntelliSense和代码高亮。调试用的扩展是Cortex-Debugmarus25.cortex-debug配合OpenOCD能直接对STM32做断点调试。cubeMX生成的Makefile里默认会编译出.elf、.hex、.bin三个文件不需要额外配置。你只需要在vscode里配置tasks.json把编译和烧录命令封装成任务。6.2 tasks.json把编译和烧录变成一键操作我的工作区结构是这样的project_root/ ├── bootloader/ │ ├── STM32F407ZGTX.ioc │ ├── Makefile │ └── build/ ├── app/ │ ├── STM32F407ZGTX.ioc │ ├── Makefile │ └── build/ └── .vscode/ ├── tasks.json └── launch.jsontasks.json里定义五个任务编译Bootloader、编译App、烧录Bootloader、烧录App、生成升级包。烧录用OpenOCD一条program命令搞定{ version: 2.0.0, tasks: [ { label: build-bootloader, type: shell, command: make, options: { cwd: ${workspaceFolder}/bootloader }, group: { kind: build, isDefault: true } }, { label: build-app, type: shell, command: make, options: { cwd: ${workspaceFolder}/app } }, { label: flash-bootloader, type: shell, command: openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c \program ${workspaceFolder}/bootloader/build/bootloader.elf verify reset exit\, dependsOn: build-bootloader }, { label: flash-app, type: shell, command: openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c \program ${workspaceFolder}/app/build/app.elf verify reset exit\, dependsOn: build-app } ] }第一次配置完之后每次改代码只需要按CtrlShiftB选build-bootloader或者build-app任务会自动执行make几秒钟后build目录里就有最新的.elf和.bin。相比Keil先F7编译再F8下载的流程这套的自由度更高因为OpenOCD的program命令可以明确指定烧录地址烧录App时不用手动改任何设置。6.3 初始烧录和升级包生成产品第一次出厂时需要先把Bootloader烧到0x08000000再把App烧到0x08010000。OpenOCD的program命令不指定偏移时默认按elf文件里的链接地址烧录所以上面两个任务里bootloader.elf烧到0x08000000、app.elf烧到0x08010000不需要额外写偏移参数。如果你手里只有bin文件就必须显式指定地址openocd -f interface/stlink.cfg -f target/stm32f4x.cfg -c program app.bin 0x08010000 verify reset exit这里一定要小心bin文件不带地址信息烧错地址是零报错的但跑起来必挂。所以我在生产烧录脚本里强制用elf宁可在编译时多一步objcopy也不要拿裸bin碰运气。升级包的生成用Python脚本读app.bin加上包头计算CRC输出一个.pkg文件这个文件才是OTA要传输的东西。脚本本身就三四十行放在tools目录里和flash_map.h共用一份地址定义import struct, zlib, sys bin_file sys.argv[1] pkg_file sys.argv[1] .pkg with open(bin_file, rb) as f: data f.read() header struct.pack(IIII, 0x4F54414D, 0x010200, len(data), zlib.crc32(data) 0xFFFFFFFF) with open(pkg_file, wb) as f: f.write(header) f.write(data) print(pkg generated:, pkg_file, len(data), bytes)注意包头里的version字段要和App工程flash_map.h里的APP_VERSION保持一致。我踩过版本号对不上的坑那是在一次发布时改了App代码忘了加版本号结果现场设备升级后版本号没变化用户以为升级失败了。后来我把版本号生成做成脚本自动读取flash_map.h从源头杜绝。6.4 VS Code调试用Cortex-Debug下断点调试Bootloader时用Cortex-Debug比串口打印高效得多。launch.json里这样配置{ version: 0.2.0, configurations: [ { name: Debug Bootloader, cwd: ${workspaceFolder}, type: cortex-debug, request: launch, servertype: openocd, device: STM32F407ZG, interface: swd, configFiles: [ interface/stlink.cfg, target/stm32f4x.cfg ], executable: ${workspaceFolder}/bootloader/build/bootloader.elf, preLaunchTask: build-bootloader } ] }这里要注意的是Bootloader测试跳转时如果App的elf信息不在当前调试会话里你在跳转后看到的反汇编是“裸地址”没有符号。两个解决办法一是调试Bootloader时把App的elf也加载进来二是跳转前在app_entry()调用处下断点然后用monitor reset halt配合手动修改PC跳到0x08010000继续跟踪。第二种方式更直观能逐步看到App的SystemInit、main入口是排查跳转类问题的经典手法。7. 三个项目里的真实故障现象、排查链路和最终修复7.1 故障一Bootloader跳转后立刻HardFault第一个项目是给一台工控设备做OTABootloader代码写完编译烧录第一次跳转就死。现象是串口能看到Bootloader的日志跳转后约几十微秒设备就像被钉住一样没有任何反应。排查链路是这样的先在App的main第一行加一个GPIO翻转发现根本没翻转说明连App的main都没进。然后用Cortex-Debug在app_entry()调用处设置断点单步进去看到PC停在HardFault_Handler。这就说明跳转本身进去了但执行第一条指令就异常。再看反汇编PC的值是0x08000145之类而App的向量表里复位向量的值却是0x08010045附近——不对这个复位向量指向的是0x080000xx区域那正是Bootloader区。最后查链接脚本发现App工程的FLASH ORIGIN还是0x08000000。也就是说App明明烧在0x08010000但它的代码段、数据段、所有内部符号的绝对地址都是按0x08000000起算的。跳转时读到的复位向量是0x0800xxxx开头的Bootloader地址跳回Bootloader的main里执行然后两边状态全乱最终HardFault。修复就是改链接脚本的ORIGIN为0x08010000重新编译、重新烧录问题消失。这个故障的教训是只要Bootloader跳前打印一下读到的app_sp和app_pc和map文件里的符号地址对一眼五秒钟就能定位完全不需要猜。我把这个打印保留在了正式代码里平时作为开机诊断信息。7.2 故障二App能跑但一开中断就死机第二个项目现象更隐蔽。App能进mainGPIO在闪一切看起来正常但只要串口一发数据进来设备立刻死机。排查时我先怀疑中断优先级配置、NVIC初始化顺序查了半天都没结果。后来用调试器看HardFault时的现场发现LR寄存器指向的模式是异常返回栈顶的几个PC值都在0x08000000到0x0800FFFF之间也就是Bootloader的向量表区域。这时候才反应过来是VTOR的问题。App里HAL_Init之后我忘了设置SCB-VTOR APP_START串口一发生中断CPU去0x08000000找USART1_IRQHandler找到的是Bootloader的向量表跳进去的是Bootloader的串口中断函数入口但那个函数用的外设寄存器状态在App环境里完全不对几行代码就触发HardFault。修复很简单在App的main里HAL_Init之后、MX_GPIO_Init之前加一行SCB-VTOR APP_START;重新编译烧录后一切正常。这个故障提醒我两件事第一App必须自己管理中断向量表不要指望Bootloader帮你做什么第二排查中断相关的崩溃第一步就该看HardFault时PC和LR落在哪个地址区间如果落在另一个固件的区域九成是向量表指错了地方。7.3 故障三OTA下载到一半断电重启后一直卡在Bootloader第三个项目是最有代表性的生产事故。客户反馈一台设备升级到一半断电了重启后设备既没有跑老版本App也没有停在Bootloader等待接收固件而是像“死”了一样反复重启。排查后发现这个初版方案用的是单分区设计Bootloader直接把接收到的数据写入App区边收边写。断电发生在擦除App区之后、完整写入新固件之前App区已经被擦掉一大半什么有效代码都不剩。Bootloader启动时没有任何校验无条件跳转到App区等于跳进一片空白Flash执行到未知指令后触发硬件错误复位再跳再错。看起来就像反复重启。这个问题没有“优雅修复”的捷径只能改架构按文章前面说的增加下载暂存区固件先完整收进暂存区并校验CRC然后通过一个升级标志告诉Bootloader“可以搬运了”。搬运完再做一次App区CRC校验整个链路确认无误后才允许跳转。另外增加了一个按钮进入Bootloader的强制条件如果开机时检测到BOOT引脚被拉低无论App区是否合法都保持等待固件下载状态相当于给设备留了最后一条命。这次故障之后我把“升级失败不能变砖”写成了OTA项目的硬性验收标准每个方案评审先问一句话传输中断、校验失败、断电、固件本身损坏这四种异常系统分别会怎样答不上来方案就不允许进开发。7.4 排查这类问题的通用套路回顾这三个故障有个共同的排查脉络可以复用。第一用端口输出或者SEGGER RTT在Bootloader和App里都保留日志关键节点打印关键地址信息比如app_sp、app_pc、CRC值。第二分段确认法每次只引入一个变量比如先不下载固件、直接跳转确认跳转OK后再逐步加通信和写入。第三用arm-none-eabi-nm或者map文件核对符号地址所有“莫名其妙的跳飞”最后都能追溯到某条地址不对。第四排查时先把看门狗关掉否则看门狗会不断复位设备干扰你对现场的判断。我在实际工作中还保留一个小习惯跳转前在Bootloader里打印一次即将跳转的地址App启动后通过串口打印自己的版本号。这样每次开机日志里有两行关键信息一行来自Bootloader表明“我要跳去哪”一行来自App表明“我确实起来了”。现场如果有人反馈设备有问题先看这两行日志能过滤掉一半的伪故障。这套流程走到现在我再做新项目基本两小时就能搭完整个OTA框架剩下的时间全在打磨业务。能脚本化的东西就不靠手记能让计算机检查的就不靠人肉复查这是我做嵌入式工程化最深的体会。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑