上个月给客户设备做远程升级,方案是在现有STM32F407板子上加OTA。客户的要求一句话:固件要能在现场通过无线模块下发,设备重启后跑新版本,升级失败不能变砖。我打开原来的Keil工程翻了半天,Bootloader和App分区得靠人记住哪个地址对应哪个固件,每来一个新人就要重新讲一遍“哪里能改、哪里不能改”。折腾一阵后,我把整个流程换成了CubeMX生成Makefile工程 + VS Code一键构建,Bootloader一套工程、App一套工程,分区全部写在链接脚本和公共头文件里,构建、烧录、生成升级包全部变成固定动作。这篇把整个方案掰开揉碎讲清楚:Flash分区怎么设计、CubeMX两边工程怎么配置、Bootloader的跳转和Flash编程怎么写、VS Code怎么一键把两个固件都编译出来,以及我踩过的几个能让人熬夜的坑。适合正在给STM32设备加OTA功能、或者想在Keil之外找更顺手工具链的嵌入式工程师。
1. 从Keil迁到CubeMX+VS Code:分区这件事不该靠手记
1.1 Keil手动分区的隐形成本
先说痛点。STM32做OTA最基本的要求就是两个固件共存:Bootloader和App。Keil里怎么区分?最常见的做法是在Options for Target的Target页面里手改IROM1的起始地址和大小。Bootloader工程把IROM1设成0x08000000、长度0x10000(64KB),App工程把IROM1设成0x08010000、长度0x70000(448KB)。听起来很简单,但这件事的可怕之处在于:它完全依赖人肉记忆。
项目里只要超过两个人维护,就会有人问“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改成0x08010000,LENGTH改成448K。就这么简单。但这个.linker script是文本,能进Git,能被diff,谁改了什么地方一清二楚。配合Makefile里可以用变量控制编译参数,分区的规则真正“固化”下来了,不再存在于任何人的脑子里。
CubeMX生成的Makefile本身已经包含了编译、汇编、链接、生成.elf/.hex/.bin的完整规则,装好arm-none-eabi-gcc和make之后,在VS Code里按一个Ctrl+Shift+B就可以构建。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。扇区分布如下:
| 扇区 | 起始地址 | 大小 | 用途 |
|---|---|---|---|
| 0 | 0x08000000 | 16KB | Bootloader区 |
| 1 | 0x08004000 | 16KB | Bootloader区 |
| 2 | 0x08008000 | 16KB | Bootloader区 |
| 3 | 0x0800C000 | 16KB | Bootloader区 |
| 4 | 0x08010000 | 64KB | App区起始 |
| 5 | 0x08020000 | 128KB | App区 |
| 6 | 0x08040000 | 128KB | App区 |
| 7 | 0x08060000 | 128KB | App区 |
| 8 | 0x08080000 | 128KB | 下载暂存区 |
| 9 | 0x080A0000 | 128KB | 下载暂存区 |
| 10 | 0x080C0000 | 128KB | 下载暂存区 |
| 11 | 0x080E0000 | 128KB | 下载暂存区 |
我的分区方案是:Bootloader占扇区0到3,共64KB;App占扇区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把系统时钟配成了168MHz,App里的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.h,MDK-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起始地址是0x08010000,App里自己的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不是LENGTH
App工程生成后,链接脚本默认是这样的:
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区的起始地址0x08010000,LENGTH改成448K。RAM的配置不用动,因为App还是从0x20000000开始用RAM,Bootloader跳转前会把栈指针切到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。
解决方法就是设置向量表偏移寄存器VTOR:
SCB->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(也就是64KB,App区偏移),然后取消注释:
#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_Program,F4要求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/C++(ms-vscode.cpptools),它提供IntelliSense和代码高亮。调试用的扩展是Cortex-Debug(marus25.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" } ] }第一次配置完,之后每次改代码只需要按Ctrl+Shift+B,选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
第一个项目是给一台工控设备做OTA,Bootloader代码写完,编译烧录,第一次跳转就死。现象是:串口能看到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能进main,GPIO在闪,一切看起来正常,但只要串口一发数据进来,设备立刻死机。排查时我先怀疑中断优先级配置、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框架,剩下的时间全在打磨业务。能脚本化的东西就不靠手记,能让计算机检查的就不靠人肉复查,这是我做嵌入式工程化最深的体会。