如何对多字节数据实现序列化+压缩(附C源码)

文摘   2024-11-13 07:31   山东  

点击上方蓝色字体了解更多的嵌入式编程实用技能。
如果你觉得该文章对你有帮助,欢迎点赞+关注

前言

经常接触数据通信的朋友应该对数据的“序列化”和“反序列化”有所了解,用与可以跨平台存储,和进行网络传输。

序列化:把对象转化为可传输的字节序列过程称为序列化。
反序列化:把字节序列还原为对象的过程称为反序列化。

如果光看定义很难一下子理解序列化的意义,那么我们可以从另一个角度来推导出什么是序列化, 那么究竟序列化的目的是什么?

通常我们定义的传输协议也可以勉强算作是一种将数据序列化的操作,目的是为了能让接收方正常接收解析出来;为什么说勉强算呢?因为这种方式通常随着数据的加入要随时更新协议文档;
而常规的序列化逻辑是不会随着数据的添加/删除而有所改变,反序列化也是如此。

简单的理解就是给你一套模板,比如将结构体的每项数据按照这个模板方式打包传输给其他设备,其他设备按照这个结构体定义,讲将数据反序列化后更新定义好的结构体变量。
可能有人会问了,我直接将结构体传输过去就行了啊,有必要那么复杂吗?

如果是相同平台的话,可能问题不大,但是如果平台大小端模式不一样,平台字长不一样呢?而且有些结构体中还有指针,其他平台拿到这个指针地址难道访问自己平台的数据吗?或者链表等非连续内存的情况呢?

因此序列化就是解决这些问题的,目前常用的序列化方式有JsonXmlprotobuf等跨平台跨语言,C++的boost序列化只支持跨平台的C++语言。

介绍

序列化/反序列化的概念大概了解了,那么现在回归正题,“如何多个字节的数据序列化并压缩”,这里介绍谷歌protobuf序列化中的Varint+ZigZag编码和解码,对数据序列化并压缩。

  • Varint是一种使用一个或多个字节序列化整数的方法,会把整数编码为变长字节(压缩),比如将一个32位正整型数据经过该方式编码后需要占用1~5个字节(正常4个字节),其中数值越小,需要占用的字节就越少,虽然数值大的情况下会比原来占用多一个字节,但是通常情况下在实际场景中小数字的使用率远远多于大数字,因此通过Varint编码对于大部分场景都可以起到很好的压缩效果。

  • ZigZag主要是对Varint编码的一种补充,针对于负整数来说,Varint编码不能起到很好的压缩效果,因此需要采用ZigZag编码,同样的,一个32位正整型数据经过该方式编码后也需要占用1~5个字节(正常4个字节)。

原理

Varint编码

除了最后一个字节外,varint编码中的每个字节都设置了最高有效位,用来隐性地表示数据长度,即bit7用作数据长度的标识,当检测到一个字节的bit7为1时,则标识下一个字节依然时该整数的数据。

比如定义一个4字节的整数型,当值为5时,其补码(计算机中实际按补码表示整数)按位展开为"00000000 00000000 00000000 00000101",而Varint编码则是去掉高字节全是0的字节,即“00000101”,赋值给4字节的整数型变量时其值没有发生变化,这里只用到了一个字节表示。
如果该值等于500呢?"00000000 00000000 00000001 11110100",如果没有长度信息的话,那么完全可以认为非0字节有两个值,分别为1和244,显然这么处理肯定有问题,因此Varint编码使用了每个字节的最高位作为长度标记位,只用7个bit作为数据位,即“0000011 1110100”,同时采用小端模式处理多字节的序列化(低字节在前,高字节在后),同时加上长度标记位,即“11110100 00000011”,在解码时遇到字节的高bit位为0则完成解码,否则继续解码。

注意:因为数据采用了7个bit作为数据位,因此每个字节所代表的范围就发生了变化,原来一个字节最大为255,采用该编码后最大值变为了127,因此当4字节的整数型超过一定值时,就会比原先多占用一个字节来表示,即5个字节。

编码代码实现

uint8_t *VarintEncoded(uint8_t *ptr, uint64_t val)
{
    while (val >= 0x80)
    {
        *ptr++ = (uint8_t)val | 0x80;
        val >>= 7;
    } 

    *ptr++ = (uint8_t)val;

    return ptr;
}

对应的解码实现

uint8_t *VarintDecode(uint8_t *ptr, uint64_t *pVal)
{
    uint8_t offset = 0;
    uint64_t result = 0;

    do
    {
result |= (uint64_t)((*ptr) & ~0x80) << offset;
        offset += 7;
    } while (((*ptr++) & 0x80) != 0);

    *pVal = result;

    return ptr;
}

ZigZag编码

该编码主要是针对有符号的整数型变量,因为从上面的Varint编码中可以看到正整数的编码原理,但如果是负数,那么继续采用Varint编码怎没有任何压缩效果,甚至占用更多字节。因此ZigZag编码是为了解决varint对负数编码效率低的问题。

比如定义一个4字节有符号的整数型,当值为-5时,其补码按位展开为"11111111 11111111 11111111 11111011",一看每个字节都是非0,完全没有压缩空间(补码的最高位是符号位,对于负数,符号位为1,它阻碍了对于无意义0的压缩)。
如何解决呢?编码按照以下步骤处理:

  1. 左移一位,即“11111111 11111111 11111111 11110110

  2. 符号位放到最后,即“11111111 11111111 11111111 11110111

  3. 除最后一位外全部取反,即“00000000 00000000 00000000 00001001

  4. 再使用Varint编码

编码代码实现

uint8_t *ZigzagEncoded(uint8_t *ptr, int64_t val)
{
    return VarintEncoded(ptr, (uint64_t)((val << 1) ^ (val >> 63)));
}

对应的解码实现

uint8_t * ZigzagDecode(uint8_t *ptr, int64_t *pVal)
{
    uint64_t u64val;

    ptr = VarintDecode(ptr, &u64val);
    *pVal = (int64_t)((u64val >> 1) ^ - (u64val&1));

    return ptr;
}

效果展示

Varint编码

demo代码

int main(void)
{
    uint8_t buf[10];
    int length = 0;
    uint64_t val;

    length = VarintEncoded(buf, 5) - buf;
    HEX_PRINTF("hex", buf, length);
    length = VarintDecode(buf, &val) - buf;
    printf("val = %ld\n\n", val);

    length = VarintEncoded(buf, 133) - buf;
    HEX_PRINTF("hex", buf, length);
    length = VarintDecode(buf, &val) - buf;
    printf("val = %ld\n\n", val);

    length = VarintEncoded(buf, 2053) - buf;
    HEX_PRINTF("hex", buf, length);
    length = VarintDecode(buf, &val) - buf;
    printf("val = %ld\n\n", val);

    length = VarintEncoded(buf, 32773) - buf;
    HEX_PRINTF("hex", buf, length);
    length = VarintDecode(buf, &val) - buf;
    printf("val = %ld\n\n", val);

    length = VarintEncoded(buf, 105536000) - buf;
    HEX_PRINTF("hex", buf, length);
    length = VarintDecode(buf, &val) - buf;
    printf("val = %ld\n\n", val);
}

运行结果:

hex[1]: 05 
val = 5

hex[2]: 85 01 
val = 133

hex[2]: 85 10 
val = 2053

hex[3]: 85 80 02 
val = 32773

hex[4]: 80 b4 a9 32 
val = 105536000

ZigZag编码

demo代码

int main(void)
{
    uint8_t buf[10];
    int length = 0;
    int64_t i64Val;

    length = ZigzagEncoded(buf, -5) - buf;
    HEX_PRINTF("hex", buf, length);
    length = ZigzagDecode(buf, &i64Val) - buf;
    printf("val = %ld\n\n", i64Val);

    length = ZigzagEncoded(buf, -133) - buf;
    HEX_PRINTF("hex", buf, length);
    length = ZigzagDecode(buf, &i64Val) - buf;
    printf("val = %ld\n\n", i64Val);

    length = ZigzagEncoded(buf, 2053) - buf;
    HEX_PRINTF("hex", buf, length);
    length = ZigzagDecode(buf, &i64Val) - buf;
    printf("val = %ld\n\n", i64Val);

    length = ZigzagEncoded(buf, -2053) - buf;
    HEX_PRINTF("hex", buf, length);
    length = ZigzagDecode(buf, &i64Val) - buf;
    printf("val = %ld\n\n", i64Val);

    length = ZigzagEncoded(buf, -32773) - buf;
    HEX_PRINTF("hex", buf, length);
    length = ZigzagDecode(buf, &i64Val) - buf;
    printf("val = %ld\n\n", i64Val);
}

运行结果:

hex[1]: 09 
val = -5

hex[2]: 89 02 
val = -133

hex[2]: 820 
val = 2053

hex[2]: 89 20 
val = -2053

hex[3]: 89 80 04 
val = -32773

扩展

熟悉Varint+ZigZag编码和解码后,结论就是无符号整数型变量采用Varint编码,有符号整数型变量采用ZigZag编码,但是没有信息表示这个数据是无符号整数型变量还是有符号整数型变量,因此编码时可以区分采用哪种编码,但是解码的时候并不清楚。因此实际使用时如果需要区分这两种,则还需要增加类型信息区分。

protobuf每个变量采用的是“tag + length[可选] + val(编码后的数据)”

目前最新实现的参数管理模块中参考此方式优化了序列化的方式,因为参数变量类型有多种,因此在此基础上做了一些优化工作;有兴趣的朋友可以下载了解。

后续还会继续优化,争取多种场景下使用

下载链接(点击阅读原文):https://gitee.com/const-zpc/param/tree/master



极客工作室
一个专注于嵌入式系统、智能硬件、AIoT的极客自媒体
 最新文章