嵌入式轻量级LZ77压缩库:低内存、确定性实时解压

嵌入式轻量级LZ77压缩库:低内存、确定性实时解压 1. 项目概述lz是一个面向嵌入式系统的轻量级LZ77压缩/解压缩库专为资源受限环境设计。其核心目标并非追求最高压缩率或吞吐性能而是以极小的内存 footprintROM 和 RAM和确定性的执行时间满足固件更新、日志压缩、配置数据打包、OTA固件分片传输等典型嵌入式场景的需求。该库不依赖标准C库如malloc、stdio完全采用静态内存分配与栈上操作所有函数均为可重入reentrant设计天然适配裸机系统与实时操作系统如 FreeRTOS、Zephyr、RT-Thread。与 zlib、miniz 或 LZ4 等通用压缩库相比lz的设计哲学截然不同它主动放弃对滑动窗口动态管理、哈希表加速匹配、多级字典、流式编码等复杂机制的支持转而采用固定大小的静态查找缓冲区lookahead buffer与固定长度的匹配历史窗口history window并通过位操作与紧凑状态机实现无分支branchless或低分支密度的解码逻辑。这种“做减法”的设计使其 ROM 占用通常低于 2 KiBARM Cortex-M0 编译后RAM 静态开销仅为数百字节典型值history window 256 字节 lookahead buffer 32 字节 状态变量 20 字节且最坏情况下的单字节解压时间可精确预测常数级循环次数这对硬实时任务至关重要。该库不提供文件格式封装如.lz容器仅定义原始压缩数据流raw compressed stream的二进制格式与编解码接口。用户需自行处理数据边界、校验如 CRC、分包与重组等上层协议逻辑——这正是嵌入式开发中“关注点分离”的体现压缩算法只负责字节流的无损变换通信协议栈负责可靠传输。2. 核心算法原理与格式规范lz实现的是 LZ77 算法的一个精简变体其数据流由一系列字面量字节literal byte和长度-距离对length-distance pair交替组成。整个编码过程在发送端压缩端完成解码过程在接收端解压端完成二者严格遵循同一格式规范。2.1 基本编码单元控制位与字面量每个压缩数据流以一个8 位控制字节control byte开头该字节的每一位bit 7 到 bit 0对应后续最多 8 个数据单元的类型标识若某位为1则对应位置的数据单元为一个字面量字节literal byte直接取自原始数据若某位为0则对应位置的数据单元为一个长度-距离对length-distance pair表示从解压输出缓冲区中某处复制一段数据。例如控制字节0b10110010表示第 1 个单元是字面量、第 2 个是 length-distance、第 3 个是字面量、第 4 个是字面量、第 5 个是 length-distance、第 6 个是 length-distance、第 7 个是字面量、第 8 个是 length-distance。2.2 Length-Distance 对的紧凑编码lz对 length-distance 对采用高度紧凑的变长编码以最小化比特浪费Distance距离表示回溯到已解压数据中的偏移量。lz限定 distance 范围为 1–256使用8 位无符号整数直接编码。distance 1 表示复制前一个字节distance 256 表示复制 256 字节之前的位置。此设计对应于 256 字节的固定 history window。Length长度表示要复制的字节数。lz支持长度范围为 3–258但并非所有值都用完整 8 位表示。其编码规则如下若 length ∈ [3, 18]使用4 位编码值即为 length - 3故 4 位可表示 0–15 → length 3–18若 length ∈ [19, 258]使用第一个字节的高 4 位 第二个字节的全部 8 位共 12 位编码。具体为length 19 ((first_byte 0xF0) 4) second_byte。此方式可覆盖 19–258共 240 个值。该编码方案确保绝大多数短匹配长度 3–18仅需 1 字节distance 0.5 字节4-bit length 1.5 字节即可表达而长匹配也仅需 2 字节distance first_byte 1 字节second_byte 3 字节远优于固定 16 位 length-distance 的 4 字节开销。2.3 解码状态机与缓冲区模型解码器维护两个关键缓冲区History Window历史窗口一个大小为HIST_SIZE默认 256的环形缓冲区circular buffer存储最近解压出的字节。新字节写入时覆盖最老字节。Lookahead Buffer前瞻缓冲区一个大小为LOOKAHEAD_SIZE默认 32的临时缓冲区用于暂存当前控制字节所描述的 1–8 个数据单元的原始字节供解码器逐个解析。解码流程为纯状态驱动读取一个 control byte对每一位bit 7 → bit 0若为1从 lookahead buffer 中读取下一个字节作为 literal写入 output并将其追加至 history window 尾部若为0从 lookahead buffer 中读取 distance1 字节再根据 length 编码规则读取 1 或 2 字节得到 length然后从 history window 中current_pos - distance处开始复制length个字节到 output并将这些复制出的字节依次追加至 history window 尾部注意复制过程本身不改变 history window 内容仅追加输出。整个过程无递归、无动态内存分配、无函数指针跳转所有循环次数均可静态计算符合 MISRA-C 与 DO-178C 的安全编码要求。3. API 接口详解lz提供两组核心 API压缩lz_compress与解压缩lz_decompress均以函数指针形式暴露便于在不同平台间移植。所有 API 均通过lz_config_t结构体传入运行时参数而非宏定义支持运行时动态配置。3.1 配置结构体lz_config_ttypedef struct { uint8_t *history_buf; // 指向 history window 缓冲区的指针必须 HIST_SIZE size_t hist_size; // history window 实际大小建议为 256 uint8_t *lookahead_buf; // 指向 lookahead buffer 的指针必须 LOOKAHEAD_SIZE size_t lookahead_size; // lookahead buffer 实际大小建议为 32 uint8_t *output_buf; // 输出缓冲区指针解压时必需压缩时可为 NULL 仅计算大小 size_t output_size; // 输出缓冲区大小解压时用于防止溢出 } lz_config_t;工程要点history_buf必须是生命周期覆盖整个解压过程的静态或全局缓冲区output_buf在裸机系统中常指向 SRAM 中预分配的帧缓冲区或 UART TX FIFOoutput_size是硬性安全边界解压函数在写满时返回LZ_ERR_OUTPUT_FULL避免缓冲区溢出。3.2 压缩 APIlz_compresstypedef enum { LZ_OK 0, LZ_ERR_INPUT -1, // 输入数据非法如空指针 LZ_ERR_OUTPUT -2, // 输出缓冲区不足仅当 output_buf ! NULL 时触发 LZ_ERR_INTERNAL -3 // 内部状态错误不应发生 } lz_status_t; lz_status_t lz_compress( const uint8_t *input, // 输入原始数据指针 size_t input_len, // 输入数据长度字节 uint8_t *output, // 输出压缩数据缓冲区可为 NULL 以仅计算压缩后大小 size_t *output_len,// [in/out] 输入output 缓冲区大小输出实际写入字节数 const lz_config_t *config // 配置结构体 );典型裸机调用示例计算压缩后大小uint8_t dummy_hist[256]; uint8_t dummy_lookahead[32]; lz_config_t cfg { .history_buf dummy_hist, .hist_size 256, .lookahead_buf dummy_lookahead, .lookahead_size 32, .output_buf NULL, // 不实际写入 .output_size 0 }; size_t comp_size 0; lz_status_t st lz_compress(raw_data, raw_len, NULL, comp_size, cfg); if (st LZ_OK) { printf(Compressed size: %u bytes\n, (unsigned)comp_size); }HAL 集成示例压缩后通过 UART 发送#define UART_TX_BUF_SIZE 512 static uint8_t uart_tx_buf[UART_TX_BUF_SIZE]; static uint8_t hist_buf[256]; static uint8_t la_buf[32]; lz_config_t lz_cfg { .history_buf hist_buf, .hist_size 256, .lookahead_buf la_buf, .lookahead_size 32, .output_buf uart_tx_buf, .output_size UART_TX_BUF_SIZE }; size_t out_len UART_TX_BUF_SIZE; lz_status_t res lz_compress(sensor_log, log_len, uart_tx_buf, out_len, lz_cfg); if (res LZ_OK) { HAL_UART_Transmit(huart1, uart_tx_buf, out_len, HAL_MAX_DELAY); }3.3 解压缩 APIlz_decompresslz_status_t lz_decompress( const uint8_t *input, // 输入压缩数据指针 size_t input_len, // 输入压缩数据长度 uint8_t *output, // 输出解压后数据缓冲区必需 size_t *output_len,// [in/out] 输入output 缓冲区大小输出实际解压出的字节数 const lz_config_t *config // 配置结构体 );FreeRTOS 任务中安全调用示例void ota_decompress_task(void *pvParameters) { QueueHandle_t xQueue (QueueHandle_t) pvParameters; uint8_t *decomp_buf pvPortMalloc(4096); // 从 FreeRTOS heap 分配 uint8_t hist[256], la[32]; lz_config_t cfg { .history_buf hist, .hist_size 256, .lookahead_buf la, .lookahead_size 32, .output_buf decomp_buf, .output_size 4096 }; while (1) { lz_packet_t pkt; if (xQueueReceive(xQueue, pkt, portMAX_DELAY) pdTRUE) { size_t out_len 4096; lz_status_t st lz_decompress(pkt.data, pkt.len, decomp_buf, out_len, cfg); if (st LZ_OK out_len 0) { // 将 decomp_buf 中 out_len 字节写入 Flash flash_write_image(decomp_buf, out_len); } } } }3.4 关键参数配置表参数类型推荐值工程影响说明hist_sizesize_t256窗口越大长距离重复匹配能力越强但 RAM 占用线性增加256 是压缩率与 footprint 的最佳平衡点覆盖绝大多数嵌入式数据局部性特征lookahead_sizesize_t32影响单次解码吞吐增大可减少 control byte 读取频率但增加栈空间32 足够容纳 8 个 length-distance 对每个最多 3 字节 8 个字面量 40 字节留有余量output_sizesize_t依据应用场景设定安全红线必须严格等于目标缓冲区大小解压函数内部执行if (written config-output_size) return LZ_ERR_OUTPUT_FULL;是防止堆栈溢出的第一道防线4. 源码关键逻辑解析以lz_decompress的核心解码循环为例分析其如何实现零动态分配与确定性时序// 简化版核心循环实际源码含完整边界检查 uint8_t ctrl *input; for (int i 0; i 8 input_len 0; i) { if (ctrl 0x80) { // bit 7: literal if (output_pos config-output_size) return LZ_ERR_OUTPUT_FULL; uint8_t lit *input; output[output_pos] lit; // 更新 history window: ring buffer write hist[(hist_pos) % config-hist_size] lit; } else { // length-distance uint8_t dist *input; // distance is always 1 byte uint16_t len; uint8_t len_code *input; if (len_code 0x10) { // length 3-18 len 3 len_code; } else { // length 19-258 uint8_t len_ext *input; len 19 ((len_code 0xF0) 4) len_ext; } // Copy len bytes from history: safe bounds-checked for (uint16_t j 0; j len; j) { if (output_pos config-output_size) return LZ_ERR_OUTPUT_FULL; uint16_t src_idx (hist_pos - dist j) % config-hist_size; uint8_t byte_to_copy hist[src_idx]; output[output_pos] byte_to_copy; hist[(hist_pos) % config-hist_size] byte_to_copy; } } ctrl 1; }无分支优化ctrl 0x80判断与ctrl 1移位构成紧凑状态机避免switch或if-else链带来的分支预测失败开销环形缓冲区索引(hist_pos - dist j) % config-hist_size使用模运算而非条件判断现代 Cortex-M 编译器ARM GCC对此有高效汇编优化如udivmsub显式边界检查每次output_pos递增后立即检查 output_size确保在写入前捕获溢出符合 IEC 61508 SIL3 要求历史窗口更新hist[(hist_pos) % ...] ...在复制字节的同时更新 history保证后续匹配可用且hist_pos为uint16_t避免 32 位除法。5. 实际工程应用案例5.1 STM32F030 固件差分升级在资源仅有 16 KiB Flash / 2 KiB RAM 的 STM32F030 上传统 OTA 升级需整包下载 12 KiB 固件耗时过长。采用lz进行差分压缩后原始固件 A旧版与 B新版通过bsdiff生成二进制差分 patch对 patch 数据调用lz_compress平均压缩率达 65%12 KiB → 4.2 KiBBootloader 中集成lz_decompress解压时仅需hist[256]la[32]output[4096]静态分配于.bss总 RAM 占用 4.5 KiB解压时间实测4.2 KiB 压缩数据在 48 MHz M0 上耗时 83 ms恒定无抖动满足 OTA 升级的实时性要求。5.2 FreeRTOS 下传感器日志压缩上传某工业网关需每 5 分钟将温湿度、振动传感器日志ASCII 格式约 1.2 KiB通过 NB-IoT 上传日志数据具有强时间局部性温度值变化缓慢字段名重复在日志采集任务中调用lz_compress将日志压缩至平均 380 字节压缩后数据通过xQueueSend发送给通信任务后者调用HAL_I2C_Master_Transmit发送至 NB-IoT 模块整个压缩过程在 10 ms 内完成M4F 100 MHz不影响其他实时任务调度。5.3 Zephyr RTOS 中的配置数据打包Zephyr 应用需将settings子系统导出的键值对JSON 格式~800 字节存储至外部 SPI Flash启动时调用settings_load()获取原始 JSON使用lz_compress压缩后写入 Flash 扇区扇区大小 4 KiB压缩后仅占 290 字节重启时读取压缩数据调用lz_decompress恢复 JSON全程无 heap 分配符合 Zephyr 的内存确定性要求。6. 性能与资源占用实测数据以下数据基于 ARM GCC 10.3-Os -mcpucortex-m4 -mfloat-abihard编译目标平台 STM32F407VG168 MHz指标数值测量条件ROM 占用1.72 KiBarm-none-eabi-size -t统计.text.rodataRAM 静态开销292 字节hist[256]la[32]state_vars[4]最大解压速度1.2 MB/s全字面量数据无匹配output_size充足典型解压速度420 KB/s混合数据30% 匹配output_size4096最坏-case 单字节解压时间3.8 μs包含所有边界检查与环形索引计算压缩率文本日志58%–67%ASCII sensor logs, 1–2 KiB压缩率固件二进制42%–51%ARM Thumb-2 machine code对比说明同等条件下minizlite 版本ROM 占用 5.3 KiBRAM 静态开销 1.1 KiB最坏-case 解压时间波动达 ±25%不满足硬实时约束。7. 集成注意事项与常见问题7.1 编译与链接禁用浮点 ABI即使未使用浮点-mfloat-abisoft可避免链接器引入libgcc中的浮点辅助函数节省数百字节 ROM关闭异常与 RTTI-fno-exceptions -fno-rttiC 项目慎用-fltoLTO 可能增加链接时间且某些旧版 GCC 对lz的位操作内联优化不佳建议先测试-Os基准。7.2 常见错误与调试现象根本原因解决方案lz_decompress返回LZ_ERR_OUTPUT_FULLoutput_size设置过小或output_buf地址非法如指向 Flash使用sizeof()精确计算output_size确认output_buf指向可写 RAM解压后数据乱码history_buf生命周期结束如栈上分配后函数返回或被其他任务覆写将history_buf声明为static或全局变量在 FreeRTOS 中确保其位于.bss段压缩率异常低接近 0%输入数据熵值过高如已加密数据、随机噪声或hist_size过小无法捕获重复模式接受该数据不可压缩的事实若为传感器数据检查采样是否引入随机噪声7.3 与 HAL/LL 库协同DMA 兼容性lz_decompress的输入input指针可直接指向 DMA 接收缓冲区首地址只要确保 DMA 传输完成中断中才调用解压函数中断安全所有lz_*函数不含全局状态除传入的config可在中断服务程序ISR中安全调用但需确保config-output_buf不与其他上下文冲突低功耗模式解压过程为纯 CPU 密集型建议在WFI前完成避免在STOP模式下唤醒后解压导致时序错乱。在 STM32CubeIDE 工程中可将lz.c添加至Core/Srclz.h至Core/Inc并在main.c中包含#include lz.h #include lz_config.h // 用户自定义配置头文件 // 全局缓冲区放置于 .bss static uint8_t g_lz_hist[256]; static uint8_t g_lz_la[32]; static uint8_t g_lz_out[2048]; lz_config_t g_lz_cfg { .history_buf g_lz_hist, .hist_size 256, .lookahead_buf g_lz_la, .lookahead_size 32, .output_buf g_lz_out, .output_size 2048 };此配置即完成全部集成无需修改 HAL 库源码。