26、序列化与压缩:zlib压缩序列化数据、压缩比与速度权衡

说实话,做嵌入式这么多年,我踩过最大的坑之一,就是忽略了数据体积。

你想想看,一个结构体序列化出来可能才几百字节,但要是存到Flash里,或者通过无线模块发出去,日积月累下来,那数据量就吓人了。我有个项目,设备每天上报一次状态,一年下来日志文件撑爆了存储芯片。后来加了zlib压缩,体积直接缩到原来的三分之一。嗯,今天我们就聊聊这个。

为什么序列化之后还要压缩?

序列化是把内存里的结构体变成字节流。但字节流里往往有很多冗余。比如你存一个int32的数值0,它占了4个字节,但实际信息量几乎为零。再比如字符串里重复的字段名、填充字节,这些都是可以压缩的。

说白了,压缩就是去掉这些冗余,让数据更紧凑。

我个人的习惯是:只要数据要落盘或者走网络,就考虑加一层压缩。尤其是嵌入式设备,Flash和带宽都是稀缺资源,能省一点是一点。

zlib 是什么?

zlib 是一个开源的压缩库,C语言写的,几乎无处不在。Linux、Windows、各种RTOS都能用。它实现了Deflate压缩算法,压缩比和速度可以调节。

你不需要自己写压缩算法,zlib 已经帮你封装好了。我们只需要调用几个函数就行。

zlib 的核心函数

zlib 的API其实不多,常用的就这几个:

函数 作用
compress() 压缩数据,简单粗暴,一次调用搞定
uncompress() 解压缩,对应上面的压缩
deflateInit() / deflate() / deflateEnd() 流式压缩,适合大块数据或不确定长度的数据
inflateInit() / inflate() / inflateEnd() 流式解压缩

对于序列化后的数据,我一般用 compress() 就够了。数据量不大,一次压缩搞定,代码也干净。

实战:序列化 + zlib 压缩

我们拿一个传感器数据结构来举例。

#include <stdio.h>
#include <string.h>
#include <zlib.h>

typedef struct {
    int id;
    float temperature;
    float humidity;
    char location[32];
} SensorData;

int serialize_and_compress(SensorData *data, unsigned char *out, size_t *out_len) {
    unsigned char buf[256];
    int serialized_len = sizeof(SensorData);

    // 1. 序列化:直接把结构体拷贝到缓冲区
    memcpy(buf, data, serialized_len);

    // 2. 压缩
    uLongf compressed_len = *out_len;
    int ret = compress(out, &compressed_len, buf, serialized_len);
    if (ret != Z_OK) {
        printf("压缩失败: %d\n", ret);
        return -1;
    }

    *out_len = compressed_len;
    return 0;
}

int main() {
    SensorData sensor = {
        .id = 1001,
        .temperature = 25.6f,
        .humidity = 68.3f,
        .location = "机房A区"
    };

    unsigned char compressed[256];
    size_t compressed_len = sizeof(compressed);

    if (serialize_and_compress(&sensor, compressed, &compressed_len) == 0) {
        printf("原始大小: %zu 字节\n", sizeof(SensorData));
        printf("压缩后大小: %zu 字节\n", compressed_len);
        printf("压缩比: %.2f%%\n", (float)compressed_len / sizeof(SensorData) * 100);
    }

    return 0;
}

你看,代码其实很简单。序列化就是memcpy,压缩就是compress。但这里有个坑——压缩后的数据不能直接memcpy回结构体,必须先解压缩。

注意:压缩后的数据是二进制流,不是结构体。你不能用指针强转的方式去读取。必须先解压缩,还原成原始字节流,再反序列化。

压缩比与速度的权衡

zlib 的 compress() 默认使用压缩级别6(0-9)。级别越高,压缩比越好,但速度越慢。级别0是不压缩,级别9是最大压缩。

我在项目中做过测试,给大家一个参考:

压缩级别 压缩比(越小越好) 压缩耗时(相对) 适用场景
0 100% 极快 只是打包,不压缩
1 约40% 实时性要求高的场景
6 约30% 中等 默认,平衡之选
9 约25% 存储场景,不关心速度

为什么会这样?因为压缩算法本质上是找重复模式。级别越高,它找得越仔细,但计算量也越大。

我个人习惯:如果数据是实时发送的,用级别1或3。如果是存到Flash里,用级别6或9。别小看这点差别,我曾经在一个项目里用级别9压缩日志,结果每次写入都要卡几百毫秒,后来改成级别3,速度上去了,压缩比也没差太多。

流式压缩:处理大块数据

如果你要压缩的数据很大,比如几兆字节的日志文件,用 compress() 一次性压缩可能会撑爆内存。这时候就要用流式压缩。

流式压缩的核心思想是:边读边压,分块处理

int stream_compress(FILE *in, FILE *out, int level) {
    z_stream strm;
    unsigned char in_buf[4096];
    unsigned char out_buf[4096];
    int ret;
    int flush;

    memset(&strm, 0, sizeof(strm));
    deflateInit(&strm, level);

    do {
        strm.avail_in = fread(in_buf, 1, sizeof(in_buf), in);
        if (ferror(in)) {
            deflateEnd(&strm);
            return Z_ERRNO;
        }
        flush = feof(in) ? Z_FINISH : Z_NO_FLUSH;
        strm.next_in = in_buf;

        do {
            strm.avail_out = sizeof(out_buf);
            strm.next_out = out_buf;
            ret = deflate(&strm, flush);
            if (ret == Z_STREAM_ERROR) {
                deflateEnd(&strm);
                return ret;
            }
            fwrite(out_buf, 1, sizeof(out_buf) - strm.avail_out, out);
        } while (strm.avail_out == 0);

    } while (flush != Z_FINISH);

    deflateEnd(&strm);
    return Z_OK;
}

这段代码看起来长,但逻辑很清晰:每次读4KB数据,压缩后写入输出文件。直到文件读完,调用 Z_FINISH 收尾。

小技巧:流式压缩的缓冲区大小可以调。4KB是个不错的起点。缓冲区太小,压缩比会下降;太大,内存占用高。我一般用4KB或8KB,嵌入式设备上够用了。

避坑指南:我曾经踩过的坑

我曾经在一个项目里,把压缩后的数据直接通过串口发送。接收端解压缩时,发现数据不对。查了半天,原来是压缩后的数据里可能包含0x00字节,而串口驱动把0x00当成了字符串结束符,直接截断了。

所以,如果你用字符串方式传输压缩数据,一定要用二进制传输,或者做base64编码。别问我怎么知道的……

另一个坑是:压缩后的数据长度可能比原始数据还大。尤其是数据本身已经很小,或者随机性很强的时候。zlib的文档里也说了,压缩后的输出缓冲区应该比输入大0.1%再加12字节。我一般直接给输入长度的1.1倍,省心。

什么时候不该用压缩?

压缩不是万能的。以下几种情况,我建议你别加压缩:

  • 数据本身已经压缩过,比如JPEG图片、MP3音频。再压也压不下去,反而浪费时间。
  • 数据量极小,比如几十个字节。压缩后的开销可能比数据本身还大。
  • 实时性要求极高,比如控制指令。压缩和解压缩的延迟可能影响系统响应。

嗯,说白了,压缩是个工具,用不用、怎么用,得看场景。

知识体系图

下面这张图总结了序列化与压缩的核心逻辑:

序列化与压缩核心流程 结构体数据 序列化 (memcpy) zlib 压缩 存储到 Flash 网络传输 日志记录 解压缩 (uncompress)

从图里可以看得很清楚:序列化是第一步,压缩是第二步。压缩后的数据可以存、可以传、可以记日志。用的时候,先解压缩,再反序列化,还原成结构体。

总结

zlib 压缩序列化数据,说白了就是用CPU时间换存储空间和带宽。嵌入式设备上,Flash和带宽往往比CPU更贵,所以这个交换通常是划算的。

我个人建议:默认加上压缩,级别选6。如果发现性能瓶颈,再调低级别或者去掉压缩。别一开始就追求极致压缩比,那点空间省下来,可能不够你调试一次崩溃的时间成本。

嗯,今天就聊到这里。记住一句话:压缩是手段,不是目的。你的目的是让数据更小、更快、更可靠。


公众号:蓝海资料掘金营,微信deep3321