简介:MNP5是Microcom网络协议中广泛使用的数据压缩协议,常与V.42bis并列。这份资源提供了MNP5协议核心压缩算法的C++实现源码,并附带相应说明文档,适合通信协议学习者、嵌入式开发人员或对调制解调器压缩机制感兴趣的读者阅读,用于理解协议中两种压缩算法如何协同工作。资源包共含2个文件,1个cpp源文件对应算法主体,1个txt说明文件辅助解读代码与协议背景,压缩包整体仅2KB,结构简洁便于快速查阅。已有170人学习下载,属于轻量但值得细读的协议实现示例。通过研读源码,可梳理出MNP5的压缩流程、编码表构建与状态切换思路,并借鉴其C++实现方式来移植到其他数据压缩场景,是一份适合入门协议分析与算法实践的参考素材。
1. 从拨号时代走来的MNP5,压缩思路至今仍能复用
串口调试、工业总线、老旧外设协议栈,偶尔还会从某个压缩包里翻出mnp5.cpp。它的名字来自 Microcom Network Protocol,是上世纪 80 年代为电话线传输设计的纠错与压缩协议族。MNP5 属于其中数据压缩层,常和 V.42bis 并列提起,但两者思路完全不同。MNP5 的实现非常小巧,核心是两个压缩算法的配合:游程编码和自适应频率编码,前者处理重复字节,后者处理字符分布不均匀的文本流。今天看它有种“老收音机里的分立元件”感觉,但它把“先观察数据分布再决定编码策略”这一套路用到了极致。适合正在读协议栈源码、写嵌入式通信驱动,或者想理解自适应压缩到底怎么动态调整编码表的开发者。哪怕你只在单片机上报过温度,也能从这份代码里挖出几个值得抄走的设计。
2. MNP5压缩原理:游程编码与自适应频率编码的配合
2.1 两种算法怎么分工
MNP5 的压缩不是把整块数据塞进一个压缩器,而是把输入流先切断,按字节特征分流。第一道工序是游程编码,专门处理连续重复的字节。常见实现里,如果某个字节连续出现 3 次及以上,编码器就输出一个转义标记、重复字节本身和重复次数。这里的“3 次”是阈值,低于阈值直接原样输出,因为游程编码要额外写入标记和长度,对短重复反而膨胀。
// mnp5 游程编码的典型实现逻辑 #define RUN_THRESHOLD 3 int rle_encode(unsigned char *src, int src_len, unsigned char *dst, int dst_len) { int s = 0, d = 0; while (s < src_len && d < dst_len - 3) { int run_len = 1; while (s + run_len < src_len && src[s + run_len] == src[s] && run_len < 255) { run_len++; } if (run_len >= RUN_THRESHOLD) { dst[d++] = 0xFF; // 转义标记 dst[d++] = src[s]; // 重复的字节 dst[d++] = (unsigned char)run_len; s += run_len; } else { dst[d++] = src[s++]; } } return d; }这段代码里0xFF是转义标记,如果原始数据里本身就有0xFF,需要在前面再插入一个0xFF做转义,否则解码端会误判。游程编码之后的数据再交给自适应频率编码。这样分工的理由很直接:游程编码擅长压缩二进制文件里的大段空白或者位图数据,自适应频率编码擅长压缩文本里“高频字符短编码、低频字符长编码”的分布特征。两者级联,比单一算法覆盖面更宽。
2.2 自适应频率编码的实现要点
2.2.1 符号表与频次跟踪
MNP5 的自适应频率编码不预设静态哈夫曼树,而是维护一张 256 项的符号表,每个符号对应一个频次计数器。每编码一个字节,对应计数器加一,同时按频次对符号重新排序。排序的代价是 O(n log n),但 256 个符号量级不大,在低速串口上完全可接受。这种动态排序的思路,后来在LZ77类算法的哈希链里也能看到影子。
#define SYMBOL_COUNT 256 typedef struct { unsigned char symbol; unsigned int freq; } sym_entry; sym_entry table[SYMBOL_COUNT]; // 初始化时每个符号出现次数为1,避免除零 void init_freq_table(void) { for (int i = 0; i < SYMBOL_COUNT; i++) { table[i].symbol = (unsigned char)i; table[i].freq = 1; } } // 每次编码一个字节后更新频次,并做一次冒泡调整 void update_freq(unsigned char c) { for (int i = 0; i < SYMBOL_COUNT; i++) { if (table[i].symbol == c) { table[i].freq++; // 向表头方向移动,保持频次降序 while (i > 0 && table[i].freq > table[i - 1].freq) { sym_entry tmp = table[i]; table[i] = table[i - 1]; table[i - 1] = tmp; i--; } break; } } }注意这里把所有符号初始频次设为 1,而不是 0,是为了避免某些字符从未出现却无法编码的边界问题。冒泡排序虽然简单,但连续编码时,高频符号会不断向表头移动,而低频符号也能保持基本稳定的位置,实际效果不错。如果你把这个表换成二叉树更新,能更快,但代码复杂度会明显上升,MNP5 当初选数组冒泡就是看重实现简单。
2.2.2 编码选型:为什么用动态霍夫曼而不是静态
静态霍夫曼需要先扫描完整块数据才能确定频次,然后在压缩后再传一张编码表。这在流式传输里行不通,因为调制解调器的数据是边收边发的。动态霍夫曼每处理一个字节就更新频次,编码和解码端同步维护相同的频次表,所以不需要额外传编码表。MNP5 使用了一种近似动态霍夫曼的方法:直接把符号在频次表中的序号作为编码输出,序号越靠前的符号编码位数越少。比如表头第一个符号用 1 位编码,第二个用 2 位,依此类推。这种“序号即码长”的做法牺牲了最优编码长度,但省去了构建树的过程。
// 根据符号在表中的位置输出不定长编码 int encode_symbol(unsigned char c, unsigned char *out) { for (int i = 0; i < SYMBOL_COUNT; i++) { if (table[i].symbol == c) { int bit_len = i + 1; // 位置0用1位,位置1用2位... int code = (1 << bit_len) - 1; // 用全1作为该符号的编码 // 最低bit_len位全为1,例如i=0输出0x01 out[0] = (unsigned char)(code & 0xFF); out[1] = (unsigned char)((code >> 8) & 0xFF); return bit_len; } } return 0; }这里编码值用的是(1 << bit_len) - 1,即 bit_len 个 1。解码端读到连续多个 1 时,根据前缀关系就能反查符号。由于每次更新频次后,符号的位置在变化,所以同一个字符在不同时刻可能得到完全不同的编码。这就要求收发两端严格保持相同的更新顺序,任何一位传输错误都会导致同步丢失。这也是 MNP5 必须搭配纠错协议的原因:压缩层对误码极其敏感,没有重传机制根本没法用。
2.3 压缩率与适用场景的边界
MNP5 对文本类数据压缩率通常能到 2:1 左右,对已经压缩过的数据(JPEG、ZIP)会出现膨胀,因为游程编码几乎找不到重复,自适应频率编码也会把输入映射成更长输出。所以协议里有“不压缩”模式,当连续 N 个字节压缩后没有变短,就自动切换到透传。实际调试时,如果发现传输速率反而下降,先查是不是压缩模式被错误开启了。另外,MNP5 的转义字节0xFF在二进制传输中很常见,如果协议只做了单向转义,没有处理原始数据里的0xFF,解码端就会把普通数据当作控制符,这也是最容易踩的坑之一。
3. 从mnp5.cpp看协议状态机:纠错、转义与数据封装
3.1 数据帧的构造与转义规则
MNP 系列协议是分层设计的,纠错层在压缩层之下。mnp5.cpp这类源文件通常只实现压缩层,但实际工作时要和下面的 MNP2/3/4 纠错层配合。纠错层负责把压缩后的字节流切分成帧,每帧有头、数据、CRC 校验和确认号。原始数据经过压缩后,如果其中出现了帧分隔符或者转义字符,需要在发送前做填充。
# 帧结构示意(HEX) # 7E 表示帧起始/结束标志 # FF 表示转义,后跟原始字节^0x20 7E 01 FF 41 42 43 7E # 上面帧中 0x41='A',0x42='B',0x43='C',FF 41 表示原始字节0x61?实际常用的转义规则并不是把0xFF简单重复,而是类似 HDLC 的位填充:转义标志0x7E,当数据中出现0x7E时,改写为0x7D 0x5E。MNP5 里选0xFF是因为压缩输出多为高位为 1 的字节,但为了和链路层区分,很多实现会再做一次0x7D转义。你在mnp5.cpp里能看到类似下面的函数:
#define HDLC_FLAG 0x7E #define HDLC_ESC 0x7D #define HDLC_ESC_MASK 0x20 int hdlc_escape(unsigned char *data, int len, unsigned char *frame, int frame_cap) { int d = 0; frame[d++] = HDLC_FLAG; for (int i = 0; i < len; i++) { if (data[i] == HDLC_FLAG || data[i] == HDLC_ESC) { if (d < frame_cap - 3) { frame[d++] = HDLC_ESC; frame[d++] = data[i] ^ HDLC_ESC_MASK; } else { return -1; // 帧缓冲区不够 } } else { frame[d++] = data[i]; } } frame[d++] = HDLC_FLAG; return d; }这个函数把帧标志0x7E和转义符0x7D都做了异或掩码处理,解码端遇到0x7D时,把下一个字节异或0x20恢复原始值。这样做的关键点:你无法预知压缩层会输出什么字节,所以链路层必须保证任何字节组合都不会破坏帧边界。很多自制串口协议只做了简单转义,没有处理转义符自身,结果传输二进制数据时总是莫名其妙丢帧。
3.2 纠错确认与重传机制
MNP2/3 提供物理层纠错,通过重传到对端。MNP4 增加了自适应数据包组装,根据线路质量调整帧长。mnp5.cpp一般不直接实现重传,而是依赖下层协议的状态。但压缩状态需要和纠错握手绑定:只有确认对方也支持 MNP5,才会开启压缩。协商过程通常在连接建立时通过 XID(交换识别)帧完成。
// 协商结果的状态变量 typedef enum { COMP_NONE = 0, COMP_RLE = 1, COMP_FREQ = 2, COMP_BOTH = 3 } compress_mode; compress_mode g_compress_mode = COMP_NONE; int negotiate_compression(unsigned char peer_caps) { if (peer_caps & 0x02) { // bit1表示对方支持MNP5 g_compress_mode = COMP_BOTH; // RLE + 频率编码都开 return 0; } g_compress_mode = COMP_NONE; // 不支持就不压缩 return -1; }为什么要先协商再启用?因为自适应频率编码的同步完全依赖双向状态一致。如果一端开启了压缩而另一端没有,压缩后的数据会被当作原始字节处理,编码表永远无法同步,整个连接瞬间变成乱码。实际调试时,如果发现握手后前几个字节能通,到后面就乱码,大概率是有一端在某个时刻重置了频次表。
3.3 关键代码段解析
继续挖mnp5.cpp里的核心压缩函数。压缩一个输入缓冲区的流程一般是:先试跑游程编码,然后对输出结果做频率编码,同时计算压缩前后长度,如果压缩后没有变短,就标记本帧不压缩,直接透传。下面的代码体现了这个决策:
int mnp5_compress(unsigned char *in, int in_len, unsigned char *out, int out_cap) { unsigned char rle_buf[512]; unsigned char freq_buf[512]; int rle_len = rle_encode(in, in_len, rle_buf, sizeof(rle_buf)); int freq_len = freq_encode(rle_buf, rle_len, freq_buf, sizeof(freq_buf)); if (freq_len < in_len && freq_len <= out_cap) { memcpy(out, freq_buf, freq_len); return freq_len; // 压缩成功 } else { memcpy(out, in, in_len); return in_len; // 压缩导致膨胀,返回原数据 } }这里的freq_encode内部会维护上一节说的频次表。注意rle_buf和freq_buf大小都固定为 512,如果输入大于这个长度,需要分块。真实协议里块大小通常取 256 或 512,因为调制解调器的缓冲有限。分块压缩时,每块开始是否需要重置频次表?看实现。有些协议为了减少累积误码影响,每帧都重置表,但这样会损失压缩率;有些只在握手时初始化一次,然后持续更新。MNP5 标准做法是持续更新,所以误码后的恢复需要靠重传整个帧,并且重传后两端频次表必须回到本帧开始前的状态,这就要求解码端预存频次表快照。
4. 实战:把MNP5集成到串口传输模块
4.1 初始化与参数协商
假设你有一个基于串口的自定义通信协议,想在两个 MCU 之间启用 MNP5 压缩。首先得在应用层握手阶段加入能力协商。常见做法是发送端先发一个特殊控制帧,包含一个字节的支持标志:bit0 支持 MNP2 纠错,bit1 支持 MNP5 压缩。对端收到后回一个同样的标志,取交集得到最终模式。
// 握手帧格式:0xAA 0x55 长度 标志 校验 #define CAP_MNP2 0x01 #define CAP_MNP5 0x02 int master_handshake(int fd) { unsigned char request[6] = {0xAA, 0x55, 0x03, CAP_MNP2 | CAP_MNP5, 0x00, 0x00}; unsigned char response[6]; request[4] = crc8(request, 4); write(fd, request, 6); read_exact(fd, response, 6); if (response[0] == 0xAA && response[1] == 0x55) { int caps = response[3]; if (caps & CAP_MNP5) { set_compress_mode(COMP_BOTH); return 1; } } return 0; // 对端不支持,保持透传 }crc8是常见的多项式 0x31 校验,用于防止握手帧在噪声线路上被误判。这里设置COMP_BOTH后,发送路径会插入压缩层,接收路径会插入解压层。如果对端只回了CAP_MNP2而没有CAP_MNP5,那就只能开纠错不开压缩。这种逐步降级的设计,是实际系统里常见的容错策略。
4.2 发送与接收路径改造
发送路径从应用缓冲区取出数据,先经过压缩层,再经过链路层转义,最后写入 UART。接收路径反向。为了不阻塞,发送函数要能处理压缩后比原数据还大的情况,所以输出缓冲区必须大于最大输入长度。按 MNP5 最坏情况膨胀率 1.5 倍算,输入 256 字节,输出缓冲区至少 384 字节。
#define COMP_BLOCK_SIZE 256 #define COMP_MAX_OUT 384 int send_packet(int fd, unsigned char *data, int len) { unsigned char comp_buf[COMP_MAX_OUT]; unsigned char frame_buf[COMP_MAX_OUT * 2]; int comp_len = mnp5_compress(data, len, comp_buf, sizeof(comp_buf)); int frame_len = hdlc_escape(comp_buf, comp_len, frame_buf, sizeof(frame_buf)); if (frame_len < 0) { return -1; } return write_all(fd, frame_buf, frame_len); }mnp5_compress里已经处理了膨胀回退逻辑,当压缩结果不小于原长度时,直接返回原数据,因此comp_buf里的长度可能等于len。链路层转义时,frame_buf要预留最大膨胀空间,因为每个字节都可能变成两字节。write_all要处理串口部分写入的情况,不能直接用write就算完。
接收路径稍微复杂,因为链路层是逐字节解析的。通常用一个状态机维护:空闲态、接收态、转义态。每收到一个字节,先判断是不是0x7D,是则进入转义态,下一个字节异或0x20恢复;再判断是不是0x7E,是则认为帧结束,把累积的帧交给解压层。
typedef enum { RX_IDLE, RX_DATA, RX_ESC } rx_state; int uart_rx_byte(unsigned char byte, unsigned char *frame_buf, int *frame_index) { static rx_state state = RX_IDLE; if (state == RX_IDLE && byte == HDLC_FLAG) { state = RX_DATA; *frame_index = 0; } else if (state == RX_DATA) { if (byte == HDLC_ESC) { state = RX_ESC; } else if (byte == HDLC_FLAG) { // 帧结束,返回帧长度,状态回到空闲 int len = *frame_index; state = RX_IDLE; return len; } else { if (*frame_index < 512) { frame_buf[(*frame_index)++] = byte; } } } else if (state == RX_ESC) { frame_buf[(*frame_index)++] = byte ^ HDLC_ESC_MASK; state = RX_DATA; } return 0; // 还没收到完整帧 }注意这里调用方需要持续喂字节,当返回值非 0 时,拿到一帧完整数据。帧缓冲区大小要足够容纳最大压缩帧加上转义前的原始长度。如果帧长度超过缓冲区,会截断,所以协议里要定义最大帧长,超过就报错。实际线路上,0x7D后面如果跟的是0x7E,恢复成0x5E,这个0x5E不会触发帧结束,只有单独的0x7E才表示帧边界。
4.3 性能调优与参数表
MNP5 的压缩率受块大小影响明显。块太大,低延迟场景下首字节迟迟发不出去;块太小,频次表尚未积累足够统计量,压缩率低。下面是一组实测中常见的配置参考,不同芯片主频和线路速率下需重新测定。
| 线路速率 (bps) | 块大小 (字节) | 压缩模式 | 预期增益 |
|---|---|---|---|
| 9600 | 128 | COMP_BOTH | 1.2 ~ 1.6 |
| 38400 | 256 | COMP_BOTH | 1.5 ~ 2.0 |
| 115200 | 512 | RLE only | 1.1 ~ 1.3 |
| 460800 | 256 | COMP_NONE | 1.0 |
表格里高波特率下反而建议 RLE only,因为主频足够快后,CPU 占用不再是瓶颈,但文本流的高频字符分布较分散,频率编码收益不稳定。另一点是频次表的更新策略:每次更新后做全表排序,随着 CPU 主频不同会有差异,可以在定时器中断里做,也可以放到传输间隙做。如果 MCU 主频低于 72MHz,建议每四个字节更新一次表,而不是每字节更新,能显著减少排序次数。
5. 调试与验证:用日志和坏帧测试确认压缩生效
验证压缩不是看数据变少,而是确认变少的原因确实是 MNP5 而不是巧合。首先在握手成功后,发送一帧已知的重复模式“AAAA…”,在接收端打印收到的原始字节数和解压后字节数。如果压缩生效,链路层收到的帧长度应该小于应用层发送的长度。写一个测试脚本,统计不同内容的压缩率:
# 伪代码:统计压缩前后长度 echo -n "AAAAAAAAAAAAAAAAAAAAAAAAAAAA" > /tmp/test.bin ./mnp5_tool -c /tmp/test.bin /tmp/comp.bin ls -l /tmp/test.bin /tmp/comp.bin # 重复模式压缩后应明显小于原文件 # 随机数据压缩后应等于或略大于原文件用随机数据测试是检验“膨胀保护”是否生效的关键。很多实现会在连续多次膨胀后自动关闭压缩,但我的建议是:随机数据测试不能只看长度,还要看解压后是否与原始逐字节一致。MNP5 的自适应编码有一个隐蔽问题:当输入在压缩过程中发生变化时,比如对方在你的压缩流里插入了控制字符而你没转义,频次表的同步会被破坏。此时解压输出不是直接乱码,而是字节错位,最终长度可能和原始长度相同,但内容不对。
提示:测试时一定要覆盖“压缩后出现 0x7E 和 0x7D 原始字节”的情况。可以构造 0x7E 和 0x7D 各占一半的数据,观察链路层是否正确转义。
另一个容易踩的坑是缓冲区溢出。mnp5_compress里为了省内存,常有freq_buf长度计算错误,导致压缩后长度大于rle_len时写入越界。建议在所有缓冲区写入处加上边界判断,并在地方法中增加断言。
最后,调试时不要只依赖串口助手。打开协议分析仪抓取 PHY 层的字节流,逐帧检查帧标志、转义和 CRC。用坏帧测试器随机丢弃或者翻转帧中的位,观察重传机制是否能恢复压缩状态。正确实现的 MNP5,在单比特错误后,重传该帧即可恢复,后面帧不受影响。如果你发现一个损坏帧导致后续所有帧解压失败,说明频次表的快照和恢复逻辑没做好。这时应该回看压缩层每次压缩开始时是否保存了符号表副本,并在收到 NAK 后重置。
把mnp5.cpp里的代码移植到新平台时,重点看两个函数:update_freq的排序逻辑和hdlc_escape的边界处理。前者决定了压缩率和同步稳定性,后者决定了传输兼容性。只要这两个函数行为正确,整个压缩层基本可以信任。
本文还有配套的精品资源,点击获取