EDS与PUS实践(二):用EdsLib完成对象打包与解包

  • EdsLib 的接口很多,但固定长度消息的核心闭环并不复杂:取得类型 ID,初始化 native object,查询尺寸,完整打包,再按同一数据契约完整解包。真正容易出错的是对象表示、长度单位和验证边界。

上一篇梳理了 EDS XML 到生成数据库的过程。本文站到 C 应用一侧,回答“拿到这些生成物以后,怎样正确调用 EdsLib”。下面直接使用实际生成的 PUS_DATABASEPUS17/Tc17_1PUS17_Tc17_1_t,不再用容易与真实代码混淆的占位名。

本章要完成什么

上一篇已经得到三类东西:生成的 C 类型、可定位类型的 ID/名称,以及保存编码规则的运行时数据库。本章把它们接起来,完成最小的固定长度对象闭环:

1
2
3
4
5
6
7
8
选择 EDS 类型
→ 初始化 native object
→ 填写任务字段
→ 查询 packed 尺寸
→ Complete Pack
→ 检查十六进制线包
→ Complete Unpack
→ 比较解包字段与实际类型

重点不只是“函数怎样调用”,还要理解每个参数代表 native 还是 packed、单位是 byte 还是 bit、Type ID 为什么可能被更新、Complete 与 Partial API 的验证强度有什么区别。

本文仍然不加入 UDP 和服务状态机。这样一旦失败,原因只可能在类型选择、对象内容、数据库、缓冲区或编解码规则中,不会与网络超时混在一起。

开始前要确认的四项输入

在写 C 代码以前先确认:

  1. PUS_DATABASE 确实链接到当前生成版本,而不是另一个旧构建目录;
  2. PUS17_Tc17_1_t 来自同一批生成头文件;
  3. PUS17/Tc17_1 的 Type ID 能通过宏或 DisplayDB 正确取得;
  4. 这个类型是固定长度对象,适合使用非 VarSize 的 Complete API。

如果头文件来自 A 构建、数据库库文件来自 B 构建,C 编译可能通过,运行时却会用错误索引解释对象。这类错误看起来像 Pack 接口坏了,实际是生成产物版本不一致。

同一个消息有两种表示

1
2
3
4
5
native object(本机 C 对象)
字段访问方便,受目标 ABI、对齐和本机字节序影响
⇅ EdsLib
packed object(线上位流)
连续 bit/byte,严格服从 EDS 位宽、字节序和约束

例如 native 结构可能因为对齐占 16 字节,而 packed 格式只有 13 字节。直接 send(fd, &obj, sizeof(obj), ...) 会把填充和本机字节序一起发走;正确做法是先打包到单独的字节缓冲区。

两种表示各自适合做什么

native object 适合业务代码读写:可以用成员名访问 APID、序列号或 Source ID,调试器也能按 C 类型显示。packed object 适合存储和传输:它没有 ABI 填充,字段按协议位宽连续排列。

两者之间并不存在“强制转换一下指针”这种零成本捷径。转换过程可能包含位拼接、大小端变化、固定值填入、长度回写和 CRC 计算。即使某个简单结构在当前 x86 主机上碰巧内存布局与线包一致,也不能把这种偶然当成接口契约。

三种长度不要混用

代码中至少会出现:

  • sizeof(source):native 源对象容量,单位 byte;
  • sizeof(packed):packed 缓冲区容量,单位 byte;
  • info.Size.Bits:该 EDS 类型的线格式长度,单位 bit。

传给 Pack 的最大 packed 容量需要把数组字节数乘 8;真正发送的字节数由 packed bit 数向上取整。传给 Unpack 的源长度必须是实际收到的 bit 数,而不是接收数组的总容量。

最小闭环需要哪些对象

对象 作用
EdsLib_DatabaseObject_t 已生成数据库的总入口
EdsLib_Id_t 数据库内部某个类型的标识
生成的 C 类型 native object 的静态表示
uint8_t[] packed object 的存储缓冲区

EdsLib_Id_t 是 EDS 数据库对象 ID,不是空间包 APID,也不是 PUS 服务号。它通常由“包索引 + 类型索引”组成,具体宏由生成结果给出。

数据库对象和类型 ID 如何配合

PUS_DATABASE 是运行时数据库总入口,EdsLib_Id_t 只在这个数据库的上下文里有意义。可以把它类比成“文件系统 + inode 编号”:单独拿一个整数无法知道类型,必须连同数据库一起查询。

这也是上层程序不应把生成索引写进持久协议的原因。Type ID 解决的是本机定位,不在线上传输;线上识别依赖 CCSDS/PUS 头中的字段,或者由外部路由表结合 APID、Service Type 和 Message Subtype 选择类型。

类型 ID 的两种取得方式

生成代码常提供可编译期使用的宏:

1
2
3
EdsLib_Id_t type_id = EDSLIB_MAKE_ID(
PUS_INDEX_ST_17,
EdsContainer_PUS17_Tc17_1_DATADICTIONARY);

这种方式简单、无字符串查询开销,适合类型固定的目标程序。缺点是上层代码与生成索引耦合。

若链接了 DisplayDB,也可以按完整类型名查询:

1
2
EdsLib_Id_t type_id =
EdsLib_DisplayDB_LookupTypeName(&PUS_DATABASE, "PUS17/Tc17_1");

字符串查询适合命令行工具、测试框架和插件式应用,但需要保留名称数据库。固定业务路径可优先使用生成宏,通用工具则可优先名字查询。无论采用哪种方式,都必须检查查询结果是否有效。

不要在业务代码里复制宏展开后的数字

下面两种写法表面等价,维护性完全不同:

1
2
3
4
5
6
/* 可追踪到生成定义 */
EdsLib_Id_t id = EDSLIB_MAKE_ID(PUS_INDEX_ST_17,
EdsContainer_PUS17_Tc17_1_DATADICTIONARY);

/* 错误做法:脱离生成版本的魔法数字 */
EdsLib_Id_t id = 0x00120034;

第一种会随生成头更新并在编译期暴露名称变化;第二种在数据库索引变化后仍能编译,却可能悄悄指向另一类型。

名字查询失败应立即终止

EdsLib_DisplayDB_LookupTypeName() 需要 DisplayDB,并在找不到名称时返回无效 ID。测试程序应打印完整名称并终止,而不是退回某个默认类型。一个拼写错误若被默认为基类,后续 packed size 和约束都可能不同,最终错误会离真正原因很远。

固定长度对象的完整调用顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
PUS17_Tc17_1_t source;
PUS17_Tc17_1_t decoded;
EdsLib_DataTypeDB_TypeInfo_t info;
EdsLib_Id_t actual_id;
uint8_t packed[64];
int32_t status;

EdsLib_Initialize();

status = EdsLib_DataTypeDB_GetTypeInfo(
&PUS_DATABASE, type_id, &info);

status = EdsLib_DataTypeDB_InitializeNativeObject(
&PUS_DATABASE, type_id, &source);

/* 只填写任务允许变化的字段 */
source.TcSt17Base.PusTcPacket.PriHdr.AppId = 321;

actual_id = type_id;
status = EdsLib_DataTypeDB_PackCompleteObject(
&PUS_DATABASE,
&actual_id,
packed,
&source,
sizeof(packed) * 8U, /* 目标最大容量,单位 bit */
sizeof(source)); /* 源最大容量,单位 byte */

actual_id = type_id;
status = EdsLib_DataTypeDB_UnpackCompleteObject(
&PUS_DATABASE,
&actual_id,
&decoded,
packed,
sizeof(decoded), /* 目标最大容量,单位 byte */
info.Size.Bits); /* 源长度,单位 bit */

这段代码最值得记住的是两个方向的单位:打包目标用 bit、源用 byte;解包目标用 byte、源用 bit。把 sizeof(packed) 直接作为 packed bit 数传入,是很常见且危险的错误。

把示例拆成可检查的七步

第一步是全局初始化:

1
EdsLib_Initialize();

它会初始化各子系统,DataTypeDB 初始化还会建立错误控制算法需要的查找表。若漏掉这一步,普通字段可能看起来仍能编码,而 CRC 路径可能给出错误结果。进程启动后调用一次即可,不应每打一个包重复初始化。

第二步查询类型信息,并立即检查状态:

1
2
3
4
5
6
status = EdsLib_DataTypeDB_GetTypeInfo(&PUS_DATABASE, type_id, &info);
if (status != EDSLIB_SUCCESS)
{
fprintf(stderr, "GetTypeInfo failed: %" PRId32 "\n", status);
return EXIT_FAILURE;
}

第三步初始化对象。建议先把整个 native 缓冲区清零,再调用 EDS 初始化函数;清零负责消除未定义填充与可变字段残值,InitializeNativeObject 负责写入派生类型约束。两者作用不同:

1
2
3
memset(&source, 0, sizeof(source));
status = EdsLib_DataTypeDB_InitializeNativeObject(
&PUS_DATABASE, type_id, &source);

第四步只填写任务可变字段,例如 APID、序列号和 Source ID。Service Type 与 Message Subtype 已属于所选类型,不应再由业务代码反复硬编码。

第五步打包。actual_id 先设为输入类型,调用后检查它是否仍是预期具体类型。第六步只打印或发送 (info.Size.Bits + 7U) / 8U 个字节。第七步解包到单独对象,并逐字段验证,而不是拿整个 native struct 做 memcmp,因为结构体填充字节不属于数据契约。

为什么 source 和 decoded 要分开

若在同一对象上原地解包,初始字段可能掩盖未写入或未解码的问题。把输出对象独立清零,可以证明每个被检查字段确实来自 packed bytes。测试时还应在 Pack 后、Unpack 前保留 packed 缓冲区,便于打印和故障注入。

InitializeNativeObject() 为什么不能用 memset 代替

清零只能得到全 0 内存。初始化函数会根据数据库类型和约束设置对象。以 PUS TC[17,1] 为例,类型可以固定:

1
2
3
4
5
6
Packet Version = 0
Packet Type = TC
Secondary Header Flag = 1
PUS Version = 2
Service = 17
Message Subtype = 1

应用只需填写 APID、序列计数、Source ID 等任务字段。若初始化后又随意覆盖服务号,实际上是在破坏所选派生类型的约束。

初始化不会替任务选择所有值

ValueConstraint 能初始化固定字段,但 APID、Sequence Count、Source ID 和时间等通常是任务运行值。若 XML 没把它们约束成常量,Initialize 不会猜一个“正确值”。应用必须明确设置,并在发送前检查取值范围。

对于容器中的普通字段,未初始化内存会带来不可重复线包。测试代码应让每个字节来源明确:要么被清零,要么由约束初始化,要么由应用填写,要么由 Complete Pack 作为特殊字段计算。

初始化与解包不是一回事

Initialize 用于新建 native object;Unpack 用于从 packed bytes 恢复 object。接收路径不需要先把服务号写成预期值再解包,否则可能掩盖错误。接收对象只需清零并交给 Complete Unpack,由输入字节与约束共同决定是否成功。

GetTypeInfo() 应该怎样使用

EdsLib_DataTypeDB_GetTypeInfo() 返回类型的 native 和 packed 尺寸信息。对于固定长度对象,发送字节数通常由 packed bit 数向上取整:

1
size_t packed_bytes = (info.Size.Bits + 7U) / 8U;

不要用 sizeof(source) 代替。缓冲区容量可以比对象大,但真正发送和比较的长度应是类型的 packed 长度,而不是整个缓冲区大小。

可变长度对象应使用对应的 VarSize 或分阶段接口,不能生搬固定长度公式。第一阶段先选固定长度消息,可以减少很多干扰。

容量和有效长度必须同时记录

uint8_t packed[64] 只说明最多容纳 64 字节,不说明当前对象就是 64 字节。建议同时保留:

1
2
3
const size_t capacity_bytes = sizeof(packed);
const uint32_t valid_bits = info.Size.Bits;
const size_t valid_bytes = (valid_bits + 7U) / 8U;

Pack 使用 capacity_bytes * 8U 做上界,日志和发送使用 valid_bytes。若线格式不是整字节对齐,最后一个字节的无效 bit 也要按库与协议规则处理,不能简单假设所有类型都是 8 的倍数。

接收时应使用 recvfrom() 返回的真实字节数计算源 bit 数。把整个 2048 字节接收数组传给 Unpack,相当于告诉运行库后面大量未接收数据也属于消息,会破坏长度验证。

“Complete” 到底完成了什么

PackCompleteObject() 不只是逐字段复制。根据数据库定义,它还会处理:

  • 标量位宽与大小端转换;
  • 位字段在 packed bitstream 中的排列;
  • 派生类型和固定值约束;
  • 长度等特殊字段;
  • Error Control 字段,例如 CRC;
  • 最终对象的一致性处理。

分阶段 Pack 接口允许调用者在中间插入处理,但此时需要显式 Finalize。完整打包已经自动执行最终化,普通固定消息不要再手工重复算长度或 PEC。

UnpackCompleteObject() 也不只是还原字段。它会在完整数据已知后验证固定值、长度和 Error Control。若只调用 partial unpack 而没有最终验证,坏包可能被还原成结构体,却没有真正通过完整性检查。

Complete API 的处理顺序为什么重要

长度值必须在 CRC 之前确定,因为 PEC 覆盖的字节中包含长度字段。Complete Pack 的逻辑可以概括为:先编码普通字段和识别派生类型,再更新固定值和长度,最后计算 ErrorControl。调用者不应在 Complete Pack 之后手工修改长度或正文;任何修改都会让原 PEC 失效。

接收方向则相反:先将 packed 数据解码,再依据完整对象重新计算特殊字段并与输入比较。只有状态成功,才能把 decoded object 当作已经通过数据契约验证的对象。

Partial API 适合什么场景

Partial/VarSize 接口用于可变长度对象、分阶段编码或应用需要插入自定义处理的情况。代价是调用者必须正确维护 processed size,并显式执行 Finalize 或 Verify。固定长度入门示例使用 Complete API,可以把特殊字段处理交给库,减少遗漏。

不要因为 Partial 看起来“更灵活”就默认使用。灵活意味着更多状态和更弱的默认保证,只有需求明确时才值得增加复杂度。

actual_id 为什么是指针

Pack/Unpack 接口接收 EdsLib_Id_t *,因为以基类型进入时,运行库可能依据约束识别更具体的派生类型,并把实际类型写回。即使当前代码总是用具体类型,也应把输入 ID 复制到可写变量,而不是假设它永远不会变化。

这也解释了为什么类型约束不是注释:它们参与运行时类型识别。

从基类进入和从具体类型进入的区别

若调用者只知道收到的是某个公共 PUS TC 基类,可以让运行库依据 Packet Type、Service Type、Message Subtype 等约束识别派生类型。成功后 actual_id 指向更具体消息,应用再决定怎样分发。

若调用者已经明确等待 TC[17,1],直接传具体 Type ID 更简单,packed size 也确定。当前最小测试选择这种固定类型方式,是为了隔离编解码本身;未来一个端口接收多种服务时,才需要“先解析公共头—查表或识别派生类型—再分发”的路由层。

无论哪种路径,调用后都应验证 actual_id。状态成功只说明某个合法类型完成了处理,测试还要确认它正是预期消息。

三类数据库在代码中的分工

DataTypeDB:编解码必需主线

  • EdsLib_DataTypeDB_GetTypeInfo():取得尺寸和类型信息;
  • EdsLib_DataTypeDB_InitializeNativeObject():按类型约束初始化对象;
  • EdsLib_DataTypeDB_PackCompleteObject():native 转 packed;
  • EdsLib_DataTypeDB_UnpackCompleteObject():packed 转 native 并验证。

DisplayDB:通用工具的名字层

  • EdsLib_DisplayDB_LookupTypeName():名字查类型 ID;
  • EdsLib_DisplayDB_LocateSubEntity():按成员路径定位字段;
  • EdsLib_Scalar_FromString():字符串写入标量;
  • EdsLib_Scalar_ToString():标量转可读字符串;
  • EdsLib_DisplayDB_IterateAllEntities():遍历并打印成员。

一个通用命令发送工具可以接收 PriHdr.AppId=321,通过 LocateSubEntity 找到 native 对象中的偏移,再用 FromString 写入,而不必为每种消息写一套赋值代码。

IntfDB:接口关系层

IntfDB 记录组件、接口、命令和参数类型关系。它适合“按接口与命令动态发现参数”这类需求,不是基本 Pack/Unpack 的必要条件,也不会自动实现网络收发或业务动作。

一个通用字段赋值工具怎样组合这些接口

若命令行输入 PriHdr.AppId=321,程序可以:

  1. 用 DisplayDB 按完整类型名取得 Type ID;
  2. LocateSubEntity() 定位成员的类型和 native 偏移;
  3. Scalar_FromString() 把字符串 321 转为正确标量;
  4. 将值写入 native object 对应位置;
  5. 最后仍由 DataTypeDB 完成 Pack。

这个流程说明 DisplayDB 提供“名字层”,DataTypeDB 提供“数据语义层”。按名字设置字段不是绕开生成类型,而是通过元数据操作同一 native object。固定业务代码可直接访问成员,通用测试工具才需要这层动态性。

错误处理不能只看最后一个返回值

每个阶段都应立即检查状态:

1
2
3
4
5
if (status != EDSLIB_SUCCESS)
{
fprintf(stderr, "pack failed: %" PRId32 "\n", status);
return EXIT_FAILURE;
}

至少要区分:类型 ID 无效、缓冲区容量不足、对象约束不满足、输入长度不符、PEC/CRC 校验失败。测试工具还应打印类型名、预期 bit 数、实收 byte 数和原始十六进制,否则定位错误时只能看到一个抽象错误码。

一条失败日志至少包含什么

建议统一输出:

1
2
3
4
5
6
7
stage=unpack
requested_type=PUS17/Tc17_3
actual_type=<id or name>
expected_bits=120
received_bytes=15
status=<EdsLib status>
packet=19 41 ...

这样能快速判断是网络少收、类型选错还是完整性校验失败。若日志只有 unpack failed,之后几乎必然要重新复现。

不要继续使用失败阶段的输出

GetTypeInfo 失败后,info 未必有效;Pack 失败后,packed 缓冲区不能发送;Unpack 失败后,decoded 字段不能用于业务判断。每个阶段都应立即返回或进入明确错误路径,不能只在函数末尾统一检查最后一次 status。

已知向量比“往返成功”更有价值

只做下面的测试存在共同错误无法暴露的问题:

1
同一数据库打包 A → 同一数据库解包 A → 字段相等

如果 XML 把字段宽度写错,Pack 和 Unpack 可能仍然互相同意。更强的验证应包含:

  1. 手工可解释或由独立实现产生的 golden vector;
  2. EdsLib 打包结果与 golden vector 逐字节相同;
  3. EdsLib 能解独立实现产生的包;
  4. 独立实现能解 EdsLib 产生的包;
  5. 单 bit 篡改固定字段或 PEC 后,完整解包失败。

后续文章引入 puslib,正是为了解决“同一套模型自己验证自己”的局限。

测试证据可以按强度分层

从弱到强大致是:

  1. XML 能生成;
  2. C 程序能链接数据库;
  3. 同一类型能 Pack 后再 Unpack;
  4. 关键字段和 packed 长度符合手算;
  5. packed bytes 与已知向量逐字节一致;
  6. 独立实现可以解析 EdsLib 输出;
  7. EdsLib 可以解析独立实现输出;
  8. 对固定值、长度和 PEC 的单点破坏会被拒绝。

前一层通过并不蕴含后一层。写测试报告时应写出达到哪一层,而不是笼统称为“EDS 验证通过”。

golden vector 必须绑定输入条件

完整字节向量只有在 APID、序列计数、Source/Destination ID、时间、可选字段和 CRC 参数都固定时才稳定。向量旁边若没有这些条件,后来更换时间字段后得到不同 TM,无法判断是正确变化还是回归。

一个实用的负向测试矩阵

改动 预期结果 能发现什么
改 Service Type 类型约束失败 派生类型约束是否生效
改 Packet Data Length 长度验证失败 长度字段是否自动计算/校验
改业务字段但不重算 PEC CRC 失败 Error Control 是否覆盖正文
截断最后一个字节 解包失败 输入长度边界是否检查
在包尾追加字节 按接口/类型策略拒绝 调用者是否传入真实长度

负向测试应一次只破坏一个条件,这样失败原因才可解释。

CRC 通过不代表消息语义正确

如果篡改 Service Type 后重新计算合法 PEC,CRC 会通过,但类型约束应该失败;如果把合法 TC[17,3] 的进程 ID 改成一个未登记值并重算 PEC,解包应该成功,服务行为则应拒绝。这两个例子分别位于“类型验证”和“业务验证”层。

因此负向测试要区分:

  • 线包损坏:长度、CRC 或编码不一致;
  • 类型不匹配:固定字段不满足预期派生类型;
  • 业务无效:包结构合法,但参数在当前运行状态下不可接受。

EdsLib 主要负责前两类,第三类由服务代码负责。

什么时候直接访问成员,什么时候按名字访问

固定业务代码推荐生成类型直接访问:编译器能检查成员名和类型,代码也最短。按名字访问更适合:

  • CLI 中由用户动态指定字段;
  • 通用报文查看器;
  • 不知道具体派生类型的调试工具;
  • 自动遍历与日志输出。

不要为了“动态”而让所有目标业务代码依赖字符串,也不要为了追求最小运行库而给每种调试消息手写打印函数。两种方式面向不同场景。

最小验证清单

一次固定消息测试至少记录:

  • 类型完整名称和 EdsLib_Id_t
  • native bytes 与 packed bits;
  • 初始化后的固定字段;
  • 应用填写的任务字段;
  • 完整 packed hex;
  • Pack/Unpack 状态;
  • 解包后的关键字段;
  • 一组 CRC 或约束破坏测试。

图片预留:截取一次“类型信息、十六进制包、解包字段、坏包被拒绝”的终端输出,保存为 eds-pus-02-edslib-pack-unpack/pack-unpack-log.png

一次完整运行应该怎样解释

如果日志显示 TC[17,1] packed size 为 104 bit,即 13 byte,不能只说“长度正确”。还应逐层解释:

  • 6 byte 来自 CCSDS 主头;
  • 5 byte 来自当前任务的 PUS TC 次头;
  • TC[17,1] 没有 application data;
  • 2 byte 来自 PEC;
  • Packet Data Length 应编码为 13 - 7 = 6
  • Service/Message Subtype 应为 17/1;
  • 改动 APID 或 Source ID 后,正文相应字节和 PEC 会变化。

这种解释把类型尺寸与协议结构联系起来。若程序输出 13 byte 却无法说明每一段来源,仍然可能是在错误模型上“自洽”。

本章与后续验证的接口

完成本章后,可以把编解码封装成两个清晰边界:

1
2
3
4
5
6
7
8
9
10
11
int pack_message(EdsLib_Id_t type_id,
const void *native_object,
uint8_t *packet,
size_t packet_capacity,
size_t *packet_size);

int unpack_message(EdsLib_Id_t expected_type,
const uint8_t *packet,
size_t packet_size,
void *native_object,
size_t native_capacity);

传输层只接触 packet + packet_size,服务层只接触通过验证的 native object。后面加入 UDP 时不修改 EDS 编码逻辑,加入 puslib 时也只更换线包另一端的实现。

小结

EdsLib 的最小主线可以浓缩成:类型 ID 定位数据契约,Initialize 建立满足约束的 native object,GetTypeInfo 给出真实尺寸,Complete Pack/Unpack 完成转换和最终验证。理解这条主线后,再学习可变长度、Partial、Binding Object 和 IntfDB 会更自然。

下一篇将把 packed object 放进真实协议语境:先拆解 CCSDS 空间包主头,再看 PUS-C 次头与 PUS 服务类型 17 的四种消息,避免把 EDS 类型 ID、APID、服务号和应用进程 ID 混在一起。

参考资料

  • NASA EdsLib
  • EdsLib 源码中的 edslib_datatypedb.hedslib_displaydb.h

EDS与PUS实践(二):用EdsLib完成对象打包与解包

https://goko-son626.github.io/post/eds-pus-02-edslib-pack-unpack.html

作者

GoKo Mell

发布于

2026-08-02

更新于

2026-09-20

许可协议

评论

:D 一言句子获取中...