
简介面向嵌入式开发者的CAN Bootloader节点应用实例完整展示如何通过CAN总线对STM32F103微控制器进行远程固件更新与修复压缩包共180个文件核心以72个h头文件、66个c源码文件、36个s汇编文件为主辅以工程配置及说明文档整体容量仅650KB便于快速获取。内容包含CAN通信协议文档与单片机端启动源码协议文档细化了数据帧格式、握手确认、差错重传与校验流程帮助理解底层交互规范源码覆盖CAN控制器初始化、固件接收、Flash擦写及异常处理可直接移植到实际项目。配合基于Qt的上位机可实现固件文件选择、传输进度监控与连接状态管理形成从协议设计到界面开发的完整闭环。目前已有272人学习下载适合具备一定嵌入式基础、希望掌握CAN Bootloader机制并提升Qt与MCU联调能力的开发者。该实例可复现现场升级场景为安全启动与回滚设计提供重要参考。 做嵌入式这么多年汽车电子和工业控制里用得最多的远程升级方案就是CAN bootloader。手头这套STM32F103的CAN bootloader工程包是我从通信协议到Flash操作再到上位机配合一点点调出来的核心价值很直接不用拆设备、不占额外的调试口一根CAN双绞线就能把固件刷进去生产维护能省一大截。这篇文章把整个方案的思路、通信协议、关键代码和踩过的坑完整整理一遍给正在做CAN IAP或者准备把F103节点接入总线升级体系的朋友做个参考。1. 为什么选CAN做Bootloader方案选型与总体设计1.1 串口、USB之外CAN的不可替代性提到IAP很多人第一反应是串口。串口确实简单但工业现场往往没有预留串口线或者设备已经装在现场串口根本够不着。USB同理线长和抗干扰都不占优势。以太网在高端设备里有但中低端MCU节点极少有网口。CAN总线在汽车和工业控制里几乎是标配一条差分线能挂几十个节点通讯距离上百米甚至更远抗干扰强多主机仲裁机制也不怕多个节点同时发数据。对嵌入式设备来说把Bootloader做进CAN就是顺势而为总线既然已经在跑业务报文升级命令顺着这根总线发过来就行不需要额外接线。F103作为大量存量设备的常青树芯片bxCAN外设支持CAN 2.0A/B标准帧扩展帧都能用波特率最高1Mbps做升级完全够用。这也是我选择CAN加F103组合的理由。如果你手头是类似芯片这套思路同样可以平移只是寄存器细节需要改。1.2 Bootloader整体架构分区、流程与升级模式整个架构分三块Bootloader固件放在Flash最前面负责CAN初始化、通信协议解析、Flash擦写、跳转App。App固件从Flash偏移地址开始存放业务逻辑正常跑收到上位机升级指令后置标志并复位让Bootloader接管。上位机工具PC端通过USB-CAN适配器按协议把bin文件分包发送并解析设备状态。Flash分区以512KB大容量型号为例可以这么划分区域地址范围大小用途Bootloader区0x08000000 - 0x08007FFF32KBBootloader程序App区0x08008000 - 0x0807EFFF约480KB业务程序参数区Flash末尾512B512B版本号、升级标志等App区的偏移量和大小要根据实际芯片容量调整。中容量64KB芯片的话Bootloader占16KBApp从0x08004000开始。这套方案我第一版在F103C8T6128KB Flash上跑通过后来又移植到F103ZET6只需改偏移宏和页大小参数。运行流程是上电先跑Bootloader检查升级标志如果有升级需求就进升级模式等待CAN命令否则延时几百毫秒后直接跳转App。App内部也可以收到升级指令后置标志并软复位让Bootloader接管升级流程。这里的标志我习惯存在BKP备份寄存器里掉电不清配合一个复位函数很干净。2. CAN Bootloader核心原理SJW、波特率与Flash操作2.1 CAN时间量子、采样点与SJW同步跳跃宽度很多人在CAN初始化时直接抄参数不太理解SJW同步跳跃宽度和采样点是干什么的。这里用大白话讲清楚。CAN控制器把每一位的时间分成若干份每份叫一个时间量子tq。一个位时间由三部分组成同步段SYNC_SEG固定1tq传播段和相位缓冲段加起来叫BS1后面还有BS2相位缓冲段。控制器在BS1和BS2交界处采样总线电平采样点的位置决定了抗干扰能力。采样点太靠前信号边沿附近容易采到毛刺太靠后留不下足够的相位缓冲总线负载重时容易出错。业界通常推荐75%到85%我习惯配在80%左右。SJW是控制器重新同步时允许把相位缓冲段向前或向后调整的最大宽度单位也是tq。简单说用来吸收各节点晶振偏差和时钟抖动。如果总线上有的节点用内部RC甚至劣质晶振SJW设成1tq时间长了累积误差会越来越大最后丢帧。把SJW设到2tq甚至3tq容错能力明显提升。但SJW也不是越大越好过大时对噪声的过滤能力会下降常规做法是保持SJW不超过BS2。F103的bxCAN参数有上限BS1最大16tqBS2最大8tqSJW最大4tq。在APB1时钟36MHz时我常用的几组参数如下表目标波特率BRP分频BS1BS2SJW实际波特率采样点1Mbps214311Mbps83.3%500kbps41431500kbps83.3%250kbps81431250kbps83.3%125kbps161431125kbps83.3%100kbps201341100kbps77.8%以500kbps为例时间量子频率 36MHz / 4 9MHz每个tq约111ns位时间 (1143) * 111ns ≈ 2us正好等于500kbps的位宽采样点在 (114)/18 ≈ 83.3%处。计算时务必确认APB1时钟到底是多少F103如果误配成72MHz给CAN外设速率会直接翻倍握手必失败。2.2 Flash分区与擦写流程的硬性约束STM32F103的Flash编程有几个特点必须明确按半字16位编程擦除按页进行大容量型号每页2KB中容量型号每页1KB。写入前必须先擦除擦除后全是0xFF之后只能把1写成0所以升级场景就是整页擦除再写入。标准的擦写流程是先解锁Flash向KEYR写入两个解锁键值再擦除指定页等待BSY标志清除然后按半字写入数据每次写完等待BSY清除最后加锁。代码上我用标准外设库的FLASH_Unlock、FLASH_ErasePage、FLASH_ProgramHalfWord三个函数但需要关心一点擦除页时如果目标地址跨页必须把涉及的每一页都擦掉否则写进去的数据和旧数据混合校验必挂。擦除整个App区还要注意擦除操作期间CPU会停在等待BSY上此时CAN接收FIFO如果满了新报文就会被丢弃。这个问题我在第4节展开讲这里先记住一个结论升级协议必须设计成按页传输加应答不能一股脑连续狂发否则Flash那边还忙着擦总线这边已经收到一堆报文然后溢出。3. 实操CAN Bootloader的完整实现3.1 CAN初始化与滤波器配置引脚选择方面F103的CAN1固定复用PA11作为RX、PA12作为TX这一点不像F0/F4可以重映射工程里必须确认引脚没被其他外设占用。初始化代码关键部分如下RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; // PA11 CAN_RX 上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // PA12 CAN_TX 复用推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); CAN_DeInit(CAN1); CAN_StructInit(CAN_InitStructure); CAN_InitStructure.CAN_TTCM DISABLE; CAN_InitStructure.CAN_ABOM ENABLE; // 自动离线恢复总线出错时能自愈 CAN_InitStructure.CAN_AWUM ENABLE; // 自动唤醒 CAN_InitStructure.CAN_NART DISABLE; // 自动重传硬件层更可靠 CAN_InitStructure.CAN_RFLM DISABLE; CAN_InitStructure.CAN_TXFP DISABLE; CAN_InitStructure.CAN_Mode CAN_Mode_Normal; CAN_InitStructure.CAN_SJW CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 CAN_BS1_14tq; CAN_InitStructure.CAN_BS2 CAN_BS2_3tq; CAN_InitStructure.CAN_Prescaler 4; // 500kbps CAN_Init(CAN1, CAN_InitStructure);滤波器配置要只放行Bootloader升级用的报文。我用两个标准帧ID作为命令和数据入口一个ID接收上位机发来的指令一个ID发送应答。以0x555作为接收过滤ID为例CAN_FilterInitTypeDef CAN_FilterInitStructure; CAN_FilterInitStructure.CAN_FilterIdHigh (0x555 5); // 标准帧ID放高16位的高段 CAN_FilterInitStructure.CAN_FilterIdLow 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh (0x7FF 5); // 只匹配11位标准ID CAN_FilterInitStructure.CAN_FilterMaskIdLow 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterNumber 0; CAN_FilterInitStructure.CAN_FilterMode CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterActivation ENABLE; CAN_FilterInit(CAN_FilterInitStructure);标准帧ID在F103滤波器寄存器里的映射位置是bit31到bit21所以代码里把0x555左移5位放到高16位中掩码同理。这个细节很多新手会栽滤波配错了报文来了进不了FIFO中断永远不触发。3.2 通信协议设计两个ID撑起完整升级流程我设计协议的原则是尽量简单可靠。上位机发送用ID 0x100设备应答用ID 0x101标准帧数据域8字节。所有多字节参数统一小端序。协议命令定义如下命令码方向含义数据域格式0x01上位机→设备握手请求Byte00x01Byte1主版本Byte2次版本0x02设备→上位机握手应答Byte0状态Byte1Bootloader版本Byte2页大小KBByte3-6App起始地址0x03上位机→设备擦除请求Byte00x03Byte1-4起始地址Byte5-6擦除长度0x04设备→上位机擦除完成Byte0状态Byte1-2擦除总页数0x05上位机→设备固件数据帧Byte0-116位帧序号Byte2-76字节固件数据0x06设备→上位机页写入完成Byte0状态Byte1-2页序号Byte3-4页CRC160x07上位机→设备升级完成校验Byte00x07Byte1-4固件总长度0x08设备→上位机校验结果Byte0状态Byte1-4整包CRC320x09上位机→设备跳转执行Byte00x09Byte1-4App入口地址0x0A设备→上位机跳转确认Byte0状态升级流程分七步上位机发0x01握手设备回0x02里面带Bootloader版本和App起始地址上位机据此判断固件是否匹配。上位机发0x03擦除请求设备执行整片App区擦除完成后回0x04。上位机把bin文件按6字节一组拆分每组一个CAN数据帧帧序号从0递增。每凑满2KB即一页停止发送等待设备回0x06。设备收到一页数据后先在RAM里计算该页CRC16再把数据写入Flash写完回0x06带上CRC值。上位机比对CRC如果不对就重发当前页。全部页发完后上位机发0x07设备对整个固件区域计算CRC32回0x08。上位机确认CRC正确后发0x09跳转命令。设备回0x0A然后跳转到App。这个协议没有做逐帧ACK因为CAN硬件本身有CRC校验和自动重传机制帧错误率极低逐帧应答会把升级时间拉长。按页CRC重传已经能覆盖绝大多数异常场景。实测下来一个64KB的固件500kbps下从握手到跳转大概25到35秒现场可接受。3.3 Flash写入与App跳转的关键代码页面缓冲我采用单页2KB的RAM数组接收中断里只往数组里写主循环判断一页收满后再调Flash写入。写入函数用标准库封装void Flash_WritePage(uint32_t dst_addr, uint16_t *src, uint16_t halfword_count) { FLASH_Unlock(); for (uint16_t i 0; i halfword_count; i) { while (FLASH-SR FLASH_SR_BSY); // 等待上一次操作完成 FLASH_ProgramHalfWord(dst_addr 2 * i, src[i]); } FLASH_Lock(); }写入前保证目标页已经擦除。页擦除用FLASH_ErasePage即可注意它是整页擦不能擦一半所以擦除长度要按页对齐。跳转App的代码是整个Bootloader最危险的部分写错了轻则程序跑飞重则设备变砖。我经过几次教训后固定用下面这套typedef void (*pFunction)(void); void JumpToApp(uint32_t app_addr) { uint32_t app_stack *(volatile uint32_t *)app_addr; pFunction app_entry (pFunction)*(volatile uint32_t *)(app_addr 4); // 检查栈顶地址是否落在SRAM范围内防止异常跳转 if ((app_stack 0x2FFE0000) ! 0x20000000) { return; } __disable_irq(); RCC_DeInit(); // 复位所有外设时钟 SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; // 关闭Bootloader使能过的所有中断 NVIC-ICER[0] 0xFFFFFFFF; NVIC-ICER[1] 0xFFFFFFFF; NVIC-ICER[2] 0xFFFFFFFF; __set_MSP(app_stack); // 重设主栈指针 app_entry(); // 跳转 }跳转前检查栈顶地址是不是落在0x20000000开头的SRAM区间这步能挡住一大半非法地址跳转导致死机的情况。跳转前把所有外设时钟复位、SysTick关闭、中断全屏蔽是因为App重新初始化外设时如果一个残留的中断在App还没来得及配置NVIC时触发直接进默认Handler死循环。这坑我踩过一次场景是升级完跳转App后不定期死机查了很久才知道是Bootloader里CAN接收中断没关干净。App工程这边需要改两个地方第一Link脚本里Flash起始地址改成0x08008000长度相应减少第二App启动后在main最开始把向量表重映射到新偏移地址SCB-VTOR 0x08008000;F103的VTOR是Cortex-M3核心提供的实测可以写不用改启动文件。向量表要求对齐到中断向量数量常见App中断数不超过64个所以0x8000偏移自然满足对齐要求。4. 常见问题与排查技巧实录4.1 握手超时设备没有应答这个现象最常见原因基本集中在三处波特率算错、滤波器没放行、CAN收发器硬件问题。先用CAN分析仪挂在总线上看上位机发0x01时分析仪能不能看到报文如果能看到报文但设备没回问题在设备侧如果设备在回但上位机收不到优先查滤波器和收发器方向。波特率问题我见过最典型的是APB1时钟配错有人用CubeMX生成工程时把APB1配成了72MHzCAN里按36MHz算分频实际波特率翻倍两边必然对不上。排查技巧是设置好参数后把CAN_BTR寄存器读出来打印确认BRP、BS1、BS2值符合预期。另一个快速验证方法是在设备端先做回环测试把CAN_Mode改成LoopBack自发自收能收到就说明控制器和中断链路没问题。4.2 跳转App后直接HardFault或死机跳转死机按照出现频率排序向量表偏移没设置、App链接脚本起始地址没改、跳转前中断没关干净、栈顶地址校验被拦截。其中向量表和链接脚本是绑定出现的App能编译能下载但一跑就死多半就是这两个。我还有一招排查法跳转前不要直接执行app_entry先试着在Bootloader里读App入口地址和栈顶地址用JLink内存窗口确认0x08008000处前4字节是合法的RAM地址0x2000xxxx第4到7字节是0x08008xxx开头这两个值合理再跳。这套检查用代码做也行就是上面JumpToApp里的栈顶校验入口地址校验可以再加一条判断是否落在App区范围内。4.3 升级中途丢数据、超时重传频繁问题根源几乎都是Flash擦写阻塞和CAN接收冲突。早期版本我在CAN接收中断里直接写Flash一进擦写函数CPU就卡在BSY等待上连续来几帧CAN数据FIFO溢出帧序号出现空洞上位机一直在重传升级龟速。改进思路是接收中断里只做RAM缓冲攒满一页后在主循环里写Flash。但主循环写Flash那一小段时间如果上位机不停发数据RAM缓冲区还是会被填满所以协议层面做了按页传输加应答。上位机每发满2048字节就停手等设备回0x06再继续。这样设备写Flash期间总线上没有新数据CAN接收的压力基本为零。如果还丢数据检查接收缓冲区大小和FIFO溢出标志。可以用CAN_GetITStatus检查CAN_IT_FMP0接收中断如果发现CAN-RF0R的FOVR位被置1说明FIFO溢出过这时候要把上位机的发包间隔加大一点比如每帧之间加2ms延时。F103的CAN FIFO只有3个邮箱靠硬件缓冲堆不了多少数据协议层面兜底才是正道。4.4 总线波形、终端电阻与硬件抗干扰软件排查完还是偶发失败就轮到硬件了。CAN总线上两个终端节点必须各接一个120欧电阻距离远、节点多的时候终端电阻缺失波形反射会让采样点附近的电平混乱。用示波器看CAN_H和CAN_L之间的差分波形显性位时差分电压约2V隐性位约0V边沿应该干净利落如果看到明显振铃或台阶终端电阻大概率没接对。收发器选型也影响稳定性。F103开发板上常见的TJA1050、SN65HVD230、PCA82C250都能跑500kbps但要注意共模输入范围。TJA1050的共模范围约-12V到12VSN65HVD230约-7V到12V如果现场总线上共模偏移严重选TJA1050更稳。另外TVS管做浪涌防护是常规操作但我不建议在CAN总线上用气体放电管它的寄生电容大高速信号会被拖垮在500kbps甚至1Mbps场景下很吃亏。想加防护就用TVS加共模电感实测抗ESD和浪涌都够用。开发过程中另一条建议不要完全依赖JLink烧录生产固件。JLink在线调Bootloader和App没问题但生产环节应该走CAN升级链路这样正好把升级流程当成产测的一部分设备出厂时Bootloader已烧好App全部通过CAN刷写既验证了功能又验证了升级通路一举两得。5. 这套方案的后续扩展方向第一版CAN bootloader跑通之后我一直在用同一套协议框架做移植。F105/F107的CAN时钟来自APB1的固定36MHz但CAN2外设配置略有差异后续换到F103ZE这类大Flash芯片只需要把页大小从1KB改成2KB调整App偏移宏即可。协议里的0x05数据帧如果你想把固件块加大可以把每帧的有效数据从6字节改成4字节加上CRC16代价是传输效率下降换取更高的数据校验强度这个取舍可以按项目可靠性要求来。再往后如果总线上挂了几十个节点可以在这个协议基础上引入节点地址。比如把0x100命令ID拆成设备地址加命令码0x100到0x1FF作为命令段每个从站用一个子ID上位机按地址逐个升级。这样批量产线升级时一个上位机就能顺序刷完整条总线的设备不用来回插拔线缆。我后面做的产线工具就是这么干的整条线几十台设备一次刷完效率提升非常明显。最后分享一个小技巧Bootloader的App巡检功能。跳转前对App区做一次CRC校验如果不等于你记录的基准值就不跳转留在Bootloader等待重新升级。这样能防止Flash里App数据损坏时设备直接跑飞。这个巡检逻辑我后来在每个项目里都会加上成本低但能避免很多现场售后问题。本文还有配套的精品资源点击获取