写在开篇·蓉儿继续挖坑
上回说到,郭靖搞清楚了Topic是数据“主题”,架构师在Excel里定好名字、类型,工具生成代码,工程师填业务逻辑。
郭靖合上笔记本,若有所思:“蓉儿,我大概知道Topic是什么了。但我有个疑问——Topic里的数据结构(比如刹车指令),是怎么变成RTPS报文中那一串字节的?还有,writerId和writerSN这两个家伙,到底是谁定义的?”
黄蓉咬了口糖葫芦:“问得好!这就是序列化要解决的问题。今天就把数据怎么放入数据帧讲透——从内存里的结构体,到RTPS报文里的字节流,中间经历了什么。顺便把writerId和writerSN的来历讲清楚。”
一、问题:数据在内存里和网络上的形态不一样
黄蓉在白板上画了一个简单的对比:
┌─────────────────────────────────────────────────────────────────────┐ │ 数据在内存里 vs 数据在网络上的形态 │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 内存里(发布者): │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ struct BrakeCommand { │ │ │ │ uint16_t pressure = 500; // 内存地址0x1000: 0x01F4 │ │ │ │ uint8_t is_emergency = 1; // 内存地址0x1002: 0x01 │ │ │ │ uint32_t timestamp = 1700000000; // 内存地址0x1004: ... │ │ │ │ } │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ │ 序列化(Serialization) │ │ ▼ │ │ 网络上(RTPS报文): │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ [DATA子消息头][writerId][writerSN][...][序列化后的数据] │ │ │ │ │ │ │ │ │ ▼ │ │ │ │ 01 F4 01 65 5B 5B 00 │ │ │ │ └pressure┘└is_emergency┘└timestamp─┘│ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘郭靖:“内存里是结构体,网络上是字节流。这两者之间怎么转换的?”
黄蓉:“序列化(Serialization)——把内存中的结构体,按约定规则转换成字节流。接收方再做反序列化(Deserialization),把字节流还原成结构体。”
二、一个简单的例子:刹车指令Topic
黄蓉用刹车指令来举例,因为数据类型简单,容易理解。
Topic定义:
Topic名称:/vehicle/brake/cmd 数据类型:BrakeCommand BrakeCommand结构: ├── pressure(刹车压力):uint16(0-1000,对应0%-100%) ├── is_emergency(是否紧急刹车):uint8(0/1) └── timestamp(时间戳):uint32
发布者内存中的数据:
pressure = 500 (50%刹车) is_emergency = 1 (紧急刹车) timestamp = 1700000000
三、序列化:把结构体变成字节流
黄蓉画了序列化的过程:
┌─────────────────────────────────────────────────────────────────────┐ │ 序列化过程(以刹车指令为例) │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 步骤1:内存中的结构体 │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ pressure = 500 (0x01F4) │ │ │ │ is_emergency= 1 (0x01) │ │ │ │ timestamp = 1700000000 (0x655B5B00) │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 步骤2:按字段顺序排列(CDR规范) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ [pressure 2字节][is_emergency 1字节][timestamp 4字节] │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 步骤3:根据字节序转换(大端/小端) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 采用大端(网络字节序): │ │ │ │ pressure → 0x01 0xF4 │ │ │ │ is_emergency→ 0x01 │ │ │ │ timestamp → 0x65 0x5B 0x5B 0x00 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 步骤4:得到最终的字节流 │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 01 F4 01 65 5B 5B 00 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘
郭靖恍然大悟:“哦~~原来结构体里的字段是按顺序一个接一个排进字节流里的!”
四、writerId和writerSN:谁定义的?怎么来的?
郭靖指着报文:“蓉儿,你之前说的writerId和writerSN,这两个家伙到底是谁定义的?工程师能自己指定吗?”
1. writerId(写入者ID)
| 属性 | 说明 |
|---|---|
| 长度 | 4字节 |
| 谁分配的 | DDS协议栈在发现阶段自动分配,不是人工指定的 |
| 唯一性 | 同一个DomainParticipant内,每个Writer有唯一的writerId |
| 作用 | 接收方根据writerId知道“这条数据是哪个发布者发的” |
| 工程师能改吗 | 不能。99%的情况下不需要关心 |
发现阶段的分配流程:
摄像头ECU(发布者) 域控制器(订阅者) │ │ │ ① Participant发现(互相打招呼) │ │<─────────────────────────────────────────>│ │ │ │ ② Writer发现(摄像头告诉域控:我要发数据) │ │ “我创建了一个Writer,我的writerId=0x0001” │ │──────────────────────────────────────────>│ │ │ │ ③ Reader发现(域控告诉摄像头:我要收数据) │ │ “我创建了一个Reader,我匹配你的writerId” │ │<──────────────────────────────────────────│
2. writerSN(写入者序列号)
| 属性 | 说明 |
|---|---|
| 长度 | 8字节 |
| 谁维护的 | 每个Writer自己维护,每发一个样本+1 |
| 初始值 | 通常从1开始 |
| 作用 | 接收方检测丢包(跳号了就知道丢了)、去重(重复的丢弃)、可靠传输时请求重传 |
| 工程师能改吗 | 不能。DDS自动维护 |
序列号的使用场景:
写入者(摄像头) 读取者(域控) │ │ │ Data(SN=1) │ │──────────────────────────────────────────>│ 收到,记下SN=1 │ Data(SN=2) │ │──────────────────────────────────────────>│ 收到,记下SN=2 │ Data(SN=3) │ │──────────────────────────────────────────>│ 收到,记下SN=3 │ Data(SN=5) ← 跳过了4! │ │──────────────────────────────────────────>│ 检测到丢包! │ │ │ AckNack(“我缺SN=4”) │ │<──────────────────────────────────────────│ │ │ │ Data(SN=4) ← 重传 │ │──────────────────────────────────────────>│
小结:
| 对比 | writerId | writerSN |
|---|---|---|
| 谁定的 | DDS发现阶段自动分配 | Writer自己维护,从1开始递增 |
| 工程师能改吗 | 不能 | 不能 |
| 有什么用 | 接收方识别数据来源 | 检测丢包、去重、重传 |
| 从哪里看到 | Wireshark抓包 | Wireshark抓包 |
| 需要关心吗 | 99%不用,除非深度调试 | 99%不用,除非排查丢包 |
五、完整的RTPS DATA子消息报文拆解
下面是一个完整的RTPS DATA子消息报文(十六进制),逐字段拆解:
52 54 50 53 02 02 01 00 11 22 33 44 55 66 77 88 99 AA BB CC 15 03 00 30 00 00 01 00 00 00 02 00 00 00 00 00 00 00 01 00 00 00 00 01 F4 01 65 5B 5B 00 00 00 00 00
第一部分:RTPS消息头(24字节)
| 字节偏移 | 字段 | 值 | 长度 | 定义和作用 |
|---|---|---|---|---|
| 0-3 | RTPS标识 | 52 54 50 53 | 4字节 | 固定值'R' 'T' 'P' 'S'。Wireshark靠这4个字节识别这是RTPS报文 |
| 4 | Protocol Version (major) | 02 | 1字节 | 主版本号=2 |
| 5 | Protocol Version (minor) | 02 | 1字节 | 次版本号=2 |
| 6-7 | Vendor ID | 01 00 | 2字节 | 供应商标识。0x0100=RTI(常用DDS供应商) |
| 8-19 | GUID Prefix | 11 22 33 44 55 66 77 88 99 AA BB CC | 12字节 | 全局唯一参与者标识,区分不同的DDS应用 |
第二部分:DATA子消息头(4字节)
| 字节偏移 | 字段 | 值 | 长度 | 定义和作用 |
|---|---|---|---|---|
| 20 | Submessage ID | 15 | 1字节 | 子消息类型=DATA(0x15),告诉解析器“后面是数据” |
| 21 | Flags | 03 | 1字节 | 标志位:bit0=1(大端),bit1=1(内联QoS) |
| 22-23 | Submessage Length | 00 30 | 2字节 | 子消息长度=48字节(不含头部) |
第三部分:DATA子消息体
| 字节偏移 | 字段 | 值 | 长度 | 定义和作用 |
|---|---|---|---|---|
| 24-27 | readerId | 00 00 01 00 | 4字节 | 读取者ID。匹配的Reader的实体ID,告诉数据发给谁 |
| 28-31 | writerId | 00 00 02 00 | 4字节 | 写入者ID。标识是哪个发布者在发数据,DDS发现阶段自动分配 |
| 32-39 | writerSN | 00 00 00 00 00 00 00 01 | 8字节 | 写入者序列号。这是该Writer发送的第1个样本,自动递增 |
| 40-41 | inlineQoS | 00 00 | 2字节 | 内联QoS(本例中无额外QoS) |
| 42-43 | 表示标识符 | 00 00 | 2字节 | PL_CDR_BE,表示后面是CDR序列化数据,大端字节序 |
| 44-45 | 表示选项 | 00 00 | 2字节 | 选项标志,0x0000表示无额外选项 |
| 46-52 | serializedPayload | 01 F4 01 65 5B 5B 00 | 7字节 | 序列化后的刹车指令数据! |
├──01 F4 | 2字节 | pressure=500(50%刹车) | ||
├──01 | 1字节 | is_emergency=1(紧急刹车) | ||
└──65 5B 5B 00 | 4字节 | timestamp=1700000000 | ||
| 53-56 | 填充 | 00 00 00 00 | 4字节 | 对齐填充,保证下一个子消息从4字节边界开始 |
关键字段总结
| 字段 | 一句话作用 | 工程师要不要管 |
|---|---|---|
| RTPS标识 | 52 54 50 53,告诉网卡“我是DDS” | ❌ 不用 |
| GUID Prefix | 区分不同的DDS应用 | ❌ 不用 |
| writerId | 标识“谁发的” | ❌DDS自动分配,不用管 |
| writerSN | 标识“第几个”,用于丢包检测 | ❌DDS自动维护,不用管 |
| readerId | 标识“发给谁” | ❌ DDS自动匹配 |
| serializedPayload | 你的数据! | ✅这就是你填的刹车指令 |
六、反序列化:接收方怎么还原数据
黄蓉画了接收方的过程(和序列化相反):
┌─────────────────────────────────────────────────────────────────────┐ │ 反序列化过程(接收方) │ ├─────────────────────────────────────────────────────────────────────┤ │ │ │ 步骤1:从RTPS报文中提取serializedPayload │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ 01 F4 01 65 5B 5B 00 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 步骤2:按字段顺序解析(CDR规范) │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ [0-1字节] pressure = 0x01F4 = 500 │ │ │ │ [2字节] is_emergency= 0x01 = 1(紧急) │ │ │ │ [3-6字节] timestamp = 0x655B5B00 = 1700000000 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ▼ │ │ 步骤3:填入内存中的结构体 │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ BrakeCommand cmd; │ │ │ │ cmd.pressure = 500; // 50%刹车 │ │ │ │ cmd.is_emergency = 1; // 紧急刹车 │ │ │ │ cmd.timestamp = 1700000000; │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────┘
七、CDR规范:序列化的“交通规则”
郭靖问:“那序列化有没有统一的规则?万一发布者用大端,订阅者用小端,不就乱套了?”
黄蓉:“这就是CDR(Common Data Representation)规范的作用。”
| CDR规则 | 说明 |
|---|---|
| 字节序 | 可配置(大端/小端),在RTPS头部标志位标明 |
| 基本类型长度 | uint16=2字节,uint32=4字节,uint64=8字节 |
| 字符串编码 | 先4字节长度,后面跟UTF-8字符 |
| 数组对齐 | 基本类型按自身长度对齐,结构体按最大成员对齐 |
八、工程师真的需要关心这些吗
郭靖问出了最关键的问题:“蓉儿,我实际写代码的时候,需要自己写序列化代码吗?需要自己定义writerId吗?”
黄蓉摇头:
不需要。DDS代码生成器会帮你生成序列化和反序列化代码。writerId和writerSN由DDS协议栈自动分配和维护。
| 工程师做 | 工具/DDS做 |
|---|---|
| 定义Topic和数据类型(IDL或设计文档) | 生成序列化/反序列化代码 |
| 填业务逻辑 | 生成发布/订阅框架 |
| 调用publish() | 负责把结构体变成字节流 |
| 实现回调函数 | 负责把字节流还原成结构体 |
| — | 自动分配writerId |
| — | 自动维护writerSN |
郭靖松了口气:“那就好!我还以为要自己算每个字段占几个字节、大端小端、还要自己维护序列号……”
黄蓉笑了:“那是DDS协议栈的事,不是你的事。你只管填数据,DDS帮你打包。writerId和writerSN是系统自动管的,你抓包能看到,但写代码时不用操心。”
九、黄蓉的小本本
郭靖翻开她的笔记本,上面写着:
数据放入数据帧的完整流程:
1. 定义数据结构(架构师在设计文档里定好)
└── 刹车指令:pressure + is_emergency + timestamp2. 工具生成序列化代码
└── 把结构体按CDR规范转成字节流3. 发布者填数据
└──cmd.pressure = 500; cmd.is_emergency = 1;4. DDS协议栈序列化
└──500→0x01F4,1→0x01,时间戳 →0x655B5B005. 装进RTPS DATA子消息
└── 加上writerId(自动分配)、writerSN(自动递增)等,塞进serializedPayload6. 接收方反序列化
└── 字节流 → 结构体,回调函数收到数据writerId和writerSN:
writerId:DDS发现阶段自动分配,标识数据来源
writerSN:Writer自己维护,每发一个+1,用于丢包检测
工程师不用管,DDS自动搞定
一句话:工程师填结构体,DDS帮你序列化。你不用操心字节怎么排,也不用管writerId/writerSN。
写在最后
郭靖合上笔记本:“原来数据放入数据帧,中间有序列化这一步。writerId是DDS自动分配的,writerSN是自动递增的,工程师都不用管。结构体里的字段按顺序排成字节流,装进RTPS DATA子消息的serializedPayload里。接收方再反序列化还原。”
黄蓉咬了口糖葫芦:“全明白了?”
郭靖点头:“明白了。我不需要关心字节怎么排,也不需要关心writerId/writerSN,只需要关心数据本身。”
黄蓉眨眨眼:“那你知道谁负责发布、谁负责订阅吗?怎么让DDS知道‘这个Topic我要发’、‘那个Topic我要收’?”
郭靖摇头。
“下篇预告:一收多发轻松办,发布订阅各司职——Publisher和Subscriber。”
打完收工,886。