STM32G4 Bootloader开发实战:从CubeMX配置到IAP升级
2026/9/24 12:25:55 网站建设 项目流程

做了三年嵌入式产品,我一直觉得Bootloader这东西离自己很远,直到客户现场那批设备需要升级协议,我才意识到没有Bootloader意味着什么:拆机、接线、插ST-Link、刷固件、贴封条,一台机器折腾十分钟,五十台就要一个下午。更崩溃的是,升级完了发现还要改参数,又得来一遍。所以当我在STM32G4上把Bootloader从CubeMX配置到Flash划分整个流程跑通之后,第一反应是:这玩意儿真没那么玄乎,但坑也确实不少,很多坑还是那种百度半天找不到答案的暗坑。

这篇文章就是我这次完整开发过程的全记录,从为什么选STM32G4、CubeMX怎么配置,到APP区地址怎么规划、跳转代码怎么写、IAP协议怎么定,最后把我踩过的几个典型问题和排查思路整理成速查表。不管你是第一次接触STM32G4 Bootloader,还是之前在其他平台做过IAP想迁移过来,这篇文章都能帮你少走弯路。

1. 项目概述与整体设计思路

1.1 为什么选STM32G4来做Bootloader

STM32G4是ST主推的数字电源和电机控制芯片,主频170MHz,带硬件数学运算内核,浮点性能很强。但单从Bootloader的角度看,选它不是因为性能,而是因为它的Flash架构比老一代F1/F4更适合做IAP。

首先是Flash容量,G4系列从128KB到512KB可选,常见型号如G431、G474都有较充裕的空间来划分Bootloader区、App区和参数区。其次是Flash的双Bank特性,G4的部分型号支持双Bank启动,这意味着可以做成A/B分区备份升级,卡在升级中途设备也不会变砖。再有就是Flash的ECC校验、读写保护这些安全特性,生产环境里对固件防读出、防篡改有要求的场景,G4比F1那种裸奔式的Flash管理要顺手得多。

当然,选G4也有代价,代价就是它的Flash操作细节和CubeMX的配置方式跟F1差别不小。如果你是从F103直接迁移过来,很容易在擦除、编程、地址对齐这些地方翻车,这也是我写这篇文章的直接原因。

1.2 整体架构:Bootloader加App的双区方案

做Bootloader之前,先把架构想清楚。我这边的方案是最经典的“Bootloader区 + App区”结构,外加一个跑在Bootloader里的串口升级协议。

整个系统上电后先跑Bootloader,Bootloader做三件事:检查升级标志位、判断App区是否有效、有效就跳过去执行App,无效就留在Bootloader里等待上位机发固件。升级时上位机通过串口把固件包发给Bootloader,Bootloader负责擦除App区的Flash、接收数据包、写入Flash、校验CRC,全部完成后再跳转。

串口升级协议我选择了自定义的简单帧格式,没有用Ymodem,原因有两个:第一,Ymodem的包格式和流控机制对于小团队小产品来说偏重,调试时出了问题不好定位;第二,自定义协议可以把握手、擦除、写入、跳转这几个关键状态拆得很清楚,每一步都能通过应答帧确认,排查问题的时候心里特别有数。

2. CubeMX配置与工程初始化避坑

2.1 时钟树配置:先跑明白频率再说别的

在CubeMX里新建STM32G4工程时,我踩的第一个坑就是时钟树。G4最高主频170MHz,但不是说随便配个PLL就能跑起来的,PLL的输入频率、倍频系数、VCO频率范围都有硬性约束。如果你拿F1的习惯直接去配,很可能会陷入“点开Clock Configuration面板,调来调去就是绿色不了”的窘境。

以外部8MHz晶振为例,要跑到170MHz,典型的配置路径是:HSE 8MHz进PLL,PLLM分频为2,得到4MHz的PLL输入,然后PLLN倍频到340MHz作为VCO输出,最后PLLR分频为2,得到170MHz的系统时钟。这三个参数牵扯到PLL输入范围必须在1~16MHz,VCO输出需要在96~480MHz之间,任何一个参数超出范围,CubeMX就会把时钟树标红,告诉你跑不了。

我在Bootloader里其实没有跑170MHz。Bootloader的工作就是串口收数据和写Flash,跑太快没有实际收益,反而增加功耗和干扰风险。我的做法是让系统时钟按实际需求配置,比如开发板方便就跑到170MHz验证稳定性,量产Bootloader里则可以适当降低主频,把重点放在UART波特率误差上。

这里要特别注意G4的UART时钟源。G4的UART可以选PCLK、HSI16、LSE等时钟源。当初我图省事,直接用内部HSI16来做USART时钟,结果一个115200波特率实际误差偏大,收包时偶发乱码。后来老老实实切回由HSE经过PLL得到的PCLK,问题立刻消失。

2.2 串口、GPIO与中断:Bootloader工程该开哪些外设

CubeMX里盗Base工程的配置,我只开了USART1、一个LED引脚(用来指示设备处于Bootloader模式),以及Flash编程需要的必要选项。很多人习惯把外设全部默认初始化,这在App里没问题,在Bootloader里就是给自己埋雷。

为什么这么说?因为Bootloader最终是要跳转到App的,跳转之前如果你把一堆外设全都初始化过、中断也挂上了,跳过去之后App的初始化顺序稍有不同,就可能导致两个程序之间的外设状态互相干扰。比如Bootloader里开着UART中断,跳转到App后App还没来得及重配UART,此时一个串口中断进来,中断向量表又已经切换到App,就可能直接HardFault。所以Bootloader里外设开得越少越好,够用就行。

UART的接收方式,我建议初版不要一上来就上DMA加空闲中断,虽然这样CPU占用率低,但调试难度大。众所周知,一次只干一件事的Bootloader场景,用最朴素的字节中断加状态机解析,反而更容易验证整套流程。先把帧协议跑通,再回头优化成DMA版本也不迟。

NVIC里只使能USART1全局中断,其他外设中断全部不勾选,这步看似不起眼,实际对跳转稳定性影响很大。

3. Flash划分与地址规划(整个项目的命根子)

3.1 STM32G4的Flash结构:扇区、Bank与双Bank

Flash划分是整个Bootloader开发里最基础也最关键的一步。很多人升级失败,根源就是地址规划出了问题,而不是代码问题。

STM32G4的Flash跟F1那种“一页1KB,全部等大”的结构完全不同。G4的Flash是按Bank组织的,每个Bank又分成若干扇区,扇区大小并不统一,常见型号里前面的扇区可能是16KB,后面的扇区可能是32KB,甚至更大,具体要看对应型号参考手册里的Flash memory organization表格。

更需要注意的是,G4的部分型号支持双Bank模式。以STM32G474为例,512KB的Flash可以做成两个256KB的Bank,支持从任意一个Bank启动。这意味着你可以把固件A放在Bank1,固件B放在Bank2,一个运行一个待升级,升级完成后通过切换Bank的方式激活新固件。这个特性在需要OTA高可靠性的场景几乎是救命的,因为哪怕写入新固件到一半断电,旧固件依然完好无损。

但千万别以为所有G4都支持双Bank,像128KB的G431,Flash较小,设计上就不支持双Bank模式。做方案选型时一定要先查数据手册,别等板子画完了才发现这个坑。

3.2 地址划分实操:Bootloader区、App区与参数区

以我手上的512KB的STM32G474为例,实际划分如下:

Bootloader区:0x08000000 到 0x0800FFFF,共64KB,存放Bootloader固件。 App区:0x08010000 到 0x0803FFFF,共192KB,存放应用程序固件。 参数区:0x08040000往后,存放升级标志位、设备配置参数、固件版本号等信息。

我刻意把Bootloader区留到64KB,虽然实际Bootloader固件编译出来可能只有20多KB,但留出冗余有几个好处:以后想加加密验签逻辑、想支持更多命令,不需要动App区地址。

App区起始地址选在0x08010000,也考虑了页对齐的问题。G4的擦除操作是按扇区进行的,而扇区大小不一定相同。如果App起始地址落在某个扇区中间,擦除操作就很难设计。所以强建议你规划地址时,先去查手册里Flash扇区表,确保App起始地址正好是某个扇区的起始地址。

整个Flash划分里有一个通用原则:地址越早规划越好,越晚改动代价越大。因为一旦App跑起来,链接脚本、中断向量表偏移、固件升级包内的跳转地址全都跟这个地址绑定,中途改地址等于全部返工。

3.3 跳转App的核心代码:MSP、VTOR与函数指针

Bootloader跳转App,表面上是把PC指到App的main函数,但实际没那么简单。Cortex-M系列和普通MCU不一样,它复位后是从向量表取值的:向量表的第一个字是初始栈顶地址,也就是MSP的初值;第二个字是复位中断函数地址,也就是程序入口。

所以跳转的实质是:从App区的向量表里读出MSP初值和复位函数地址,然后把MSP寄存器设为那个值,再把程序跳到复位函数地址。如果不能理解这个机理,你在跳转这里踩坑是必然的。

我实际使用的跳转函数大概长这样:

typedef void (*pFunction)(void); uint32_t JumpToApp(uint32_t app_addr) { uint32_t msp_value; uint32_t reset_vector; pFunction app_entry; msp_value = *(volatile uint32_t *)app_addr; reset_vector = *(volatile uint32_t *)(app_addr + 4); app_entry = (pFunction)reset_vector; __disable_irq(); HAL_UART_DeInit(&huart1); HAL_RCC_DeInit(); SysTick->CTRL = 0; SysTick->LOAD = 0; SysTick->VAL = 0; SCB->VTOR = app_addr; __set_MSP(msp_value); app_entry(); return 0; }

函数里有两个地方是新手最容易漏的。

第一个地方是SCB->VTOR设置。如果不把中断向量表的地址改成App区的起始地址,App里一旦触发任何一个中断,CPU还是会去Bootloader区的向量表找中断处理函数,表现就是跳转成功后程序跑一会儿,一进中断就死机,甚至直接HardFault。很多人在App里不设置VTOR,总觉得链接脚本里改了地址就够了,这是不对的。

第二个地方是__set_MSP。跳转前必须把主栈指针切到App的栈顶地址。有人不切也能跑通,这是因为App代码段刚执行时栈还没用多少,Bootloader的栈暂时够用,但只要App里随便调个稍深的函数,栈就溢出了,表现为极其诡异的死机。这种问题很难查,所以跳转前老老实实设置MSP,一步都不能省。

对了,跳转前最好先__disable_irq()并清掉SysTick,延迟函数之类的先停掉,避免跳转过程中系统滴答中断插进来,造成状态错乱。

4. IAP升级协议与Flash写入流程实现

4.1 自定义帧协议:带CRC的最小可用方案

升级协议是整个系统上位机跟Bootloader之间的通信契约,协议设计越清晰,后期排查问题越容易。我设计的协议非常简单,每帧固定这么几部分:帧头2字节、命令1字节、数据长度1字节、数据区不定长、CRC16校验2字节。

实际使用中的命令也不多:握手、擦除、写数据、查版本、跳转,就这五个。每个命令都有对应的应答,要么应答ACK,要么应答NAK并附上原因码。上位机收不到正确应答就重发当前帧,这也算是一个最简单的超时重传机制。

CRC16校验是不可省的环节。串口传数据偶发一两个bit的错误太常见了,没有校验就把错误数据写进Flash,后果是整个App区作废,只能重新拆机上仿真器救砖。实测用标准的Modbus CRC16更新算法就够用,代码很小,消耗的时间对升级场景完全无所谓。

uint16_t crc16_update(uint16_t crc, uint8_t a) { crc ^= a; for (int i = 0; i < 8; i++) { if (crc & 1) crc = (crc >> 1) ^ 0xA001; else crc >>= 1; } return crc; }

4.2 擦除和写入:G4的64位编程

G4的Flash编程宽度是64位,也就是一次必须写入8字节,这是硬性要求。你写4字节甚至单字节,HAL库会直接报错或者数据不对。所以上位机发包时,最好每包数据就是8字节的整数倍,Bootloader收满8字节再调用一次编程函数。

我在实际操作中用的是HAL库的HAL_FLASHEx_Erase和HAL_FLASH_Program,步骤如下:先解锁Flash,擦除目标扇区,再按64位为单位写入数据,最后锁住Flash。代码大致这样:

HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase; uint32_t page_error = 0; erase.TypeErase = FLASH_TYPEERASE_PAGES; erase.Banks = FLASH_BANK_1; erase.Page = app_start_page; erase.NbPages = 1; if (HAL_FLASHEx_Erase(&erase, &page_error) != HAL_OK) { return 0; // 擦除失败 } uint64_t data = 0; data |= ((uint64_t)rx_buf[0] << 0); data |= ((uint64_t)rx_buf[1] << 8); data |= ((uint64_t)rx_buf[2] << 16); data |= ((uint64_t)rx_buf[3] << 24); data |= ((uint64_t)rx_buf[4] << 32); data |= ((uint64_t)rx_buf[5] << 40); data |= ((uint64_t)rx_buf[6] << 48); data |= ((uint64_t)rx_buf[7] << 56); HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, app_write_addr, data); HAL_FLASH_Lock();

擦除这里有一个特别容易踩的坑:Page编号。很多时候你拿着App起始地址0x08010000,想擦除它所在扇区,但G4的Page号跟地址之间不是简单的线性映射,因为每个扇区大小不一样,前几个扇区可能是16KB,后面可能是32KB,直接拿地址除以扇区大小是算不出正确Page号的。最稳妥的做法,是去参考手册的扇区表里查一下App起始地址落在第几个扇区,把这个编号写成宏定义,而不是每次运行时动态计算。

还有一种做法是你只在握手阶段擦除一次,而不是每收一包数据就擦一次,这样能大幅缩短升级总时间。但想清楚,如果数据传了一半断电,Flash里就是半旧半新,没有备份机制的话只能靠恢复出厂固件兜底。所以我在量产的方案里把擦除放在最后一步,先把固件全部收到RAM缓存里,校验整个固件的CRC无误后,再一次性擦除、写入。这个思路跟G4的双Bank配合起来,基本可以做到升级不掉线。

4.3 校验与跳转:别让半包数据把设备搞成砖

升级流程收尾这步,最忌讳的就是“收到就跳转”。我见过太多人写Bootloader,不管数据包对不对,写完了就跳,结果上电发现设备没反应,原因是某个包的数据在传输中出了问题。所以收完所有数据之后,必须对整个固件做一次CRC校验,校验通过才允许跳转,校验失败就返回错误码,等待上位机重新发起升级。

我的升级流程设计成这样的状态机:空闲状态收到握手命令后,进入等待固件信息状态;收到固件长度、总CRC等元信息后,开始计数收数据;当实际收到的字节数与固件长度一致,启动全固件CRC校验;校验通过,擦除App扇区并写入;全部完成,返回跳转命令;收到跳转命令后,执行前面那个跳转函数进入App。

这个流程里我把擦除放在校验之后,看起来多了一步,但安全系数提高了一个档次。毕竟擦除是一件不可逆的事情,一旦擦了又不能正确写入,设备就只能卡在Bootloader里。如果你没有做双Bank备份,这还是小问题,顶多重新升级一次;但如果你跳过了校验直接擦除,然后又因为擦除时间太长导致上位机超时,那才是真正的噩梦。

5. 常见问题与排查技巧实录

5.1 升级与跳转环节的典型问题速查

我这次开发过程中遇到的问题不算少,整理成一个速查表,以后遇到类似问题可以对照排查。

现象可能原因解决思路
跳转后进入HardFaultMSP未设置或设置错误检查向量表第一个字是否为合法栈顶地址
跳转后一进中断就死机App未设置VTOR,或设置地址错误在App最早期设置SCB->VTOR为App起始地址
波特率对但是全是乱码UART时钟源选择不当,误差偏大优先使用PLL输出的PCLK作为UART时钟
写入Flash失败未解锁、未擦除、地址未8字节对齐严格按HAL_FLASH_Unlock、擦除、写64位、Lock的顺序
第二次升级失败写保护未关闭或地址越界检查Flash写保护配置,以及App区长度是否超出
收到完整数据但校验不过传输错误或协议帧错位加CRC校验,收包状态机增加超时和重同步
升级完成后App起不来跳转前未关闭Bootloader外设和中断先DeInit所有外设,再关全局中断,最后跳转

5.2 强烈建议:先做最小验证,再上完整协议

如果你现在准备在STM32G4上从零做Bootloader,我强烈建议你不要一上来就把所有的升级流程全部实现。先做最小验证,把链路跑通,再逐步加功能。

我个人的经验是分四步走。第一步,先在Bootloader里写一个最简单的功能:上电检测某个GPIO电平,如果是低电平就跳过跳转,停留在Bootloader里。第二步,验证跳转:手动构造一个最简单的App程序,不用真正处理业务,只要在main里点个灯,然后把Bootloader的跳转函数跑通,确保能从Bootloader跳到App。第三步,验证Flash写入:写一个测试程序,把一串数据写入App区,写完读出来比对,确保编程宽度、地址对齐这些细节都正确。第四步,才是把串口协议和命令状态机整体串起来。

这套流程看起来很慢,其实最省时间。因为每一步的验证范围都很小,出了问题能快速定位是硬件问题、地址问题还是协议问题。我最开始直接写完整版协议,结果一次调试同时面对串口乱码、Flash写入失败、跳转死机三个问题,根本分不清先查哪个,白白折腾了两天。

5.3 升级中断电了怎么办:双Bank与备份机制的兜底思路

虽然上面把基本流程跑通了,但真放到产品上的话,还有一个问题必须考虑:如果写入新固件写到一半断电了,或者擦除后新的固件没写成功,设备是不是就废了?

最简陋的方案是靠Bootloader兜底:因为擦除和写入是在升级流程里独立的一步,只要不是极极端情况导致Bootloader区也被擦坏,设备始终能进入Bootloader,你就可以通过串口重新升级。但这要求Bootloader区在整个设计里面处于独立保护区状态,并且用户不能轻易破坏它。

如果你想做得再稳一点,强烈建议借助G4的双Bank特性。把App区进一步分成A份和B份,固定一份作为备份,一份作为运行固件。升级时先把新固件写入不运行的Bank,等校验通过后再切换Bank启动。这样升级中断电,顶多还在旧固件上运行,数据不会丢,设备不会砖。这就是我之前说G4做Bootloader比F1有天然优势的原因。

个人经验与后续扩展

最后分享一点我做完整个Bootloader以后的整体感觉。Bootloader本身并不复杂,复杂的往往是那些不易察觉的边界条件:Flash扇区表查没查对、跳转前外设关没关干净、App区地址在链接脚本和VTOR里是不是一致。这些细节每个单独拿出来都很小,可一旦出错,表现为各种玄学死机,查起来特别痛苦。

我在这次开发过程中还留了两个后续可以扩展的方向。一个是在Bootloader里加固件加密和签名验签,防止固件被他人提取或篡改,STM32G4的硬件加密模块和Flash读写保护都能配合使用。另一个是升级通道不局限于串口,可以扩展到CAN、USB或者以太网,协议框架只要设计成命令加数据的模式,移植起来非常容易。

如果你也在做STM32G4的Bootloader,建议把这份流程按照最小验证的思路走一遍,把底层跳转和Flash操作先跑通,剩下的协议和交互都是锦上添花。说实话,做完这些东西之后,客户现场再提升级需求,我心里是真的不慌了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询