二进制字节流序列化

工业级二进制序列化实战教程:EEPROM读写打包函数

这是在工业嵌入式开发中每天都会写的代码 ,也是最容易踩坑的地方。我会从为什么要用二进制开始,逐行拆解这个函数,讲解一套通用的二进制打包模板,保证你看完就能自己写任何工业协议的序列化代码。


一、先搞懂:为什么不用JSON,要用二进制?

工业设备和嵌入式系统有三个致命限制:

  1. CPU性能弱:很多单片机主频只有几十MHz,解析JSON会占用大量CPU资源
  2. 带宽有限:串口波特率通常只有9600/115200,JSON的冗余度太高
  3. 实时性要求高:控制指令必须在毫秒级到达,不能有任何延迟

二进制协议的优势正好解决这些问题:

  • 体积最小:1个int就是4字节,没有任何冗余
  • 速度最快:直接内存拷贝,不需要解析字符串
  • 最稳定:没有格式错误,不会出现JSON的引号、逗号问题

这就是为什么所有工业协议(Modbus、CAN、Profinet)全都是二进制协议的原因。


二、先吃透协议格式(所有代码都是围绕它写的)

所有二进制序列化的第一步,都是先写死协议文档,再写代码。

复制代码
协议格式(字节级精确):
┌─────────┬──────────┬────────┬──────────┬────────────┬──────────┐
│ 帧头(4) │ 命令字(1)│ 板ID(1)│ 通道号(1)│ 数据区(N)  │ CRC16(2) │
└─────────┴──────────┴────────┴──────────┴────────────┴──────────┘

数据区分三种情况:
1. 读/擦除:数据个数(4) + [(起始地址(4) + 长度(4))...]
2. 写:    数据个数(4) + [(起始地址(4) + 长度(4) + 数据(N))...]

重点:每个字段的字节数必须精确到个位,多一个少一个都不行!


三、逐行拆解函数(从输入到输出)

1. 函数签名:输入和输出

cpp 复制代码
std::string BuildEepromPack(
    int iBoardId,              // 要操作的板卡ID
    int iChn,                  // 操作类型:读/写/擦除
    const std::vector<uint32_t>& addresses, // 要操作的地址列表
    const std::vector<uint32_t>& lengths,   // 每个地址对应的长度
    const std::vector<std::vector<uint8_t>>& hexData, // 写操作的数据
    int count                  // 要操作的条目数
)

设计思路:

  • 把所有可变参数都传进来,函数内部只负责打包,不做业务逻辑
  • 返回值用std::string而不是char*,自动管理内存,避免内存泄漏
  • 所有输入参数都用const &,避免拷贝,提高性能

2. 第一步:计算数据区总长度(最容易出错的地方)

cpp 复制代码
int data_len = 4; // 第一个固定字段:数据个数(4字节)
std::vector<int> data_bytes(count, 0);

for (int i = 0; i < count; i++) {
    // 如果是写操作,就取对应数据的长度;读/擦除操作数据长度为0
    data_bytes[i] = (iChn == EEPROM_WRITE && i < (int)hexData.size()) 
                    ? (int)hexData[i].size() : 0;
    // 每个条目固定占:起始地址(4) + 长度(4) + 数据(N)
    data_len += 4 + 4 + data_bytes[i];
}

核心逻辑:

  • 数据区第一个字段永远是count(4字节),告诉接收方后面有多少个条目
  • 每个条目固定有8字节的头部(地址+长度),写操作再追加对应的数据
  • 提前计算好总长度,后面一次性分配缓冲区,避免动态扩容

3. 第二步:计算整个数据包的总长度

cpp 复制代码
int pack_len = 7 + data_len + 2;
// 7 = 帧头(4) + 命令字(1) + 板ID(1) + 通道号(1)
// data_len = 数据区总长度
// 2 = CRC16校验码长度

总长度必须精确,多分配内存没关系,但少分配会导致缓冲区溢出崩溃

4. 第三步:初始化缓冲区

cpp 复制代码
std::vector<uint8_t> uaSendBuff(pack_len, 0);

为什么用vector而不是char数组?

  • 自动管理内存,不需要手动free,不会内存泄漏
  • 可以动态调整大小,编译时不需要知道长度
  • 自动初始化所有字节为0,避免野值

5. 第四步:写入固定头部(按字节顺序写入)

cpp 复制代码
uaSendBuff[0] = 0xFE; uaSendBuff[1] = 0xFE;
uaSendBuff[2] = 0xFE; uaSendBuff[3] = 0x68; // 帧头:4字节固定值
uaSendBuff[4] = 0x01;                        // 命令字:固定0x01
uaSendBuff[5] = (uint8_t)iBoardId;           // 板ID:强制转成1字节
uaSendBuff[6] = (uint8_t)iChn;               // 通道号:强制转成1字节

工业代码标准:

  • 固定帧头必须用特殊字节,方便接收方快速识别数据包开头
  • 所有int类型都要强制转成对应字节数,避免编译器自动扩展
  • 按索引逐个字节写入,绝对不能用结构体直接拷贝(会有字节对齐问题)

6. 第五步:写入count字段(小端序)

cpp 复制代码
WriteLowByteInt(&uaSendBuff[7], count, 4);

关键知识点:什么是小端序?

  • 小端序:低位字节在前,高位字节在后
  • 比如count=0x12345678,写入后内存顺序是0x78 0x56 0x34 0x12
  • 这个协议用小端序,和x86 CPU的字节序一致,不需要转换
  • 如果是Modbus协议,就需要用大端序(高位在前)

7. 第六步:循环写入每个条目

cpp 复制代码
int offset = 11; // 从第11个字节开始(前面已经写了7+4=11字节)
for (int i = 0; i < count; i++) {
    // 写入起始地址(4字节小端序)
    WriteLowByteInt(&uaSendBuff[offset], addresses[i], 4);
    // 写入长度(4字节小端序)
    WriteLowByteInt(&uaSendBuff[offset + 4], lengths[i], 4);
    offset += 8; // 地址+长度占8字节
    
    // 如果是写操作,追加数据
    if (iChn == EEPROM_WRITE && i < (int)hexData.size() && data_bytes[i] > 0) {
        memcpy(&uaSendBuff[offset], hexData[i].data(), hexData[i].size());
        offset += data_bytes[i];
    }
}

核心技巧:用offset指针跟踪当前写入位置

  • 不要硬编码索引,用offset变量自动累加,避免算错位置
  • 写操作的数据直接用memcpy拷贝,速度最快
  • 严格做边界检查,避免越界写入

8. 第七步:计算并写入CRC16校验码

cpp 复制代码
// CRC校验范围:从命令字开始到数据区结束(不包含帧头和CRC本身)
int crc_len = 1 + 1 + 1 + data_len;
// 计算CRC16,写入数据包最后2字节
WriteLowByteInt(&uaSendBuff[pack_len - 2], GetCrc16(&uaSendBuff[4], crc_len), 2);

工业协议标准:

  • CRC校验范围绝对不能包含帧头,因为帧头是用来同步的,可能会被修改
  • CRC校验范围也不能包含CRC本身,否则会循环计算
  • 校验码写在数据包最后,接收方收到后先算CRC,不对直接丢弃

9. 第八步:返回结果

cpp 复制代码
return std::string((char*)uaSendBuff.data(), pack_len);
  • 把vector转成string返回,自动管理内存
  • 必须指定长度,不能直接用std::string(uaSendBuff.data()),否则会遇到0字节截断

四、三个实际调用例子(看完就会用)

1. 读EEPROM:读取地址0x00000000的16字节数据

cpp 复制代码
std::vector<uint32_t> addresses = {0x00000000};
std::vector<uint32_t> lengths = {16};
std::vector<std::vector<uint8_t>> emptyData; // 读操作不需要数据

std::string pack = BuildEepromPack(
    1,          // 板ID=1
    EEPROM_READ, // 读操作
    addresses,
    lengths,
    emptyData,
    1           // 1个条目
);

2. 写EEPROM:向地址0x00000010写入4字节数据0x11 0x22 0x33 0x44

cpp 复制代码
std::vector<uint32_t> addresses = {0x00000010};
std::vector<uint32_t> lengths = {4};
std::vector<std::vector<uint8_t>> hexData = {{0x11, 0x22, 0x33, 0x44}};

std::string pack = BuildEepromPack(
    1,           // 板ID=1
    EEPROM_WRITE, // 写操作
    addresses,
    lengths,
    hexData,
    1            // 1个条目
);

3. 批量擦除:擦除地址0x00000000和0x00000100的256字节

cpp 复制代码
std::vector<uint32_t> addresses = {0x00000000, 0x00000100};
std::vector<uint32_t> lengths = {256, 256};
std::vector<std::vector<uint8_t>> emptyData; // 擦除操作不需要数据

std::string pack = BuildEepromPack(
    1,            // 板ID=1
    EEPROM_ERASE, // 擦除操作
    addresses,
    lengths,
    emptyData,
    2             // 2个条目
);

五、新人必踩的5个坑(一定要记住)

❌ 坑1:用结构体直接拷贝

cpp 复制代码
// 绝对不要这么写!
struct EepromPack {
    uint8_t head[4];
    uint8_t cmd;
    uint8_t boardId;
    uint8_t chn;
    uint32_t count;
} __attribute__((packed)); // 即使加了packed也不推荐
  • 不同编译器的字节对齐规则不一样,跨平台会出问题
  • 结构体无法处理可变长度的数据区

❌ 坑2:字节序搞反

  • 这个协议用小端序,Modbus用大端序,必须严格按照协议文档来
  • 写反了字节序,数据会完全错误,而且很难排查

❌ 坑3:CRC校验范围错误

  • 必须和接收方的CRC校验范围完全一致,否则永远校验失败
  • 记住:CRC校验范围是从命令字开始到数据结束

❌ 坑4:缓冲区越界

  • 必须提前精确计算总长度,不要边写边扩容
  • 所有循环都要做边界检查,特别是hexData[i]的访问

❌ 坑5:忘记初始化缓冲区

  • 必须把缓冲区初始化为0,否则未初始化的字节会变成野值
  • vector的构造函数vector<uint8_t>(pack_len, 0)会自动初始化

六、通用二进制序列化模板(以后直接套用)

任何工业二进制协议,都可以套用这个模板:

cpp 复制代码
std::string BuildPack(/* 输入参数 */) {
    // 1. 计算数据区总长度
    int data_len = 0;
    // ... 计算每个字段的长度 ...

    // 2. 计算整个数据包的总长度
    int pack_len = 固定头部长度 + data_len + 校验码长度;

    // 3. 初始化缓冲区
    std::vector<uint8_t> buf(pack_len, 0);
    int offset = 0;

    // 4. 写入固定头部
    buf[offset++] = 0xAA;
    buf[offset++] = 0x55;
    // ... 其他固定字段 ...

    // 5. 写入可变数据区
    for (int i = 0; i < count; i++) {
        // 写入每个字段,offset自动累加
        WriteLowByteInt(&buf[offset], value, 4);
        offset += 4;
        // ... 其他字段 ...
    }

    // 6. 计算并写入校验码
    uint16_t crc = GetCrc16(&buf[校验起始位置], 校验长度);
    WriteLowByteInt(&buf[pack_len - 2], crc, 2);

    // 7. 返回结果
    return std::string((char*)buf.data(), pack_len);
}

七、调试技巧(打包错了怎么排查)

  1. 打印十六进制日志:把打包好的数据转成十六进制打印出来,和协议文档逐字节对比
  2. 用串口调试助手验证:把数据发给设备,看设备有没有回复
  3. 分步调试:在每个写入步骤后打印offset和当前缓冲区内容,看哪一步写错了
  4. 单独测试CRC:写一个测试函数,用已知的数据计算CRC,看和设备返回的是否一致
cpp 复制代码
// 构造Eeprom请求包(通用)
    // 协议格式: 帧头(4) + 命令字0x01(1) + 板ID(1) + 通道号(1) + 数据位 + CRC16(2)
    // 读/擦除数据位: 数据个数(4) + [(起始地址(4) + 长度(4))...]
    // 写数据位:     数据个数(4) + [(起始地址(4) + 长度(4) + 数据(N))...]
    std::string BuildEepromPack(int iBoardId, int iChn, 
                                 const std::vector<uint32_t>& addresses,
                                 const std::vector<uint32_t>& lengths,
                                 const std::vector<std::vector<uint8_t>>& hexData,
                                 int count) {
        // 计算数据区总长度
        int data_len = 4; // 数据个数(4)
        std::vector<int> data_bytes(count, 0);
        for (int i = 0; i < count; i++) {
            data_bytes[i] = (iChn == EEPROM_WRITE && i < (int)hexData.size()) 
                            ? (int)hexData[i].size() : 0;
            data_len += 4 + 4 + data_bytes[i]; // 起始地址(4) + 长度(4) + 数据(N)
        }
        
        int pack_len = 7 + data_len + 2;
        std::vector<uint8_t> uaSendBuff(pack_len, 0);
        
        uaSendBuff[0] = 0xFE; uaSendBuff[1] = 0xFE;
        uaSendBuff[2] = 0xFE; uaSendBuff[3] = 0x68;
        uaSendBuff[4] = 0x01;
        uaSendBuff[5] = (uint8_t)iBoardId;
        uaSendBuff[6] = (uint8_t)iChn;
        
        WriteLowByteInt(&uaSendBuff[7], count, 4);
        
        int offset = 11;
        for (int i = 0; i < count; i++) {
            WriteLowByteInt(&uaSendBuff[offset], addresses[i], 4);
            WriteLowByteInt(&uaSendBuff[offset + 4], lengths[i], 4);
            offset += 8;
            if (iChn == EEPROM_WRITE && i < (int)hexData.size() && data_bytes[i] > 0) {
                memcpy(&uaSendBuff[offset], hexData[i].data(), hexData[i].size());
                offset += data_bytes[i];
            }
        }
        
        int crc_len = 1 + 1 + 1 + data_len;
        WriteLowByteInt(&uaSendBuff[pack_len - 2], GetCrc16(&uaSendBuff[4], crc_len), 2);
        
        return std::string((char*)uaSendBuff.data(), pack_len);
    }
相关推荐
Lazionr1 小时前
类和对象(中):对象生命周期与运算符重载
c++
凡人叶枫1 小时前
Effective C++ 条款13:以对象管理资源(RAII)
java·linux·开发语言·c++·嵌入式开发
星恒随风1 小时前
C++ 类和对象入门(六):友元、内部类、匿名对象和编译器优化
开发语言·c++·笔记·学习·状态模式
Irissgwe1 小时前
C++ STL 详解:stack 和 queue 的介绍使用与模拟实现
c++·stl·queue·stack
油炸自行车1 小时前
【bug】Qt 6 Q_NAMESPACE 跨 DLL 链接错误:LNK2019 无法解析 staticMetaObject
数据库·c++·qt·bug·link2019·q_namespace_exp·namespaceexport
插件开发1 小时前
英伟达cuda程序通用性关键 geforce 20xx代到最新版 在20xx上编译的c++程序可以通用吗?
java·c++·人工智能
BestOrNothing_20151 小时前
ROS2 C++ 小车控制完整实战(三):自定义 srv 服务通信保姆级教程
c++·service通信·ros2·client·server·srv
KuaCpp1 小时前
C++进阶(上)
linux·c++
草莓熊Lotso2 小时前
【Linux网络】深入理解 TCP 协议(一):报头设计与可靠性基石
linux·运维·服务器·c语言·网络·c++·tcp/ip