1. 为什么嵌入式环境下的压缩需求值得单独拎出来讲
做嵌入式开发的人大多有过这样的经历:设备 Flash 空间算得刚刚好,固件镜像再大几十 KB 就装不进去;或者通过串口、总线传输日志和配置数据,带宽窄得可怜,传一个几百 KB 的文件要等好几分钟。这时候压缩就成了刚需。但嵌入式环境跟桌面环境完全是两码事——内存可能只有几百 KB,CPU 主频不高,还没有现成的 zlib 库可以直接链进来用。VxWorks 作为一款经典的实时操作系统,在这方面的生态相对封闭,很多通用压缩库要么移植成本高,要么运行时开销不可接受。
标题里提到的deflate和inflate,是 DEFLATE 压缩算法的两个方向:deflate 负责压缩,inflate 负责解压。DEFLATE 本身是 zlib、gzip、PNG 等格式的底层算法,属于 LZ77 与 Huffman 编码的组合。它的特点是压缩比不错、解压速度快、内存占用可控,非常适合嵌入式场景。但“适合”不等于“拿来就能用”,在 VxWorks 上落地这套东西,需要解决几个具体问题:算法源码怎么裁剪、内存怎么分配、缓冲区怎么管理、压缩级别怎么选、出错怎么恢复。
这篇内容面向的是有一定嵌入式 C 语言基础、需要在 VxWorks 环境下实现数据压缩/解压功能的开发者。不管你是要把固件打包压小一点,还是要在设备端压缩日志再上传,或者要在两个模块之间用压缩格式交换数据,下面这些从实际项目里攒下来的经验应该都能用上。我会把 DEFLATE 的核心机制讲清楚,把移植和裁剪的关键步骤拆开,再重点说说那些文档里不会写、只有踩过才知道的坑。
2. DEFLATE 算法在资源受限环境下的真实工作方式
2.1 LZ77 滑动窗口与 Huffman 编码的分工
DEFLATE 的本质是两级压缩。第一级是 LZ77,它维护一个“滑动窗口”,在已经处理过的数据里找重复片段,找到之后用“距离 + 长度”的二元组来替代原始字节。比如一段数据里出现了两次“configuration”,第二次出现时就可以用“往回退 13 个字节,复制 13 个字节”来表示。这一步对重复性强的数据(日志、配置文件、文本协议)效果极好。
第二级是 Huffman 编码,它把 LZ77 输出的符号(字面字节、长度码、距离码)按照出现频率重新分配变长编码,频率高的用短码、频率低的用长码。DEFLATE 用的是动态 Huffman,也就是每次压缩时根据实际数据统计频率、现场构建编码表,而不是用固定的编码表。这就是为什么 DEFLATE 的压缩效果比单纯的静态 Huffman 好,但代价是压缩端需要额外的计算来建表。
理解这个分工很重要,因为它直接决定了资源消耗的分布:压缩端要同时承担 LZ77 匹配搜索和 Huffman 建表的开销,CPU 和内存压力都大;解压端只需要按编码表还原,计算量小得多。所以在 VxWorks 这种实时系统里,如果压缩和解压不在同一侧,完全可以让资源充裕的一侧做压缩、资源紧张的一侧只做解压。
2.2 滑动窗口大小与内存占用的换算关系
DEFLATE 标准规定滑动窗口最大 32KB(32,768 字节),对应的最大匹配距离也是 32KB。窗口越大,能找到的重复片段越多,压缩比越高,但内存占用也越大。在 VxWorks 上,这个窗口是压缩端和解压端都要分配的——压缩端用它做匹配搜索,解压端用它存放历史数据以便回溯复制。
算一笔账:32KB 窗口 + 压缩端约 256KB 的哈希链和待处理缓冲区,一个完整的压缩实例峰值可能吃掉 300KB 以上内存。对于内存只有 1MB 级别的嵌入式板子,这个数字不算小。实际项目中常见的折中方案是把窗口降到 8KB 或 16KB,压缩比会损失几个百分点,但内存占用能砍掉一大半。如果你的数据重复模式主要出现在局部(比如结构化日志),8KB 窗口通常够用;如果是大段重复的固件镜像,那还是老老实实上 32KB。
2.3 压缩级别背后的时间-空间权衡
DEFLATE 通常提供 0 到 9 共 10 个压缩级别。级别 0 是不压缩只封装,级别 1 最快但压缩比最低,级别 9 压缩比最高但最慢。这个“慢”在嵌入式上不是小事:级别 9 会做大量的惰性匹配(lazy matching)和更深的哈希链搜索,CPU 时间可能是级别 1 的十几倍。
在 VxWorks 上选级别,我的经验是默认用 6,这是压缩比和速度的平衡点,大多数场景够用。如果压缩是在启动阶段一次性做的、对时间不敏感,可以上到 9;如果是运行中周期性压缩日志、不能阻塞太久,降到 3 到 5 更稳妥。有一个容易被忽略的点:级别对解压速度几乎没有影响,因为解压只依赖压缩时生成的 Huffman 表,所以“压缩慢一点换更小的体积”在解压侧是纯赚的,前提是压缩侧的时间预算允许。
3. 把 DEFLATE 源码搬进 VxWorks 的裁剪与适配
3.1 源码选型:为什么优先考虑精简实现而非完整 zlib
桌面环境大家习惯直接用 zlib,但把完整 zlib 移植到 VxWorks 上会带来一堆不必要的负担:它包含 gzip 文件格式处理、多种压缩策略、复杂的错误码体系,还有对标准 C 库的若干依赖。对于只需要“裸 DEFLATE 数据流”的嵌入式场景,这些大多是死代码。
更合适的做法是选一个精简的 DEFLATE 实现,比如那些单文件、无外部依赖、只保留核心压缩解压逻辑的版本。这类实现通常只有两三千行 C 代码,接口就是deflate_init / deflate_run / inflate_init / inflate_run这么几个函数,内存分配通过回调交给调用方控制。选型时重点看三个指标:是否可配置窗口大小、是否支持自定义内存分配器、是否有明确的静态内存模式(不依赖 malloc)。第三点尤其关键,VxWorks 的实时任务里动态内存分配可能引入不确定的延迟,能用静态缓冲区就别用堆。
3.2 内存分配器的接管与静态缓冲区的规划
精简实现一般会把内存操作抽象成几个函数指针:分配、释放、以及可选的“临时缓冲区获取”。移植时第一件事就是把这些指针替换成自己的实现。在 VxWorks 上我通常准备两块静态内存池:一块给压缩实例(包含窗口、哈希表、输出缓冲),一块给解压实例(主要是窗口和输出缓冲)。大小按最坏情况算,比如 32KB 窗口的压缩实例预留 300KB,解压实例预留 64KB。
这里有个实操细节:压缩和解压的缓冲区不要共用,哪怕它们不会同时运行。因为一旦共用,代码里就得加互斥保护,而实时系统里多一把锁就多一份不确定性。静态内存多花几十 KB,换来的是逻辑简单、无锁、可预测,这笔买卖划算。另外,输出缓冲区建议按“分块输出”设计,不要指望一次性把整个压缩结果放进内存——压缩比是未知的,最坏情况(不可压缩数据)输出会比输入还大一点点,所以输出缓冲要么按输入大小的 1.05 倍预留,要么做成流式回调。
3.3 与 VxWorks 编译环境的对接要点
VxWorks 的工具链对标准 C 的支持程度因版本而异,移植时几个高频问题值得提前规避。第一是头文件依赖,精简实现里如果用了<stdint.h>的定长类型,确认工具链里有对应定义,没有就自己 typedef。第二是字节序,DEFLATE 的 Huffman 码是位流、按低位优先打包,跟平台字节序无关,但如果你在压缩前后做了额外的数据封装(比如加长度头),那部分要注意大小端一致。第三是编译器优化选项,压缩解压是计算密集型代码,开-O2是基本操作,但某些工具链在-O3下对位操作的优化反而可能引入对齐问题,建议先用-O2跑通再尝试更高优化。
还有一个容易翻车的点:VxWorks 的任务栈默认可能只有几 KB,而 DEFLATE 的压缩函数递归深度虽然不深,但局部变量加上函数调用链可能吃掉一两 KB 栈空间。如果压缩任务跑在独立任务里,把栈调到 16KB 以上比较保险;如果是在中断或高优先级任务里调用,务必先测一下栈水位。
4. 压缩端实操:从接口设计到参数调优
4.1 流式接口的设计思路
嵌入式场景下数据往往不是一次性到齐的——可能是从串口一段段读进来,可能是从 Flash 分块读出。所以压缩接口最好设计成流式的:初始化时给定窗口和级别,然后反复调用“喂入一块数据、取出已产生的输出”,最后调用“结束”把剩余位流刷出去。这种设计的好处是内存占用恒定,不随输入数据总量增长。
具体到函数签名,大致是这样一套:
int deflate_init(void *ctx, int level, int window_bits); int deflate_feed(void *ctx, const uint8_t *in, size_t in_len, uint8_t *out, size_t out_cap, size_t *out_len); int deflate_finish(void *ctx, uint8_t *out, size_t out_cap, size_t *out_len);deflate_feed的语义是“尽可能多地消费输入、尽可能多地产生输出”,返回时通过out_len告诉调用方这次产出了多少字节。调用方循环喂数据、循环取输出,直到输入耗尽。deflate_finish负责把内部还压着的数据和位流的收尾标记刷出来。这套接口的关键约定是:输出缓冲区由调用方提供,库内部不持有动态输出队列,这样内存完全可控。
4.2 压缩级别的实测对比与选择依据
我在一块主频几百 MHz 的嵌入式板子上做过一组实测,输入是一份约 200KB 的文本型配置数据,窗口固定 32KB,结果大致如下:
| 压缩级别 | 输出大小 | 压缩耗时 | 解压耗时 |
|---|---|---|---|
| 1 | 约 62KB | 基准 1x | 基准 1x |
| 3 | 约 54KB | 约 2.5x | 约 1x |
| 6 | 约 48KB | 约 6x | 约 1x |
| 9 | 约 46KB | 约 15x | 约 1x |
数据是示意性的,但趋势很有代表性:从 1 到 6,压缩比提升明显、时间增长可接受;从 6 到 9,压缩比只多了一点点,时间却翻了一倍多。所以除非存储空间真的卡到极限,级别 6 是性价比最高的选择。另外注意解压耗时那一列基本不变,再次印证了“压缩端多花时间换体积,解压端不受影响”这个特性。
4.3 输出缓冲区的容量估算与溢出防护
前面提过,不可压缩数据的 DEFLATE 输出会比输入略大,最坏情况大约多出 5 字节每 16KB 块,加上收尾开销,整体按输入的 1.02 到 1.05 倍预留是安全的。但流式接口下更稳妥的做法是不依赖估算,而是靠返回值驱动:deflate_feed返回时如果输出缓冲区满了但输入还没消费完,调用方就先把输出取走、再继续喂。这样即使估算偏小也不会溢出。
防护上还有一条:所有输出写入前都要检查剩余容量,宁可返回“缓冲区不足”让上层重试,也绝不越界写。嵌入式系统里一次缓冲区溢出可能就是整机重启,这个检查不能省。
5. 解压端的边界处理与异常恢复
5.1 解压状态机的分步推进
解压比压缩简单,但状态更多。一个 DEFLATE 解压器内部要维护:当前处于“读块头”“读 Huffman 表”“解码符号”“回溯复制”哪个阶段,位流读到第几位,窗口里写到哪个位置。流式解压接口的设计要点是状态必须完全保存在上下文结构里,函数返回后不能依赖任何栈上临时变量,因为下一次调用可能是从完全不同的代码路径进来的。
int inflate_init(void *ctx, int window_bits); int inflate_feed(void *ctx, const uint8_t *in, size_t in_len, uint8_t *out, size_t out_cap, size_t *out_len, size_t *in_used);注意这里多了in_used,因为解压时输入可能只消费了一部分(比如一个块的位流还没读完),调用方需要知道消费了多少才能正确推进输入指针。这个细节在拼接多个压缩块或者从网络流里解压时特别重要,处理不好就会出现“数据错位、后面全乱”的问题。
5.2 损坏数据的检测与安全退出
嵌入式设备上的压缩数据可能来自 Flash 坏块、传输误码、甚至上电时序问题导致的半写状态,所以解压端必须假设输入可能是坏的。DEFLATE 本身有一定的自校验能力:Huffman 码表非法、距离超出窗口、块类型非法,这些都能在解码过程中被发现。但要注意,发现错误后不能简单 return,必须把上下文重置到干净状态,否则下一次解压会带着脏状态继续跑,产生更难排查的连锁错误。
我的做法是在上下文里加一个error字段,任何非法状态都置位并停止消费输入,上层看到错误码后决定是丢弃这段数据还是请求重传。另外建议在压缩数据外面套一层简单的校验(比如 CRC32 或者长度头),因为 DEFLATE 的位流在极少数情况下可能“碰巧”解码成功但内容是错的,外层的校验能兜住这种情况。
5.3 窗口回溯复制的性能陷阱
解压时最耗时的操作是 LZ77 的回溯复制:从窗口里往回退 N 个字节,复制 M 个字节到当前位置。当 N 很小、M 很大时(比如“往回退 1 字节,复制 1000 字节”,即游程编码场景),如果按字节逐个复制,效率会很低。优化方法是识别出 N=1 的情况用 memset 式的填充,N 较小时用小块 memcpy 循环。这个优化在解压大量重复数据(比如全零填充的固件区段)时效果非常明显,实测能快好几倍。
还有一个陷阱:回溯复制的源和目标在窗口里可能重叠(当 N < M 时),这时候不能用普通 memcpy,因为 memcpy 对重叠区域的行为未定义。必须用逐字节复制或者专门处理重叠的循环。这个 bug 很隐蔽——大部分测试数据不会触发重叠,一旦遇到特定模式的数据就崩,排查起来非常费劲。
6. 那些只有踩过才知道的坑
6.1 位流对齐问题导致的“解压出来差几个字节”
DEFLATE 的块与块之间、以及某些特殊块类型之后,位流需要对齐到字节边界。如果压缩端和解压端对“何时对齐”的理解不一致,就会出现解压结果前面都对、最后差几个字节的现象。这类问题的根源通常是移植时改动了位流读写的宏或者内联函数,把“读 N 位”的语义改错了。排查方法很直接:用一份已知正确的小数据(比如几十字节的固定内容)做压缩,把输出的十六进制打出来,跟标准实现的结果逐字节对比,差异点就是 bug 所在。
6.2 静态缓冲区复用引发的脏数据
为了省内存,有些实现会复用同一块缓冲区做压缩和解压。如果复用前没有正确重置窗口和状态,上一次残留的数据会污染下一次的结果。表现是“第一次压缩解压都对,第二次开始出错”。解决办法要么是每次 init 时把整个上下文 memset 清零(简单粗暴但有效),要么是在 finish 时显式重置所有状态字段。我倾向于前者,几十 KB 的 memset 在初始化阶段开销可以忽略,换来的是确定性。
6.3 大文件分块压缩时的字典断裂
流式压缩一个大文件时,如果每块都独立 init/finish,块与块之间的重复模式就找不到了,压缩比会明显下降。正确做法是整个文件用一个压缩实例连续 feed,只在最后 finish 一次。但这样又带来一个问题:如果中途设备重启,已经压缩的部分怎么恢复?实际项目中常见的折中是按固定大小(比如 64KB)分块、每块独立压缩并加块头,牺牲一点压缩比换取可恢复性。这个取舍要看具体需求,没有标准答案。
6.4 实时性要求下的最坏执行时间
DEFLATE 的压缩时间跟数据内容强相关——重复多的数据匹配快,随机数据匹配慢。如果压缩任务有硬实时约束(比如必须在 10ms 内完成),就不能只看平均耗时,要测最坏情况。测试方法是拿随机数据(最不可压缩、匹配搜索最费时)跑压缩,记录单次 feed 的最大耗时。如果超标,要么降级别、要么减小窗口、要么把压缩拆成更小的块分散到多个周期里做。
7. 一套可复用的落地检查清单
把上面这些内容收拢成一份上手时能对着核对的清单,按顺序过一遍基本能避开大部分坑:
- 选型阶段:确认实现支持自定义内存分配、支持窗口大小配置、不强制依赖标准库的 malloc/free。
- 内存规划:压缩实例按窗口大小 × 10 预留,解压实例按窗口大小 × 2 预留,全部用静态池,压缩解压分开不共用。
- 接口设计:压缩解压都用流式 feed/finish 接口,输出缓冲由调用方提供,返回值明确告知消费量和产出量。
- 参数选择:默认级别 6、窗口 32KB;内存紧张时窗口降到 8KB 或 16KB;实时约束紧时级别降到 3 到 5。
- 错误处理:解压端任何非法状态都置错误位并重置上下文;压缩数据外层加 CRC 或长度校验。
- 测试覆盖:至少测四类数据——高重复文本、随机二进制、全零填充、边界大小(0 字节、1 字节、刚好一个窗口)。
- 性能验证:测平均耗时和最坏耗时,最坏耗时用随机数据触发;关注解压端回溯复制的重叠场景。
这套东西我在几个不同资源档位的板子上都跑过,从几百 KB 内存的小 MCU 级环境到带 MMU 的中端平台,核心逻辑不用改,主要调的就是窗口大小和压缩级别两个旋钮。真正花时间的从来不是算法本身,而是内存规划、边界处理和那些位流层面的细节对齐。把这几块想清楚了,DEFLATE 在 VxWorks 上就是个很稳的工具,压缩比和速度都够用,代码量也不大,维护起来没什么负担。