1. 为什么选择Fast DDS?从零开始的清晰认知
如果你正在为机器人、自动驾驶或者分布式工业系统寻找一个靠谱的通信中间件,那你很可能已经听说过DDS的大名。DDS,全称是数据分发服务,它是一种专门为高性能、高可靠、实时数据交换而设计的通信标准。而Fast DDS,就是目前这个领域里最活跃、应用最广泛的开源实现之一,由eProsima公司主导开发。
我刚开始接触DDS时,也被它那一堆术语搞得有点懵:DomainParticipant、Publisher、DataWriter、Topic、QoS... 感觉像在学一门新语言。但后来我明白了,它的核心思想其实特别直观,就是**“以数据为中心”**。想象一下一个大型的实时数据市场:生产者(发布者)不需要知道谁要买他的东西,他只需要把商品(数据)贴上标签(Topic)放到市场上;消费者(订阅者)呢,也只需要告诉市场:“我对贴有某某标签的商品感兴趣”。市场(DDS全局数据空间)会自动完成匹配和送货。这种“发布/订阅”模型彻底解耦了数据的生产者和消费者,让系统变得非常灵活和可扩展。
那么,为什么在众多消息中间件里,我会推荐你从Fast DDS开始呢?首先,它是真正的工业级标准,遵循了OMG组织的DDS规范,这意味着你的代码和架构是可移植的,未来换用其他合规的实现也不会是大问题。其次,它的性能非常出色,特别是在低延迟和高吞吐量场景下,实测下来比一些传统的消息队列要稳得多。最后,也是对我们开发者最友好的,它的C++ API相当清晰,配套工具链(比如代码生成器)也很完善,社区支持活跃,踩坑了容易找到解决方案。
所以,无论你是想为你的无人机搭建一个可靠的传感器数据总线,还是为你的仿真系统构建一个分布式通信框架,Fast DDS都是一个值得投入时间学习的强大工具。接下来,我就带你手把手,从一个空的C++项目开始,一步步构建出属于你自己的发布者和订阅者应用。
2. 环境准备与项目初始化:打好地基
万事开头难,但把环境搭好,后面就顺了。这里我假设你用的是Linux系统(Ubuntu 20.04/22.04为主),因为这是Fast DDS最主流的生产环境。Windows和macOS也能跑,但一些依赖和路径问题可能会多花你一点时间。
2.1 安装Fast DDS核心库与依赖
第一步,我们把Fast DDS本身和它需要的“左膀右臂”装好。Fast DDS依赖两个核心库:Fast CDR(负责数据序列化)和 Foonathan Memory(一个智能内存分配器)。最省心的安装方式就是通过包管理器。
打开你的终端,依次执行下面这些命令。别担心,我会解释每个命令在干嘛。
# 首先更新软件包列表,确保能获取到最新版本
sudo apt-get update
# 安装编译所需的通用工具链,比如gcc, g++, cmake, make
sudo apt-get install -y build-essential cmake
# 安装Fast DDS的依赖库
sudo apt-get install -y libasio-dev libtinyxml2-dev libssl-dev
# 安装Fast CDR和Fast DDS本体
sudo apt-get install -y libfastcdr-dev libfastdds-dev
这几行命令执行完,Fast DDS运行时库就安装到你的系统里了。但光有库还不够,我们还需要一个关键的工具——Fast DDS-Gen。这是一个用Java写的代码生成器,它能根据我们定义的接口文件(IDL),自动生成C++的数据结构和序列化代码,能省去我们大量手写样板代码的时间。
2.2 安装Fast DDS-Gen代码生成器
Fast DDS-Gen需要Java环境。我们先安装Java,然后去GitHub下载它的发布包。
# 安装Java运行时(推荐OpenJDK 11或以上)
sudo apt-get install -y openjdk-11-jre-headless
# 创建一个专门的目录存放工具,保持工作区整洁
mkdir -p ~/tools/fastdds_gen
cd ~/tools/fastdds_gen
# 从GitHub下载最新稳定版的Fast DDS-Gen压缩包
# 你可以去eProsima的GitHub仓库查看最新版本号,这里以2.9.0为例
wget https://github.com/eProsima/Fast-DDS-Gen/releases/download/v2.9.0/fastddsgen-2.9.0-linux-x64.tar.gz
# 解压
tar -xzf fastddsgen-2.9.0-linux-x64.tar.gz
# 将可执行文件链接到系统路径,这样在任何地方都能直接调用`fastddsgen`命令
sudo ln -s $(pwd)/fastddsgen-2.9.0/scripts/fastddsgen /usr/local/bin/fastddsgen
安装完成后,在终端里输入 fastddsgen -version 测试一下,如果能看到版本号信息,恭喜你,工具链就绪了!
2.3 创建你的第一个Fast DDS项目骨架
我习惯为每个新实验创建一个独立的工作空间,避免污染系统环境。我们来创建一个标准的C++项目目录结构,这个结构清晰明了,以后项目复杂了也容易管理。
# 回到你的主目录或你喜欢的工作区
cd ~
mkdir -p fastdds_hello_world/{protocol, publisher, subscriber, build}
cd fastdds_hello_world
tree .
你看到的目录结构应该是这样的:
fastdds_hello_world/
├── protocol/ # 存放数据定义(IDL文件)和生成的代码
├── publisher/ # 存放发布者应用代码
├── subscriber/ # 存放订阅者应用代码
└── build/ # 用于编译的构建目录
这个结构是我在多个项目里用下来的最佳实践。protocol 目录隔离了数据契约,publisher 和 subscriber 是具体的业务逻辑,build 目录用于外部构建(Out-of-source build),保持源码目录的干净。
3. 定义数据契约:编写你的第一个IDL文件
DDS通信的核心是数据。我们首先要定义“数据长什么样”,这就是IDL文件的工作。IDL(接口描述语言)是一种与编程语言无关的描述语言,用来定义数据结构。你可以把它理解为一份双方都必须遵守的“数据合同”。
3.1 理解IDL的基本语法
IDL的语法很像C++的结构体,但更简洁。我们从一个最简单的例子开始。在 protocol 目录下,创建我们的第一个IDL文件 HelloWorld.idl。
// protocol/HelloWorld.idl
module HelloWorldModule {
struct HelloWorld {
unsigned long index; // 无符号长整型,用作消息序号
string message; // 字符串,存放实际的消息内容
};
};
我来解释一下这几行代码:
module: 这相当于C++里的命名空间(namespace),用来组织和管理不同的数据结构,避免名字冲突。这里我们创建了一个叫HelloWorldModule的模块。struct: 定义了一个结构体HelloWorld,这就是我们想要通过网络发送的数据类型。unsigned long和string: 这是IDL的基本数据类型。unsigned long通常对应C++里的uint32_t,string对应std::string。IDL还支持很多其他类型,比如long,double,boolean, 序列(数组)等。
注意:IDL文件的后缀必须是
.idl,并且结构体名称不能与模块名相同,否则代码生成器可能会报错。这是新手常踩的一个坑。
3.2 使用Fast DDS-Gen生成C++代码
合同拟好了,现在需要把它翻译成C++能懂的代码。这就是 fastddsgen 工具的用武之地。
进入 protocol 目录,执行生成命令:
cd ~/fastdds_hello_world/protocol
fastddsgen -d . -replace HelloWorld.idl
我来拆解一下这个命令:
-d .: 指定生成代码的输出目录为当前目录(.)。-replace: 如果目标文件已存在,则覆盖它。第一次运行可以不加,但后续修改IDL后重新生成时,这个参数就很有用了。HelloWorld.idl: 我们的输入文件。
命令执行成功后,你会发现在 protocol 目录下多出了一堆 .hpp、.cxx 文件,比如 HelloWorld.hpp、HelloWorldPubSubTypes.cxx 等。别被文件数量吓到,它们各有分工:
HelloWorld.hpp: 定义了C++的HelloWorld结构体,和你IDL里写的一一对应。HelloWorldPubSubTypes.hpp/.cxx: 这是最关键的类,它继承了Fast DDS的TypeSupport,负责你自定义数据类型的序列化(把对象变成字节流)和反序列化(把字节流变回对象)。没有它,数据就无法在网络中传输。- 其他文件: 一些辅助性的序列化和类型支持代码。
至此,数据的“模具”已经造好了。接下来,我们就要用这个模具来制造数据(发布者)和接收数据(订阅者)了。
4. 构建发布者:让数据“说”出来
发布者的角色就像新闻发言人。它的工作流程非常清晰:初始化DDS环境 -> 创建发言主题 -> 准备好麦克风 -> 开始周期性发言。让我们用代码来实现这个比喻。
4.1 发布者核心类设计与初始化
在 publisher 目录下创建 main.cpp。我们先搭建发布者类的骨架。我强烈建议你按照这个模式来组织代码,逻辑清晰,易于维护。
// publisher/main.cpp
#include <chrono>
#include <thread>
#include <iostream>
// Fast DDS的核心头文件
#include <fastdds/dds/domain/DomainParticipant.hpp>
#include <fastdds/dds/domain/DomainParticipantFactory.hpp>
#include <fastdds/dds/publisher/Publisher.hpp>
#include <fastdds/dds/publisher/DataWriter.hpp>
#include <fastdds/dds/publisher/DataWriterListener.hpp>
#include <fastdds/dds/topic/Topic.hpp>
#include <fastdds/dds/topic/TypeSupport.hpp>
// 引入我们刚刚生成的代码
#include "../protocol/HelloWorldPubSubTypes.hpp"
using namespace eprosima::fastdds::dds;
using namespace HelloWorldModule; // 使用我们IDL定义的模块
class HelloWorldPublisher {
private:
// 1. 要发送的数据实例
HelloWorld hello_;
// 2. DDS核心实体
DomainParticipant* participant_;
Publisher* publisher_;
Topic* topic_;
DataWriter* writer_;
TypeSupport type_;
// 3. 监听器:用于接收DataWriter的事件回调,比如匹配到订阅者
class PubListener : public DataWriterListener {
public:
PubListener() : matched_(0) {}
~PubListener() override = default;
// 当有订阅者匹配或取消匹配时,这个函数会被自动调用
void on_publication_matched(
DataWriter* writer,
const PublicationMatchedStatus& info) override {
if (info.current_count_change == 1) {
matched_ = info.total_count;
std::cout << "[Publisher] 发现一个订阅者,匹配成功!当前匹配数: " << matched_ << std::endl;
} else if (info.current_count_change == -1) {
matched_ = info.total_count;
std::cout << "[Publisher] 一个订阅者断开连接。当前匹配数: " << matched_ << std::endl;
}
}
std::atomic<int> matched_; // 原子计数器,记录匹配的订阅者数量
} listener_;
public:
HelloWorldPublisher()
: participant_(nullptr)
, publisher_(nullptr)
, topic_(nullptr)
, writer_(nullptr)
, type_(new HelloWorldPubSubType()) // 使用我们生成的类型支持
{
// 初始化数据样本
hello_.index(0);
hello_.message("Hello, Fast DDS!");
}
virtual ~HelloWorldPublisher() {
// 析构函数,按创建顺序的逆序清理资源,非常重要!
if (writer_ != nullptr) {
publisher_->delete_datawriter(writer_);
}
if (publisher_ != nullptr) {
participant_->delete_publisher(publisher_);
}
if (topic_ != nullptr) {
participant_->delete_topic(topic_);
}
if (participant_ != nullptr) {
DomainParticipantFactory::get_instance()->delete_participant(participant_);
}
std::cout << "[Publisher] 资源已清理。" << std::endl;
}
};
上面这段代码定义了类的成员和生命周期管理。接下来是最关键的 init() 方法,它负责按步骤创建所有DDS实体。
4.2 逐步详解初始化流程
init() 方法就像组装一台精密仪器,每一步都不能错。我们把它拆开细看。
bool init() {
// 第一步:创建域参与者(Domain Participant)
// 可以把“域”理解为一个独立的虚拟网络,只有同一个域ID内的实体才能互相发现和通信。
DomainParticipantQos participant_qos;
participant_qos.name("HelloWorld_Publisher_Participant"); // 给参与者起个名字,调试时很有用
participant_ = DomainParticipantFactory::get_instance()->create_participant(0, participant_qos);
if (participant_ == nullptr) {
std::cerr << "创建域参与者失败!" << std::endl;
return false;
}
std::cout << "[Publisher] 域参与者创建成功 (域ID: 0)." << std::endl;
// 第二步:向域注册我们自定义的数据类型
// 这相当于在“数据市场”里注册一种新的商品规格。
type_.register_type(participant_);
std::cout << "[Publisher] 数据类型 HelloWorld 注册成功." << std::endl;
// 第三步:创建主题(Topic)
// 主题是连接发布者和订阅者的纽带,由“主题名”和“数据类型名”唯一确定。
// 订阅者只有订阅了同名、同数据类型的主题,才能收到消息。
topic_ = participant_->create_topic(
"HelloWorldTopic", // 主题名称,双方约定好的字符串
type_.get_type_name(), // 数据类型名称,就是上面注册的
TOPIC_QOS_DEFAULT); // 使用默认的主题QoS策略
if (topic_ == nullptr) {
std::cerr << "创建主题失败!" << std::endl;
return false;
}
std::cout << "[Publisher] 主题 'HelloWorldTopic' 创建成功." << std::endl;
// 第四步:创建发布者(Publisher)
publisher_ = participant_->create_publisher(PUBLISHER_QOS_DEFAULT, nullptr);
if (publisher_ == nullptr) {
std::cerr << "创建发布者失败!" << std::endl;
return false;
}
// 第五步:创建数据写入者(DataWriter)
// DataWriter是真正执行写操作(发送数据)的实体。它绑定到一个特定的Topic。
DataWriterQos writer_qos = DATAWRITER_QOS_DEFAULT;
// 这里我们可以调整一些QoS,比如设置可靠性为“可靠传输”(默认是尽力而为)。
// writer_qos.reliability().kind = RELIABLE_RELIABILITY_QOS;
writer_ = publisher_->create_datawriter(topic_, writer_qos, &listener_);
if (writer_ == nullptr) {
std::cerr << "创建DataWriter失败!" << std::endl;
return false;
}
std::cout << "[Publisher] DataWriter 创建成功,等待订阅者连接..." << std::endl;
return true; // 所有初始化步骤成功完成
}
4.3 实现数据发布与主循环
初始化完成后,发布数据就很简单了。我们实现一个 publish() 方法,并在 run() 方法中循环调用它。
bool publish() {
// 只有在有订阅者匹配时,才发送数据,避免无意义的网络流量。
if (listener_.matched_ > 0) {
// 更新数据样本
hello_.index(hello_.index() + 1);
// hello_.message() 可以保持不变,也可以动态修改
// 关键的一行:将数据写入DataWriter,Fast DDS会负责后续的发送。
if (writer_->write(&hello_) == ReturnCode_t::RETCODE_OK) {
return true;
} else {
std::cerr << "写入数据失败!" << std::endl;
}
} else {
std::cout << "[Publisher] 暂无订阅者,等待中..." << std::endl;
}
return false;
}
void run(uint32_t samples) {
uint32_t samples_sent = 0;
std::cout << "[Publisher] 开始发布,计划发送 " << samples << " 条消息." << std::endl;
while (samples_sent < samples) {
if (publish()) {
samples_sent++;
std::cout << "[Publisher] 已发送消息: index=" << hello_.index()
<< ", message=\"" << hello_.message() << "\"" << std::endl;
}
// 每秒发送一条,方便观察
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
}
std::cout << "[Publisher] 任务完成,共发送 " << samples_sent << " 条消息." << std::endl;
}
最后,在 main 函数中启动这一切。
int main(int argc, char** argv) {
std::cout << "=== Fast DDS 发布者应用启动 ===" << std::endl;
uint32_t samples = 10; // 发送10条消息后退出
HelloWorldPublisher* my_publisher = new HelloWorldPublisher();
if (my_publisher->init()) {
my_publisher->run(samples);
} else {
std::cerr << "发布者初始化失败,程序退出。" << std::endl;
}
delete my_publisher;
return 0;
}
发布者的代码就完成了。你可以看到,核心逻辑就是创建实体、注册类型、然后在一个循环里不断 write 数据。接下来,我们构建一个订阅者来接收这些消息。
5. 构建订阅者:让数据“听”进去
订阅者就像是新闻听众。它的生命周期和发布者对称:初始化DDS环境 -> 订阅感兴趣的主题 -> 等待并处理到来的数据。很多概念是相通的,所以我们重点关注不同的部分。
5.1 订阅者核心类与监听器
在 subscriber 目录下创建 main.cpp。订阅者类的结构与发布者类似,但核心在于 DataReaderListener。
// subscriber/main.cpp
#include <chrono>
#include <thread>
#include <iostream>
#include <fastdds/dds/domain/DomainParticipant.hpp>
#include <fastdds/dds/domain/DomainParticipantFactory.hpp>
#include <fastdds/dds/subscriber/Subscriber.hpp>
#include <fastdds/dds/subscriber/DataReader.hpp>
#include <fastdds/dds/subscriber/DataReaderListener.hpp>
#include <fastdds/dds/subscriber/SampleInfo.hpp>
#include <fastdds/dds/topic/Topic.hpp>
#include <fastdds/dds/topic/TypeSupport.hpp>
#include "../protocol/HelloWorldPubSubTypes.hpp"
using namespace eprosima::fastdds::dds;
using namespace HelloWorldModule;
class HelloWorldSubscriber {
private:
DomainParticipant* participant_;
Subscriber* subscriber_;
DataReader* reader_;
Topic* topic_;
TypeSupport type_;
// 订阅者的监听器:核心在于 on_data_available 回调
class SubListener : public DataReaderListener {
public:
SubListener() : samples_(0) {}
~SubListener() override = default;
// 匹配状态变化的回调(和发布者类似)
void on_subscription_matched(
DataReader* reader,
const SubscriptionMatchedStatus& info) override {
if (info.current_count_change == 1) {
std::cout << "[Subscriber] 发现一个发布者,匹配成功!" << std::endl;
} else if (info.current_count_change == -1) {
std::cout << "[Subscriber] 一个发布者断开连接。" << std::endl;
}
}
// 最重要的回调:当有新数据到达时,此函数被自动调用
void on_data_available(DataReader* reader) override {
SampleInfo info; // 样本信息,包含数据状态、时间戳等元数据
HelloWorld data; // 用于接收数据的本地对象
// 从DataReader中取出下一个数据样本
if (reader->take_next_sample(&data, &info) == ReturnCode_t::RETCODE_OK) {
// 检查这是否是有效数据(而不是,例如,被删除数据的通知)
if (info.valid_data) {
samples_++;
std::cout << "[Subscriber] 收到消息 #" << samples_
<< ": index=" << data.index()
<< ", message=\"" << data.message() << "\"" << std::endl;
}
}
}
std::atomic<int> samples_; // 记录已接收的有效样本数
} listener_;
public:
HelloWorldSubscriber()
: participant_(nullptr)
, subscriber_(nullptr)
, topic_(nullptr)
, reader_(nullptr)
, type_(new HelloWorldPubSubType())
{}
virtual ~HelloWorldSubscriber() {
// 清理资源的顺序与发布者对称
if (reader_ != nullptr) {
subscriber_->delete_datareader(reader_);
}
if (subscriber_ != nullptr) {
participant_->delete_subscriber(subscriber_);
}
if (topic_ != nullptr) {
participant_->delete_topic(topic_);
}
if (participant_ != nullptr) {
DomainParticipantFactory::get_instance()->delete_participant(participant_);
}
std::cout << "[Subscriber] 资源已清理。" << std::endl;
}
};
订阅者的 init() 方法与发布者高度相似,主要区别在于最后创建的是 DataReader 而不是 DataWriter。
5.2 订阅者初始化与数据读取
bool init() {
// 第一步:创建域参与者(注意,域ID必须与发布者相同,这里是0)
DomainParticipantQos participant_qos;
participant_qos.name("HelloWorld_Subscriber_Participant");
participant_ = DomainParticipantFactory::get_instance()->create_participant(0, participant_qos);
if (participant_ == nullptr) {
std::cerr << "创建域参与者失败!" << std::endl;
return false;
}
// 第二步:注册数据类型(必须与发布者注册的类型完全一致)
type_.register_type(participant_);
// 第三步:创建主题(主题名和数据类型名必须与发布者严格一致!)
topic_ = participant_->create_topic(
"HelloWorldTopic", // 与发布者相同的主题名
type_.get_type_name(), // 与发布者相同的数据类型名
TOPIC_QOS_DEFAULT);
if (topic_ == nullptr) {
std::cerr << "创建主题失败!" << std::endl;
return false;
}
// 第四步:创建订阅者
subscriber_ = participant_->create_subscriber(SUBSCRIBER_QOS_DEFAULT, nullptr);
if (subscriber_ == nullptr) {
std::cerr << "创建订阅者失败!" << std::endl;
return false;
}
// 第五步:创建数据读取者(DataReader),并关联监听器
DataReaderQos reader_qos = DATAREADER_QOS_DEFAULT;
// 同样,可以在这里配置QoS,比如设置历史深度,缓存多少条消息。
// reader_qos.history().depth = 10;
reader_ = subscriber_->create_datareader(topic_, reader_qos, &listener_);
if (reader_ == nullptr) {
std::cerr << "创建DataReader失败!" << std::endl;
return false;
}
std::cout << "[Subscriber] 初始化完成,等待发布者消息..." << std::endl;
return true;
}
订阅者的 run() 方法比发布者简单,因为它不需要主动发送,只需要等待监听器被回调。通常我们让主线程休眠,或者处理其他逻辑。
void run(uint32_t expected_samples) {
std::cout << "[Subscriber] 开始监听,期望接收 " << expected_samples << " 条消息." << std::endl;
// 循环等待,直到监听器收到足够多的样本
while (listener_.samples_ < expected_samples) {
std::this_thread::sleep_for(std::chrono::milliseconds(500)); // 每500ms检查一次
}
std::cout << "[Subscriber] 已收到 " << listener_.samples_ << " 条消息,任务完成." << std::endl;
}
最后是 main 函数:
int main(int argc, char** argv) {
std::cout << "=== Fast DDS 订阅者应用启动 ===" << std::endl;
uint32_t expected_samples = 10;
HelloWorldSubscriber* my_subscriber = new HelloWorldSubscriber();
if (my_subscriber->init()) {
my_subscriber->run(expected_samples);
} else {
std::cerr << "订阅者初始化失败,程序退出。" << std::endl;
}
delete my_subscriber;
return 0;
}
至此,订阅者也完成了。你会发现,订阅者的核心逻辑在监听器的 on_data_available 回调里。这是一种异步通知机制,数据一到,回调函数立刻被触发,非常适合事件驱动的程序。
6. 使用CMake构建与运行:一键编译的艺术
代码写好了,我们需要一个强大的构建系统把它们组织起来。CMake是目前C++项目的事实标准,它能帮我们管理依赖、设置编译选项,并生成跨平台的构建文件(如Makefile)。
6.1 编写顶层的CMakeLists.txt
在项目根目录 fastdds_hello_world/ 下创建 CMakeLists.txt。这个文件是项目的总入口。
# fastdds_hello_world/CMakeLists.txt
cmake_minimum_required(VERSION 3.15) # 指定CMake最低版本
project(fastdds_hello_world VERSION 1.0.0 LANGUAGES CXX) # 定义项目名和语言
# 设置C++标准为17,Fast DDS需要C++14以上,用17更稳妥
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) # 禁用编译器扩展,保证可移植性
# 将二进制文件(生成的可执行文件)和库文件输出到统一的目录,方便管理
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib)
# 关键:将生成的协议代码的头文件路径包含进来
# ${CMAKE_CURRENT_BINARY_DIR} 指向构建目录,fastddsgen生成的代码会放在那里
include_directories(${CMAKE_CURRENT_BINARY_DIR})
# 添加子目录,CMake会依次处理这些目录下的CMakeLists.txt
add_subdirectory(protocol)
add_subdirectory(publisher)
add_subdirectory(subscriber)
# 可选:安装规则,如果你想将可执行文件安装到系统路径
# install(TARGETS HelloWorldPublisher HelloWorldSubscriber DESTINATION bin)
6.2 编写协议层的CMakeLists.txt
这是最复杂但也最核心的部分。我们需要在 protocol/CMakeLists.txt 中定义一个自定义命令,让CMake在构建时自动调用 fastddsgen 来生成代码。
# protocol/CMakeLists.txt
# 查找Fast DDS和Fast CDR的包,必须找到
find_package(fastcdr REQUIRED)
find_package(fastdds REQUIRED)
# 查找fastddsgen可执行文件,确保它已安装
find_program(FASTDDSGEN_EXECUTABLE
NAMES fastddsgen fastddsgen.bat fastddsgen.exe
DOC "Fast DDS IDL compiler"
)
if(NOT FASTDDSGEN_EXECUTABLE)
message(FATAL_ERROR "未找到 fastddsgen 工具。请确保已安装Fast DDS-Gen并已将其添加到PATH环境变量中。")
endif()
# 获取当前目录下所有的.idl文件
file(GLOB_RECURSE IDL_FILES "${CMAKE_CURRENT_SOURCE_DIR}/*.idl")
message(STATUS "找到IDL文件: ${IDL_FILES}")
# 设置生成代码的输出目录为构建目录下的protocol子目录
set(GENERATED_OUTPUT_DIR ${CMAKE_CURRENT_BINARY_DIR})
set(GENERATED_SOURCES "") # 初始化一个变量,用于存放所有生成的源码文件路径
# 遍历每一个IDL文件
foreach(IDL_FILE ${IDL_FILES})
# 获取不带后缀的文件名,例如 HelloWorld.idl -> HelloWorld
get_filename_component(IDL_NAME ${IDL_FILE} NAME_WE)
# 定义生成的头文件和源文件列表(必须与fastddsgen实际生成的文件名匹配)
set(GEN_HPP
${GENERATED_OUTPUT_DIR}/${IDL_NAME}.hpp
${GENERATED_OUTPUT_DIR}/${IDL_NAME}PubSubTypes.hpp
${GENERATED_OUTPUT_DIR}/${IDL_NAME}CdrAux.hpp
${GENERATED_OUTPUT_DIR}/${IDL_NAME}TypeObjectSupport.hpp
)
set(GEN_CPP
${GENERATED_OUTPUT_DIR}/${IDL_NAME}PubSubTypes.cxx
${GENERATED_OUTPUT_DIR}/${IDL_NAME}CdrAux.ipp
${GENERATED_OUTPUT_DIR}/${IDL_NAME}TypeObjectSupport.cxx
)
# 添加自定义命令:这是将IDL生成整合到CMake构建流程的关键
add_custom_command(
OUTPUT ${GEN_HPP} ${GEN_CPP} # 声明这个命令的输出文件
COMMAND ${FASTDDSGEN_EXECUTABLE}
-d ${GENERATED_OUTPUT_DIR} # 指定输出目录
-replace # 覆盖已存在的文件
${IDL_FILE} # 输入文件
DEPENDS ${IDL_FILE} # 声明依赖,IDL文件改变时重新生成
COMMENT "正在为 ${IDL_NAME}.idl 生成Fast DDS代码"
VERBATIM
)
# 将生成的文件路径加入到总列表中
list(APPEND GENERATED_SOURCES ${GEN_HPP} ${GEN_CPP})
endforeach()
message(STATUS "生成的源代码: ${GENERATED_SOURCES}")
# 创建一个静态库,将生成的代码编译进去。这样publisher和subscriber只需要链接这个库即可。
add_library(protocol STATIC ${GENERATED_SOURCES})
# 链接Fast DDS和Fast CDR库
target_link_libraries(protocol fastcdr fastdds::fastdds)
# 设置头文件包含目录,让库的使用者能找到生成的头文件
target_include_directories(protocol PUBLIC
${CMAKE_CURRENT_BINARY_DIR} # 生成的头文件在这里
${CMAKE_CURRENT_SOURCE_DIR} # 原始的IDL文件目录(如果需要)
)
6.3 编写发布者与订阅者的CMakeLists.txt
这两个文件就非常简单了,因为它们的主要依赖——生成的协议代码——已经被封装成 protocol 库了。
# publisher/CMakeLists.txt
# 创建一个可执行文件
add_executable(HelloWorldPublisher main.cpp)
# 链接我们刚刚创建的protocol库,CMake会自动传递头文件路径和依赖库
target_link_libraries(HelloWorldPublisher protocol)
# subscriber/CMakeLists.txt
add_executable(HelloWorldSubscriber main.cpp)
target_link_libraries(HelloWorldSubscriber protocol)
6.4 编译与运行你的应用
所有文件准备就绪,现在进入激动人心的编译和运行阶段。
# 1. 进入我们之前创建的build目录(外部构建)
cd ~/fastdds_hello_world/build
# 2. 运行cmake生成Makefile。`..` 表示CMakeLists.txt在上一级目录
cmake -DCMAKE_BUILD_TYPE=Release .. # 使用Release模式以获得优化,Debug模式用于调试
# 3. 开始编译。`-j4` 表示使用4个线程并行编译,加快速度(根据你的CPU核心数调整)
cmake --build . -j4
# 编译成功后,可执行文件在 build/bin/ 目录下
ls bin/
# 你应该能看到 HelloWorldPublisher 和 HelloWorldSubscriber
现在,打开两个终端窗口,分别运行发布者和订阅者。
终端1 (运行订阅者,先启动):
cd ~/fastdds_hello_world/build/bin
./HelloWorldSubscriber
你会看到输出:[Subscriber] 初始化完成,等待发布者消息...
终端2 (运行发布者):
cd ~/fastdds_hello_world/build/bin
./HelloWorldPublisher
如果一切正常,你将看到两个程序开始交互。发布者终端会打印匹配成功和发送的消息,订阅者终端会实时打印接收到的消息。这就是你的第一个完全手写的、基于CMake管理的Fast DDS发布订阅系统!
7. 调试与进阶技巧:避开我踩过的那些坑
第一次跑通固然令人兴奋,但在实际项目中,你肯定会遇到各种问题。这里分享几个我踩过坑后总结的调试技巧和进阶配置。
7.1 常见问题与调试方法
问题1:编译时找不到 fastdds/dds/xxx.hpp 头文件。
- 原因: CMake没有正确找到Fast DDS的安装路径。
- 解决: 确保你通过
apt-get安装的是libfastdds-dev包。然后检查find_package(fastdds REQUIRED)是否成功。可以在CMakeLists.txt中添加message(STATUS "Fast DDS include dirs: ${fastdds_INCLUDE_DIRS}")来查看找到的路径。
问题2:运行时提示 Failed to create participant 或 create_topic failed。
- 原因: 最常见的是域ID不匹配,或者QoS配置不兼容。
- 解决:
- 检查域ID: 确保发布者和订阅者的
create_participant的第一个参数(域ID)是相同的数字。0是默认域。 - 启用日志: Fast DDS有丰富的日志系统。在运行程序前设置环境变量:
export FASTDDS_LOG_VERBOSITY=INFO或export FASTDDS_LOG_VERBOSITY=WARNING。这会在控制台输出详细的内部日志,对定位问题极有帮助。 - 检查网络: 如果是跨机器通信,确保防火墙没有屏蔽7400等端口(Fast DDS发现协议默认端口)。
- 检查域ID: 确保发布者和订阅者的
问题3:订阅者收不到消息。
- 原因:
- 主题名或数据类型名拼写不一致(大小写敏感!)。
- 发布者和订阅者的QoS策略不兼容(例如,一方是可靠传输,另一方是最佳努力)。
- 发布者在订阅者启动前就发送了消息,且没有配置持久化QoS。
- 解决:
- 打印类型名: 在双方代码中添加
std::cout << "Type name: " << type_.get_type_name() << std::endl;确保完全一致。 - 统一QoS: 在初学阶段,双方都使用
_QOS_DEFAULT。等熟悉后,再研究可靠性、持久性、截止时间等高级QoS。 - 先启动订阅者: 在简单测试时,养成先启动订阅者,再启动发布者的习惯。
- 打印类型名: 在双方代码中添加
7.2 配置XML文件实现零代码调整
手动在代码里配置QoS很繁琐。Fast DDS支持通过XML文件进行配置,这在实际项目中是更优雅的方式。你可以创建一个 participant_config.xml:
<?xml version="1.0" encoding="UTF-8"?>
<profiles xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles">
<participant profile_name="custom_participant_profile">
<rtps>
<!-- 设置发现协议为“简单发现”,适用于局域网内快速发现 -->
<builtin>
<discovery_config>
<discoveryProtocol>SIMPLE</discoveryProtocol>
<leaseDuration>
<sec>30</sec>
</leaseDuration>
</discovery_config>
</builtin>
<!-- 设置发送和接收Socket缓冲区大小,提升大流量性能 -->
<userTransports>
<transport_id>udp_transport</transport_id>
</userTransports>
<useBuiltinTransports>false</useBuiltinTransports>
</rtps>
</participant>
<data_writer profile_name="reliable_writer_profile">
<qos>
<!-- 设置为可靠传输,确保数据不丢失 -->
<reliability>
<kind>RELIABLE_RELIABILITY_QOS</kind>
</reliability>
<!-- 设置历史深度,缓存最新的10条消息 -->
<history>
<kind>KEEP_LAST_HISTORY_QOS</kind>
<depth>10</depth>
</history>
</qos>
</data_writer>
<data_reader profile_name="reliable_reader_profile">
<qos>
<reliability>
<kind>RELIABLE_RELIABILITY_QOS</kind>
</reliability>
<history>
<kind>KEEP_LAST_HISTORY_QOS</kind>
<depth>10</depth>
</history>
</qos>
</data_reader>
</profiles>
然后在创建域参与者时加载这个XML文件:
// 在 init() 函数中,替换 create_participant 那行
DomainParticipantFactory* factory = DomainParticipantFactory::get_instance();
// 加载XML配置文件
factory->load_XML_profiles_file("participant_config.xml");
// 使用XML中定义的配置创建参与者
participant_ = factory->create_participant_with_profile(0, "custom_participant_profile");
这种方式将配置和代码分离,修改QoS策略、传输设置等无需重新编译,大大提升了灵活性和可维护性。
7.3 性能优化初探
当你的系统需要处理高频数据(如传感器数据)时,默认配置可能成为瓶颈。这里有两个立竿见影的优化点:
-
关闭无关模块: 如果你的应用只在单机或特定网络运行,可以关闭一些发现协议特性来减少开销。
// 在创建参与者之前设置QoS DomainParticipantQos participant_qos; participant_qos.wire_protocol().builtin.discovery_config.leaseDuration = eprosima::fastrtps::c_TimeInfinite; participant_qos.wire_protocol().builtin.use_SIMPLE_EndpointDiscoveryProtocol = true; participant_qos.wire_protocol().builtin.use_STATIC_EndpointDiscoveryProtocol = false; // 关闭静态发现 -
调整发送缓冲区: 对于高频小数据包,调整Socket缓冲区可以减少丢包。
// 在XML配置中,或者在代码中设置TransportDescriptor auto udp_transport = std::make_shared<UDPv4TransportDescriptor>(); udp_transport->sendBufferSize = 65536; // 增大发送缓冲区 udp_transport->receiveBufferSize = 65536; // 增大接收缓冲区 participant_qos.transport().user_transports.push_back(udp_transport);
从简单的“HelloWorld”到可配置、可优化的生产级应用,Fast DDS的学习路径是循序渐进的。我建议你先把手动编写的这个例子彻底吃透,理解每个DDS实体的生命周期和协作关系。然后,再去探索Fast DDS-Gen的 -example 选项生成的示例代码,它会展示更多高级特性,比如服务(Service)和请求-回复(Request-Reply)模式。当你需要处理更复杂的系统时,XML配置和QoS策略调优将成为你的得力工具。记住,理解原理比记住API更重要,理解了“以数据为中心”和“全局数据空间”这两个核心概念,你就能更自如地运用Fast DDS解决实际的分布式通信难题。

498

被折叠的 条评论
为什么被折叠?



