资讯动态

STM32F103 CAN总线Bootloader设计与实现详解

发布时间:2026/9/10 0:39:18 来源:尧图企业网站定制
简介面向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起始地址是0x08000000CPU一上电就从这里取向量表、执行代码。但闪存空间是有限的Bootloader和App必须商量好谁占哪一块。以STM32F103C8T664KB Flash为例我这份源代码的内存划分如下区间地址范围大小用途Bootloader区0x08000000 - 0x08003FFF16KB固化引导程序一般不变App区0x08004000 - 0x0800F7FF46KB用户应用程序升级标志区0x0800F800 - 0x0800FBFF1KB记录升级状态、固件大小等参数区0x0800FC00 - 0x0800FFFF1KB预留参数存储很多人问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; // 位段113个时间单元 CAN_InitStructure.CAN_BS2 CAN_BS2_4tq; // 位段24个时间单元 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位半字编程单位必须先用半字方式写入而且写入前必须把目标扇区擦掉。整个擦写流程解锁FlashFLASH-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的波特率是挂PCLK1APB1上的APB1分频如果不对波特率全错这会影响Bootloader通信。写在最后几个操作层面的经验这份Bootloader源代码在我这边好几个项目上跑了两年多F103C8、F103RCT6都验证过稳定性是有保障的。分享几个我个人的习惯给准备动手的朋友第一Bootloader区和App区中间留4KB左右的空白不是坏主意。我一度把App紧贴着Bootloader放结果App的越界访问直接踩到Bootloader的代码排查起来非常痛苦。留些余量至少能暴露问题而不是直接覆盖。第二升级完不要着急发光荣榜。我每次升级完都会让设备主动发一个“升级完成”的报文上位机收到后才能断电。并且在App的首个启动位置再写一次“App自检成功”的标志下次Bootloader启动时会做二次校验双保险。第三保存代码版本信息到参数区。这份源代码在参数区预留了1KB空间我会把编译日期、版本号、CRC都存进去。升级出问题的时候能很快对出是哪一版固件引起的。如果你准备把CAN Bootloader落到自己的项目里建议不要一上来就追求复杂功能。先把“握手 - 分包传输 - Flash写入 - 跳转”这个最小闭环跑通再逐步加CRC、断线续传、固件加密。我见过很多人一开始就堆架构结果连最基本的升级都调不通信心直接被磨没了。先简后繁一步一步来这个Bootloader很快就能变成你工具箱里一个特别趁手的家伙。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价