EDS与PUS实践(五):用EdsLib与UDP验证服务类型17线包
- 本地打包后立刻在同一个函数里解包,只能说明基本接口能调用。把发送端和接收端拆成两个进程,中间真的走一次 UDP,测试边界会清晰很多。
上一篇完成了 PUS 服务类型 17 的 EDS 建模。本文只增加一个变量:让 packed bytes 离开打包进程,经 UDP 到达独立接收进程。这样可以观察真实发送长度、进程边界和接收端校验,但不会把“传输成功”夸大成“服务行为已经实现”。
本章在整个系列中的位置
前面的验证都可以在一个进程、一个函数里完成:初始化对象,Pack,立即 Unpack。这样适合检查 API,却不会暴露实际发送长度、接收长度、端口绑定、进程启动顺序和错误归属。本章把传输边界单独拉出来:
1 | |
两端仍使用同一份 EDS 数据库,因此它不是独立协议实现交叉验证。本章的价值是把“编解码”和“跨进程传输”接起来,并建立之后替换中间接收端的稳定接口。
开始前已经具备什么
进入本章前,以下内容应当已经单独通过:
- 四种 ST17 类型可以生成;
- TC[17,1] 和 TC[17,3] 的 packed size 与手算一致;
- Complete Pack/Unpack 能检查固定值、长度和 PEC;
EdsLib_Initialize()已在进程启动时调用;- 固定输入的 packed hex 可以逐字段解释。
如果单进程 Pack/Unpack 尚未稳定,不应先加 UDP。否则收到 unpack failed 时无法判断是模型、编码、截断还是传输问题。
这一步到底想验证什么
目标很克制,只验证下面这条链:
1 | |
这里还没有实现“收到 TC 后自动产生 TM”的服务行为。发送什么类型,接收端就按同一类型解包,主要检查 XML、EdsLib 和 UDP 传输之间有没有断层。
将结论写成可证伪的断言
本章要证明的不是“UDP 能发包”这种泛化结论,而是以下具体断言:
- 发送端
sendto()的返回值等于 EDS packed byte 数; - 接收端
recvfrom()的返回值等于发送长度; - 接收端收到的 hex 与发送端逐字节相同;
- Complete Unpack 成功,说明固定值、长度和 PEC 仍有效;
- 解包得到的 APID、Subtype、Source ID 和 Process ID 等于发送输入;
- 超时、长度不符、解包失败能以不同错误退出。
每条都可以通过日志和退出码观察。若最后只打印 PASS,测试的可解释性仍然不足。
为什么先只发送 TC
这一阶段发送端是命令构造器,接收端是同类型解码器,所以发送 TC[17,1]/TC[17,3] 最直接。接收端不会自动变成服务提供者,也不会把 TC 变成 TM。等下一篇加入 puslib 服务处理器后,链路才扩展为 TC 请求方向和 TM 报告方向。
为什么参考 cmdUtil 和 tlm_decode
NASA EdsLib 的 cfecfs/util 中有命令发送和遥测解码工具。它们展示了比较完整的地面端思路:
- 根据任务数据库定位类型;
- 给字段赋值;
- EdsLib 打包;
- UDP 发送;
- 接收 UDP;
- 根据路由信息确定类型;
- EdsLib 解包和打印。
这些工具还依赖 cFE 头、MissionLib、TopicId 和任务接口表,不能原样拿来验证一套独立的服务类型 17 EDS 数据库。最小实验只保留“EdsLib + UDP”主线,类型直接选 TC[17,1] 或 TC[17,3]。
可以复用的是结构,不是所有依赖
cmdUtil 的价值在于展示“定位消息类型—填写字段—Pack—sendto”的通用流程;tlm_decode 展示“recvfrom—路由类型—Unpack—遍历显示”。其中类型路由依赖 cFE MissionLib 的接口数据库和 TopicId 映射,当前独立 PUS 数据库没有这套任务信息。
若为了让原工具编译而伪造 TopicId 或 cFE 头,测试会混入一套并不存在的接口语义。更诚实的最小修改是删除 cFS 专属路由,保留 EdsLib 与 UDP,并要求调用者明确给出预期消息类型。
这两个参考工具不使用 Python puslib
它们是 C/EdsLib/cFE MissionLib 路线,与后续 Python puslib 是两套不同角色。前者说明 NASA EDS/cFS 环境下怎样构造与解码任务消息;后者提供不读取当前 EDS XML 的独立 PUS 实现。不能因为名字里都有 PUS 或 ground tool 就认为内部机制相同。
发送端的核心
发送端做的事情并不多:
1 | |
一个 UDP 数据报承载一个完整 PUS 包。没有再套一层自定义长度或 JSON,这样接收端拿到的就是 EdsLib 生成的原始线包。
发送端可以进一步拆成六个可检查的步骤:
- 初始化 EdsLib 数据库;
- 根据命令行选 TC[17,1] 或 TC[17,3] 的类型 ID;
- 查询 packed size 并初始化 native object;
- 填写 APID、序列号、Source ID,以及 17.3 的进程 ID;
PackCompleteObject()生成长度和 PEC;sendto()只发送(packed_bits + 7) / 8个字节。
最后一点很关键:缓冲区可以定义成 2 KiB,但不能把 2 KiB 全部发出去。否则接收端看到的不是“一个 PUS 包”,而是 PUS 包后跟大量未使用字节。
发送前的检查顺序
推荐按下面顺序失败即退出:
1 | |
若 sendto() 返回负数,报告 errno;若返回长度不等于期望,也应判失败。虽然 UDP 本地发送通常全报文成功,测试代码不应假设系统调用永不出错。
发送地址属于配置,不属于消息
127.0.0.1:1234 只决定数据报交给哪个本机 socket。它不会进入 PUS packed bytes,也不会影响 APID、Service Type 或 PEC。修改端口后,预期 PUS hex 应保持不变;若 hex 变化,说明传输配置错误地渗入数据模型。
为什么一个数据报只放一个空间包
这种映射不是 PUS 强制要求,而是测试设计:UDP 本身保留数据报边界,一个数据报一个完整包可以避免额外分帧协议。若一次 UDP 放多个空间包,接收端还要根据 Packet Data Length 循环切分;若一个空间包拆成多个 UDP,又要增加重组状态。最小验证没有必要引入这些变量。
如何在不写死 C 成员时给字段赋值
参考工具允许用户用字符串指定字段,例如:
1 | |
实现方式是:
1 | |
这种方式适合通用命令工具;固定业务程序直接访问生成成员更容易得到编译期检查。最小实验保留通用赋值思路,是为了尽量复用 EdsLib 原有工具的结构,而不是另造一套报文字段解析器。
动态赋值必须检查三次
第一,LocateSubEntity() 是否精确找到路径;第二,字符串能否转换为字段类型;第三,值是否在 EDS 范围内。只检查最后 Pack 状态可能让错误字段保持默认值,最终产生一个合法但不是用户想要的包。
字段指针通常由 native 对象基址加成员绝对 byte offset 得到,不能把 packed bit offset 当 native pointer 偏移。DisplayDB 返回的是对 native object 的定位信息,随后仍由 DataTypeDB Pack 处理线上排列。
通用工具和固定业务代码的取舍
测试 CLI 需要动态字段路径,便于试不同类型;嵌入式热路径更适合生成结构成员和编译期 Type ID,减少字符串表和解析开销。两者可以共享同一个 pack_message() 核心,无需强迫整个系统只用一种访问方式。
接收端的核心
接收端固定等待一包:
1 | |
测试程序明确传入预期类型,这是一种刻意简化。没有 MissionLib 时,不应该假装已经拥有完整的自动路由能力。
接收端同样需要严格区分“缓冲区容量”和“实收长度”:
1 | |
UDP 保留数据报边界:一次 sendto() 对应接收端的一次完整数据报,不需要像 TCP 那样自行处理粘包/拆包。但如果接收缓冲区太小,数据报会被截断;如果没有超时,程序也可能永久等待。因此测试程序最好设置接收超时,并将超时与解包失败分开报告。
bind() 完成才算接收端 ready
仅启动进程不代表端口已经可用。测试脚本应等待接收端完成 socket 创建和 bind() 后再发送。可以由接收端在成功 bind 后输出 ready 标记,脚本读取到标记再启动发送端。
固定 sleep 1 在本机通常能工作,却把调度速度当成协议条件;负载较高时仍可能先发后绑,UDP 数据报没有连接重试,会直接丢失。
长度验证应早于 Unpack
接收端知道预期固定类型时,可以先取得 expected_packed_bytes。若 received 不等于预期,立即报告:
- 小于预期:数据报可能被错误构造或截断;
- 大于预期:可能选错类型,或发送端把整个缓冲区发出;
- 等于预期但 Unpack 失败:再检查约束、长度字段和 PEC。
这种顺序能把 socket 层长度问题与 EdsLib 验证问题分开。
接收缓冲区截断要显式检测
普通 recvfrom() 返回的数据可能已被内核截断。更严格的 Linux/POSIX 测试可使用足够大的缓冲区,或通过 recvmsg() 与 MSG_TRUNC 检查。最小程序至少保证缓冲区大于数据库给出的最大包长,并把实收长度打印出来。
为什么没有 MissionLib 仍然能做这一步
原始 cmdUtil/tlm_decode 可以通过 cFE/MissionLib 的 TopicId、接口表和路由关系确定消息类型。独立的服务类型 17 实验没有这些任务数据库,所以改成由测试参数显式给出类型:
1 | |
删除的是“cFS 任务路由与自动类型发现”,保留的是:DisplayDB 设置字段、DataTypeDB 打包/解包、UDP 收发和字段遍历显示。这个裁剪不会把 cFE 主头误当成 PUS 次头,也不会假装已有一套并不存在的 TopicId 映射。
代价也很明确:接收端必须事先知道期望类型。若未来需要一个端口接收多种 PUS 消息,可以先读取固定的 CCSDS/PUS 头,根据 Packet Type、Service 和 Message Subtype 查表选择 EDS 类型,再调用完整解包;那是下一层路由功能,不应偷偷塞进最小测试。
固定类型接收不是“功能不完整”的错误
测试的目标决定最小接口。当前分别启动 receiver 17.1 或 receiver 17.3,能够精准验证对应类型。自动路由会引入公共头预解析、消息映射表和未知类型策略,增加的每项都需要独立测试。
先固定类型建立可信基线,再添加路由,能判断新失败是否来自路由层。这正是小步验证的意义。
未来自动路由的合理边界
自动路由至少需要:
- 检查收到的数据大于等于公共头最小长度;
- 读取 Packet Type、Service Type 和 Message Subtype;
- 结合 APID/任务接口表选择 EDS Type ID;
- 根据该类型查询期望 packed size;
- 再调用 Complete Unpack;
- 未知组合进入明确拒绝路径。
这张映射表可以由任务数据或 IntfDB/MissionLib 辅助生成,但不能凭服务号直接猜 C 类型。
一个 PUS 包在 UDP 中是什么样
UDP payload 直接是完整 packed PUS packet:
1 | |
代码发送的是遥控 TC[17,1] 或 TC[17,3],不是“UDP 命令头 + PUS 包”。接收端收到的也正是这串 TC 字节。该 C→C 测试不会自动产生 TM;只有引入真正的 PUS 服务类型 17 处理器后,才会出现 TC 请求和 TM 响应两条方向相反的数据流。
抓包时应该比较哪一段
Wireshark/tcpdump 会显示以太网、IP 和 UDP 头,应用日志通常只打印 UDP payload。逐字节比较时应从 CCSDS 主头的第一个 byte 开始,不要把网络头加入 PUS golden vector。
本机 loopback 可能看不到以太网头,但 IP/UDP 头仍由内核构造。无论接口如何,PUS PEC 只覆盖 packed PUS packet,不覆盖 UDP/IP 头。
UDP checksum 与 PUS PEC各自的价值
UDP checksum 检查 UDP 伪首部、头和 payload 在传输中的错误,PUS PEC 检查空间包本身的数据完整性。包可能经过存储、转发或重新封装,PUS PEC仍随包存在;UDP checksum 在每段 UDP 传输上重新计算。两者作用域不同,不应二选一。
两条实际路径
测试分别跑:
1 | |
17.1 检查无业务数据的 TC;17.3 额外检查 16 位 Application Process ID。接收端除了返回成功,还会输出:
- 实际收到的字节数;
- 十六进制包;
- APID、序列号;
- PUS 版本;
- Service/Message Subtype;
- Source ID;
- 17.3 的 Application Process ID;
- PEC。
建议每次日志都把“类型、期望长度、实收长度、完整 hex、解包状态”放在一起。例如 TC[17,3] 至少要能回答:
- 为什么整包比 TC[17,1] 多 2 字节;
- 长度字段是否因此从 6 变成 8;
- 进程 ID 的两个字节位于 PEC 前;
- 修改进程 ID 后 PEC 是否同时变化;
- 接收端显示的值是否与发送参数一致。
TC[17,1] 的预期观察
当前任务剖面下,TC[17,1] 是 13 byte。接收端应看到 Packet Type=TC、Service=17、Subtype=1,无 Application Process ID。长度字段为 6,最后 2 byte 是 PEC。
若接收端打印了 Process ID,说明类型或字段遍历逻辑混入 17.3;若整包 15 byte,可能发送端选错类型或增加了不应存在的业务字段。
TC[17,3] 的预期观察
在相同公共输入下,TC[17,3] 是 15 byte,长度字段为 8,PEC 前出现 2 byte 大端 Process ID。接收后不仅比较数值 1,还要确认 00 01 位于正确位置。这样能同时验证类型宽度和字节序。
两条路径为什么要分开跑
17.1 提供最小公共头基线,17.3 只增加一个服务字段。两条结果相减可以直观看到新增字段对 packed size、length 和 PEC 的影响,比只测复杂消息更容易定位。
这里可以放一张两个终端的截图:
图片预留:并排截取 UDP 发送端与接收端的终端输出,保存为
eds-pus-05-service-type-17-edslib-udp/udp-two-processes.png。
为什么 UDP 不属于 PUS 服务类型 17
PUS 服务类型 17 规定的是请求、报告和数据字段,UDP 只是测试环境里的搬运方式。以后完全可以换成:
- RTOS 消息队列;
- 软件总线;
- 串口或 CAN 上层适配;
- 真实通信链路;
- 单元测试中的内存缓冲区。
只要交给下一层的还是同一串 PUS packed bytes,EDS 数据契约不需要因为传输介质改变而重写。
这也提供了一条实用的分层接口:
1 | |
EDS/PUS 层只处理 packet 和 size;UDP、消息队列或驱动层实现这两个边界。以后换传输时,不应改动 Service/Message Subtype、长度或 PEC 的定义。
transport 接口还应表达错误
真实封装可以返回明确错误码,区分超时、缓冲区不足、系统调用失败和对端关闭等情况。EDS 层收到的只有两种状态:获得一段完整候选 packet,或传输失败;不要让 socket errno 穿透到 PUS 服务逻辑中。
RTOS 中怎样替换 UDP
在 RTOS 下,transport_send() 可以写消息队列或驱动,transport_receive() 可以阻塞等待队列。只要它交付完整 packet 与真实长度,上层 pack_message()/unpack_message() 不变。若底层最大帧小于空间包,则在 transport 内部增加分片重组,不修改 PUS XML 来迎合某次分片。
推荐的启动和收尾顺序
双进程测试应避免竞争:
- 先启动接收端并完成
bind(); - 再启动发送端;
- 发送端打印 packed hex 与
sendto()返回长度; - 接收端打印
recvfrom()长度、解包状态和字段; - 两端都以非零退出码报告失败;
- 脚本设置超时并回收后台进程。
不要用固定 sleep 1 作为唯一同步手段。更稳定的做法是让接收端输出 ready 标记,或让脚本轮询端口/进程状态;至少也要在超时时清理后台接收器,避免下次测试出现端口已占用。
一个可靠脚本承担什么职责
脚本不是协议实现,它只负责组织进程:
- 创建或确认构建产物;
- 启动 receiver 并等待 ready;
- 启动 sender 并保存双方日志;
- 等待进程退出并汇总退出码;
- 超时时终止本次启动的后台进程;
- 不吞掉原始错误输出。
脚本不应自己重写 CRC 或解析 PUS 字段,否则测试逻辑会散落到 shell、C 和 XML 三处。
端口冲突与遗留进程
若前一次超时留下 receiver,下一次 bind 可能报地址已使用,或者新 sender 的包被旧进程接收。脚本应记录自己启动的 PID,只清理该 PID,并在开始前检查端口状态。不要使用宽泛的 pkill 误伤其他测试。
这一步值得加入哪些负向测试
| 场景 | 预期观察 |
|---|---|
| 发送前翻转 Service 位且不重算 PEC | 接收端 CRC/完整对象验证失败 |
| 发送前翻转正文并重新计算 PEC | CRC 通过,但按错误类型/字段语义检查 |
| 截断 PEC 最后 1 字节 | 实收长度不符,拒绝解包 |
| 接收端故意选择 TC[17,1] 解 TC[17,3] | packed size 或类型约束不符 |
| 发送到错误端口 | 接收超时,而不是“解包失败” |
网络错误、协议错误、类型选择错误要分别报告。只有这样,测试失败才能指向真正的层次。
再补三组边界测试
| 场景 | 预期观察 |
|---|---|
sendto() 只发送前 6 byte |
receiver 报长度不足,不进入业务字段打印 |
| 发送合法 17.3,receiver 按 17.1 等待 | 先报预期长度不一致 |
| 发送端修改 APID 后重新 Pack | receiver 成功,APID 与 PEC 同时变化 |
最后一项是正向变体:它证明任务字段可以变化且 CRC 被重新计算;前两项证明接收端没有用缓冲区默认值补齐坏包。
一次只注入一个故障
同时改 subtype、截断 PEC 并发错端口,只会得到超时,无法证明任何校验。故障注入应单变量,并记录改动前后的 hex,才能把预期失败与具体防线对应起来。
这条测试没有证明什么
它没有证明:
- 收到 TC[17,1] 后一定生成 TM[17,2];
- 17.3 的目标进程真的存在;
- 失败确认行为符合 PUS 服务类型 1;
- UDP 可以代表真实星地链路;
- 整套系统已经集成完成。
它证明的是:XML 可以生成数据库,EdsLib 能产生符合该数据库的线包,字节经过独立进程和 UDP 后还能被完整校验、解包。
这个边界看起来保守,但结论可重复、可定位:EDS 模型生成的 TC 线包可以跨进程传输,并由同一数据契约完成长度、约束和 PEC 验证。
本章真正得到的工程接口
实现结果可以收敛成三个相互独立模块:
1 | |
这三个模块都不执行 ST17 业务。正因为边界清楚,下一篇可以保持 builder 和 decoder 不动,只把中间的“同数据库 receiver”替换成 puslib 服务进程;之后移植 RTOS 时,又只替换 transport。
如何准确汇报这一阶段
合适的表述是:“已验证 TC[17,1] 和 TC[17,3] 能由 EdsLib 按当前 EDS 数据库打包,经 UDP 跨进程传输后,由同一数据库完成长度、类型约束和 PEC 校验并恢复字段。”
不合适的表述是:“PUS17 服务已经通过 UDP 验证。”后者会让人误以为 TC 已触发真实测试并生成 TM,也掩盖了两端共享同一数据库的共同错误风险。
下一篇再把接收端换成不读取 EDS XML 的 puslib。届时 Python 端不仅解析 TC,还会实际执行 PUS 服务类型 17 行为并生成 TM,C/EdsLib 再从另一个 UDP 端口收回并解包。这样验证范围才从“传输闭环”扩展到“独立实现交叉闭环”。
参考资料
EDS与PUS实践(五):用EdsLib与UDP验证服务类型17线包
https://goko-son626.github.io/post/eds-pus-05-service-type-17-edslib-udp.html

