简介:面向STM32F103的CAN总线Bootloader完整源码包,专为需要在CAN网络中远程升级固件的嵌入式工程师与学生设计。内容贯穿Bootloader全流程:CAN控制器初始化、标准帧与扩展帧分离收发、通信错误处理、固件分帧接收与CRC校验、Flash编程与烧写、完成后跳转至应用入口,并附有安全升级机制,可帮助阅读者搞清Cortex-M3启动流程、中断向量表映射和CAN应用层协议设计。
整包共180个文件,以66个C源码与71个头文件为主,另有汇编启动文件、Keil工程配置、说明文档和可直接烧录的bin固件,结构清晰,压缩包仅647KB,便于源码级对照学习。已有2610人浏览学习,适合在汽车电子、工业控制等场景下需要自行开发CAN升级工具链的开发者参考。通过这套工程,不仅能掌握HAL库配置CAN外设、多帧数据包解析、异常恢复策略,还能理清Bootloader与上位机交互、Flash扇区划分与跳转指针传递等设计要点,适合作为产品级OTA方案的基础模板。 STM32F103的CAN总线Bootloader,这东西听起来好像挺唬人,但在汽车电子、工业控制这类现场,它几乎是标配。我当年第一次接触这个需求,是因为一批设备已经装到客户现场了,结果固件有bug要升级,总不能派人扛着ST-Link去现场,拧开外壳、飞线、烧录、再装回去,那画面太美了。后来就把CAN Bootloader安排上了,从那以后,远程升级就变成上位机点个按钮的事。这篇文章就把这套源代码的核心设计思路、关键代码实现,以及调试过程中踩过的坑完整拆开讲一遍,给准备上手或者正在被CAN升级折磨的朋友一个参考。
1. 搞CAN Bootloader之前,先想清楚这些问题
1.1 为什么升级非要用CAN,而不是串口或USB
很多新手的第一反应是:串口Bootloader不香吗?香,但场景不一样。串口在实验室里用着确实简单,可一到工业现场就露怯——RS232电平抗干扰差,线长了跑不了高速;RS485虽然抗干扰好一点,但很多设备没有引出485口,而且布线得单独拉线。USB更是离谱,总不能让人去现场拔线吧?
CAN总线不一样。它在汽车电子和工业控制里几乎是无处不在的双线差分总线,抗干扰能力强,通讯距离能到几十米甚至上千米,物理层上天生适合作为设备的常驻通信总线。Bootloader走CAN最大的好处就是:不需要额外引出调试接口,直接复用设备工作时的CAN总线。设备还在车架上、还在控制柜里、还在机器人关节处,都可以通过同一根总线把新固件灌进去。
1.2 一个合格的CAN Bootloader,应该满足什么
Bootloader这东西说简单也简单,说复杂也复杂。核心功能就三个:
- 擦写Flash:把旧程序干掉,写入新程序。
- 通信校验:通过CAN可靠地把固件数据传过来,不能传一半就坏了。
- 跳转执行:写完还能正常跳到App运行。
但真正做过这个就知道,难点在后面两个。通信要应对总线错误、超时丢帧;跳转要处理中断向量表、系统时钟和外设状态,稍有不慎就跳飞了。
我个人的经验是,做Bootloader之前先给自己定三条设计原则:
- 升级失败不能把设备锁死。万一升级到一半断线了、总线出问题了,设备重新上电还能回到Bootloader继续升级,而不是变砖。
- 必须有校验措施。每一帧数据有校验,整个固件包也要有CRC校验,不能让错误数据刷进Flash。
- App出问题还能救回来。App如果因为升级写坏了,Bootloader要能检测到并停在Bootloader模式,而不是傻乎乎跳到一个不存在的程序里。
这三条原则贯穿了整个源代码的设计。下面我就按实际开发逻辑,把这份源代码的结构和核心实现逐段拆开讲。
2. Flash分区与程序启动流程:架构设计是第一步
2.1 内存到底怎么分
拿到任何一个Bootloader源码,第一件事不是看代码,而是看它的内存分布。STM32F103的Flash起始地址是0x08000000,CPU一上电就从这里取向量表、执行代码。但闪存空间是有限的,Bootloader和App必须商量好谁占哪一块。
以STM32F103C8T6(64KB Flash)为例,我这份源代码的内存划分如下:
| 区间 | 地址范围 | 大小 | 用途 |
|---|---|---|---|
| Bootloader区 | 0x08000000 - 0x08003FFF | 16KB | 固化引导程序,一般不变 |
| App区 | 0x08004000 - 0x0800F7FF | 46KB | 用户应用程序 |
| 升级标志区 | 0x0800F800 - 0x0800FBFF | 1KB | 记录升级状态、固件大小等 |
| 参数区 | 0x0800FC00 - 0x0800FFFF | 1KB | 预留参数存储 |
很多人问,Bootloader区留16KB是不是有点大?说实话,如果你只是做CAN接收和Flash擦写,固件确实很小,8KB也够。但我把Bootloader区留成16KB是故意的,因为后期很可能要加功能——比如双Bank切换、加密校验、甚至自定义诊断服务。Bootloader一旦出厂就很难改,留足余量能省掉后面很多麻烦。
2.2 上电启动流程:先看标志,再决定跳不跳
这个Bootloader在上电时走的是经典IAP流程。简化成代码就是:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_CAN_Init(); // 检查升级标志:如果为有效值,则进入升级模式等待CAN数据 if (CheckUpdateFlag() == UPDATE_REQUESTED) { EnterBootloaderMode(); // 等待上位机下发固件 } else { // 没有升级请求,检查App区是否有效,有效则直接跳转 if (CheckAppValid(APP_START_ADDR)) { JumpToApp(APP_START_ADDR); } else { EnterBootloaderMode(); // App无效,必须留在Bootloader等待升级 } } while (1); }这个流程里最关键、也最容易写错的就是JumpToApp()。跳转前必须做三件事:关中断、重映射中断向量表、设置栈指针。否则跑过去就是死机。
带着SysTick中断跳转、带着CAN接收中断跳转,基本都是跳完就进HardFault。所以跳转前要先把全局中断关了:
__disable_irq(); SCB->VTOR = APP_START_ADDR; // 中断向量表重映射到App起始地址 __set_MSP(stack_addr); // 把栈指针切到App的栈顶 JumpToAppFunction(); // 跳到App的Reset_Handler还有一个新手特别容易踩的坑:SCB->VTOR这个寄存器在STM32F1系列不是每个型号都支持随意重映射。F103是Cortex-M3内核,VTOR是有的,但需要设置到正确的Flash地址,很多初学的人忘了这件事,导致App里的中断一触发就跑飞。这个问题在第5章我会再细讲。
2.3 升级标志位放在哪?为什么不用Flash而用备份寄存器
这份源代码里我选择把升级标志放在RTC备份寄存器里(BKP->DR1),而不是写在Flash里。原因是:Flash写入要考虑擦除寿命和掉电时序,而且写Flash本身有风险——万一跑到一半掉电,标志位写坏了,Bootloader和App的状态就说不清了。
备份寄存器的好处是:只要系统不掉电,它永远保持,而且写起来像写普通寄存器一样快;系统一掉电,它跟着丢,正好符合“升级请求是一次性的”这个语义。
App侧主动请求升级的流程是:
void AppRequestUpdate(void) { BKP_WriteBackupRegister(BKP_DR1, UPDATE_FLAG_VALUE); // 写入升级标志 NVIC_SystemReset(); // 软复位 }系统重启后Bootloader检测到这个标志,就知道要进升级模式,不再跳转App。
3. 源代码核心模块拆解:CAN初始化、通信协议、Flash驱动
3.1 CAN外设初始化:波特率、位时序、滤波器一个都不能错
CAN初始化看起来是老三样:GPIO复用、CAN模式、波特率。但细节里藏着杀机。STM32F103的CAN外设挂载在APB1总线上,APB1的时钟是36MHz(主频72MHz分频下来的)。
我这里用500kbps的波特率,计算公式是:波特率 = APB1时钟 / (BRP × (TS1 + TS2 + 1))。
以500kbps为例,我配的是:
CAN_InitStructure.CAN_SJW = CAN_SJW_1tq; // 同步跳跃宽度:1个时间单元 CAN_InitStructure.CAN_BS1 = CAN_BS1_13tq; // 位段1:13个时间单元 CAN_InitStructure.CAN_BS2 = CAN_BS2_4tq; // 位段2:4个时间单元 CAN_InitStructure.CAN_Prescaler = 4; // 波特率分频:BRP = 4这样算下来:36MHz / (4 × (13 + 4 + 1)) = 36MHz / 72 = 500kHz。采样点位置在 (1 + 13) / 18 ≈ 77.8%,这是CAN总线比较标准的采样点位置,容错性最好。
SJW为什么要设成1个tq?这是很多人忽略的。SJW是同步跳跃宽度,它决定CAN控制器在总线边沿检测到相位误差时,最多能把采样点调整多少。设大了容错好,但对总线噪声也更敏感;设小了抗干扰好,但同步能力弱。现场总线环境复杂,1个tq是比较折中的选择。网上那些CAN通信时好时坏、低速正常高速不行的案例,十有八九和位时序配置有关。
过滤器配置同样要注意。我这份源代码里做了个非常实用的设计:把过滤器ID组1配置成只接收本Bootloader约定的命令ID,其余全部滤掉。这样App正常工作时的CAN业务数据不会干扰升级流程,反之亦然。初始化里还要关闭CAN外设的自动唤醒和睡眠模式,防止总线空闲时CAN进低功耗,固件传一半卡住。
3.2 通信协议设计:一帧8字节怎么把几十KB固件传完
CAN的一个数据帧最多只能带8字节数据。而一份固件编译出来动辄几十KB,所以必须设计一个分包传输协议。我这份源码里的协议结构如下:
| 帧ID | 方向 | 数据段内容 | 功能 |
|---|---|---|---|
| 0x500 | 上位机 -> 设备 | 0xAA, x | 握手请求,请求进入升级模式 |
| 0x510 | 设备 -> 上位机 | 0x55, 状态 | 握手应答,0x01表示就绪 |
| 0x521 | 上位机 -> 设备 | 包序号(2B), 数据(4B) | 固件数据帧,每帧4字节有效数据 |
| 0x522 | 上位机 -> 设备 | 包序号 + 0xFFFFFFFF | 结束帧,表示固件已传完 |
| 0x530 | 设备 -> 上位机 | 状态, 已接收包数 | 每收1帧回一次,永不应答结束 |
| 0x540 | 上位机 -> 设备 | CRC32(4B) | 请求校验整个固件 |
数据帧的8个字节我设计成:字节0-1是包序号(大端),字节2-5是固件数据,字节6-7是CRC16。这个设计兼顾了效率——一帧能传4字节有效数据,又保证了每个包都能独立校验。
接收端的逻辑是状态机,这是通信稳定性的灵魂:
typedef enum { BL_IDLE, // 空闲,等待握手 BL_DATA, // 正在接收固件数据 BL_FINISH // 固件接收完毕,等待CRC校验 } BootloaderState; // CAN接收中断里做状态流转,而不是在主循环里轮询 void CAN_RX_Handler(CAN_RxHeaderTypeDef *header, uint8_t *data) { switch (boot_state) { case BL_IDLE: if (header->StdId == CMD_START && data[0] == 0xAA) { boot_state = BL_DATA; packet_count = 0; // 擦除App区Flash,准备写入 EraseAppFlash(); SendAck(BL_READY); } break; case BL_DATA: if (header->StdId == CMD_DATA) { uint16_t seq = (data[0] << 8) | data[1]; uint16_t crc = CalcCRC16(data + 2, 4); if (crc == ((data[6] << 8) | data[7])) { FlashWrite(APP_START_ADDR + seq * 4, data + 2); packet_count++; SendAck(BL_PACKET_OK); } else { SendAck(BL_PACKET_ERR); // 上位机收到后重发 } } break; case BL_FINISH: // 等待CRC32校验 break; } }状态机的好处是升级流程不会因为某一步失败而乱套。CRC校验失败就重发当前包,而不是从头开始。实测现场蹭蹭的干扰下,这个机制把升级成功率拉到了99%以上。
3.3 Flash驱动:那些官方例程不会告诉你的细节
Flash操作是Bootloader里最“硬核”的部分,因为这里一旦出错,设备直接变砖。F103的Flash是16位(半字)编程单位,必须先用半字方式写入,而且写入前必须把目标扇区擦掉。整个擦写流程:
- 解锁Flash:
FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB; - 等待Flash空闲:
while (FLASH->SR & FLASH_SR_BSY); - 擦除指定页:
FLASH->CR |= FLASH_CR_PER; FLASH->AR = address; FLASH->CR |= FLASH_CR_STRT; - 等待BSY置位结束,再逐半字写入:
*(volatile uint16_t *)addr = data; while (FLASH->SR & FLASH_SR_BSY);有两个特别要命的细节:
第一,擦除Flash的耗时可能超过看门狗时限。如果你在Bootloader里开了IWDG,擦除一个扇区可能要几十毫秒,期间无法喂狗,系统就不断复位。这种问题根本没法一眼看出来,我是用调试器挂上之后才发现的。解决办法:擦除期间关掉IWDG,擦完再重新初始化,或者在每次擦页中途喂一次狗。
第二,“边写边擦”会数据错乱。如果你不清除BSY标志就写入下一个数据,Flash控制器会一直忙,写进去的全是垃圾。这个最好加个超时机制:
uint32_t timeout = 0xFFFFFF; while (FLASH->SR & FLASH_SR_BSY) { if (--timeout == 0) return FLASH_TIMEOUT; }4. 从烧录到跳转:App端必须配合的三件事
很多人以为Bootloader写完就能自动升级了,结果拿一个普通的App工程编译出来烧进去,永远跑不起来。原因很简单:App自己也得适配Bootloader。这份源代码配套的App工程做了三件必须做的事,缺一不可。
4.1 第1件事:改链接脚本,把App抬到偏移地址
App工程的分散加载文件(.sct,如果是Keil MDK)或链接器脚本(.ld,如果是GCC)必须把Flash起始地址改为App区起始地址。
以Keil为例,默认的分散加载是:
LR_IROM1 0x08000000 0x00010000 { ; load region size_region ER_IROM1 0x08000000 0x00010000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 {} }改成:
LR_IROM1 0x08004000 0x0000B800 { ER_IROM1 0x08004000 0x0000B800 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00005000 {} }这里有个很容易被忽略的点:RW_IRAM1的起始地址0x20000000不用动,SRAM没有偏移,App一样能用内存。
4.2 第2件事:中断向量表重映射
App代码里,在进入main后最短的时间内执行:
int main(void) { // 中断向量表指向App区的起始地址 SCB->VTOR = 0x08004000; // 下面再初始化所有外设 HAL_Init(); ... }这一行代码不做,或者做得太晚,后果就是:App跑起来后,任何中断(SysTick、串口、CAN、定时器)一旦触发,CPU都会跳到0x08000000那个原始的向量表去取中断处理函数,而那个地址已经被Bootloader占着了,App中断全部失灵,看起来就是“系统卡死”。这个坑我几乎可以确定每个做嵌入式的人都会踩一次。
还有一点,如果你改了链接脚本但代码里用了NVIC_SetVectorTable()这种老接口,F103上这类操作写的是NVIC_SetVectorTable(NVIC_VectTab_FLASH, 0x4000);,效果和直接写SCB->VTOR一样。但注意优先级,必须在任何外设中断使能之前调用。
4.3 第3件事:烧录算法和调试设置
同样重要的一个“隐藏”设置是调试器的Flash编程算法。如果你直接把Keil工程里的调试算法改成“STM32F10x Med-density 128K Flash”,它默认会把整个Flash地址从0x08000000开始擦写。但Bootloader已经占了0x08000000-0x08003FFF,如果通过调试器烧App时选了全片擦除,就等于把Bootloader也擦没了。
所以App工程的烧录算法页面里的“编程范围”(Program range)一定要设置成0x08004000开始,或者至少不要勾选“擦除全片”,而是让调试器只擦App区。实际项目中我因此刷废过开发板,不得不重新烧Bootloader。之后学乖了,在工程模板里就把这些设置固化好。
5. 实测联调与避坑记录:这批坑我基本都踩过
5.1 如何用示波器判断CAN通信好不好
很多人在CAN Bootloader联调时遇到“上位机发帧但设备无反应”,第一反应是查代码。我的建议是先测波形,再查代码。
CAN总线空闲时,CAN_H和CAN_L都是2.5V,差分电压为0V,叫隐性电平。通信时,显性电平表现为CAN_H拉到3.5V、CAN_L拉到1.5V,差分电压约2V。用示波器两根探头分别接CAN_H和CAN_L,用数学通道看差分波形,或者直接把两根线接到示波器的A-B差分模式,能看到干净的方波基本说明物理层没问题。
实测中如果发现波形幅值偏低、上升沿变缓,多半是终端电阻没接好。CAN网络两端必须各接一个120Ω终端电阻,否则波形反射严重,通信误码率直线上升。特别是升级现场如果设备是散落布置的,总线长度几十上百米,终端电阻更是不能省。
5.2 升级到一半断线怎么办
我在一次现场维护中遇到升级到一半CAN线被误拔,上位机一直卡在“等待应答”。这时候才发现,如果不做超时和重传机制,整个升级流程就永久卡死了。
所以这份源代码在通信超时上也做了兜底:CAN接收中断里维护了一个“看门狗定时器”,如果在规定的5秒内没有收到任何有效帧,就把状态机重置回BL_IDLE,并重新等待上位机的握手命令。上位机那边也是一样的逻辑:如果发送包序号后500ms没收到ACK,就重发当前包;连续重发10次失败,就报错退出,等待重新开始。
这个机制保证了一件事:哪怕升级到一半断了,重新上电或者重新点击升级,设备都能恢复,完全不需要拆机。这也是我前面说的三条设计原则里“可恢复性”的实际落地。
5.3 跳转后App跑飞:排查思路参考
如果你遇到Bootloader明明写完固件了,一跳转就死机,不要急着怀疑Bootloader,按这个顺序查:
- 查App是否编译到正确地址:用调试器读出App区的第一条指令,看是不是
0x2000xxxx开头的栈指针值。如果Reading出来是0xFFFFFFFF,说明App根本没烧进去。 - 查向量表重映射执行位置:在App的main入口处打断点,看
SCB->VTOR的值是否为0x08004000。 - 查跳转前中断状态:用调试器看
PRIMASK,确保跳转前全局中断是关闭的。 - 查外设复位状态:跳转前把所有在Bootloader中使用过的外设都恢复RESET,留意CAN外设是否处于睡眠模式。CAN在睡眠模式下退出需要一定的唤醒时序,App如果不对CAN重新初始化,就会一直唤醒失败。
我遇到过一个诡异的问题:App在特定操作后偶尔死机,排查半天才发现Bootloader跳转前没把SysTick中断清干净,App启动时突然来了个SysTick异常跳到了旧的向量表里。跳转前把SysTick停掉,问题立刻消失。
5.4 F103最小系统上要注意的PA8引时钟问题
很多人在自己的最小系统板上跑这份代码时,会遇到CAN初始化卡死,或者App启动后主频不对。ST官方库的SystemInit()里会根据启动模式配置RCC。如果你的最小系统板上BOOT0/BOOT1跳线没设置好,或者外部晶振没焊好,系统起跑后频率就乱套了。
特别是PA8,它可以输出系统时钟(MCO功能),我见过有人把PA8引出时钟信号给外部芯片用,结果MCO配置写得不对,导致整个时钟树混乱。解决办法是:在Bootloader和App的时钟初始化里明确配置SYSCLK、HCLK、PCLK1/PCLK2,不要依赖默认值。CAN的波特率是挂PCLK1(APB1)上的,APB1分频如果不对,波特率全错,这会影响Bootloader通信。
写在最后:几个操作层面的经验
这份Bootloader源代码在我这边好几个项目上跑了两年多,F103C8、F103RCT6都验证过,稳定性是有保障的。分享几个我个人的习惯给准备动手的朋友:
第一,Bootloader区和App区中间留4KB左右的空白不是坏主意。我一度把App紧贴着Bootloader放,结果App的越界访问直接踩到Bootloader的代码,排查起来非常痛苦。留些余量,至少能暴露问题而不是直接覆盖。
第二,升级完不要着急发光荣榜。我每次升级完都会让设备主动发一个“升级完成”的报文,上位机收到后才能断电。并且在App的首个启动位置再写一次“App自检成功”的标志,下次Bootloader启动时会做二次校验,双保险。
第三,保存代码版本信息到参数区。这份源代码在参数区预留了1KB空间,我会把编译日期、版本号、CRC都存进去。升级出问题的时候,能很快对出是哪一版固件引起的。
如果你准备把CAN Bootloader落到自己的项目里,建议不要一上来就追求复杂功能。先把“握手 -> 分包传输 -> Flash写入 -> 跳转”这个最小闭环跑通,再逐步加CRC、断线续传、固件加密。我见过很多人一开始就堆架构,结果连最基本的升级都调不通,信心直接被磨没了。先简后繁,一步一步来,这个Bootloader很快就能变成你工具箱里一个特别趁手的家伙。
本文还有配套的精品资源,点击获取