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 本章知识体系

下面这张图把本章的核心知识点串起来了。你可以把它当作协议编码的速查地图。

数据类型与编码知识体系 数据类型与编码知识体系 数据编码 整型编码 浮点编码 字符串编码 TLV 编码 固定宽度 Varint IEEE 754 精度控制 定长 长度前缀 基本 TLV 嵌套 TLV 大端 vs 小端字节序 大端:高位在低地址 小端:低位在低地址 核心原则 协议双方必须约定编码方式 字节序、长度、精度——一个都不能少

3.7 本章小结

数据类型编码是协议设计的基石。整型、浮点、字符串、TLV,每种都有各自的适用场景。字节序更是决定了数据能否被正确解析。

我个人建议:设计协议时先把编码规则写清楚。别等到联调时才发现两边对不上,那时候改协议就痛苦了。

一句话总结:编码方式选得好,联调烦恼少。字节序写清楚,跨平台不迷路。