在 Linux 上做网络联调,最让人头疼的往往不是连接断了,而是数据明明发过去了,对面却解出一堆乱码 ------同一份协议,两端 sizeof 对不上、字节序差一个方向、字段整体偏移了 3 个字节。
常见做法是照着协议文档把结构体一写、memcpy 出去就发,本地自测一切正常;可一旦跨机器、跨语言或者协议要加字段,隐式填充、大小端差异和 TCP 粘包会同时暴露,靠 printf 逐字节猜根因极其低效。
本文分享一套可直接落地的编解码实践:布局校验 + TLV 可扩展编码 + 反序列化安全清单,覆盖 C、Python 与 netlink 三种场景,可直接落地。
一、字节序与对齐:序列化踩坑的第一现场
线缆上只有字节没有结构体,进协议前必须先解决布局问题:
- 大小端不一致 :x86/ARM 默认小端,网络序规定大端,整型不转换就会读出
0x78563412这种倒序魔数。 - 填充不可见 :
uint8_t后面的uint32_t会被编译器补 3 字节,sizeof常比字段之和还大。 - packed 不通用 :
__attribute__((packed))能去填充,但跨编译器、跨语言复刻时极易漏掉。
先看几个高频坑的对照关系:
| 现象 | 根因 | 一行验证 |
|---|---|---|
| 数值变成 4 亿多 | 没做 htonl |
printf("%x", htonl(v)) |
| 首字段就读错 | 结构体存在填充 | sizeof + offsetof |
| 仅在某台机器挂 | 对端大小端不同 | hexdump -C pkt.bin |
这段 C 代码在 x86-64 下打印结构体布局与字节序转换结果,用来确认线上格式:
c
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
#include <stddef.h>
struct hdr {
uint8_t ver;
uint32_t len; /* 未 packed 时前面会被补 3 字节 */
uint16_t port;
};
int main(void) {
printf("sizeof=%zu len@%zu port@%zu\n",
sizeof(struct hdr),
offsetof(struct hdr, len),
offsetof(struct hdr, port));
uint32_t x = 0x12345678;
printf("host=0x%x onwire=0x%x\n", x, htonl(x));
return 0;
}
典型输出是 sizeof=12 len@4 port@8,而字段之和只有 7------差的 5 字节就是填充。
核心结论: 先确认内存布局,再确认数值方向,sizeof 打印远比肉眼数字段可靠。

二、定长结构体:最快却最脆的编码方式
把协议直接映射成结构体最省事,但它有两个成立前提:
- 布局即协议:字段顺序、类型宽度、填充共同构成线上格式,改一行等于换协议。
- 版本号放首位 :首字节留
ver,新增字段只往尾部追加,老解析器按len截断即可。 - 跨语言靠格式串 :Python 端用
struct手写格式串,与 C 的sizeof对不上就整体错位。
下面这段 Python 用 struct 复刻同一条消息,并打印实际占用字节数:
python
import struct
FMT = "!B I H" # ! = 网络字节序,且不做隐式填充
pkt = struct.pack(FMT, 1, 6, 53)
print(pkt.hex(), struct.calcsize(FMT)) # 01000000060035 7
ver, length, port = struct.unpack(FMT, pkt)
print(ver, length, port)
C 侧 12 字节、Python 侧 7 字节,差值全部来自填充------格式串里没有 x 补位就必然错位。
核心结论: 两端必须打印并核对 sizeof 与 calcsize,相等才算协商出了同一种线上格式。
三、TLV 变长编码:给字段留出扩展空间
字段只会越来越多,定长结构迟早撑不住,改用变长编码:
- type + length + value:长度前缀让解析器能整段跳过不认识的类型,这是向前兼容的最小实现。
- 长度固定宽度 :统一用
uint16大端,别混用 varint,避免两端实现细节不一致。 - 嵌套即递归:value 里再放一组 TLV,就是 protobuf tag 之前的手写形态。
这个最小实现包含编码、解析和未知字段跳过三件事:
python
import struct
def tlv(t: int, v: bytes) -> bytes:
return struct.pack("!BH", t, len(v)) + v
def parse(buf: bytes):
i, out = 0, {}
while i < len(buf):
t, n = struct.unpack_from("!BH", buf, i)
assert i + 3 + n <= len(buf), "length 越界"
out[t] = buf[i + 3:i + 3 + n]
i += 3 + n
return out
msg = tlv(1, b"alice") + tlv(3, b"\x00\x35") + tlv(9, b"ext")
print(parse(msg))
未知 type 直接跳过、长度先断言再切片,兼容性和安全性一次拿到。
核心结论: TLV 把「加字段」从破坏性变更降级为普通变更,是自定义二进制协议的性价比之选。
四、netlink:内核与用户态的序列化契约
跟内核通信同样是一套手工编解码规则,别当黑盒:
- 16 字节消息头 :
nlmsghdr含 len/type/flags/seq/pid,type 决定 payload 是哪个结构。 - 属性走 nlattr:4 字节头(len+type)加值,再补齐到 4 字节边界------本质是对齐版 TLV。
- 用现成工具验收 :
ss -e看字段语义,strace看真实字节,不必自己啃内核源码。
用 strace 抓 ip 命令收发的 netlink 报文,十六进制就是内核实际拿到的序列化结果:
bash
strace -f -e trace=sendmsg,recvmsg -s 300 \
ip route show table local 2>&1 | head -30
报文头 16 字节对上 nlmsghdr,后面的 4 字节一组属性对上 nlattr,说明编解码无误。
核心结论: netlink 的序列化契约 = 固定头 + 4 字节对齐的属性序列,画出来和 TLV 是同一张图。

五、反序列化的安全边界:越界、截断与脏数据
解析对端字节比生成字节危险得多,先把防线立住:
- 长度字段不可信 :先判断
offset + len <= bufsize再切片,溢出与越界都在这一句拦住。 - 粘包先攒后切 :
recv到的可能是半条消息,按头部长度循环补齐再解析 body。 - 失败要可观察 :解析失败打印
hexdump与当前 offset,否则日志里只有「解析错误」四个字。
常见解析故障可以按下面这张表快速归因:
| 现象 | 可能根因 | 定位手段 |
|---|---|---|
| 首字段就是乱码 | 字节序没转换 | 两端各 xxd 一次对比 |
| 越往后偏得越多 | 填充或对齐不同 | 打印 sizeof/offsetof |
| 偶尔读到上一条内容 | 粘包未按长度切分 | 抓包看 TCP 段边界 |
| 长度字段巨大 | 对端脏数据或越界 | 校验后再分配内存 |
核心结论: 解析器默认不信任对端,先校验长度、再拷贝数据,失败路径必须留下 hexdump。
六、方案选型:结构体、TLV 与 protobuf 的取舍
不同场景要选不同编码,先看这张对照表:
| 方案 | 体积 | 解析开销 | 演进能力 | 典型场景 |
|---|---|---|---|---|
| 结构体直转 | 最小 | 最低 | 差 | netlink、抓包热路径 |
| 手写 TLV | 小 | 低 | 好 | 自定义二进制协议 |
| protobuf | 中 | 中 | 强 | 多语言服务间 RPC |
| JSON | 大 | 高 | 强 | 管理面与调试期 |
选型时只认三条原则:
- 热路径能定长就定长:内核交互、每包必走的路径避免堆分配与反射。
- 多语言必须上 schema :两端从同一份
.proto生成代码,别再各写各的结构体。 - 先 hexdump 再猜逻辑 :任何看不懂的字段,先
xxd出来对着协议文档数偏移。
核心结论: 编码方案没有最优只有最匹配,判断标准是「谁解析、加字段多不多、每秒走多少包」。

结语
序列化与反序列化看着是「把结构体发出去」的小事,实际决定了协议能否跨机器、跨语言、跨版本稳定运行。把布局校验放在编码之前、把长度校验放在解析之前,绝大多数线上乱码和越界问题都能在开发期暴露,而不是等到抓包时逐字节比对。
把本文的三件套放进你自己的协议栈:编码侧打印 sizeof/calcsize 对齐、解析侧先断言长度再切片、联调期用 strace 和 xxd 直接看字节,排错时间会明显缩短。
协议的正确性不写在文档里,而写在两端都能还原的那串字节里。