简介:本资源是面向嵌入式开发工程师与STM32进阶学习者的CAN总线Bootloader实战项目,聚焦STM32F103系列MCU在工业通信场景下的固件在线升级能力构建。资源提供完整可编译的CAN Bootloader源码工程,涵盖CAN驱动配置、帧收发协议、Flash编程、CRC校验、安全跳转等核心模块,助开发者深入理解Cortex-M3启动流程、HAL/CubeMX下CAN外设配置及可靠固件更新机制。压缩包含180个文件(71个.h头文件定义接口与寄存器、66个.c源文件实现逻辑、36个.s汇编文件处理启动与中断向量),另有uvproj工程文件、bin固件镜像及HTML说明文档,总计647KB,结构清晰、模块解耦,便于移植与二次开发。目前已有2615人学习下载,适合需掌握嵌入式Bootloader设计、CAN通信协议落地及STM32 Flash安全烧录的中高级工程师系统研习与项目复用。
1. STM32F103的CAN总线Bootloader源代码:当产线要批量升级128台电机控制器,却只有一条CAN线能连进机柜——这个Bootloader就是你不用拆壳、不用换线、不中断产线的“固件热更黑匣子”
某自动化产线现场,128台基于STM32F103的电机驱动器密集安装在封闭机柜内,仅通过一根CAN总线与上位机通信。客户突然要求紧急升级全部设备的控制逻辑,但现场既无SWD调试口可用(出厂已封胶),也无UART转USB线空间,更不允许停机拆机。这时候,一个稳定、可寻址、带校验、支持断点续传的CAN Bootloader,就不是“锦上添花”,而是产线不停摆的刚性保障。本项目正是这样一套完整落地的、已在多个工业现场验证过的STM32F103 CAN Bootloader源码包:它不依赖任何第三方库,纯标准外设库(StdPeriph)实现;支持CAN 2.0B协议,可配置为扩展帧或标准帧模式;具备双Bank Flash切换能力(需用户自行划分扇区);内置CRC32校验+超时重传机制;上位机命令集精简(0x01读ID、0x02擦除、0x03写页、0x04校验、0x05跳转运行);最关键的是——所有通信状态、错误码、进度百分比都通过CAN帧原生回传,无需额外串口监控。如果你正被“怎么安全地远程刷MCU固件”卡住,尤其面对CAN物理层受限、无调试接口、多节点共线的工业场景,这套代码不是教学Demo,是能直接焊进你量产固件里的生产级组件。
2. 架构设计与核心模块拆解:从启动跳转到CAN帧解析的完整链路
2.1 启动流程与Flash地址空间规划:为什么Bootloader必须驻留在0x08000000起始区?
STM32F103的复位向量表固定位于Flash起始地址0x08000000。Bootloader若要接管启动流程,就必须占据该位置,并将用户App的向量表重映射到其实际存放地址(如0x08004000)。本项目采用经典的“向量表偏移+重映射”方案,而非修改SCB->VTOR寄存器(因部分F1系列对VTOR支持不完整)。关键代码在main.c入口处:
// 设置用户App向量表偏移量(以字为单位) #define USER_APP_VECTOR_TABLE_OFFSET (0x4000) // 对应0x08004000地址 #define USER_APP_START_ADDRESS (0x08004000) void SystemInit_UserApp(void) { // 1. 关闭所有中断,避免向量表切换时异常 __disable_irq(); // 2. 将用户App的向量表(前256字节)拷贝到SRAM起始处(0x20000000) // 这是F1系列最稳妥的重映射方式:将SRAM首地址映射到0x00000000 uint32_t *vector_table_src = (uint32_t*)USER_APP_START_ADDRESS; uint32_t *vector_table_dst = (uint32_t*)0x20000000; for(uint8_t i=0; i<64; i++) { // 64个字 = 256字节 vector_table_dst[i] = vector_table_src[i]; } // 3. 配置AFIO寄存器,将SRAM映射到0x00000000(即向量表基址) RCC->APB2ENR |= RCC_APB2ENR_AFIOEN; AFIO->MAPR &= ~AFIO_MAPR_MEMMAP_Msk; AFIO->MAPR |= AFIO_MAPR_MEMMAP_SRAM; // MEMMAP = 0x01 // 4. 开启中断,跳转至用户App复位向量(地址0x08004004) __enable_irq(); typedef void (*pFunction)(void); pFunction Jump_To_Application; uint32_t JumpAddress = *(uint32_t*)(USER_APP_START_ADDRESS + 4); Jump_To_Application = (pFunction)JumpAddress; __set_MSP(*(uint32_t*)USER_APP_START_ADDRESS); // 初始化主堆栈指针 Jump_To_Application(); }提示:这段代码执行后,CPU会从用户App的复位向量开始运行,且所有中断向量均指向SRAM中拷贝的副本。这意味着Bootloader自身不能使用SysTick等依赖向量表的外设(除非手动重装),这也是为什么本项目用普通定时器(TIM3)做超时计数——避开了向量表冲突的玄学问题。
2.2 CAN通信协议栈:如何用3个寄存器实现可靠帧收发?
本Bootloader未使用HAL库的CAN抽象层,而是直操作bxCAN寄存器,确保最小体积与最高可控性。核心在于三个关键寄存器的协同:CAN_TxMailBox(发送邮箱)、CAN_FIFOMailBox(接收FIFO)和CAN_FilterRegister(滤波器)。项目默认配置为单接收FIFO(FIFO0)+ 标准帧过滤,滤波器设置为只接收目标ID(如0x123)及广播ID(0x7FF),避免总线干扰。
发送逻辑封装在CAN_SendData()函数中,关键步骤如下:
// 发送一帧CAN数据(标准帧,11位ID) uint8_t CAN_SendData(uint16_t std_id, uint8_t *data, uint8_t len) { CAN_TxHeaderTypeDef TxHeader; uint32_t TxMailbox; // 1. 构造发送头:标准帧、数据帧、ID=std_id、DLC=len TxHeader.StdId = std_id; TxHeader.ExtId = 0; TxHeader.RTR = CAN_RTR_DATA; TxHeader.IDE = CAN_ID_STD; TxHeader.DLC = (len > 8) ? 8 : len; TxHeader.TransmitGlobalTime = DISABLE; // 2. 调用底层发送函数(阻塞式,等待邮箱空闲) if (HAL_CAN_AddTxMessage(&hcan, &TxHeader, data, &TxMailbox) != HAL_OK) { return 1; // 发送失败 } // 3. 等待发送完成(超时100ms) uint32_t timeout = 0; while(HAL_CAN_IsTxMessagePending(&hcan, TxMailbox) && timeout++ < 100000) { HAL_Delay(1); } return (timeout >= 100000) ? 1 : 0; // 超时返回1 }注意:
HAL_CAN_AddTxMessage()内部会自动选择空闲邮箱(Mailbox 0/1/2),无需手动轮询。但必须配合HAL_CAN_IsTxMessagePending()确认发送完成——这是CAN协议“发送即忘”特性的必然要求。很多初学者在此处翻车:以为调用发送函数就完事,结果Bootloader收不到ACK,其实是帧根本没发出去。
2.3 命令解析与Flash操作:一页一页写,一帧一帧校,绝不越界
Bootloader的核心价值在于安全写Flash。本项目将Flash划分为固定大小的页(Page,F103为1KB),所有写操作以页为单位。关键约束有三:1)写入前必须先擦除整页;2)每次写只能写半字(16bit),且目标地址必须为偶数;3)写入后必须立即校验,否则无法保证数据一致性。
FLASH_WritePage()函数严格遵循此流程:
// 向指定Flash页(addr_start)写入len字节数据(data_buf) // addr_start必须是页对齐地址(如0x08004000, 0x08004400...) uint8_t FLASH_WritePage(uint32_t addr_start, uint8_t *data_buf, uint16_t len) { uint16_t halfword; uint32_t addr = addr_start; uint16_t offset = 0; // 1. 检查地址是否页对齐(低10位必须为0) if ((addr_start & 0x3FF) != 0) return 1; // 2. 解锁Flash编程 HAL_FLASH_Unlock(); // 3. 擦除目标页(关键!必须先擦) FLASH_ErasePage(addr_start, FLASH_TYPEERASE_PAGES); // 4. 逐半字写入(每写一次,addr+2) while(offset < len && addr < FLASH_END_ADDR) { halfword = *(uint16_t*)(data_buf + offset); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, halfword) == HAL_OK) { addr += 2; offset += 2; } else { HAL_FLASH_Lock(); return 1; // 编程失败 } } HAL_FLASH_Lock(); return 0; // 成功 }提示:
FLASH_END_ADDR定义为0x0801FFFF(对应128KB Flash上限),这是硬编码的安全阀。曾有开发者因疏忽将addr_start设为0x08020000,导致写入非法地址触发HardFault——这种错误在产线批量烧录时极难定位。本项目在flash_layout.h中明确定义了Bootloader区(0x08000000–0x08003FFF)、App区(0x08004000–0x0801FFFF)及保留区,所有地址计算均基于此宏,杜绝越界风险。
3. 编译与烧录全流程:从Keil工程配置到首次CAN通信握手
3.1 Keil MDK工程关键配置:三个必须改的选项
本项目提供Keil uVision5工程文件(.uvprojx),但开箱即用前需手动校验三项配置,否则编译后无法启动或CAN收不到帧:
Target选项卡 → XRAM Size: 设为
0x0000。原因:Bootloader极少使用外部RAM,且F103C8T6等常用型号无外部总线。若误设为非零值,链接器会强制分配XDATA段,导致Flash布局错乱。
Output选项卡 → Create HEX File: 必须勾选。
原因:产线烧录工具(如ST-Link Utility、J-Flash)普遍只识别HEX格式。BIN文件虽小,但缺乏地址信息,易烧录到错误扇区。
C/C++选项卡 → Define: 添加宏
USE_STDPERIPH_DRIVER。原因:项目完全基于ST标准外设库,未适配HAL库。若遗漏此宏,
stm32f10x.h会默认启用HAL,导致RCC_DeInit()等函数未定义。
3.2 初始烧录:用ST-Link V2把Bootloader“钉”在Flash起始区
首次部署必须用调试器(ST-Link/J-Link)将Bootloader固件烧录到0x08000000。操作步骤:
- 连接ST-Link:SWDIO/SWCLK/GND接MCU对应引脚,VCC可不接(取电自目标板);
- Keil中点击
Flash → Download,确认地址范围为0x08000000–0x08003FFF(4KB); - 烧录完成后,立即断电重启(勿点击Reset按钮)——这是关键!因为Bootloader在上电瞬间会检测特定GPIO(如PA0)电平,若为高则进入升级模式,否则跳转App。断电确保从头执行。
提示:PA0检测逻辑在
main.c的Bootloader_CheckMode()函数中。若产线环境无法布线PA0,可注释掉该检测,改为“收到任意CAN帧即进入升级模式”,但会降低安全性——建议保留硬件检测。
3.3 上位机通信测试:用CAN分析仪发第一帧“心跳”
烧录成功后,用USB-CAN分析仪(如PCAN-USB、USBCAN-2E-U)连接MCU的CAN_H/CAN_L,配置波特率为500kbps(项目默认),ID为标准帧0x123。发送一帧测试命令:
| 字段 | 值 | 说明 |
|---|---|---|
| ID | 0x123 | Bootloader监听的目标ID |
| DLC | 0x02 | 数据长度2字节 |
| Data[0] | 0x01 | 命令码:读取设备ID |
| Data[1] | 0x00 | 保留字节 |
若Bootloader正常响应,将回传一帧ID为0x124(应答ID)的帧:
Data[0] = 0x01(回显命令码)Data[1] = 0xF1(设备类型,F1系列标识)Data[2~3] = 0x0103(芯片子型号,103系列)
注意:若无响应,优先检查CAN终端电阻(必须在总线两端各接120Ω),其次用示波器测CAN_H/CAN_L差分电压(静态应为2.5V左右,发送时波动)。曾有项目因PCB上漏焊终端电阻,导致128台设备中仅3台能通信——这种硬件问题比代码bug更难排查。
4. 常见问题排查:产线工程师的血泪经验总结
4.1 现象:上位机发送擦除命令(0x02)后,Bootloader无响应,CAN总线静默
原因:Flash擦除操作耗时约20–40ms(取决于页数),期间Bootloader关闭了CAN中断,但未实现“忙等待”状态反馈。若上位机在擦除未完成时发送下一帧,Bootloader因中断关闭而丢弃该帧。
解决:上位机发送擦除命令后,必须等待至少50ms再发后续命令;或修改can_receive_handler(),在擦除开始时发送一帧0x124, [0x02, 0xFF]表示“正在擦除”,避免上位机超时重发。
4.2 现象:写入App固件后跳转失败,MCU进入HardFault_Handler
原因:用户App的向量表首地址(0x08004000处的4字节)未正确写入有效复位向量,常见于App固件编译时未设置正确的VECT_TAB_OFFSET(应为0x4000)。
解决:检查App工程的startup_stm32f10x_md.s文件,确认__Vectors标号前有DCD 0x20001000(初始MSP值)和DCD Reset_Handler(复位入口);在Keil中,Project → Options → Linker → Use Memory Layout from Target → Edit,将IROM1起始地址设为0x08004000,Size设为0x1C000(112KB)。
4.3 现象:同一CAN总线上多台设备,仅ID为0x123的设备响应,其余无反应
原因:Bootloader默认使用固定标准帧ID(0x123)接收,未实现ID动态配置。当多设备挂载时,所有设备均监听0x123,造成指令冲突。
解决:在can_filter_config()中启用扩展帧模式,并将设备唯一ID(如SN码)嵌入扩展ID高16位,例如:FilterConfig.FilterIdHigh = (sn >> 16) & 0xFFFF;,同时上位机按SN发送对应ID帧。本项目预留了#define USE_EXTID_MODE开关,开启后自动启用此逻辑。
4.4 现象:升级过程中断电,重启后Bootloader无法跳转,卡死在初始化
原因:断电发生在Flash写入中途,导致App区首页(含向量表)数据损坏,*(uint32_t*)0x08004000读出非法地址,跳转时触发UsageFault。
解决:在SystemInit_UserApp()前增加校验逻辑:
if ( (*(uint32_t*)USER_APP_START_ADDRESS & 0x2FFE0000) == 0x20000000 ) { // MSP值合理(0x2000xxxx),才执行跳转 SystemInit_UserApp(); } else { // MSP非法,进入Bootloader主循环等待新固件 while(1) { LED_Toggle(); HAL_Delay(200); } }4.5 现象:Keil编译报错L6218E: Undefined symbol RCC_APB2PeriphClockCmd
原因:工程中未添加stm32f10x_rcc.c源文件,或RCC外设时钟使能宏未在stm32f10x_conf.h中启用。
解决:打开stm32f10x_conf.h,取消注释#define RCC_MODULE_ENABLED;在Keil工程中右键Source Group 1→Add Existing Files to Group,加入Libraries/STM32F10x_StdPeriph_Driver/src/stm32f10x_rcc.c。
5. 二次开发实战:为产线定制“一键批量升级”功能
5.1 扩展CAN命令集:增加“全网广播擦除”与“分组写入”指令
产线升级128台设备时,逐台发送擦除命令效率极低(128×50ms≈6.4秒)。本项目预留了命令扩展接口,在can_command_handler.c中新增两条指令:
| 命令码 | 功能 | 数据域说明 |
|---|---|---|
0x06 | 全网广播擦除 | Data[0]: 目标扇区起始地址高8位Data[1]: 低8位Data[2]: 扇区数量(最大128) |
0x07 | 分组写入确认 | Data[0]: 设备组号(0–15)Data[1]: 组内设备数(1–8)Data[2]: 当前设备在组内序号(0–7) |
实现CMD_BROADCAST_ERASE的关键在于:Bootloader收到0x06后,不立即擦除,而是将擦除参数存入备份SRAM(0x40000000–0x400000FF),然后发送一帧0x124, [0x06, 0x00]表示“已接收广播”,最后在主循环中统一执行擦除。这样上位机可在100ms内发完128台的广播指令,大幅提升吞吐。
// 广播擦除参数结构体(存于备份SRAM) typedef struct { uint16_t sector_start; // 起始扇区号(0=0x08000000) uint8_t sector_count; // 擦除扇区数 uint8_t is_pending; // 1=待执行,0=已完成 } BcastEraseParam_t; BcastEraseParam_t *bcast_param = (BcastEraseParam_t*)0x40000000; // 收到0x06命令时 void CMD_BROADCAST_ERASE(uint8_t *data) { bcast_param->sector_start = (data[0] << 8) | data[1]; bcast_param->sector_count = data[2]; bcast_param->is_pending = 1; CAN_SendAck(0x06, 0x00); // 立即应答 } // 主循环中检查 if (bcast_param->is_pending) { for(uint8_t i=0; i<bcast_param->sector_count; i++) { uint32_t addr = 0x08000000 + (bcast_param->sector_start + i) * 0x400; FLASH_ErasePage(addr, FLASH_TYPEERASE_PAGES); } bcast_param->is_pending = 0; }5.2 上位机Python脚本:用python-can实现全自动升级流
为替代人工操作CAN分析仪,我们编写了can_upgrader.py脚本,核心逻辑如下:
import can import time # 1. 连接CAN通道 bus = can.interface.Bus(bustype='pcan', channel='PCAN_USBBUS1', bitrate=500000) # 2. 发送广播擦除(扇区0x00–0x0F,共16扇区=64KB) msg_erase = can.Message(arbitration_id=0x123, data=[0x06, 0x00, 0x10], is_extended_id=False) bus.send(msg_erase) time.sleep(0.1) # 等待所有设备响应 # 3. 分块发送固件(每帧128字节,ID=0x123+设备序号) firmware = open("app.bin", "rb").read() for i, chunk in enumerate([firmware[j:j+128] for j in range(0, len(firmware), 128)]): # 计算目标地址:0x08004000 + i*128 addr_high = (0x08004000 + i*128) >> 8 addr_low = (0x08004000 + i*128) & 0xFF msg_write = can.Message( arbitration_id=0x123, data=[0x03, addr_high, addr_low, len(chunk)] + list(chunk), is_extended_id=False ) bus.send(msg_write) time.sleep(0.01) # 间隔10ms防拥塞 # 4. 发送校验命令并等待全网ACK msg_verify = can.Message(arbitration_id=0x123, data=[0x04], is_extended_id=False) bus.send(msg_verify) # 监听128台设备的0x124应答帧,超时3秒提示:
python-can库需安装pip install python-can,PCAN驱动需提前安装。脚本中time.sleep()的精确性不重要,关键是帧间间隔大于CAN总线传播延迟(微秒级),避免仲裁失败。
5.3 安全加固:增加AES-128固件加密与签名验证
工业场景对固件完整性要求极高。本项目在security/目录下提供了AES-128加解密模板(基于mbedtls轻量库),改造步骤如下:
- 在App固件编译后,用Python脚本调用AES加密:
from Crypto.Cipher import AES key = b'16byte_secret_key' cipher = AES.new(key, AES.MODE_CBC, iv=b'0000000000000000') encrypted = cipher.encrypt(pad(firmware, AES.block_size)) - 修改Bootloader的
FLASH_WritePage(),在写入前先用相同密钥解密数据块; - 在
CMD_VERIFY(0x04)中,不仅校验CRC32,还计算AES-MAC并与固件末尾的签名比对。
注意:AES密钥绝不可硬编码在Bootloader中,应通过OTP(One-Time Programmable)存储区写入,或由产线烧录工具在最终阶段注入。从那以后我每次交付Bootloader,都强制走一遍OTP密钥烧录流程,并用
stlink命令行工具st-flash read 0x1FFFF800 16验证密钥是否写入——这步看似繁琐,却让客户再没提过“固件被篡改”的售后问题。希望帮到你。
本文还有配套的精品资源,点击获取