一、引言:DDS通信的核心价值与应用场景

在分布式系统中,数据的高效、实时、可靠传输是核心需求之一。尤其在机器人、工业控制、自动驾驶等领域,设备间的协同依赖低延迟、高一致性的数据交互,而数据分发服务(Data Distribution Service, DDS) 正是为满足这一需求而生的中间件标准。

DDS由对象管理组(OMG)制定,采用“发布-订阅”架构,支持去中心化的分布式通信,具备实时性强、可靠性高、可扩展性好等特点。它通过标准化的接口和协议,屏蔽了底层网络细节,让开发者无需关注数据传输的底层实现,只需专注于业务逻辑。

Unitree(宇树科技)作为机器人领域的知名企业,在其新一代SDK(unitree_sdk2)中对DDS进行了深度封装,提供了简洁易用的通信接口,降低了机器人开发中分布式通信的门槛。本文将结合Unitree SDK2的官方DDS通信例程,从核心概念、代码解析、实践操作到注意事项,全面拆解DDS通信的实现逻辑与应用方法。

二、DDS核心概念铺垫

在解析例程前,需先明确几个DDS核心概念,这是理解代码逻辑的基础:

  1. 发布-订阅模式(Publish-Subscribe):DDS的核心通信模式。发布者(Publisher)负责生产数据并发送到指定“主题”,订阅者(Subscriber)通过订阅该主题接收数据,两者无需直接建立连接,实现解耦。
  2. 主题(Topic):数据的逻辑标识,是发布者和订阅者的“约定”。只有订阅了相同主题的订阅者,才能接收对应发布者发送的数据(例程中主题为TopicHelloWorld)。
  3. 数据类型(Data Type):发布/订阅的数据结构定义,发布者和订阅者必须使用完全一致的数据类型,否则无法正常解析数据(例程中数据类型为HelloWorldData::Msg)。
  4. 域(Domain):DDS将通信环境划分为不同域,只有处于同一域的发布者和订阅者才能通信,域ID用于隔离不同的通信场景(例程中域ID默认为0)。
  5. 通道(Channel):Unitree SDK2对DDS通信链路的封装,包含发布/订阅的底层资源管理(如Socket、缓存队列等),通过ChannelFactory统一创建和管理。

三、Unitree SDK2的DDS封装设计

Unitree SDK2并未让开发者直接操作底层DDS API,而是通过一层简洁的封装,提供了更高层次的通信接口,核心封装类包括:

  1. ChannelFactory(通道工厂):单例模式,负责初始化DDS通信环境(指定域ID、网卡名称)、管理通道资源,是所有通信操作的入口。
  2. ChannelPublisher(发布者类):封装DDS发布者逻辑,提供InitChannel()(初始化通道)、Write()(发送消息)等方法。
  3. ChannelSubscriber(订阅者类):封装DDS订阅者逻辑,提供InitChannel()(绑定消息处理函数、初始化通道)、CloseChannel()(关闭通道)等方法。
  4. 消息类型模板:支持自定义消息结构(如例程中的HelloWorldData::Msg),需通过头文件统一定义,确保发布者和订阅者数据结构一致。

这种封装的优势在于:开发者无需关注DDS底层的域配置、QoS(服务质量)参数、序列化/反序列化等复杂细节,只需调用SDK提供的接口即可快速实现分布式通信。

四、消息发布例程深度解析(publisher.cpp)

发布例程的核心功能是:初始化DDS环境,创建发布者,循环向TopicHelloWorld主题发送“HelloWorld”消息。以下是代码逐段解析:

1. 头文件依赖

#include <unitree/robot/channel/channel_publisher.hpp>  // 发布者类头文件
#include <unitree/common/time/time_tool.hpp>            // 时间工具(获取时间戳)
#include "HelloWorldData.hpp"                           // 自定义消息类型头文件
  • 必须包含SDK提供的发布者封装头文件和时间工具头文件;
  • HelloWorldData.hpp是自定义消息类型的定义文件,需与订阅者共用,确保数据结构一致。

2. 主题定义

#define TOPIC "TopicHelloWorld"
  • 定义通信主题名称,需与订阅者的主题完全一致(大小写敏感),否则订阅者无法接收消息。

3. 主函数核心逻辑

(1)参数检查与通信环境初始化
if (argc < 2)
{
  std::cout << "Usage: " << argv[0] << " networkInterface" << std::endl;
  exit(-1);
}
unitree::robot::ChannelFactory::Instance()->Init(0, argv[1]);
  • 发布例程要求终端传入网卡名称(如eth0wlan0),用于指定机器人通信使用的网络接口;
  • ChannelFactory::Instance()获取单例工厂对象,Init(0, argv[1])的两个参数分别为:域ID(0)网卡名称(argv[1]),初始化DDS通信环境。
(2)创建发布者对象
ChannelPublisherPtr<HelloWorldData::Msg> publisher = ChannelPublisherPtr<HelloWorldData::Msg>(new ChannelPublisher<HelloWorldData::Msg>(TOPIC));
  • 使用智能指针ChannelPublisherPtr管理发布者对象,避免内存泄漏;
  • 模板参数HelloWorldData::Msg指定发布的消息类型,构造函数传入主题名称,绑定发布者与主题。
(3)初始化发布通道
publisher->InitChannel();
  • 初始化发布通道,底层会创建DDS发布者实例、数据写入器(DataWriter),并绑定主题,完成通信前的资源准备。
(4)循环发送消息
while (true)
{
    HelloWorldData::Msg msg(unitree::common::GetCurrentTimeMillisecond(), "HelloWorld.");
    publisher->Write(msg);
    sleep(1);
}
  • 构造消息对象msg:第一个参数是通过GetCurrentTimeMillisecond()获取的当前时间戳(作为userID字段),第二个参数是消息内容“HelloWorld.”;
  • publisher->Write(msg)将消息发送到指定主题,底层会自动完成消息序列化和网络传输;
  • sleep(1)表示每秒发送一条消息,控制发布频率。

五、消息订阅例程深度解析(subscriber.cpp)

订阅例程的核心功能是:初始化DDS环境,订阅TopicHelloWorld主题,接收发布者消息并处理,支持通道关闭与重新初始化。以下是代码逐段解析:

1. 头文件依赖与主题定义

#include <unitree/robot/channel/channel_subscriber.hpp>  // 订阅者类头文件
#include "HelloWorldData.hpp"                           // 与发布者共用的消息类型头文件

#define TOPIC "TopicHelloWorld"  // 与发布者主题完全一致
  • 订阅者需包含channel_subscriber.hpp头文件,消息类型头文件与发布者保持一致。

2. 消息处理函数

void Handler(const void* msg)
{
    const HelloWorldData::Msg* pm = (const HelloWorldData::Msg*)msg;
    std::cout << "userID:" << pm->userID() << ", message:" << pm->message() << std::endl;
}
  • 自定义消息处理函数,当订阅者接收到消息时,DDS会自动回调该函数;
  • 函数参数const void* msg是接收到的原始消息指针,需强制转换为HelloWorldData::Msg*类型,才能解析userID(时间戳)和message(消息内容)字段;
  • 此处仅打印消息,实际应用中可扩展为业务逻辑处理(如机器人运动控制、数据存储等)。

3. 主函数核心逻辑

(1)初始化通信环境
ChannelFactory::Instance()->Init(0);
  • 订阅例程无需传入网卡名称(默认使用系统默认网卡),仅需指定域ID(0),与发布者保持同一域。
(2)创建订阅者对象
ChannelSubscriberPtr<HelloWorldData::Msg> subscriber = ChannelSubscriberPtr<HelloWorldData::Msg>(new ChannelSubscriber<HelloWorldData::Msg>(TOPIC));
  • 智能指针ChannelSubscriberPtr管理订阅者对象,模板参数指定消息类型,构造函数传入主题名称,绑定订阅者与主题。
(3)初始化订阅通道(绑定处理函数)
subscriber->InitChannel(std::bind(Handler, std::placeholders::_1), 1);
  • InitChannel()的两个参数:
    1. 消息处理函数绑定:通过std::bind(Handler, std::placeholders::_1)Handler函数绑定为消息回调函数,std::placeholders::_1表示回调时传入的消息指针参数;
    2. 消息队列大小(1):指定接收消息的缓存队列大小,若消息接收速度超过处理速度,队列满后会丢弃旧消息(可根据需求调整)。
(4)通道测试:关闭与重新初始化
sleep(5);
subscriber->CloseChannel();
std::cout << "reseted. sleep 3" << std::endl;
sleep(3);
subscriber->InitChannel();
  • 先睡眠5秒,接收并处理发布者消息;
  • CloseChannel()关闭订阅通道,释放底层DDS订阅者、数据读取器(DataReader)等资源;
  • 睡眠3秒后,调用无参数的InitChannel(),使用之前绑定的处理函数和队列大小重新初始化通道,支持通道的动态重启。
(5)循环等待消息
while (true)
{
    sleep(10);
}
  • 订阅者需保持运行状态以接收消息,此处通过无限循环阻塞程序,避免进程退出。

六、消息类型定义文件(HelloWorldData.hpp)解析

例程中未给出HelloWorldData.hpp的源码,但根据发布者和订阅者的使用逻辑,可推导出其核心结构(实际开发中需手动创建该文件):

#ifndef HELLOWORLDDATA_HPP
#define HELLOWORLDDATA_HPP

namespace HelloWorldData
{
    class Msg
    {
    private:
        uint64_t userID_;    // 时间戳字段(发布者传入的当前时间毫秒数)
        std::string message_;// 消息内容字段

    public:
        // 构造函数
        Msg(uint64_t userID, const std::string& message) : userID_(userID), message_(message) {}

        // 访问器方法(供订阅者解析)
        uint64_t userID() const { return userID_; }
        std::string message() const { return message_; }
    };
}

#endif // HELLOWORLDDATA_HPP
  • 消息类Msg需包含与发布者构造函数对应的字段(userID_message_);
  • 提供公开的访问器方法(userID()message()),供订阅者的处理函数解析消息内容;
  • 需使用头文件保护宏(HELLOWORLDDATA_HPP),避免重复包含。

七、例程编译与运行步骤

1. 编译准备

(1)环境依赖
  • 安装Unitree SDK2(需从官网下载对应版本,支持Ubuntu等Linux系统);
  • 配置SDK环境变量:指定头文件路径(UNITREE_SDK2_INCLUDE)和库文件路径(UNITREE_SDK2_LIB)。
(2)编写Makefile

创建Makefile文件,指定编译规则:

CC = g++
CFLAGS = -std=c++11 -I$(UNITREE_SDK2_INCLUDE)  # 包含SDK头文件
LDFLAGS = -L$(UNITREE_SDK2_LIB) -lunitree_robot -lunitree_common  # 链接SDK库

# 目标文件
TARGET_PUB = publisher
TARGET_SUB = subscriber

# 源文件
SRC_PUB = publisher.cpp
SRC_SUB = subscriber.cpp

all: $(TARGET_PUB) $(TARGET_SUB)

# 编译发布者
$(TARGET_PUB): $(SRC_PUB)
	$(CC) $(SRC_PUB) -o $(TARGET_PUB) $(CFLAGS) $(LDFLAGS)

# 编译订阅者
$(TARGET_SUB): $(SRC_SUB)
	$(CC) $(SRC_SUB) -o $(TARGET_SUB) $(CFLAGS) $(LDFLAGS)

# 清理目标文件
clean:
	rm -f $(TARGET_PUB) $(TARGET_SUB)

2. 编译与运行

(1)编译代码

在例程目录下执行:

make
  • 生成publisher(发布者可执行文件)和subscriber(订阅者可执行文件)。
(2)运行订阅者

打开第一个终端,执行:

./subscriber
  • 订阅者初始化后进入等待状态,等待接收发布者消息。
(3)运行发布者

打开第二个终端,执行(需替换eth0为实际网卡名称):

./publisher eth0
  • 发布者开始每秒发送一条消息,订阅者终端会打印接收的消息:
    userID:1695700000000, message:HelloWorld.
    userID:1695700001000, message:HelloWorld.
    ...
    

八、常见问题与注意事项

1. 通信失败的核心排查点

  • 主题不一致:发布者和订阅者的TOPIC定义必须完全一致(大小写、字符完全匹配);
  • 域ID不匹配ChannelFactory::Init()的第一个参数(域ID)必须相同,否则无法跨域通信;
  • 网卡名称错误:发布者传入的网卡名称不存在或未连接网络,需通过ifconfig命令查看实际网卡名称;
  • 消息类型不一致HelloWorldData::Msg的字段、构造函数、访问器方法必须在发布者和订阅者中完全一致,否则会出现解析错误或崩溃;
  • SDK版本不兼容:例程基于unitree_sdk2,若使用旧版本SDK(如unitree_sdk1),接口可能不兼容,需升级SDK。

2. 性能与可靠性优化建议

  • 调整消息队列大小:订阅者InitChannel()的第二个参数(队列大小)可根据消息频率调整,高频消息建议增大队列(如10、20),避免消息丢失;
  • 设置QoS参数:Unitree SDK2支持自定义DDS的QoS(服务质量)参数(如可靠性、时效性),需通过ChannelPublisher/ChannelSubscriber的扩展接口配置(适用于对通信质量有特殊要求的场景);
  • 资源释放:长期运行的程序需在退出时调用CloseChannel()释放资源,避免内存泄漏;
  • 网络环境:机器人通信建议使用有线网络(如eth0),减少无线网络(wlan0)的延迟和丢包。

3. 扩展应用场景

  • 自定义消息类型:实际开发中可扩展Msg类的字段(如机器人关节角度、传感器数据),只需确保发布者和订阅者的头文件一致;
  • 多主题通信:支持创建多个发布者/订阅者对象,绑定不同主题,实现多维度数据的并行传输;
  • 跨设备通信:只要设备处于同一网络、同一域,即可通过该例程实现机器人与PC、机器人与机器人之间的分布式通信。

九、总结

DDS作为分布式实时通信的标准中间件,凭借其解耦、实时、可靠的特性,成为机器人等复杂系统的理想选择。Unitree SDK2通过对DDS的封装,将复杂的底层逻辑简化为“初始化-创建发布/订阅者-发送/接收消息”的简洁流程,极大降低了开发者的使用门槛。

本文通过解析Unitree SDK2的官方DDS例程,从核心概念、代码逻辑、编译运行到问题排查,全面覆盖了DDS通信的实践要点。开发者可基于该例程快速搭建机器人分布式通信框架,并根据实际需求扩展消息类型、调整通信参数,实现多设备间的高效数据交互。

随着机器人技术向多模块、分布式方向发展,DDS通信的应用将更加广泛。掌握Unitree SDK2的DDS封装使用,不仅能提升机器人开发效率,更能为复杂场景下的协同控制提供可靠的通信支撑。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐