从零理解差分升级 JojoDiff + Uzlib:一份补丁,怎样还原成新固件?
我们已经能在电脑上生成升级、回退差分包,也能用上位机验证还原。但真正准备移植时,仍可能卡在这些问题上:旧程序地址填在哪里?谁负责解压?为什么需要回调?函数返回后,怎样知道新程序可靠?
本文依据当前“差分升级工具”上位机的还原验证实际 C 源码,使用“内存中的旧程序、完整差分包、独立输出区域”讲清整条链路。暂不涉及 Flash 擦除、编程对齐、Bootloader 切换和断电恢复。
读完后,你应当能理解四个读写接口的作用,知道初始化、还原、结束检查和 CRC 校验的调用顺序,并能区分哪些代码属于算法、哪些属于设备适配。
本文使用本项目约定的56字节小端头部。它是应用层协议,不是 JojoDiff 或 Uzlib 的通用标准。当前包不包含通信分包大小、总包数。其他项目采用不同协议时,应先核对包格式。
一、先用“修改一本书”理解差分还原
假设旧程序是一段内容:
旧内容:ABCDEF 新内容:ABCXYZ完整更新可以直接发送ABCXYZ;差分更新则可以表达成“保留 ABC,把后面的内容改成 XYZ”。设备持有旧内容,再执行修改说明,得到新内容。
这只是理解方式,不是 JojoDiff 实际补丁的字节编码。真正的固件是任意二进制数据,真实补丁由工具生成,不需要你手写。
这条链上各组件的分工如下:
| 组件 | 在哪里用 | 负责什么 |
|---|---|---|
| JojoDiff | 电脑制包 | 比较旧、新文件,生成原始差分补丁 |
| 压缩编码器 | 电脑制包 | 压缩原始补丁,降低传输量 |
| Uzlib | 设备还原 | 解开压缩,提供原始补丁字节 |
| Janpatch | 设备还原 | 解释补丁,读取旧程序,生成新程序 |
| 你编写的应用层 | 设备还原 | 解析包头、配置读写、组织调用和校验 |
**目的:先分清“谁做什么”。**设备还原不需要运行 JojoDiff.exe,也不需要 Python。它需要的是 C 源码、读写接口和正确的输入。
差分并不保证所有文件都能生成更小的升级包。文件变化程度、固件布局及压缩效果都会影响结果。本文先解决“怎样正确还原”。
二、准备三个互不重叠的数据区域
先不管具体 MCU,也不管存储器型号。只假设应用能访问三个区域:
区域A:旧程序 已存在,只读 区域B:完整差分包 已接收完整,只读 区域C:新程序输出 容量足够,用于保存结果区域C不能覆盖区域A或区域B。还原过程仍然可能读取旧程序和补丁,过早覆盖会破坏后续输入。
| 需要准备的信息 | 用途 |
|---|---|
| 旧程序起点、实际长度 | 为旧程序读取接口提供边界 |
| 完整差分包起点、实际长度 | 读取包头、验证包完整性 |
| 新程序输出起点、可用容量 | 防止还原输出越界 |
这里的“起点”可以是 RAM 指针,也可以是外部存储的逻辑地址。之后讲到回调时,你会看到算法并不必直接持有真实硬件地址。
**目的:先把数据放对地方,再启动算法。**当前阶段不讨论直接覆盖旧程序。
三、差分包里装了什么?先认清三个长度
完整包的布局为:
完整差分包起点 │ ├── 0~55:56字节头部 └── 56开始:压缩后的原始补丁当前头部定义:
| 字节偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 16 | 旧版本名称,UTF-8,未用字节补0 |
| 16 | 16 | 新版本名称,UTF-8,未用字节补0 |
| 32 | 4 | 压缩有效数据大小,不含56字节头部 |
| 36 | 4 | 旧程序大小 |
| 40 | 4 | 新程序大小 |
| 44 | 4 | 解压后的原始差分补丁大小 |
| 48 | 4 | 旧程序 CRC32 |
| 52 | 4 | 新程序 CRC32 |
四字节数值使用小端格式。例如0x00001234在文件中是34 12 00 00。
最需要区分的三个长度是:
| 名称 | 交给谁 | 含义 |
|---|---|---|
| 压缩有效数据大小 | zip.size | Uzlib 一共能读取多少压缩字节 |
| 原始补丁大小 | patch.patch.size | Janpatch 一共能读取多少解压后补丁字节 |
| 新程序大小 | patch.target.size | 最终应生成多少字节 |
**目的:让每层知道自己的数据边界。**压缩字节的位置,不能直接等同于解压后补丁的位置。
再明确一下压缩术语:本项目使用 DEFLATE 压缩、gzip 封装,解压由 Uzlib 完成,压缩窗口限制为4 KiB。Uzlib 是库名,gzip 是封装格式,它们不是二选一的概念。
如果导入的是 HEX,上位机会先按约定转成连续字节镜像,地址空洞填0xFF。设备做旧程序 CRC 时,也必须读到相同的字节内容和相同长度,不能仅拼接 HEX 中实际出现的数据记录。
四、为什么要用回调?先理解“库向你要数据”
你无需把所有硬件驱动写进算法。只要告诉库:
当你要读旧程序时,调用这个函数;当你要写新程序时,调用另一个函数。
你注册进去的函数就是回调。你负责实现它,库负责在需要时调用它。
图中箭头表示“调用/请求”;数据通过回调返回给调用者。当前有四个连接点:
| 回调 | 谁调用它 | 你需要提供什么 |
|---|---|---|
read_zip | Uzlib 适配层 | 指定偏移处的压缩字节 |
read_old | Janpatch 适配层 | 指定偏移处的旧程序字节 |
read_patch | Janpatch 适配层 | 调用decompress()取得原始补丁 |
write_new | Janpatch 适配层 | 将生成的数据保存到输出区域 |
这些回调名称来自当前上位机验证封装。你的应用可以换函数名,但签名和职责要匹配。
一个容易误解的地方:不是先完整解压,再开始还原
你只需启动janpatch_cli()。它需要补丁字节时,调用read_patch(),后者再调用decompress()。Uzlib 需要更多压缩输入时,再调用read_zip()。
因此实际执行类似:
Janpatch 需要一段原始补丁 → read_patch() → decompress() → 必要时 read_zip() ← 返回解压后的补丁字节 → 根据补丁读取旧程序 → 将新程序片段交给 write_new() → 重复,直到完成**目的:不必为完整解压补丁准备同等大小的 RAM。**但解压字典和工作缓存仍需要 RAM,后文会计算。
此处是“从已保存的完整包中按需读取”,不是“网络数据还没接收完整就开始还原”。
五、按十步走完一次还原
第1步:解析头部——先知道输入的含义
**做什么:**从完整包前56字节取出版本、长度和 CRC。
**为什么:**后续解压、还原、容量检查都需要这些信息。
**怎样做:**按字节解析小端整数,避免依赖结构体对齐和 CPU 字节序。
staticuint32_tread_le32(constuint8_t*p){return(uint32_t)p[0]|((uint32_t)p[1]<<8)|((uint32_t)p[2]<<16)|((uint32_t)p[3]<<24);}/* 调用前必须确认包至少有56字节 */payload_size=read_le32(package+32);old_size=read_le32(package+36);new_size=read_le32(package+40);patch_size=read_le32(package+44);这里以及后文的代码是分步讲解片段,不能直接拼接为完整可运行程序。已有完整流程请查看项目FotaDesktop/native/bridge.c。
版本字段最多占满16字节,不保证有字符串结束符。打印前复制到17字节数组,额外补\0。版本名称只是应用元数据,Janpatch 不会自动比较设备版本。
第2步:检查长度与输出范围——不要先写了再发现放不下
**做什么:**核对输入和输出边界。
**为什么:**包头声明的大小不能直接当成可信的存储范围。
至少检查:
- 完整包实际长度不小于56。
payload_size == 完整包实际长度 - 56。- 各必要长度非零,且能被当前接口类型表示。
- 新程序大小不超过输出容量。
- 三个区域互不重叠,地址计算不溢出。
**通过标志:**所有字段在有效范围内,输入完整、输出有足够空间。
第3步:核对旧程序——确保拿对了“原书”
**做什么:**核对旧程序长度,再计算旧程序 CRC32。
**为什么:**补丁是针对某份旧镜像生成的。选错旧文件,即使版本名称相近,也不能继续还原。
当前项目使用的 CRC 调用规则为:
uint32_tcrc=0xFFFFFFFFU;crc=uzlib_crc32(old_data,chunk_length,crc);/* 如果分块读取,在块之间继续沿用crc */crc=~crc;/* 所有块处理完,最后仅取反一次 */上面old_data和chunk_length表示本次处理的数据块。整块位于 RAM 时也可以一次处理,前提是长度参数可表示。
**通过标志:**实际长度与头部一致,实际 CRC 与偏移48处的旧 CRC 一致。
CRC用于检查内容一致性和意外损坏,不等于固件签名;本文只讲还原与完整性验证。
第4步:配置并初始化 Uzlib——接好压缩输入
**做什么:**提供压缩输入缓存、长度和读取函数。
uint8_tinput_buffer[512];Unzip_Steam_T zip={0};zip.databuf=input_buffer;zip.buflen=sizeof(input_buffer);zip.size=payload_size;/* 不含56字节头部 */zip.read=read_zip;intstatus=unzip_init(&zip);if(status!=TINF_OK){/* 停止:压缩头或初始化不通过 */}**为什么:**Uzlib 要知道“字节从哪里来、最多读多少、暂存在哪里”。
通过标志:unzip_init()返回TINF_OK。这只表明初始化/头解析通过,不代表整个压缩数据已经验证完成。
zip和input_buffer必须一直有效到结束校验完成。不能在一个临时函数内初始化局部变量后返回,再在其他地方继续使用它们。
第5步:配置 Janpatch 三个流——旧、补丁、新各归其位
Janpatch_Ctrl_T patch={0};patch.source.size=old_size;patch.source.read=read_old;patch.patch.size=patch_size;/* 解压后原始补丁长度 */patch.patch.read=read_patch;patch.target.size=new_size;patch.target.write=write_new;patch.janpatch_progress_callback=NULL;**为什么:**算法只认识三个逻辑流,需要应用把它们连接到正确输入输出。
**通过标志:**回调有效,各大小正确。当前janpatch_cli()会把三个流的位置重置为0。
注意头文件实际叫janpatch_cil.h,虽然函数和源文件使用janpatch_cli,不要凭印象写成另一个文件名。
第6步:启动还原——让库完成数据搬运与修改
intgenerated=janpatch_cli(&patch);**为什么:**这一步启动真正的补丁解析和新程序输出。
当前适配层已经准备工作缓存,并处理解压补丁流所需的有限回读缓存。初次移植时保留janpatch_cli.c,不要直接绕过它调用底层核心。
| 函数 | 成功返回 | 失败返回 |
|---|---|---|
janpatch_cli() | 生成的新程序字节数 | -1 |
底层janpatch() | 0 | 非0 |
不要把这两个函数的返回值语义混淆。
第7步:完成解压校验——拿到了补丁,还要确认压缩流正常结束
intfinish=decompress_finish_chksum();if(finish!=TINF_DONE){/* 失败:不能认可输出 */}**为什么:**恰好取得指定补丁字节,不代表压缩流结束标记和尾部校验已通过。
当前适配层要求一个完整 gzip 成员,并拒绝额外解压数据、截断、尾随数据及尾部校验错误。
**通过标志:**返回TINF_DONE,不是TINF_OK。前者表示完成,后者在初始化场景中表示可继续。
第8步:检查新程序长度——数量要对
if(generated<0||(uint32_t)generated!=new_size){/* 失败:还原或输出长度不符合预期 */}**为什么:**输出少了或多了都不能视为目标镜像。
**通过标志:**返回值非负,且等于包头声明的新程序大小。
第9步:核对新程序 CRC——内容也要对
**做什么:**读取实际生成的新程序,按第3步相同规则计算 CRC,与头部偏移52处的值比较。
**为什么:**相同字节数也可能包含不同内容。长度匹配只能证明数量,不能证明内容。
**通过标志:**新程序 CRC 匹配。实验时,如果手头还有原始新 BIN,最好再逐字节比较一次。
第10步:宣布成功——成功是整条链路的结论
只有此前检查全部通过,才把输出标记为有效。
| 最终检查项 | 对应目的 |
|---|---|
| 旧程序长度及CRC | 基础镜像正确 |
| 解压结束校验 | 压缩输入完整且正常结束 |
| 新程序长度 | 还原输出数量正确 |
| 新程序CRC | 还原内容匹配目标 |
**不要把“write_new被调用了”“输出区有数据了”当成功。**失败前可能已经写入了一部分结果,这些数据应保持无效状态。
六、把四个回调写成最容易理解的内存版本
以下示例要求输入输出已经是有效内存指针,且上层完成长度、类型和区域不重叠检查。它不适用于直接访问任意 MCU 的 Flash 地址,仅用于理解当前电脑验证封装。
staticconstuint8_t*old_data;staticconstuint8_t*zip_data;staticuint8_t*new_data;staticuint32_told_length,zip_length,new_length;/* 绑定时:zip_data = package + 56;只在这一层跳过包头。 */staticuint16_tread_zip(void*dst,uint16_tn,uint32_tpos){if(pos>zip_length||n>zip_length-pos)return0;memcpy(dst,zip_data+pos,n);returnn;}staticsize_tread_old(void*dst,size_tn,longpos){if(pos<0||(uint32_t)pos>old_length||n>old_length-(uint32_t)pos)return0;memcpy(dst,old_data+pos,n);returnn;}staticsize_tread_patch(void*dst,size_tn,longpos){(void)pos;/* 由当前janpatch_cli适配层管理有限回读 */if(n>UINT16_MAX)return0;returndecompress(dst,(uint16_t)n);}staticsize_twrite_new(void*src,size_tn,longpos){if(pos<0||(uint32_t)pos>new_length||n>new_length-(uint32_t)pos)return0;memcpy(new_data+pos,src,n);returnn;}回调有三个容易犯错的点:
- **返回字节数。**底层驱动如果“返回0表示成功”,必须在回调里转换为成功返回
n,否则 Janpatch 会认为读取/写入失败。 - 旧程序必须按位置读取。
read_old不能忽略pos,始终从上次位置往后读。 - 补丁流不是压缩流。
read_patch的pos属于原始补丁;不能拿它去计算压缩存储地址。当前流适配缓存与顺序解压配套,不能随意替换掉缓存逻辑。
Janpatch适配层要求每次有效读取/写入请求完整完成;底层驱动分多次操作时,应在回调内部完成再返回。Uzlib压缩输入回调可以提供不超过请求的有效字节,但为了初次移植易于检查,建议在边界内也读满请求。
七、移植时到底复制哪些文件?改哪些接口?
当前项目实际参与 DLL 构建的算法及适配源文件为:
Janpatch/Src/janpatch.c Janpatch/Src/janpatch_cli.c Uzlib/Src/tgunzip.c Uzlib/Src/tinflate.c Uzlib/Src/tinfgzip.c Uzlib/Src/crc32.c Uzlib/Src/adler32.c加入对应Janpatch/Inc和Uzlib/Inc头文件路径,并保留所需头文件。
| 层次 | 初次移植时怎么做 | 目的 |
|---|---|---|
| Janpatch、Uzlib算法 | 保留当前优化版本 | 不在接硬件前引入算法变动 |
janpatch_cli.c、tgunzip.c | 保留现有流缓存和错误处理 | 维持顺序解压与补丁读取的配合 |
main.h依赖 | 提供标准类型和必要声明 | 脱离上一个芯片工程的头文件依赖 |
| 应用层 | 参考bridge.c组织头部、校验和调用 | 将包协议连接到算法 |
| 读写回调 | 先用内存,后接存储器驱动 | 把硬件差异限制在接口处 |
bridge.c中的 Windows DLL 导出宏不属于嵌入式算法要求。设备直接编译 C 源文件,不需要 ctypes,也不需要 Windows DLL。
当前优化后的适配层使用静态全局状态,属于单实例实现。一次还原必须完成后才能开始下一次;不要在多个任务或中断里同时调用。
本项目先前仅更新上位机C封装的56字节头部解析,没有同步改原嵌入式通信协议实例。接入新包时,应以本文及当前bridge.c为依据,自行衔接应用层头部处理。
八、需要多少 RAM?8位单片机能不能用?
当前主要缓存为:
| 缓存 | 字节数 |
|---|---|
| Uzlib 历史字典,4 KiB | 4096 |
| Janpatch 旧程序/补丁/输出工作缓存,3×512 | 1536 |
| 原始补丁当前页与上一页缓存,2×512 | 1024 |
| 应用提供的压缩输入缓存 | 512 |
| 小计 | 7168,即7 KiB |
另外还要计算结构体、调用栈、应用数据。如果用整块 RAM 做验证,旧程序、完整包及新程序所占 RAM 也需另算。实际设备可以通过回调把三份数据放在其他存储中,工作缓存则仍然存在。
不能只看 MCU 是8位还是32位。还需要检查:
- RAM是否足够。
int、size_t、long能否表示镜像长度及偏移。- 编译器对标准类型、运算和存储区指针的支持。
- 程序存储器、外部存储器如何通过驱动访问。
当前janpatch_cli()返回int,还要求旧程序、原始补丁、新程序大小以及旧程序与补丁大小之和不超过相关INT_MAX检查。若目标编译器的int为16位,常见上限32767会成为限制;size_t过窄也会限制流长度。
CRC接口的单次长度类型为unsigned int,小位宽平台需分块计算。
因此,当前实现不是“所有 MCU 只换回调即可原样运行”的承诺。先理解接口,再按目标平台评估类型和资源,才能判断是否需要进一步改造。
九、没有开发板,也能做的四组小实验
可以先使用已有上位机的双向生成和C库验证功能,观察结果;有C调试环境时,再沿bridge.c下断点。
| 实验 | 怎么做 | 预期现象 | 想理解什么 |
|---|---|---|---|
| 正常还原 | 旧BIN + 对应压缩升级包 | 旧CRC、解压、长度、新CRC都通过 | 完整成功链路 |
| 用错旧程序 | 保持长度相同,替换一字节的旧BIN再验证 | 旧CRC不匹配 | 长度相同不代表镜像相同 |
| 包被截断 | 复制包,删除末尾一个字节 | 包长度检查失败 | 越早检查,越早阻止无效输入 |
| 反向恢复 | 使用“新到旧”包,输入原新BIN | 输出与原旧BIN一致 | 回退包同样遵循源到目标的规则 |
“删除字节后失败”首先验证的是包总长度,不等于专门测到解压器。若要测试解压完整性,应使用保持包总长度不变的损坏样本,并记录实际失败阶段;不要把不同层的失败混为一谈。
后续调试C接口时,还可以观察:
read_zip: pos、请求长度、返回长度 read_old: pos、请求长度、返回长度 read_patch: 请求长度、实际解压长度 write_new: pos、写入长度、返回长度先用很小的样本,日志会更容易读。调试日志仅记录偏移和长度就能看清调用关系,不必打印整份固件内容。
十、初学者常见问题
为什么只把完整包地址交给 Janpatch 不行?
完整包包含应用头和压缩数据,而 Janpatch需要的是解压后的原始补丁,必须经过应用解析和Uzlib。
unzip_init()成功,为什么还要结束校验?
初始化只是准备状态并解析压缩头,完整输入是否结束正常,要等数据消费及尾部检查。
为什么输出空间要单独准备?
还原时仍可能读取旧程序和差分包。当前教程使用独立区域,避免写入破坏输入。
上位机验证成功,能否证明任意开发板都能直接使用?
它证明了当前样本在这套实际C算法和电脑内存适配下可以还原。目标编译器、资源、地址类型和设备读写驱动仍需验证。
以后接Flash,主要变化在哪里?
旧程序读取、压缩包读取、新程序写入及CRC读取要连接真实存储器驱动;再处理擦除、写入粒度、缓存和错误上报。本文先不展开这些实现。