
简介面向STM32F103嵌入式开发者的CAN总线Bootloader完整源码工程聚焦通过CAN接口实现固件在线升级适合需要掌握单片机启动流程与CAN通信机制的工程师及进阶学习者可作为从零移植升级方案的实战模板。压缩包共180个文件其中头文件71个、C语言源文件66个、汇编启动文件36个另含工程配置、固件bin及说明文档整体仅647KB文件归类清晰便于按模块查阅。目前已有2616人学习下载常被用作车载或工业现场CAN升级方案的重要参考。源码完整覆盖CAN控制器参数配置、标准帧收发、错误检测与恢复、CRC数据校验、Flash写入保护和跳转执行新固件等关键环节同时展示中断向量表映射、外设初始化以及与上位机交互的帧格式设计从底层驱动到应用跳转形成完整闭环。通过阅读代码可理清固件升级流程、帧协议设计与错误处理思路对产品化开发具有直接借鉴意义系统理解Bootloader架构后可快速迁移。1. 为什么说CAN Bootloader的关键在Flash分区而不是CAN干过车载ECU或工控节点升级的人大概都经历过这种场景产线上一批板子要更新固件每块都得拆壳、插调试器、烧录、再装回壳体几块还好上百块板子这样折腾一遍时间和人工成本都压不住。这份STM32F103的CAN总线Bootloader源代码解决的就是这个问题目标板只留一组CAN收发器接口就能通过总线完成固件下载不用拆壳也不用SWD调试口。整份源码不是一行行零散demo而是把CAN驱动、Flash擦写驱动、协议解析和程序跳转都串起来的一套完整IAP方案。这里先说一个反直觉的结论这类Bootloader最容易卡住人的地方其实不在CAN收发而在Flash分区设计和Boot往App跳转的临界状态。适合正在做车辆总线节点、伺服驱动、控制器在线升级的开发者新手照着文档能把板子跑起来老手则可以把它当成改自己私有协议的基础模板。2. 架构设计CAN帧协议、Flash分区与状态机的联动2.1 四条主流程从握手到跳转的完整链路拿到一份Bootloader源码先别急着看CAN初始化要把它的动作链路拆成四个阶段握手、擦除、编程、跳转。这套代码的动作顺序也遵循这个逻辑每个阶段都有明确触发条件和回应帧。上电后Boot先做一件事判断“要不要留在Boot里”。常见做法有两种一种是检测一个普通GPIO引脚的电平按着按键上电就进Boot不按就跳App另一种更稳靠备份寄存器里的魔法数字判断比如上次App自己请求跳回Boot时写0xA5A5。这套代码把判断逻辑放在main函数入口判定不满足就直接过跳转函数。进入Boot模式后上位机发送握手帧Boot收到后回包含自身版本号和单帧数据大小的应答帧。此后步骤基本是线性的擦除整个App区按块写入固件数据收完最后一帧后校验“已收块数”和固件长度确认无误后发跳转命令。我见过不少改动失败的工程问题就出在擦除和编程之间没有明确切换状态上位机发快了Boot还在擦扇区下一帧数据已经到了直接丢帧。所以这套代码在协议层用状态机把四个阶段隔离不允许跨状态操作。下面这张表概括了四个阶段谁先谁后、由什么触发、做了什么、回了什么。阶段触发条件Boot动作回应帧握手收到握手命令校验魔法字返回版本信息返回0x81擦除收到擦除命令擦除App区全部扇区返回0x82表示完成编程收到数据帧按块号写入Flash每写一块回0x83跳转收到跳转命令校验完成后跳转App跳转前返回0x84这四步是任何CAN Bootloader的基本骨架。骨架搭对了后面加加密、加回滚才有地方挂。2.2 CAN通信帧设计命令如何编码进8字节数据域CAN标准帧的Data段只有8个字节不可能像串口那样一帧扔几百字节所以协议设计要跟Flash的物理特性配合。这套代码采用每帧3字节有效载荷的设计前面5字节做帧头控制后面3字节是固件数据。SPI Flash或串口Flash一次编程动辄256字节CAN一帧3字节看着低效但胜在稳定Bob升级包拆成几百帧完全可接受。通信帧的固定结构是这么安排的Data[0]和Data[1]是魔法字固定为0xAA和0x55用来过滤总线上其他节点的干扰报文Data[2]是命令字Data[3]和Data[4]是块序号高8位在前低8位在后Data[5]到Data[7]才是真正的固件数据。块序号对应App区起始地址的偏移块号乘3就是实际写入偏移这个换算关系写在协议头文件里Boot和上位机各保留一份。这种“命令块号少量数据”的结构最大的好处是单帧自包含。哪怕上位机在擦除阶段丢了一帧Boot也能通过超时机制发现异常不会把数据写到错误扇区。如果后续想提高传输效率可以把块号压缩成一个字节把有效载荷扩展到4字节代价是单次升级固件大小不能超过1024字节适合小型Boot替换场景。数据域不够用这个坑是CAN Bootloader绕不开的设计前提。2.3 Flash分区与地址映射Boot与App如何互不干扰STM32F103的Flash从0x08000000开始Boot代码放在低地址区App从高地址区开始。源码里随处可以看到几个关键宏#define BOOT_ADDR 0x08000000 #define APP_ADDR 0x08004000 #define APP_MAX_SIZE 0x30000Boot区固定占用前16KB也就是0x08000000到0x08003FFFApp区从0x08004000开始可用的最大空间约192KB。为什么不把Boot塞进4KB因为F103的Flash擦除是按扇区来的前几个扇区虽然有1KB的但Boot里要放CAN协议栈、Flash驱动和跳转逻辑16KB是留了余量的舒适区。如果强行压缩Boot体积也能写进4KB但后续加版本查询、加安全校验就非常憋屈。分区确定之后App工程必须以0x08004000作为ROM起始地址来编译。这里牵出一个新手容易忽略的点STM32的向量表默认放在0x08000000。App跑在0x08004000中断向量表必须跟着搬过去否则App一进中断就跳回Boot区执行乱码。Cortex-M3的做法是设置SCB-VTOR寄存器。很多人在Boot里发完跳转后欢呼“跳过去了”结果App里定时器一开中断就HardFault根源就在这。分区和地址映射是整个项目里改动成本最高、设计时最需要认真权衡的地方。分区表一旦发布出去升级过的老设备再换新Boot就不兼容所以地址宏最好保持多年稳定不要随意动。3. 核心模块拆解CAN接收、Flash写入和跳转函数的代码实现3.1 CAN时钟与引脚初始化500kbps波特率的计算拿到代码后第一件事是看CAN初始化我把这段代码整理成了阅读顺序最舒服的形式void CAN_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* 引脚复用PA11为RXPA12为TX */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_11; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_12; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_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; /* 接收FIFO不锁定旧帧被新帧覆盖 */ CAN_InitStructure.CAN_TXFP DISABLE; /* 优先级按报文ID排 */ 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; CAN_Init(CAN1, CAN_InitStructure); /* 接收滤波器Boot阶段放行所有ID简化处理 */ CAN_FilterInitTypeDef Filter; Filter.CAN_FilterNumber 0; Filter.CAN_FilterMode CAN_FilterMode_IdMask; Filter.CAN_FilterScale CAN_FilterScale_32bit; Filter.CAN_FilterIdHigh 0x0000; Filter.CAN_FilterIdLow 0x0000; Filter.CAN_FilterMaskIdHigh 0x0000; Filter.CAN_FilterMaskIdLow 0x0000; Filter.CAN_FilterFIFOAssignment CAN_FIFO0; Filter.CAN_FilterActivation ENABLE; CAN_FilterInit(Filter); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); }波特率这一块的验算公式是CAN波特率 PCLK1 / (CAN_SJW CAN_BS1 CAN_BS2) / CAN_Prescaler。F103默认系统时钟72MHzAPB1分频后PCLK1 36MHz。套进去算36MHz除以1加14加3共18个时间量子再除以预分频4结果是500kbps。这个计算很值得写在代码注释里因为改动主频或更换芯片后这个配置是第一个要重新验算的。滤波器这里放行所有ID是有意的Boot阶段不需要关心总线上的其他报文只看数据段里的魔法字。3.2 接收中断帧解析与状态切换的边界CAN的接收放在中断里做沿用标准外设库的中断服务函数名void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx; uint8_t buf[8]; if (CAN_GetITStatus(CAN1, CAN_IT_FMP0) RESET) return; CAN_Receive(CAN1, CAN_FIFO0, rx); if (rx.DLC 8) return; memcpy(buf, rx.Data, rx.DLC); /* 过滤非本协议报文 */ if (buf[0] ! IAP_MAGIC_H || buf[1] ! IAP_MAGIC_L) return; switch (buf[2]) { case CMD_HANDSHAKE: IAP_HandleHandshake(); break; case CMD_ERASE: IAP_HandleErase(); break; case CMD_PROGRAM: IAP_HandleProgram(buf[3], buf[4], buf[5], 3); break; case CMD_JUMP: IAP_HandleJump(); break; default: break; } CAN_ClearITPendingBit(CAN1, CAN_IT_FMP0); }接收中断里只做最小处理把帧读出来核对魔法字然后根据命令字分发到对应的处理函数。擦除Flash和写Flash的动作都放到IAP_HandleXxx函数里同步执行没有用环形队列原因是F103的Boot代码要保持简单可控不用操作系统队列容易引入不确定性。如果总线负载高、丢帧频繁我会在后面加一层队列缓冲但基础版先把链路跑通更重要。帧解析有个隐藏边界数据块序号是16位高字节和低字节分开传输写Flash时要拼成一个完整的u16处理函数内部会做block (buf[3] 8) | buf[4]。上位机发送时也必须按大端顺序组帧否则第一个块写到0x08004000第二个块可能蹦到0x08010000升级一次废一次。3.3 Flash擦除与编程等待BSY标志是写入成败的分水岭Flash操作是Bootloader里最需要敬畏的部分。我见过不少改造代码的人卡在这一步原因是擦除页之后没等BSY标志清掉就立刻写入结果写入失败。这套代码的实现是先轮询Flash忙标志再操作static void Flash_WaitBusy(void) { while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) SET); } uint8_t IAP_EraseApp(void) { uint32_t addr; uint32_t pages APP_MAX_SIZE / 1024; uint8_t status; FLASH_Unlock(); for (addr APP_ADDR; addr APP_ADDR APP_MAX_SIZE; addr 1024) { Flash_WaitBusy(); status FLASH_ErasePage(addr); if (status ! FLASH_COMPLETE) { FLASH_Lock(); return 0; } } FLASH_Lock(); return 1; } uint8_t IAP_WriteFlash(uint32_t addr, uint8_t *data, uint16_t len) { uint16_t i; uint16_t halfword; FLASH_Unlock(); for (i 0; i len; i 2) { halfword data[i] | (data[i 1] 8); Flash_WaitBusy(); if (FLASH_ProgramHalfWord(addr i, halfword) ! FLASH_COMPLETE) { FLASH_Lock(); return 0; } } FLASH_Lock(); return 1; }擦除的粒度是1KB一页App区最大192KB完整升级最多擦除192页。擦除Flash和写入Flash都必须加锁函数入口调用FLASH_Unlock收尾调用FLASH_Lock。很多人翻车在没有成对调用锁和解锁第一次升级正常第二次升级时Flash状态寄存器报错程序死循环。FLASH_ProgramHalfWord要求地址按2字节对齐数据长度也必须是偶数协议里单帧3字节的有效载荷在实际写入前需要补一个0xFF凑成4字节这是设计数据帧时所需要明确的规则。3.4 跳转函数向量表偏移、清中断、切栈顶一个都不能少跳转执行是整个Bootloader的压轴戏。看似简单实际上这里集中了最容易翻车的中断冲突跳之前不清中断App的串口一收数据就跳回Boot跳之前不搬向量表App一触发中断就HardFault。核心代码固定了操作顺序void IAP_JumpToApp(void) { uint32_t sp_val; uint32_t pc_val; void (*jump)(void); int32_t i; sp_val *(volatile uint32_t *)APP_ADDR; /* 栈顶 AAPPSP */ pc_val *(volatile uint32_t *)(APP_ADDR 4); /* 校验栈顶指针必须在RAM范围内防止刷写错误固件 */ if ((sp_val 0x2FFF0000) ! 0x20000000) return; /* 校验复位向量必须在Flash范围内 */ if ((pc_val 0x2FFE0000) ! 0x08000000) return; /* 关闭外设时钟与SysTick避免跳转后外设中断干扰 */ RCC_DeInit(); SysTick-CTRL 0; SysTick-LOAD 0; SysTick-VAL 0; __disable_irq(); for (i 0; i 8; i) NVIC-ICER[i] 0xFFFFFFFF; /* 清挂起中断 */ SCB-VTOR APP_ADDR; /* 向量表搬移 */ __set_MSP(sp_val); jump (void(*)(void))pc_val; jump(); }跳转前三道检验栈顶指针必须在0x20000000开头的RAM区间复位向量必须在0x08000000开头的Flash区间。这两条能挡住大部分错误固件防止刷写后半成品直接跳转导致设备变砖。向量表偏移放在关闭全局中断之后确保App启动前的中断不会经旧的向量表路由。跳转函数用函数指针执行复位向量跳转之后不再返回所以跳转前把外设时钟复位到默认把SysTick关闭把NVIC挂起中断全部清空是必须做的三步清洁工作。4. 编译烧录与联调跑通一次CAN在线升级的完整步骤4.1 工程目录结构与源码文件源码包的目录组织按Boot和App分成两块Boot是独立的Keil工程App是一个带向量表偏移的示例工程这种安排保证读者能直接编译出两个hex文件。STM32F103_CAN_Bootloader/ |-- Boot/ | |-- MDK-ARM/ | | |-- Boot.uvprojx | |-- User/ | | |-- main.c // 上电判断、主循环 | | |-- can_driver.c // CAN初始化与中断服务 | | |-- iap_protocol.c // 协议帧解析与状态机 | | |-- flash_iap.c // 擦除、编程、跳转 | | |-- iap_config.h // 分区地址、命令字、魔法字 | |-- Drivers/ | |-- STM32F10x_StdPeriph_Driver/ |-- App/ | |-- User/ | | |-- main.c // 示例App周期发送CAN报文 | |-- MDK-ARM/ | |-- App.uvprojx |-- Doc/ |-- CAN升级协议.mdBoot工程里四个源文件的分工很明确can_driver处理底层收和发iap_protocol处理帧语义flash_iap处理Flash物理操作main把上电判断和状态循环串起来。协议格式写进独立md文档这个习惯建议直接抄走因为半年后自己翻代码也要靠它回忆帧结构。App示例工程的重要任务不是功能而是验证向量表偏移是否生效所以里面只放了串口打印和一个周期CAN报文跑通即视为验收。4.2 Boot与App的链接配置向量偏移和ROM地址不能各写各的在Keil工程里Boot工程和App工程的Target选项有非常关键的差异下面这张配置表抄过去就能用。配置项Boot工程App工程IROM1起始地址0x080000000x08004000IROM1大小0x40000x3C000VECT_TAB_OFFSET00x4000Boot跑在默认地址不用动向量表App的起始地址在链接脚本里改成0x08004000然后启动文件中的向量表偏移宏VECT_TAB_OFFSET设为0x4000。注意是偏移量不是App的绝对地址。很多人在这一步把两个值搞混写成了0x08004000结果向量表指向了Flash之外的地址跑起来连启动都进不去。App工程编译出的hex可以从0地址偏移烧录也可以直接用它给Boot升级。联调时我习惯先用调试器把Boot烧进去再故意用调试器把App烧到0x08004000验证跳转最后才用CAN整体走一遍升级流程。这样可以把“Boot跳转坏了”和“CAN传输坏了”两个变量拆开。4.3 CAN升级联调步骤报文的实际发送字节序列准备一个USBCAN调试工具波特率设为500k连接目标板的CANH和CANL。这套协议的计算是按CAN标准帧设计的发送窗口逐条执行即可。第一步目标板上电保持Boot模式发送握手帧AA 55 01 00 00 00 00 00Boot应答0x81和版本号说明通道畅通。此时若长时间无应答先测总线电压和终端电阻再看目标板是不是跳去App了。第二步发送擦除帧AA 55 02 00 00 00 00 00Boot进入擦除流程擦除期间CAN不回帧上位机要等两秒左右再发下一帧不能连续轰炸。第三步按块发送数据帧。假设固件第一块数据是0x01 0x02 0x03块号从0开始AA 55 03 00 00 01 02 03 AA 55 03 00 01 04 05 06 AA 55 03 00 02 07 08 09每帧发送间隔建议10毫秒以上让Boot有时间写入Flash和回应答。每收到一帧Boot会回0x83和当前块号对上位机的发送进度作确认。如果上位机连续发送速度过快FIFO溢出后会丢帧固件长度核对不上擦除重来是代价最小的事。第四步固件发送完毕后发跳转帧AA 55 04 00 00 00 00 00Boot校验所有块是否收齐校验通过后回0x84再执行跳转。App启动后周期上送CAN报文用USBCAN就能实时看到App的报文升级流程至此跑通。注意跳转后Boot区不再参与通信如果App没有启动总线会静默。此时先检查App的向量表偏移配置而不是先怀疑Boot写坏了。5. 常见问题与排查升级失败时先验证这五个点5.1 现象跳转后App没反应总线一直静默原因向量表偏移没有设到App地址App一上电第一个中断通常是SysTick或PendSV就从0x08000000的旧向量表取地址然后卡死。排查时先看App工程是否定义了VECT_TAB_OFFSET再看启动文件是否把它用到了SCB-VTOR。解决在App的系统初始化函数里显式执行SCB-VTOR 0x08004000并根据当前芯片的向量表大小配置对齐不要只依赖Keil的分散加载文件。5.2 现象擦除Flash后写入失败返回FLASH_Error原因最常见是写Flash前没解锁或者使用了错误的编程粒度。F103的Flash编程是半字粒度不是字节粒度数据长度是奇数或者源地址未2字节对齐都会触发编程错误。解决在每次调用FLASH_ProgramHalfWord前确认地址是偶数数据长度调整为偶数倍用FLASH_Unlock开锁FLASH_Lock收尾。擦除和编程之间必须轮询BSY标志不能靠延时猜速度。5.3 现象升级数据块丢帧严重固件长度总是对不上原因CAN接收FIFO溢出。升级上位机的发送速度超过Boot处理和写Flash的速度发送窗口太快FIFO被填满后新帧进不来。解决把CAN接收中断优先级提到最高中断服务函数只做帧拷贝把协议解析和Flash写入放到主循环上位机每发一帧等待应答或至少延时10ms。如果是高负载总线最好在Boot里做块级确认机制不要依赖CAN底层重发。5.4 现象第二次升级失败第一次却是成功的原因第一次升级后Flash上残留的标志位或App区尾部的标志字没有清理。Boot再次上电时误以为App已经有效直接跳过去了根本没有进协议等待状态。解决跳转前或擦除阶段主动清除App尾部的有效性标志在握手命令里加入“强制擦除”的语义无论上电判断结果如何收到擦除帧就留在Boot执行擦写。5.5 现象不同板卡之间波特率一致性差总线上部分板子能升部分失败原因F103的外部晶振8MHz和12MHz混用CAN波特率按8MHz算12MHz板子的实际位时间偏差超过容限采样点错位导致总线错误帧。解决把CAN波特率计算统一封装成一个宏按实际主频参数算好分频和BS段排查时先看每块板子是否都用同一规格晶振。量产项目里这类硬件差异最阴协议再健壮也救不了物理层频率漂移。6. 二次开发协议扩展、固件加密与回滚策略这套Bootloader说到底是个干净的骨架真正的价值在于二次改造空间。我最常加帮的第一个功能是版本查询。在协议状态下增加一条命令0x05Boot收到后回传固件的分区版本号和编译时间这样产线升级前可以先确认板上固件版本避免重复升级。实现上只需要在IAP_Handle函数里加一个case分支读取固定在Flash参数区的结构体不需要动协议主流程。如果目标是敏感行业或防止固件被读出来反编译可以考虑对固件做AES-128-CBC加密。上位机对固件包加密后Boot中嵌入密钥收帧时先解密再写Flash。密钥可以结合芯片唯一ID序列号做混淆这样即使同型号芯片不同板子的密文也无法互换。加密的代价是每次写入前多一段解密耗时升级速度会慢一点但对工业设备来说完全可接受。另一个实用的改造是回滚策略把App区分出一个备份分区App运行时把自己的有效副本保存到备份区Boot每次升级前先擦除App区若新固件校验失败自动从备份区恢复设备不会因一次断电升级变砖。实现方案是预留出厂固件区记录两个分区的版本号顺序typedef struct { uint8_t version; /* 版本号高者生效 */ uint16_t app_len; /* 固件长度 */ uint16_t app_crc; /* 固件CRC16 */ uint8_t status; /* 0x55有效0xAA无效 */ } AppInfo_t;校验和跳转都要以这个结构体为唯一依据Boot里把App有效性判断从“有没有跳转”改成“查版本查CRC”整个升级链路就可靠很多。做这些改造时我一般先动手改协议文档再改Boot代码最后改上位机顺序不能反。从那以后我每次写Bootloader都会先写一个跳转函数的裸机测试确保复位向量和栈顶校验通过才开始写协议栈这个习惯帮我少踩了太多暗坑。希望帮到你。本文还有配套的精品资源点击获取