4、无损压缩算法(下):LZ77系列(LZSS)原理,以及如何在MCU上解压。

上一章我们聊了LZ77的基础原理。说白了,就是找重复,然后用「距离+长度」这对组合拳来替换。但用过LZ77的朋友都知道,它有个挺尴尬的问题——有时候压缩后的数据反而比原来还大。

为什么会这样?我举个例子。你遇到一个完全随机的数据流,里面几乎没有重复。LZ77还是会傻乎乎地输出一个个「距离+长度」对,每个对至少占2个字节。而原始数据每个字节就能表示。这不就亏了嘛。

今天要讲的LZSS,就是来解决这个问题的。

LZSS:给LZ77加个「开关」

LZSS的全称是Lempel-Ziv-Storer-Szymanski。别被名字吓到,它的改进思路其实特别简单——加一个标志位,告诉解压器:「接下来这个数据,是原始字节,还是压缩对。」

我在项目中第一次手写LZSS解压器时,就被这个设计惊艳到了。一个比特的代价,换来的是压缩率的稳定提升。

核心改进点:

  • 引入1位标志位,区分「原始字节」和「(距离,长度)对」
  • 只有当重复长度大于等于3时,才使用压缩对
  • 长度小于3的重复,直接输出原始字节,避免负优化

压缩格式长什么样?

LZSS的输出是一个比特流。每个数据单元前面都有一个标志位:

  • 标志位 = 0:后面跟着1个原始字节(8位)
  • 标志位 = 1:后面跟着1个压缩对(距离12位 + 长度4位)

你想想看,这样设计的好处是什么?解压器每读1位,就知道接下来该读8位还是16位。不需要额外的同步头,也不需要复杂的上下文切换。

标志位 数据类型 后续数据长度 说明
0 原始字节 8位 直接输出该字节
1 压缩对 16位(12位距离 + 4位长度) 从历史缓冲区复制数据

我的经验:距离用12位,可以回溯4096字节。长度用4位,最大表示15+3=18字节的重复。这个参数组合在嵌入式GUI的字体和图标数据上表现特别好。我曾经试过把距离改成10位、长度改成6位,压缩率反而下降了。所以这个默认参数是有道理的。

MCU上的解压流程

在MCU上实现LZSS解压,核心就三个步骤:读标志、判类型、执行操作。我画了一张流程图,帮你理清思路。

LZSS 解压流程图(MCU 实现) 开始解压 读1位标志位 标志位=0? (原始字节?) 是 读8位原始字节 直接输出 否 读16位压缩对 (12位距离 + 4位长度) 从历史缓冲区复制数据 数据全部解压? 是 解压完成 否(继续循环) 关键参数 历史缓冲区大小:4096字节 最大匹配长度:18字节 最小匹配长度:3字节 标志位:1位

MCU解压的C语言实现

下面是我在STM32上实际跑过的解压代码。嗯,这里要注意,MCU的内存通常很有限,所以历史缓冲区的大小要精打细算。

/* LZSS 解压器 - 适用于Cortex-M系列MCU */
#define LZSS_WINDOW_SIZE  4096  /* 历史缓冲区大小 */
#define LZSS_MIN_MATCH    3     /* 最小匹配长度 */
#define LZSS_MAX_MATCH    18    /* 最大匹配长度 */

/* 解压状态机 */
typedef struct {
    uint8_t  window[LZSS_WINDOW_SIZE];  /* 环形历史缓冲区 */
    uint16_t window_pos;                /* 当前写入位置 */
    uint32_t src_idx;                   /* 源数据读取位置 */
    uint32_t dst_idx;                   /* 目标数据写入位置 */
    uint8_t  bit_buf;                   /* 位缓存 */
    uint8_t  bit_count;                 /* 缓存中有效位数 */
} lzss_ctx_t;

/* 读1位数据 */
static uint8_t lzss_read_bit(lzss_ctx_t *ctx, const uint8_t *src) {
    if (ctx->bit_count == 0) {
        ctx->bit_buf = src[ctx->src_idx++];
        ctx->bit_count = 8;
    }
    ctx->bit_count--;
    return (ctx->bit_buf >> ctx->bit_count) & 0x01;
}

/* 核心解压函数 */
uint32_t lzss_decompress(const uint8_t *src, uint8_t *dst, uint32_t src_size) {
    lzss_ctx_t ctx = {0};
    uint32_t dst_size = 0;

    while (ctx.src_idx < src_size) {
        uint8_t flag = lzss_read_bit(&ctx, src);

        if (flag == 0) {
            /* 原始字节:直接读取并输出 */
            uint8_t byte = src[ctx.src_idx++];
            dst[ctx.dst_idx++] = byte;
            ctx.window[ctx.window_pos] = byte;
            ctx.window_pos = (ctx.window_pos + 1) % LZSS_WINDOW_SIZE;
            dst_size++;
        } else {
            /* 压缩对:读取距离和长度 */
            uint16_t dist = 0;
            uint8_t len = 0;

            /* 读取12位距离 */
            dist = src[ctx.src_idx++] << 4;
            dist |= (src[ctx.src_idx] >> 4) & 0x0F;

            /* 读取4位长度 */
            len = (src[ctx.src_idx++] & 0x0F) + LZSS_MIN_MATCH;

            /* 从历史缓冲区复制数据 */
            uint16_t copy_pos = (ctx.window_pos - dist + LZSS_WINDOW_SIZE) % LZSS_WINDOW_SIZE;

            for (uint8_t i = 0; i < len; i++) {
                uint8_t byte = ctx.window[copy_pos];
                dst[ctx.dst_idx++] = byte;
                ctx.window[ctx.window_pos] = byte;
                ctx.window_pos = (ctx.window_pos + 1) % LZSS_WINDOW_SIZE;
                copy_pos = (copy_pos + 1) % LZSS_WINDOW_SIZE;
                dst_size++;
            }
        }
    }

    return dst_size;
}

我曾经踩过的坑:环形缓冲区的取模运算,在Cortex-M0上开销很大。后来我改用位与运算(& 0x0FFF)代替取模,速度提升了30%。因为窗口大小是4096,正好是2的12次方。你设计时也尽量用2的幂次大小。

压缩率实测数据

我在一个实际项目中测试过LZSS的压缩效果。数据源是嵌入式GUI的16x16图标字体库,共128个字符,每个字符32字节。

数据类型 原始大小 LZ77压缩后 LZSS压缩后 LZSS节省
图标字体库 4,096 字节 3,248 字节 2,896 字节 29.3%
按钮背景图 1,536 字节 1,024 字节 896 字节 41.7%
随机噪声 2,048 字节 2,560 字节 2,112 字节 -3.1%

看到没?对于随机噪声,LZ77反而膨胀了25%。而LZSS只膨胀了3.1%,几乎可以忽略。这就是标志位的功劳——遇到无法压缩的数据,直接原样输出,不强行压缩。

MCU上的优化技巧

在实际部署时,我总结了几条经验:

  • 位操作要小心:MCU的位操作指令有限,尽量用宏或内联函数封装读位操作,避免函数调用开销。
  • 缓冲区对齐:如果MCU支持非对齐访问,可以一次读2字节的压缩对。如果不支持,老老实实逐字节读。
  • 预计算窗口大小:如果已知数据最大重复距离不超过2048,可以把窗口缩小到2048,用11位距离。这样压缩对变成15位,省1位。
  • 查表法加速:对于固定模式的图标数据,可以预计算匹配位置表,解压时直接查表,省去搜索时间。

我个人习惯:在项目初期先用PC工具对目标数据集做压缩率测试。如果压缩率低于10%,说明数据随机性太强,LZSS不适合。这时候可以考虑跳过压缩,或者换用其他算法。别在MCU上浪费宝贵的Flash空间去存一个几乎没压缩的数据。

好了,LZSS的原理和实现就讲到这里。说白了,它就是在LZ77的基础上加了一个聪明的「开关」,让压缩算法变得更实用。在MCU上实现时,注意位操作的效率和缓冲区的管理,就能稳定地跑起来。