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

发布时间:2026/10/21 13:53:29
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负责操作内部Flashprotocol.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位所以更实用的做法是用扩展帧ID29位高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和MaskAPP起始地址main.c 的 jump_to_app参数和链接脚本保持一致升级等待超时main.c上电后等待CAN帧的最长时长看门狗main.c升级期间喂狗跳转前关闭实际项目里我一般将上电等待升级超时设成500ms。500ms内收到升级请求才进入Bootloader主流程否则直接跳转APP。这样既不影响正常业务启动又能支持产线快速触发升级。链接脚本是另一个大坑。以MDK为例Bootloader工程的IROM1起点0x08000000大小0x4000APP工程的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(bustypepcan, channel0, bitrate500000) def send_frame(cmd: int, pkt_id: int, data: bytes): can_id (cmd 16) | (pkt_id 0x0FFF) bus.send(can.Message(arbitration_idcan_id, datadata, is_extended_idTrue)) # 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:i8] 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]0x7FData[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侧直接按DLC8写入把填充的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-5ID就落在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线看下次上电能否恢复。这三个场景全过一遍才敢往现场发。希望这段笔记能帮你少踩几个坑顺利把项目跑起来。本文还有配套的精品资源点击获取