STM32F4串口/485 OTA升级实战:Bootloader分区与避坑指南 简介面向STM32F4系列嵌入式开发者这份资料提供一套可自制的OTA升级方案通过串口或RS485接口完成固件传输与在线更新适合产品远程维护、现场升级等场景。资源包含bootloader与app两个完整的Keil工程共277个文件以C/H源码、uvprojx工程文件为主同时提供axf/hex/bin固件、批处理脚本、链接脚本及映射文件方便直接烧录与内存布局分析整个压缩包仅2.86MB。工程中除核心升级逻辑外还包含STM32F4外设驱动源码可对照学习BootLoader跳转、固件分包接收、Flash写入等关键流程批处理脚本可用于一键清理编译中间文件快速迭代验证。目前已有2540人学习该资源对于正在搭建OTA升级框架、调试485通信升级方案的开发者具有直接参考与移植价值。1. 串口/485给STM32F4做OTA为什么先搞定Bootloader分区再动手做嵌入式开发的人迟早会遇到OTA。STM32F4的OTA一般两种走法要么走网络要么走串口/485。我大部分项目优先做串口/485原因很现实很多设备本身不带网口但串口和485总线几乎是标配尤其工业、电力、消防这类现场。用一台PC或者工控机通过485总线给挂载的多片F4刷固件成本低一套上位机就能管一片区域的设备。“方式1”在我的习惯里指Bootloader常驻启动区上位机通过串口/485按帧下发固件每收满一帧校验通过就写入应用区写完跳转到新程序。这个方案核心价值在于把“固件升级”变成一套可控协议而不是裸写Flash的玄学。新手照着做能少翻车老手也能在分区、校验、断点续传上摸到边界。这里先把Bootloader分区、帧格式、485收发切换、Flash写入和跳转这几个关节拆开参数和坑都交代清楚。2. STM32F4的Flash分区与启动流程先画好内存地图再谈OTA2.1 Flash资源怎么分配才够用STM32F4常见的是512KB、1MB Flash版本按默认的扇区大小来分。以F407VE512KB Flash为例扇区从0到7前4个扇区各16KB第4个扇区64KB后面每个128KB。Bootloader不能只占一个扇区因为Bootloader本身可能超过16KB而且要把升级参数、标志字、日志区单独隔离出来避免被应用误擦。我一般会这样分区区域地址范围大小用途Bootloader区0x08000000 - 0x0800FFFF64KB存放Bootloader固件含串口/485驱动和升级逻辑参数区0x08010000 - 0x080103FF16KB存升级标志、版本号、帧计数、CRC结果应用区A0x08010400 - 0x0803FFFF~192KB应用主程序可运行应用区B0x08040000 - 0x0807FFFF256KB备份区存储新收到的完整固件日志区0x08080000 - 0x08080FFF4KB记录升级日志便于排查分区提醒Bootloader区至少留64KB因为标准库HAL串口驱动编译出来很容易超过20KB加上后续要扩展多帧缓存小了会让人很被动。应用区要按实际固件大小来定如果你的App编译出来只有80KB那分192KB绰绰有余甚至可以把A区和B区缩减留出更多空间给日志或者字库。如果固件超过130KB就要考虑每次升级覆盖旧应用区而不是保留双备份后面的进阶章节会说到怎么做。2.2 启动顺序和跳转条件芯片上电后从0x08000000取栈顶指针和复位向量此时执行的是Bootloader。Bootloader要做的事很简单先检查参数区里的升级标志如果有“待升级”标志就进入升级模式等待上位机发握手帧如果标志是“无升级”就直接跳转到应用区A的起始地址。跳转代码我习惯用函数指针关键点在关闭全局中断和重新设置栈指针typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t sp *(volatile uint32_t *)app_addr; // 取应用区起始4字节作为栈顶指针 uint32_t reset_addr *(volatile uint32_t *)(app_addr 4); // 取复位向量地址 pFunction jump (pFunction)reset_addr; __disable_irq(); // 跳转前必须关中断 SysTick-CTRL 0; // 关闭系统滴答定时器 SCB-VTOR app_addr; // 把向量表偏移到应用区 __set_MSP(sp); // 设置主栈指针 jump(); // 跳转 }这段逻辑说明跳转前关闭中断是因为在Bootloader里可能启用了串口中断、定时器如果不关跳到App后这些中断仍然开着但中断向量表还没来得及切换程序会跑飞。VTOR寄存器是F4特有的向量表偏移寄存器App编译时需要在链接脚本里把FLASH段起始地址改成应用区地址否则即使跳过去了中断响应也会错乱。有一点很多人忽略跳转前最好把用过的外设都复位一遍省得App里初始化UART、DMA时碰到残留状态。2.3 APP端要配合做的修改App端不是直接拿原来的工程编译就行的。必须在CubeMX或者链接脚本里把Flash起始地址改到应用区起始地址例如/* 假设应用区A起始地址为0x08010400 */ FLASH_BASE 0x08010400;同时中断向量表重定向SCB-VTOR 0x08010400;如果你用HAL库这是必须的一步。另外App里跟升级有关的操作要留出通信接口比如收到上位机的“进入升级”指令后先擦除参数区写入升级标志然后复位。复位后Bootloader发现标志就不会跳转App了自然进入升级流程。这中间有个时序陷阱App写升级标志后不能立刻用软复位有的外围器件断电后需要时间释放总线建议延时200ms再复位。3. 串口和485的收发机制帧协议、DMA缓存和RE/DE切换3.1 帧格式为什么要自己定义不直接用现有协议串口本身只保证字节流不保证一帧完整。OTA升级不同它要承载几十KB到几百KB的数据中间任何一帧丢错都可能导致设备变砖。所以协议帧要同时考虑校验、序号、长度、类型还捎带ACK和重发机制。方式1我用的帧格式是帧头(2B)类型(1B)序号(2B)长度(2B)数据(NB)CRC16(2B)帧尾(2B)0xAA550x01握手0x02固件0x03结束递增数据长度最大1024固件内容对“类型序号长度数据”算CRC0x0D0A帧头用0xAA55一般通信里这个特征比较明显不容易和普通数据混淆。序号从0开始每一帧加1上位机发送后要等ACKACK里带序号收到超时再重发。长度字段限制最大1024是因为F4的Flash每次最多写入1KB双字编程超过的话底层还要切分协议反而复杂。CRC16我用查表法速度快且代码量小。3.2 485收发切换RE/DE引脚时序是最大的坑485是半双工同一时刻要么收要么发。STM32的UART不会自动切换方向得用GPIO控制收发器芯片的DE/RE引脚。我常用的做法是定义一个RS485控制引脚发送前把引脚拉高发送完最后一字节后延时再拉低。为什么不能立刻拉低因为UART的TX移位寄存器里还有数据没挪出先拉低方向切换会让最后一字节被截断。看这个发送函数void RS485_SendData(uint8_t *buf, uint16_t len) { RS485_DE_HIGH(); // 切换为发送模式 HAL_UART_Transmit(huart1, buf, len, 100); // 阻塞发送 while (__HAL_UART_GET_FLAG(huart1, UART_FLAG_TC) RESET); // 等待发送完成 RS485_DE_LOW(); // 切换为接收模式 }这里的__HAL_UART_GET_FLAG是等TX移位寄存器彻底空掉。如果不用这个直接拉低会导致最后一个字节在线上长度不够接收端解析不到帧尾。如果是用DMA发送就要在DMA发送完成中断或者传输完成回调里再拉低DE千万别在启动DMA后立刻拉低。接收侧我用的方案是UART空闲中断配合DMA。STM32F4的UART有IDLE中断总线空闲可以用来判断一帧接收结束。DMA把数据搬运到一个环形缓冲区每收到一帧IDLE中断里计算本次接收长度然后交到帧解析器。这个方式不用一个字节一个字节打断CPU效率高许多。代码如下void HAL_UART_IdleCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { __HAL_UART_CLEAR_IDLEFLAG(huart1); HAL_UART_DMAStop(huart1); // 停止当前DMA传输 uint32_t len RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(hdma_usart1_rx); // 实际接收长度 ProcessFrame(rx_buf, len); HAL_UART_Receive_DMA(huart1, rx_buf, RX_BUF_SIZE); // 重启DMA } }__HAL_DMA_GET_COUNTER表示DMA还剩余多少字节未传输用缓冲区总长减去它得到本次接收到的字节数。一定要先停止DMA再取长度否则DMA还在跑计数值不稳定。还有一个坑IDLE中断只用一次可能还不够因为在低波特率下本身一帧数据中间可能有间隔需要配置UART的空闲中断检测超时时间。具体用寄存器修改也行不过HAL库里直接在huart-Init里设置Prescaler和TimeOutStandard和AdvancedInit模式不同老工程要注意。3.3 波特率和超时参数怎么选OTA对速度的要求和稳定性是矛盾的。波特率越高出错概率越大特别是485总线在长线传输时波特率太高会导致信号反射。我一般用115200这是可靠性和速度的平衡点工业现场的485线长几十米都没问题。如果现场干扰重降到57600也完全可以接受。超时参数要按单帧传输时间算比如115200bps一帧1024字节数据加协议头尾大约90ms盲目设1000ms超时会导致出错后卡很久。我习惯设150ms重发超时设300ms。4. Flash擦除与写入边收边写还是收完再写方式1怎么选4.1 最小擦除单元是扇区不是任意地址STM32F4的Flash擦除按扇区进行一个扇区最小16KB最大1MB。写入可以双字节、单字或者多字编程但前提是擦掉。因为Bootloader在低地址应用区在0x08010400擦就擦应用区A所在的扇区。注意Flash擦除期间所有中断都要暂停因为擦除指令执行时CPU阻塞任何外部中断都不响应如果上位机这时发来数据UART的DMA缓存够用就不会丢但如果你用的是轮询接收那就直接丢帧。擦除代码void EraseAppSector(uint32_t addr) { FLASH_EraseInitTypeDef erase; uint32_t sector_error 0; erase.TypeErase FLASH_TYPEERASE_SECTORS; erase.Sector GetSectorFromAddr(addr); erase.NbSectors 1; erase.VoltageRange FLASH_VOLTAGE_RANGE_3; HAL_FLASH_Unlock(); // 开始擦除 if (HAL_FLASHEx_Erase(erase, sector_error) ! HAL_OK) { // 擦除失败记录错误并向上位机返回错误帧 } HAL_FLASH_Lock(); }这里有个隐藏点HAL_FLASHEx_Erase擦除完成后会清除Flash操作状态寄存器但如果你在中断里调用这个函数要提前开Flash中断优先级否则擦除可能被更高优先级中断打断。F4的Flash擦除时间最少几毫秒期间调度器如果切任务就会有风险。我习惯裸机OTA升级过程中不进RTOS彻底避免调度问题。4.2 写入方式一帧一写还是缓存后整扇区写方式1我直接用“边收边写”每收到一帧有效数据先把数据拷贝到页缓存满一页或者收到结束帧时统一写。因为F4的Flash写入是按页编程但页大小在不同型号上有差异F407是16KB一页实际上同扇区。简单说收满1KB就写1KB速度快也不需要中间暂存全部固件。写入函数uint8_t WriteFirmwareFrame(uint32_t app_addr, uint8_t *buf, uint16_t len) { uint32_t offset 0; uint64_t data 0; HAL_FLASH_Unlock(); while (len 8) { for (int i 0; i 8; i) { data | ((uint64_t)buf[offset i]) (8 * i); // 组成64位数据 } if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, app_addr offset, data) ! HAL_OK) { HAL_FLASH_Lock(); return 1; } offset 8; len - 8; } HAL_FLASH_Lock(); return 0; }手册上F4要求双字8字节对齐编程如果数据长度不是8的倍数最后几个字节要单独凑满一个双字把多余的位填0。比如一帧1024字节1024正好是8的倍数没问题。但如果长度字段被改成了1027就不能直接循环写要先补到1032才行。实际做协议时直接把长度限制在8的倍数省掉很多边缘逻辑。4.3 固件写完后怎么校验写完最后一帧不能立刻跳转要回读Flash里的内容和PC端镜像比对一下。常见的做法是整体CRC校验。PC端发送固件前先算所有字节的CRC32在结束帧里把CRC32和总长度传过来。Bootloader在本地把收到的数据逐帧累算CRC结束时比对。若一致就刷新升级标志进入App不一致返回错误帧停留在升级模式等待重新发送。回读校验的代码uint32_t CalculateFlashCRC(uint32_t addr, uint32_t size) { uint32_t crc 0xFFFFFFFF; for (uint32_t i 0; i size; i 4) { uint32_t val *(volatile uint32_t *)(addr i); crc CRC32_Step(crc, val); } return crc; }5. 避坑与常见问题串口/485 OTA升级的5个真实翻车现场5.1 现象一上电后跳进App但中断全部不响应这是最常见的一个坑。现象是Bootloader能跳转但App里一旦开启串口中断、定时器中断程序就死在中断里或者进入HardFault。原因基本一致没有设置向量表偏移或者没有在跳转前把Bootloader期间打开的中断都关掉。尤其是用HAL库时HAL_RCC_DeInit没有调用外设时钟状态被带进App。解决跳转前执行这些操作HAL_RCC_DeInit(); HAL_DeInit(); __disable_irq(); SCB-VTOR APP_ADDR; __enable_irq();HAL_RCC_DeInit会把系统时钟恢复为默认状态App侧再自己重新初始化。注意__enable_irq放的位置放在VTOR设置之后别放在跳转前否则中断可能在向量表还没切换时冒出来。5.2 现象二485方式升级设备A升级完了设备B收不到应答参测的时候经常遇到多站485升级。现象是第1、2台设备都正常第3台开始握手不成功上位机一直超时。原因485总线是共享总线一个字节发完必须等总线释放才能收。我的RS485_SendData函数里只有延迟拉低DE但没考虑总线turnaround时间。上位机发完一帧要等设备回ACK设备回ACK前它自己要先释放总线。两个设备之间如果距离近还存在反射波导致后面的设备收不到完整帧。解决在485发送完成拉低之后增加3~5个字节时间的延时再切换接收例如RS485_DE_LOW(); DelayUs(5 * BITS_TIME); // 至少等5个位时间 HAL_UART_Receive_DMA(...);同时上位机在发送完每帧后等待应答的超时要加长比如300ms如果总线设备多可以再往上加。5.3 现象三升级中途断电再上电设备直接起不来这是个血泪教训。设备在上电升级过程中已经写了一半Flash这时如果断电Flash里就是半个旧固件半个新固件启动跳转必挂。原因边收边写模式下没有做“两区备份”。只使用单个应用区时擦除和写入过程中掉电应用区大概率损坏。解决有两种方向一种是升级流程开始时先不擦应用区而是把新固件完整接收并写入备份区B写完后校验完成再把备份区整体拷到应用区A。这种方式安全但慢掉电后Bootloader发现应用区A损坏还能从B区拷贝回来。另一种更省事只在应用区内做“先写新内容到未使用的空闲Flash再最后跳转时切换”但F4的Flash扇区结构限制了这种方案没法完全避险。我建议在方式1里加入简单状态机标志位先在参数区标明“升级中”写入过程每隔几十个字节更新一次帧计数启动时如果发现标志“升级中”就认为升级未完成强制停留在Bootloader等待重发不跳转。这样至少避免设备变砖只是多了一次重新升级。5.4 现象四App比Bootloader大写入时地址越界有时候Bootloader编译完只有30KBApp却有200KB分区应用区A只有192KB。升级上位机发送过程中Bootloader收到第150KB时写入地址已经超出了应用区末尾。原因上位机没有主动查询Bootloader的固件容量直接发整个bin。Bootloader那边也没有判断写地址是否超出分区末尾。解决在握手帧里加入双方互相上报容量。Bootloader握手时回传应用区最大长度上位机收到后先比较自己固件大小超出就终止并提示。Bootloader写每帧前也检查地址if (write_addr frame_len APP_END_ADDR) { // 返回错误帧停止升级 }这个检查成本很低但很多人漏掉。5.5 现象五串口升级正常485升级偶尔丢最后一帧丢最后一帧的现象最迷惑大约5%的概率升级完成后跳转失败重新握手又能升级成功。后来发现是结束帧没有被正确解析。原因485在收到最后一帧数据后如果上位机马上发结束帧设备还在上一个接收周期里没有把RX DMA重新准备好结束帧被丢弃。解决在固件帧处理完成后不要立即回到接收等待状态先等DMA重新启动再返回主循环。收完最后一帧时加一个状态锁等HAL_UART_RxCpltCallback触发后再转入接收。这个时序问题在多任务环境下更明显需要特别关注。6. 进阶升级失败自动回滚与版本管理小技巧6.1 让Bootloader支持“新固件失败则回滚”既然已经有了双区备份的影子可以进一步做自动回滚。Bootloader启动时检查参数区里的“新固件可运行”标志如果这个标志没有置位说明升级完成后系统还没有正常进入过App于是自动跳回备份区的上一版本。实现方法App启动后等待3秒如果系统正常初始化完成就往参数区写“可运行”然后正常运行。如果3秒内出现HardFault或看门狗复位Bootloader判定新固件不可用回滚到备份区。这块代码放在App的main函数里int main(void) { if (ReadUpgradeFlag() FLAG_NEW_FW) { Delay(3000); if (!CheckSystemReady()) { NVIC_SystemReset(); // 3秒后未就绪强制复位触发回滚 } else { WriteUpgradeFlag(FLAG_OLD_FW_OK); // 标记旧固件可用 } } // 正常启动App }实际上这个标志位应该在Bootloader写入App只负责清标志或者写“正常”。这样如果App在启动早期就卡死Bootloader有机会接管。6.2 在协议里加一个版本胶囊我们早期用固定帧格式每次升级前先人工确认版本后来被整烦了。改进做法是握手帧里带上App当前版本号与固件bin版本号。Bootloader上报当前App版本上位机再发升级请求时如果版本相同就直接拒绝省得工作人员误操作。版本号我习惯用4字节表示主版本、次版本、补丁版本、build编号升级历史记录也存到日志区每次升级后写一条带时间戳的日志。方便后期排查是哪个版本翻车。6.3 串口/485 OTA的合理解耦方法如果多个项目复用这套升级逻辑最好把Bootloader和App的通信协议独立成模块不依赖具体主板GPIO定义。我一般把串口驱动、485方向控制、Flash编程和升级状态机四个文件拆开新工程师接手时只需要改板级配置头文件不需要动协议逻辑。这也是我在项目里被骂过“代码耦合太紧”之后学到的——从那以后我每次做OTA都强制把协议层、板级层分开至少保证换一块PCB时不用重写升级核心。这个小习惯真心希望帮到你。以上从分区设计到485切换从Flash写入到掉电保护把这次串口/485 OTA升级的主要节点都过了一遍。如果你正打算给F4加远程升级能力可以先按方式1跑通基础流程再逐步加回滚、版本管理和多设备并发。资源包里包含了完整的工程模板、Bootloader源码、上位机协议说明和烧录工具参数配置下载下来对照这篇笔记操作应该能少踩不少坑。本文还有配套的精品资源点击获取