3. 数据类型与编码:整型、浮点型、字符串、变长数据(TLV)的编码方式。大端与小端字节序。
协议说白了就是双方约定好的数据格式。你发一串字节流过去,对方得知道哪个字节代表什么。这中间最基础的问题就是:数据怎么编码。
我刚开始做嵌入式协议时,觉得编码这事太简单了,不就是把 int 转成字节嘛。结果第一次联调就被打脸——两边对同一个数字的理解差了十万八千里。嗯,今天我们就来把这块彻底捋清楚。
3.1 整型编码:最基础也最容易踩坑
整型编码是所有协议的基础。你想想看,不管是长度、序号、还是标志位,最终都要落到整型上。
3.1.1 固定宽度整型
最常用的就是 uint8、uint16、uint32、uint64。每个类型占固定字节数,编码方式也最直接。
// 示例:uint32 编码与解码
// 编码:将 0x12345678 转为字节流
uint32_t value = 0x12345678;
uint8_t buffer[4];
// 大端编码(网络字节序)
buffer[0] = (value >> 24) & 0xFF; // 0x12
buffer[1] = (value >> 16) & 0xFF; // 0x34
buffer[2] = (value >> 8) & 0xFF; // 0x56
buffer[3] = value & 0xFF; // 0x78
// 小端编码
buffer[0] = value & 0xFF; // 0x78
buffer[1] = (value >> 8) & 0xFF; // 0x56
buffer[2] = (value >> 16) & 0xFF; // 0x34
buffer[3] = (value >> 24) & 0xFF; // 0x12
核心原则:协议双方必须约定好字节序。没有商量的余地。
3.1.2 变长整型(Varint)
固定宽度整型有个问题:如果大部分数值都很小,比如 0~127,用 4 个字节存就太浪费了。Varint 就是用来解决这个问题的。
我在做物联网协议时,设备上报的温度值大部分在 0~100 之间。用 uint32 每个值要 4 字节,一天下来数据量就上去了。换成 Varint,大部分情况只用 1 字节,省了 75% 的带宽。
// Varint 编码示例(每个字节的最高位是延续标志位)
// 0x7F → 1 字节:0111 1111
// 0x80 → 2 字节:1000 0001 0000 0000
// 0x3FFF → 2 字节:1000 0000 0111 1111
uint8_t* encode_varint(uint32_t value, uint8_t* buf) {
while (value > 0x7F) {
*buf++ = (value & 0x7F) | 0x80;
value >>= 7;
}
*buf++ = value & 0x7F;
return buf;
}
经验之谈:Varint 适合数值分布偏小的场景。如果你的数据均匀分布在 0~4G 之间,用 Varint 反而可能比固定宽度更浪费。
3.2 浮点型编码:IEEE 754 是唯一标准
浮点型编码没什么好选的,基本就是 IEEE 754 标准。float 占 4 字节,double 占 8 字节。
我曾经遇到过一个坑:两个设备都用 float,但一个用软件模拟浮点运算,精度和硬件浮点单元不一样。联调时发现小数点后第 6 位对不上,排查了两天才找到原因。
// IEEE 754 单精度浮点编码结构
// 符号位(1bit) | 指数位(8bit) | 尾数位(23bit)
// 例如:3.14f
// 二进制:0 10000000 10010001111010111000011
// 符号:0(正数)
// 指数:10000000(实际指数 = 128 - 127 = 1)
// 尾数:1.10010001111010111000011(隐含的1.xxx)
// 编码时直接用 memcpy 最安全
float f = 3.14f;
uint8_t buf[4];
memcpy(buf, &f, 4); // 注意:这里依赖本机字节序
注意:浮点数的字节序和整型一致。如果协议用大端,浮点也要大端。不要想当然地认为 float 就是小端。
3.3 字符串编码:长度是灵魂
字符串编码的核心问题就一个:对方怎么知道字符串到哪结束?
3.3.1 定长字符串
最简单的方式:固定长度,不够的补 0。适合设备 ID、MAC 地址这种固定长度的场景。
// 定长 16 字节的字符串
char device_id[16] = "SENSOR_001";
// 编码后:SENSOR_001\0\0\0\0\0\0\0
// 解码时直接取 16 字节,去掉尾部 \0
3.3.2 长度前缀字符串
更灵活的方式:先编码长度,再编码内容。我个人习惯用这种方式,几乎适用于所有场景。
// 长度前缀字符串编码
// 结构:长度(2字节) + 内容(N字节)
// 例如:"hello" → 0x00 0x05 'h' 'e' 'l' 'l' 'o'
uint8_t* encode_string(const char* str, uint8_t* buf) {
uint16_t len = strlen(str);
// 写入长度(大端)
*buf++ = (len >> 8) & 0xFF;
*buf++ = len & 0xFF;
// 写入内容
memcpy(buf, str, len);
return buf + len;
}
3.3.3 空字符结尾(C 风格)
像 C 语言那样用 \0 结尾。简单,但有个致命问题:字符串内容里不能有 \0。
我的建议:协议里尽量用长度前缀。空字符结尾在二进制协议里容易出问题,特别是传输加密数据时。
3.4 变长数据与 TLV 编码
TLV(Type-Length-Value)是协议设计里最经典的变长数据编码方式。你想想看,一个协议要支持多种数据类型,每种数据长度还不一样,TLV 就是最好的解决方案。
3.4.1 TLV 基本结构
| 字段 | 含义 | 典型长度 |
|---|---|---|
| Type | 数据类型标识 | 1~2 字节 |
| Length | Value 的长度 | 1~4 字节 |
| Value | 实际数据 | Length 指定 |
// TLV 编码示例
// Type: 0x01 (温度传感器数据)
// Length: 0x04 (4 字节)
// Value: 0x41 0xA0 0x00 0x00 (25.0f 的 IEEE 754 编码)
uint8_t tlv_buffer[] = {
0x01, // Type
0x00, 0x04, // Length (大端)
0x41, 0xA0, // Value 开始
0x00, 0x00
};
3.4.2 TLV 嵌套
TLV 可以嵌套,这是它最强大的地方。一个 TLV 的 Value 里可以包含多个子 TLV。
// 嵌套 TLV 示例:一个数据包包含多个传感器数据
// 外层 TLV: Type=0x10 (传感器数据包), Length=...
// ├─ 子 TLV: Type=0x01 (温度), Length=4, Value=25.0f
// ├─ 子 TLV: Type=0x02 (湿度), Length=4, Value=60.0f
// └─ 子 TLV: Type=0x03 (气压), Length=4, Value=1013.25f
// 编码后的字节流
0x10, 0x00, 0x12, // 外层 TLV 头
0x01, 0x00, 0x04, // 温度 TLV
0x41, 0xC8, 0x00, 0x00,
0x02, 0x00, 0x04, // 湿度 TLV
0x42, 0x70, 0x00, 0x00,
0x03, 0x00, 0x04, // 气压 TLV
0x44, 0x7A, 0x50, 0x00
实战技巧:解析嵌套 TLV 时,建议用递归或状态机。我见过有人用一堆 if-else 嵌套,代码又臭又长,还容易漏掉边界情况。
3.5 大端与小端字节序
字节序是协议设计里最容易出问题的地方。说白了就是:一个多字节数据,高位字节放前面还是后面?
3.5.1 两种字节序对比
| 字节序 | 别名 | 存储方式 | 常见平台 |
|---|---|---|---|
| 大端 (Big-Endian) | 网络字节序 | 高位字节在低地址 | TCP/IP 协议、PowerPC、网络设备 |
| 小端 (Little-Endian) | 主机字节序 | 低位字节在低地址 | x86、ARM(可配置)、大部分 PC |
// 判断本机字节序
int is_little_endian() {
uint16_t test = 0x0001;
uint8_t* p = (uint8_t*)&test;
return p[0] == 0x01; // 小端:低地址存低位
}
// 字节序转换(大端 ↔ 本机)
uint16_t ntohs(uint16_t netshort) {
// 网络字节序转主机字节序
// 如果本机是小端,交换字节
// 如果本机是大端,直接返回
return (netshort >> 8) | (netshort << 8);
}
3.5.2 我的字节序选择原则
做了这么多年协议,我总结了几条原则:
- 网络协议一律用大端。TCP/IP 协议栈默认就是大端,别搞特殊。
- 设备内部通信可以用小端。如果两端都是 x86,小端省去转换开销。
- 跨平台协议必须显式指定字节序。别依赖"默认",默认就是坑。
我曾经踩过的坑:一个设备用 ARM 小端,另一个用 PowerPC 大端。两边都以为对方和自己一样,结果解析出来的数据全是乱的。从那以后,我每个协议文档第一页就写清楚字节序。
3.6 本章知识体系
下面这张图把本章的核心知识点串起来了。你可以把它当作协议编码的速查地图。
3.7 本章小结
数据类型编码是协议设计的基石。整型、浮点、字符串、TLV,每种都有各自的适用场景。字节序更是决定了数据能否被正确解析。
我个人建议:设计协议时先把编码规则写清楚。别等到联调时才发现两边对不上,那时候改协议就痛苦了。
一句话总结:编码方式选得好,联调烦恼少。字节序写清楚,跨平台不迷路。