11、序列化性能优化:减少内存拷贝、使用零拷贝技术、批量序列化

性能优化,说白了就是跟时间赛跑。嵌入式系统里,内存和CPU都是稀缺资源。序列化操作如果写得糙,一次拷贝就能吃掉几百微秒,这在实时系统里是致命的。

我个人习惯,写序列化代码前先问自己三个问题:数据能不能原地序列化?能不能一次搞定?能不能批量处理?这三个问题对应着今天要聊的三个核心技巧——减少内存拷贝、零拷贝技术、批量序列化。

11.1 减少内存拷贝:别让数据在内存里“旅游”

很多新手写序列化,喜欢先拼一个临时缓冲区,再拷到目标区。你想想看,数据从结构体拷到临时buf,再从buf拷到发送队列,中间还可能有字节序转换的拷贝。一次序列化,数据在内存里转了三圈。

我在项目中遇到过这样的案例:一个CAN总线数据采集模块,每秒要序列化2000帧数据。原始代码用了三层拷贝,CPU占用率直接飙到70%。优化后减少到一次拷贝,CPU降到15%。

核心思路就一条:能直接写目标缓冲区,就别经过中间人

优化前:三次拷贝

// 第一次:从结构体拷贝到临时buf
memcpy(temp_buf, &data.field1, sizeof(data.field1));
// 第二次:字节序转换(隐含拷贝)
htonl_buf(temp_buf, temp_buf + offset);
// 第三次:从临时buf拷贝到发送队列
memcpy(tx_queue + pos, temp_buf, total_len);

优化后:一次拷贝

// 直接写入目标缓冲区,边转换边写入
uint8_t *dst = tx_queue + pos;
*(uint32_t *)dst = htonl(data.field1);  // 直接写目标位置
dst += sizeof(uint32_t);
*(uint16_t *)dst = htons(data.field2);
dst += sizeof(uint16_t);

这里有个细节要注意:直接写目标缓冲区时,要确保目标地址对齐。ARM Cortex-M系列对非对齐访问会触发异常,或者性能骤降。我建议用memcpy代替直接指针赋值,编译器会优化成合适的指令。

小技巧:如果目标缓冲区是DMA专用的内存区域(比如某些MCU的DMA描述符区),直接写入还能省掉一次DMA搬运。我在STM32H7上这么干过,序列化延迟从12μs降到了3μs。

11.2 零拷贝技术:让数据“原地不动”

零拷贝,听起来高大上,其实核心就一句话:数据在哪儿,就在哪儿序列化,别搬来搬去

嵌入式里常见的零拷贝场景有两种:

  • 共享内存序列化:两个任务通过共享内存通信,序列化结果直接写在共享区,接收方直接读。
  • DMA直接发送:序列化结果放在DMA能访问的内存,配置好描述符直接发,CPU不碰数据。

我曾经在一个视频采集卡项目里用过零拷贝。原始方案是:采集→拷贝到系统内存→序列化→拷贝到DMA缓冲区→发送。优化后:采集直接到DMA缓冲区,序列化在原地完成,然后直接发。延迟从5ms降到了0.8ms。

实现零拷贝的关键是内存池设计。我习惯用固定大小的内存块,每个块带一个引用计数。序列化时直接拿一块,写完后把指针传给发送模块,发送完再归还。

// 零拷贝内存池示例
typedef struct {
    uint8_t data[MAX_PACKET_SIZE];
    uint32_t len;
    volatile int ref_count;  // 引用计数
} ZeroCopyBuf;

// 序列化时直接使用内存池中的块
ZeroCopyBuf *buf = mem_pool_alloc();
if (buf) {
    serialize_to_buffer(&msg, buf->data, &buf->len);
    // 直接交给发送模块,无需拷贝
    tx_module_send(buf);
}

注意:零拷贝虽然快,但生命周期管理要小心。我曾经踩过一个坑:发送模块还没发完,内存池就把块回收了,导致发送了脏数据。解决方案是用引用计数,发送完成回调里减计数,减到0才归还。

11.3 批量序列化:一次搞定一批数据

单个序列化开销不大,但架不住数量多。比如传感器数据采集,每秒几千个点,每个点都单独序列化,函数调用开销、锁开销、内存分配开销加起来,相当可观。

批量序列化的思路很简单:把多个数据对象攒到一起,一次序列化完成

我常用的做法是定义一个批量缓冲区,先收集数据,攒够一定数量或超时后,一次性序列化并发送。

// 批量序列化缓冲区
typedef struct {
    SensorData items[BATCH_SIZE];
    int count;
    uint64_t first_timestamp;  // 用于超时判断
} BatchBuffer;

// 批量序列化函数
int batch_serialize(BatchBuffer *batch, uint8_t *out, uint32_t *out_len) {
    uint8_t *p = out;
    // 写入批量头:数量、时间戳范围
    *p++ = batch->count;
    *(uint64_t *)p = batch->first_timestamp; p += 8;
    
    // 批量写入所有数据
    for (int i = 0; i < batch->count; i++) {
        p += serialize_sensor(&batch->items[i], p);
    }
    *out_len = p - out;
    return 0;
}

批量序列化的好处不止是减少函数调用。你想想看,一次memcpy搬1KB数据和100次memcpy每次搬10字节,前者效率高得多,因为减少了缓存缺失和函数调用开销。

我在一个工业物联网网关里用过批量序列化。原始方案每个传感器数据单独序列化发送,每秒3000个包,CPU占用40%。改成批量后,每100个数据合成一个包,每秒30个包,CPU降到8%。

批量序列化的三个关键参数:

  • 批量大小:太大增加延迟,太小效果不明显。我一般根据实时性要求来定,实时性高的用10-20个一批,非实时的可以攒到100-200个。
  • 超时时间:防止数据一直攒不够。我习惯设5-10ms的超时,到了就强制发送。
  • 内存预算:批量缓冲区要提前分配好,不能在序列化过程中动态分配。我一般在初始化时就把所有批量缓冲区分配好。

11.4 三种技术的组合使用

实际项目中,这三种技术往往是组合使用的。我画了一张图,帮你理清它们的关系:

序列化性能优化技术组合 原始数据 减少内存拷贝 零拷贝技术 批量序列化 高效序列化输出 减少拷贝:直接写目标缓冲区,避免中间层 零拷贝:数据原地序列化,不搬移 批量:攒一批数据一次序列化

从图上你能看出来,三种技术不是互斥的。减少拷贝是基础,零拷贝是进阶,批量序列化是策略层面的优化。我一般这样组合:

  • 高实时性场景:减少拷贝 + 零拷贝,数据到了就序列化,直接发。
  • 高吞吐场景:减少拷贝 + 批量序列化,攒一批再发,减少发送次数。
  • 极致性能场景:三种全上,内存池 + 批量 + 零拷贝,延迟和吞吐都兼顾。

我的经验:别一开始就上零拷贝。先做减少拷贝,通常能解决80%的性能问题。如果还不够,再上批量序列化。零拷贝是最后的手段,因为它增加了代码复杂度,而且对内存管理要求高。

11.5 避坑指南

最后分享几个我踩过的坑:

  • 缓存一致性问题:用DMA做零拷贝时,CPU和DMA可能看到不同的缓存内容。我曾在Cortex-A7上吃过这个亏,后来加了cache flush/invalidate才解决。
  • 批量缓冲区溢出:批量序列化时,如果数据量超过预期,缓冲区会溢出。我习惯在批量缓冲区里加一个水位标记,超过80%就强制发送。
  • 内存碎片:频繁分配释放零拷贝内存块,会导致碎片。我建议用固定大小的内存池,不要用malloc/free。

嗯,序列化性能优化就聊到这儿。记住三个关键词:少拷贝、原地干、攒一批。下次写序列化代码时,先想想能不能用上这些技巧。


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