我刚编码出来的那个 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 出来。点任意字节,看它属于哪个字段:
点任意字节,看它属于哪个字段。
尚未选择字节——点上面的格子。
头固定 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 对面;它能通的唯一原因,是帧逐字节对上了。这比我写得出来的任何单元测试都更严格——也正是我一直把主机侧的编码器留在身边、编译好、可运行、当作设备对照基准的原因。