☰
STM32F107 CAN Bootloader IAP固件升级工程拆解
2026/11/5 4:12:02 网站建设 项目流程

简介:面向使用STM32F107进行工业控制、汽车电子与物联网设备开发的嵌入式工程师,提供一套可在CAN总线上完成固件远程更新的完整实现,重点解决设备现场免拆机升级与后期维护难题,适合已具备Cortex-M3与CAN基础的中级开发者参考。整包约75.36MB、共2000个文件,以630个c源文件与317个头文件构成Bootloader和APP主体代码,另有87个汇编启动文件、28个分散加载icf配置及若干Keil工程文件(uvprojx/uvoptx),并配套28个txt说明、图莫斯CAN盒相关工具与exe程序,便于直接编译、烧录与联调。资源附带《CAN刷写步骤说明》类文档,梳理了硬件连接、CAN帧格式、波特率配置到固件校验、Flash擦除写入的完整链路;Bootloader与APP工程相互独立,可分别验证跳转逻辑与业务代码,帮助读者理解升级协议设计、中断向量重定位及异常回滚思路。目前已有2933人学习下载。

1. 现场只留一根 CAN 线,STM32 CAN 升级靠这套 Boot 撑住

设备装在柜子里,外壳封死,对外只引出一对 CAN_H/CAN_L。这时候要改一个采样周期或者修一个通信时序的 bug,拆机接仿真器基本上不现实。这套 STM32F107 的 CAN 升级程序(Boot + APP + 说明文档)解决的就是这件事:上电先跑一小段 Boot,检查参数区里有没有升级标志,没有就直接跳进 APP 跑业务;有就在总线上收固件、写内部 Flash、校验、复位。它适合两类人:一类是手上已经有 F107 板子、总线上跑着 CAN 通信、需要给现场设备远程改固件的开发者;另一类是刚接触 IAP,想找一份能对着改、能真实跑通的完整工程来拆的人。整套东西不复杂,但每一步都是坑,下面按工程结构一层层拆开。

2. 工程结构与内存分区:Boot 和 APP 到底谁占哪块 Flash

拿到这种升级包,第一件事不是打开 main.c,而是先把目录和 Flash 地图看清楚。Boot 和 APP 是两个独立工程、独立编译、独立生成 bin,最后烧到同一片 Flash 的不同地址段。搞不清谁占哪块,后面跳转必然 HardFault。

2.1 四类文件的关系:Boot 工程、APP 工程、上位机、文档

下载包里一般是这样几块东西,不同人打包习惯略有差别,但结构大同小异:

CAN_IAP_F107/ ├── Boot/ # IAP 工程,烧到 0x08000000 │ ├── User/ │ │ ├── main.c # 上电判断 + 主循环等升级命令 │ │ ├── iap.c # Flash 擦写、跳转、标志读写 │ │ └── can_protocol.c # 帧解析与应答 │ ├── Drivers/ # 标准外设库 + CMSIS 启动文件 │ └── Project/Boot.uvprojx ├── App/ # 业务工程,烧到 0x08008000 │ ├── User/main.c # 业务代码 + 向量表偏移 │ └── Project/App.uvprojx ├── Tools/ # 上位机:CAN 卡发送脚本或小工具 └── Doc/ # 协议说明、烧录步骤、版本记录

Boot 的职责被刻意压得很小:初始化时钟和 CAN、读标志、要么跳转要么留在升级模式收数据、写 Flash、校验、置标志、复位。它不该包含任何业务逻辑,也不该依赖 APP 里的任何代码。APP 这边只比普通工程多两件事:链接地址改到 0x08008000,启动时把向量表偏移过去。上位机负责把 bin 文件按协议切成帧,一条条发到总线上。文档里通常写着帧格式和烧录顺序,改协议前先对着文档确认一遍字段定义,比事后抓波形省事得多。

2.2 内存分区表:一次规划错,后面全返工

STM32F107 的 Flash 是 256KB,起始 0x08000000,页大小 2KB,也就是 128 页。SRAM 64KB,起始 0x20000000。分区方案必须按页对齐,不能出现“Boot 占 30KB”这种规划,因为擦除的最小单位就是一页。

区域起始地址大小内容说明
Boot0x0800000032KB(16 页)启动、CAN 协议、Flash 操作永不擦除,出事时的保底区域
APP0x08008000218KB业务固件升级时整段可擦
预留0x0803E8002KB(1 页)扩展用以后加参数或备份区
参数区0x0803F0004KB(2 页)升级标志、固件长度、CRC、版本号双页交替写,防写坏

为什么 Boot 给 32KB:标准外设库 + CAN + Flash 操作编译下来一般 10~16KB,留一倍余量,将来往协议里加校验、加签名不至于重排分区。APP 区从 0x08008000 开始,这个偏移必须是 2KB 的整数倍,而且最好是个“整数”,0x8000 对应 32KB,肉眼好算。参数区放在最后两页,是因为它离 APP 区最远,就算 APP 区擦除时算错了页数,也不太容易一把火烧到标志位。

参数区用两页轮换写是有原因的:单页存标志,写一次要先擦一次,万一擦完还没来得及写就断电,标志就没了。两页交替,每页前面加一个序号,读取时取序号大的那页,写的时候写另一页,任何时候至少有一页是完整的。这个套路在掉电场景下能救命。常见做法是每页开头放 8 字节头:魔数、序号、长度、头 CRC,后面才是真正的参数。判断有效性的顺序是:魔数对 → 头 CRC 对 → 数据 CRC 对,三级都过才认。

2.3 两个工程的编译配置:IROM 起点和 bin 生成

Boot 工程保持默认,IROM1 起始 0x08000000,大小 0x8000。APP 工程必须改,这一步漏了,编译出来的固件里所有中断向量还是按 0x08000000 算的,跳过去必炸。

在 Keil 的 Options for Target → Target 页里,APP 工程这样填:

配置项Boot 工程APP 工程
IROM1 Start0x080000000x08008000
IROM1 Size0x80000x36800
IRAM1 Start0x200000000x20000000
IRAM1 Size0x100000x10000

如果用的是分散加载文件或者 GCC 的 ld 脚本,等价写法是把 ROM 的 ORIGIN 改成 0x08008000。用 GCC 的话,链接脚本里那段大概长这样:

MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 218K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K }

生成 bin 用 fromelf 或者 objcopy,命令行加在 Keil 的 User → After Build 里:

# Keil MDK:编译后自动产出 bin,供上位机直接发送 fromelf --bin --output "$L@L.bin" "#L" # GCC 工具链等价写法 arm-none-eabi-objcopy -O binary app.elf app.bin

这里有个容易被忽略的点:bin 文件不带地址信息,从上位机发出去的第一字节默认落到 APP 起始地址,所以 bin 必须由链接地址已经改成 0x08008000 的工程生成。如果图省事拿 hex 转 bin 又没改链接地址,写进去的固件只要一跑就死。烧录顺序也有讲究:先烧 Boot,再烧 APP,两个 hex 分开烧,别想着合并成一个文件烧完了事——真要合并也不是不行,但要保证 Boot 区和 APP 区不重叠,合并工具不会自动帮你检查这个。

3. 启动流程拆解:从复位向量到 SCB->VTOR 的完整跳转链路

Boot 的 main 函数短到可以一屏看完:初始化、读标志、决定去向。真正容易出问题的是跳转前后那几十行,中断没关干净、栈指针没设对、外设没复位,都会以 HardFault 的形式还回来。

3.1 上电后先读升级标志

标志位的读法看着简单,但判断逻辑要经得起掉电考验。下面这段是参数区读取的简化版,把双页轮换和三级校验揉进去了:

/* 参数区最后两页,页大小 2KB,双页轮换保存,掉电不丢 */ #define PARAM_PAGE_A 0x0803F000UL #define PARAM_PAGE_B 0x0803F800UL #define PARAM_MAGIC 0x5AA5C33CUL typedef struct { uint32_t magic; /* 固定魔数,防止空片误判 */ uint32_t seq; /* 递增序号,取大者为准 */ uint32_t upgrade_flag; /* 1 = 需要升级 */ uint32_t fw_len; /* 固件字节数 */ uint32_t fw_crc; /* 固件 CRC32 */ uint32_t version; /* 版本号,升级成功后打印 */ } param_t; static const param_t *param_get_valid(void) { const param_t *a = (const param_t *)PARAM_PAGE_A; const param_t *b = (const param_t *)PARAM_PAGE_B; uint8_t a_ok = (a->magic == PARAM_MAGIC); uint8_t b_ok = (b->magic == PARAM_MAGIC); if (a_ok && b_ok) { return (a->seq >= b->seq) ? a : b; /* 两页都有效取序号大的 */ } if (a_ok) return a; if (b_ok) return b; return 0; /* 空片,按首次上电处理 */ }

magic的作用是区分“这片 Flash 从来没写过”和“写过但内容被擦掉了”。空片全 0xFF,任何字段都读不出正确值,直接判无效。seq解决的是写一半掉电的问题:新数据写进另一页并让 seq 加一,写完再回头擦旧页,中途断电最坏情况是两页都有效,取大者即可,数据不会丢。实际做的时候我一般还会在结构体末尾加一个自身 CRC 字段,读取时算一遍,进一步降低误判率。

判断完标志,流程分两条路:

int main(void) { SystemInit(); can_cfg_500k(); /* 升级通道先备好,跳转前也可用 */ const param_t *p = param_get_valid(); if (p && p->upgrade_flag == 1) { boot_upgrade_loop(); /* 进升级模式,等上位机发命令 */ } /* 没有升级请求,检查 APP 区是否像一份有效固件 */ if (app_is_valid()) { boot_jump_to_app(APP_BASE_ADDR); } boot_upgrade_loop(); /* APP 无效就死等升级,别乱跳 */ }

app_is_valid()的判据通常是:APP 起始地址的 4 字节(也就是初始栈顶)落在 0x20000000~0x20010000 之间,并且不等于 0xFFFFFFFF。再严格一点,可以在 APP 末尾放一个固件 CRC 和一个固定尾标,Boot 读尾标确认完整性。加这一层的价值在于:万一升级过程中断电,APP 区是半截数据,Boot 不会傻乎乎跳进去跑飞,而是老老实实留在升级模式,等人重新发一次。

3.2 跳转函数:关中断、复位外设、设 MSP、跳 PC

跳转本身只有两条指令的语义:把栈顶写进 MSP,把复位向量地址当成函数指针调用。麻烦的是跳之前的环境清理。

typedef void (*app_entry_t)(void); void boot_jump_to_app(uint32_t app_addr) { uint32_t sp = *(volatile uint32_t *)app_addr; /* 0x08008000 处是初始栈顶 */ uint32_t pc = *(volatile uint32_t *)(app_addr + 4); /* +4 处是 Reset_Handler */ /* 栈顶必须落在 SRAM 范围,否则这份固件根本不是给这片芯片编的 */ if ((sp & 0x2FFE0000UL) != 0x20000000UL) { return; } __disable_irq(); /* 1. 关总中断,防止跳转瞬间进 ISR */ SysTick->CTRL = 0; /* 2. 停掉 SysTick,否则 APP 里会莫名进中断 */ SysTick->LOAD = 0; SysTick->VAL = 0; for (uint32_t i = 0; i < 8; i++) { /* 3. 清 NVIC 使能与挂起,一个都别留 */ NVIC->ICER[i] = 0xFFFFFFFFUL; NVIC->ICPR[i] = 0xFFFFFFFFUL; } RCC_DeInit(); /* 4. 时钟回默认值,APP 会自己重新配 */ __set_MSP(sp); /* 5. 换栈 */ __enable_irq(); /* 6. APP 里要能开自己的中断 */ ((app_entry_t)pc)(); /* 7. 跳过去,不返回 */ }

逐条说下参数和取舍。app_addr就是 0x08008000,写成宏更好维护。栈顶校验用0x2FFE0000掩码是 ST 官方例程里的写法,本质是判断高 16 位是不是 0x2000,SRAM 只有 64KB,0x20000000~0x20010000 之外的栈顶一律视为非法。RCC_DeInit()这步有争议:它会关掉所有外设时钟,如果 Boot 里已经把 CAN 配好了,跳转后 APP 必须重新初始化 CAN;好处是 APP 拿到的是一个干净的外设状态,不会出现“Boot 里配了一次、APP 里配第二次,波特率玄学不对”的问题。我一般倾向加上,然后要求 APP 的初始化代码从头到尾配一遍,不留任何对 Boot 状态的假设。

第 3 步清 NVIC 经常被省掉,代价是跳转后 APP 里某个中断一使能就立刻进一次中断服务程序,而那个 ISR 可能指向的还是 Boot 的地址。表现就是刚上电几毫秒内跑飞,抓都抓不到。

3.3 APP 端必须改的两处:向量表与链接地址

跳转成功了,APP 跑起来,第一件事是把向量表挪过去。Cortex-M3 复位后 VTOR 默认是 0,所有中断都从 0x08000000 那张表取地址,而那张表是 Boot 的。

#define APP_VECT_TAB_OFFSET 0x8000UL /* 相对 0x08000000 的偏移 */ int main(void) { /* 必须在任何中断使能之前完成,通常放在 main 第一行 */ NVIC_SetVectorTable(NVIC_VectTab_FLASH, APP_VECT_TAB_OFFSET); SystemInit(); uart_init(); can_cfg_500k(); /* ... 业务初始化 ... */ while (1) { } }

标准外设库的NVIC_SetVectorTable干的就是往 SCB->VTOR 写(base | offset)。用 HAL 库的话对应SCB->VTOR = FLASH_BASE | 0x8000;,寄存器操作,和库无关。用 FreeRTOS 的项目要特别注意:xPortStartScheduler里会重设 VTOR,但如果你在 main 里已经设好了,那一步只是重复设置,不影响;真正的坑是有人在调度器启动之后才设向量表,那时候 SysTick 已经在跑了,设完立刻换表会丢一拍。

第二处是链接地址,前面 2.3 已经说过。这两处必须同时改:只改链接地址不改 VTOR,程序能跑但所有中断进 Boot 的表;只改 VTOR 不改链接地址,中断能进 APP 的表,但代码里的绝对地址跳转全是错的,一样跑不起来。

3.4 Boot 里的 CAN 收包为什么不能放在中断里跳转

在中断服务程序里调用boot_jump_to_app是新手最常见的写法,逻辑上似乎很顺:收到升级完成命令,立刻跳过去。实际上这条路走不通,因为跳转时正在处理一个 CAN 接收中断,__disable_irq()之后这个中断的现场还挂在栈上,__set_MSP一换栈,返回路径就断了,除非你再也不返回。

正确做法是中断里只做最轻的事:把数据和 ID 塞进环形缓冲区,置个标志,然后立刻退出。主循环轮询缓冲区,解析出“跳转”命令后,先回一个 ACK 帧(这一步很关键,不然上位机不知道设备已经收到了),再等 CAN 发送完成标志,最后才执行跳转。中间留几十毫秒延时让最后几帧发完,比事后查“为什么上位机显示失败但设备已经跳了”省事得多。

/* 中断只负责搬运,不做任何决策 */ void USB_LP_CAN1_RX0_IRQHandler(void) { CanRxMsg rx; CAN_Receive(CAN1, CAN_FIFO0, &rx); ring_push(&can_ring, rx.StdId, rx.Data, rx.DLC); /* 短临界区 */ }

ring_push里操作共享变量时要关一下中断,出函数前恢复,别整个 ISR 都在临界区里。缓冲区大小按“主循环最坏一轮处理时间 × 总线帧率”估,500kbps 下 8 字节帧大约每秒几千帧,给 64 帧的环形缓冲足够跑 Flash 擦除那几十毫秒的阻塞了。

4. CAN 升级协议与 Flash 写入:8 字节怎么变成一页固件

CAN 一帧只有 8 字节,一页 Flash 是 2048 字节。中间的转换逻辑就是这个项目最核心的部分,写错一个字节序或者漏一个应答,现象都是“传到一半卡住”,而且极难定位。

4.1 帧格式定义:帧头、命令、序号、数据、应答

协议设计得越简单越好维护,毕竟是装在设备里、以后没机会改的东西。这套工程用的是固定 8 字节定义:

字节名称说明
0帧头固定 0x5A,不对就整帧丢弃
1CMD0x01 进入升级 / 0x02 擦除 / 0x03 数据 / 0x04 校验 / 0x05 复位
2-3SEQ小端序号,数据帧从 0 开始递增
4-7DATA数据帧携带 4 字节固件;擦除帧携带固件总长度;校验帧携带 CRC32

应答帧复用同一套结构,把 CMD 最高位置 1(也就是cmd | 0x80),DATA 第一个字节放状态码,0 表示成功,1 表示校验失败,2 表示 Flash 写失败。上位机每发一帧等一次 ACK,超时 100ms 重发,连续三次失败就中止。

每帧带 4 字节有效载荷,意味着 100KB 固件要发 25600 帧。500kbps 下一帧实际占用约 130 位(含帧间隔和位填充),理论 3800 帧/秒,实测加上 ACK 往返和 Flash 写入,大概能跑到 800~1500 帧/秒,整包 20~40 秒。这个速度对现场升级完全够用,比追求吞吐量更重要的是稳定性。

CAN 位时序得跟总线一致,F107 的 CAN 挂在 APB1 上,默认 36MHz:

static void can_cfg_500k(void) { GPIO_InitTypeDef gpio; CAN_InitTypeDef can; CAN_FilterInitTypeDef filter; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_CAN1, ENABLE); /* PB8/PB9 复用为 CAN1_RX / CAN1_TX */ gpio.GPIO_Pin = GPIO_Pin_8; gpio.GPIO_Mode = GPIO_Mode_IPU; GPIO_Init(GPIOB, &gpio); gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Mode = GPIO_Mode_AF_PP; gpio.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &gpio); /* 36MHz / 6 = 6MHz,一位 12 个 tq,采样点 75% */ CAN_StructInit(&can); can.CAN_Prescaler = 6; can.CAN_BS1 = CAN_BS1_8tq; can.CAN_BS2 = CAN_BS2_3tq; can.CAN_SJW = CAN_SJW_1tq; can.CAN_Mode = CAN_Mode_Normal; CAN_Init(CAN1, &can); /* 只收 0x601,其余一概不理,减少中断负担 */ filter.CAN_FilterNumber = 0; filter.CAN_FilterMode = CAN_FilterMode_IdMask; filter.CAN_FilterScale = CAN_FilterScale_32bit; filter.CAN_FilterIdHigh = 0x601 << 5; filter.CAN_FilterIdLow = 0; filter.CAN_FilterMaskIdHigh = 0x7FF << 5; filter.CAN_FilterMaskIdLow = 0; filter.CAN_FilterFIFOAssignment = CAN_FIFO0; filter.CAN_FilterActivation = ENABLE; CAN_FilterInit(&filter); }

CAN_Prescaler和 BS1/BS2 是配套算出来的:36MHz 除以 6 得到 6MHz 的 tq 时钟,一位由 1+8+3=12 个 tq 组成,6M/12=500kbps,采样点落在 (1+8)/12 ≈ 75%,这是 CAN 里比较稳妥的位置。如果现场总线是 250kbps,把 Prescaler 改成 12 就行,其他不动。滤波器的掩码写法要注意<< 5,因为标准帧 ID 在寄存器里是左对齐的,忘了移位就永远收不到帧,这类问题抓波形都看不出来。

4.2 接收状态机:把散帧拼成一次完整的升级

主循环里维护一个状态机,四个状态足够:

typedef enum { ST_IDLE = 0, /* 空闲,等待进入升级命令 */ ST_ERASED, /* 已擦除,等待数据帧 */ ST_RECEIVING, /* 收数据中,按 SEQ 校验连续性 */ ST_DONE /* 收完,做整体 CRC 校验 */ } up_state_t; static up_state_t state = ST_IDLE; static uint32_t expect_seq = 0; static uint32_t write_addr = APP_BASE_ADDR; static uint32_t fw_total = 0; static void handle_frame(uint16_t id, const uint8_t *d, uint8_t len) { if (len != 8 || d[0] != 0x5A) return; /* 不是本协议的帧,直接丢 */ uint8_t cmd = d[1] & 0x7F; uint16_t seq = (uint16_t)(d[2] | (d[3] << 8)); uint32_t val = (uint32_t)d[4] | ((uint32_t)d[5] << 8) | ((uint32_t)d[6] << 16) | ((uint32_t)d[7] << 24); switch (cmd) { case 0x01: /* 进入升级 */ state = ST_ERASED; /* 实际擦除延后到 0x02 */ send_ack(0x01, 0, 0); break; case 0x02: /* 擦除,val = 固件总长度 */ if (flash_erase_app(val) != 0) { send_ack(0x02, seq, 2); break; } fw_total = val; write_addr = APP_BASE_ADDR; state = ST_RECEIVING; expect_seq = 0; send_ack(0x02, seq, 0); break; case 0x03: /* 数据帧,4 字节 */ if (state != ST_RECEIVING) { send_ack(0x03, seq, 1); break; } if (seq != expect_seq) { /* 序号不连续,请求重发 */ send_ack(0x03, expect_seq - 1, 1); break; } if (flash_program_word(write_addr, val) != 0) { send_ack(0x03, seq, 2); state = ST_IDLE; break; } write_addr += 4; expect_seq++; send_ack(0x03, seq, 0); break; case 0x04: /* 校验,val = 上位机算的 CRC32 */ if (crc32_flash(APP_BASE_ADDR, fw_total) == val) { param_save(fw_total, val, state); /* 写参数区,清升级标志 */ send_ack(0x04, seq, 0); state = ST_DONE; } else { send_ack(0x04, seq, 1); state = ST_IDLE; } break; case 0x05: /* 复位,重启进 APP */ NVIC_SystemReset(); break; default: break; } }

要点在三处。一是seq的校验:CAN 本身有 CRC,但丢了帧它不会告诉应用层,序号是应用层唯一能发现丢帧的手段。发现序号跳了,不要让上位机从头重发,只请求重发上一帧,靠 ACK 里回带期望序号实现。二是擦除放在 0x02 命令里做而不是 0x01,因为擦 109 页要 2~4 秒,设备得先给上位机一个“我收到了”的应答,再闷头去擦,否则上位机以为设备挂了。三是写 Flash 之前必须确认write_addr + 4 <= APP_BASE_ADDR + APP_MAX_SIZE,溢出保护不能省。

4.3 擦除与写入:页对齐、回读校验、超区保护

F107 的 Flash 操作走标准外设库,流程固定:解锁 → 清标志 → 擦页 → 写 → 锁。写之前该地址必须是擦过的(0xFFFFFFFF),否则写不进去,库函数返回 FLASH_ERROR_PG。

#define APP_BASE_ADDR 0x08008000UL #define FLASH_PAGE_SIZE 2048UL #define APP_MAX_SIZE 0x36800UL /* 218KB,与 APP 工程 IROM 大小一致 */ /* 按固件长度擦除足够的页,返回 0 成功 */ uint8_t flash_erase_app(uint32_t fw_size) { uint32_t pages, addr; if (fw_size == 0 || fw_size > APP_MAX_SIZE) return 1; /* 超区直接拒绝 */ pages = (fw_size + FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE; /* 向上取整 */ FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (uint32_t i = 0; i < pages; i++) { addr = APP_BASE_ADDR + i * FLASH_PAGE_SIZE; if (FLASH_ErasePage(addr) != FLASH_COMPLETE) { FLASH_Lock(); return 2; } } FLASH_Lock(); return 0; } /* 写一个 32 位字,写完立刻回读比对 */ uint8_t flash_program_word(uint32_t addr, uint32_t data) { uint8_t ret = 0; if (addr < APP_BASE_ADDR || addr >= APP_BASE_ADDR + APP_MAX_SIZE) return 3; FLASH_Unlock(); if (FLASH_ProgramWord(addr, data) != FLASH_COMPLETE) ret = 1; FLASH_Lock(); if (ret == 0 && *(volatile uint32_t *)addr != data) ret = 2; /* 回读校验 */ return ret; }

pages这个向上取整不能省。固件长度 5000 字节,实际占 3 页(6144 字节),少擦一页,最后 904 字节写到没擦过的区域,FLASH_ProgramWord直接返回错误,上位机看到的就是“最后几帧一直失败”。这类现象特别迷惑,因为前面 99% 都是成功的。

回读校验放在写函数里,代价是每次多一次读,收益是 Flash 寿命末期或者电压不稳导致的写入失败能立刻被发现,而不是等到整体 CRC 校验才暴露。整体校验能发现错误但定位不到哪一帧,回读能。做现场升级,定位能力比几微秒的性能重要。

每写一个字的解锁/加锁开销其实不小,更快的做法是整段解锁、连续写、最后加锁,但那样中断关得久,CAN 接收容易溢出。折中方案是按页缓冲:收满 2048 字节放到 RAM 缓冲里,解锁一次、连续写 512 个字、加锁,期间 ACK 先不发,写完一起发。代价是 RAM 占 2KB,F107 有 64KB,不心疼。

4.4 校验、置标志与复位:收尾阶段最不能省的三步

CRC32 用查表法或者按位算都行,按位算慢一点但省 1KB 表空间,Boot 里通常选按位。校验范围是APP_BASE_ADDR起fw_total字节,和上位机算的范围必须严格一致——上位机算的是 bin 文件,设备算的是 Flash 里的内容,中间经过 4 字节一帧的拆分和重组,字节序错一位结果就完全不同。

校验通过之后,把固件长度、CRC、版本号写进参数区,同时把upgrade_flag清成 0,然后回 ACK,等 100ms 让应答帧发出去,再NVIC_SystemReset()。顺序不能颠倒:先清标志再复位,如果先复位后清标志,那就成了死循环,每次上电都进升级模式。

参数区写入本身也要走“擦-写-回读”,并且写的是另一页,这样万一写参数的时候掉电,旧的参数还在,最多是这次升级白做,设备仍然能用旧固件启动。这就是 2.2 里双页轮换的实际价值。

5. 避坑与排查:升级卡死、跳转 HardFault、断电变砖的 5 个原因

这几个坑我在不同板子上都复现过,写下来省得后来人再花两天抓波形。

5.1 现象:跳转过去立刻 HardFault,连 main 都进不去

原因基本是三个之一:APP 工程 IROM 起始地址没改,还是 0x08000000;APP 里没调NVIC_SetVectorTable;跳转函数里没设 MSP 就跳了。前两个会让向量表指向错误的位置,第三个会让 APP 用 Boot 的栈,压栈几下就溢出。

排查顺序固定:先用调试器在boot_jump_to_app里下断点,看sp和pc读出来的值对不对,sp应该在 0x2000xxxx,pc应该在 0x08008xxx;再用__set_MSP之后单步跟进去,看第一条指令是不是 APP 的 Reset_Handler。这两步过了还在 HardFault,就查 VTOR 有没有写成功,读一下SCB->VTOR的值,应该是 0x08008000。曾经有一次查了两小时,最后发现是 APP 工程的分散加载文件改了一半,IROM 改了 IRAM 没改,栈顶算到了 SRAM 外面。

5.2 现象:升级到 60% 突然复位,之后设备再也起不来

原因是参数区标志的写入时机错了。有人图省事,收到“进入升级”命令就立刻把upgrade_flag写成 1,然后开始擦 APP 区。这个顺序在正常流程下没问题,但一旦擦除中途掉电,标志还在,APP 已经被擦成 0xFF,Boot 每次都进升级模式——设备成了砖,只能返厂接 SWD。

解决办法是把“进入升级”和“置标志”拆开:0x01 命令只回 ACK,不动参数区;真正擦除前先确认 Boot 自己的完整性,擦完之后如果收到校验通过命令,才把upgrade_flag写进参数区并立即清 0。也就是说标志只用于“升级已完成、下次上电直接跳 APP”这一个用途,不用于“正在升级中”。另外 Boot 里必须有超时保护:进入升级模式后 30 秒没收到任何有效帧,自动复位一次,重新判断 APP 有效性,避免设备永远卡在升级模式。

5.3 现象:擦除那一帧发出去之后,上位机等 ACK 等到超时

原因是擦除是阻塞操作,F107 擦一页大约 20~40ms,109 页全擦要 2~4 秒。如果 ACK 是在擦完之后才发,上位机的 100ms 超时早就到了,于是重发擦除命令,设备又擦一遍,两边彻底对不上。

解决是擦除前后各发一帧:收到 0x02 立刻回一帧“已接受”,擦完再回一帧“已完成”。上位机的状态机要认这两种应答,不能只认一个。同样的道理适用于大页写入,凡是耗时超过 50ms 的操作,都要拆成两段应答。看门狗也要在擦除循环里喂,不然擦到一半狗叫了,设备复位,APP 区半残,又回到 5.2 的场景。

5.4 现象:波特率、滤波器都配了,就是收不到任何帧

按概率排:CAN_H/CAN_L 接反(这个最常见)、终端电阻没接(总线两端各 120Ω,中间节点不能接)、滤波器移位忘了写(标准帧 ID 在寄存器里是左对齐的,0x601 << 5)、采样点和总线上其他节点不一致。还有一种很隐蔽:Boot 里初始化了 CAN,跳转到 APP 之后 APP 又初始化一次,但RCC_DeInit()没调用,前一次的配置残留在寄存器里,第二次CAN_Init参数虽然对,但由于 CAN 处于初始化模式之外,配置根本没生效。

排查手段就是从简到繁:先用示波器看总线有没有波形,有波形但设备不回 ACK,就查滤波器;有 ACK 但收不到数据,就查 FIFO 中断有没有使能;单个设备通、多设备不通,就查 ID 冲突和终端电阻。别一上来就怀疑芯片坏了。

5.5 现象:升级成功,第一次上电正常,断电再上电又回到 Boot

标志位没清,或者清的位置不对。正确顺序是:CRC 校验通过 → 写新参数(含upgrade_flag=0和新版本号)→ 回读确认 → 回 ACK → 复位。少任何一步都可能出现“这次好了,下次又回去”。还有一种情况是参数区写到了错误的页上,把 Boot 的某段数据覆盖了,表现是 Boot 本身跑飞,这种最麻烦,只能重新烧 Boot。

预防手段很简单:参数区的地址和大小用宏定义在头文件里,Boot 和上位机共用同一份定义,写参数前先 assert 地址落在参数区范围内。我后来加了一条规则,所有写 Flash 的函数入口都强制检查地址区间,多写两行 if,能挡掉 90% 的地址算错问题。

6. 二次开发:把它改成 A/B 双区回滚和带签名的升级

原工程是单区方案:APP 只有一份,升级时整段擦掉重写,中途出错就得依赖 Boot 兜底重来。稍微上点规模的设备,我建议改成 A/B 双区,改动不大,收益很直接。

做法是 EEPROM 里的分区表扩成两行:APP_A 在 0x08008000,APP_B 在 0x08020000,各自带独立版本号和有效标志。当前运行哪个由参数区里的active_slot决定,升级永远写非活动的那一区,写完校验通过才把active_slot切过去。切过去之后如果新固件连续启动失败两次(可以用一个启动计数器实现,进 APP 后立刻加一,跑到主循环稳定后清零),Boot 自动切回旧区。核心逻辑大概是这样:

/* 双区切换:只在新区校验通过后调用,写参数区保证掉电一致 */ static void slot_activate(uint8_t slot, uint32_t len, uint32_t crc) { param_t p = *param_get_valid(); /* 拷一份旧的,保留另一区的信息 */ p.active_slot = slot; p.upgrade_flag = 0; p.boot_try = 0; /* 清零重试计数 */ p.slot[slot].len = len; p.slot[slot].crc = crc; p.slot[slot].valid = 1; param_save(&p); /* 内部走双页轮换 + 回读校验 */ }

安全上再加一层:固件末尾追加 64 字节签名,Boot 用预置的公钥验一遍再允许跳转。算法选 ECDSA P-256 或者 Ed25519 都行,F107 跑一次验签约 100~200ms,只在启动时做一次,完全可接受。加了签名之后,即使有人从总线上抓到整包固件,也没法伪造一份能跑的自定义固件刷进去。

协议层面还有一条路是往 ISO-TP 多帧和 UDS 的 0x34/0x36/0x37 服务上靠。好处是能直接用现成的诊断工具和脚本,坏处是代码量翻倍,8 字节一帧的紧凑格式对 500kbps 总线其实够用。我的建议是:设备数量少、只有自己人用,就保持现在的私有协议;一旦要给第三方工具对接,再上 UDS,别一开始就把简单问题复杂化。

改完之后验证方法要固定成一套:先用一个只有 20KB 的最小固件跑通全流程,再换一个接近区上限的大固件跑一遍;然后人为在擦除、写数据、写参数三个节点各断一次电,确认每次都能回到可用状态;最后把 CAN 线拔掉再插上,验证超时重连。这套流程我从踩过 5.2 那个坑之后,每次改 Boot 都强制走一遍,一次没落过,因为省下的那半小时,往往要用一整天返工来还。希望帮到你。

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

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

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

立即咨询