工业级二进制序列化实战教程:EEPROM读写打包函数
这是在工业嵌入式开发中每天都会写的代码 ,也是最容易踩坑的地方。我会从为什么要用二进制开始,逐行拆解这个函数,讲解一套通用的二进制打包模板,保证你看完就能自己写任何工业协议的序列化代码。
一、先搞懂:为什么不用JSON,要用二进制?
工业设备和嵌入式系统有三个致命限制:
- CPU性能弱:很多单片机主频只有几十MHz,解析JSON会占用大量CPU资源
- 带宽有限:串口波特率通常只有9600/115200,JSON的冗余度太高
- 实时性要求高:控制指令必须在毫秒级到达,不能有任何延迟
二进制协议的优势正好解决这些问题:
- 体积最小: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);
}
七、调试技巧(打包错了怎么排查)
- 打印十六进制日志:把打包好的数据转成十六进制打印出来,和协议文档逐字节对比
- 用串口调试助手验证:把数据发给设备,看设备有没有回复
- 分步调试:在每个写入步骤后打印offset和当前缓冲区内容,看哪一步写错了
- 单独测试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);
}