STM32F107 CAN Bootloader实现指南:基于CAN总线的远程固件升级方案
2026/9/2 7:13:48 网站建设 项目流程

简介:针对STM32F107芯片的CAN引导加载程序工程包,面向嵌入式开发者以及工业自动化、汽车电子领域工程师,用于在没有外部烧录器的情况下,通过CAN总线远程更新应用固件,从而降低设备维护成本。压缩包共359个文件,以145个头文件、133个C源文件为主,另有72个汇编文件、Keil工程配置、说明文档和可执行工具,整体仅1.27MB,结构紧凑,便于快速导入和二次开发。内容涵盖启动引导、程序跳转、CAN底层收发、传输数据加密、错误重传、CRC完整性校验、Flash分区擦写与容错恢复等核心机制,代码层次清晰,注释较完整,适合作为STM32F107 Bootloader项目的参考模板,也便于移植到类似产品中。同时包内附有编译脚本和二进制固件,可先在Keil中编译烧录验证,再结合实际CAN总线设备联调,从而快速验证升级流程。已有524人学习下载,对正在设计在线升级方案的工程师具有直接的借鉴意义。 做嵌入式开发这么多年,现场升级固件这事儿一直是绕不开的坎。尤其是设备已经装到机柜里、现场没有调试器、外壳也封死了的时候,想更新一个bug修复版本,总不能把设备拆下来寄回工厂。所以这次整理的这个CANbootloader,针对STM32F107的方案,就是专门解决这类远程升级痛点的。它基于CAN总线实现IAP功能,可以让你通过车辆或工业现场的CAN网络,直接把应用程序固件刷进芯片里,整个过程不需要拆卸设备,也不需要额外的硬件调试工具。

这个方案我实测下来的核心价值有两个:一是CAN总线在工业控制和车载环境里太普及了,利用现成的总线网络做升级,几乎零成本;二是STM32F107这颗芯片本身自带两个CAN控制器,做双CAN冗余或者CAN转以太网网关都非常合适。如果你正在做车载OBD诊断设备、工业控制器的远程维护,或者物联网边缘网关这类产品,这套CANbootloader方案可以直接作为参考模板使用。

1. 为什么选择STM32F107做CANbootloader

STM32F107属于意法半导体的互联型产品线,和常见的F103相比,最大的区别在于它内置了以太网MAC控制器和两个CAN 2.0B控制器。在做CANbootloader这个项目的时候,这颗芯片的双CAN特性简直是量身定做的:一个CAN接口可以用于正常通信和程序升级,另一个CAN接口可以保留给其他功能,两条总线互不干扰。

从bootloader的架构角度看,F107的Flash容量分为小容量(64KB)、中容量(256KB)和大容量(512KB)几个档位,无论哪个型号,Flash操作都需要遵循标准的解锁、擦除、编程流程。它的Flash不是按字节直接写入的,而是以页为单位擦除(大容量型号每页2KB),编程时每次写入16位(半字)。这个特性直接决定了bootloader的设计——上位机下发的固件数据,必须按照Flash页大小进行分包和排列,否则就会出现擦写错位的问题。

我选择F107做CANbootloader还有个现实原因:这颗芯片在CANopen、J1939协议栈上的参考资料非常多,很多现成的协议库可以直接复用。如果你手头刚好有基于F107的产品在跑CAN通信,加上bootloader功能不需要改动太多硬件,软件层面增加一个“升级模式”的入口就行。

提示:如果你使用的是F105或F107,注意它们的USB OTG控制器和CAN控制器共用部分引脚,在做引脚分配时要避免冲突。

2. bootloader的整体架构与Flash分区设计

2.1 内存分区规划

在做CANbootloader之前,第一个要解决的问题就是Flash分区。以STM32F107VCT6为例,它拥有256KB Flash,起始地址是0x08000000。我通常会把Flash分成三个区域:

区域地址范围大小用途
Bootloader区0x08000000-0x08003FFF16KB存放bootloader本身
App区0x08004000-0x0801FFFF112KB存放应用程序固件
暂存区(可选)使用片外存储或按需划分视情况而定存放接收中的固件备份

Bootloader放在起始地址,因为芯片上电后默认从0x08000000取指执行。16KB的空间对于CANbootloader来说完全够用——CAN驱动、Flash驱动、简单的命令解析,这些加起来不会超过8KB,剩余空间还可以放一些版本信息和升级日志。

App区是真正跑业务逻辑的地方。为什么App起始地址要偏移到0x08004000而不是紧挨着Bootloader?这里有个细节:考虑到Bootloader后续可能要扩展功能(比如增加加密认证),预留一些空间会方便很多。如果当初把App紧贴着Bootloader放,后期Bootloader哪怕多塞一个功能,整个App分区都要跟着挪,应用程序里的中断向量表偏移、链接脚本、固件打包工具全都要改一遍,这种牵一发动全身的事还是尽量避免。

2.2 中断向量表重映射

App区确定后,必须要处理中断向量表的问题。默认情况下,STM32的中断向量表固定在Flash起始地址0x08000000,也就是Bootloader所在的位置。如果App直接运行而不做任何处理,一旦产生中断,芯片会跳到Bootloader的向量表去取中断服务函数地址,结果就是程序跑飞。

F107的解决方案是使用向量表偏移寄存器。在Cortex-M3内核中,可以通过设置SCB->VTOR寄存器来改变向量表的起始位置。在App的启动代码中,需要在main函数最开始执行类似这样的操作:

#define APP_BASE_ADDR 0x08004000 void SystemInit(void) { // 其他初始化代码... SCB->VTOR = APP_BASE_ADDR; }

当然,不同库函数版本的写法有差异,有些老的固件库可能没有在SystemInit里处理VTOR,你就需要在main函数开头手动加上这句。还要注意一点:向量表重映射之后,App里的所有中断配置(UART、CAN、定时器等)才能正常工作,否则会出现中断完全无效或者死机的现象。

2.3 链接脚本(LD文件)的调整

向量表偏移只是软件层面的第一步,真正决定App能不能跑在正确位置的是链接脚本。如果你用的是Keil MDK,需要在Options for Target里把IROM1的起始地址改为0x08004000,大小改为0x1C000(112KB)。如果用STM32CubeIDE或GCC工具链,则要修改链接脚本中的FLASH起始地址。

MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 112K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K }

这个操作容易出错的地方在于:修改了Flash起始地址后,编译生成的bin文件就是从0x08004000开始的纯App代码,里面不含Bootloader的部分。而上位机要发送给bootloader的,恰恰是这种纯App的bin文件。如果你直接烧录了整个hex文件(包含地址信息),可能会导致bootloader覆盖,所以在固件打包时建议使用bin格式,并且由上位置软件按地址偏移进行解析。

3. CAN通信协议设计与实现细节

3.1 帧格式与ID分配

CANbootloader和上位机之间的通信需要一套完整的协议,不能简单地把固件数据往CAN总线上丢。我设计的协议基于CAN 2.0A标准帧(11位ID),因为标准帧在工业现场的兼容性更好,而且对于升级这种小数据包场景足够用。

帧ID分配策略如下:

帧方向CAN ID说明
上位机→设备0x100命令帧
上位机→设备0x101数据帧
设备→上位机0x200应答帧
设备→上位机0x201状态帧

命令字定义在数据域的第一个字节,比如0x01表示握手、0x02表示擦除、0x03表示写入、0x04表示校验、0x05表示跳转执行。数据域最多8字节,对于一次Flash写入来说,CAN标准帧的8字节数据刚好对应Flash编程时每次写入4个半字(8字节)的操作,非常契合。

3.2 通信流程与状态机

一个完整的升级流程是这样的:

  1. 上位机发送握手命令(0x01),bootloader收到后回复设备版本号、Bootloader版本号、Flash大小等信息
  2. 上位机发送擦除命令(0x02),bootloader执行Flash擦除
  3. 上位机分包发送固件数据(0x03),每包包含地址偏移和数据内容
  4. 上位机发送校验命令(0x04),bootloader计算已写入数据的CRC32并返回
  5. 上位机发送跳转命令(0x05),bootloader执行App启动

整个通信过程用状态机管理,bootloader的逻辑是这样的:

typedef enum { STATE_IDLE, STATE_HANDSHAKE, STATE_ERASE, STATE_WRITE, STATE_VERIFY, STATE_JUMP } BootState; BootState currentState = STATE_IDLE; void CAN_RX_Handler(uint32_t id, uint8_t *data, uint8_t len) { switch (currentState) { case STATE_IDLE: if (id == CMD_FRAME_ID && data[0] == CMD_HANDSHAKE) { // 回复设备信息 currentState = STATE_HANDSHAKE; } break; case STATE_HANDSHAKE: if (id == CMD_FRAME_ID && data[0] == CMD_ERASE) { // 擦除App区 Flash_EraseAppArea(); currentState = STATE_ERASE; } break; case STATE_ERASE: if (id == DATA_FRAME_ID) { // 解析地址和数据,写入Flash Flash_WriteData(address, data); } else if (id == CMD_FRAME_ID && data[0] == CMD_VERIFY) { // 校验 currentState = STATE_VERIFY; } break; // 其他状态处理... } }

这里有几个容易踩的坑。第一个是超时处理:如果上位机发了一帧数据后停住了,bootloader不能一直傻等,否则设备就死在那了。我在实际项目中加了看门狗和超时计数,超过500ms没有收到下一帧数据就自动复位回到IDLE状态。第二个是连续帧的编号问题:固件分包发送时,每帧数据都要带一个包序号,bootloader收到后检查序号是否连续。CAN总线不像USB那样有底层的流控机制,丢帧是正常现象,靠包序号做容错是非常必要的。

3.3 Flash驱动编写要点

STM32F107的Flash编程有几个必须注意的地方。

Flash擦除操作:在擦除期间,CPU不能从Flash取指令执行,所以擦除函数必须放在RAM中运行,或者使用已经在RAM中的代码。如果你直接用Keil默认配置,擦除Flash时会死机,因为在擦除Flash的同时CPU还在从Flash读取擦除函数的指令。解决办法是在RAM中执行擦除操作:

__RAM_FUNC void Flash_ErasePage(uint32_t pageAddr) { // 等待Flash空闲 while (FLASH->SR & FLASH_SR_BSY); // 解锁Flash FLASH->KEYR = 0x45670123; FLASH->KEYR = 0xCDEF89AB; // 执行页擦除 FLASH->CR |= FLASH_CR_PER; FLASH->AR = pageAddr; FLASH->CR |= FLASH_CR_STRT; // 等待操作完成 while (FLASH->SR & FLASH_SR_BSY); // 锁定Flash FLASH->CR &= ~FLASH_CR_PER; FLASH->CR |= FLASH_CR_LOCK; }

写Flash操作同样要注意,写入前必须确保目标地址是擦除状态(0xFFFFFFFF),否则写入会失败。我遇到过的情况是,上位机发的固件分包没有按页对齐,导致写入时跨越了页边界,结果后半个页的数据写不进去。解决方案是上位机在分包前先做对齐处理,或者bootloader内部做跨页拼包。

4. 上位机与联调过程中的关键经验

4.1 上位机工具的选择与协议对接

CANbootloader的下位机只是整个系统的一半,上位机软件同样重要。如果你使用CAN分析仪(比如周立功USBCAN、CANable等),通常厂商都会提供二次开发SDK,你可以用Python或C#写一个简单的刷写工具。

我用Python写过一套基于python-can库的上位机工具,核心逻辑很简单:

import can bus = can.interface.Bus(bustype='pcan', channel='PCAN_USBBUS1', bitrate=500000) def send_frame(can_id, data): msg = can.Message(arbitration_id=can_id, data=data, is_extended_id=False) bus.send(msg) def write_firmware(file_path): with open(file_path, 'rb') as f: firmware = f.read() # 发送握手命令 send_frame(0x100, [0x01]) # 等待应答... # 发送擦除命令 send_frame(0x100, [0x02]) # 分包发送固件 chunk_size = 8 for i in range(0, len(firmware), chunk_size): chunk = firmware[i:i+chunk_size] # 补充包序号和地址信息 data = [0x03, seq, addr_hi, addr_lo] + list(chunk) send_frame(0x101, data) seq += 1 # 发送校验和跳转命令 send_frame(0x100, [0x04]) send_frame(0x100, [0x05])

这里有一个需要特别注意的地方:CAN的波特率必须和设备端的实际配置一致。Bootloader阶段的CAN波特率和App阶段的CAN波特率很可能是不同的——很多产品在正常工作时用的波特率是250Kbps(CANopen标准),而bootloader为了兼容不同波特率的总线环境,可能默认配置成500Kbps或者自动识别。我在一个项目里就踩过这个坑:上位机用500K发送握手命令,设备端bootloader工作在250K,两边互相收不到数据,排查了好半天才发现是波特率不匹配。

注意:建议在bootloader中实现波特率自适应功能。通过监听总线上的数据流,尝试不同的波特率配置,直到收到合法帧为止。这个功能在总线上有多个设备、波特率不确定的场景下非常实用。

4.2 坏块处理和掉电保护策略

实际做产品时,不可以假设升级过程永远顺利。CAN总线受干扰、上位机崩溃、现场突然断电,这些情况都有概率发生。如果不做任何保护,一旦在擦除Flash之后、写入完成之前断电,设备就变砖了。

我的做法是在Flash中保留一个升级标志区,在做任何破坏性操作之前,先在标志区写入“正在升级”的标记。Bootloader启动时检查这个标记:

  • 如果标记显示“升级未完成”,说明上次升级失败了,此时bootloader不会跳转到App,而是停留在bootloader模式等待重新升级
  • 如果标记显示“升级成功”,bootloader正常跳转到App执行

这个策略实现起来很简单,可靠性却极高。我实际测试过在写入过程中直接断电,重新上电后设备会停在bootloader模式,用上位机重新刷一次完整固件就能恢复正常。这里再补一句经验:不要用单个字节做标志,用两个字节互为取反(比如0xA5和0x5A),可以避免Flash写入不完整导致的标志误判。

4.3 提高CAN总线传输效率的技巧

CAN总线波特率500Kbps时,理论带宽是50000字节/秒,但实际有效数据速率远低于这个值。因为每一帧还有帧头、仲裁、应答等开销,标准帧的实际数据效率大概在60%左右。如果固件有100KB,传输时间至少需要3秒以上,实际可能要5到10秒。为了提高效率,我做了两件事:

使用DLC=8充分利用数据域。有些工程师的习惯是每帧只发有效数据长度,结果是一包只发几个字节,白白浪费了CAN帧的负载能力。bootloader固件在传输时一定要凑满8字节,不足的部分用0xFF填充。

支持多帧连续发送。上位机在发送数据帧时不要等每帧都收到应答后再发下一帧,应该采用窗口机制,一次连发16帧或32帧,设备端通过包序号确认,如果发现某帧丢了,就连续发NACK,上位机从这个序号开始重传。这个机制和TCP的滑动窗口非常像,可以大幅提升传输速度。

4.4 与LAN8720以太网功能的联动场景

关于最新的网络热词LAN8720,其实这和STM32F107的CANbootloader是有直接关联的。STM32F107内置以太网MAC,配合LAN8720这颗PHY芯片可以构建以太网接口。有些产品既需要CAN通信又需要以太网上行连接,通常情况下以太网接口负责和上位机通信,CAN接口负责和底层设备通信。

在这种架构下,CANbootloader依然有它的用武之地——当设备通过以太网正常工作时,如果收到云端或上位机的升级指令,设备可以先把固件下载到片外Flash或SD卡中,然后主动切换到bootloader模式,再从本地Flash把固件通过CAN或内部Flash接口写入App区。这样虽然传输路径变了,但bootloader的核心逻辑完全复用,只是数据来源从CAN变成了以太网下载的本地缓存。这也是我在项目中实际验证过的做法,升级成功率比直接走以太网在线刷写高很多,因为本地缓存避免了网络中断导致的半截刷写问题。

5. 常见问题排查与调试实战

5.1 设备上电后无法进入bootloader

这个问题的常见原因有两个:一是boot引脚配置不正确,STM32F107没有独立的BOOT1引脚,需要通过Boot0和Boot1的组合来设置启动模式,如果你用的是系统存储器启动模式,CANbootloader可能根本没有执行;二是跳转条件不满足,我在bootloader中通常会在main函数开头检查一个升级标志位,如果没有升级请求就直接跳转到App,如果你发现无论如何都进不了bootloader,先检查这个标志位的读取逻辑。

int main(void) { // 初始化CAN、时钟等 // 检查是否需要进入升级模式 if (CheckUpgradeFlag() == UPGRADE_REQUEST) { // 进入bootloader主循环 Bootloader_MainLoop(); } else { // 跳转到App JumpToApp(); } }

很多人调试时容易忽略这一点,以为只要烧录了bootloader就一定能进入bootloader模式,实际上没有升级标志的话,bootloader跑一圈就直接跳App了,从外部看起来就像不存在一样。

5.2 跳转到App后程序跑飞

跳转后程序跑飞,我在调试中最容易遇到的情况就是中断向量表没有正确设置。解决思路是:在App工程中确认VTOR已设置;用调试器单步执行,看跳转后第一条指令是否从0x08004000取的是栈顶地址。

这里贴一个标准的跳转代码,里面包含了关键的处理步骤:

typedef void (*pFunction)(void); void JumpToApp(void) { uint32_t appStackAddr = *(volatile uint32_t *)APP_BASE_ADDR; pFunction appEntry = (pFunction)*(volatile uint32_t *)(APP_BASE_ADDR + 4); // 检查栈顶地址是否合法(RAM范围) if ((appStackAddr & 0x2FFE0000) != 0x20000000) { return; // 栈顶地址异常,说明没有有效的App } // 关闭全局中断 __disable_irq(); // 设置主栈指针 __set_MSP(appStackAddr); // 跳转到App的复位向量 appEntry(); }

注意末尾的appEntry()是一个不会返回的函数调用,跳转后bootloader的代码就彻底放弃执行了。另外一定要在跳转前关闭所有外设中断,否则中断挂在旧的中断处理函数上,App启动时一旦触发就会跑飞。

5.3 CAN通信正常但数据写不进Flash

这种情况比较隐蔽,表现为上位机发送数据后bootloader有应答,但重新读Flash发现数据不对。我遇到过的原因有两个:

Flash没有解锁。STM32F107的Flash默认是锁定的,直接写操作会被忽略,必须按照前面提到的解锁序列写入两个密钥值。

写入地址越界。如果上位机发的地址不小心超出了App区的范围(比如把Bootloader区的地址也传给了bootloader),写入操作会看到硬件错误。务必在bootloader里加地址范围检查,一旦发现目标地址落在Bootloader区直接拒绝写入。

还有一个我长期保养的习惯:每次写完一页Flash后,立刻把这页读回来做一次回读校验,和原始数据比对。这一动作在调试阶段能帮你快速定位问题,不至于等到最后校验失败时再大海捞针。

5.4 波特率自动识别失败

波特率自适应功能我实现过一版,核心思路是配置CAN外设的硬件过滤器,让它接收所有帧,然后在每次接收中断里判断当前帧是否包含合法的命令字。如果收到非法数据,就切换波特率重新尝试。实测发现,在总线上有持续通信时,波特率识别很快,一两秒钟内就能锁定;但如果总线空闲,上位机一直不发数据,自适应功能就等于摆设。

所以我在实际项目中改成了半自动方式:Bootloader默认尝试一个常用波特率列表(500K、250K、125K、1M),每个波特率等待0.5秒,如果收到上位机的握手请求就锁定当前波特率;如果列表轮询完毕还没握手成功,则保持最后一个波特率进入循环监听。这种方式在工程中稳定得多,也不会出现无限期的扫描等待。

6. 针对当前方案的实际优化建议

6.1 增加固件加密与签名认证

当你的设备部署在用户现场,通过CAN总线升级固件时,任何人都能接入总线发送伪造的升级包。一旦有人逆向出你的协议格式,就能往设备里灌入恶意固件,后果不堪设想。

我的建议是在bootloader中增加CRC32校验的基础上,再叠加AES-128或AES-256加密。具体做法是:上位机在发送固件前先用AES密钥对固件做加密,bootloader收到后先解密再写入Flash。密钥可以预烧录在芯片的选项字节区或者使用芯片唯一的UID参与运算,确保每个设备的密钥不同。这个方案虽然不能做到绝对安全,但能挡住90%以上的恶意攻击场景。

6.2 增加App版本回退机制

升级过程中由于意外断电或者其他原因导致App固件不完整时,bootloader通过前面提到的升级标志判断,停在bootloader模式等待重新升级。但如果新固件本身就存在bug(比如逻辑错误),即使升级成功了,设备也可能运行异常。

更稳妥的做法是保留两个App区:一个存放当前正在运行的稳定版本,另一个存放新升级的版本。Bootloader默认执行稳定版本,升级时先把新固件写入待升级区,重启后尝试运行新固件,如果新固件在启动自检时上报异常,bootloader自动回退到稳定版本。这个方案需要Flash容量足够(建议512KB以上型号),但带来的维护便利性非常值得。

6.3 日志记录与远程诊断

最后补充一个我在现场维护过程中觉得很有用的功能:在bootloader中增加一个简单的日志记录机制。把每次升级操作的时间、帧数、失败原因等关键信息写入Flash的独立扇区。当设备出现问题时,可以通过CAN总线把这些日志读出来,快速定位是上位机问题、总线问题还是设备Flash问题。

这个日志功能占用资源很少,一个扇区循环写入就够,但排查问题时比任何调试工具都直观。我有一次在现场排查设备离线问题,就是靠bootloader日志发现是上位机发送的帧序号跳变导致固件写入错乱,而不是总线硬件故障。

CANbootloader这个方案,本质上解决的是“如何安全、可靠、低成本的更新嵌入式设备固件”这个普适性问题。我在多个项目中体会到,bootloader设计的好坏直接决定产品后期的维护成本。如果你正打算给基于STM32F107的产品加上CAN升级功能,我的建议是从最小可用版本开始,先实现握手、擦除、写入、跳转这几步,跑通之后再逐步加加密、双备份、日志这些进阶功能。视频或教程不可能告诉你每个细节,真正深入理解这套机制,还是需要自己动手把板子跑起来,哪怕只是简单的点灯和CAN回环测试,也会让你少走很多弯路。

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

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

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

立即咨询