点阵字体压缩技术:游程编码(RLE)原理与实现、差分编码、位图压缩算法对比

点阵字体说白了就是一张张位图。一个16×16的汉字,256个bit,32个字节。存几千个字,ROM一下子就吃紧了。我早年做一款工业手持设备,字库就占了128KB Flash的四分之一,那会儿真是掰着手指头算空间。

压缩,是绕不开的路。今天咱们聊几种最常用的点阵字体压缩手段。不扯虚的,直接上原理、上代码、上对比。

游程编码(RLE) — 最朴素的压缩思路

RLE的原理简单到离谱:把连续重复的数据,用「重复次数 + 值」来替代。

你想想看,一个「一」字,16×16点阵里,中间十几行全是0x00,两侧几列全是0xFF。这种连续重复的pattern,在点阵字体里太常见了。

核心思想:将连续的相同字节序列,替换为「计数值 + 数据值」。

基本RLE实现

我习惯用两种模式:字节级RLE和位级RLE。字节级适合横向扫描的字体,位级适合纵向扫描。

// 字节级RLE编码 — 我项目里常用的版本
// 输入:原始点阵数据,长度len
// 输出:压缩后的数据流
// 返回值:压缩后字节数

int rle_encode(uint8_t *src, int len, uint8_t *dst) {
    int src_idx = 0, dst_idx = 0;
    
    while (src_idx < len) {
        uint8_t cur = src[src_idx];
        int count = 1;
        
        // 统计连续相同字节
        while (src_idx + count < len && src[src_idx + count] == cur && count < 255) {
            count++;
        }
        
        // 写入计数值和数据值
        dst[dst_idx++] = (uint8_t)count;
        dst[dst_idx++] = cur;
        
        src_idx += count;
    }
    
    return dst_idx;
}

// 解码
void rle_decode(uint8_t *src, int compressed_len, uint8_t *dst) {
    int src_idx = 0, dst_idx = 0;
    
    while (src_idx < compressed_len) {
        int count = src[src_idx++];
        uint8_t val = src[src_idx++];
        
        for (int i = 0; i < count; i++) {
            dst[dst_idx++] = val;
        }
    }
}

我的经验:RLE对纯色区域多的字体压缩效果极好。比如黑体、宋体的大字号,空白行多,压缩比能到3:1以上。但遇到笔画密集的草书或小字号,效果就差了,甚至可能膨胀。

RLE的变种 — 我踩过的坑

基本RLE有个问题:如果数据本身很少重复,计数值本身就成了开销。我曾经在一个项目里,用基本RLE压缩8×8的ASCII字符集,结果压缩后比原来还大了15%。

后来我改用了一种变通方案:

  • 标志位RLE:用1个bit标记后续是「重复模式」还是「原始数据」
  • 自适应RLE:当重复长度小于3时,不压缩,直接存原始数据
// 自适应RLE — 避免小重复导致的膨胀
int rle_adaptive_encode(uint8_t *src, int len, uint8_t *dst) {
    int src_idx = 0, dst_idx = 0;
    
    while (src_idx < len) {
        uint8_t cur = src[src_idx];
        int count = 1;
        
        while (src_idx + count < len && src[src_idx + count] == cur && count < 255) {
            count++;
        }
        
        if (count >= 3) {
            // 重复次数≥3,用RLE
            dst[dst_idx++] = 0xFF;  // 标志:RLE模式
            dst[dst_idx++] = (uint8_t)count;
            dst[dst_idx++] = cur;
        } else {
            // 重复次数少,直接存原始数据
            for (int i = 0; i < count; i++) {
                dst[dst_idx++] = 0x00;  // 标志:原始模式
                dst[dst_idx++] = src[src_idx + i];
            }
        }
        
        src_idx += count;
    }
    
    return dst_idx;
}

注意:自适应RLE虽然避免了膨胀,但解码逻辑变复杂了,解码速度会下降。如果你的系统对解码实时性要求高(比如滚动刷新),需要权衡。

差分编码 — 利用相邻行的相似性

点阵字体有个特点:相邻两行的数据往往非常相似。尤其是笔画较少的字,上一行和下一行可能只差几个bit。

差分编码的思路就是:不存原始数据,只存「当前行与上一行的差异」。

差分编码原理

说白了就是做异或(XOR)。

// 差分编码 — 按行差分
// 第一行存原始数据,后续行存与上一行的异或结果

void diff_encode(uint8_t *bitmap, int rows, int bytes_per_row, uint8_t *output) {
    // 第一行:原始数据
    memcpy(output, bitmap, bytes_per_row);
    int out_idx = bytes_per_row;
    
    // 后续行:与上一行异或
    for (int r = 1; r < rows; r++) {
        for (int b = 0; b < bytes_per_row; b++) {
            output[out_idx++] = bitmap[r * bytes_per_row + b] ^ 
                                bitmap[(r-1) * bytes_per_row + b];
        }
    }
}

// 解码
void diff_decode(uint8_t *compressed, int rows, int bytes_per_row, uint8_t *output) {
    // 第一行
    memcpy(output, compressed, bytes_per_row);
    int in_idx = bytes_per_row;
    
    // 后续行:异或回去
    for (int r = 1; r < rows; r++) {
        for (int b = 0; b < bytes_per_row; b++) {
            output[r * bytes_per_row + b] = 
                compressed[in_idx++] ^ output[(r-1) * bytes_per_row + b];
        }
    }
}

关键点:差分编码本身不压缩数据,它只是让数据变得更「稀疏」。差分后的数据再用RLE或Huffman压缩,效果会好很多。我习惯把差分编码当作预处理步骤。

三种位图压缩算法对比

咱们把RLE、差分编码、还有LZ系列(LZ77/LZSS)放在一起比比。LZ系列虽然复杂,但在某些场景下确实香。

算法 压缩比(典型) 解码速度 内存开销 实现复杂度 适用场景
RLE(基本) 1.5:1 ~ 4:1 极快 极低(几个字节) 低 大字号、空白多的字体
差分+RLE 2:1 ~ 5:1 快 低(一行缓冲区) 中 笔画连续的字体、等宽字体
LZSS 3:1 ~ 6:1 中等 中(几KB滑动窗口) 高 字库量大、对压缩比要求高的场景

我个人习惯这样选:

  • 字库小于64KB,用基本RLE就够了,解码快,代码简单
  • 字库64KB~256KB,用差分+RLE,压缩比提升明显,解码速度还能接受
  • 字库超过256KB,或者Flash空间实在紧张,上LZSS

一个小技巧:我曾经在一个项目里,把字库按字符使用频率分组。高频字符用RLE(解码快),低频字符用LZSS(压缩率高)。这样整体压缩比提升了30%,解码平均速度只慢了5%。

算法选择决策流程

下面这张图是我自己总结的选型思路,画成SVG方便你参考。

点阵字体压缩算法选型决策流程 点阵字体压缩算法选型决策流程 字库原始数据 字库大小评估 < 64KB 64KB ~ 256KB > 256KB 基本RLE 解码快,代码简单 差分 + RLE 压缩比与速度的平衡 LZSS 压缩比最高 压缩后的字库数据 💡 说明: 实际项目中,建议先对字库抽样测试压缩比。 我曾经遇到过「理论上RLE最好,实际LZSS反而更优」的情况, 因为字库的局部重复模式比全局重复模式更复杂。

实际项目中的避坑指南

聊几个我踩过的坑,你遇到了能少走弯路。

  • 别迷信压缩比:我曾经为了追求高压缩比,用了复杂的Huffman编码。结果解码时MCU算不过来,刷新一帧字体要200ms,卡得没法用。后来换回RLE,虽然压缩比低了20%,但解码速度提升了10倍。
  • 注意对齐:很多MCU的字库需要按字节对齐访问。压缩后的数据如果跨字节边界,解码逻辑会变得很麻烦。我习惯在压缩时保持字节对齐,哪怕多浪费几个bit。
  • 别忘了解压缓冲区:有些压缩算法需要额外的解压缓冲区。比如LZSS需要滑动窗口,如果你的MCU RAM只有2KB,就别想了。
  • 测试边界情况:全0的字(空格)、全1的字(黑块)、笔画最密集的字(比如「龘」),这些边界情况的压缩比可能和平均值差很多。我每次都会专门测试这些极端case。

重要提醒:压缩算法选型不是一锤子买卖。同一个字库,不同字号、不同字体(黑体 vs 宋体 vs 楷体)的压缩效果可能天差地别。我建议你在项目初期就做一个压缩比测试脚本,批量跑一遍所有字符,用数据说话。

嗯,点阵字体的压缩技术,说白了就是「用时间换空间」。RLE、差分编码、LZSS,各有各的脾气。选哪个,取决于你的字库有多大、MCU有多快、RAM有多少。没有银弹,只有权衡。