STM32F103 HD CAN Bootloader:从Flash分区到固件升级实战
简介该CANbootloader围绕STM32F103_HD芯片提供完整的CAN总线固件升级方案主要面向嵌入式软件开发者、汽车电子与工业自动化工程师。它无需连接JTAG或SWD调试口即可完成程序更新特别适合现场设备维护和远程批量升级场景。压缩包共359个文件包含145个h头文件、133个c源文件及72个s汇编文件另有Keil工程文件uvprojx/uvoptx、说明文档和1个exe工具大小约1.27MB目录分类清晰便于阅读。目前已有423位开发者学习/下载。工程覆盖boot启动、CAN报文接收与解析、CRC完整性校验、安全跳转逻辑、Flash擦写与传输加密等核心环节同时涉及错误处理、多设备同步更新和固件版本管理思路配合ST标准外设库代码可帮助用户快速上手STM32的HAL/LL库开发也是理解CAN协议栈与Bootloader机制的一份可运行参考工程。 从车间回来的时候工具包里那块VET6最小板又被我插在了总线转接器上旁边的同事瞅了一眼问这板子怎么又变了个程序我说走的CAN没拆盖子没插串口三分钟写完直接复位就跑起来了。这就是我给不少产线设备配的CANbootloader方案今天就把这套东西完整拆开讲一遍。这个包名为 CANbootloader_stm32f103_HD 的方案面向的正是 STM32F103 高密度HDHigh-Density型号的CAN在线升级引导程序覆盖从 Flash 分区、CAN 通信协议、Bootloader 跳转到 App 侧配置的一整套工程实践。如果你手头设备已经量产外壳封死没预留 SWD 和串口却留着两根 CAN 线——这篇文章基本就是给你准备的。1. 不谈理想先谈现场为什么固件升级必须走CAN1.1 没有串口的设备CAN是唯一选择很多做嵌入式开发的朋友第一次听到 CAN bootloader 的第一反应是串口 bootloader 不香吗香但在真实的生产环境里你未必有机会用到它。我接手过好几个项目控制器装在配电柜、电机仓、车辆底盘里机箱封装完成之后为了防水防尘外壳上根本不留串口插座只引出一对 CAN_H / CAN_L。产品的升级需求却一直存在客户改个参数、修个 bug、加个协议总不能把设备拆开用烧录器吧。此时 CAN 总线作为设备本来就有的、物理上已经存在的通信通道成了唯一可行的升级入口。另外一个因素是传输距离与可靠性。CAN 差分信号能支持几十米以上的距离而且抗干扰能力强在车间现场比 UART 踏实得多。现场工人只需要把 USB 转 CAN 的工具接到总线上从笔记本跑一次升级程序不用接触任何带电部分连盖子都不用拆。1.2 HD型号不是大F103那么简单先厘清一个概念。STM32F103 按 Flash 容量划分了低密度 LD、中密度 MD、高密度 HD、超高密度 XL 几档。HD 指高密度典型型号是 STM32F103VET6512KB Flash / 64KB SRAM、ZET6、RCT6256KB / 48KB。和 C8T6中密度64KB / 20KB相比HD 不光是容量翻几倍Flash 页大小也不一样。这个差异直接影响了 Bootloader 的擦写逻辑。中密度 F103 的 Flash 页大小为 1KB高密度是 2KB。代码里如果写死了页大小或者从网上扒的例程没注意密度差异擦除地址就能偏到天上去。另外 HD 型号有更宽的可配置 Flash 扇区数量和中断向量数Boot 区与 App 区的划分空间也更从容。所以当你看到 HD 这个后缀时需要先确认目标芯片是高密度再谈后续的 Flash 分区不然整个工程的运行行为都不成立。2. 双区启动框架从Flash分区到跳转判定2.1 地址分配Boot区和App区怎么切做 Bootloader 的第一步就是把整个 Flash 划分成两块各管各的。我的习惯是Boot 区 16KBApp 区从 0x08004000 开始剩下全部给 App。对于 HD 型号Flash 起始地址 0x08000000每页 2KB16KB 正好是 8 页地址边界也整齐。规划如下区间起始地址大小用途Boot 区0x0800000016KBBootloader 程序、向量表App 区0x08004000剩余应用固件、应用向量表参数区可选Flash 最后一页2KB版本号、升级标志等Bootloader 自己的向量表位于 0x08000000这是芯片复位后默认执行的位置。App 向量表位于 0x08004000在 App 运行之前必须让 CPU 知道中断向量不在原来的地方了这部分后面会专门讲。2.2 进入Boot的三种姿势Bootloader 启动后得决定自己是直接跳 App 正常运行还是停留在 Boot 等待升级。没有这个判断每次上电都等上位机设备就没法独立工作了。我常用的进入 Boot 方式有三种按键检测上电时读 GPIO按住则进入 Boot否则直接跳 App。适合开发调试量产现场不好按。SRAM 标志法推荐App 内部在收到升级指令后向 SRAM 固定地址写入一个约定的魔数然后软复位。Boot 启动时读这个地址如果魔数匹配就清除标志并停留在 Boot。超时等待法上电后 Boot 先等几秒 CAN 握手信号等到了就升级等不到就跳 App。这个方式不依赖外部硬件但每次上电都会有延迟现场未必接受。最实用的是SRAM 标志 超时等待组合。App 要求升级时写标志软复位Boot 看到标志直接停留 30 秒等待升级如果没有标志Boot 只等 1 秒收不到就立刻跳 App。这样调试阶段快速响应量产阶段也看不出明显延迟。SRAM 标志要选一个项目里绝对用不到的地址比如 0x20000000 0x0FF0 这种 SRAM 末尾附近的区域。写和读都用 volatile 指针防止编译器优化掉。2.3 App工程里必须做的两处设置很多人 Bootloader 都写对了跳转却失败其实是 App 工程没配好。App 侧有两个设置是强制性的第一是链接地址。Keil 里打开 Options for Target - Target 选项卡把 IROM1 的 Start 改为 0x08004000Size 改为 0x7C000如果 Flash 是 512KB与 Boot 分区对齐。改完之后可以编译看一下 Map 文件确认复位向量被链接到了 0x08004000 附近。第二是向量表偏移。在 App 工程的 system_stm32f10x.c 中定义 VECT_TAB_OFFSET 宏#define VECT_TAB_OFFSET 0x00004000这样 SystemInit 里会执行SCB-VTOR FLASH_BASE | VECT_TAB_OFFSET;把中断向量表基地址指到 0x08004000。不设这个App 一旦开中断CPU 会去 0x08000000 取中断向量找到的是 Boot 的向量表轻则中断响应错乱重则直接 HardFault。3. CAN总线通信协议设计3.1 帧ID、命令码、应答码的定义CAN 通信协议是整个 Bootloader 的灵魂定得太复杂调试累定得太草率传输不可靠。我用的是标准帧11 位 ID数据段固定 8 字节。协议上设计了三种类型的 CAN 帧帧方向CAN ID用途上位机 - 设备0x100命令帧握手、擦除、跳转上位机 - 设备0x101数据帧固件内容设备 - 上位机0x200应答帧状态、ACK/NAK命令帧的第一个字节表示命令码这样命令和数据走不同的 IDCAN 滤波器配置更干净。常用命令码定义如下#define CMD_HANDSHAKE 0x01 // 握手请求 #define CMD_ERASE 0x02 // 擦除指定范围 #define CMD_DATA 0x03 // 数据帧 #define CMD_CRC_CHECK 0x04 // 校验请求 #define CMD_JUMP_APP 0x05 // 跳转 App #define CMD_GET_VERSION 0x06 // 获取版本信息应答帧里第一个字节填收到的命令码第二个字节填状态0x00 表示成功0x01 表示失败0x02 表示忙。3.2 7字节8字节分包策略CAN 数据段最大 8 字节。如果一帧全部塞固件内容帧序号就没地方放了。所以数据帧我采用这样分配字节0帧序号从 1 开始取低 8 位字节1-7固件数据共 7 字节8 变 7一帧少了 1 字节但换来的是帧序号和数据的自描述接收端可以根据序号自动判断丢帧不用依赖总线时序。对于一个几十 KB 的固件多传输的帧数完全可接受。序号按 256 循环是常见做法。接收端维护一个 last_seq收到的序号不等于 last_seq 1就说明中间丢帧了。还有一种更省事的方案是不用序号依赖底层 ACK 和重传但现场总线环境复杂我建议序号一定保留。3.3 超时重传与防误更新CAN 总线存在丢帧的可能尤其在电磁环境复杂的车间。协议必须有超时重传机制。我定的规则如下上位机每发送一帧后等待设备 ACK超时 100ms 重发上位机连续重发 5 次仍无 ACK停止传输并报告错误设备每收到一帧向 0x200 回一帧 ACK内容是帧序号这样上位机能知道设备收到第几帧未收到的帧可以精准补发。防误更新也不能忽略。升级指令不能仅仅靠一帧握手命令触发避免总线上其他设备误操作。我的做法是上位机先发一个固定 4 字节魔数比如 0xCA 0xFE 0xBA 0xBE设备回应 OK 后才算进入升级流程。这个魔数通常配合 App 侧的更新标志一起使用两头都堵住。4. 核心代码剖析4.1 CAN初始化和滤波器处理Boot 阶段的 CAN 配置和 App 是独立的因为 Boot 只接收升级相关的 ID不需要处理业务报文。初始化要点是 CAN 时钟使能、GPIO、波特率和滤波器。void CAN_Init_bootloader(void) { 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_DeInit(CAN1); CAN_StructInit(CAN_InitStructure); CAN_InitStructure.CAN_TTCM DISABLE; CAN_InitStructure.CAN_ABOM ENABLE; CAN_InitStructure.CAN_AWUM ENABLE; CAN_InitStructure.CAN_NART ENABLE; CAN_InitStructure.CAN_RFLM DISABLE; CAN_InitStructure.CAN_TXFP ENABLE; CAN_InitStructure.CAN_Mode CAN_Mode_Normal; CAN_InitStructure.CAN_SJW CAN_SJW_1tq; CAN_InitStructure.CAN_BS1 CAN_BS1_13tq; CAN_InitStructure.CAN_BS2 CAN_BS2_2tq; CAN_InitStructure.CAN_Prescaler 9; // 36MHz / (9 * 16) 250kbps 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); }波特率这里解释一下。F103 的 CAN 外设挂载在 APB1 上系统时钟 72MHz 时APB1 最大 36MHz这个 36MHz 是 CAN 的输入时钟。我采用 16 个时间片量化一个位同步段 1Tq BS1 13Tq BS2 2Tq分频系数 9算下来36MHz / (9 * 16) 250kbps。如果你用的系统时钟不是 72MHz必须重新配分频系数这是很多移植现场出问题的根源。滤波器也可以按需只放行 0x100 / 0x101 / 0x200 这几个 ID简化的话直接全部放行也可以boot 阶段总线上没有业务流量风险不大。4.2 Flash擦写页大小、解锁、边擦边写高密度 F103 的每页是 2KB写可以按 16 位半字进行。擦写 Flash 的第一步是解锁然后操作最后记得加锁。下面是一个擦除 App 区的示例#define APP_START_PAGE_ADDR 0x08004000 #define FLASH_HD_PAGE_SIZE 0x800 // 2KB void Erase_App_Area(void) { uint32_t addr; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (addr APP_START_PAGE_ADDR; addr FLASH_MAX_ADDR; addr FLASH_HD_PAGE_SIZE) { if (FLASH_ErasePage(addr) ! FLASH_COMPLETE) { // 擦除失败记录错误并停止 break; } } FLASH_Lock(); }写入固件时我习惯先把上位机发来的数据攒满 8 字节再调用 Flash 写半字函数。F103 的标准库提供 FLASH_ProgramHalfWord写入时要注意地址对齐和 Flash 忙等待void Flash_WriteData(uint32_t addr, uint8_t *data, uint16_t len) { uint16_t i; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i 0; i len; i 2) { uint16_t half data[i] | ((uint16_t)data[i 1] 8); FLASH_ProgramHalfWord(addr i, half); while (FLASH_GetFlagStatus(FLASH_FLAG_BSY) SET); } FLASH_Lock(); }这里要强调一个很隐蔽的问题Flash 写操作期间如果代码正在从 Flash 取指并且恰好要对同一块 Flash 区域进行读写总线会互相冲突。Bootloader 本身运行在 0x08000000 到 0x08003FFF而 App 区从 0x08004000 开始所以 Boot 代码执行时擦写 App 区指令预取和 Flash 编程冲突的概率比较低实测下来比较稳定。但为了保证极端情况下的可靠性特别是中断频繁的场景更好的做法是把 Flash 编程函数放到 RAM 中执行Keil 里用attribute((section(.ramfunc))) 标记即可。这是我发正式版本时一定会做的保险。4.3 跳转与向量表重定向从 Boot 跳转到 App核心步骤是验证 App 栈顶地址合法取 App 复位向量设置 MSP跳转。代码长这样typedef void (*pFunction)(void); #define APP_BASE_ADDR 0x08004000 #define SRAM_BASE_ADDR 0x20000000 #define SRAM_END_ADDR 0x20010000 // 64KB SRAM void Jump_To_App(void) { uint32_t app_stack *(volatile uint32_t *)APP_BASE_ADDR; pFunction app_entry; if ((app_stack SRAM_BASE_ADDR) (app_stack SRAM_END_ADDR)) { app_entry (pFunction)(*(volatile uint32_t *)(APP_BASE_ADDR 4)); __disable_irq(); // 关闭所有外设时钟避免 App 初始化时出现未预期的状态 RCC_DeInit(); __set_MSP(app_stack); app_entry(); } }跳转前关闭中断和 RCC 复位很多人的跳转失败就栽在这里。RCC_DeInit 会把外设时钟全部关闭让 App 从干净状态启动。如果 App 启动后要读某些保留的老配置你要提前确认这个动作不会造成数据丢失。向量表重定向我们前面已经讲过在 App 侧用 SCB-VTOR 设置偏移。但在实际项目里还会出现一种情况App 的 main 函数执行前SystemInit 已经调用了 SCB-VTOR但有些芯片的核心版本比较老需要再确认一下。标准库的实现是兼容的ST 在 system_stm32f10x.c 里就是这样写的所以你在 F103 HD 上可以放心使用。如果你用的是非常老的芯片批次踩到 VTOR 无效的坑可以退一步用复制向量表到 SRAM 的老办法但普通用户完全不用纠结这条路用 SCB-VTOR 就够。4.4 校验收尾传输完成后必须有一个完整性校验否则下载一个被截断的 bin 到设备上跑起来可能问题百出。文件校验我用 CRC32。F103 内置了 CRC 外设可以直接用uint32_t CRC32_Calculate(uint32_t *buf, uint32_t len) { uint32_t i; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_CRC, ENABLE); CRC_ResetDR(); for (i 0; i len; i) { CRC_CalcCRC(buf[i]); } return CRC_GetCRC(); }上位机先算出整个固件的 CRC32然后在最后一帧之后发送 CMD_CRC_CHECKBootloader 从 Flash 读出已写入内容重新计算 CRC比对结果。一致返回 0x00不一致返回 0x01上位机看到失败可以重新传输。千万别省这一步我见过省了 CRC 然后设备跑两小时才暴露问题的案例排查成本远大于加一个校验的计算量。5. 发版前的实测记录与常见坑5.1 向量表偏移的坑这个坑我认为值得再深入一点。App 工程如果只改了 IROM1 而没有改 VECT_TAB_OFFSET下载后按下复位程序看起来是正常的但一发生中断就会跑飞。原因是中断发生时 CPU 去 0x08000000 找向量表那里放的是 Boot 的复位向量和中断向量App 的中断处理函数地址根本不在里面。现象很诡异主循环跑得好好的按一下按键开了外部中断直接 HardFault。排查步骤可以这样走先确认 App 的 Map 文件里 0x08004000 处是否放上了正确的初始 SP再确认 SystemInit 里 SCB-VTOR 是否写入了 0x08004000最后再查中断是否真的能触发。用调试器读 SCB-VTOR 的实际值是最直接的证据。5.2 波特率匹配和时钟源很多移植者把 Boot 程序下载进去之后发现上位机根本连不上。问题大多出在波特率不匹配。Boot 里的 CAN 波特率是根据系统时钟 72MHz 计算出来的如果你的板子用的是外部晶振 8MHz那没问题但如果你用了内部 HSI 8MHz 作为 PLL 输入又没配好分频实际系统时钟根本不是 72MHzCAN 波特率就会偏差很大。有一个挺隐蔽的小窍门Bootloader 上电后不要一上来就把 PLL 跑到 72MHz可以先跑在 8MHz 内部时钟此时 CAN 总线直接用 8MHz 计算出一个较低的波特率握手。测试时很多问题出在 Boot 的初始化顺序上。正确做法是先把系统时钟配置到额定值再去配置 CAN两个都是稳定状态这样参数才可复现。我在 Boot 里加了系统时钟自检上电后把 SYSCLK 和 PCLK1 的实际值读出来用于计算波特率保证任何时钟配置下都能匹配上位机实测下来省了很多事。5.3 中断与看门狗跳转前处理不干净中断App 启动后就可能受干扰。我见过一个现象Boot 阶段开了 CAN 接收中断收到一帧后跳转App 还没把中断向量表切过来这帧请求触发了中断CPU 走错向量表直接卡死。标准的处理是跳转前 __disable_irq()App 的 SystemInit 和 main 初始化完成后再 __enable_irq()。如果 App 在 main 一开始就开中断你要确保向量表偏移已经在 SystemInit 阶段设好顺序不能反。看门狗是另一个高发问题。Bootloader 如果使能了独立看门狗 IWDG跳转前又没关App 启动后没有在超时时间内喂狗整个系统就会不断复位看起来就是永远无法进入升级流程或者升级完成后不停重启。我的策略是Boot 阶段不开启看门狗把喂狗责任完全交给 App。如果产品本身要求 Boot 阶段也看门那跳转前必须把看门狗的状态通过一个全局变量或者 SRAM 标志传递给 AppApp 启动后立即接管喂狗。没有这个交接必翻车。5.4 一套完整的测试流程建议在正式发板之前我建议按以下顺序做一轮回归测试首次烧录用 SWD 直接把 Bootloader 烧进芯片上电后不放 App看 Boot 能否通过 CAN 握手。最小跳转测试用一个小点灯程序作为 App编译时把 IROM1 改到 0x08004000确认 Boot 跳转后 App 能跑。中断完整性测试在 App 里开一个定时器中断确认中断向量表偏移正确。升级全流程测试正常模式运行 App通过业务命令触发更新标志软复位后停留在 Boot使用上位机完成擦除、下载、校验、跳转。异常场景测试传输到一半拔掉 CAN 线重新连接后看重传和恢复是否正常发送一个 CRC 错误的 bin确认设备不会跳转重复升级 10 次确认 Flash 擦写稳定。最近一次给一个成套设备做升级工人不小心把 CAN 总线接反了Boot 程序愣是没进更新流程反而是总线错误处理把设备拉回 App 正常跑起来。所以说把 ABOM自动离线恢复打开、把重传机制做好真不是多此一举现场的问题永远比你想象的更离谱。这套 CAN Bootloader 方案我现在已经用在了三个量产项目上包括一个 96KB 的固件、一个 140KB 的固件和一个小型 bootloader 自身远程升级的验证板。如果后续你需要在 Boot 里加多协议支持或者加入Boot 远程升级 Boot的功能思路也是顺着这套框架延伸关键仍然是把 Flash 分区、中断向量、通信握手这三件事想清楚。本文还有配套的精品资源点击获取