从零理解差分升级 JojoDiff + Uzlib:一份补丁,怎样还原成新固件? 从零理解差分升级 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 指针也可以是外部存储的逻辑地址。之后讲到回调时你会看到算法并不必直接持有真实硬件地址。**目的先把数据放对地方再启动算法。**当前阶段不讨论直接覆盖旧程序。三、差分包里装了什么先认清三个长度完整包的布局为完整差分包起点 │ ├── 05556字节头部 └── 56开始压缩后的原始补丁当前头部定义字节偏移长度内容016旧版本名称UTF-8未用字节补01616新版本名称UTF-8未用字节补0324压缩有效数据大小不含56字节头部364旧程序大小404新程序大小444解压后的原始差分补丁大小484旧程序 CRC32524新程序 CRC32四字节数值使用小端格式。例如0x00001234在文件中是34 12 00 00。最需要区分的三个长度是名称交给谁含义压缩有效数据大小zip.sizeUzlib 一共能读取多少压缩字节原始补丁大小patch.patch.sizeJanpatch 一共能读取多少解压后补丁字节新程序大小patch.target.size最终应生成多少字节**目的让每层知道自己的数据边界。**压缩字节的位置不能直接等同于解压后补丁的位置。再明确一下压缩术语本项目使用 DEFLATE 压缩、gzip 封装解压由 Uzlib 完成压缩窗口限制为4 KiB。Uzlib 是库名gzip 是封装格式它们不是二选一的概念。如果导入的是 HEX上位机会先按约定转成连续字节镜像地址空洞填0xFF。设备做旧程序 CRC 时也必须读到相同的字节内容和相同长度不能仅拼接 HEX 中实际出现的数据记录。四、为什么要用回调先理解“库向你要数据”你无需把所有硬件驱动写进算法。只要告诉库当你要读旧程序时调用这个函数当你要写新程序时调用另一个函数。你注册进去的函数就是回调。你负责实现它库负责在需要时调用它。图中箭头表示“调用/请求”数据通过回调返回给调用者。当前有四个连接点回调谁调用它你需要提供什么read_zipUzlib 适配层指定偏移处的压缩字节read_oldJanpatch 适配层指定偏移处的旧程序字节read_patchJanpatch 适配层调用decompress()取得原始补丁write_newJanpatch 适配层将生成的数据保存到输出区域这些回调名称来自当前上位机验证封装。你的应用可以换函数名但签名和职责要匹配。一个容易误解的地方不是先完整解压再开始还原你只需启动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_sizeread_le32(package32);old_sizeread_le32(package36);new_sizeread_le32(package40);patch_sizeread_le32(package44);这里以及后文的代码是分步讲解片段不能直接拼接为完整可运行程序。已有完整流程请查看项目FotaDesktop/native/bridge.c。版本字段最多占满16字节不保证有字符串结束符。打印前复制到17字节数组额外补\0。版本名称只是应用元数据Janpatch 不会自动比较设备版本。第2步检查长度与输出范围——不要先写了再发现放不下**做什么**核对输入和输出边界。**为什么**包头声明的大小不能直接当成可信的存储范围。至少检查完整包实际长度不小于56。payload_size 完整包实际长度 - 56。各必要长度非零且能被当前接口类型表示。新程序大小不超过输出容量。三个区域互不重叠地址计算不溢出。**通过标志**所有字段在有效范围内输入完整、输出有足够空间。第3步核对旧程序——确保拿对了“原书”**做什么**核对旧程序长度再计算旧程序 CRC32。**为什么**补丁是针对某份旧镜像生成的。选错旧文件即使版本名称相近也不能继续还原。当前项目使用的 CRC 调用规则为uint32_tcrc0xFFFFFFFFU;crcuzlib_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.databufinput_buffer;zip.buflensizeof(input_buffer);zip.sizepayload_size;/* 不含56字节头部 */zip.readread_zip;intstatusunzip_init(zip);if(status!TINF_OK){/* 停止压缩头或初始化不通过 */}**为什么**Uzlib 要知道“字节从哪里来、最多读多少、暂存在哪里”。通过标志unzip_init()返回TINF_OK。这只表明初始化/头解析通过不代表整个压缩数据已经验证完成。zip和input_buffer必须一直有效到结束校验完成。不能在一个临时函数内初始化局部变量后返回再在其他地方继续使用它们。第5步配置 Janpatch 三个流——旧、补丁、新各归其位Janpatch_Ctrl_T patch{0};patch.source.sizeold_size;patch.source.readread_old;patch.patch.sizepatch_size;/* 解压后原始补丁长度 */patch.patch.readread_patch;patch.target.sizenew_size;patch.target.writewrite_new;patch.janpatch_progress_callbackNULL;**为什么**算法只认识三个逻辑流需要应用把它们连接到正确输入输出。**通过标志**回调有效各大小正确。当前janpatch_cli()会把三个流的位置重置为0。注意头文件实际叫janpatch_cil.h虽然函数和源文件使用janpatch_cli不要凭印象写成另一个文件名。第6步启动还原——让库完成数据搬运与修改intgeneratedjanpatch_cli(patch);**为什么**这一步启动真正的补丁解析和新程序输出。当前适配层已经准备工作缓存并处理解压补丁流所需的有限回读缓存。初次移植时保留janpatch_cli.c不要直接绕过它调用底层核心。函数成功返回失败返回janpatch_cli()生成的新程序字节数-1底层janpatch()0非0不要把这两个函数的返回值语义混淆。第7步完成解压校验——拿到了补丁还要确认压缩流正常结束intfinishdecompress_finish_chksum();if(finish!TINF_DONE){/* 失败不能认可输出 */}**为什么**恰好取得指定补丁字节不代表压缩流结束标记和尾部校验已通过。当前适配层要求一个完整 gzip 成员并拒绝额外解压数据、截断、尾随数据及尾部校验错误。**通过标志**返回TINF_DONE不是TINF_OK。前者表示完成后者在初始化场景中表示可继续。第8步检查新程序长度——数量要对if(generated0||(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(poszip_length||nzip_length-pos)return0;memcpy(dst,zip_datapos,n);returnn;}staticsize_tread_old(void*dst,size_tn,longpos){if(pos0||(uint32_t)posold_length||nold_length-(uint32_t)pos)return0;memcpy(dst,old_datapos,n);returnn;}staticsize_tread_patch(void*dst,size_tn,longpos){(void)pos;/* 由当前janpatch_cli适配层管理有限回读 */if(nUINT16_MAX)return0;returndecompress(dst,(uint16_t)n);}staticsize_twrite_new(void*src,size_tn,longpos){if(pos0||(uint32_t)posnew_length||nnew_length-(uint32_t)pos)return0;memcpy(new_datapos,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为依据自行衔接应用层头部处理。八、需要多少 RAM8位单片机能不能用当前主要缓存为缓存字节数Uzlib 历史字典4 KiB4096Janpatch 旧程序/补丁/输出工作缓存3×5121536原始补丁当前页与上一页缓存2×5121024应用提供的压缩输入缓存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读取要连接真实存储器驱动再处理擦除、写入粒度、缓存和错误上报。本文先不展开这些实现。