3. 无损压缩算法(上):游程编码(RLE)原理与在GUI中的应用场景

说到嵌入式GUI的资源压缩,我第一个想聊的就是游程编码。为什么?因为这东西太实用了。你想想看,嵌入式系统的资源就那么点,Flash和RAM都金贵得很。RLE这个算法,说白了就是「把重复的东西合并记录」,简单到不能再简单,但在某些场景下效果出奇的好。

我记得刚入行那会儿,接手一个温控器的界面项目。屏幕不大,但图标特别多。当时不懂压缩,直接把BMP扔进去,结果Flash爆了。后来老工程师跟我说:「你试试RLE。」我一开始还不太信——这么简单的算法能省多少?结果一试,图标数据直接缩到原来的三分之一。嗯,从那以后我就再也不敢小看RLE了。

3.1 游程编码的核心思想

RLE的原理,一句话就能说清楚:把连续重复的数据,替换成「重复次数 + 数据值」。

举个例子。假设你有一段像素数据:

原始数据:0xAA 0xAA 0xAA 0xAA 0xBB 0xBB 0xCC 0xCC 0xCC 0xCC

这段数据里,0xAA连续出现了4次,0xBB连续2次,0xCC连续4次。如果用RLE编码,可以变成:

RLE编码:0x04 0xAA   0x02 0xBB   0x04 0xCC

原来10个字节,现在变成6个字节。压缩率60%。

核心公式:压缩率 = (原始大小 - 压缩后大小) / 原始大小 × 100%。数据重复度越高,压缩率越好看。

但这里有个坑——如果数据本身重复度很低,RLE反而会膨胀。比如一段完全不重复的数据:

原始数据:0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08
RLE编码:0x01 0x01 0x01 0x02 0x01 0x03 0x01 0x04 0x01 0x05 0x01 0x06 0x01 0x07 0x01 0x08

8个字节变成16个字节,膨胀了整整一倍。所以RLE不是万能的,它只适合「有大量连续重复数据」的场景。

3.2 RLE在GUI中的典型应用场景

GUI里哪些数据适合RLE?我总结了几类:

场景 数据特征 典型压缩率 我的经验
纯色背景/大面积色块 连续相同像素值 80%~95% 做过一个仪表盘,背景纯蓝,压缩后几乎只剩几行指令
黑白图标/字体位图 0/1交替但常有连续段 50%~70% 温控器图标用RLE,从2KB压到800字节
调色板索引图 索引值重复度高 40%~60% 256色界面,相邻像素索引值经常相同
字体点阵数据 空白行/列连续 30%~50% 中文字库用RLE,省了不少Flash
渐变背景 相邻像素值变化小 10%~30% 效果一般,不如用其他算法

我个人习惯,在项目初期就会先跑一遍数据统计。看看像素值的重复度分布,如果连续重复段占比超过40%,RLE基本就能用。

3.3 RLE的两种常见编码格式

实际工程中,RLE有两种主流实现方式。我分别说一下。

3.3.1 字节对齐模式

这种模式最简单。每个数据块由「计数字节 + 数据字节」组成。计数字节表示后面数据重复的次数。

// 编码示例
输入:0x12 0x12 0x12 0x34 0x34 0x56
输出:0x03 0x12   0x02 0x34   0x01 0x56

优点:实现简单,解码快。缺点:计数字节最大只能表示255次重复。如果一段数据重复了1000次,就得拆成4个块。

小技巧:我在做LCD驱动时,经常遇到整行像素全白或全黑的情况。这时候计数值会超过255。我的做法是:如果重复次数超过255,就拆成多个块,每个块最多255次。解码器那边不用改,直接顺序读就行。

3.3.2 标志位模式

这种模式更灵活。用一个标志位来区分「重复块」和「非重复块」。比如用最高位作为标志:

// 编码规则
// 最高位为1:后面是重复数据,低7位表示重复次数
// 最高位为0:后面是非重复数据,低7位表示数据长度

// 示例
输入:0x12 0x12 0x12 0x34 0x56 0x78
输出:0x83 0x12   0x03 0x34 0x56 0x78
// 0x83 = 10000011b,表示重复3次0x12
// 0x03 = 00000011b,表示后面3个字节是非重复数据

这种模式的好处是:对于非重复数据,不会膨胀。但解码逻辑稍微复杂一点。

注意:标志位模式有个隐藏问题。如果数据本身的值范围是0~255,最高位被占用了,实际能表示的数据范围就变成了0~127。我在一个项目里踩过这个坑——图标数据里有个像素值是0x80,结果被解码器当成标志位处理了。后来我改用字节对齐模式才解决。

3.4 RLE在嵌入式GUI中的实现要点

说几个我在实际项目中总结的经验:

  1. 解码器要轻量。嵌入式系统资源有限,解码器最好控制在几十行C代码以内。RLE的解码器一般就一个循环加一个memcpy。
  2. 考虑对齐问题。如果MCU不支持非对齐访问,RLE数据块最好按2字节或4字节对齐。否则解码时会有性能损失。
  3. 结合DMA使用。RLE解码后的数据可以直接喂给DMA,送到LCD的GRAM里。我做过一个方案,RLE解码 + DMA传输,刷屏速度比直接传原始数据还快。
  4. 预处理统计。在编译阶段,用脚本跑一遍所有图片资源,统计重复度。如果重复度低于30%,自动换用其他压缩算法。

3.5 RLE知识体系总览

下面这张图,是我梳理的RLE在嵌入式GUI中的知识结构。你可以把它当成一个快速索引。

RLE在嵌入式GUI中的知识体系 游程编码(RLE) 核心原理 编码格式 GUI应用场景 重复数据合并 计数值+数据值 压缩率公式 字节对齐模式 标志位模式 优缺点对比 纯色背景 黑白图标 索引图/字库 核心要点 RLE适合高重复度数据,不适合随机数据 解码器要轻量,建议结合DMA使用

3.6 一个完整的RLE编解码示例

最后,我贴一段实际项目里用过的RLE编解码代码。这是字节对齐模式的实现,我在STM32F4上跑过,解码速度大概在2MB/s左右。

// RLE编码函数
// 输入:src - 原始数据,src_len - 原始数据长度
// 输出:dst - 编码后数据,返回编码后长度
int rle_encode(uint8_t *dst, const uint8_t *src, int src_len)
{
    int dst_len = 0;
    int i = 0;
    
    while (i < src_len) {
        uint8_t count = 1;
        // 统计连续重复的字节数
        while ((i + count < src_len) && (src[i + count] == src[i]) && (count < 255)) {
            count++;
        }
        // 写入计数字节和数据字节
        dst[dst_len++] = count;
        dst[dst_len++] = src[i];
        i += count;
    }
    return dst_len;
}

// RLE解码函数
// 输入:src - 编码数据,src_len - 编码数据长度
// 输出:dst - 解码后数据,返回解码后长度
int rle_decode(uint8_t *dst, const uint8_t *src, int src_len)
{
    int dst_len = 0;
    int i = 0;
    
    while (i < src_len) {
        uint8_t count = src[i++];
        uint8_t value = src[i++];
        // 重复写入count次value
        memset(&dst[dst_len], value, count);
        dst_len += count;
    }
    return dst_len;
}

优化建议:如果MCU支持DMA,可以把memset换成DMA传输。我试过用DMA2的MEM2MEM模式,解码速度能跑到5MB/s以上。另外,如果数据量很大,建议把dst缓冲区放在SRAM里,别放Flash,否则写入速度会拖后腿。