3、无损压缩算法(上):RLE(游程编码)原理与在GUI图标压缩中的应用
说到嵌入式GUI的资源压缩,我第一个想聊的就是RLE。为什么?因为这东西太实用了,而且实现起来极其简单。你想想看,在资源受限的MCU上,你不可能跑个复杂的压缩算法,那会吃掉大量RAM和CPU时间。RLE(Run-Length Encoding,游程编码)恰恰相反——它几乎不消耗额外内存,解压速度还飞快。
我记得刚入行那会儿,接手一个128×64的单色屏项目。图标资源占了Flash的一大半,老板天天催着要省空间。我当时试了好几种压缩方案,最后发现RLE最适合这种场景。说白了,图标里大片的连续相同像素,正是RLE的用武之地。
3.1 RLE的核心思想
RLE的原理简单到令人发指:把连续重复的数据,替换成「重复次数 + 数据值」的对儿。
举个例子,原始数据是:
AAAAABBBCCCCDDDDD
经过RLE编码后变成:
5A3B4C5D
你看,原本17个字符,压缩后只有8个字符。压缩率接近53%。当然,这是理想情况。如果数据本身重复性不高,RLE反而可能让数据变大。这一点我后面会详细说。
核心公式:
压缩率 = (原始大小 - 压缩后大小) / 原始大小 × 100%
当连续重复长度 ≥ 2 时,RLE才有收益。
3.2 RLE的两种常见编码格式
实际工程中,RLE的编码格式有很多变种。我挑两种最常用的来讲,这两种我在项目里都用过。
3.2.1 字节对格式(最常用)
这种格式用两个字节表示一个游程:第一个字节是计数值,第二个字节是数据值。
| 原始数据 | 编码后 | 说明 |
|---|---|---|
| 0xFF 0xFF 0xFF 0xFF | 0x04 0xFF | 4个0xFF |
| 0x00 0x00 0x00 | 0x03 0x00 | 3个0x00 |
| 0x12 0x34 0x56 | 0x01 0x12 0x01 0x34 0x01 0x56 | 没有重复,反而膨胀了 |
这种格式的优点是实现简单,解码器只需要循环读取两个字节,然后填充数据即可。缺点是计数值通常限制在255以内(一个字节),如果连续重复超过255次,需要拆分成多个游程。
3.2.2 标志位格式(更灵活)
这种格式用一个标志位来区分「重复数据」和「非重复数据」。我习惯用最高位作为标志:
- 最高位为1:表示后面是重复数据,低7位是计数值
- 最高位为0:表示后面是非重复数据,低7位是长度
// 编码示例
原始数据: 0xFF 0xFF 0xFF 0xFF 0x12 0x34 0x56
编码后: 0x84 0xFF 0x03 0x12 0x34 0x56
// 0x84 = 1000 0100b → 重复模式,计数值=4
// 0x03 = 0000 0011b → 非重复模式,长度=3
这种格式的好处是能处理非重复数据,不会像字节对格式那样膨胀。但解码逻辑稍微复杂一点。
我的建议:如果你的图标数据中重复和非重复区域混杂,用标志位格式。如果图标背景色单一(比如大部分是白色背景),用字节对格式更省事。
3.3 RLE在GUI图标压缩中的实战
好,理论说完了,咱们直接上代码。下面是我在一个温控器项目里实际用过的图标压缩代码。
3.3.1 图标数据的特点
嵌入式GUI的图标通常有这些特点:
- 尺寸小:16×16、24×24、32×32是常见尺寸
- 颜色少:单色或16色居多
- 大面积连续色块:背景色往往是大片相同的像素
- 边缘锐利:图标边缘通常只有1-2个像素的过渡
这些特点决定了RLE非常适合图标压缩。尤其是单色图标,压缩率经常能达到70%以上。
3.3.2 编码器实现(C语言)
/**
* RLE编码器 - 字节对格式
* input: 原始数据指针
* in_len: 原始数据长度
* output: 编码后数据指针
* return: 编码后数据长度
*/
uint16_t rle_encode(uint8_t *input, uint16_t in_len, uint8_t *output)
{
uint16_t out_idx = 0;
uint16_t i = 0;
while (i < in_len) {
uint8_t count = 1;
// 统计连续重复的字节数
while ((i + count < in_len) &&
(input[i + count] == input[i]) &&
(count < 255)) {
count++;
}
// 写入计数值和数据值
output[out_idx++] = count;
output[out_idx++] = input[i];
i += count;
}
return out_idx;
}
3.3.3 解码器实现(C语言)
/**
* RLE解码器 - 字节对格式
* input: 编码后数据指针
* in_len: 编码后数据长度
* output: 解码后数据缓冲区
* return: 解码后数据长度
*/
uint16_t rle_decode(uint8_t *input, uint16_t in_len, uint8_t *output)
{
uint16_t out_idx = 0;
uint16_t i = 0;
while (i < in_len) {
uint8_t count = input[i++];
uint8_t value = input[i++];
// 填充count个value
for (uint8_t j = 0; j < count; j++) {
output[out_idx++] = value;
}
}
return out_idx;
}
注意:解码时一定要做边界检查!我曾经在一个项目里因为没检查输出缓冲区大小,导致解码时写穿了内存,整个系统直接挂掉。排查了整整两天才找到问题。
3.4 RLE的局限性
RLE不是万能的。我吃过它的亏,所以得跟你说明白。
| 场景 | RLE表现 | 原因 |
|---|---|---|
| 单色图标(大面积背景) | 优秀,压缩率70-90% | 大量连续相同像素 |
| 16色图标(色块分明) | 良好,压缩率40-60% | 颜色切换频繁但仍有连续区域 |
| 照片/渐变图 | 差,可能膨胀 | 相邻像素几乎都不相同 |
| 抖动处理后的图像 | 极差,必然膨胀 | 像素值随机分布,无重复 |
所以,RLE只适合特定类型的图像。在GUI中,它最适合压缩图标、按钮、边框这类图形元素。对于背景图或照片,你得用其他算法,比如LZSS或者哈夫曼编码,这些后面章节会讲到。
3.5 一个完整的图标压缩示例
咱们来看一个实际的32×32单色电池图标。原始数据是128字节(32×32/8),每个bit代表一个像素。
// 原始图标数据(部分)
// 电池图标:中间是电池主体,上下是电极
uint8_t battery_icon[] = {
0xFF, 0xFF, 0xFF, 0xFF, // 第1行:全黑(电极)
0x80, 0x00, 0x00, 0x01, // 第2行:左右边框
0x80, 0x00, 0x00, 0x01, // 第3行:左右边框
0x80, 0x00, 0x00, 0x01, // 第4行:左右边框
0x80, 0x00, 0x00, 0x01, // 第5行:左右边框
0xFF, 0xFF, 0xFF, 0xFF, // 第6行:全黑(电极)
// ... 省略中间部分
};
// 经过RLE编码后
// 0xFF 0xFF 0xFF 0xFF → 0x04 0xFF
// 0x80 0x00 0x00 0x01 → 0x01 0x80 0x02 0x00 0x01 0x01
// 压缩率计算:
// 原始:128字节
// 压缩后:约52字节
// 压缩率:(128-52)/128 ≈ 59.4%
你看,一个简单的电池图标,压缩率接近60%。如果你的Flash里有100个这样的图标,就能省下7.6KB的空间。在资源紧张的MCU上,这可不是小数目。
优化技巧:如果你发现某些图标的压缩率不理想,可以试试调整图标的绘制方式。比如把渐变改成纯色块,或者减少抖动处理。我曾经把一个压缩率只有20%的图标,通过重新设计,把压缩率提升到了65%。
3.6 性能考量
RLE的解压速度非常快,因为它只需要简单的循环和内存拷贝。在我的测试中,在72MHz的Cortex-M3上,解码一个32×32的图标只需要不到100微秒。这对于GUI刷新来说完全可以接受。
但要注意一点:RLE解码是串行的,你不能随机访问某个像素。如果你需要频繁更新图标的某个局部区域,RLE可能不是最佳选择。这种情况下,可以考虑把图标分成多个小块分别压缩,或者干脆不解压,直接存储原始数据。
嗯,RLE就聊到这儿。它虽然简单,但在嵌入式GUI中确实是个利器。下一节我们会讲另一种无损压缩算法——LZSS,它在处理更复杂的数据时表现更好。