DDS 通信技术说明

DDS 通信技术手册

本文面向在容器与 Kubernetes 环境中部署、运维和排障 DDS 及 UDP 组播通信的工程人员,按"原理---部署---排查---诊断程序"四部分组织。

术语 含义
标准 DDS OMG Data Distribution Service 规范及其实现,基于 DCPS 发布/订阅模型与 RTPS 线协议
本工程 目录 dds测试程序 中的 venus/dds,基于 UDP 组播的 send/receive 程序,不属于标准 DDS 实现
组播 向组地址发送、由已加入该组的主机接收的传输方式

约定:本文对"标准 DDS"与"本工程"分别给出原理与排查方法;两者通信模型不同,排查手段不可混用。

目录

第一部分 基础与模型

  1. 通信模型与组播成立条件

  2. DDS 模型辨析

  3. 部署环境对组播的影响

第二部分 部署

  1. 运行方式

  2. 多网卡绑定

  3. Kubernetes 部署

第三部分 故障排查

  1. 排查方法

  2. 通用排查手段

第四部分 诊断程序

  1. 检查点设计

  2. 诊断程序源码

  3. 按场景排查

附录

  • 附录 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 通信时序

  1. 订阅端创建 DataReader;其 RTPS 层加入组播组(IGMP)。

  2. 发布端创建 DataWriter,向组播地址发送 SPDP 宣告;订阅端据此发现参与者。

  3. 双方经 SEDP(单播)交换端点信息并匹配;仅当 Topic、类型与 QoS 相容时建立通信。

  4. 发布端调用 write,数据经 CDR 编码后由 DataWriter 发出,DataReader 接收并交付应用。

  5. 可靠 QoS 下,通过 HEARTBEAT / ACKNACK 完成确认与重传。

结论 :标准 DDS 采用"组播发现、单播数据";发现端口为 7400 + 250×域号。

3. 部署环境对组播的影响

3.1 Docker 网络模式与组播可达性

模式 组播可达性 说明
host 可达 容器共享宿主机网络命名空间
bridge(默认) 跨主机/到物理网不可达 私有二层叠加 NAT
macvlan / ipvlan(l2) 可达 容器接入物理二层
overlay(K8s 跨节点) 取决于 CNI 多数 CNI 不转发组播

3.2 bridge 模式下组播不可达的机理

现象:容器间组播在同宿主机同一网桥内可通,跨宿主机或跨至物理网络不可达。

机理:

  1. NAT 仅转换单播地址与端口,不处理组播地址。

  2. docker0 与物理网卡分属不同二层域,Docker 默认不进行跨接口组播转发。

  3. 容器发出的 IGMP 加入报文止于 docker0,未告知物理交换机组播成员关系。

  4. 网桥组播 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 接收端无输出

  1. 发送端是否输出 [SEND] 已发送 ... 字节;若无,检查 [CFG] 与 [ERR]。

  2. 发送端 [ROUTE] 的源地址与网卡是否符合预期;如否,指定 -iface。

  3. 接收端 [JOIN] 是否成功;失败则依 [ERR] 核对地址、权限与网卡。

  4. 接收端仅出现 [WAIT],说明入组成功但未收到报文,转入抓包与防火墙检查。

11.2 多网卡不可达

  1. 比对两端 [ROUTE] 的默认出接口。

  2. 两端以 -iface(或 DDS_IFACE)指定同一网卡,重启后核对 [NET] 选用网卡。

  3. 在目标网卡抓包验证:tcpdump -ni eth1 udp port 9999。

11.3 容器与 Kubernetes

  1. 确认网络模式为 hostNetwork 或 macvlan,不使用默认 CNI。

  2. 由 [NET] 选用网卡 确认 -iface 生效(hostNetwork 为节点网卡名,macvlan 为 net1)。

  3. Pod 内执行 ip addr、ip route get 239.0.0.1、ip maddr show 复核。

  4. 收发 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
相关推荐
洋不写bug1 小时前
网络编程(二)TCP回显服务器与客户端通信详解
服务器·网络·tcp/ip·tcp·网络通信·javaee·回显服务器
java_logo1 小时前
Docker 部署 DeepSeek Harness:轻松搭建局域网里的 AI Agent 平台
运维·docker·容器·ai agent·deepseek·轩辕镜像·deepseekharness
K成长日志2 小时前
BLE Host层L2CAP--数据包格式
网络·物联网·无线通信·蓝牙·iot·ble
liangshanbo12152 小时前
主系统登录后,子系统怎么实现自动登录?
java·网络·数据库
weixin_435247062 小时前
医疗科研“Agent化”:给研究型医院搭AI原生科研平台的3个架构取舍
云原生
谢亮_vipxieliang2 小时前
Go 并发安全性保障
服务器·网络·golang
袁章根3 小时前
基于瑞昱RTL8367S交换机电路设计(一)
网络·嵌入式硬件·硬件架构·硬件工程
洋不写bug3 小时前
网络原理(一)应用层协议、传输层UDP/TCP报文格式解析
网络·tcp/ip·udp·tcp·应用层·javaee·传输层
时间的拾荒人4 小时前
Qt 网络编程与音视频开发实战:从 UDP/TCP 到 HTTP 与多媒体
网络·qt·音视频