Fast DDS实战指南:从零构建C++发布订阅系统的关键步骤

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 目录隔离了数据契约,publishersubscriber 是具体的业务逻辑,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 longstring: 这是IDL的基本数据类型。unsigned long 通常对应C++里的 uint32_tstring 对应 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.hppHelloWorldPubSubTypes.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 participantcreate_topic failed

  • 原因: 最常见的是域ID不匹配,或者QoS配置不兼容
  • 解决
    1. 检查域ID: 确保发布者和订阅者的 create_participant 的第一个参数(域ID)是相同的数字。0是默认域。
    2. 启用日志: Fast DDS有丰富的日志系统。在运行程序前设置环境变量:export FASTDDS_LOG_VERBOSITY=INFOexport FASTDDS_LOG_VERBOSITY=WARNING。这会在控制台输出详细的内部日志,对定位问题极有帮助。
    3. 检查网络: 如果是跨机器通信,确保防火墙没有屏蔽7400等端口(Fast DDS发现协议默认端口)。

问题3:订阅者收不到消息。

  • 原因
    • 主题名或数据类型名拼写不一致(大小写敏感!)。
    • 发布者和订阅者的QoS策略不兼容(例如,一方是可靠传输,另一方是最佳努力)。
    • 发布者在订阅者启动前就发送了消息,且没有配置持久化QoS。
  • 解决
    1. 打印类型名: 在双方代码中添加 std::cout << "Type name: " << type_.get_type_name() << std::endl; 确保完全一致。
    2. 统一QoS: 在初学阶段,双方都使用 _QOS_DEFAULT。等熟悉后,再研究可靠性、持久性、截止时间等高级QoS。
    3. 先启动订阅者: 在简单测试时,养成先启动订阅者,再启动发布者的习惯。

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 性能优化初探

当你的系统需要处理高频数据(如传感器数据)时,默认配置可能成为瓶颈。这里有两个立竿见影的优化点:

  1. 关闭无关模块: 如果你的应用只在单机或特定网络运行,可以关闭一些发现协议特性来减少开销。

    // 在创建参与者之前设置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; // 关闭静态发现
    
  2. 调整发送缓冲区: 对于高频小数据包,调整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解决实际的分布式通信难题。

已经博主授权,源码转载自 https://pan.quark.cn/s/a4b39357ea24 在信息技术领域,特别是软件编程行业,微软公司推出的集成开发环境(IDE)Visual Studio,凭借其卓越的功能和广泛的适用范围,成为了众多程序员的常用工具。不过,在实际操作期间,用户可能会遭遇各种挑战,其中一种较为普遍的挑战是“Visual Studio遭遇了异常情况,这或许与某个附加组件有关”。本文将详细研究这一现象的成因、潜在后果以及最终的应对措施。 ### 原因剖析 Visual Studio通过支持多种插件和附加组件来扩展其功能,这些组件通常由第三方开发者设计,旨在为用户提供更多个性化和专业化的工具。然而,这些插件的质量良莠不齐,部分可能未经过充分的测试或与特定版本的Visual Studio存在兼容性难题,从而在执行时引发异常。异常的出现可能源于以下几个因素: 1. **代码缺陷**:若附加组件中的代码存在逻辑问题或资源管理不当,就可能导致运行时异常。 2. **资源竞争**:多个插件同时占用相同的资源(例如内存、文件句柄等),可能会产生资源冲突,进而触发异常。 3. **依赖不匹配**:插件可能需要特定版本的库或框架,如果系统中安装的版本不一致,也可能导致异常。 4. **安全隐患**:部分插件可能存在安全漏洞,一旦被恶意利用,可能会导致更严重的问题,包括但不限于异常崩溃。 ### 后果分析 当Visual Studio遇到由附加组件引发的异常时,不仅会中断当前的工作进程,降低开发效能,还可能带来以下潜在风险: 1. **数据遗失**:若异常发生在保存操作之前,可能会导致未保存的工作内容遗失。 2. **稳定性减弱**:频繁的异常会导致Visual Stud...
内容概要:本文围绕有源中点箝位(ANPC)三电平并网逆变器,提出并深入研究了一种融合双极性倍频脉宽调制(DPWMA)、正负序分离锁相控制与电网电压前馈控制的高性能一体化并网策略。研究首先系统分析了ANPC三电平逆变器在开关损耗均衡、中点电位稳定、输出谐波含量低等方面的拓扑结构优势,为实现高质量并网奠定了坚实的硬件基础。在此基础上,通过引入DPWMA调制策略,有效提升了等效开关频率,显著优化了输出电压电流的波形质量,降低了谐波畸变。为应对电网电压不平衡、畸变等复杂工况,研究采用了正负序分离锁相技术,实现了对电网正序和负序分量的精确分离与独立控制,从而保障了在非理想电网条件下的精准相位同步。同时,通过叠加电网电压前馈控制,构建了前馈-反馈复合控制体系,提前补偿电网扰动,极大地增强了系统的动态响应速度和抗干扰能力。最终,通过Simulink仿真平台对稳态、电网不平衡及动态扰动等多种工况进行了全面验证,结果表明该复合控制策略能显著提升并网系统的电能质量、稳定性和工况适应性,为新能源发电等大功率并网应用提供了先进的技术解决方案。; 适合人群:具备电力电子、自动控制理论或新能源并网技术等相关专业知识背景,从事相关领域科研或工程开发工作的研究人员,尤其适合高校研究生、青年教师及电力系统仿真与设计工程师。; 使用场景及目标:①应用于对电能质量要求严苛的大功率并网逆变器控制系统设计与优化;②解决电网电压不平衡、谐波畸变等复杂非理想工况下的并网稳定性与同步精度问题;③为ANPC三电平逆变器的先进控制策略开发与性能提升提供详尽的仿真验证方案和技术参考;④支持高水平科研论文的复现、学位论文的课题研究以及重大工程项目前期的技术预研与论证。; 阅读建议:建议读者结合文中详述的系统拓扑、控制架构图及仿真模型,循序渐进地理解各控制模块的设计原理与协同工作机制,重点关注DPWMA调制的实现细节、正负序分离的数学原理与实现方法,以及前馈控制的嵌入方式与参数整定策略,并通过仿真实验与传统控制策略进行对比分析,以深刻掌握该复合控制策略的性能优势与工程应用价值。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值