ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 CAN Bootloader开发实战:从Flash分区到跳转实现

STM32 CAN Bootloader开发实战:从Flash分区到跳转实现 简介这是一套面向嵌入式开发者的STM32F103 CAN总线Bootloader完整源码工程适合想理解Cortex-M3微控制器启动流程、掌握CAN固件在线升级机制的中初级嵌入式工程师尤其适合汽车电子、工业自动化等应用场景也可作为毕设与工业项目移植蓝本。资源包共180个文件压缩后仅647KB以71个头文件、66个C源码文件和36个汇编启动文件为主同时包含工程配置、二进制固件与说明文档目录组织清晰便于逐模块研读。Bootloader实现覆盖CAN控制器初始化与波特率配置、标准帧及扩展帧的收发处理、CRC数据校验、Flash擦除与写入、新固件跳转执行并加入位错误、CRC错误、过载帧等异常恢复机制以及密码验证升级保护具有实际工程可移植性。已有2610人学习下载能够帮助开发者系统理解STM32启动过程、CAN通信协议和实际Bootloader工程落地要点参考价值较强。1. 为什么要在CAN总线上做Bootloader搞嵌入式的老工程师应该都有过这种经历产品已经装到现场了结果发现固件有Bug或者客户要求加个新功能这时候总不能把设备拆回来。设备外壳封死了、螺丝打了胶、线束都扎好了拆一次的成本比写代码还高。所以远程升级这件事几乎成了工业设备、车载电子、储能设备这类产品的刚需。我手上这批设备用的主控是STM32F103外设接口里恰好有CAN总线。CAN这个东西在工业现场太常见了两根差分线就能把几十个节点串在一起距离远、抗干扰强比串口稳得多。既然设备之间本来就靠CAN通信那升级固件顺路走CAN总线就是最合理的方案——不用额外引线也不用拆外壳只要上位机接到CAN网络里就能通过广播或者点对点的方式把固件发下去。Bootloader的实质说白了就是芯片上电后先跑一段“引导程序”由它来决定是直接跳转到用户App还是等待接收新固件。STM32F103的Flash容量从64KB到512KB不等Bootloader本身只占很小一段空间剩下的绝大部分都留给App。通过CAN总线把固件一帧一帧发下去Bootloader收到后写入Flash写完了再跳转执行整个升级链路就通了。这篇文章面向的是已经会点STM32基础、但没怎么碰过IAPIn-Application Programming的开发者。内容包括Flash空间划分、CAN外设初始化参数的计算逻辑、升级协议的帧格式设计、跳转代码的写法以及我在调试过程中踩过的坑。看完你不仅能跑通一套能用的CAN Bootloader还能理解每一步为什么要这么设计。2. 方案选型和地址分配2.1 Bootloader与App的Flash空间划分STM32F103的Flash是从0x08000000开始编址的中断向量表默认放在这个起始位置。Bootloader放在最前面那么App就必须往后挪。我用的芯片是STM32F103C8T6Flash一共64KB实际可用的从0x08000000到0x0800FFFF。我常用的划分方式是这样Bootloader占16KB也就是从0x08000000到0x08003FFFApp从0x08004000开始一直用到芯片末尾。有的老工程师习惯给Bootloader分8KB但我不推荐——如果你的Bootloader里要加协议解析、Flash驱动、超时重传这些功能8KB很容易写满改起来会很难受。留16KB代码空间宽松调试时心里不慌。App区域里还有一个细节最后预留1KB或者2KB存放升级标志和版本信息。比如我习惯在Flash末尾存一个结构体里面包含固件版本号、固件长度、校验值。Bootloader上电后先读这个结构体判断有没有合法的固件。需要注意的是STM32F103的Flash是分页管理的每页1KB。C8T6总共64页Bootloader占16页剩下48页给App用也就是48KB。如果你用更高容量的型号比如ZET6有512KB那Bootloader可以保持16KB不变App空间跟着芯片容量走。2.2 CAN外设的初始化参数怎么定STM32F103内置的是bxCAN外设也就是基本扩展CAN控制器。它只有3个发送邮箱和2个接收FIFO对Bootloader这种“一问一答”式的通信场景完全够用。初始化CAN外设时最核心的是波特率的计算。F103的CAN外设挂在APB1总线上时钟是36MHz。波特率计算公式是BaudRate APB1_CLK / (1 BS1 BS2) / Prescaler我目标是500kbps的标准工业波特率取Prescaler 4BS1 7BS2 6这里的数值代表的是时间段长度单位是Tq算下来就是36000000 / (1 7 6) / 4 36000000 / 14 / 4 642857不对让我重新算。实际上公式里的BS1和BS2用的是寄存器值加1比如寄存器里写BS1 6实际时间段是7个Tq。正确算法是BaudRate 36MHz / ((BS1 1) (BS2 1) 1) / Prescaler如果把BS1寄存器设为6、BS2寄存器设为5那总时间段就是7 6 1 14个Tq除数是14配合Prescaler 5得到36MHz / 14 / 5 514285接近500k但不够精确。所以更好的组合是BS1寄存器 78个TqBS2寄存器 67个Tq加起来8 7 1 16个TqPrescaler 4得到36MHz / 16 / 4 562500也不对。36除以16再除以4等于0.5625MHz也就是562.5k。这也不对。让我重新配。要得到500k需要满足36MHz / (总Tq数) / Prescaler 500k也就是总Tq数乘以Prescaler等于72。72可以拆成16 × 4.5不行Prescaler必须是整数或者18 × 4或者12 × 6或者9 × 8。总Tq数 同步段1 BS1 BS2其中同步段固定是1。取BS1 9BS2 7总Tq 1 9 7 1717没法整除72。取BS1 10BS2 7总Tq 18Prescaler 4这就是36MHz / 18 / 4 500k完美。所以最终参数是Prescaler 4BS1 10BS2 7。在标准库的CAN_InitTypeDef里CAN_BS1_10tq对应BS1 10CAN_BS2_7tq对应BS2 7CAN_SJW_1tq表示同步跳跃宽度为1个Tq。说到SJWSynchronization Jump Width这是热词里出现的知识点。SJW的作用是当CAN总线上的节点时钟存在微小偏差时控制器可以通过调整采样点的位置来重新同步。SJW设置的越大重新同步的能力越强但抗干扰能力会下降因为采样点允许的偏移范围变大了。对于Bootloader这种低速、短帧、无复杂组网的场景SJW 1Tq就够了。如果你的网络里有大量节点时钟精度又参差不齐可以适当调大到2Tq。2.3 跳转逻辑怎么设计最保险跳转是整个Bootloader里最危险的一步。跳转之前务必确认三件事第一App区域的首个32位字是合法的栈顶地址。STM32F103的RAM基址是0x20000000栈顶地址必须在RAM范围内。判断方法很简单读0x08004000处的值检查它是否落在0x20000000到0x20010000之间。不在这个范围就直接放弃跳转留在Bootloader里等待升级。第二App区域第二个32位字是合法的复位向量。复位向量必须落在Flash的App区域内也就是0x08004000到0x0800FFFF之间。这样能大概率避免跳转到空白Flash或者非法地址导致硬件错误。第三跳转前必须把当前使能的所有外设时钟关掉。如果Bootloader初始化了CAN外设、串口、中断不关掉就直接跳转App启动时外设还处于Bootloader配置的状态中断可能会以乱七八糟的方式触发。最稳妥的做法是调用RCC_DeInit把时钟恢复到复位状态同时关闭所有中断。实际跳转代码我写在后面第4节这里先提醒一个容易被忽略的点跳转前一定要把SysTick关掉。Bootloader运行期间SysTick肯定在跑如果它一直在触发中断跳转后SysTick的中断服务函数地址已经变了但外设没关就会跑飞到未知区域。3. 上位机与CAN升级协议设计3.1 一帧CAN数据报文的格式规划CAN标准数据帧最多携带8字节。对于固件下载这种场景8字节显然不够一次写入整个数据块所以协议必须分帧传输。我设计了一套简单的帧协议ID采用标准帧格式不使能扩展帧减少CAN控制器滤波配置的复杂度。帧ID规划如下帧ID方向功能数据字段含义0x01主机→设备握手请求数据[0]: 固定魔数0x5A0x02设备→主机握手应答数据[0]: 版本号, 数据[1]: Bootloader版本0x03主机→设备擦除Flash数据[0:3]: 擦除起始地址, 数据[4:7]: 擦除长度0x04设备→主机擦除完成数据[0]: 0x00成功, 非0失败0x05主机→设备固件数据数据[0:1]: 包序号, 数据[2:7]: 固件内容0x06设备→主机数据确认数据[0:1]: 收到的包序号0x07主机→设备跳转指令数据[0]: 跳转地址低8位...0x08设备→主机跳转结果数据[0]: 0x00准备跳转握手和擦除比较简单重点说固件数据帧。一帧CAN报文最多7字节有效数据留1字节给包序号的话一次最多传6字节固件内容。如果一个固件有40KB那就要拆成6800多帧按500k波特率实测一帧加应答的往返时间大概在0.5ms左右总耗时约3秒多可以接受。包序号我用16位范围0~65535足够覆盖最大固件。每发送一帧数据主机必须等待设备回对应的确认帧才能继续发下一帧。这种停等协议虽然效率不算最高但胜在简单可靠非常适合Bootloader这种一次性场景。3.2 完整升级流程的状态机Bootloader端的程序本质上就是一个状态机。上电后先检查跳转条件如果不需要升级就直接跳App如果需要升级就进入升级循环。我把状态划分成这么几个WAIT_HANDSHAKE等待主机发握手帧RECEIVE_ERASE等待擦除指令RECEIVE_DATA等待固件数据帧VERIFY_CHECK校验固件完整性JUMP_READY准备跳转状态切换的逻辑是收到握手帧→应答版本号→收到擦除指令→执行Flash擦除→应答完成→进入数据接收→每收一帧数据就写一页Flash→回确认帧→收完最后一帧数据→等待跳转指令→收到跳转指令后执行跳转。这里有个设计选择值得说明擦除和写入是分开的擦除Flash在接收数据之前一次性完成。为什么不边收边擦因为Flash擦除期间CPU不能正常响应中断而CAN接收依赖FIFO缓冲。如果擦除时间长FIFO溢出就会丢帧。F103擦除1KB页大概是20~40ms如果擦除整个App区连续擦几十页时间太长CAN FIFO撑不住。所以先把所有页擦完再开始接收数据。主机端的流程就简单多了打开CAN设备→周期性发送握手帧直到收到应答→发送擦除指令→等待擦除完成→按包序号逐帧发送数据→等待每帧确认→发送跳转指令→结束。4. 核心源码模块实现细节4.1 Flash操作的几个关键点STM32F103的Flash操作有几个隐藏很深的坑我一个个说。第一个坑解锁。Flash在上电后默认是锁定状态直接调FLASH_Program会卡死。必须先调用FLASH_Unlock()写完再FLASH_Lock()。如果你用了标准库还要先清一下标志位否则可能出现“操作成功但实际没写进去”的诡异现象。void Flash_Unlock_UserArea(void) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); }第二个坑擦除以页为单位但写入是按16位半字为单位。也就是说Flash只能一页一页擦写的时候必须凑成16位对齐的数据。而且同一地址不能重复写写过之后再写会失败——除非重新擦除。所以每次接收一帧CAN数据如果数据不是偶数长度必须补齐到偶数。比如CAN帧里带了6字节固件内容这6字节可以分成3个16位半字写入3个地址。如果带的是5字节就得在末尾补1字节0xFF再写入。不补的话最后一个半字没法写而且后续地址会错位。void Flash_WriteBuffer(uint32_t addr, uint8_t *data, uint16_t len) { uint16_t i; uint32_t halfWord; for (i 0; i len; i 2) { halfWord data[i] | (data[i 1] 8); FLASH_ProgramHalfWord(addr i, halfWord); } }第三个坑写Flash时CPU会暂停执行这段时间CAN中断照样触发但中断服务函数执行不了。不过不用担心丢帧——CAN外设有2个FIFO每帧8字节Flash写半字的时间是微妙级别最多几个微秒FIFO完全接得住。真正危险的是擦除阶段这也是我坚持“先擦除后写入”的原因。4.2 跳转代码的完整写法跳转函数是所有Bootloader源码里最需要小心的部分。网上的参考代码很多但不少有细节问题。我实际验证过的版本是这样typedef void (*pFunction)(void); void Jump_To_App(uint32_t appAddr) { pFunction jumpFunc; uint32_t stackAddr *(volatile uint32_t *)appAddr; uint32_t resetAddr *(volatile uint32_t *)(appAddr 4); if ((stackAddr 0x2FFE0000) ! 0x20000000) { return; } RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; __disable_irq(); jumpFunc (pFunction)resetAddr; jumpFunc(); while (1) { } }关于栈顶地址的判断我用的是0x2FFE0000掩码。STM32F103最大RAM是64KB地址范围0x20000000~0x2000FFFF。栈顶地址如果在这个范围内高16位一定是0x2000或者0x2001。屏蔽低17位后应该等于0x20000000。这个判断比简单比较范围更严谨。跳转之前外设时钟复位和中断关闭的顺序很重要。先RCC_DeInit恢复时钟再清SysTick最后关全局中断。如果先关中断再复位时钟RCC_DeInit过程中有些寄存器写入可能依赖中断状态顺序反了可能导致复位不彻底。4.3 APP工程侧的配合修改Bootloader写完APP工程不配合也白搭。最重要的一件事修改中断向量表偏移。F103没有VTOR寄存器Cortex-M3内核有这个寄存器但ST在F1系列上没引出来所以不能用“改VTOR”这种优雅方案。标准做法是在启动文件里或者在main函数最开始把中断向量表复制到RAM然后修改SRAM基址处的向量表指针。我推荐的做法是用SystemInit之后的几行代码#define APP_BASE_ADDR 0x08004000 void Jump_To_App_Init(void) { if (((*(volatile uint32_t *)APP_BASE_ADDR) 0x2FFE0000) 0x20000000) { Vector_Offset_To_RAM(APP_BASE_ADDR); } } void Vector_Offset_To_RAM(uint32_t appAddr) { uint32_t i; uint32_t *pSrc (uint32_t *)appAddr; uint32_t *pDest (uint32_t *)0x20000000; for (i 0; i 64; i) { pDest[i] pSrc[i]; } SCB-VTOR 0x20000000; }为什么复制到RAM而不是直接设置VTOR到Flash因为F103的设计如此向量表必须驻留在0x00000000~0x000FFFFF或RAM区域。直接指向Flash的0x08004000在F103上是不生效的。复制到RAM的办法虽然占用几百字节RAM但最稳定。还需要改的两个地方链接脚本里Flash的起始地址改成0x08004000长度相应减16KB。这个不解释不改的话固件编译出来还是从0x08000000开始Bootloader跳过去就废了。启动文件里如果定义了SystemInit函数且会操作时钟那没问题。但注意如果APP里的SystemInit把RCC重新配置这不影响跳转结果因为跳转时已把时钟复位APP重新初始化完全正常。5. 调试经验与典型问题排查5.1 用CAN波形判断通信质量调试CAN Bootloader时示波器或者逻辑分析仪几乎是必需品。只靠代码调试会很痛苦因为你不知道是协议问题还是物理层问题。我看CAN波形主要看三个东西隐形电平是否为2.5V左右、显性电平是否压到1.5V和3.5V附近、波形边沿是否干净。CANH和CANL的差分电压显性时大约是2VCANH约3.5VCANL约1.5V隐形时两者都约2.5V差分接近0V。如果你看到波形上升沿和下降沿很缓像电容充电一样那大概率是终端电阻有问题。CAN总线两端各需要120欧姆终端电阻总共60欧姆。如果终端匹配没做好波形反射会导致误码率上升表现就是通信时好时坏。还有一种常见现象波形幅值正常但发送端波形有高频毛刺。这种情况多半是地线电位差造成的不同节点之间地没连好。在实验室里用同一个电源问题不大到了现场长距离布线就会出现。处理办法是确保所有CAN节点的地线共地或者使用带隔离的CAN收发器。5.2 握手成功但下载到一半就卡住的排查我调试时遇到最多的故障就是握手成功、擦除成功发送几十帧数据后设备没反应了。排查思路按优先级排列第一检查上位机发送节奏。如果用PCAN或USB-CAN适配器Windows下发送速度可能远高于你的预期。一帧接一帧地发不给设备留处理时间Flash写入再快也会跟不上。处理办法是在上位机里加延时或者严格等待确认帧再发下一帧。第二检查包序号。我遇到过固件大于64KB的情况包序号是16位的超过65535就回卷了。虽然实际固件很少超过这个值但保险起见跳转指令里可以带总包数Bootloader收到全部包以后做一次总长度校验不匹配就拒绝跳转。第三检查Flash擦除范围。如果擦除指令里的长度参数和多帧数据的总量不一致会导致Bootloader写地址越界。越界写入Flash会硬件错误直接死在中断里。我后来在协议里加了一条规定跳转指令必须携带固件总长度Bootloader据此做最终校验长度不对就不跳。5.3 升级完成后App跑飞了这个问题的排查难度最高因为它可能涉及Bootloader、链接脚本、中断向量表三处配合。我总结几种情况如果是Bootloader跳转后就死机连App第一行代码都没跑到检查跳转函数是不是在跳转前开了中断。我遇到一次是在跳转前使能了CAN接收中断结果CAN总线上残留的最后一帧报文触发了中断而中断向量已经指向App区域App的中断处理函数逻辑处理不了这个异常直接死循环。如果是App运行过程中随机死机重点查中断向量表有没有正确重映射。我用RAM重映射方案复制完之后记得把SCB-VTOR指向RAM基址。如果忘了设这个寄存器中断来了会去0x08000000找向量表——那里是Bootloader如果App中断处理函数地址不同于Bootloader的对应地址程序就会跳错地方。还有一类情况App能运行但某些外设中断不响应。这种大概率是向量表复制长度不够。Cortex-M3向量表一共67个向量16个系统异常51个外部中断我复制64个够用。但如果你用了F103的高密度型号中断源更多建议复制到256个反正RAM不缺这点空间。5.4 波特率不一致导致的疑难杂症CAN波特率设置错误的表现和串口有很大区别。串口波特率不对大概率收不到任何数据CAN波特率不对往往表现为“能收到部分帧但校验失败”。这是因为CAN的仲裁机制。发送节点发出帧接收节点如果在采样点检测到电平不一致就会发出错误帧导致发送节点重发。重发次数多了总线就可能被错误帧占据。如果你在调试中发现设备长时间发不出帧同时总线上错误帧计数器持续增长先别急着查代码量一下波特率对不对。判断波特率是否正确的快捷方法是找到总线上正在传输的数据用示波器数一下一bit的时间长度。比如500kbps的波特率一bit周期是2微秒。如果实际测到的是1.8微秒那波特率配置就有偏差。我调CAN Bootloader时最常用的一组参数是Prescaler4BS110TqBS27TqSJW1Tq采样点位置在78%附近。这个采样点对绝大多数工业CAN网络都适用兼容性很好。5.5 一个小工具技巧用CANTest快速验证上位机逻辑如果你用的是周立功或者创芯的USB-CAN适配器它自带的CANTest工具就能模拟上位机。我调试时习惯先在CANTest里手动发握手帧看设备回应再手动发擦除帧观察Flash擦除后设备是否回复完成最后用CANTest的周期发送功能按照固定延时逐帧发数据。这样做的好处是能把上位机软件的Bug排除在外。如果CANTest手动发帧一切正常说明Bootloader没问题问题出在你的上位机发送节奏或者帧格式上。反过来如果CANTest手动发都出问题那就老老实实查Bootloader代码。这种“手动化→半自动→全自动”的调试路径我用了很多年省了不少排查时间。特别是遇到那种“代码看着没问题死了又不知道死在哪”的情况逐帧手动发送能很快定位到是发送端还是接收端的问题。整个CAN Bootloader项目做下来我最深的体会是这类活儿真正的难点不在于写那几百行C代码而在于把协议、Flash操作、中断处理、编译链接这一整套链路都理清楚。任何一个环节想得不周全都会在调试阶段加倍偿还。我踩过的坑都写在上面了你照着这个框架做一套自己的CAN Bootloader至少能少走一半弯路。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进