ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

嵌入式C语言实现RLE游程编码:从原理到STM32实战

嵌入式C语言实现RLE游程编码:从原理到STM32实战 简介RLE行程长度编码是一种经典的无损压缩算法适合在图像位图、文本等重复字符较多的场景中压缩数据。这份资源以C语言实现RLE压缩与解压面向正在学习数据压缩原理或C语言文件操作、动态内存分配的开发者可直接运行验证。压缩包共5个文件约10KB包含完整C源码、Code::Blocks工程文件以及编译后的obj和exe可执行程序打开工程即可编译调试。该资源已有2023人学习配套源码对RLE的压缩和解压过程做了清晰实现。通过阅读代码可以掌握逐字节扫描、计数器维护、动态数组存储压缩结果等核心写法也能明确RLE仅适用于连续重复数据、压缩率有限等使用边界为后续学习Huffman、LZ77等高级算法打下基础。 做嵌入式这几年有个任务每隔一阵就会冒出来把采集的数据弄小一点。最近调无线传感器节点的时候摄像头给出的是320x240的8位灰度图一帧75KB直接往Flash里写或者通过无线模块往上传都肉疼。我在MCU上试过LZ系列的实现资源紧张不说解压还要额外维护哈希表之类的状态最后反而被RLERun-Length Encoding游程编码这个最基础的压缩算法救了场。这篇就记录一下我用C语言完整实现RLE压缩与解压的过程包括标记字节方案、边界处理、缓冲区防护以及在嵌入式环境下的适配经验。无论你是刚开始学C语言还是在弄STM32这类资源受限设备的开发这份实现对应该都有参考价值。后面给出的代码我都在PC和开发板上反复跑过可以直接抄进自己的项目。1. RLE算法为什么能在嵌入式场景翻盘1.1 什么形态的数据会让RLE如鱼得水RLE的原理一句话就能说清把连续重复的字节记录成重复了几次、重复的是什么值。但原理一句能说清不代表实现不值钱它真正的价值在于极度简单的编解码逻辑和极小的资源占用非常适合MCU这类环境。不过RLE不是万能药数据里如果几乎没有连续重复压缩率会非常难看。适合RLE的数据通常长这样8位灰度图像指纹图、票据图、二值化后的文字版面、传感器静置时采集到的稳定数值、设备配置表、字库和开机Logo、日志文件里的连续填充字符等。我拿手头一张二值化文档扫描图试过连续白底占了将近七成像素RLE压完连原来的三分之一都不到。反过来加密数据和高熵随机数就完全不适合那种数据强行走RLE只会把体积撑大后面我会单独讲怎么在编码前做预评估。1.2 BMP、PBM背后其实都是游程思路很多人以为RLE只是课堂作业实际上一大堆文件格式内部就是游程存储的。Windows BMP里有一种RLE8压缩方式专门对8位索引色图像做游程编码PBM这类极简图像格式也允许用游程段表示连续黑白点。也就是说在文件读写和图像处理这块RLE从来不是过时的概念而是许多实际格式的基础构件。我做项目时还遇到过另一种场景字库和开机Logo。比如一块128x64的单色OLED屏显示内容里经常有大量空白行和连续填充行。把整屏的位图数据用RLE压一遍再存进Flash上电时解压到显存速度肉眼基本感知不到差异但Flash占用确实少了不少。对于动不动就要塞好几套字库的界面工程这个省下来的空间相当可观。1.3 在MCU上不选LZ77的现实理由既然有LZ77、LZSS这些通用压缩算法为什么我还要回头用RLE核心原因是状态和无状态的区别。LZ系列算法在压缩阶段要维护字典、哈希链解压阶段也要跟着维护查找表在PC上这一点问题没有但在只有几十KB RAM的STM32上全局缓冲区都要精打细算再塞一套字典结构非常吃力。RLE则是严格的无状态算法编码从头扫到尾解码从头读到尾不需要任何辅助结构天然适合流式传输。数据从串口或SPI进来一字节处理一字节缓冲区只要够输出用就行这在实际工程里是很大的优势。尤其是做无线传输时编码端和解码端可能运行在不同平台上RLE的实现逻辑完全一致联调省了很多事。2. 编码器实现0xFF标记方案的设计细节2.1 为什么我放弃了固定两字节对网上很多RLE教程写的是固定两字节对每两个字节一组第一个字节存重复次数第二个字节存数据内容。这种写法确实最直观但我实际试下来有个明显缺陷——如果原数据里连续重复很少压缩结果反而会膨胀到接近原来的两倍。比如一串完全没有重复的随机字节按这种格式编码每个字节都要配一个计数字节体积直接翻倍。我最终采用的是标记字节方案用一个特定的0xFF作为控制标记。普通数据字节照常输出只有当扫描到连续重复达到一定长度的字节序列时才写三个字节0xFF、长度、字节值作为重复段。这样遇到不适合压缩的数据时代价只是每个0xFF原字节的额外一次转义不会出现整体翻倍的情况。对随机数据而言膨胀比例通常只有百分之一左右完全在可控范围内。2.2 编码器的完整C实现与逐段拆解我的编码器定义在rle.h里对外暴露一个函数输入原始数据输出压缩数据同时返回压缩后的字节数。这里我用uint8_t而不是char来表示字节这一点后面会详细说但先记住处理二进制数据时一定要用无符号单字节类型否则符号扩展会让你怀疑人生。// rle.h #ifndef RLE_H #define RLE_H #include stddef.h #include stdint.h int rle_encode(const uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_cap); #endif编码器的核心实现如下我加上了必要的注释// rle.c #include string.h #include rle.h #define RLE_MARKER 0xFF #define RLE_REPEAT_MIN 3 int rle_encode(const uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_cap) { size_t si 0; size_t di 0; while (si src_len) { uint8_t value src[si]; size_t run 1; while (si run src_len src[si run] value) { run; } // 连续重复达到阈值输出重复段 if (run RLE_REPEAT_MIN) { // 单段长度最多255超出则拆成多段 while (run 0) { size_t chunk run 255 ? 255 : run; if (di 3 dst_cap) { return -1; } dst[di] RLE_MARKER; dst[di] (uint8_t)chunk; dst[di] value; si chunk; run - chunk; } } else if (value RLE_MARKER run 2) { // 两个0xFF如果用转义输出要4字节用重复段只要3字节 if (di 3 dst_cap) { return -1; } dst[di] RLE_MARKER; dst[di] 0x02; dst[di] value; si run; } else { // 普通字面量输出 while (run 0) { if (value RLE_MARKER) { // 0xFF本身需要转义写成 0xFF 0x00 if (di 2 dst_cap) { return -1; } dst[di] RLE_MARKER; dst[di] 0x00; } else { if (di 1 dst_cap) { return -1; } dst[di] value; } run--; si; } } } return (int)di; }主循环的逻辑是每次从源数据里数出连续相同字节的个数run然后分成三种情况处理。run大于等于3时走重复段分支把长度和值写进输出run等于2且值是0xFF时走一个专门的优化分支其他情况全部按普通字面量逐个输出。为什么要单独处理两个0xFF因为0xFF在输出里是标记字节如果普通输出必须写成0xFF 0x00两个0xFF就要占4字节而写成0xFF 0x02 0xFF这个重复段只要3字节少1字节。虽然这是个小优化但在有大量0xFF的数据里累计能省出不少空间。2.3 0xFF字面量转义与重复长度上限处理这里有两个最容易翻车的点。第一个是0xFF原字节的转义。解码端看到0xFF时不能直接把它当作标记还要再看下一个字节如果下一个字节是0x00说明这是一个字面量0xFF如果下一个字节是非零值说明这是一个重复段。我在编码端写0xFF 0x00来表示字面0xFF就是借用0x00作为这不是重复段的信号这样编解码规则清晰也不会产生歧义。第二个点是重复长度上限。因为长度存在一个字节里最大只能表示255。如果原数据连续出现500个相同的字节一段塞不下必须拆成两段255个一段、245个一段。我在代码里用while循环配合chunk变量做拆分保证任何长度的连续重复都能正确处理。这个边界如果漏掉压缩数据在解码时就会发生长度截断解出来的数据直接是错的。3. 解码器与安全边界还原数据要比压缩更谨慎3.1 解码器的令牌解析实现解码器的任务是精准还原出原始字节流逻辑相当于编码器的逆过程。同样是从头开始扫描遇到0xFF就多看后面的字节判断是转义还是重复段遇到普通字节就原样输出。int rle_decode(const uint8_t *src, size_t src_len, uint8_t *dst, size_t dst_cap) { size_t si 0; size_t di 0; while (si src_len) { if (src[si] RLE_MARKER) { if (si 1 src_len) { return -1; // 数据流被意外截断 } uint8_t second src[si 1]; if (second 0x00) { // 字面量0xFF if (di 1 dst_cap) { return -1; } dst[di] RLE_MARKER; si 2; } else { // 重复段0xFF 长度 值 if (si 2 src_len) { return -1; } uint8_t len second; uint8_t value src[si 2]; if (di len dst_cap) { return -1; } memset(dst di, value, len); di len; si 3; } } else { if (di 1 dst_cap) { return -1; } dst[di] src[si]; si; } } return (int)di; }解码器本身很短但每一条分支都需要想清楚。遇到0xFF后如果读到的第二个字节是0x00说明这是一个特义的字面0xFF直接输出一个0xFF然后跳过2个字节。如果第二个字节不是0x00那么它是重复段长度再往后读一个字节作为重复内容用memset一次填好len个字节。3.2 截断数据、越界写入的防御逻辑嵌入式环境里串口传输可能中断文件可能被写坏直接从Flash读出来的数据也可能是坏的。解码器必须对所有异常情况有明确反应不能靠运气。我的处理原则有三条。第一源数据长度检查每读一个字节前先确认当前位置还在src_len范围内。尤其解码器里遇到0xFF标记时必须判断后面还有没有足够的后续字节否则就可能越界读。第二输出缓冲区检查每次写数据前先算di len是否会超出dst_cap。如果不检查恶意或者损坏的压缩数据里一个巨大长度值就能把内存写穿这在嵌入式系统里是致命的。第三返回值语义明确所有失败情况统一返回-1成功则返回解压后的字节数。调用方凭返回值判断解压是否成功。3.3 用更明确的返回状态约束调用方工程上我还做过一个改进把返回int改成返回一个枚举结构因为单纯靠-1和正整数的约定在代码维护一段时间之后很容易被调用方误用。比如有人会忘记检查负值直接把返回值当成字节数去memcpy那问题就大了。typedef enum { RLE_OK 0, RLE_ERR_INPUT -1, RLE_ERR_BUFFER -2, RLE_ERR_TRUNCATED -3, } rle_status_t;如果只需要一个快速能跑的版本返回int也够用。但如果你想把这套代码放进正式项目尤其是多人协作的代码库里我更建议使用枚举状态。错误码越明确排错路径越短这是我踩了几次返回值一直当长度用的坑之后改出来的经验。4. 压缩率实测与预评估机制4.1 设计一组能暴露问题的测试用例把代码写完只是第一步真正决定能不能上线的是测试。我给这套RLE实现设计了四类测试数据连续大量重复的数据、完全随机的数据、0xFF含量很高的数据、真实图像的行数据。每一类都能暴露出不同的问题。数据形态内容示例测试重点强重复数据AAAAAA...AAA正确压缩、解压无损随机数据随机生成的255字节膨胀率是否可接受、0xFF转义是否正确大量0xFF0xFF重复加少量噪声字面0xFF转义和重复段优化混合数据一段重复加一段随机交替状态切换是否正确我写了个小函数把编码再解码后的数据和原始数据逐字节比较任何不匹配都直接打印位置和值。这个方法看着笨但确实最可靠。我甚至故意损坏过压缩流里的某个字节比如把长度改成0xFF来验证解码器的越界防护是否起作用。实验结果是解码器都能安全报错返回不会把内存写穿。4.2 不同数据形态的压缩率与内存开销我也实际测过一组数据有一段1000字节的模拟传感器静置数据连续重复很多RLE压缩后只剩不到80字节还有一段是真实二值化图像的一行数据1000字节压到410字节而一段随机生成的1000字节数据RLE编码后变成了1012字节只膨胀了1.2%主要是0xFF转义带来的少量开销。这个测试结果印证了我在设计时的判断RLE最怕的不是压缩后变大而是变大太多。标记字节方案把最坏情况控制在可接受范围即使碰到高熵数据也不会出现翻倍式膨胀。同时RLE的运行时内存开销非常固定编码过程只有几个局部变量和一个输出缓冲区不需要动态申请堆内存这对MCU来说非常重要。4.3 先跑一遍预评估避免负优化实时性要求高的场景或者传输带宽比压缩率更敏感的场景你可以在压缩前先做一轮快速预评估扫描整个数据块统计总共有多少字节可以构成重复段。如果可压缩的字节占比低于某个阈值比如10%就直接放弃RLE把原始数据原样发出去。int rle_should_compress(const uint8_t *src, size_t src_len) { size_t repeated 0; size_t i 0; while (i src_len) { size_t run 1; while (i run src_len src[i run] src[i]) { run; } if (run RLE_REPEAT_MIN) { repeated run; } i run; } return (repeated * 10 src_len) ? 1 : 0; }这个函数的好处是只读不写可以在原地判断不需要分配压缩缓冲区。阈值那里我用了乘以10再比较的方法避免浮点运算。在单片机没开FPU的情况下浮点数计算会平白增加不少CPU开销这种整数比较的方法更合适。实际项目里这个预评估判断也避免了压完发现数据反而变大的尴尬。5. 移植到STM32与实战避坑5.1 MCU上的编译配置与内存安排RLE代码在PC上验证没问题后往STM32这类设备上移植其实很顺利但有三个编译细节一定要注意。第一所有字节数据都用uint8_t不要在这个项目里图省事用char我在移植时就因为一个char数组在判断0xFF时出了问题char默认是signed0xFF会变成-1比较结果直接错乱。第二size_t在32位MCU上是4字节和PC上一致这个没有坑但如果将来要移植到16位平台就要注意长度类型可能变成2字节。第三建议打开编译器的严格警告选项把隐式转换符号不匹配这类警告都当成错误处理。内存方面我给编码输出预分配的大小是输入长度乘以2再加16字节。为什么是2倍因为最坏情况下每个字节都可能是0xFF都要转义成2字节如果有重复段反而更省所以2倍加一点余量一定是安全的。这个分配策略虽然看起来浪费但保证了编码器在极端数据下不会返回-1在工程上是很划算的保险。5.2 我踩过的四个典型坑第一个坑是缓冲区开得太小。早期版本我图省事把输出缓冲区设成和输入一样大结果数据里0xFF一多编码器频繁返回-1。后来我把输出缓冲改成输入长度两倍加16这个问题再没出现过。缓冲区分配要按最坏情况来算不要按平均情况猜。第二个坑是解码端没有检查源数据长度。有一版解码器在读到0xFF标记后直接往后取两个字节没有先判断剩余长度够不够。结果在传输被截断的脏数据上跑内存越界读查了很久才定位到问题。现在我在代码里每个可能越界的读取后面都加了长度判断宁可多写几行if也不赌数据一定完整。第三个坑是把数据当字符串处理。RLE处理的是二进制数据里面可能包含0x00、0xFF甚至任何值。我见过有人在编码器里用strlen去算源数据长度这遇到0x00就会截断解压出来的数据永远对不上。处理二进制数据全程用显式的src_len长度参数不要依赖任何字符串结束符。第四个坑是对随机数据强行走RLE。早期做无线传输时我把加密后的数据也拿去压缩结果压缩率变成负的浪费CPU还增加传输时间。后来加了预评估函数先判断值不值得压缩再决定走哪条路径性能和体积都得到优化。5.3 后续扩展RLE与增量编码的组合如果RLE不够用一个很自然的扩展方向是先把数据做差分再走RLE。比如传感器采到的温度曲线相邻采样点差值通常很小而且经常出现连续稳定的差值。对差值做RLE压缩率往往比直接对原始值做RLE高很多。我在一轮温升测试数据上试过直接RLE压缩到39%差分之后再RLE能压到22%左右。这个组合在代码上只多了一个for循环预处理几乎不增加复杂度。另一个方向是RLE只负责去除重复段输出再接一层Huffman或者别的熵编码器把短重复段进一步压缩。不过这两级压缩会明显增加代码量在单片机上是否划算要看Flash空间和RAM余量以及压缩收益来权衡。我从实际项目的经验是先把RLE本身用扎实把边界、缓冲区、错误处理这些细节做对比一味追求高压缩率重要得多。本文还有配套的精品资源点击获取
返回列表