简介:本资源是一套面向STM32F4系列MCU开发者的完整OTA远程升级实战方案,聚焦于通过串口或RS485总线实现固件安全更新,适用于工业现场、智能终端等需离线升级的嵌入式场景,适合具备C语言基础与Keil开发经验的中级以上嵌入式工程师学习实践。压缩包共277个文件,涵盖90个头文件(h)与88个源码文件(c),构成完整的Bootloader与App双工程架构;另有编译中间文件(o/d)、调试配置(dbgconf)、链接脚本(sct)、二进制镜像(bin/hex/axf)及自动化批处理脚本(bat),体现典型ARM Cortex-M4项目工程规范。资源包大小为2.86MB,结构清晰、开箱即用。目前已有2540人学习下载,读者可直接获取可运行的双工程源码、内存布局配置、串口/485通信协议框架、固件校验与跳转逻辑等核心实现,大幅降低自研Bootloader的技术门槛与验证成本。
1. 串口+485 OTA不是“把固件发过去就完事”:它是一套带校验、可回滚、能抗干扰的嵌入式升级闭环
某高校实验室在部署一批野外环境监测节点时,发现设备分散在无网络覆盖的山坳里,Wi-Fi 和 LTE 模块成本高、功耗大、维护难。他们最终用 STM32F407 + MAX485 搭了一套纯串行链路 OTA 系统——不是靠 USB 拔插烧录,也不是用 SD 卡手动换固件,而是通过一根 RS-485 总线,从主控端远程下发新固件、校验完整性、安全跳转到 Bootloader、擦写 Flash、校验写入、自动回滚失败包,整个过程无人值守。这套方案跑通后,32 个节点升级耗时平均 8.3 秒(含握手与 CRC 校验),断电重上电后仍能自恢复,连续 6 个月零升级失败。它不依赖任何云服务或无线模块,却实现了比很多“智能OTA”更稳的现场交付能力。如果你手上有 STM32F4 系列芯片(F405/F407/F411 均适用)、需要在工业现场/电力终端/楼宇控制器等弱网甚至无网场景下做固件迭代,又不想引入复杂协议栈或额外硬件,那这个基于标准 UART + 485 收发器的 OTA 方案,就是你该立刻跑起来验证的最小可行闭环。
2. 架构设计:Bootloader 与 App 分区隔离 + 双缓冲校验 + 串口帧协议驱动
这个项目不是“App 里加个串口接收函数然后 memcpy 到 Flash”,而是一个严格分层、职责清晰、具备生产级鲁棒性的嵌入式升级架构。核心思想是:Bootloader 是唯一有权操作 Flash 的可信入口,App 只负责发起升级请求并提供数据流,所有擦写、校验、跳转逻辑全部收口在 Bootloader 中。整个系统运行时内存布局和 Flash 分区如下(以 STM32F407ZGT6 为例,Flash 总容量 1MB):
| 区域 | 起始地址 | 大小 | 说明 |
|---|---|---|---|
| Bootloader | 0x08000000 | 32KB | 固定位置,永不更新,含 UART 驱动、485 控制、CRC32 计算、Flash 擦写、校验、跳转逻辑 |
| App(当前) | 0x08008000 | ≤ 896KB | 用户应用程序,编译时需配置VECT_TAB_OFFSET = 0x8000 |
| App(备份) | 0x080E0000 | ≤ 128KB | 升级时临时存放新固件,升级成功后与当前 App 交换角色 |
| 升级元数据区 | 0x080FF000 | 4KB | 存储版本号、CRC32、状态标志(UPDATING/VALID/INVALID)、时间戳 |
提示:分区大小非固定值,实际使用中需根据你的 App 体积动态调整。例如若 App 编译后为 620KB,则备份区可设为
0x080C8000起始,留出足够余量。关键不是数字本身,而是“App 和备份区必须完全独立、互不覆盖”的设计原则。
2.1 Bootloader 启动流程:从复位向量到安全跳转的三阶段控制
Bootloader 的启动不是简单地执行完就跳走,而是包含三个明确阶段:初始化 → 升级决策 → 应用跳转。每阶段都有硬性检查点,任一失败即进入安全模式(如 LED 快闪、串口输出错误码)。
// boot_main.c - Bootloader 入口主循环(精简关键逻辑) void SystemInit(void) { // 1. 时钟、GPIO、UART、485方向控制引脚初始化 RCC_Configuration(); GPIO_Configuration(); USART_Configuration(USART1, 115200); // 使用 USART1,PA9/PA10 RS485_DE_Config(); // PA8 控制 DE/RE 引脚 } int main(void) { uint32_t app_addr; // 阶段1:检查升级标志位(来自元数据区) if (is_upgrade_pending()) { // 读取 0x080FF000 处的 flag == 0xAA55 // 阶段2:加载备份区固件并校验 if (verify_app_image(BACKUP_APP_BASE) == SUCCESS) { // 校验通过:擦除当前 App 区,复制备份区内容 flash_erase_page(APP_CURRENT_BASE); flash_copy_page(BACKUP_APP_BASE, APP_CURRENT_BASE, APP_SIZE); // 更新元数据:标记升级完成、清除 pending flag update_metadata(FLAG_VALID); } else { // 校验失败:标记 INVALID,强制停留在 Bootloader update_metadata(FLAG_INVALID); while(1) { blink_error_led(3); } } } // 阶段3:跳转到当前 App(必须先关闭所有外设中断!) app_addr = *(volatile uint32_t*)(APP_CURRENT_BASE + 4); // 获取 MSP 初始值 JumpAddress = *(volatile uint32_t*)(APP_CURRENT_BASE + 0); // 获取复位向量地址 if (((JumpAddress & 0x2FFE0000) == 0x20000000) || ((JumpAddress & 0x08000000) == 0x08000000)) { MSR_MSP(app_addr); // 设置主堆栈指针 Jump_To_Application = (pFunction) JumpAddress; Jump_To_Application(); // 执行跳转 } while(1); // 跳转失败兜底 }这段代码的关键在于:
is_upgrade_pending()不是读寄存器,而是从 Flash 特定页(0x080FF000)读取一个 32 位标志字,避免 RAM 掉电丢失;verify_app_image()不仅校验 CRC32,还检查向量表首字是否为有效 Stack Pointer(≥ 0x20000000),防止误跳转到非法地址;flash_copy_page()是逐页擦写+校验写入,不是裸 memcpy —— 每写一页(16KB)后立即读回比对,失败则终止并报错。
2.2 串口帧协议设计:带超时、重传、分包的可靠传输层
RS-485 是半双工总线,易受共模干扰、终端匹配不良、线缆过长影响,裸发二进制固件必然丢包。本项目采用自定义轻量帧协议(非 Modbus),结构如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| SOF(Start of Frame) | 1 byte | 固定0xAA,用于帧同步 |
| CMD(Command) | 1 byte | 0x01=请求升级,0x02=发送固件块,0x03=校验请求,0x04=确认完成 |
| SEQ(Sequence) | 1 byte | 当前包序号(0~255),支持滑动窗口重传 |
| LEN(Length) | 2 bytes | 数据长度(LE 小端),最大 1024 字节(适配 UART DMA Buffer) |
| DATA | 0~1024 bytes | 固件原始字节,按 4 字节对齐填充 |
| CRC16 | 2 bytes | XMODEM-CRC16(0x1021 poly),覆盖 CMD+SEQ+LEN+DATA |
接收端逻辑(在 Bootloader 中):
- 每收到一个完整帧,先校验 SOF 和 CRC16;
- 若 CRC 错,丢弃并回复
NACK(CMD=0x00, SEQ=收到的 SEQ); - 若正确,缓存到 RAM 缓冲区(
backup_buffer[1024]),并回复ACK(CMD=0x05, SEQ=当前 SEQ); - 连续 3 次未收到 ACK,则主控端重发该 SEQ 包(超时 200ms);
- 所有包接收完毕后,再统一计算整个固件的 CRC32 并写入备份区末尾。
注意:
LEN字段为 2 字节,意味着单帧最大载荷 1024 字节,这是权衡传输效率与 RAM 占用的结果。STM32F4 的 SRAM 通常为 192KB,但 Bootloader 必须常驻,留给接收缓冲的空间有限。1024 字节既能保证 UART DMA 一次搬运完成,又不会挤占其他关键变量空间。
2.3 App 如何触发升级?—— 通过共享标志位 + 软复位协同
App 本身不操作 Flash,但它要能主动发起升级流程。实现方式是:App 在检测到升级条件(如收到 PC 端指令、SD 卡插入、或定时检查服务器版本)后,向元数据区写入升级标志,并触发软复位。Bootloader 在启动时读取该标志,决定是否进入升级模式。
// app_main.c - App 中触发升级的典型调用 void trigger_ota_upgrade(void) { // 1. 解锁 Flash 写权限(需先解锁) FLASH_Unlock(); // 2. 擦除元数据页(0x080FF000,4KB 页) FLASH_ErasePage(0x080FF000); // 3. 写入升级标志(0xAA55)和版本信息 uint32_t metadata[1024] = {0}; metadata[0] = 0xAA55; // upgrade pending flag metadata[1] = 0x01020304; // new version: 1.2.3.4 metadata[2] = get_current_timestamp(); // 时间戳,用于日志追溯 for(int i=0; i<1024; i++) { FLASH_ProgramWord(0x080FF000 + i*4, metadata[i]); } FLASH_Lock(); // 4. 触发系统复位(NVIC_SystemReset()) NVIC_SystemReset(); }这里有个血泪经验:必须在写完标志后再复位,不能边写边复位。曾有开发者把NVIC_SystemReset()放在FLASH_Unlock()后面,导致标志位只写了一半就被复位,Bootloader 读到乱码,直接卡死。正确做法是:确保整个元数据页擦写+编程全部完成,并调用FLASH_Lock()上锁后,再执行复位。
3. 工程落地:Keil MDK 配置、链接脚本修改、串口工具实测步骤
光看架构不够,得真正在 Keil uVision5(或 STM32CubeIDE)里跑起来。本节聚焦工程级落地细节,覆盖从新建工程到 PC 端下发固件的全链路。
3.1 Bootloader 工程配置:起始地址、向量表偏移、禁止优化关键函数
Bootloader 必须固化在0x08000000,且其向量表也必须在此处。Keil 中需设置:
Target 选项卡:
- IROM1:
Start=0x08000000,Size=0x00008000(32KB) - IRAM1:
Start=0x20000000,Size=0x00030000(192KB,但 Bootloader 实际只用约 12KB)
- IROM1:
Output 选项卡:勾选Create HEX File(用于烧录初始 Bootloader)
C/C++ 选项卡:添加宏定义
-D BOOTLOADER,用于条件编译区分 Bootloader/App 逻辑Linker 选项卡:使用自定义 scatter 文件(见下节),禁用默认分散加载
最关键的是向量表偏移设置:
在system_stm32f4xx.c中,找到SystemInit()函数末尾,添加:
#ifdef BOOTLOADER SCB->VTOR = FLASH_BASE | 0x00000000; // VTOR 指向 0x08000000 #else SCB->VTOR = FLASH_BASE | 0x00008000; // App 指向 0x08008000 #endif否则中断向量会错位,串口中断无法响应。
3.2 链接脚本(scatter file)详解:精确控制代码/RO/RW/ZI 段落址
Keil 默认 scatter 文件无法满足双区分离需求,必须手写。以下是 Bootloader 的bootloader.sct:
LR_IROM1 0x08000000 0x00008000 { ; load region size_region ER_IROM1 0x08000000 0x00008000 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00030000 { .ANY (+RW +ZI) } }App 工程的app.sct则完全不同:
LR_IROM1 0x08008000 0x000E0000 { ; App 区:从 0x08008000 开始,最大 900KB ER_IROM1 0x08008000 0x000E0000 { *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 0x00030000 { .ANY (+RW +ZI) } }提示:
.ANY (+RO)表示将所有只读段(代码、常量)放入指定区域;+RW +ZI表示读写段和清零段放入 RAM。务必确认ER_IROM1的起始地址与你在SCB->VTOR中设置的地址一致,否则跳转后第一条指令就执行错位置。
3.3 PC 端固件下发实测:用 Python + pyserial 写一个可靠上传脚本
不用商业串口助手,自己写一个带重传、进度条、CRC 校验的上传脚本,才是工程师该有的姿势。以下为ota_upload.py核心逻辑(Python 3.8+):
# ota_upload.py import serial, time, sys, struct, binascii from tqdm import tqdm # pip install tqdm def calc_crc16(data: bytes) -> int: crc = 0 for b in data: crc ^= b << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF return crc def send_frame(ser, cmd, seq, data=b''): frame = bytearray([0xAA, cmd, seq]) frame += struct.pack('<H', len(data)) # 小端 2 字节长度 frame += data frame += struct.pack('<H', calc_crc16(frame[1:])) # CRC 覆盖 CMD 及之后 ser.write(frame) def upload_firmware(ser, firmware_path, baudrate=115200): ser.baudrate = baudrate ser.timeout = 0.3 # 接收超时 300ms with open(firmware_path, 'rb') as f: fw_data = f.read() total_packets = (len(fw_data) + 1023) // 1024 # 向上取整 print(f"准备上传 {len(fw_data)} 字节固件,共 {total_packets} 包...") for seq in tqdm(range(total_packets), desc="上传中"): start = seq * 1024 end = min(start + 1024, len(fw_data)) chunk = fw_data[start:end] # 发送一包 send_frame(ser, 0x02, seq, chunk) # 等待 ACK ack_received = False for retry in range(3): resp = ser.read(4) # ACK 格式:0xAA 0x05 seq 0x00 if len(resp) >= 4 and resp[0] == 0xAA and resp[1] == 0x05 and resp[2] == seq: ack_received = True break time.sleep(0.05) if not ack_received: print(f"\n❌ 第 {seq} 包超时未收到 ACK,退出") return False # 发送校验请求 send_frame(ser, 0x03, 0xFF) time.sleep(0.1) final_resp = ser.read(4) if final_resp and final_resp[0] == 0xAA and final_resp[1] == 0x06: print("\n✅ 固件上传完成,等待设备重启...") return True return False if __name__ == '__main__': if len(sys.argv) < 3: print("用法: python ota_upload.py <COMx> <firmware.bin>") sys.exit(1) ser = serial.Serial(sys.argv[1]) try: upload_firmware(ser, sys.argv[2]) finally: ser.close()运行命令:
python ota_upload.py COM3 firmware_v2.1.bin这个脚本的价值在于:
- 每包发送后严格等待 ACK,超时自动重试;
- 使用
tqdm显示实时进度条,避免“黑屏等待”焦虑; - 最终发送
CMD=0x03请求 Bootloader 对整个固件做 CRC32 校验,只有校验通过才返回0x06成功码; - 所有帧结构、CRC 计算与 Bootloader 完全对齐,无需额外转换。
4. 避坑指南:那些让你熬夜到凌晨三点的串口 OTA 真实翻车现场
串口 OTA 看似简单,实则处处是坑。下面这 5 条,全是某开发者在产线调试时用万用表+逻辑分析仪+32 个节点反复验证出来的血泪教训,每一条都对应一个真实崩溃场景。
4.1 现象:Bootloader 启动后串口无任何输出,LED 也不闪,设备“变砖”
原因:Bootloader 的SCB->VTOR设置错误,或向量表首地址(0x08000000)处未写入有效 Stack Pointer。常见于:忘记在system_stm32f4xx.c中添加#ifdef BOOTLOADER分支;或烧录 Bootloader 时用了错误的 hex 文件(比如把 App 的 hex 烧到了0x08000000)。
解决:用 ST-Link Utility 读取0x08000000~0x0800000F8 个字,确认前 4 字节是合理 SP(如0x2001FFFC),后 4 字节是复位向量地址(如0x08000121)。若为0xFFFFFFFF,说明 Flash 未正确编程。
4.2 现象:固件能发过去,但升级后 App 启动即 HardFault
原因:App 工程的VECT_TAB_OFFSET未设置为0x8000,导致中断向量表仍映射在0x08000000(Bootloader 区),而 App 代码在0x08008000,中断触发时跳转到 Bootloader 代码区执行,必然异常。
解决:在 Keil 的Options for Target → C/C++ → Define中添加VECT_TAB_OFFSET=0x8000;同时在startup_stm32f407xx.s中确认__Vectors符号起始地址为0x08008000(可通过 map 文件验证)。
4.3 现象:RS-485 总线上多个节点,只有第一个能升级,其余收不到数据
原因:485 收发器的 DE/RE 控制引脚未做硬件互斥。当多个节点同时尝试发送(如都发 ACK),总线冲突导致信号畸变。本项目要求:只有 Bootloader 处于接收态时,DE=0/RE=1;发送 ACK 时,DE=1/RE=1;其余时间 DE=0/RE=0(高阻态)。若用软件模拟 DE 控制,必须确保发送前 1ms 拉高 DE,发送后 1ms 再拉低。
解决:改用硬件自动流向控制芯片(如 SP3485 自带 DE 自动切换),或在软件中严格插入__NOP()延时,并用示波器抓 DE 引脚波形验证时序。
4.4 现象:升级过程中断电,再上电后设备无法启动,Bootloader 也不运行
原因:元数据区(0x080FF000)在擦除后、写入前断电,该页变为全0xFF,而 Bootloader 读取flag时认为0xFF == 0xFF(即 pending 状态),于是再次尝试升级,但备份区固件不完整,校验失败,陷入死循环。
解决:元数据区采用“两页镜像 + 原子写入”策略。例如用0x080FF000和0x080FF800两页,每次写入先擦新页,写满后再擦旧页。Bootloader 启动时读两页,取flag==0xAA55且 CRC 校验通过的那页为准。即使断电,也总有一份完整元数据。
4.5 现象:PC 端显示上传 100%,但设备端提示 “CRC32 mismatch”
原因:PC 脚本计算的 CRC32 与 Bootloader 计算的不一致。根源往往是字节序或多项式差异。本项目 Bootloader 使用CRC32-MPEG2(poly=0x04C11DB7,refin=false, refout=false),而 Pythonzlib.crc32()默认是CRC32-IEEE(poly=0xEDB88320)。
解决:在 Python 脚本中使用crcmod库显式指定:
import crcmod crc32_func = crcmod.predefined.mkCrcFun('mpeg2') fw_crc = crc32_func(fw_data)并在 Bootloader 中用相同算法实现(ST 提供的HAL_CRC_Accumulate()可配置)。
5. 二次开发实战:如何快速接入 Modbus RTU、支持差分升级、增加 OTA 日志追溯
当你已跑通基础串口 OTA,下一步往往不是“再做个 Wi-Fi OTA”,而是让现有方案更贴合产线需求。本节给出三个高频改造方向,每个都附可直接粘贴的代码片段和验证方法。
5.1 接入 Modbus RTU 协议:复用现有 UART,只需替换帧解析层
很多工业客户要求 OTA 必须走标准 Modbus RTU,而非私有协议。好消息是:物理层(UART+485)完全复用,只需替换 Bootloader 中的帧接收与解析逻辑,无需改动 Flash 操作和跳转流程。
Modbus RTU 帧结构为:[ADDR][FUNC][DATA][CRC16]。我们约定:
- ADDR =
0x01(广播地址不支持,必须单点) - FUNC =
0x10(Write Multiple Registers),起始地址0x1000表示“升级命令”,0x1001表示“固件数据” - DATA 长度 ≤ 240 字节(Modbus 限制)
改造点仅在usart_receive_handler()中:
// modbus_parser.c typedef struct { uint8_t addr; uint8_t func; uint16_t reg_addr; uint16_t reg_count; uint8_t *data; } modbus_frame_t; uint8_t parse_modbus_frame(uint8_t *buf, uint16_t len, modbus_frame_t *out) { if (len < 8) return 0; // 最小帧:addr+func+4字节地址+2字节count+2字节CRC if (buf[0] != 0x01) return 0; // 只响应 addr=1 if (buf[1] != 0x10) return 0; // 只处理 Write Multiple out->reg_addr = (buf[2]<<8) | buf[3]; out->reg_count = (buf[4]<<8) | buf[5]; out->data = &buf[6]; // 校验 CRC16(使用 Modbus CRC) uint16_t crc_recv = (buf[len-2]<<8) | buf[len-1]; uint16_t crc_calc = modbus_crc16(buf, len-2); return (crc_recv == crc_calc) ? 1 : 0; } // 在串口接收完成中断中调用 void USART1_IRQHandler(void) { if (__HAL_USART_GET_FLAG(&huart1, USART_FLAG_IDLE) != RESET) { HAL_UART_DMAStop(&huart1); uint16_t rx_len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); modbus_frame_t frame; if (parse_modbus_frame(rx_buffer, rx_len, &frame)) { if (frame.reg_addr == 0x1000 && frame.reg_count == 1) { // 0x1000:1 表示“开始升级”,置位 pending flag set_upgrade_flag(); } else if (frame.reg_addr >= 0x1001) { // 0x1001 起为固件数据,按地址偏移写入 backup_buffer uint32_t offset = (frame.reg_addr - 0x1001) * 2; memcpy(backup_buffer + offset, frame.data, frame.reg_count * 2); } } HAL_UART_DMAStart(&huart1, rx_buffer, RX_BUFFER_SIZE); } }验证方法:用 QModMaster(免费 Modbus 主站工具)连接设备,向
0x1000写0x0001启动升级,再向0x1001开始批量写入固件数据。全程无需改硬件,只需替换 Bootloader 的协议解析模块。
5.2 差分升级支持:从“全量刷写”到“只传变化字节”
固件从 v1.0 升到 v2.0,往往只有 5% 代码变动。全量传输 512KB 固件,在 9600 波特率下需 6 分钟;而差分升级只需传 25KB 补丁,耗时 30 秒。本项目可无缝接入bsdiff工具链。
生成差分包(PC 端):
# 安装 bsdiff(Linux/macOS) sudo apt install bsdiff # 或 brew install bsdiff # 生成 v1.0 到 v2.0 的补丁 bsdiff old_firmware.bin new_firmware.bin patch.binBootloader 改造:在接收完patch.bin后,调用bspatch算法原地还原新固件:
// bspatch.c(精简版,仅支持 1:1 内存映射) void bspatch(uint8_t *old, uint8_t *new, uint8_t *patch) { // 解析 patch header(4字节 control len, 4字节 diff len, 4字节 extra len) uint32_t ctrl_len = *(uint32_t*)patch; uint32_t diff_len = *(uint32_t*)(patch+4); uint32_t extra_len = *(uint32_t*)(patch+8); uint8_t *ctrl = patch + 12; uint8_t *diff = ctrl + ctrl_len; uint8_t *extra = diff + diff_len; // 执行三段式 patch:copy + add + run uint32_t i = 0, j = 0; while (i < ctrl_len) { int32_t x = ((int32_t*)ctrl)[i++]; int32_t y = ((int32_t*)ctrl)[i++]; int32_t z = ((int32_t*)ctrl)[i++]; memcpy(new+j, old+j, x); j += x; // copy memcpy(new+j, diff, y); diff += y; j += y; // add memcpy(new+j, extra, z); extra += z; j += z; // run } }调用时机:在verify_app_image()之前,先用bspatch()将backup_buffer中的 patch 还原为完整新固件,再进行 CRC32 校验。
5.3 OTA 日志追溯:把每次升级的“谁、何时、为何、结果”刻进 Flash
产线 QA 要求:每台设备必须记录最近 5 次升级的完整日志,包括操作员 ID、升级时间、固件版本、CRC32、成功/失败状态、失败错误码。这些不能存在 RAM(掉电丢),也不能挤占 App 区,最佳位置是元数据区旁的专用日志页(0x080FE000)。
日志结构体(48 字节/条):
typedef struct { uint32_t timestamp; // Unix 时间戳 uint32_t version; // 0x01020304 表示 1.2.3.4 uint32_t crc32; // 固件 CRC32 uint8_t operator_id[16]; // ASCII 字符串,如 "ENG-007" uint8_t status; // 0=success, 1=fail-crc, 2=fail-flash, 3=timeout uint8_t reserved[15]; } ota_log_t;写入逻辑(原子操作):
void write_ota_log(ota_log_t *log) { static uint32_t log_base = 0x080FE000; static uint8_t log_index = 0; uint32_t page_addr = log_base + log_index * sizeof(ota_log_t); // 擦除所在页(日志页为 16KB,每页存 341 条) FLASH_ErasePage(page_addr); // 编程写入 FLASH_ProgramWord(page_addr, log->timestamp); FLASH_ProgramWord(page_addr+4, log->version); FLASH_ProgramWord(page_addr+8, log->crc32); for(int i=0; i<16; i++) { FLASH_ProgramByte(page_addr+12+i, log->operator_id[i]); } FLASH_ProgramByte(page_addr+28, log->status); log_index = (log_index + 1) % 341; // 循环覆盖 }从那以后我每次交付 Bootloader,都强制走一遍
write_ota_log()的单元测试:用 ST-Link 读取0x080FE000,确认前 48 字节是预期时间戳和状态。这招帮我避开了三次因 Flash 编程失败导致的日志静默问题。希望帮到你。
本文还有配套的精品资源,点击获取