现象

最近在做一个 Unity 飞行模拟项目,需要把飞行姿态数据(加速度 + 欧拉角)通过 UDP 实时发送给一台运动平台设备。发送格式很简单:

$ax,ay,az,rx,ry,rz

例如 $0.00,0.00,0.00,0.55,0.00,0.00,纯 ASCII,30 个字节。

结果平台接收端的日志刷屏报错:

丢弃无效 UDP 报文: "$0.00,0.00,0.00,0.55,0.00,0.00\x02" 丢弃无效 UDP 报文: "$0.00,0.00,0.00,0.55,0.00,0.00\xFF" 丢弃无效 UDP 报文: "$0.00,-0.04,-0.02,0.55,0.00,0.00`"

每条报文末尾都多出一个随机字节\x02\xFF\x0F、反引号……校验失败,整条丢弃。

诡异的是:同一台设备、同一套接收软件,接 X-Plane、MSFS 这些主流模拟器的数据就完全正常。

排查:发送端是清白的

第一反应是发送端拼包出了问题。但检查代码:

丢弃无效 UDP 报文: "$0.00,0.00,0.00,0.55,0.00,0.00\x02"
丢弃无效 UDP 报文: "$0.00,0.00,0.00,0.55,0.00,0.00\xFF"
丢弃无效 UDP 报文: "$0.00,-0.04,-0.02,0.55,0.00,0.00`"

UDP 是面向报文的协议,接收方拿到的字节流和发送方发出的完全一致,一个不多一个不少。发送端不可能"多发"一个字节。

日志里还有一个关键线索:报文内容完全相同时,尾巴却在变(\x02 \xFF \x0F)。如果是发送端拼进去的内容(比如校验码),内容相同则尾巴必然相同。尾巴随机变化,说明它根本不是报文的一部分,而是接收端自己内存里的残留数据。

用最小 Python 脚本验证:

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.bind(("0.0.0.0", 5005))
while True:
    data, addr = s.recvfrom(2048)
    print(len(data), data)   # 恒为 30 字节,内容干净

果然干干净净。发送端彻底排除嫌疑。

真相:strlen(buf) + 1 的 C 语言惯性

那为什么其他模拟器就正常?答案藏在 C 语言的字符串机制里。

C 字符串没有长度属性,靠 \0(0x00)作为结束标记。 strlen() 返回的长度不包含 \0,所以 C 写的遥测插件发送时有个经典习惯:

sendto(sock, buf, strlen(buf) + 1, 0, ...);
//                        ↑ +1:把结尾的 '\0' 也装进报文

于是其他模拟器发出的报文实际是 31 字节$0.00,...,0.00 + 0x00

而那套接收软件是按 C 字符串方式解析的——sscanf / %s 一路扫描,碰到 \0 才停:

  • 收其他模拟器:报文自带 \0,扫描在报文范围内正常结束 ✅

  • 收我们的报文:C# 的字符串自带长度,GetBytes 只转换可见字符,报文 30 字节、没有 \0。接收端扫描越过末尾,把缓冲区里的残留字节当成报文内容 → 校验失败 ❌

所谓"其他模拟器要 +1 读取",并不是接收端多读了一个字节,而是人家发送时本来就多发了一个字节,接收端一直在报文范围内正常工作。

为什么会出现两种风格

C/C++

C# / Python / Java

字符串长度

无长度,靠 \0 结尾(哨兵机制)

字符串对象自带长度

解析网络数据

习惯扫 \0

习惯用 recvfrom 返回的长度

发送习惯

strlen(buf) + 1,连 \0 一起发

只发可见字符

两边各自都"正确",但对不上:按 \0 扫描的接收端,遇到不带 \0 的报文就越界;按长度解析的接收端,遇到带 \0 的报文会多看到一个 \x00

修复(其中一个方案)

接收端是第三方闭源软件,改不了,只能发送端适配——补一个 \0,与其他模拟器行为对齐:

byte[] bytes = Encoding.ASCII.GetBytes(msg + '\0');
_client.Send(bytes, bytes.Length, _ep);

改完后"丢弃无效 UDP 报文"的报错消失,数据全部正常解析。

教训

  1. UDP 保证报文完整性,收到的字节数一定等于发出的字节数。看到"多出来的数据",先怀疑接收端的缓冲区/解析逻辑,而不是发送端。

  2. 随机变化的垃圾字节 ≈ 越界读到了内存残留。这是定位越界读问题的强烈信号。

  3. 文本协议也要明确"帧边界"约定:是按长度、按 \0、还是按 \n?对接第三方设备时,先抓一包"已知正常"的数据看原始字节,比读文档和猜都快。

  4. C 字符串的 \0 习惯会在跨语言对接时悄悄埋下坑——C# / Python 程序员很容易忘记它的存在,而 C 程序员很容易忘记别人没有它。