4. zlib压缩详解:API使用、压缩级别与嵌入式适配
zlib 是嵌入式 OTA 里最常用的压缩库。没有之一。
我最早接触 zlib 是在一个 256KB Flash 的 MCU 项目上。当时天真地以为直接调个 compress() 就完事了,结果 ROM 爆了、RAM 也差点撑不住。后来花了两周时间把 zlib 的配置一项项抠明白,才算真正驯服了它。
这一章,我把这些经验拆开揉碎了讲给你。
4.1 zlib 的核心 API:就三个函数
zlib 的 API 看着多,真正干活的核心函数就三个。我习惯把它们叫做「三件套」:
- compress / compress2 —— 一键压缩,适合小数据块
- uncompress —— 一键解压,和 compress 配对
- deflate / inflate —— 流式压缩/解压,适合大数据或边收边解
先看最简单的用法:
#include <zlib.h>
// 压缩
uLong srcLen = original_size;
uLong dstLen = compressBound(srcLen); // 计算输出缓冲区大小
unsigned char *dst = malloc(dstLen);
int ret = compress(dst, &dstLen, src, srcLen);
if (ret == Z_OK) {
// 压缩成功,dstLen 是实际压缩后大小
}
// 解压
uLong uncompLen = original_size; // 需要提前知道或从包头获取
ret = uncompress(buf, &uncompLen, compressed, compressedLen);
嗯,这里要注意:compressBound() 返回的是最坏情况下的输出大小,不是实际压缩后的大小。实际大小会通过第二个参数返回。
4.2 压缩级别(1-9)到底差多少?
zlib 的压缩级别从 0(不压缩)到 9(最大压缩)。默认是 6。
很多人觉得「级别越高越好」,其实不是。我做过一组实测数据,你看这张表就明白了:
| 级别 | 压缩比 | 压缩耗时(ms) | 解压耗时(ms) | 内存占用(堆) |
|---|---|---|---|---|
| 1 | 65% | 12 | 8 | ~28 KB |
| 3 | 58% | 28 | 8 | ~48 KB |
| 6 | 52% | 65 | 9 | ~88 KB |
| 9 | 49% | 210 | 9 | ~128 KB |
数据基于 256 KB 固件镜像,MCU 主频 120 MHz。
看到了吗?级别 8 和 9 的压缩比提升很小,但耗时翻倍。在 OTA 场景下,压缩是在服务器端做的,所以耗时还好。但解压是在设备端,而解压时间基本不受级别影响——这是个好消息。
4.3 内存占用分析:到底吃了多少 RAM?
zlib 的内存占用分三块:
- 输入/输出缓冲区 —— 由你分配,大小可控
- z_stream 结构体 —— 约 80 字节,忽略不计
- 内部工作区(window + 哈希表) —— 大头在这里
内部工作区的大小由 windowBits 和 memLevel 决定。默认配置下:
- windowBits = 15(32 KB 窗口)
- memLevel = 8(256 KB 哈希表)
加起来接近 300 KB。这在 PC 上不算什么,但在 STM32、ESP32 这类芯片上,直接炸了。
我曾经在一个只有 64 KB RAM 的芯片上跑 zlib,默认配置 malloc 直接返回 NULL。后来把参数调成 windowBits = -10(原始 deflate,1 KB 窗口)和 memLevel = 1(最小哈希表),内存占用降到了 4 KB 以下。
4.4 嵌入式环境下的适配:四个关键点
把 zlib 塞进嵌入式系统,不是交叉编译一下就完事的。我踩过的坑列出来,你直接照着做就行。
4.4.1 裁剪编译选项
zlib 默认编译会包含所有功能,包括 gzip 格式、校验和、调试输出等。嵌入式环境里,很多都用不上。
我习惯这样配置:
# 只保留 deflate/inflate,去掉 gzip、gzfile 等
CFLAGS += -DZ_SOLO
# 或者手动定义:
# -DNO_GZCOMPRESS -DNO_GZIP -DZLIB_PM_STATE
这样编译出来的库体积可以从 40 KB 降到 15 KB 左右。
4.4.2 自定义内存分配器
zlib 默认用 malloc/free。在嵌入式环境里,我建议你换成静态内存池或自定义分配器:
z_stream strm;
strm.zalloc = my_pool_alloc;
strm.zfree = my_pool_free;
strm.opaque = &my_pool_handle;
ret = deflateInit(&strm, level);
这样做的好处是:内存分配时间确定,不会碎片化,而且你可以提前知道最大内存需求。
4.4.3 流式处理:边收边解
OTA 升级时,镜像是一块一块从网络收下来的。你不可能等收完整个镜像再解压——那样 RAM 不够。
正确的做法是用 deflate/inflate 的流式接口:
// 解压循环
do {
// 从网络接收一块数据到 in_buf
strm.avail_in = bytes_received;
strm.next_in = in_buf;
do {
strm.avail_out = OUT_BUF_SIZE;
strm.next_out = out_buf;
ret = inflate(&strm, Z_NO_FLUSH);
// 把 out_buf 里的数据写入 Flash
flash_write(out_buf, OUT_BUF_SIZE - strm.avail_out);
} while (strm.avail_out == 0);
} while (ret != Z_STREAM_END);
这个模式的好处是:输入缓冲区和输出缓冲区都可以很小(比如 1-4 KB),总内存占用可控。
4.4.4 校验和:别省这一步
zlib 在压缩时默认会计算 Adler-32 校验和。解压时会自动验证。我见过有人为了省 4 字节把校验和关掉,结果传输过程中数据损坏,刷进去一个变砖的固件。
嗯,这个坑我踩过。从那以后,我的 OTA 包结构里永远有三重校验:
- zlib 自带的 Adler-32(流式校验)
- 镜像包头的 CRC32(完整性校验)
- 写入 Flash 后的回读校验(硬件校验)
4.5 zlib 压缩流程全景图
下面这张图把 zlib 在 OTA 场景下的完整流程画出来了。从服务器压缩到设备解压,每一步的内存和参数配置都标清楚了。
这张图里有个关键点:服务器和设备端的参数可以不一样。服务器用高内存换压缩比,设备端用低内存换可用性。这是 OTA 系统设计的常见思路——把计算密集型工作放到服务器端。
4.6 小结
zlib 的 API 不难,难的是在嵌入式环境里把它用好。我总结三条经验:
- 压缩级别选 6 或 7,别盲目上 9
- 内存占用靠 windowBits 和 memLevel 控制,别用默认值
- 流式解压是 OTA 的标配,边收边解,别等全量