简介:面向STM32及国产单片机开发者的Zlib裁剪移植资源,解决标准Zlib库在RAM受限场景下难以直接运行的问题。标准Zlib默认MAX_WBITS为15,需要两个32KB缓冲区才能正常工作,而单片机RAM通常只有几十KB;作者通过将MAX_WBITS调至8、压缩等级设为3,重写deflate_compress,并移植正点原子malloc完成内存管理,大幅降低了运行内存需求,实现数据压缩,同时支持分批压缩而非一次性处理,更适合嵌入式实时场景。资源还参考libharu实现PDF FlateDecode功能,实测压缩率可达10倍以上;压缩后数据可送入加密函数,为嵌入式端安全存储与传输提供可行方案,也能用于固件升级包瘦身、日志压缩回传等场景。压缩包共149个文件,以C源码与头文件为主,包含deflate/inflate/trees等核心模块,辅以Keil、Qt、Visual Studio等工程文件、启动与链接脚本、批处理脚本、说明文档及hex烧录文件,整体约4.77MB,结构清晰,便于直接参考或集成。已有2208人学习下载,适合具备一定单片机开发经验、需要在资源受限设备中增加压缩能力的工程师。 做嵌入式这几年,遇到不少同事一聊到数据压缩,第一反应都是“那是 PC 和服务器端的活,单片机带不动”。实际上,在 STM32 以及 GD32、APM32 这类国产 Cortex-M 单片机上移植 zlib 是完全可行的。我用它给一段 80KB 级别的采样数据做过压缩,压缩后只剩 12KB,无线传输耗时直接砍掉了大半,代价只是板子内存和 Flash 预算里多划出一块区域。
这篇文章不是讲算法理论,而是把这次“在裸机 MCU 上跑通 zlib 压缩/解压”的过程完整复盘一遍:从选型、裁剪、内存适配到调试踩坑,都给出一份能直接照着动手的方案。适合想给 OTA 固件、日志系统、传感数据采集增加压缩能力的开发者,尤其是第一次碰 zlib 的嵌入式工程师。
1. 为什么要在单片机上做数据压缩,zlib 能带来什么
1.1 zlib 到底是什么,移植难度有多大
zlib 是对 DEFLATE 压缩算法的标准 C 语言实现,平时最常出现在压缩包工具、网络传输协议里。它的关键点有二:一是纯 C 编写,几乎不依赖操作系统;二是接口设计非常克制,核心只需要几个函数就能跑起来。
MCU 上用它最常见的价值是节省传输带宽和存储空间。举几个实际场景:
- OTA 固件升级:固件先在本地压缩,再通过 2G/4G、Wi-Fi 或串口下发,接收端解压后写入 Flash,能显著减少下载流量。
- 数据采集设备:传感器数据或者一段 GPS 轨迹,按块压缩后批量上传,减少上传时间。
- 日志记录:复杂字符串日志压缩后落盘,同一张 SD 卡能多存好几倍数据。
真正在 STM32 上移植,算法代码本身不是难点,难的是内存模型和编译环境的适配。zlib 默认的压缩会话会申请几十 KB 甚至接近 300KB 的堆内存,这在 Cortex-M 上必须手动裁剪,否则一调用deflateInit就Z_MEM_ERROR。
1.2 移植前先问自己:压缩这笔账值不值
不是所有数据都适合在 MCU 上压缩。压缩要付出 CPU 时间、RAM 和 Flash 空间,换来的是传输时间或者存储容量。我个人总结的判断条件很简单:
- 数据重复度高,比如日志、文本、采集波形,压缩率通常能到 60% 以上。
- 数据链路是瓶颈,比如用串口 115200 传输几十 KB 数据,等的时间让人难受。
- 板子还有 20KB 以上空闲 RAM,Flash 也愿意交出 30KB 左右。
如果数据是加密算法输出、随机噪声或者已经被压缩过的图片,那就不用白折腾了,直接传输反而更快。
2. 移植前必须想清楚的选型和裁剪思路
2.1 用完整版 zlib 还是找 miniz 这种替代库
zlib 官网提供的源码包是完整版,除了 DE/INFLATE 还包含 gzip 文件读写接口、编译脚本、测试工具等,你不能全塞进 MCU。另一个经常被提起的替代方案是 miniz,它是单文件实现,专门照顾资源受限系统。
| 对比项 | 完整版 zlib | miniz |
|---|---|---|
| 源码体积 | 10 多个 C 文件 | 单个或少量文件 |
| 可裁剪性 | 通过宏控制,但需按文件筛查 | 裁剪宏比较直观 |
| Flash 占用 | 全编译接近 50-60KB | 约 30-40KB,裁剪后更小 |
| API 兼容性 | 原生 zlib API | 兼容大部分 zlib API |
| 嵌入式心智负担 | 中 | 低 |
如果你的项目对“zlib 标准接口”没有特别要求,miniz 上手更快。不过完整版 zlib 在很多公司内部已有使用基础,也更容易查到资料。我当时选的是完整版,原因是通讯协议对端已经用 zlib 的uncompress解包,不想引入额外兼容风险。
实际动手时,完整版也只需要加入核心压缩解压文件,不需要 gzip 相关功能。编译阶段我会在工程宏里加Z_SOLO,这个宏会把gz*系列和文件 I/O 相关代码剥掉,只保留内存数据流接口。
2.2 三个决定内存占用的关键参数
zlib 内存大头来自三块:窗口大小(windowBits)、哈希表级别(memLevel)、压缩级别(level)。
windowBits决定压缩时向前搜索的窗口尺寸,范围 8 到 15,对应 256B 到 32KB。窗口越大,找到重复数据的概率越高,但内存和计算量都上升。memLevel是 1 到 9 之间的整数,控制内部哈希表大小,直接决定压缩用 RAM。level是 0 到 9,控制压缩效率与速度的折中,不直接决定内存总量,但影响计算时间。
我提供一个直观的经验区间。默认的deflateInit2(strm, 6, Z_DEFLATED, 15, 8, Z_DEFAULT_STRATEGY)在 PC 上没问题,但在小内存 MCU 上基本跑不动。我实际测试下来:
| 配置 | 压缩时内存量级 | 说明 |
|---|---|---|
| windowBits=15, memLevel=8 | 200KB 以上 | 绝大多数 STM32 直接劝退 |
| windowBits=12, memLevel=6 | 约 30KB 量级 | 大多数场景可以从这档起步 |
| windowBits=11, memLevel=5 | 约 15KB 量级 | 内存吃紧时的妥协方案 |
解压端比压缩端省很多内存,一般在 20KB 以内也能跑,所以接收端压力小,压力主要在发送端。如果你的项目压缩在云端做,MCU 只负责解压,那移植难度又会低一截。
2.3 为什么不要一上来就调用compress2
完整版 zlib 提供compress2,只需要一行就能压缩完整数据块,非常方便。但它内部使用的参数是 zlib 编译时的默认配置,窗口大小基本固定为MAX_WBITS,也就是 15。在小 RAM 单片机上直接调,很容易内存失败。
我在工程里改成用deflateInit2+deflate这套流式接口,因为可以自己指定windowBits和memLevel。这样虽然代码多了几行,但对资源控制是必须的。
3. 从源码准备到最终跑通的完整移植过程
3.1 源码文件清单和工程结构
从 zlib 官网或者官方镜像下载zlib 1.2.x源码包后,不需要把整个包复制进工程,只看这些核心文件:
adler32.c:校验和算法,压缩数据尾会用到。compress.c:compress/compress2接口。crc32.c:CRC32 校验。deflate.c、trees.c:压缩核心。inflate.c、inffast.c、inftrees.c:解压核心。uncompr.c:uncompress接口。zutil.c:常用工具和内存分配函数。- 头文件:
zlib.h、zconf.h、zutil.h、deflate.h、inflate.h等。
我把这些文件统一放在Middlewares/Zlib目录下,工程里新增一个分组Zlib全部编译。Keil 需要把/Middlewares/Zlib加入 Include Path;STM32CubeIDE 则是项目属性里配置 C 语言包含路径。
3.2 Keil 和 CubeIDE 的堆区配置
zlib 接口内部会通过malloc/calloc动态分配内存。因此堆区大小必须给够,否则deflateInit直接返回Z_MEM_ERROR。
Keil 工程里,启动文件startup_stm32fxxx.s中有Heap_Size配置。我建议一开始直接给:
Heap_Size EQU 0x00008000也就是 32KB。如果你用我上面推荐的windowBits=12, memLevel=6,这个堆空间勉强够压缩。如果后面发现不够,优先调小参数,而不是继续加堆。
STM32CubeIDE 使用 GCC 链接脚本.ld,里面有一段类似:
_Min_Heap_Size = 0x200;这个值需要加大到0x8000左右,同时把_Min_Stack_Size也检查一遍。zlib 的局部变量比较大,特别是把缓冲区放在栈上时,栈太小会 HardFault。
3.3 自定义内存分配函数,摆脱编译器差异
如果不想依赖 Keil 的 C 库堆管理,或者项目本身就是 Foo-Free 环境,可以直接改写zutil.c里的zcalloc和zcfree两个函数。它们就是 zlib 申请/释放内存的唯一入口。
我采用的是静态内存池方案。在zutil.c顶部定义一个大数组,内存直接从池里取:
static unsigned char alloc_pool[96 * 1024] __attribute__((aligned(8))); static unsigned char *pool_ptr = alloc_pool; void *zcalloc(void *opaque, unsigned int items, unsigned int size) { size_t need = (size_t)items * size; void *ret = pool_ptr; if (pool_ptr + need > alloc_pool + sizeof(alloc_pool)) { return (void *)0; } pool_ptr += need; memset(ret, 0, need); return ret; } void zcfree(void *opaque, void *ptr) { (void)opaque; (void)ptr; }我的做法只适合“压缩/解压完成后一次性释放所有内存”的简单场景。如果你在系统里反复执行deflateInit/deflateEnd,这种不回收的内存池会越用越少,此时最好还是用正规malloc/free,或者在记录好释放点时手动回滚pool_ptr。
3.4 最小可用代码:一次压缩一块数据
为了让系统流程简单,我先做一个“整块压缩,整块解压”的函数,验证链路是否通畅,再优化成流式传输。
压缩端:
#include "zlib.h" int z_compress_block(uint8_t *src, uint32_t src_len, uint8_t *dst, uint32_t dst_cap, uint32_t *out_len) { z_stream strm; int ret; memset(&strm, 0, sizeof(strm)); ret = deflateInit2(&strm, 6, Z_DEFLATED, 12, 6, Z_DEFAULT_STRATEGY); if (ret != Z_OK) { return ret; } strm.next_in = src; strm.avail_in = src_len; strm.next_out = dst; strm.avail_out = dst_cap; do { ret = deflate(&strm, Z_FINISH); } while (ret == Z_OK); if (ret == Z_STREAM_END) { *out_len = strm.total_out; } deflateEnd(&strm); return ret; }解压端:
int z_uncompress_block(uint8_t *src, uint32_t src_len, uint8_t *dst, uint32_t dst_cap, uint32_t *out_len) { uLongf dest_len = dst_cap; int ret = uncompress(dst, &dest_len, src, src_len); if (ret == Z_OK) { *out_len = dest_len; } return ret; }压缩输出缓冲不能随意给一个src_len + 100,正确做法是用compressBound(src_len)计算最坏情况。虽然很多场景下压缩后的数据小于原数据,但最坏情况压缩后反而更大,这是 DEFLATE 算法的正常表现。
实际通信时需要定义一层简单的数据头,我通常这样组织:
| 偏移 | 内容 | 字节数 |
|---|---|---|
| 0 | 数据头标识(比如0x5A5A) | 2 |
| 2 | 原始数据长度 | 4 |
| 6 | 压缩后数据长度 | 4 |
| 10 | 压缩数据 | 变长 |
这样接收方拿到的解压目标长度是明确的,避免uncompress因不知道原始长度而失败。
4. 实测数据:资源占用和参数调优记录
4.1 Flash 和 RAM 实测
我的测试板是一颗主频 168MHz 的 Cortex-M4 内核 MCU,编译器用 ARM GCC,开启-Os。只编译压缩和解压核心文件,加上Z_SOLO宏裁剪掉 gzip 文件接口。
- Flash:约 46KB。
- RAM:
windowBits=12, memLevel=6时,压缩会话峰值约 32KB;解压会话约 18KB。 - 压缩速度:对 64KB 的重复度较高数据,压缩级别 6 耗时约 1.2 秒;压缩级别 1 大约只需 450ms。
- 压缩率:文本类数据约 75% 以上;二进制传感器数据约 40%-60%。
所以如果你板子的 Flash 本来就紧张,建议先用miniz,或者只保留压缩/解压其中一个方向,通过裁剪宏把对应代码剔除,能省出 10-20KB。
4.2 调优一个例:把内存从 100KB 压到 20KB
如果你用的是compress2,内存和压缩率都是死的。改用deflateInit2后,我是这样调的:
- 先把
memLevel从 8 降到 6,内存立刻下降一截。 - 再把
windowBits从 15 一路往下降到 12,内存继续缩减。 - 试到内存能稳定通过
deflateInit,然后再看压缩率。如果压缩率不满意,先恢复windowBits,不要先恢复memLevel。
压缩质量上,windowBits从 15 降到 12,对几 KB 以内的小块数据影响其实没有想象中大,因为数据本身可能还没填满 32KB 窗口。对几十 KB 的日志块,影响会更明显。
4.3 国产 MCU 和 RISC-V 芯片同样适用
这个方案不挑具体芯片。GD32、APM32、AT32、N32G45x 这些 Cortex-M 内核的国产单片机和 STM32 的移植步骤几乎一样,因为它们跑的是同样的 ARM 指令集和 C 编译环境,zlib 又是纯 C。
如果换成 CH32V 这类 RISC-V 内核 MCU,思路也完全一样,只要工具链和链接脚本正确,zcalloc的适配方式照搬即可。关键是要确认编译器支持 C89/C99 标准,以及链接脚本中堆空间确实存在。
5. 常见问题与排查实录
5.1 错误返回码速查表
| 返回码 | 含义 | 处理思路 |
|---|---|---|
Z_MEM_ERROR (-4) | 分配不到内存 | 加大堆、减小 windowBits/memLevel、检查自定义内存池 |
Z_BUF_ERROR (-5) | 输入输出缓冲区状态不对 | 输出缓冲不够,或输入流被截断;提前用compressBound预留 |
Z_DATA_ERROR (-3) | 数据不是合法 zlib 流 / 校验失败 | 检查传输协议、大小端、波特率或丢包逻辑 |
Z_STREAM_END | 正常结束 | 只有这个返回值才说明整块数据处理完成 |
我在调试过程中遇到最多的就是Z_MEM_ERROR,多数情况不是代码写错,而是 Keil 的Heap_Size没改,或者静态内存池太小。
5.2 解压总是失败,问题却在传输层
有一次我压缩端正常,解压端传给对端程序后一直Z_DATA_ERROR。排查了很久,最后发现是串口接收侧的环形缓冲只做了单字节拷贝,压缩数据中大量字节是0x00,中途被某个中断优先级倒挂弄丢了几字节。
建议在联调协议时,先不要压缩真实数据,而是把压缩后的数据按字节打印出来,核对两端字节数是否一致。另外,zlib 流的初始字节通常是0x78,比如0x78 0x9C、0x78 0x01、0x78 0xDA。如果接收端第一个字节不是0x78,基本可以确定传输出错或者数据头偏移错误。
5.3 HardFault 的三个隐蔽原因
- 局部变量把 z_stream 结构体或缓冲区放在了栈上,而栈只有 1KB / 2KB,一调用就爆。
- 输出缓冲区比真实压缩结果小,但解压根没做长度校验,越界写坏相邻变量。
- 自定义
zcalloc内存池数组放在普通函数内部,导致栈外分配,地址错乱。
我最后的稳定习惯是:大缓冲区一律放全局变量,或者由外部传入指针,不放进函数栈。这比调栈大小更省心。
5.4 Keil AC5 和 AC6 的移植差异
Keil AC5 编译器比较老,zlib 源码本身是 C89 风格,能直接编过。AC6 是 armclang,默认标准更高,但 zlib 1.2.11 以上也比较干净,只需要注意把所有文件都加入到编译中,不要漏掉trees.c和inffast.c这类看起来不起眼但必不可少的文件。
STM32CubeIDE 的 GCC 默认链接脚本对堆大小定义在.ld文件的_Min_Heap_Size,如果你改完还报错,记得清理重新编译一次,链接脚本的修改有时不会立即生效。
最后再分享一个小细节
我踩过最深的坑是把压缩级别调成 9,小内存芯片在压缩大块数据时明显卡顿,主循环里其它任务全部被拖死。后来把压缩级别降到 6,甚至对实时性要求高的场景用级别 1,体验好了很多。实际工程里,压缩率并不是越高越好,能塞进内存、能在规定时间内跑完,比多节省 3% 的空间重要得多。
如果你也是第一次给 MCU 加压缩功能,建议按这个顺序走:先跑通compress2/uncompress验证链路,再用deflateInit2控制资源,最后才接入 OTA 或日志系统。千万别一上来就追求“高压缩率+低内存+高速”三者兼得,那是 PC 端才敢想的事。
本文还有配套的精品资源,点击获取