简介:这是一份面向嵌入式开发工程师与STM32进阶学习者的BOOTLOADER实战工程,专为NUCLEO-G474RE开发板设计,解决基于FDCAN总线的固件远程升级(DFU)核心需求。资源包含完整的双APP架构实现:引导加载程序支持80MHz主频下1Mb/s FDCAN通信、Flash快速编程(32×64位写入)、命令协议解析(0x00进入、0x90跳转)及用户应用安全跳转;配套两个可验证的用户程序——GPIO LED无限翻转与UART串口发送Hello消息,均提供编译好的.bin文件便于快速测试。压缩包共352个文件,以245个.h头文件和65个.c源文件构成完整HAL驱动框架(含fdcan、uart、tim等关键外设),辅以4个链接脚本、5个.xmt CAN报文配置模板及1个Python自动化脚本(用于bin→xmt转换),结构清晰、模块解耦。目前已有1267人学习下载,提供从底层寄存器配置到上位机交互的全链路参考,是理解STM32G4系列Bootloader机制与CAN Bootloader工程落地的优质实践素材。 前阵子在NUCLEO-G474RE上把一个串口BOOTLOADER从零到落地跑通,从分区设计、跳转逻辑到Flash擦写协议,整个过程比预想的更有意思。STM32G474这颗片子本身性价比很高,170MHz主频、Cortex-M4F内核、512KB Flash、128KB SRAM,还带了8KB硬件EEPROM,做产品固件升级非常合适。这篇文章想把完整的设计过程、代码思路、调试踩坑整理出来,给准备在STM32G474上做Bootloader或者想了解Bootloader原理的朋友一些参考。
Bootloader这件事看着简单,但真正自己写过一遍,收获完全不一样。它不像写业务代码那样改改逻辑就行,而是要把芯片启动、内存布局、Flash控制器、中断向量、通信协议全都串起来。任何一个环节没想清楚,最后都会在调试时还回来。尤其是NUCLEO-G474RE这块板子,ST官方把ST-Link虚拟串口和烧录器都做在了板上,拿来起项目非常顺手,特别适合当Bootloader的实验平台。这篇文章覆盖的内容,既可以帮你把基础Bootloader跑起来,也能给你后续做A/B分区、UDS Bootloader这些进阶设计打底。
1. Bootloader方案设计与分区规划
1.1 为什么选Bootloader这个方向,又为什么用G474
先说背景。做产品量产之后,固件升级是躲不开的需求。产线要刷固件,售后要更新代码,用户现场要修Bug或者加功能,每次都拿JTAG/SWD去拆壳烧录根本不现实。Bootloader + 串口/网络升级是行业标配。像汽车电子里的UDS Bootloader,消费电子里的OTA,核心思路都是同一套东西,只是传输通道和协议安全等级不同。
STM32G474RE这颗料在电机控制、数字电源领域非常常见,这些产品一旦装到设备里,升级只能走UART/CAN,所以Bootloader是刚需。而且G4系列的Flash控制器和F1/F4不完全一样,页大小、编程粒度、寄存器设计都有差异,网上资料却少得多。我在NUCLEO-G474RE上做完后发现,很多通用教程里的写法拿过来会踩坑,所以觉得有必要把基于G4的实践单独记录下来。
另外一个现实问题是,很多朋友手里的Bootloader代码要么是ST官方给个Demo但看不懂,要么是从别的芯片上抄过来改的,遇到问题没法排查。自己从头搭一遍,把启动流程和Flash操作原理吃透,以后不管换到G0、L4还是S32K344、RK3576这些平台,思路都能复用。Bootloader的核心从来不是某一款芯片的寄存器,而是你懂不懂“上电之后从哪里开始跑、怎么安全地把控制权交出去”。
1.2 双区还是单区,先想清楚再动手
Bootloader的架构大体分两种:只有Boot+App的单分区,和Boot+App+App的双分区(也就是常说A/B分区)。A/B分区是目前OTA产品里的主流做法,升级的时候往备用区写,写完校验通过再切换激活区,万一新固件跑不起来还能回滚到旧区。但这个项目我建议先用单分区把基础跑通,原因很简单,单分区涉及的状态管理、Flash擦写、跳转逻辑已经足够复杂,先把地基打扎实,再上A/B才是正经路子。
单分区架构下,Boot区放Bootloader代码,App区放用户固件,参数状态可以放在EEPROM或者Flash末尾几页。升级时Boot负责接收新固件,直接擦写覆盖App区。这种方案成本最低,Flash利用效率高,缺点是没有天然的回滚能力。如果升级中途掉电或者固件本身有问题,最坏情况是设备变砖,只能重新进Boot刷一遍。所以单分区Bootloader必须把“进入Boot模式”的通道设计得非常可靠,这一点我在第4章会详细讲。
A/B分区听起来高端,但代价是App区要预留两份固件的空间,Flash少一半可用,还要维护“当前激活哪个区”的状态标志。对于Flash只有512KB的G474来说,一个动辄200KB的App固件,做A/B分区会非常紧张,所以很多量产项目宁可做单分区加回滚标志,也不盲目上A/B。这个取舍后面第6章再展开。
1.3 NUCLEO-G474RE上的内存布局怎么划
基于NUCLEO-G474RE的实际配置,我推荐下面这个分区方案。STM32G474RE的Flash起始地址是0x08000000,总容量512KB,按手册说明每页是2KB。这里要注意,G4的Flash是按照页来擦除的,不是F1那种按扇区(大小还不一致),每页固定2KB对分区计算非常友好。
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Boot区 | 0x08000000 | 64KB(32页) | Bootloader固件 |
| App区 | 0x08010000 | 448KB(224页) | 用户App固件 |
| 参数/标志区 | EEPROM | 8KB | 升级标志、版本号、启动计数 |
Boot区选64KB并不是因为我写的Bootloader真有这么大——一个精简版串口Bootloader加上协议处理、打印调试,编译出来也就十几KB到二十几KB。之所以留64KB,一是方便以后加功能,比如加密握手、双分区引导、多协议支持;二是如果用了HAL库,代码体积会明显膨胀,留足冗余能避免后面挤得难受。App区从0x08010000开始,这个地址天然满足Cortex-M4向量表对齐要求,也避开了Boot区,后续写跳转逻辑时不用做额外地址换算。
参数标志区我用了G4内部的8KB硬件EEPROM,这算是G4系列一个很实用的特性。传统STM32F1/F4没有真正的EEPROM,很多人只能拿Flash末尾几页模拟。G4把这个问题解决了,而且EEPROM擦写次数和Flash不是一个量级,存升级状态、版本信息这类频繁改写的字段非常合适。如果你不想用EEPROM,也可以用备份寄存器或者Flash最后两页,但灵活性差不少。
2. 启动流程与跳转逻辑实现
2.1 上电之后Boot到底该干什么
从CPU角度的启动顺序很简单:复位后从0x08000000读取初始栈指针,从0x08000004读取复位向量,然后跳进去执行。这个地址上放着的是Bootloader,所以上电后所有控制权默认在Boot手里。
Boot的主流程我的设计是这样:
- 初始化时钟、串口、参数区(EEPROM)。
- 检查EEPROM里的升级请求标志。如果标志有效,说明上次有升级指令或者升级没完成,直接进入等待固件传输模式。
- 如果标志无效,校验App区固件完整性(主要指栈顶地址合法性、复位向量合法性和CRC校验)。
- 如果App有效,等一个短暂的“用户命令窗口”,比如500ms,期间串口收到握手指令就进入升级模式,收不到就跳转App。
- 如果App无效,不跳转,一直等待升级指令。
这里有两个细节容易被忽略。一个是“命令窗口”的目的:如果设备刚上电时上位机想立刻升级,它可以抢在Boot跳转之前发出握手命令;如果正常启动,Boot等500ms没人理它,自动进App,用户完全感觉不到Boot存在过。另一个是强制升级通道,比如板载按键PC13拉低、或者某个诊断引脚电平异常,都可以作为“无论App是否有效都强制进Boot”的条件,以防App跑飞或者刷入错误固件后无法进入升级模式。
2.2 跳转函数:为什么顺序不能乱
跳转是整个Bootloader里最核心的代码,也是很多人第一次写时最容易翻车的地方。直接上代码,我用的跳转函数是这样:
typedef void (*app_entry_t)(void); static void jump_to_app(uint32_t app_addr) { uint32_t msp_value; uint32_t reset_vector; // 检查栈顶地址是否在RAM范围内 msp_value = *(volatile uint32_t *)app_addr; reset_vector = *(volatile uint32_t *)(app_addr + 4); if ((msp_value & 0xFFF00000) != 0x20000000) { return; // 栈顶不在0x20000000区域,App区数据无效 } if ((reset_vector & 0xFF000000) != 0x08000000) { return; // 复位向量不在Flash区域,同样判定无效 } // 1. 关闭全局中断 __disable_irq(); // 2. 清空NVIC所有中断使能和挂起状态 for (uint32_t i = 0; i < 8; i++) { NVIC->ICER[i] = 0xFFFFFFFF; NVIC->ICPR[i] = 0xFFFFFFFF; } // 3. 关闭SysTick,防止跳转后系统滴答中断捣乱 SysTick->CTRL = 0; // 4. 重映射向量表 SCB->VTOR = app_addr; // 5. 切换回MSP主栈指针,并将MSP设为App的初始栈顶 __set_CONTROL(0); __DSB(); __ISB(); __set_MSP(msp_value); // 6. 跳转到App复位向量 ((app_entry_t)reset_vector)(); }这段代码的每一步都有讲究。第一件事要关闭全局中断,因为Boot里可能还挂着串口中断或者定时器中断,如果带着中断跳进App的Reset_Handler,中断可能在App初始化外设之前就触发,导致HardFault。紧接着要把NVIC里的中断使能和挂起位全部清掉,这一步经常被漏掉。某个中断在Boot阶段挂起了,跳转后App使能对应外设的一瞬间,挂起的中断立刻被响应,而中断服务函数可能还没就位,系统直接卡死。
然后设置SCB->VTOR,把中断向量表从0x08000000切到App区起始地址。Cortex-M4上如果App区基地址没对齐到向量表边界,VTOR写入会失败,后续中断全都会乱。这里0x08010000本身对齐没问题,如果你改到别的地址,记得确认对齐。
最后要把控制权交出之前,确保当前用的是MSP。Cortex-M上如果跑过RTOS或者手动切过线程栈PSP,直接设置MSP是无效的,必须先清掉CONTROL寄存器里的SPSEL位。__set_CONTROL(0)这一步把特权线程模式+主栈指针重置,再配合DSB/ISB保证后续指令能看到修改后的状态。处理完这些,App的复位向量才会在一个全新的、干净的上下文里启动。
2.3 App侧必须配合的两处改动
Boot写好了,App如果还是按0x08000000编译,跳过去必挂。App侧要改两处。
第一处是链接脚本,也就是决定代码段和中断向量表放在哪里的文件。用STM32CubeIDE(GCC工具链)时,在Linker Script里把Flash的ORIGIN改成App区起始地址:
MEMORY { FLASH (rx) : ORIGIN = 0x08010000, LENGTH = 448K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K }用Keil的话,修改分散加载描述文件,或者直接定义VECT_TAB_OFFSET=0x10000这个宏。这里一定要同步修改App的VECT_TAB_OFFSET,否则编译出来的中断向量表还挂在0x08000000,App一开中断就跳飞。
第二处是在SystemInit或者main的早期设置向量表偏移。ST的HAL库里通常有一段条件编译代码可以用,如果没有,最简单的方式是在main最开始加一行:
SCB->VTOR = 0x08010000;这行要在任何外设中断使能之前执行,最好放在时钟初始化之后、外设初始化之前。有些工程师只在Boot跳转前设了VTOR就以为万事大吉,结果App复位过程中又执行了SystemInit里的某些初始化覆盖掉VTOR,照样出问题。稳妥做法是两头都设,Boot设一次,App启动早期再设一次,双保险。
3. 固件传输协议与Flash擦写实现
3.1 通信协议设计:简单但不简陋
Bootloader的通信协议不需要设计成多复杂,但一定要有帧头、长度、命令、数据和校验,这样才能抗干扰、抗粘包。我这里参考了UDS Bootloader里“先握手、再擦除、然后传输、最后校验”的思路,做了简化。串口参数用115200 8N1,这个速率在Boot阶段足够用。
| 字段 | 长度(字节) | 说明 |
|---|---|---|
| 帧头 | 2 | 固定0x5A 0xA5 |
| 命令 | 1 | 0x01握手、0x02擦除、0x03写数据、0x04校验、0x05跳转、0x06复位 |
| 数据长度 | 2 | 小端模式,N |
| 数据 | N | 具体载荷 |
| CRC32 | 4 | 对“命令+长度+数据”计算 |
| 帧尾 | 1 | 固定0x0A |
命令的具体含义我列一下:
- 0x01 握手:上位机发这个命令来确认Boot是否在线,Boot回复版本号和当前状态,上位机据此决定是否进入擦除流程。
- 0x02 擦除:数据段里携带起始页和页数,Boot执行页擦除。擦完必须回复成功或者出错码。
- 0x03 写数据:携带目标地址和数据,Boot逐双字写入Flash。
- 0x04 校验:Boot对App区整块计算CRC32,和上位机发来的期望值比对,返回一致还是不一致。
- 0x05 跳转:所有数据写完且校验通过,上位机发跳转命令,Boot执行上一章的跳转函数。
这里有个容易踩的坑:擦除和写数据是Bootloader里最耗时的操作,尤其是擦除一页2KB的Flash,在低频时钟下可能要几十毫秒。这段时间内上位机如果还在一个劲发数据,串口接收缓冲区早满了。所以协议里一定要有流控机制,最简单的就是“发一帧、等应答、再发下一帧”,一帧一停。等我把写Flash的耗时打印出来,你就知道为什么必须这么做了。
3.2 升级流程的状态机,别用if套if
业务逻辑一多,用一堆if嵌套到最后自己都看不懂。升级过程天然是个状态机,老老实实列状态:
- IDLE:等待握手命令。
- HANDSHAKE:收到握手并响应,等待擦除命令。
- ERASE:执行擦除,如果收到合理命令则进入WRITE。
- WRITE:接收固件数据并写入Flash,每帧更新已接收长度。
- VERIFY:校验CRC,决定能不能跳转。
- JUMP:恢复默认状态,执行跳转。
状态机的好处是每个状态只处理属于它的那几条命令,非法命令直接回NACK,流程清晰很多。比如在WRITE状态收到跳转命令,应该拒绝而不是傻乎乎地执行,因为固件还没传完。
擦除命令我建议设计成“整区擦除”,不要按页一页页从串口发,那样太慢了。上位机在写完“我要开始升级”之后发一条0x02命令,Boot把App区所有页面一次性擦完,再进入WRITE状态等待数据。整区擦除的时间虽然长,但发生在传输数据之前,上位机设一个足够长的超时就行,不会中途断。
3.3 Flash解锁、页擦除与双字编程
G4的Flash操作和F1系列不同,直接写地址是没用的,必须先解锁、配置控制寄存器、操作,完了再上锁。擦除一个页的基本流程:
static void flash_unlock(void) { // 等待忙标志 while (FLASH->SR & FLASH_SR_BSY) {} if (FLASH->CR & FLASH_CR_LOCK) { FLASH->KEYR = 0x45670123U; FLASH->KEYR = 0xCDEF89ABU; } } static int flash_erase_page(uint32_t page_addr) { flash_unlock(); while (FLASH->SR & FLASH_SR_BSY) {} CLEAR_BIT(FLASH->CR, FLASH_CR_PER); SET_BIT(FLASH->CR, FLASH_CR_PER); FLASH->PAGEADDR = page_addr; // 指定要擦除的页地址 SET_BIT(FLASH->CR, FLASH_CR_STRT); while (FLASH->SR & FLASH_SR_BSY) {} if (FLASH->SR & FLASH_SR_WRPERR) { SET_BIT(FLASH->SR, FLASH_SR_WRPERR); CLEAR_BIT(FLASH->CR, FLASH_CR_PER); return -1; } CLEAR_BIT(FLASH->CR, FLASH_CR_PER); FLASH->CR |= FLASH_CR_LOCK; return 0; }写数据的时候,G4支持的最小编程单位是双字,也就是8字节。你要写入的地址必须是8字节对齐,数据长度也得是8的倍数。实际操作中,每一帧串口数据先攒到RAM缓冲区,凑够8字节或者收到完整帧后再一次写入,不能收到一两个字节就去碰Flash。
static int flash_program_doubleword(uint32_t addr, uint64_t data) { flash_unlock(); while (FLASH->SR & FLASH_SR_BSY) {} SET_BIT(FLASH->CR, FLASH_CR_PG); *(volatile uint32_t *)addr = (uint32_t)(data & 0xFFFFFFFF); __DSB(); while (FLASH->SR & FLASH_SR_BSY) {} *(volatile uint32_t *)(addr + 4) = (uint32_t)(data >> 32); __DSB(); while (FLASH->SR & FLASH_SR_BSY) {} if (FLASH->SR & FLASH_SR_PROGERR) { SET_BIT(FLASH->SR, FLASH_SR_PROGERR); CLEAR_BIT(FLASH->CR, FLASH_CR_PG); FLASH->CR |= FLASH_CR_LOCK; return -1; } CLEAR_BIT(FLASH->CR, FLASH_CR_PG); FLASH->CR |= FLASH_CR_LOCK; return 0; }写Flash期间千万别开中断。我最早版本在UART接收中断里直接调Flash写入函数,结果一串数据过来,写入函数被新中断打断,Flash控制器状态被干扰,偶发编程错误,排查了很久。后来改成“串口中断只接收并缓存,主循环里集中处理Flash写入”,问题消失。这个改动也顺势解决了Flash操作期间关中断的问题,一举两得。
4. 固件校验、错误恢复与安全设计
4.1 用G4硬件CRC算固件指纹
固件传输完,怎么确定拿到的东西和上位机发的一模一样?逐字节比对太慢,标准做法是算CRC。G4内部带了硬件CRC计算单元,默认实现CRC-32(IEEE 802.3,多项式0x04C11DB7),计算整个App区几百KB的速度比我用软件CRC快得多。
这里有个大坑:ST硬件CRC的输入输出字节序和PC端常见的zlib/crcmod不一定一致,你需要在PC端和嵌入式端约定好同一套参数。具体来说,硬件CRC的初始值、输入数据是按字节还是按半字处理、输出是否需要反转,都要对上。我调试时PC端用Python的binascii.crc32算出来一个值,和G4硬件CRC算出来的对不上,折腾了大半天,最后发现是输入字节序反转和输出异或的问题。后来统一在PC端生成固件包时用和硬件CRC一致的算法,问题解决。
CRC校验策略我这样设计:上位机在发送固件前,先把整个App bin文件的CRC32算好,追加到升级包末尾。Boot在接收完所有数据后,对接收到的App区逐页读出来算CRC,和升级包里携带的期望值比对。如果一致,说明这次传输的固件和上位机本地文件一致,可以安全跳转。如果校验失败,Boot马上回NACK,等待重新发送,坚决不跳转。
4.2 升级中途掉电了怎么办,别慌
嵌入式升级最怕的就是写到一半断电。App区擦掉了、新固件只写了一半,下次上电Boot一检查App有效性就发现有问题。这时候Boot的正确做法是:发现App校验失败,不跳转,停留在Boot模式下等待升级命令。设备虽然不能正常工作,但它没有彻底死掉,还能重新刷。这就是为什么说Bootloader的通病恢复通道非常重要。
我建议在Boot里做两层保护。第一层,App区开头保留4字节魔数区,只有App固件完整写入并校验通过后,Boot才会在末尾写一个“valid”标记。第二层,这个标记不进Flash,而是用EEPROM存。EEPROM里存三个关键字段:升级请求标志、当前App状态(有效/无效/升级中)、升级完成标志。每次启动,Boot先看EEPROM,如果App状态不是有效,直接进升级模式。
“升级中”这个状态特别有用。上位机发握手命令后,Boot先把App状态改成“升级中”,然后才开始擦写。如果中途掉电,EEPROM里仍记录着“升级中”,下次上电Boot就知道上次升级没完成,自动进入升级模式等待重新刷写。就像你下载文件时先写一个.tmp文件名,下载完成再改名为正式文件,道理一模一样。
看门狗在Boot阶段我建议先不开。IWDG一旦启动就没法关闭,跳转到App之后它还在倒计时。如果App初始化和喂狗之间花的时间稍长,系统直接复位,反而添乱。如果产品必须要求Boot阶段有看门狗,那就只能在App启动早期立即重配看门狗并喂狗,两边保持同样的超时策略,这个衔接很考验细节。
4.3 量产考虑:读保护、写保护与PCROP
Bootloader写完只是第一步,量产时固件安全是另一道坎。G4支持RDP读保护,分三个等级。Level 0是无保护,Level 1禁止调试接口读Flash,Level 2直接锁死调试口。量产至少上到Level 1,不然别人拿个ST-Link就能把固件读走。
RDP升到Level 2要非常谨慎,因为一旦锁死,调试口永久失效,固件也再没法读出来,出了生产问题只能换芯片。所以Level 2通常用于对固件安全要求极高的场合,一般产品Level 1就够了。
WRP写保护用来锁住Boot区,防止Bootloader自己被误擦写或者被恶意改写。PCROP则可以把某些敏感代码区域设为“仅可执行不可读取”,是保护算法和密钥的进阶手段。这些功能在量产烧录时通过CubeProgrammer或者产线工具配置,用起来不复杂,但设计Bootloader时就要预留好地址规划,别等量产了发现安全区域和分区冲突。
5. 实测调试记录与问题排查实录
5.1 实验环境和验证流程
我在NUCLEO-G474RE板上的验证流程是这样的:先用STM32CubeProgrammer把Bootloader编译出的hex烧到0x08000000,然后把App的hex(链接地址已改为0x08010000)也烧进去,但把App的复位向量临时改成一个死循环,用来模拟App损坏状态,验证Boot的跳转保护逻辑。正常路径下,Boot启动后等待命令窗口超时,自动跳到App,板载LED开始闪烁。
上位机工具我用了一个Python
本文还有配套的精品资源,点击获取