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() 返回的是最坏情况下的输出大小,不是实际压缩后的大小。实际大小会通过第二个参数返回。

我的习惯:在 OTA 镜像包头里存两个长度——原始大小和压缩后大小。这样解压时就不用猜了,直接分配好缓冲区。

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 场景下,压缩是在服务器端做的,所以耗时还好。但解压是在设备端,而解压时间基本不受级别影响——这是个好消息。

我的建议:服务器端用级别 6 或 7 就够了。别上 9,收益太低。如果 MCU 资源紧张,甚至可以考虑级别 3,压缩比差不了太多,但内存占用少一半。

4.3 内存占用分析:到底吃了多少 RAM?

zlib 的内存占用分三块:

  1. 输入/输出缓冲区 —— 由你分配,大小可控
  2. z_stream 结构体 —— 约 80 字节,忽略不计
  3. 内部工作区(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 以下。

注意:调小 windowBits 和 memLevel 会降低压缩比。windowBits 每减 1,窗口大小减半,压缩比可能下降 2-5%。在 OTA 场景下,你需要根据镜像大小和 Flash 余量做权衡。

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 后的回读校验(硬件校验)
避坑指南:我曾经在一个项目里把 windowBits 设成 -15(raw deflate),结果忘了在包头里存原始大小,解压时 inflate 直接返回 Z_DATA_ERROR。后来我养成了习惯:所有参数都在包头里存一份,解压时先读包头再初始化流。

4.5 zlib 压缩流程全景图

下面这张图把 zlib 在 OTA 场景下的完整流程画出来了。从服务器压缩到设备解压,每一步的内存和参数配置都标清楚了。

zlib 在 OTA 场景下的压缩/解压流程 服务器:compress2(level=6) 内存:~88 KB(window+哈希表) 输出:压缩包 + Adler-32 校验 网络传输 设备:inflateInit(windowBits=-10) 内存:~4 KB(裁剪后) 流式解压:4 KB 输入缓冲 + 4 KB 输出缓冲 关键参数对比 服务器:windowBits=15, memLevel=8 → 高压缩比,但内存大 设备端:windowBits=-10, memLevel=1 → 低内存,可接受压缩比

这张图里有个关键点:服务器和设备端的参数可以不一样。服务器用高内存换压缩比,设备端用低内存换可用性。这是 OTA 系统设计的常见思路——把计算密集型工作放到服务器端。

4.6 小结

zlib 的 API 不难,难的是在嵌入式环境里把它用好。我总结三条经验:

  • 压缩级别选 6 或 7,别盲目上 9
  • 内存占用靠 windowBits 和 memLevel 控制,别用默认值
  • 流式解压是 OTA 的标配,边收边解,别等全量