news 2026/9/10 0:39:05

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103 CAN总线Bootloader设计与实现详解

简介:面向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之前先给自己定三条设计原则:

  1. 升级失败不能把设备锁死。万一升级到一半断线了、总线出问题了,设备重新上电还能回到Bootloader继续升级,而不是变砖。
  2. 必须有校验措施。每一帧数据有校验,整个固件包也要有CRC校验,不能让错误数据刷进Flash。
  3. App出问题还能救回来。App如果因为升级写坏了,Bootloader要能检测到并停在Bootloader模式,而不是傻乎乎跳到一个不存在的程序里。

这三条原则贯穿了整个源代码的设计。下面我就按实际开发逻辑,把这份源代码的结构和核心实现逐段拆开讲。

2. Flash分区与程序启动流程:架构设计是第一步

2.1 内存到底怎么分

拿到任何一个Bootloader源码,第一件事不是看代码,而是看它的内存分布。STM32F103的Flash起始地址是0x08000000,CPU一上电就从这里取向量表、执行代码。但闪存空间是有限的,Bootloader和App必须商量好谁占哪一块。

以STM32F103C8T6(64KB 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; // 位段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位(半字)编程单位,必须先用半字方式写入,而且写入前必须把目标扇区擦掉。整个擦写流程:

  1. 解锁Flash:FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB;
  2. 等待Flash空闲:while (FLASH->SR & FLASH_SR_BSY);
  3. 擦除指定页:FLASH->CR |= FLASH_CR_PER; FLASH->AR = address; FLASH->CR |= FLASH_CR_STRT;
  4. 等待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,按这个顺序查:

  1. 查App是否编译到正确地址:用调试器读出App区的第一条指令,看是不是0x2000xxxx开头的栈指针值。如果Reading出来是0xFFFFFFFF,说明App根本没烧进去。
  2. 查向量表重映射执行位置:在App的main入口处打断点,看SCB->VTOR的值是否为0x08004000
  3. 查跳转前中断状态:用调试器看PRIMASK,确保跳转前全局中断是关闭的。
  4. 查外设复位状态:跳转前把所有在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很快就能变成你工具箱里一个特别趁手的家伙。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 0:38:00

STM32 PWM呼吸灯实战:从TIM3配置到引脚重映射的完整解析

简介&#xff1a;这是一个面向STM32F1微控制器的双极性SPWM波形生成代码工程&#xff0c;适用于逆变器、电机驱动、开关电源等需要正弦波输出的应用场景&#xff0c;也适合正在学习电力电子与嵌入式实时控制的开发者参考&#xff0c;可作为高校相关课程设计与毕业设计的参考模板…

作者头像 李华
网站建设 2026/9/10 0:36:54

基于ESP8266的WiFi网络授时时钟设计与实现

简介&#xff1a;一份完整的STM32网络授时时钟工程代码&#xff0c;以STM32F103C8T6为主控、ESP-12F为WiFi模块&#xff0c;配合PCF8563时钟芯片、按键和OLED显示屏&#xff0c;实现联网获取天气与时间信息并定时刷新。工程按功能拆分为多个可读性较强的模块&#xff1a;bsp_es…

作者头像 李华
网站建设 2026/9/10 0:36:03

PS2手柄+STM32四电机麦克纳姆轮小车:从接线到PID调试全解析

简介&#xff1a;这是一套基于STM32RCT6微控制器、利用PS2手柄控制四轮全向轮小车的完整工程资源。项目从手柄信号解析、电机PWM调速到全向轮运动逻辑均有详细代码实现&#xff0c;适合学习STM32库函数开发、嵌入式电机控制及无线遥控小车的开发者参考。压缩包共233个文件&…

作者头像 李华
网站建设 2026/9/10 0:32:41

毕业设计论文写作:那些吞噬时间的隐形黑洞与我的破局之道

1. 引言&#xff1a;一场与时间的拉锯战 作为一名正在进行毕业设计的大学生&#xff0c;我深知论文写作中的繁琐和耗时。在这个过程中&#xff0c;许多环节都让我感到无比耗费时间&#xff0c;尤其是在参考文献格式、中英文混排、文本修改、人工核对和任务交接等方面。于是&am…

作者头像 李华