# limited_mcu_diff_ota **Repository Path**: jaesoon/limited_mcu_diff_ota ## Basic Information - **Project Name**: limited_mcu_diff_ota - **Description**: 在资源受限的MCU上,通过diff的方式实现ota,不需要A/B分区,减少资源消耗(其实主要是为了在资源受限的设备上,实现OTA) - **Primary Language**: Unknown - **License**: GPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-16 - **Last Updated**: 2026-10-16 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # MCU 差量 OTA 内部 Flash 放不下一份备份固件时,用差量补丁就地升级。设备侧是可移植的 C,不依赖指令集;补丁在 PC 上生成。 已在 PC 上用仿真 Flash(含掉电、半扇区编程)跑通。还原代码用 Clang 交叉编译通过: | 目标 | 还原代码体积 | | --- | --- | | Cortex-M0 / M4 | 约 4.3–4.8 KB | | RV32 / RV64 | 约 5.2–5.4 KB | 运行时 RAM 是 **1 个擦除扇区** 再加几百字节栈。内部 Flash 额外只留 **3 个扇区**:1 个 scratch,2 个状态页。 ## 为什么不是 bsdiff `bsdiff` 压缩率好,但设备侧要做 bzip2 解压,窗口是几百 KB,MCU 放不下。这里的补丁是按扇区随机读取的指令流,设备只解当前这一个扇区。 ## 布局 ``` [bootloader][应用程序........][scratch 1扇区][status 2扇区] 补丁放在外部 SPI Flash,或内部单独划出的区域 ``` 和 AB 分区比,多出来的是 3 个扇区加上补丁本身,不是一整份固件。 ## 补丁里有什么 按新固件顺序描述每个变化过的扇区: | 指令 | 含义 | | --- | --- | | COPY | 从旧固件原样拷一段(函数被挪走时很短) | | XOR | 从旧固件拷一段,再异或少量差异(地址微调) | | LIT | 新插入的原始字节 | | FILL | 连续相同字节,常见于 `0x00` / `0xFF` 填充 | 完全相同的扇区不进计划,也不会被擦写。 设备不自己分析依赖。生成补丁时已经排好顺序:一个扇区被覆盖之前,还需要它的旧数据的步骤都已完成;遇到循环依赖,先把旧扇区拷进 scratch。因此任意时刻只需要这一个备份扇区。逐步怎么匹配、切扇区、排计划、装进文件,见 [docs/生成补丁.md](docs/生成补丁.md)。 ## 跨版本 设备上只保留一份固件,不能在板上把 v1 先变成 v2 再变成 v5。正确做法是: 1. 服务器保存「每个历史版本 → 最新版」的补丁。 2. 设备上报当前固件 CRC。 3. 服务器下发 **old_crc 完全一致** 的那一个补丁。 4. 补丁体积相对新固件超过约 30% 就不要发。`otadiff gen` 在这种情况下仍会写出文件,但进程退出码是 2。 差量小的前提是两版二进制仍然像:插入一段代码、改几个常量、把函数挪个位置,COPY/XOR 都接得住。若链接器把函数顺序重排了一遍,补丁会接近整包,这时应插入一个中间版本,或改发全量。 实测:32 KB 固件在中间插入 128 字节并改 2 个字节,补丁 **283 字节(不到 1%)**。 让差量保持小的编译习惯: - 链接地址固定,不要每次都改起始地址。 - 用稳定的链接脚本固定段顺序,避免 `.text` 里函数随编译随机换位。 - 新增代码尽量放在镜像末尾或独立 section。 - 版本字符串放在固定地址的小段里,避免它把后面的内容全部挤开。 ## 断电 状态写在两个扇区里乒乓保存,只保留最近一次成功的检查点: 1. `BEGIN`:补丁和旧固件都核对过,应用区还没动。 2. `SPILLED`:本扇区的旧数据已在 scratch,目标扇区可以重写。 3. `STAGED`:本扇区的新数据已在 scratch,再擦目标扇区。 4. `DONE`:这一扇区已经写完并读回校验。 5. `COMPLETE`:整包 CRC 通过。 掉电后用**同一个补丁**再调用 `ota_apply()`。写到一半的扇区会整扇区擦掉重来。补丁必须事先完整落盘,因为续传要重新读取它。 ## 移植到 ARM 和 RISC-V 差分引擎只做字节读写,用 `memcpy` 组多字节整数,Cortex-M0 和 RISC-V 都不会因为未对齐访问而 fault。需要你提供的是 Flash 和补丁读取: ```c ota_port_t port; uint8_t sector[SECTOR_SIZE]; /* 不要放在过小的栈上 */ port.app_base = 0x08004000; port.app_sectors = 64; port.sector_size = 2048; /* 必须等于补丁里的扇区大小,且为 2 的幂 */ port.scratch_base = ...; /* 1 个扇区 */ port.status_base = ...; /* 紧接着 2 个扇区 */ port.write_align = 4; /* 编程粒度 4 或 8 */ port.read = ...; port.write = ...; /* 调用前该扇区已擦除 */ port.erase = ...; port.patch_read = ...; /* 按偏移读补丁 */ port.yield = kick_watchdog; if (ota_apply(&port, sector) == OTA_OK) ota_boot_application(port.app_base); ``` `write` 要按芯片的编程宽度拆开(很多 ARM 是 4 字节,部分 RISC-V 是 8 字节)。库保证传下来的地址和长度按 `write_align` 对齐。 同一条 Flash bank 在擦除时不能取指。要么把 `ota_apply` 链进 RAM,要么在 `yield` 里喂狗并接受 CPU 在擦除期间停住。 跳转到新固件见 `include/ota_boot.h`: - Cortex-M:读向量表里的 MSP 和复位地址,必要时改 `VTOR`。 - 常见 RISC-V:直接跳到镜像入口。 - 沁恒 CH32(QingKe)虽然是 RISC-V,向量表却和 Cortex-M 一样,要走前一种。 模板在 `port/port_template.c`。 ## 编译和试跑 需要 GCC(已在 MSYS2 mingw64 下验证): ```text gcc -std=c11 -Wall -Wextra -Werror -Os -Iinclude -Isrc -o otadiff.exe ^ src/ota_crc32.c src/ota_apply.c src/ota_generate.c ^ tools/otadiff.c tools/flash_sim.c tools/test_ota.c otadiff test otadiff gen old.bin new.bin patch.odf 4096 otadiff info patch.odf ``` 不编译 C 生成器时,用标准库脚本生成同一格式: ```text python tools/odf_gen.py old.bin new.bin patch.odf --sector 4096 --old-ver 1 --new-ver 5 ``` 补丁相对新固件超过 30% 时文件仍会写出,退出码为 2。`--max-ratio 0` 关闭这项检查。生成后可以用 `otadiff apply old.bin patch.odf out.bin` 核对还原结果。 `sector_size` 必须等于目标芯片的擦除扇区(或擦除扇区的整数倍,且设备一次能在 RAM 里放下它)。设备上的 `ota_port_t.sector_size` 要和生成补丁时一致。 固件工程只编译 `src/ota_crc32.c` 和 `src/ota_apply.c`,不要把 `ota_generate.c` 链进去。生成补丁用 PC 上的 `otadiff gen` 或 `tools/odf_gen.py`。