ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103 CAN Bootloader源码解析:CAN总线远程固件升级方案

STM32F103 CAN Bootloader源码解析:CAN总线远程固件升级方案

简介:面向STM32嵌入式开发者,基于ARM Cortex-M3内核的CAN总线Bootloader完整工程源码,专注于解决通过CAN接口实现固件远程升级与启动流程控制的问题,适合需要掌握Bootloader底层机制、CAN通信协议及Flash编程的工程师学习参考。压缩包共包含180个文件,以71个C源文件、66个头文件和36个汇编启动文件为主体,另有Keil工程文件、配置文件与1个可直接烧录的bin固件,整体仅647KB,结构紧凑便于分析。已有2616人学习浏览,实用性得到验证。通过阅读源码,可以深入理解Cortex-M3启动过程、中断向量表安排、CAN标准/扩展帧收发与滤波配置,以及CRC校验、Flash擦写保护和Bootloader向应用程序跳转的实现细节;同时工程内附带的HAL库例程和汇编启动代码,也为二次开发与移植提供了直接参考。

1. 现场CAN升级:这个Bootloader源码能帮你解决什么问题

假设你手里有一批已经装进机箱的控制器,控制器上只引出了一个CAN接口,没有预留串口,也没留SWD调试线。某天发现某个版本固件有个隐蔽bug,需要现场升级。拆机重新烧录的成本太高,这时候一条CAN线远程升级就成了唯一的出路。

这个STM32F103的CAN总线Bootloader源代码,做的就是这件事:它把STM32F103的IAP能力通过CAN外设接起来,上位机通过CAN帧把固件分包发送,Bootloader接收、校验后写入内部Flash,最后跳转到APP执行。不需要外挂存储,不依赖专门的烧录器,你只需要熟悉CAN协议和STM32标准库,就能在现有CAN网络里完成升级。适合车载ECU、工业控制节点、电池管理系统等产品的开发者,特别是那些已经在用CAN通信,又不想为升级单独预留串口的场景。

接下来按实际动手顺序,从架构、源码、编译、联调、避坑到二次开发,把这份代码的关键点拆开讲清楚。

2. 技术选型与架构:CAN升级通道的Flash分区和代码结构

2.1 为什么用CAN做Bootloader,而不是UART

在动手读代码前,先理解为什么选CAN。UART做Bootloader看似简单,但有两个硬伤:一是点对点通信,上位机和设备必须独享物理连接,到现场要多找一根线;二是UART没有硬件层的数据校验和重发机制,协议栈要自己处理字节错乱,非常容易出问题。

CAN则天然适合升级场景。它是差分总线,抗干扰能力强,一对双绞线能挂几十个节点,上位机通过USB-CAN适配器接到总线上就能同时面对所有设备。CAN控制器内部自带CRC校验、位同步和自动重发,发送失败会重试,错误帧会自动隔离,链路层的数据完整性比UART高一个量级。对于要连续传输几百KB固件的场景,采用CAN能把精力集中在业务层的分包和校验,不用操心底层丢字节。

再从资源占用看。STM32F103的bxCAN外设带有3个发送邮箱、2个接收FIFO和一组筛选器,驱动代码几百行就能写完,不会挤占APP空间。CAN帧的数据场是8字节,设计一个自定义升级协议非常合适:一个CAN ID区分命令类型,8字节数据里放命令字、包序号、数据与校验字段,刚好填满一帧。相比UART一字节一字节地流式传输,CAN天然就是报文式模型,和“分包升级”的语义完全对应。

2.2 源码目录结构与Flash分区方案

拿到源码,先看目录结构,这能帮你快速定位需要改的文件。常见项目的组织方式如下:

CANS_Bootloader/ ├── app/ # APP示例工程,用于验证跳转 ├── bootloader/ # Bootloader主工程 │ ├── can_driver.c # CAN外设初始化、收发、过滤配置 │ ├── flash_if.c # Flash擦写、读保护状态、校验 │ ├── protocol.c # 自定义CAN升级协议解析 │ ├── main.c # 主流程:等待升级/跳转APP │ └── system.c # 时钟、NVIC、看门狗等初始化 ├── icf/ 或 .ld # 链接脚本,定义Flash分区 └── host/ # 可选的上位机示例脚本

这个结构是常见的分层方式,不同版本可能略有差异,但核心文件基本固定。can_driver.c负责底层收发,flash_if.c负责操作内部Flash,protocol.c把CAN帧翻译成升级动作,main.c里控制整个状态机。

Flash分区是整个Bootloader的基石,必须先定好。以STM32F103C8T6这颗64KB Flash的芯片为例,可以这样划分:

区域地址范围大小存放内容
Bootloader0x08000000 - 0x08003FFF16KB常驻的IAP程序
参数区0x08004000 - 0x08007FFF16KB升级状态、版本号、回滚标志
APP区0x08008000 - 0x0800FFFF32KB用户应用程序

如果芯片是512KB的大Flash,可以按比例扩大,APP从0x08010000起步。关键在于Bootloader和APP的地址要在链接脚本里写死,两边不能重叠。一个经验法则:Bootloader尽量控制在16KB以内,因为CAN Bootloader本身代码不多,做更多冗余只会压缩APP区空间。

3. 核心模块拆解:从CAN帧进入中断到Firmware落盘的完整链路

3.1 CAN接收与协议解析

Bootloader的第一步是接收CAN帧。STM32F103通常把CAN接收配置成FIFO0中断,每次收到帧就放进环形缓冲区,主循环再做协议解析。下面是一段典型的初始化代码:

// can_driver.c CAN1初始化 void can_init(uint32_t baud) { GPIO_InitTypeDef GPIO_InitStructure; CAN_InitTypeDef CAN_InitStructure; CAN_FilterInitTypeDef CAN_FilterInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_11; // CAN_RX GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_12; // CAN_TX 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; 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_8tq; CAN_InitStructure.CAN_BS2 = CAN_BS2_7tq; CAN_InitStructure.CAN_Prescaler = 4; // APB1 36MHz 分频,目标是500Kbps CAN_Init(CAN1, &CAN_InitStructure); CAN_FilterInitStructure.CAN_FilterNumber = 0; CAN_FilterInitStructure.CAN_FilterMode = CAN_FilterMode_IdMask; CAN_FilterInitStructure.CAN_FilterScale = CAN_FilterScale_32bit; CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0x0000; CAN_FilterInitStructure.CAN_FilterMaskIdLow = 0x0000; CAN_FilterInitStructure.CAN_FilterFIFOAssignment = CAN_FIFO0; CAN_FilterInitStructure.CAN_FilterActivation = ENABLE; CAN_FilterInit(&CAN_FilterInitStructure); CAN_ITConfig(CAN1, CAN_IT_FMP0, ENABLE); // 开FIFO0消息挂号中断 NVIC_Init(&NVIC_InitStructure); }

这里的波特率计算是重点。APB1时钟36MHz,目标波特率500Kbps,公式是baud = pclk1 / ((BS1 + BS2 + 1) * Prescaler)。代入36e6 / ((8 + 7 + 1) * 4) = 562500,接近500K。如果总线上实际波特率不同,优先调Prescaler,其次调BS1、BS2。过滤器的配置直接决定能收到哪些ID,上面把掩码全部置0,也就是不过滤任何ID,调试阶段很方便。生产环境里建议把掩码改成只放行升级命令ID,避免业务报文干扰Bootloader。

中断接收函数的核心动作是保存帧,绝不可以在中断里做耗时操作:

// can_driver.c CAN接收中断 void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx_msg; while (CAN_MessagePending(CAN1, CAN_FIFO0)) { CAN_Receive(CAN1, CAN_FIFO0, &rx_msg); if (rx_msg.IDE == CAN_Id_Standard && rx_msg.DLC <= 8) { rx_queue[rx_queue_in++] = rx_msg; // 循环缓冲入队 rx_queue_in &= (QUEUE_SIZE - 1); } } }

这里采用循环缓冲,队列大小至少16帧,因为主循环可能在擦写Flash,期间中断会积压帧。我一般把队列设在16到32帧。中断里只入队,Flash操作放到主循环做,Flash擦写太慢会拖崩中断响应。

协议解析在主循环完成。一个常见的CAN升级协议可以这样设计:使用标准ID的bit15-12作为命令字,bit11-0作为包序号,数据场的8字节全部存放固件数据。但标准ID只有11位,所以更实用的做法是用扩展帧ID(29位):高8位命令字,中间12位包序号,低位保留。下面是解析代码:

// protocol.c 解析一个CAN帧 uint8_t protocol_parse(CanRxMsg *msg, uint8_t *app_buf, uint32_t *app_len) { uint8_t cmd = (msg->CanId >> 16) & 0xFF; // 命令字 uint16_t pkt_num = (msg->CanId >> 4) & 0x0FFF; // 包序号 uint8_t len = msg->DLC; switch (cmd) { case CMD_DATA_WRITE: memcpy(&app_buf[(uint32_t)pkt_num * 8], msg->Data, len); *app_len += len; // 每收32帧应答一次,降低总线压力 if ((pkt_num & 0x1F) == 0x1F) can_send_ack(pkt_num); break; case CMD_ERASE_START: flash_erase_app(APP_ADDR, APP_SIZE); can_send_ack(0xFF); break; default: return 0; } return 1; }

这段代码先把数据暂存到内存,全部收完后一次性写入Flash。这种方式适合APP区容量不超过RAM的场景,比如4KB的引导程序。如果APP有几十KB,就得边收边写入Flash,内存只留一页缓冲。注意包序号按pkt_num * 8计算偏移,因为每帧最大8字节。最后一帧如果不足8字节,DLC字段记录实际长度,这样不会多写。

3.2 Flash擦写与APP跳转

固件收完后进入真正的硬件操作。STM32F103的Flash写入需要先解锁,再擦页,然后按半字写入。这一步时序不对,轻则写失败,重则连Bootloader也擦掉。下面是擦除APP区的示例:

// flash_if.c 擦除APP区 void flash_erase_app(uint32_t app_addr, uint32_t app_size) { FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPERR); uint32_t end_addr = app_addr + app_size; while (app_addr < end_addr) { if (FLASH_ErasePage(app_addr) != FLASH_COMPLETE) { error_code = 1; return; } app_addr += 1024; // 中容量STM32F103页大小是1KB } FLASH_Lock(); }

FLASH_Unlock和FLASH_Lock必须成对使用。升级期间建议保持中断开启,但中断里不要做复杂操作,否则CAN帧会丢。注意“页大小”不是固定的,高容量F103的页大小是2KB,如果你用的是高容量芯片,步长要改成2048。

写Flash时可用如下函数:

// flash_if.c 写入一页数据 uint8_t flash_write_page(uint32_t addr, uint32_t *data, uint16_t len) { FLASH_Unlock(); for (uint16_t i = 0; i < len; i += 2) { if (FLASH_ProgramHalfWord(addr + i, (uint16_t)(data[i/2] & 0xFFFF)) != FLASH_COMPLETE) { FLASH_Lock(); return 1; } } FLASH_Lock(); return 0; }

注意这里的数据局部变量是32位指针,但FLASH_ProgramHalfWord写入半字。实际使用时,你可能从CAN帧里拿到了8字节,要按16位拆开才能写。建议直接以半字为单位遍历源缓冲区,这里只是示意。参数说明:addr是Flash地址,必须按半字对齐;data是源数据;len是数据字节数,必须是偶数。

跳转APP是整个Bootloader最需要小心的地方:

// main.c 跳转到APP typedef void (*pFunction)(void); void jump_to_app(uint32_t app_addr) { uint32_t app_sp = *(volatile uint32_t *)app_addr; // 取APP初始SP pFunction app_reset = (pFunction)*(volatile uint32_t *)(app_addr + 4); // 取Reset_Handler if ((app_sp & 0xFFFF0000) != 0x20000000) { return; // SP不合法,不跳转 } __disable_irq(); SysTick->CTRL = 0; __set_MSP(app_sp); app_reset(); }

关键点有四个。第一,必须检查APP首字是否是合法RAM地址,否则直接HardFault。第二,跳转前要__disable_irq(),把SysTick停掉,避免跳转过程中进中断。第三,APP工程的编译选项里必须加向量偏移,比如MDK里IROM1起始地址改成0x08008000。第四,跳转后APP要重新初始化所有外设。

再说说为什么要关全局中断而不是关某几个中断。因为跳转时如果刚好有一个中断被触发,中断返回后的PC会被打回跳转前的地址,而SP可能还停在Bootloader栈里,APP一跑栈就乱了。所以必须在跳转瞬间关中断,这是无数人踩过的坑。

4. 编译烧录和上位机联调:让Bootloader按你的协议跑起来

4.1 工程配置与代码修改点

拿到源码,第一步不是编译,而是改三个地方:CAN波特率、过滤器ID、APP地址。这三个对应源码里的can_init、过滤器配置和jump_to_app入口参数。整理成表格:

要改的内容配置文件修改方式
CAN波特率can_driver.c 的 can_init调整Prescaler、BS1、BS2
接收哪些帧IDcan_driver.c 的过滤器设置FilterIdHigh/Low和Mask
APP起始地址main.c 的 jump_to_app参数和链接脚本保持一致
升级等待超时main.c上电后等待CAN帧的最长时长
看门狗main.c升级期间喂狗,跳转前关闭

实际项目里,我一般将上电等待升级超时设成500ms。500ms内收到升级请求才进入Bootloader主流程,否则直接跳转APP。这样既不影响正常业务启动,又能支持产线快速触发升级。

链接脚本是另一个大坑。以MDK为例,Bootloader工程的IROM1起点0x08000000,大小0x4000;APP工程的IROM1起点改成0x08008000,大小根据Flash余量设置。改完后,APP的中断向量表位置就变了。在早期STM32F103工程里,需要在APP启动早期设置VTOR:

// APP工程 main.c 最前面 #define APP_BASE 0x08008000 SCB->VTOR = APP_BASE;

如果不写这行,MCU上电后中断向量仍在Bootloader区,APP一旦进中断,PC就跳到Bootloader,表现为“APP能跑但不响应中断”。如果在Bootloader跳转前没有关中断,设置VTOR之前进来一个中断,直接HardFault。

4.2 上位机发送流程与校验逻辑

源码只包含嵌入式部分,上位机要按协议自己写。这里用Python配合USB-CAN适配器演示发送流程:

# host/can_send.py 伪代码示意 import can, time bus = can.interface.Bus(bustype='pcan', channel=0, bitrate=500000) def send_frame(cmd: int, pkt_id: int, data: bytes): can_id = (cmd << 16) | (pkt_id & 0x0FFF) bus.send(can.Message(arbitration_id=can_id, data=data, is_extended_id=True)) # 1. 请求升级 send_frame(CMD_BOOT_REQ, 0, b'\x00' * 8) time.sleep(0.1) # 2. 擦除 send_frame(CMD_ERASE_START, 0, b'\x00' * 8) # 3. 分包发送固件 firmware = open('app.bin', 'rb').read() for i in range(0, len(firmware), 8): chunk = firmware[i:i+8] if len(chunk) < 8: chunk = chunk + b'\xff' * (8 - len(chunk)) # 补填充 send_frame(CMD_DATA_WRITE, i//8, chunk) time.sleep(0.002) # 等待设备处理 # 4. 发送结束帧,包含固件长度和校验值 send_frame(CMD_DATA_END, 0, len(firmware).to_bytes(4, 'big') + b'\x00'*4)

这里的sleep(0.002)不是可有可无。F103擦一页Flash要20ms左右,上位机连发间隔太短,即使有缓冲也可能溢出。不要每帧等ACK,那样太慢,常见做法是每32帧等一次ACK,或者固定间隔发。

校验逻辑建议这样:Bootloader在收完固件后按字节累加得到一个校验和,和上位机最后一帧里下发的校验值比对,一致才写Flash。如果把校验放在每一帧里,传输效率会低一半。联调时,在ACK反馈里带上帧计数和错误码,比如Data[0]=0x7F,Data[1]=帧计数,Data[2]=0表示成功,非0表示失败。这样上位机一帧就能拿到状态,排查问题会快很多。

5. 避坑手册:CAN BootLoader开发中的五个经典翻车现场

5.1 现象一:跳转APP后立刻死机,调试器挂不上

  • 现象:发送完固件,Bootloader跳转后板子变砖,连接SWD只能读到芯片ID,无法连接Core。
  • 原因:跳转前没有关闭全局中断,或者SP设置后Reset_Handler的中断向量还在Bootloader区,APP第一次进中断就跳飞了。
  • 解决:按前面jump_to_app的写法,先__disable_irq(),再停SysTick和所有外设中断;确保APP工程开了SCB->VTOR = APP_BASE。如果确认关中断了还是死,用调试器看APP地址处的数据,是否真的是合法的SP和Reset_Handler。有些人把bin文件偏移烧错了,也会出现这种“跳进去就死”的现象。

5.2 现象二:Flash写入超时,擦除永远不完成

  • 现象:调试发现FLASH_ErasePage一直返回FLASH_TIMEOUT,板子在擦写时偶尔有CAN错误。
  • 原因:Flash操作期间CAN中断频繁抢占,导致擦写超时;或者是72MHz主频下没有在FLASH_ACR里设置等待周期。
  • 解决:擦写前调一次FLASH_SetLatency(FLASH_Latency_2);擦写期间保持中断开启,但中断里只做入队。另外把CAN接收中断优先级设为最高(抢占优先级0),避免被其他中断长时间打断。如果总线上业务报文很多,擦写期间可以用发送“擦除中”标志,暂停上位机数据流。

5.3 现象三:CAN帧时不时丢一包,上位机重发也救不回来

  • 现象:升级过程中上位机提示丢包,Bootloader侧看到CAN FIFO溢出标志位。
  • 原因:两个根源,一是CAN中断优先级太低,被其他中断打断时间过长;二是代码在Flash写入期间长时间关中断,整个CAN接收被阻塞。
  • 解决:把CAN1_RX0中断优先级提到最高。Flash擦写时不要关总中断。如果CAN波特率太高且总线不太干净,降到250K再试。还有一个看不见的原因:CAN过滤器配置成了只接收入口ID,但上位机发的ID带扩展帧标志位,你没设CAN_FilterScale_32bit,导致匹配不上,看起来就像丢包。

5.4 现象四:最后一帧写错,固件莫名其妙多了几个字节

  • 现象:烧录后APP能跑,但某些功能异常,对比bin文件发现结尾多了填充数据。
  • 原因:上位机把不足8字节的最后一帧也按8字节整帧发送,Bootloader侧直接按DLC=8写入,把填充的0xFF也写进Flash了。或者包序号偏移计算错误,越界写到后面的参数区。
  • 解决:严格使用CAN帧的DLC字段作为本次实际长度,不要用常数8。偏移计算使用pkt_num * 8,最后一帧按pkt_num * 8 + DLC计算结束位置。同时,上位机发送最后一帧时,让DLC等于实际剩余字节数,不要补满。

5.5 现象五:上位机连发加快就翻车,慢速一切正常

  • 现象:把sleep间隔从2ms调到0.5ms,升级就失败;恢复2ms又正常。这不是玄学,是缓冲溢出。
  • 原因:CAN接收环形缓冲只设了4帧或8帧,而主循环在擦写Flash,擦一页要十几毫秒,队列早满了。
  • 解决:把环形缓冲加大到32帧,并在CAN中断里只入队。更好的做法是加入流控:上位机发擦除命令后,等收到ACK才开始传数据;设备在擦写Flash期间,如果队列快满,就把CAN中断里的入队继续保留,同时主循环优先处理队列,避免处理协议时又被新帧淹没。

6. 二次开发思路:从改协议到双A备份回滚

6.1 把自定义CAN帧改成适合业务的协议

这份源码里的协议是最简式,实际产品里很可能需要把升级帧ID嵌入现有CAN网络协议,避免和运行帧冲突。常见做法是使用扩展帧ID,把高8位定义成命令字,中间12位做包序号。然后通过过滤器只放行升级帧区间,例如:

CAN_FilterInitStructure.CAN_FilterIdHigh = 0x0700; CAN_FilterInitStructure.CAN_FilterMaskIdHigh = 0xFF00; // 只匹配高8位

上位机发送时,ID = (cmd << 16) | seq。只要cmd取值1-5,ID就落在0x010000-0x05FFF之间,你可以在过滤器中用掩码放行这个范围。改协议时切记同步修改上位机和过滤器的位偏移,否则就会出现“上位机发了,设备没收到”的诡异问题。

6.2 增加双Bank备份与回滚机制,给产品上一道保险

如果升级到一半断电或CAN断线,单Bank方案基本没救。我在项目中加过双Bank备份,思路是:把Flash划分为Bootloader区、APP_Bank0区、APP_Bank1区、参数区。升级时把新固件写入当前非活动Bank,写完后做完整CRC校验,通过后把参数区活动标志翻转,再跳转旧Bank继续运行。下次复位时Bootloader根据活动标志跳新Bank,如果校验失败自动回滚旧固件。

核心代码就两步:

// 翻转活动Bank uint32_t active_bank = read_param(PARAM_ACTIVE_BANK); active_bank ^= 1; write_param(PARAM_ACTIVE_BANK, active_bank);

然后在main.c里根据活动Bank选择APP地址。注意参数区要放在最后一页,用双字备份保存,防止写入时掉电导致参数损坏。这个改造的代价是APP可用Flash减半,但换来了“升级过程中掉电不挂机”的可靠性。

再提一个容易忽略的细节:APP跑着实时控制任务时,升级完跳过去后外设状态已经变了。因此APP启动时要重新初始化全部外设,我习惯在APP的SystemInit()后加一行RCC_DeInit(),确保外设时钟从头配。

我最初做这个项目时也犯过不少错:第一版跳转APP忘了关中断,结果一开机就HardFault;后来在Flash擦写期间关中断丢帧,又加了一点重试逻辑才好转。从那以后我每次写完一版Bootloader,都会强制走一遍完整流程:上电不触发升级直接跳APP;触发升级但校验失败,看回滚是否正常;在擦写过程中人为拔掉CAN线,看下次上电能否恢复。这三个场景全过一遍,才敢往现场发。希望这段笔记能帮你少踩几个坑,顺利把项目跑起来。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进