简介:这是一份面向飞行模拟开发者的 X-Plane 11 UDP 通信中文说明文档,适合希望用外部程序读取或驱动模拟器的开发者、座舱与运动平台搭建者,以及从 X-Plane 10 迁移到 11 的老用户。文档说明飞行模拟器如何通过以太网或 WiFi,在 49000 端口收发的 UDP 消息与数据进行交互,解决跨程序通信时消息类型、数据格式难以对照的问题。压缩包共 1 个 docx 文件,约 44KB,体量轻便,便于随查随用。目前已有 2063 人学习下载,具备一定参考基础。内容涵盖数据输出与输入两条主线:以 RPOS 等消息按设定频率回传经纬度、海拔、离地高度等紧致排列的数据,用于驱动自研视景或移动地图;也整理了 ACFN、PREL、ACPR 等新消息及 PAPT、VEH1、MENU 等已停止消息的替代方案,并附上空值终止、网络数据转储到 error.out 等排错提示,可帮助读者快速定位报文格式与调试方向。
1. 端口 49000 上的那条旁路:为什么飞行模拟器最硬的接口不是插件
X-Plane 11 对外最完整的接口,不是插件 SDK,而是一个从 V10 时代就没动过的 UDP 端口:49000。第一次接触会觉得反直觉——一个把气动、天气、航电都算到毫秒级的仿真器,居然允许你用几个字节的裸报文去挪飞机、读走位姿、甚至按下座舱里的开关。这份 X-Plane 11 中文说明文档记录的就是这套旁路协议:没有握手、没有会话、没有 schema,只有「4 个字符标签 + 一个 0 字节 + 一段紧密打包的数据」。搞懂它的价值在于,你能把模拟器当成一个可编程的传感器和执行器,而不是只能用手柄玩的软件。它也正面回答了一个反复出现的工程问题:进程内的插件跑得快,但会拖死主循环;进程外的 UDP 慢一点,却几乎不可能把模拟器搞崩。
2. 报文信封:4 个字符加一个 0 字节,凭什么撑起整套协议
所有发往 49000 的消息都是同一个形状:前 4 个字节是 ASCII 标签,第 5 个字节是值为 0 的单字节,后面跟数据体。整份文档里几十种消息,无一例外。这个信封简单到几乎不像设计,但它决定了后面所有细节——包括为什么有些字段要用字符串传、为什么结构体必须手动补填充、为什么协议升级只能靠废弃标签而不能靠追加字段。
2.1 标签即类型:一套没有 schema 的自描述协议
标签本身就是类型判别符。模拟器收到一个包,读前 4 字节决定用哪张结构表去解析后面那一段,剩下的字节数必须和数据体长度严格相等。它没有版本协商,没有字段个数前缀,也不接受未知标签。好处是抓包时人眼能直接读出RPOS、VEHX、DREF,调试成本极低;代价是协议天生不可扩展——你没法在结构体尾部加一个字段再让老客户端忽略它,因为接收方按固定长度读,多一个字节就是「大小不符」。
所以这套协议的演进方式是「废弃 + 新增」,而不是「兼容 + 追加」。文档里那张停用表就是证据:PAPT(按机场放置)让位给PREL,PMAP(机场附近)也让位给PREL;VEH1(移动一架飞机)和VEHA(移动所有飞机)被合并成VEHX;MENU、CHAR、MOUS(分别对应菜单选择、字符输入、鼠标选择)被合并成CMND;ISET(开关互联网输出)被ISE4取代;HACF(炸掉一架飞机)和R_QT(快速播放动画)直接 discontinued。合并的思路很清楚:把「语义差异」从标签下沉到数据体里的字段。移动一架还是移动所有,不再由两个标签区分,改由VEHX里那个int p参数决定;点菜单还是敲字符,也不再由标签区分,统一表达成一条命令字符串。
这套做法有个隐含前提:客户端必须和模拟器版本对齐。V10 的老代码在 V11 上不会报错,只会静默地不生效——标签还在,语义没了。
2.2 字符串表数字:为什么频率写成 "60" 而不是一个 int
RPOS是个典型例子。发过去的内容不是结构体,而是RPOS+ 0 字节 + 十进制字符串"60"+ 0 字节,一共 8 个字节。文档原文写作RPOS_60_,其中下划线代表空字符。
用文本传一个整数,动机是绕开字节序。飞行模拟器可能跑在 x86、ARM,客户端可能是 C、C#、Python,甚至某个组态软件。文本表示省掉了htonl/ntohl和各种端序判断,任何语言拿到b"60"都能解析。代价是多几个字节、需要一次atoi,以及必须记住「数字是字符串不是整数」——这一点是新手最常见的失败点,后面避坑章还会提。
这个取舍只用在小体量的控制参数上。真正的高频数据,比如RPOS回包的 64 字节位姿,是全二进制紧密打包的,因为那是每秒 60 次的流量,多一个字节都是浪费。同一个协议里两种风格并存,说明作者是按「这条消息多久发一次」来选编码方式的,而不是教条地统一。
2.3 结构体对齐:Intel 64 位下的 4 字节与 8 字节边界
文档明确要求:结构体字节对齐必须匹配 Intel 64 位 X-Code 的规则。翻译成可操作的规则就是两条——int和float对齐到 4 字节边界,double对齐到 8 字节边界。
VEHX是最直观的例子。结构是int p后面跟三个double(纬度、经度、海拔),再跟三个float(航向、俯仰、滚转)。int只占 4 字节,但下一个double必须落在 8 的倍数偏移上,所以int后面会被编译器插入 4 个字节的填充,整个结构是 44 字节而不是 40。如果你按「4 + 24 + 12」算,就会少 4 字节,模拟器侧按 44 读,字段全部错位。
反过来,RPOS的输出结构没有这个烦恼,因为它是 3 个double打头,24 字节之后接 10 个float,24 本身就是 4 的倍数,天然对齐,全程无填充。文档那句「没有恼人的结构对齐问题」说的就是这件事。
数组的坑更隐蔽,文档专门点了出来:结构里只要出现double,一个 4 字节int数组只有第一个元素会被推到 8 字节边界,数组内部元素仍然各占 4 字节,后面每个元素只往后推 4 字节。换句话说,填充只发生在数组开头,不会发生在数组中间。PREL结构就是这种混合布局——前半段是int+ 机场 ID 定长字符数组 + 两个int,后半段是 5 个double,字符数组的长度直接决定后面第一个double落在哪个偏移、要不要补填充。
提示:不要试图用
#pragma pack(1)关掉对齐来「简化」计算。模拟器那一侧是按默认对齐编译的,你压紧了,长度就对不上。
2.4 一个能跑的信封函数
把上面的规则固化成代码,后面所有消息都能复用。下面这段 Python 只做两件事:拼信封、锁住本地端口。
import socket HOST, PORT = "127.0.0.1", 49000 # 端口固定,模拟器只在这个口上收 def envelope(tag: str, body: bytes) -> bytes: # 标签必须正好 4 个 ASCII 字符,第 5 字节写 0 做空终止 assert len(tag) == 4, "标签长度必须为 4" return tag.encode("ascii") + b"\x00" + body def rpos_request(freq: int) -> bytes: # 频率用十进制字符串,结尾再补一个 0;不要用 struct.pack("<i", freq) return envelope("RPOS", str(freq).encode("ascii") + b"\x00") sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.bind(("0.0.0.0", 0)) # 必须先 bind,回发包才知道往哪送 sock.sendto(rpos_request(60), (HOST, PORT)) # 请求每秒 60 次位姿三个参数值得展开。HOST填模拟器所在机器的地址,同机调试就写回环;PORT永远是 49000,文档强调「X-Plane 始终在端口 49000 上接收」,没有例外。freq是期望的回发频率,上限受模拟器帧率约束,写 200 也不会真的给你 200。那句bind是容易被忽略的关键:UDP 的会话是靠源地址维持的,不 bind 的话操作系统每次sendto可能换一个临时端口,回包就落到别处去了。
3. 输出订阅:RPOS、RADR、FLIR 如何把模拟器变成数据源
输出类消息的共同点是「先订阅、再被动收」。你先发一条带参数的请求,模拟器记住这个源地址和频率,然后持续往回推。三条输出通道的用途完全不同:RPOS给位姿,用来驱动移动地图或外部视景;RADR给天气雷达点,用来画自己的气象显示;FLIR给红外图像帧,用来做前视红外画面。它们的参数单位还各不相同,混用会直接翻车。
3.1 RPOS:64 字节里的 13 个自由度
请求发出去之后,回包是RPOS+ 0 字节 + 固定 64 字节的数据区,全部紧密排列,没有一个填充字节。字段顺序和类型如下:
| 偏移 | 类型 | 字段 | 含义 |
|---|---|---|---|
| 0 | double | dat_lon | 经度,度 |
| 8 | double | dat_lat | 纬度,度 |
| 16 | double | dat_ele | 海拔高度,米 |
| 24 | float | y_agl_mt | 离地高度,米 |
| 28 | float | veh_the_loc | 俯仰角,度 |
| 32 | float | veh_psi_loc | 真航向,度 |
| 36 | float | veh_phi_loc | 滚转角,度 |
| 40 | float | vx_wrl | 东向速度,米每秒 |
| 44 | float | vy_wrl | 天向速度,米每秒 |
| 48 | float | vz_wrl | 南向速度,米每秒 |
| 52 | float | Prad | 滚转角速度,弧度每秒 |
| 56 | float | Qrad | 俯仰角速度,弧度每秒 |
| 60 | float | Rrad | 偏航角速度,弧度每秒 |
注意坐标系的约定:速度三分量是东、天、南,不是常见的东北天,也不是机体轴。做运动平台的时候这个符号搞反,平台会往反方向倾,而且看起来「很像那么回事」,极难自查。另外姿态角用的是度,角速度用的是弧度每秒,同一个包里两套单位并存,解包后不做换算直接积分,必定漂移。
解包代码可以写成一行格式串,长度校验一定要留:
import struct RPOS_FMT = "<3d10f" # 3 个 double + 10 个 float,小端,紧密 RPOS_SIZE = struct.calcsize(RPOS_FMT) # 24 + 40 = 64 def parse_rpos(pkt: bytes) -> dict: body = pkt[5:] # 跳过 4 字节标签 + 1 字节 0 if len(body) != RPOS_SIZE: raise ValueError(f"长度不符:期望 {RPOS_SIZE},实际 {len(body)}") v = struct.unpack(RPOS_FMT, body) keys = ("lon", "lat", "ele_m", "agl_m", "pitch", "heading", "roll", "vx_east", "vy_up", "vz_south", "roll_rate", "pitch_rate", "yaw_rate") return dict(zip(keys, v))<表示小端且不自动对齐,正好对应文档要求的「紧密、无间距」。字段个数是最容易数错的地方:double三个,float是十个,不是九个——y_agl_mt之后还有三个姿态角、三个线速度、三个角速度,少算一个,后面全部错位,而且不会报错,只会解出一堆看似合理的数。
3.2 RADR:每帧 N 个点的天气数据
RADR的请求格式和RPOS一样,RADR+ 0 字节 + 十进制字符串 + 0 字节,但参数语义变了:这里写的是「每帧要回多少个雷达点」,不是「每秒回多少次」。文档的例子是发RADR_10_,让模拟器每帧回 10 个点。
每个点的结构只有 4 个float,一共 16 字节:经度、纬度、降水等级(0 到 100)、风暴顶高度(海平面以上米数)。文档强调这些数据「完全紧密地打包在一起,没有空格」,所以一帧 N 个点就是 N 乘 16 字节连续排下来,按 16 字节切片循环解析即可。
用的时候要注意两点。第一,降水等级是归一化的 0 到 100,不是真实的 dBZ,映射到雷达配色需要自己定一条曲线。第二,风暴顶高度是海拔而非离地高度,山区场景里如果不减去地形,画出来的回波会整体偏高。这两个细节文档写了,但混在字段说明里,容易一扫而过。
3.3 FLIR:帧率驱动的图像推流
FLIR同样是「标签 + 字符串参数」,参数是每秒多少帧。文档对这条通道的描述最简略,只说完整提取代码另见一份专门文档。从机制上推断,它是把渲染出来的红外画面读回内存再往外推,单帧体积比RPOS大几个数量级,所以必须按帧率控制而不是按条控制,否则一条千兆链路会被瞬间打满。
这条通道的实用价值在于「所见即所得」:你要做的不是重新渲染一遍红外效果,而是拿到模拟器已经算好的结果。代价是它比RPOS脆弱得多——丢一个位姿包只是抖一下,丢一帧图像就是画面撕裂,所以工程上通常不追求满帧率,宁可降到 10 帧换稳定。
3.4 回发地址:无连接会话是怎么维持的
三条输出通道共享同一个会话语义,文档反复强调同一句话:模拟器会把数据发回「你发送命令的那个 IP 地址和端口号」。也就是说,协议层没有会话 ID,没有 token,服务端靠源地址认人。
这个设计让整个系统无状态:模拟器不记录谁订阅了什么,只是收到请求时把「目标地址 + 频率」塞进一张表,超时或者收到频率为 0 的请求就删掉。客户端崩了,模拟器毫无感知,也不会残留半个连接。代价有两个:一是客户端必须在整个生命周期里锁住同一个本地端口,中途换端口等于重新订阅;二是跨网段时要做端口映射,否则回包被 NAT 丢掉,表现就是「请求发出去了,一个字节都没回来」。
注意:排查「收不到回包」时,先确认自己 bind 了固定端口,再确认中间没有设备改写源端口,最后才去怀疑协议格式。顺序反了会浪费很多时间。
4. 输入控制:VEHX、PREL、ACFN 怎么把外部状态灌进模拟器
输出是订阅,输入是命令。这一类消息的复杂度不在信封,而在数据体:有的结构体需要手工补填充,有的枚举值多达三十几个,有的消息本质上是两条消息的合体。搞清这一组,你就能用外部程序完整接管一架飞机——从加载机型、选择出生方式,到每帧推送六自由度。
4.1 VEHX:用 44 字节接管一架飞机的六自由度
VEHX是「用你自己的飞行模型驱动模拟器」的主通道。结构是int p、三个double(纬度、经度、海拔)、三个float(真航向、俯仰、滚转)。p是飞机索引,0 表示你正在飞的那架主飞机,1 到 19 表示 AI 机。
打包时必须显式补上那 4 个字节的填充:
import struct # i=int(4) + 4 字节填充 + 3 个 double(24) + 3 个 float(12) = 44 VEHX_FMT = "<i4x3d3f" assert struct.calcsize(VEHX_FMT) == 44 def build_vehx(idx: int, lat: float, lon: float, ele_m: float, heading_deg: float, pitch_deg: float, roll_deg: float) -> bytes: body = struct.pack(VEHX_FMT, idx, lat, lon, ele_m, heading_deg, pitch_deg, roll_deg) return b"VEHX\x00" + body # 5 字节信封 + 44 字节数据4x就是那 4 个填充字节,写成显式的比依赖编译器默认行为更安全,换语言、换平台都不会变。这个结构里没有速度字段,位置靠每帧推送、由模拟器自己差分求速度,所以推送频率要稳定——频率抖动会让模拟器算出的速度忽大忽小,飞机看起来在抖。另外p用 0 时接管的是主飞机,此时模拟器原本的飞行模型仍在跑,两边会打架,正确做法是先切到外部控制模式再推。
4.2 PREL:为什么「定位」需要三十多种出生方式
PREL看起来只是「把飞机放到某个位置」,但它的结构里第一个字段是一个枚举type_start,取值范围从 5 一直到 36。这个设计很值得琢磨:如果只是给经纬高,一个double三元组就够了,为什么需要三十多种「出生方式」?
因为出生方式决定的不是位置,是一整套初始状态。loc_ram(10,停机坪)意味着起落架放下、发动机慢车、襟翼收起、停在滑行道上;loc_tak(11,跑道起飞)意味着已经在跑道头对正、油门可以推满;loc_vfr(12)和loc_ifr(13)是两种进近,初始高度、速度、襟翼构型、甚至是否已建立航向道都不同;loc_wat(17)是水上,起落架收起、浮筒放下;loc_pad(18)是直升机坪;loc_cat(19)是弹射起飞,初速度直接给满。这些差异没法用一个坐标表达,只能让调用方选一个语义标签,剩下的由模拟器内部填。
结构本身分两段:前半段是type_start、飞机索引p_idx、机场 ID 字符数组、跑道索引、跑道方向;后半段是 5 个double——纬度、经度、海拔、真航向、米每秒速度。文档给了一条最实用的建议:如果你只想指定坐标,就用loc_specify_lle(6),此时机场相关字段全部填 0,只把后半段的 5 个double填好。这条路径绕开了机场 ID 的长度问题和跑道路径查询,是外部程序最常用的入口。
前半段那个机场 ID 字符数组是填充计算的分水岭:它的长度决定后面第一个double落在哪个偏移,长度不是 8 的倍数就得补。这也是为什么同一个结构,不同人算出来的sizeof会不一样。
4.3 ACFN、ACPR、SIMO、CMND:把 UI 动作压成报文
ACFN负责加载机型,结构是int acfn_p、xchr acfn_path_rel[150]、int livery_index。文档特别提了一句:旧的OLDACF结构没有涂装选项,现在统一用ACFN并在末尾带涂装索引。p填 0 是主飞机,1 到 19 是其他飞机。路径是相对路径,长度上限 150,超了会被截断而不是报错。
ACPR是ACFN和PREL的合体:发ACPR+ 0 字节 + 上面那两个结构,一次完成「加载机型 + 放到位置」。拆开用也行,但两步之间有窗口期——飞机加载完了还停在默认位置,如果你的程序在这期间推了VEHX,会看到飞机先闪一下再归位。要求「一次到位」的场景直接用ACPR。
SIMO管场景和电影的存取,四种类型:存场景、读场景、存电影、读电影,后面跟一个路径字符数组。它的用途是做回归测试——把一次飞行存成场景,改代码后重新加载,比对RPOS输出是否一致,比手动复现快得多。
CMND最省事,数据体就是一个命令字符串,比如sim/flight_controls/flaps_up。命令清单不用猜,文档给了出处:进设置菜单的操纵杆和设备界面,切到按钮高级页,屏幕上那些分组名加右侧命令串就是全部可用命令。这个设计的巧妙之处在于,它把整套 UI 操作暴露成了字符串接口——收襟翼、切换视角、弹菜单,全是一条字符串的事,不需要为每种操作定义结构体。
4.4 RREF 与 DREF:dataref 总线的读与写
前面几条消息都是「专用通道」,只覆盖位姿、机型、场景这些固定目标。想读写模拟器内部任意一个变量,得用 dataref 总线。这是整套协议里最灵活、也最容易出问题的部分。
读用RREF。请求结构是三个字段:dref_freq(每秒要几次,填 0 表示停止推送)、dref_en(你自己编的整数句柄)、dref_string[400](dataref 路径)。回包结构只有两个字段:dref_en和dref_flt,一共 8 字节。也就是说,回包里不带路径,只带你当初编的那个句柄,靠句柄反查是哪个变量。这意味着句柄必须全局唯一,且订阅表要自己维护。
import struct RREF_IN_FMT = "<ii400s" # 频率 + 句柄 + 路径,共 408 字节 RREF_OUT_FMT = "<if" # 回包:句柄 + 浮点值,共 8 字节 def build_rref(freq: int, handle: int, path: str) -> bytes: raw = path.encode("ascii") assert len(raw) < 400, "路径超长会被截断" body = struct.pack(RREF_IN_FMT, freq, handle, raw) # 400s 会自动补 0 return b"RREF\x00" + body def parse_rref(pkt: bytes): handle, value = struct.unpack(RREF_OUT_FMT, pkt[5:]) return handle, value路径有个语法细节:如果目标 dataref 是数组(比如发动机推力有 8 台),在路径末尾加方括号和下标,例如sim/flightmodel/engine/POINT_thrust[1]取第二台,下标从 0 开始。文档特意说明方括号里的数字就是索引本身,不要写成变量名。回包里的dref_flt永远是float,即便原变量是整数——按整数用的时候自己做一次截断。
写用DREF,结构是float var加char dref_path[500]。文档给了一个很容易被忽略的约束:整条消息要凑到 509 字节。验算一下,5 字节信封 + 4 字节浮点 + 500 字节路径正好 509,所以路径区必须补满 0,不能只发到字符串结束。这个定长设计的好处是模拟器侧可以用一块固定大小的缓冲区收包,不用处理变长;坏处是你少发一个字节,整条消息就被丢弃,而且不一定有提示。
4.5 DATA 与 DSEL:把数据输出屏幕变成可编程接口
DATA是另一个「设置型」通道,结构是int index加float data[8]。index对应数据输出屏幕里那一长串变量的行号,data[8]是最多 8 个数值。它能让外部硬件直接顶替摇杆——把俯仰、滚转、油门、方向舵按行号塞进去,模拟器就当成本地输入用。
有两条规则必须记住。第一,不是所有变量都可写:马赫数由空速和音速决定,你写它也不会生效;油门、舵面、起落架这类是可写的。第二,index填 -999 表示「放手」,把控制权交还模拟器内部,同时让这个通道回到默认状态。不写 -999 的直接后果是外部输入一直压着,用户切回键盘也没反应,看起来像模拟器卡死了。
DSEL管的是「让模拟器主动输出哪些数据行」,数据体是一串整数,按数据输出屏幕里的顺序编号,第一个是 1,第二个是 2。文档的例子DSEL0456表示请求第 4、5、6 项。配套的还有取消选择的USEL、以及和座舱显示相关的DCOC、UCOC。这一组消息的实际用途是免去手动进设置界面逐条勾选——附加组件启动时自动把需要的数据行打开,退出时关掉。
提示:
DATA的index和DSEL的编号不是一套东西。前者是「我要写第几行的值」,后者是「我要读第几行的值」,别混着用。
5. 避坑与排查:那些报文发出去却石沉大海的时刻
这套协议最让人难受的地方是失败时几乎没有反馈。UDP 本身不保证送达,模拟器收到格式不对的包通常也只是丢弃,不会回一个错误码。文档给出的唯一抓手是设置菜单里的「转储网络数据到 log.txt」开关——打开后模拟器会把收到的数据和它期望的结构长度写进日志文件(文档里提到文件名叫 error.out,不同版本可能落在 log.txt)。开发期把开关打开,退出模拟器再翻日志,比盲猜快十倍。下面几条是这类项目里高频出现的翻车现场。
5.1 链路层:收不到、收不全
现象一:sendto返回的字节数正确,模拟器毫无反应,日志里也什么都没有。
原因通常是目标端口写错,或者包发到了另一台机器的 49000 上。这套协议只有一个接收端口,没有备用口,也没有广播发现机制。先用ss -ulpn之类的工具确认监听状态,再确认HOST指向的是运行模拟器的那台机器。如果模拟器和客户端不在同一台机器,还要检查中间的设备有没有拦掉入站 UDP——UDP 没有重传,被拦就是静默丢弃,表现和「发错端口」完全一样。
现象二:请求发出去了,日志显示模拟器收到了,但客户端一个回包都没收到。
原因是客户端没有bind固定端口。模拟器把回包发到「请求的源 IP 和源端口」,如果你的 socket 每次sendto都换临时端口,回包就落到一个已经关闭的端口上。解决方式是在创建 socket 之后立刻bind(("0.0.0.0", 0))把端口钉住,之后一直复用同一个 socket。跨网段的情况还要额外做端口映射,否则源地址在 NAT 之后被改写,回包同样回不来。
5.2 数据层:字段错位、控制权抢不回
现象三:日志里提示「期望 N 字节,实际收到 M 字节」,两个数差 4 或差 8。
典型的对齐计算错误。最常见的是int后面直接跟double却没补填充,比如VEHX的int p后面跟三个double,中间必须有 4 个填充字节,否则你发 40 字节,模拟器按 44 字节读。另一个来源是用了紧凑打包(关掉对齐)的编译选项,或者用了某个默认对齐规则不同的语言运行时。解决办法是按 Intel 64 位规则手算一遍,在格式串里显式写填充(比如4x),再用calcsize断言长度。别指望「大概对」——差一个字节就是全错。
现象四:RPOS请求发出去,回包频率不对,或者根本没有回包。
十有八九是把频率写成了 4 字节整数。这条消息的参数是十进制字符串,"60"加一个 0 字节,不是struct.pack("<i", 60)。用抓包工具看一眼请求包长度就能分辨:字符串版本是 8 字节(RPOS+ 0 +60+ 0),整数版本是 9 字节,而且第 5 字节之后的内容是二进制,不是可读数字。类似地,RADR的参数是「每帧多少个点」,RPOS的参数是「每秒多少次」,两个单位不同,抄代码时特别容易连着抄错。
现象五:用DREF设了一个开关,下一秒又弹回原状;或者设完之后用户再也控制不了这架飞机。
两种不同的原因。弹回原状,通常是因为那个变量由模拟器每帧重算(比如由空速推导出来的量),写进去立刻被覆盖;也可能是有另一个通道同时在写同一个变量,比如外部输入和DATA消息在打架。判断方法很简单:先用RREF订阅同一个 dataref,观察它在你不写的时候是否自己变化,会变的就是派生量,别写。控制权抢不回来,是因为DATA通道没发index = -999的释放消息,外部输入一直压着。这类问题在联调时最折磨人,建议把「释放」写进程序的退出路径,而不是靠手动补救。
6. 旁路接口的取舍:什么时候该用 UDP,什么时候该写插件
把这几条通道摆在一起看,会发现它们覆盖的能力范围其实很不均匀。RPOS精确到厘米和零点几度,VEHX能接管六自由度,CMND能触发几乎任何 UI 动作,但你想读一个不在 dataref 清单里的内部状态,或者想在气动力计算之前插一脚,UDP 就无能为力了。文档自己也承认这一点:插件是「快速读取和写入大量内容」的方式,但要求你会写插件;UDP 是「不用学插件也能收发数据」的方式,代价是能力有边界、延迟不可控。
这个边界不是随便画的,它对应一条很实际的工程分界线。UDP 跑在模拟器进程之外,你的程序崩溃、死循环、内存泄漏,模拟器都感知不到,最多是少了一路输入;插件跑在进程之内,读写快、能挂回调、能改气动,但你一旦在回调里阻塞,整个仿真循环就跟着卡。飞行仿真对帧率极其敏感,主循环卡 100 毫秒,画面和物理都会抖。所以凡是「外部硬件、视景系统、移动地图、运动平台」这类可以容忍几十毫秒延迟、但绝对不能拖累主循环的场景,走 UDP 是更稳的选择;凡是「改飞行模型、加自定义仪表逻辑、拦截内部事件」这类必须进内核的场景,才值得付出写插件的成本。
至于为什么是 UDP 而不是 TCP,答案藏在数据本身的性格里。位姿流是 60Hz 的连续采样,丢一帧的后果是画面抖一下,下一帧立刻补上;而 TCP 的重传和队头阻塞会让延迟越积越大,最终表现为「画面越来越滞后」,这在实时系统里比丢帧难受得多。协议选择「宁丢不卡」,是站在实时性一边的。同样的逻辑解释了为什么控制参数用文本、高频数据用二进制:前者一天发一次,多几个字节无所谓,换来跨语言跨端序的省心;后者一秒发六十次,每个字节都要算。
这套协议里我最欣赏的一个细节是DREF的 509 字节定长。它看起来像是历史包袱——路径最多 500 字节,剩下的补 0,发不满就丢。但这个约束让服务端可以用一块固定缓冲区收包,不用做变长解析,也就没有了长度字段被伪造的风险。用一点带宽换确定性的解析路径,是很典型的实时系统思路。
验证方法上,我习惯分三步走:先用「转储网络数据到 log.txt」确认模拟器真的收到了包、期望长度是多少;再只发一条最简单的订阅(比如RPOS频率 1),确认回包能落到自己的端口上;最后才上复杂结构,并且一次只改一个字段。跳过前两步直接调VEHX,出了问题是「没收到」「长度错」「字段错位」「坐标系反了」四种可能混在一起,根本没法定位。从那以后我每次接一个新的仿真器接口,都强制先跑通一条最小回环再动业务代码——这个习惯帮我省掉的调试时间,比任何文档都多。希望帮到你。
这份中文说明文档把上面所有消息的标签、结构、字段顺序、对齐要求、废弃对照表都收在了一起,包括那些只在日志里才会暴露的长度约定。需要对照原文逐字核对的,可以直接取用。
本文还有配套的精品资源,点击获取