DDS 通信技术手册
本文面向在容器与 Kubernetes 环境中部署、运维和排障 DDS 及 UDP 组播通信的工程人员,按"原理---部署---排查---诊断程序"四部分组织。
| 术语 | 含义 |
|---|---|
| 标准 DDS | OMG Data Distribution Service 规范及其实现,基于 DCPS 发布/订阅模型与 RTPS 线协议 |
| 本工程 | 目录 dds测试程序 中的 venus/dds,基于 UDP 组播的 send/receive 程序,不属于标准 DDS 实现 |
| 组播 | 向组地址发送、由已加入该组的主机接收的传输方式 |
约定:本文对"标准 DDS"与"本工程"分别给出原理与排查方法;两者通信模型不同,排查手段不可混用。
目录
第一部分 基础与模型
-
通信模型与组播成立条件
-
DDS 模型辨析
-
部署环境对组播的影响
第二部分 部署
-
运行方式
-
多网卡绑定
-
Kubernetes 部署
第三部分 故障排查
-
排查方法
-
通用排查手段
第四部分 诊断程序
-
检查点设计
-
诊断程序源码
-
按场景排查
附录
-
附录 A 常见问题
-
附录 B 术语表
-
附录 C 命令速查
第一部分 基础与模型
1. 通信模型与组播成立条件
1.1 传播方式
| 方式 | 目标 | IPV4 地址 | 约束 |
|---|---|---|---|
| 单播 | 单一目的主机 | 主机地址(如 192.168.1.20) | 依赖路由可达 |
| 广播 | 本地网段全部主机 | 255.255.255.255 | 不跨网段 |
| 组播 | 已加入组的主机 | 组地址(如 239.0.0.1) | 依赖二层可达与 IGMP |
1.2 组播地址空间
| 范围 | 用途 | 跨网段转发 |
|---|---|---|
| 224.0.0.0 -- 224.0.0.255 | 链路本地控制 | 否(TTL 恒为 1) |
| 224.0.1.0 -- 238.255.255.255 | 可路由组播 | 需组播路由配置 |
| 239.0.0.0 -- 239.255.255.255 | 管理范围组播(本工程使用 239.0.0.1) | 默认否 |
1.3 组播成立条件
组播通信的建立与维持依赖以下五项条件,任一项不满足即导致收发不可达。
| 条件 | 定义 | 不满足的后果 |
|---|---|---|
| 加入组 | 接收方通过 IGMP 声明加入组地址 | 完全不接收 |
| 出接口 | 发送方指定报文离开的网卡 | 多网卡环境中发往错误网卡 |
| TTL | 报文可跨越的跳数,默认 1 | 跨网段时被丢弃 |
| 回环 | 允许本机接收自身发送的组播 | 同机自测不可达 |
| 二层可达 | 收发双方处于同一广播域 | 跨交换机或跨容器网络时不可达 |
1.4 分层定位
| 层 | 观测对象 | 手段 |
|---|---|---|
| 应用层 | 进程、地址、端口、报文内容 | 进程日志 |
| 传输层 | UDP、端口、接收缓冲 | ss / netstat |
| 网络层 | 组地址、出接口、TTL、路由 | ip route get、tcpdump |
| 链路层 | 网卡、网桥、VLAN、IGMP snooping | ip maddr、交换机配置 |
2. DDS 模型辨析
2.1 标准 DDS(OMG)
DDS 是以数据为中心的发布/订阅中间件规范,规定行为与线协议 RTPS,不规定实现。实现负责 API、QoS 语义与协议编解码。
2.1.1 主流实现
| 实现 | 语言 / 许可 | 典型场景 | ROS 2 RMW |
|---|---|---|---|
| eProsima Fast DDS | C++ / Apache-2.0 | 机器人、自动驾驶、ROS 2 默认 | rmw_fastrtps_cpp |
| Eclipse Cyclone DDS | C / EPL | 机器人、汽车、嵌入式 | rmw_cyclonedds_cpp |
| OpenDDS | C++ / OCI | 军工、航空航天 | 非默认 |
商业实现:RTI Connext、ADLINK OpenSplice、Twin Oaks CoreDX、GurumNetworks GurumDDS。实现数量不受规范限制,各实现依同一 RTPS 线协议互通。
2.1.2 端口体系
端口由域号 d 与参与者号 p 推导,常量 PB=7400、DG=250、PG=2、d0..d3=0/10/1/11。
| 用途 | 公式 | 域 0 默认 | 重要性 |
|---|---|---|---|
| 组播·参与者发现(SPDP) | 7400 + 250d |
7400 | 最高:被阻断则无法发现对端 |
| 单播·发现(SPDP/SEDP) | 7401 + 250d + 2p |
7401 | 最高:端点发现与匹配 |
| 组播·用户数据 | 7410 + 250d |
7410 | 高 |
| 单播·用户数据 | 7411 + 250d + 2p |
7411 | 高 |
组播地址:发现 239.255.0.1+d,数据 239.255.1.1+d。防火墙至少放行 UDP 7400--7650。每个域占 250 个端口,单域参与者上限约 120。
2.1.3 协议栈
| 层 | 协议 / 机制 | 职责 |
|---|---|---|
| 数据模型 | IDL 类型 / Topic / QoS | 数据结构与服务质量定义 |
| API 模型 | DDS DCPS | 发布/订阅编程模型 |
| 线协议 | RTPS(DDSI-RTPS) | 发现与数据收发的报文格式 |
| 发现子协议 | SPDP(组播)/ SEDP(单播) | 参与者与端点发现 |
| 序列化 | CDR / XCDR | IDL 数据编码 |
| 传输 | UDP(单播/组播)、TCP、共享内存 | 报文通道 |
| 安全(可选) | DDS Security;TLS/DTLS | 认证、访问控制、加密 |
2.2 本工程实现
本工程为 venus/dds(镜像元数据 venus-plugin-edge-server),由 send 与 receive 两个 Go 程序构成,通过 UDP 组播 239.0.0.1:9999 收发固定文本,默认周期 2 秒。其不含 RTPS、DCPS 与 QoS,属应用层点对组播通信。
2.3 对照
| 维度 | 标准 DDS | 本工程 VENUS/DDS |
|---|---|---|
| 通信模型 | 发布/订阅中间件 | UDP 组播收发程序 |
| 端口 | 7400 系列,随域号变化 | 固定 9999 |
| 协议 | RTPS、SPDP/SEDP、CDR | 仅 UDP |
| 地址 | 239.255.0.1+d |
固定 239.0.0.1 |
| 发现与可靠性 | 自动发现、QoS 可配 | 无发现机制、尽力而为 |
2.4 分层架构
标准 DDS 自下而上分为四层,右侧为横切能力。组播用于"发现"阶段;发现完成后数据默认经单播传输;发布端与订阅端能否通信由 QoS 匹配决定。本工程仅对应"应用层 + UDP 组播",不具备 RTPS 与 DCPS 层。

图 2-1 DDS 分层架构
2.5 通信时序
从端点创建到数据交付包含五个阶段:端点创建与入组、SPDP 组播发现、SEDP 单播匹配、数据收发与交付、可靠传输确认。

图 2-2 DDS 通信时序
-
订阅端创建 DataReader;其 RTPS 层加入组播组(IGMP)。
-
发布端创建 DataWriter,向组播地址发送 SPDP 宣告;订阅端据此发现参与者。
-
双方经 SEDP(单播)交换端点信息并匹配;仅当 Topic、类型与 QoS 相容时建立通信。
-
发布端调用
write,数据经 CDR 编码后由 DataWriter 发出,DataReader 接收并交付应用。 -
可靠 QoS 下,通过 HEARTBEAT / ACKNACK 完成确认与重传。
结论 :标准 DDS 采用"组播发现、单播数据";发现端口为
7400 + 250×域号。
3. 部署环境对组播的影响
3.1 Docker 网络模式与组播可达性
| 模式 | 组播可达性 | 说明 |
|---|---|---|
| host | 可达 | 容器共享宿主机网络命名空间 |
| bridge(默认) | 跨主机/到物理网不可达 | 私有二层叠加 NAT |
| macvlan / ipvlan(l2) | 可达 | 容器接入物理二层 |
| overlay(K8s 跨节点) | 取决于 CNI | 多数 CNI 不转发组播 |
3.2 bridge 模式下组播不可达的机理
现象:容器间组播在同宿主机同一网桥内可通,跨宿主机或跨至物理网络不可达。
机理:
-
NAT 仅转换单播地址与端口,不处理组播地址。
-
docker0与物理网卡分属不同二层域,Docker 默认不进行跨接口组播转发。 -
容器发出的 IGMP 加入报文止于
docker0,未告知物理交换机组播成员关系。 -
网桥组播 snooping 缺省无 querier 时成员关系超时;
bridge-nf-call-iptables亦可能丢弃桥接流量。
判据:同一宿主机同一网桥内可通、跨主机不可通。
处置:改用 host 网络或 macvlan/ipvlan(l2)。
3.3 容器接入二层
| 方案 | 配置要点 | 限制 |
|---|---|---|
--network host |
容器复用宿主机网络 | 占用宿主机端口,无网络隔离 |
| macvlan | 父网卡 + 独立 MAC,接入物理二层 | 与宿主机通信受限;父网卡需 promisc;部分云环境禁用 |
| ipvlan(l2) | 共享 MAC 接入二层 | l3/l3s 模式不支持组播 |
3.4 跨网段组播
现象:默认 TTL 为 1,组播报文在离开本网段时被丢弃。
成立条件(须同时满足):
-
应用将 TTL 设为大于 1;
-
沿途三层设备启用组播路由(PIM-SM / PIM-DM),各子网存在 IGMP querier,PIM-SM 另需 RP,并通过 RPF 检查;
-
网络策略放行该组地址。
约束:224.0.0.0/24 不转发;239.0.0.0/8 为管理范围,极少跨域。
判据:企业网、云与广域网络通常关闭组播路由,跨网段组播一般不可用;此类场景应改用单播。
3.5 关于"组播转单播"的澄清
网络层不会将组播自动转换为单播。实际出现"单播"的行为有以下三种来源:
| 来源 | 说明 |
|---|---|
| 协议设计 | 标准 DDS 以组播完成初始发现(SPDP),端点发现与数据默认走单播 |
| 中间件回退 | 组播被阻断时,改用单播 peer 列表、Discovery Server 或 RTPS over TCP |
| 设备行为 | 交换机未启用 snooping 时将组播按广播泛洪;或直接丢弃 |
本工程为固定地址的 UDP 组播,不具备自动切换单播的能力。
第二部分 部署
4. 运行方式
4.1 本地运行
在两个终端分别运行接收端与发送端,默认 239.0.0.1:9999,周期 2 秒:
go run receive.go
go run send.go
发送端输出"已发送",接收端输出"收到",即链路可用。
4.2 容器运行
docker load -i dds-amd64.tar
docker run --rm --network host --name dds-recv venus/dds-amd64:v1 /receive
docker run --rm --network host --name dds-send venus/dds-amd64:v1 /send
约束 :组播要求
--network host;Windows 版 Docker Desktop 默认不支持 host 网络,组播验证应在 Linux 主机进行。
5. 多网卡绑定
现象 :多网卡主机上收发不可达,或仅部分对端可达。机理:组播不进行自动选路,内核按路由表选定出接口;发送与接收若绑定不同网卡,则不在同一转发路径上。
判据:发送方出接口与接收方入组网卡必须为同一张、且与对端同网段的网卡。
5.1 网卡判定
ip -4 addr # 各网卡地址
ip route get 239.0.0.1 # 内核默认出接口(dev 字段)
Get-NetAdapter # Windows:Name 列为网卡名
5.2 绑定配置
| 参数 | 环境变量 | 默认 | 适用 |
|---|---|---|---|
-iface |
DDS_IFACE | (空,内核默认) | 收发 |
-mcast |
DDS_MCAST | 239.0.0.1 | 收发 |
-port |
DDS_PORT | 9999 | 收发 |
-interval |
DDS_INTERVAL_MS | 2000 | 发送端 |
-message |
DDS_MESSAGE | Hello, Multicast! | 发送端 |
-wait |
DDS_WAIT_SEC | 5 | 收发 |
-readbuf |
DDS_READBUF | 4194304 | 接收端 |
DDS_IFACE=eth1 ./receive
DDS_IFACE=eth1 ./send
5.3 操作系统层组播路由
不改动程序时,可通过路由将组播固定到指定网卡:
ip route add 239.0.0.0/8 dev eth1 # Linux
route add 239.0.0.0 mask 255.0.0.0 0.0.0.0 if <ifIndex> # Windows(管理员)
6. Kubernetes 部署
前提:Calico、Flannel、Cilium 等默认集群网络不转发组播,须使 Pod 使用物理网卡(hostNetwork)或接入物理二层(macvlan/ipvlan)。
6.1 通信过程
收发 Pod 分处两个节点时,数据依次经过:发布 Pod → 容器网络(CNI)→ 节点网卡 → 物理二层网络 → 对端节点网卡 → CNI → 订阅 Pod。组播能否跨越取决于 CNI 是否将其转发至物理网卡。

图 6-1 K8s 中部署 DDS 的容器通信过程
-
可达:
hostNetwork(Pod 复用节点网卡)或macvlan/ipvlan(l2)(Pod 接入物理二层)。 -
不可达:默认 CNI(bridge/overlay)不转发组播,报文止于容器网络。
-
跨节点前提:收发 Pod 所在节点处于同一二层(同 VLAN),且两端绑定同一网段网卡。
6.2 方案对比
| 方案 | 组播 | 关键配置 | 限制 |
|---|---|---|---|
hostNetwork: true |
可达 | dnsPolicy: ClusterFirstWithHostNet;DDS_IFACE 指定节点网卡 |
占用节点端口;同节点同端口冲突;无网络隔离 |
| macvlan | 可达 | Multus 二级网络;Pod 接口通常为 net1 |
与宿主机通信受限;父网卡需 promisc |
| ipvlan(l2) | 可达 | 同 macvlan | l3/l3s 不支持组播 |
| 单播改造 | 可达 | 发送端遍历 peer 列表点对点发送 | 需修改程序 |
6.3 hostNetwork 部署
apiVersion: apps/v1
kind: Deployment
metadata:
name: dds-recv
spec:
replicas: 1
selector: { matchLabels: { app: dds-recv } }
template:
metadata: { labels: { app: dds-recv } }
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: node-1
containers:
- name: dds
image: venus/dds-amd64:v1
command: ["/receive"]
env:
- { name: DDS_IFACE, value: "ens192" }
- { name: DDS_MCAST, value: "239.0.0.1" }
- { name: DDS_PORT, value: "9999" }
发送端将 command 改为 ["/send"],名称与标签相应调整。
6.4 macvlan / ipvlan 部署
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata: { name: dds-macvlan }
spec:
config: '{
"cniVersion": "0.3.1",
"type": "macvlan",
"master": "ens192",
"mode": "bridge",
"ipam": {
"type": "host-local",
"subnet": "192.168.2.0/24",
"rangeStart": "192.168.2.200",
"rangeEnd": "192.168.2.210",
"gateway": "192.168.2.1"
}
}'
metadata:
annotations:
k8s.v1.cni.cncf.io/networks: dds-macvlan
二级接口通常命名为 net1,据此设置 DDS_IFACE=net1。
6.5 约束与替代
-
收发 Pod 须处于同一二层,通过亲和性调度约束。
-
DDS_IFACE必须与 Pod 实际网卡名一致:hostNetwork 为节点网卡名,macvlan 为net1。 -
放行节点防火墙 UDP 9999;hostNetwork 下同节点同端口不可复用。
-
组播不可用时,将发送端改为遍历 peer 列表的单播发送。
第三部分 故障排查
7. 排查方法
7.1 分层定位模型
沿通信路径自下而上逐层排除,避免直接归因于程序逻辑。
| 层 | 待确认 | 手段 |
|---|---|---|
| 应用层 | 两侧进程运行、启动参数、日志状态 | docker ps、docker logs、ps |
| 主机/网卡 | 本机地址、默认出接口、组加入状态 | ip addr、ip route get、ip maddr |
| 抓包 | 报文是否发出、是否到达 | tcpdump、Wireshark、pktmon |
| 容器/中间网络 | 网络模式、跨主机链路、VLAN、防火墙 | docker inspect、交换机配置 |
| 程序 | 地址端口、网卡绑定、TTL、回环 | 见第四部分 |
7.2 断点判据
在发送主机抓包,将故障域一分为二:
-
抓取到组播报文 → 故障位于发送之后的网络或接收侧。
-
未抓取到报文 → 故障位于发送侧(网卡、路由或权限)。
7.3 现象与成因对应
| 现象 | 常见成因 | 下一步 |
|---|---|---|
| 发送端正常,接收端无输出 | 网络或接收侧问题 | 发送端抓包,检查接收端网卡与组 |
| 发送端无输出 | 进程未启动、命令错误、端口占用 | 检查日志、ps、端口 |
| 接收端报"加入组播失败" | 地址非法、权限不足、网卡不存在 | 核对地址、权限与网卡名 |
| 本机可达、跨主机不可达 | 非同一二层、TTL=1、交换机拦截 | 检查 VLAN、IGMP snooping |
| 容器化后不可达 | 使用 bridge 网络 | 改用 host 或 macvlan |
| 间歇性可达 | IGMP snooping 无 querier,成员关系超时 | 配置 querier 或关闭 snooping |
| 多网卡不可达 | 收发绑定网卡不一致 | 显式绑定(第 5 章) |
| Windows 发送正常、接收静默 | 防火墙拦截入站 UDP | 放行 UDP 9999 |
8. 通用排查手段
8.1 命令集
| 目的 | LINUX | WINDOWS |
|---|---|---|
| 查看地址 | ip -4 addr |
Get-NetIPAddress -AddressFamily IPv4 |
| 查看出接口 | ip route get 239.0.0.1 |
route print -4 |
| 查看组加入 | ip maddr show |
netsh interface ip show joins |
| 抓包 | tcpdump -ni any udp port 9999 -A |
pktmon / Wireshark |
| 放行防火墙 | firewall-cmd --add-port=9999/udp |
New-NetFirewallRule |
8.2 决策流程

图 8-1 接收端无输出时的决策流程
该流程按三层判定逐级切分故障域:其一判定发送端自身是否正常;其二区分发送侧网络与接收/中间网络;其三在跨主机场景下定位到中间网络。各分支直接给出对应的检查项。
8.3 标准 DDS 排查
ss -uanp | grep -E '74[0-9][0-9]' # 监听状态
tcpdump -ni any port 7400 # 发现报文
ip maddr show | grep 239.255 # DDS 组加入
ros2 node list ; ros2 topic list ; ros2 doctor
echo $ROS_DOMAIN_ID ; echo $RMW_IMPLEMENTATION
8.4 防火墙放行
firewall-cmd --permanent --add-port=7400-7650/udp && firewall-cmd --reload # 标准 DDS
New-NetFirewallRule -DisplayName "DDS-UDP9999-In" -Direction Inbound -Protocol UDP -LocalPort 9999 -Action Allow # 本工程
第四部分 诊断程序
9. 检查点设计
诊断能力内置于 send.go 与 receive.go。两程序在启动阶段完成配置校验、网卡枚举与路由探测,运行阶段记录收发与统计,无独立模式参数。
| 日志分类 | 检查内容 | 失败提示 | 发送端 | 接收端 |
|---|---|---|---|---|
[ENV] |
操作系统与架构 | --- | 是 | 是 |
[CFG] |
组地址、端口、网卡、参数 | 地址非法/非组播地址/端口越界 | 是 | 是 |
[NET] |
枚举网卡并校验所选网卡 | 网卡不存在/未 UP/无 MULTICAST/无 IPv4 | 是 | 是 |
[ROUTE] |
内核拟用源地址与出网卡 | 源地址未匹配网卡 | 是 | 是 |
[SEND] |
建连与逐包发送 | 建连/发送错误 | 是 | --- |
[JOIN]/[SOCK]/[IGMP] |
组加入、读缓冲、成员表 | 加入组播失败 | --- | 是 |
[RECV] |
逐包接收 | 接收错误 | --- | 是 |
[WAIT] |
接收端长时间无数据 | "已 N 秒未收到数据包" | --- | 是 |
[STAT] |
周期计数统计 | --- | 是 | 是 |
参数与环境变量见 5.2。运行方式:
go run receive.go
go run send.go
go run send.go -iface eth1
go run receive.go -iface eth1
10. 诊断程序源码
两文件相互独立,仅依赖标准库;按单文件编译运行。
10.1 send.go(发送端)
Go
// dds 发送端(内置诊断):在每个可能失败的点打日志,用于定位 UDP 组播通信失败。
//
// 用法:go run send.go # 默认 239.0.0.1:9999,每 2 秒一条
//
// go run send.go -iface eth1 -message hi -interval 1000
//
// 参数亦可用环境变量:DDS_MCAST / DDS_PORT / DDS_IFACE / DDS_INTERVAL_MS / DDS_MESSAGE / DDS_WAIT_SEC
package main
import (
"flag"
"fmt"
"log"
"net"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
)
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
var (
mcast = flag.String("mcast", envStr("DDS_MCAST", "239.0.0.1"), "组播地址")
port = flag.Int("port", envInt("DDS_PORT", 9999), "UDP 端口")
iface = flag.String("iface", envStr("DDS_IFACE", ""), "发送网卡名(空=内核默认)")
interval = flag.Int("interval", envInt("DDS_INTERVAL_MS", 2000), "发送间隔(毫秒)")
message = flag.String("message", envStr("DDS_MESSAGE", "Hello, Multicast!"), "发送内容")
waitSec = flag.Int("wait", envInt("DDS_WAIT_SEC", 5), "统计提示间隔(秒)")
)
var (
sendCount int64
sendErrors int64
)
func logf(cat, format string, a ...interface{}) {
log.Printf("["+cat+"] "+format, a...)
}
func ifaceFlags(ifi net.Interface) string {
var f []string
if ifi.Flags&net.FlagUp != 0 {
f = append(f, "UP")
} else {
f = append(f, "DOWN")
}
if ifi.Flags&net.FlagRunning != 0 {
f = append(f, "RUNNING")
}
if ifi.Flags&net.FlagMulticast != 0 {
f = append(f, "MULTICAST")
} else {
f = append(f, "no-multicast")
}
if ifi.Flags&net.FlagLoopback != 0 {
f = append(f, "LOOPBACK")
}
return strings.Join(f, "|")
}
func ifaceIPv4(ifi net.Interface) []net.IP {
addrs, err := ifi.Addrs()
if err != nil {
return nil
}
var out []net.IP
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
if v4 := ipnet.IP.To4(); v4 != nil {
out = append(out, v4)
}
}
}
return out
}
func joinIPs(ips []net.IP) string {
if len(ips) == 0 {
return "-"
}
ss := make([]string, len(ips))
for i, ip := range ips {
ss[i] = ip.String()
}
return strings.Join(ss, ",")
}
func interfaceNameByIP(ip net.IP) string {
ifs, _ := net.Interfaces()
for _, ifi := range ifs {
for _, a := range ifaceIPv4(ifi) {
if a.Equal(ip) {
return ifi.Name
}
}
}
return ""
}
func resolveGroup() (*net.UDPAddr, error) {
ip := net.ParseIP(*mcast)
if ip == nil {
return nil, fmt.Errorf("非法组播地址 %q", *mcast)
}
if ip.To4() == nil {
return nil, fmt.Errorf("仅支持 IPv4 组播地址: %q", *mcast)
}
if !ip.IsMulticast() {
return nil, fmt.Errorf("地址 %s 不是组播地址(应在 224.0.0.0/4 内)", ip)
}
if *port < 1 || *port > 65535 {
return nil, fmt.Errorf("端口 %d 非法", *port)
}
return &net.UDPAddr{IP: ip, Port: *port}, nil
}
func listInterfaces() {
ifs, err := net.Interfaces()
if err != nil {
logf("NET", "枚举网卡失败: %v", err)
return
}
logf("NET", "本机网卡共 %d 张:", len(ifs))
for _, ifi := range ifs {
logf("NET", " #%-3d %-18s mtu=%-5d flags=%s ipv4=%s",
ifi.Index, ifi.Name, ifi.MTU, ifaceFlags(ifi), joinIPs(ifaceIPv4(ifi)))
}
}
func probeRoute(group *net.UDPAddr) {
c, err := net.DialUDP("udp4", nil, group)
if err != nil {
logf("ROUTE", "内核路由探测失败: %v", err)
return
}
defer c.Close()
src := c.LocalAddr().(*net.UDPAddr).IP
logf("ROUTE", "内核拟用源地址 %s 发往 %s", src, group)
if name := interfaceNameByIP(src); name != "" {
logf("ROUTE", "该源地址属于网卡 %s(未指定 DDS_IFACE 时发送会走这里)", name)
} else {
logf("WARN", "源地址 %s 未匹配到本机网卡(可能路由/绑定异常)", src)
}
}
func resolveIface() (net.IP, error) {
if *iface == "" {
logf("CFG", "未指定网卡(DDS_IFACE 为空):使用内核默认网卡,多网卡环境可能发到错误网卡")
return nil, nil
}
ifi, err := net.InterfaceByName(*iface)
if err != nil {
return nil, fmt.Errorf("找不到网卡 %q: %w", *iface, err)
}
ips := ifaceIPv4(*ifi)
logf("NET", "选用网卡: #%d %s mtu=%d flags=%s ipv4=%s",
ifi.Index, ifi.Name, ifi.MTU, ifaceFlags(*ifi), joinIPs(ips))
if ifi.Flags&net.FlagUp == 0 {
logf("WARN", "网卡 %s 未 UP", ifi.Name)
}
if ifi.Flags&net.FlagMulticast == 0 {
logf("WARN", "网卡 %s 未置 MULTICAST 标志", ifi.Name)
}
if len(ips) == 0 {
return nil, fmt.Errorf("网卡 %q 没有 IPv4 地址", *iface)
}
return ips[0], nil
}
func runSend(group *net.UDPAddr, srcIP net.IP) {
var laddr *net.UDPAddr
if srcIP != nil {
laddr = &net.UDPAddr{IP: srcIP}
}
srcDesc := "内核默认"
if laddr != nil {
srcDesc = laddr.IP.String()
}
logf("SEND", "创建发送连接:源=%s 目标=%s", srcDesc, group)
conn, err := net.DialUDP("udp4", laddr, group)
if err != nil {
logf("ERR", "创建发送连接失败: %v", err)
return
}
defer conn.Close()
payload := []byte(*message)
logf("SEND", "开始发送:间隔=%dms 内容=%q 长度=%d 字节", *interval, *message, len(payload))
tk := time.NewTicker(time.Duration(*interval) * time.Millisecond)
defer tk.Stop()
for range tk.C {
n, err := conn.Write(payload)
if err != nil {
atomic.AddInt64(&sendErrors, 1)
logf("ERR", "发送失败: %v", err)
continue
}
atomic.AddInt64(&sendCount, 1)
logf("SEND", "已发送 %d 字节 -> %s", n, group)
}
}
func main() {
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
logf("ENV", "os=%s arch=%s", runtime.GOOS, runtime.GOARCH)
group, err := resolveGroup()
if err != nil {
logf("ERR", "组播配置错误: %v", err)
os.Exit(1)
}
logf("CFG", "发送端 组播=%s 网卡=%q 间隔=%dms 内容=%q", group, *iface, *interval, *message)
listInterfaces()
probeRoute(group)
srcIP, err := resolveIface()
if err != nil {
logf("ERR", "网卡解析失败: %v", err)
os.Exit(1)
}
go func() {
for range time.Tick(time.Duration(*waitSec) * time.Second) {
logf("STAT", "已发送=%d 发送错误=%d",
atomic.LoadInt64(&sendCount), atomic.LoadInt64(&sendErrors))
}
}()
runSend(group, srcIP)
}
10.2 receive.go(接收端)
Go
// dds 接收端(内置诊断):在每个可能失败的点打日志,用于定位 UDP 组播通信失败。
//
// 用法:go run receive.go # 默认 239.0.0.1:9999
//
// go run receive.go -iface eth1
//
// 参数亦可用环境变量:DDS_MCAST / DDS_PORT / DDS_IFACE / DDS_WAIT_SEC / DDS_READBUF
package main
import (
"errors"
"flag"
"fmt"
"log"
"net"
"os"
"runtime"
"strconv"
"strings"
"sync/atomic"
"time"
)
func envStr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envInt(key string, def int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return def
}
var (
mcast = flag.String("mcast", envStr("DDS_MCAST", "239.0.0.1"), "组播地址")
port = flag.Int("port", envInt("DDS_PORT", 9999), "UDP 端口")
iface = flag.String("iface", envStr("DDS_IFACE", ""), "接收网卡名(空=内核默认)")
waitSec = flag.Int("wait", envInt("DDS_WAIT_SEC", 5), "等待/统计提示间隔(秒)")
readBuf = flag.Int("readbuf", envInt("DDS_READBUF", 4*1024*1024), "内核接收缓冲(字节)")
)
var (
recvCount int64
recvErrors int64
lastRecvNs int64
)
func logf(cat, format string, a ...interface{}) {
log.Printf("["+cat+"] "+format, a...)
}
func ifaceFlags(ifi net.Interface) string {
var f []string
if ifi.Flags&net.FlagUp != 0 {
f = append(f, "UP")
} else {
f = append(f, "DOWN")
}
if ifi.Flags&net.FlagRunning != 0 {
f = append(f, "RUNNING")
}
if ifi.Flags&net.FlagMulticast != 0 {
f = append(f, "MULTICAST")
} else {
f = append(f, "no-multicast")
}
if ifi.Flags&net.FlagLoopback != 0 {
f = append(f, "LOOPBACK")
}
return strings.Join(f, "|")
}
func ifaceIPv4(ifi net.Interface) []net.IP {
addrs, err := ifi.Addrs()
if err != nil {
return nil
}
var out []net.IP
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok {
if v4 := ipnet.IP.To4(); v4 != nil {
out = append(out, v4)
}
}
}
return out
}
func joinIPs(ips []net.IP) string {
if len(ips) == 0 {
return "-"
}
ss := make([]string, len(ips))
for i, ip := range ips {
ss[i] = ip.String()
}
return strings.Join(ss, ",")
}
func interfaceNameByIP(ip net.IP) string {
ifs, _ := net.Interfaces()
for _, ifi := range ifs {
for _, a := range ifaceIPv4(ifi) {
if a.Equal(ip) {
return ifi.Name
}
}
}
return ""
}
func resolveGroup() (*net.UDPAddr, error) {
ip := net.ParseIP(*mcast)
if ip == nil {
return nil, fmt.Errorf("非法组播地址 %q", *mcast)
}
if ip.To4() == nil {
return nil, fmt.Errorf("仅支持 IPv4 组播地址: %q", *mcast)
}
if !ip.IsMulticast() {
return nil, fmt.Errorf("地址 %s 不是组播地址(应在 224.0.0.0/4 内)", ip)
}
if *port < 1 || *port > 65535 {
return nil, fmt.Errorf("端口 %d 非法", *port)
}
return &net.UDPAddr{IP: ip, Port: *port}, nil
}
func listInterfaces() {
ifs, err := net.Interfaces()
if err != nil {
logf("NET", "枚举网卡失败: %v", err)
return
}
logf("NET", "本机网卡共 %d 张:", len(ifs))
for _, ifi := range ifs {
logf("NET", " #%-3d %-18s mtu=%-5d flags=%s ipv4=%s",
ifi.Index, ifi.Name, ifi.MTU, ifaceFlags(ifi), joinIPs(ifaceIPv4(ifi)))
}
}
func probeRoute(group *net.UDPAddr) {
c, err := net.DialUDP("udp4", nil, group)
if err != nil {
logf("ROUTE", "内核路由探测失败: %v", err)
return
}
defer c.Close()
src := c.LocalAddr().(*net.UDPAddr).IP
logf("ROUTE", "内核拟用源地址 %s 发往 %s", src, group)
if name := interfaceNameByIP(src); name != "" {
logf("ROUTE", "该源地址属于网卡 %s", name)
} else {
logf("WARN", "源地址 %s 未匹配到本机网卡(可能路由/绑定异常)", src)
}
}
func resolveIface() (*net.Interface, error) {
if *iface == "" {
logf("CFG", "未指定网卡(DDS_IFACE 为空):使用内核默认网卡,多网卡环境可能入组到错误网卡")
return nil, nil
}
ifi, err := net.InterfaceByName(*iface)
if err != nil {
return nil, fmt.Errorf("找不到网卡 %q: %w", *iface, err)
}
ips := ifaceIPv4(*ifi)
logf("NET", "选用网卡: #%d %s mtu=%d flags=%s ipv4=%s",
ifi.Index, ifi.Name, ifi.MTU, ifaceFlags(*ifi), joinIPs(ips))
if ifi.Flags&net.FlagUp == 0 {
logf("WARN", "网卡 %s 未 UP", ifi.Name)
}
if ifi.Flags&net.FlagMulticast == 0 {
logf("WARN", "网卡 %s 未置 MULTICAST 标志", ifi.Name)
}
if len(ips) == 0 {
return nil, fmt.Errorf("网卡 %q 没有 IPv4 地址", *iface)
}
return ifi, nil
}
func logIGMP() {
b, err := os.ReadFile("/proc/net/igmp")
if err != nil {
return
}
logf("IGMP", "/proc/net/igmp:\n%s", strings.TrimRight(string(b), "\n"))
}
func joinGroup(group *net.UDPAddr, ifi *net.Interface) (*net.UDPConn, error) {
desc := "内核默认"
if ifi != nil {
desc = fmt.Sprintf("%s(#%d)", ifi.Name, ifi.Index)
}
logf("JOIN", "尝试加入组播组 %s(网卡=%s)", group, desc)
conn, err := net.ListenMulticastUDP("udp4", ifi, group)
if err != nil {
logf("ERR", "加入组播失败: %v", err)
return nil, err
}
logf("JOIN", "加入组播成功")
if err := conn.SetReadBuffer(*readBuf); err != nil {
logf("WARN", "设置内核读缓冲失败: %v", err)
} else {
logf("SOCK", "已请求内核读缓冲 = %d 字节", *readBuf)
}
logIGMP()
return conn, nil
}
func runRecv(conn *net.UDPConn, group *net.UDPAddr) {
logf("RECV", "开始接收 %s ...", group)
atomic.StoreInt64(&lastRecvNs, time.Now().UnixNano())
go func() {
for range time.Tick(time.Duration(*waitSec) * time.Second) {
idle := time.Since(time.Unix(0, atomic.LoadInt64(&lastRecvNs)))
if idle >= time.Duration(*waitSec)*time.Second {
logf("WAIT", "已 %s 未收到任何数据包(接收端在运行;排查本段网络/发送端/防火墙)",
idle.Round(time.Second))
}
}
}()
buf := make([]byte, 65535)
for {
n, src, err := conn.ReadFromUDP(buf)
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
atomic.AddInt64(&recvErrors, 1)
logf("ERR", "接收错误: %v", err)
continue
}
atomic.StoreInt64(&lastRecvNs, time.Now().UnixNano())
atomic.AddInt64(&recvCount, 1)
logf("RECV", "收到 %d 字节 来自 %s: %s", n, src, string(buf[:n]))
}
}
func main() {
flag.Parse()
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
logf("ENV", "os=%s arch=%s", runtime.GOOS, runtime.GOARCH)
group, err := resolveGroup()
if err != nil {
logf("ERR", "组播配置错误: %v", err)
os.Exit(1)
}
logf("CFG", "接收端 组播=%s 网卡=%q 等待提示=%ds", group, *iface, *waitSec)
listInterfaces()
probeRoute(group)
ifi, err := resolveIface()
if err != nil {
logf("ERR", "网卡解析失败: %v", err)
os.Exit(1)
}
go func() {
for range time.Tick(time.Duration(*waitSec) * time.Second) {
logf("STAT", "已接收=%d 接收错误=%d",
atomic.LoadInt64(&recvCount), atomic.LoadInt64(&recvErrors))
}
}()
conn, err := joinGroup(group, ifi)
if err != nil {
os.Exit(1)
}
runRecv(conn, group)
}
11. 按场景排查
11.1 接收端无输出
-
发送端是否输出
[SEND] 已发送 ... 字节;若无,检查[CFG]与[ERR]。 -
发送端
[ROUTE]的源地址与网卡是否符合预期;如否,指定-iface。 -
接收端
[JOIN]是否成功;失败则依[ERR]核对地址、权限与网卡。 -
接收端仅出现
[WAIT],说明入组成功但未收到报文,转入抓包与防火墙检查。
11.2 多网卡不可达
-
比对两端
[ROUTE]的默认出接口。 -
两端以
-iface(或DDS_IFACE)指定同一网卡,重启后核对[NET] 选用网卡。 -
在目标网卡抓包验证:
tcpdump -ni eth1 udp port 9999。
11.3 容器与 Kubernetes
-
确认网络模式为 hostNetwork 或 macvlan,不使用默认 CNI。
-
由
[NET] 选用网卡确认-iface生效(hostNetwork 为节点网卡名,macvlan 为net1)。 -
Pod 内执行
ip addr、ip route get 239.0.0.1、ip maddr show复核。 -
收发 Pod 须处于同一二层;组播不可用时改为单播发送。
11.4 检查清单
| # | 检查项 | 依据 |
|---|---|---|
| 1 | 两侧进程运行 | docker ps / 终端 |
| 2 | 地址端口一致 | [CFG] |
| 3 | 出接口正确 | [ROUTE] / ip route get |
| 4 | 接收端已入组 | [JOIN] / ip maddr show |
| 5 | 抓包可见组播报文 | [SEND] / tcpdump |
| 6 | 防火墙已放行 | 防火墙规则 |
| 7 | 收发处于同一二层 | VLAN / 容器网络 |
附录
附录 A 常见问题
A.1 本工程的"DDS"是否为标准 DDS? 否。本工程 venus/dds 是基于 UDP 组播的收发程序,不含 RTPS、DCPS 与 QoS,属应用层实现。
A.2 本工程通信参数 组地址 239.0.0.1,端口 9999,报文内容 Hello, Multicast!,默认周期 2 秒。
A.3 标准 DDS 的主流实现 开源实现为 eProsima Fast DDS、Eclipse Cyclone DDS、OpenDDS;商业实现为 RTI Connext、ADLINK OpenSplice、Twin Oaks CoreDX、GurumNetworks GurumDDS。各实现依 RTPS 线协议互通。
A.4 端口中哪一类最关键 发现端口(7400 系列)最关键。发现被阻断时,参与者无法建立通信;防火墙至少放行 UDP 7400--7650。
A.5 标准 DDS 使用的协议 应用层为 DDS/DCPS 与 QoS;实现内部为 RTPS(发现 SPDP/SEDP,序列化 CDR);底层为 UDP/IP(含 IGMP 组播),可选 TCP、共享内存与 TLS/DTLS。
A.6 组播与单播的选择 组播适用于一对多、成员动态的场景,依赖二层可达与 IGMP;单播适用于点对点及跨网段/跨 NAT 场景。标准 DDS 采用"组播发现、单播数据"。
A.7 双网卡环境的配置 收发双方绑定同一张、与对端同网段的网卡。本工程通过 -iface/DDS_IFACE 指定,或以操作系统路由固定组播出接口(见 5.3)。
A.8 bridge 网络下组播不可达的原因 组播不经 NAT 转换;docker0 与物理网卡分属不同二层且默认不转发;IGMP 成员关系不传播;网桥 snooping 与 iptables 可能丢弃。详见 3.2。
A.9 跨网段组播是否可行 默认不可行。须同时满足 TTL>1 与全程组播路由配置(PIM、IGMP querier、RP、RPF)。企业网、云与广域网络通常不具备该条件,建议改用单播。
A.10 "组播转单播"的成因 网络层不进行该转换。标准 DDS 出于协议设计以组播发现、单播传数据;组播被阻断时中间件回退到单播 peer 或 Discovery Server;部分交换机将组播按广播泛洪。详见 3.5。
A.11 Kubernetes 部署要点 默认 CNI 不转发组播。首选 hostNetwork: true,或经 Multus 使用 macvlan/ipvlan(l2)。须指定正确网卡、约束收发 Pod 处于同一二层,并在必要时改为单播。
A.12 发送端正常而接收端无输出时的处理 按第 7、8、11 章执行:发送端抓包切分故障域,随后检查接收端入组、网卡、防火墙与中间网络。
A.13 诊断程序相比原程序的变化 新增启动自检与结构化日志(配置、网卡、路由、入组、收发、统计),参数环境变量化,循环内错误不再退出进程,接收缓冲增至 64KB + 内核 4MB。默认参数保持兼容。
附录 B 术语表
B.1 网络基础
| 术语 | 含义 |
|---|---|
| 单播 | 发往单一目的主机 |
| 广播 | 发往本地网段全部主机 |
| 组播 | 发往已加入该组的主机 |
| IGMP | 主机加入与维护组播成员的协议 |
| IGMP snooping | 交换机依据 IGMP 决定组播转发端口 |
| querier | 周期性查询组播成员并在无成员时停止转发的设备 |
| TTL | 组播报文跳数上限,默认 1 |
| 回环 | 是否允许本机接收自身发送的组播 |
| 二层域 | 同一网桥或 VLAN 的可达范围 |
| NAT | 地址端口转换,仅处理单播,不转发组播 |
B.2 DDS 标准
| 术语 | 含义 |
|---|---|
| DDS | OMG 以数据为中心的发布/订阅中间件规范 |
| DCPS | DDS 的核心发布/订阅模型 |
| RTPS | DDS 互操作线协议 |
| SPDP | 参与者发现协议(组播) |
| SEDP | 端点发现协议(单播) |
| CDR | 数据序列化格式 |
| Domain / DomainId | 逻辑隔离的通信域及域号 |
| Topic | 发布/订阅主题 |
| QoS | 服务质量策略 |
| DDS Security | 安全插件(认证、访问控制、加密) |
| Discovery Server | 以单播完成发现的服务器 |
B.3 本工程与部署
| 术语 | 含义 |
|---|---|
| venus/dds | 本工程镜像,UDP 组播收发程序 |
| DDS_IFACE | 指定收发网卡的环境变量 |
| bridge | Docker 默认虚拟网桥,私有二层叠加 NAT |
| hostNetwork | Pod 复用宿主机网络命名空间 |
| macvlan / ipvlan | 使容器接入物理二层的方案 |
| Multus | K8s 多网络插件,用于挂载二级网络 |
| CNI | K8s 容器网络接口 |
| tcpdump / pktmon | Linux / Windows 抓包工具 |
附录 C 命令速查
C.1 本工程(端口 9999)
| 目的 | LINUX | WINDOWS |
|---|---|---|
| 查看地址 | ip -4 addr |
Get-NetIPAddress -AddressFamily IPv4 |
| 查看出接口 | ip route get 239.0.0.1 |
route print -4 |
| 查看组加入 | ip maddr show |
netsh interface ip show joins |
| 抓包 | tcpdump -ni any udp port 9999 -A |
pktmon / Wireshark |
| 放行防火墙 | firewall-cmd --add-port=9999/udp |
New-NetFirewallRule |
| 运行 | go run receive.go / go run send.go |
同左 |
C.2 标准 DDS(端口 7400 系列)
| 目的 | 命令 |
|---|---|
| 监听状态 | ss -uanp | grep -E '74[0-9][0-9]' |
| 发现报文 | tcpdump -ni any port 7400 |
| 组加入 | ip maddr show | grep 239.255 |
| ROS 2 诊断 | ros2 node list / ros2 topic list / ros2 doctor |
| 域与实现 | echo $ROS_DOMAIN_ID ; echo $RMW_IMPLEMENTATION |
| 放行端口 | firewall-cmd --add-port=7400-7650/udp |
C.3 容器与 Kubernetes
| 目的 | 命令 |
|---|---|
| 网络模式 | docker inspect -f '{``{.HostConfig.NetworkMode}}' <容器名> |
| 容器日志 | docker logs -f <容器名> |
| Pod 网卡 | kubectl exec -it <pod> -- ip addr |
| Pod 组加入 | kubectl exec -it <pod> -- ip maddr show |