ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32G474串口Bootloader从零实现:分区、跳转与Flash擦写

STM32G474串口Bootloader从零实现:分区、跳转与Flash擦写 简介这是一份面向嵌入式开发工程师与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的架构大体分两种只有BootApp的单分区和BootAppApp的双分区也就是常说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区0x0800000064KB32页Bootloader固件App区0x08010000448KB224页用户App固件参数/标志区EEPROM8KB升级标志、版本号、启动计数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侧要改两处。第一处是链接脚本也就是决定代码段和中断向量表放在哪里的文件。用STM32CubeIDEGCC工具链时在Linker Script里把Flash的ORIGIN改成App区起始地址MEMORY { FLASH (rx) : ORIGIN 0x08010000, LENGTH 448K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K }用Keil的话修改分散加载描述文件或者直接定义VECT_TAB_OFFSET0x10000这个宏。这里一定要同步修改App的VECT_TAB_OFFSET否则编译出来的中断向量表还挂在0x08000000App一开中断就跳飞。第二处是在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命令10x01握手、0x02擦除、0x03写数据、0x04校验、0x05跳转、0x06复位数据长度2小端模式N数据N具体载荷CRC324对“命令长度数据”计算帧尾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-32IEEE 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 量产考虑读保护、写保护与PCROPBootloader写完只是第一步量产时固件安全是另一道坎。G4支持RDP读保护分三个等级。Level 0是无保护Level 1禁止调试接口读FlashLevel 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本文还有配套的精品资源点击获取
返回列表