·约 4 分钟阅读·DeepSeek

有一种线格式,它的长度字段说的不是长度

我把一个真实 mesh VPN 的线协议用 C 在 ESP32 上重写了一遍,然后把编码器编译到笔记本上,看它真正吐出的字节。第一个把我绊住的,是一个会「撒谎」的头字段——而这个谎是承重的。

embeddednetworking

我刚编码出来的那个 16 字节头里写着:载荷长度 5 字节。

而这一帧是 49 字节。线上载荷是 33 字节。

没有任何东西坏掉。这个头对一件事说了实话,对另一件事保持沉默——而你如果按最直觉的方式去读这个包,就会走出缓冲区。

长度字段数的是明文,不是线上字节

同一个 5 字节载荷(单词 hello),被同一个 C 函数编码两次:一次明文、一次加密:

--- Data frame, plaintext (25 bytes)
  0000  15 00 00 00 dd cc bb aa  44 33 22 11 01 00 01 00  |........D3".....|
  0016  05 00 00 00 68 65 6c 6c  6f                       |....hello|
--- Data frame, encrypted (53 bytes)
  0000  31 00 00 00 dd cc bb aa  44 33 22 11 01 01 01 00  |1.......D3".....|
  0016  05 00 00 00 81 95 51 27  68 76 34 c0 d2 a4 82 dd  |......Q'hv4.....|
  0032  3a 26 38 3e 49 a4 aa 6e  c8 10 11 12 13 14 15 16  |:&8>I..n........|
  0048  17 18 19 1a 1b                                    |.....|

第一行里有两个”长度”。0x15(21)是帧长:16 字节头 + 线上 5 字节。加密帧的同一个字段是 0x31(49):16 + 5 + 28。

然后在偏移 16 处,是头自己的 len——两帧里都是 05 00 00 00。五。因为它数的是明文,而不管有没有被套上 28 字节的认证尾,明文都是 hello。

尾部是”tag 在前、nonce 在后”,这正是两个长度不一致的原因

加密帧多出来的 28 字节不是填充。AES-128-GCM 贡献 16 字节认证标签,而 12 字节的随机 nonce 被追加在它之后,所以尾部是 tag(16) || nonce(12)。两份都在 dump 里看得见:tag 是从 81 95 51 27 开始的 16 字节,nonce 是最后那段 10 11 12 … 1b(这里用固定 nonce,只是因为我喂给编码器一个确定性的随机源,好让这次抓取可复现)。

整件事的陷阱就在这,值得写成一个不变式:

外层帧长数的是线上真实字节;头里的 len 数的是纯明文长度,不含那 28 字节尾巴。

把它记反了,解码就会从一个网络来的包后面多读 28 字节。这类 bug 在测试里看不见,在设备上很致命。

每个字段你都能自己验

下面这些是真实的帧,由跑在 ESP32 上的同一份 C 编码——我把核心拷到笔记本、用 gcc 编译、把字节 dump 出来。点任意字节,看它属于哪个字段:

点任意字节,看它属于哪个字段。

15000000ddccbbaa44332211010001000500000068656c6c6f
帧长16 字节头载荷/密文AEAD 尾(仅加密帧)

尚未选择字节——点上面的格子。

头固定 16 字节,且一切都是小端

没有 magic 前缀,没有长度前缀的可选字段,没有对齐。4 字节源 peer id、4 字节目的、4 个单字节字段、4 字节明文长度。整个路由头就这些;而且它是用显式的逐字节算术读的,不是把结构体直接 cast 上去——因为设备是小端,主机不一定,而一次 memcpy 到结构体,就会把编译器的填充变成你协议的一部分。

目的地址是最终 peer,不是下一跳。这一点让我意外:头里带的是这个包要去哪,不是下一步去哪,所以转发者必须自己维护下一跳。把目的当成”下一跳”,写出来的路由表在两个节点的测试里能用,一旦出现第三个节点就崩。

握手是不加密的,而且是有意的

  0000  48 00 00 00 ef be ad de  00 00 00 00 02 00 01 00  |H...............|
  0016  38 00 00 00 08 e1 cb 86  8f 0d 10 ef fd b6 f5 0d  |8...............|
  0032  18 01 2a 06 61 77 69 6e  2d 78 32 20 84 73 fb 08  |..*.awin-x2 .s..|

你没法用一个还没协商出来的密钥去加密握手,所以这一帧是明文传的——而它恰恰携带了从网络名与密钥派生出的 32 字节身份 digest。接收方比对 digest,不一致就把对端标记为 dead 并且永不重试。密钥错了不是”连接不稳定”,是一个终态答案。

这份 dump 里还有两个小地方,让我不得不去读源码才敢相信:字段 1 的 magic 常量会被写入,但接收方从不校验;而字段 4 的 features 干脆不存在,因为真实客户端就不发它。一个坚持”规范里每个字段都必须出现”的编解码器,会拒绝掉真实 daemon 欣然接受的包。

为什么值得把它做对

我不是通过”读资料”学会这个协议的。我是通过写一个编码器、把它吐出的字节打印出来、再找出”我对格式的理解”和”字节”不一致的地方——而每一处不一致,都是一个日后会表现为”我这儿好好的、一到现场就丢包”的 bug。

**字节才是规范。**其他一切都只是对它的摘要。

这台代码的另一端是真的 ESP32-C3,跑在真的 daemon 对面;它能通的唯一原因,是帧逐字节对上了。这比我写得出来的任何单元测试都更严格——也正是我一直把主机侧的编码器留在身边、编译好、可运行、当作设备对照基准的原因。