
简介OpenDDS示例程序基于OMG DDS标准面向需要实现高效、低延迟数据通信的嵌入式及分布式系统开发者。压缩包内含可直接编译运行的DDSDemo工程完整演示Publisher、Subscriber、Topic、DataWriter/DataReader、QoS策略、IDL文件定义及代码生成等核心机制。包内共有172个文件涵盖13个cpp源码、11个h头文件、4个idl接口定义、4个vcxproj及2个sln工程文件附带可执行程序、动态库、日志与构建中间文件压缩包约199.85MB。目前已有3256人学习下载。示例注释清晰从IDL编写、生成C类到配置QoS与传输层、利用自动发现机制连接两端均有直观展示适合正在入门OpenDDS或准备基于DDS做原型验证的开发者对照实践。1. OpenDDS示例程序值得跑一遍一个带注释的最小发布订阅工程OpenDDS示例程序看着不起眼却是理解整个数据分发服务DDS体系最直接的入口。这个项目剥掉了OpenDDS繁重的配置外壳用最小的代码量展示了发布者与订阅者之间的完整通信链路而且代码注释非常到位几乎每个关键步骤都说明了“为什么要这么写”。很多第一次接触OpenDDS的开发者都是在跑通这个简单示例之后才意识到DDS并没有想象中那么玄学核心流程不过是一组对象的创建、一次类型注册和一条Topic的匹配。这个示例适合两类读者一类是想评估DDS能否满足自己项目通信需求的技术选型者另一类是已经在用其他中间件、想快速迁移到OpenDDS的开发者。把它跑起来比对着英文文档啃概念要高效得多。2. 环境准备与编译把ACE/TAO和OpenDDS正确拼装起来凡是编译过OpenDDS的人都知道最耗精力的不是业务代码而是把ACE、TAO、OpenDDS三层依赖理顺。ACE提供底层跨平台抽象TAO是CORBA实现OpenDDS的IDL编译与类型编解码都建立在二者之上。这个示例程序恰恰把这条依赖链完整走了一遍所以环境这关绕不过去。2.1 版本选择与源码准备OpenDDS的configure脚本会在配置阶段自动检测Perl、Python、C编译器以及系统里是否已有ACE/TAO。我一般会建议第一次编译的人让脚本自己去处理依赖而不是手动下载ACE源码来编因为ACE与TAO的版本一旦不匹配后续使用TAO_IDL编译器时会生成出完全无法链接的代码这种错误报出来跟你的机器配置毫无关系纯粹是依赖版本问题。# 进入OpenDDS源码目录让configure自动检测并拉取匹配的依赖 cd OpenDDS ./configure --prefix$HOME/opendds-install这里有几个参数值得解释一下。--prefix指定安装目录后续make install会把头文件和库统一放到这个目录下方便管理如果你的机器上已经装好了一套ACE可以用--with-ace$HOME/ACE_wrappers让脚本跳过自动获取流程如果configure过程中出现网络超时多半是它正在下载TAO源码包重试几次一般能过。configure参数作用备注--prefix指定安装路径建议单独目录避免污染系统目录--with-ace手动指定ACE路径适合本机已有ACE的情形--tests是否编译测试程序默认关首次跑示例可不开启--optimize开启编译优化调试阶段建议关闭2.2 configure与make编译OpenDDS本体# 在OpenDDS源码根目录执行 ./configure --prefix$HOME/opendds-install # 开始编译-j4表示4个并行任务按CPU核数调整 make -j4这条命令看起来简单实际编译时间从十几分钟到半小时不等取决于机器性能。编译期间如果报错优先去检查ACE/TAO的环境变量是否真的被configure识别到了其次看报错信息里的文件路径多半会指向某个版本不匹配的宏定义。等make跑完OpenDDS本体就绪接下来进入示例工程。很多人在这一步会有一个误区以为示例程序是直接make就能编的其实OpenDDS的工程都用MPC生成示例目录下看不到手工维护的Makefile。MPC是ACE/TAO体系里统一的跨平台工程管理工具它在各个平台下生成对应的Makefile或Visual Studio工程但源文件清单只有一个.mpc文件避免维护多份构建配置。2.3 用MPC生成示例工程并编译cd $DDS_ROOT/examples/Messenger $ACE_ROOT/bin/mwc.pl -type gnuace makemwc.pl是MPC的命令行入口-type gnuace的含义是生成GNU Make格式的构建文件。示例里的.mpc文件声明了publisher与subscriber依赖dds库、需要IDL编译器生成绑定代码等规则。如果不小心删掉了.idl文件与TypeSupport生成物的关联编译时会出现找不到头文件的错误。所以在示例目录里执行make之前务必保证DDS_ROOT、ACE_ROOT两个变量已经正确导出。2.4 环境变量与运行验证编译完成后先确认环境变量齐全。以下四句是每次新开终端都要执行的最小集合export ACE_ROOT$HOME/ACE_wrappers export TAO_ROOT$ACE_ROOT/TAO export DDS_ROOT$HOME/OpenDDS export LD_LIBRARY_PATH$ACE_ROOT/lib:$DDS_ROOT/lib:$TAO_ROOT/orbsvcs/lib提示LD_LIBRARY_PATH里如果漏了$TAO_ROOT/orbsvcs/lib运行时多半会报找不到libTAO相关的动态库这是最常见的运行期翻车点。启动验证# 终端1启动订阅者 ./subscriber # 终端2启动发布者 ./publisher看到订阅者终端打印出“收到消息: Hello World”就算跑通。同一台机器上默认配置即可不需要额外改任何传输设置。若想跨机器联调需要关注防火墙是否放行多播或改用TCP传输这块内容留到避坑章节细说。到此为止示例程序已经在你的机器上完成从编译到运行的全流程接下来拆代码。3. 核心模块拆解从IDL到DataWriter的DDS完整链路示例程序只有一个主题Topic和一种消息类型但麻雀虽小五脏俱全。发布端、订阅端、数据结构定义、构建配置一应俱全正好可以沿着一条完整的数据流把DDS的关键对象挨个认识一遍。3.1 先从IDL谈起通信双方的“数据结构契约”DDS通信的第一步不是写代码而是用IDL定义消息结构。IDL接口定义语言在这里的作用相当于一份通信双方共同遵守的数据契约发布者按这个结构填充数据订阅者按这个结构解析数据。// Messenger.idl module Messenger { struct Message { string subject; long subject_id; string text; }; #pragma keylist Message subject };这段IDL定义了一个名为Message的结构体包含subject、subject_id、text三个字段。module对应C里的命名空间编译后生成的代码会放在Messenger命名空间下。最后一行#pragma keylist Message subject指定了subject字段作为该消息类型的键key它决定了DDS如何区分不同的数据实例。简单解释一下keylist的作用发布者连续发10条subject不同的消息时订阅端需要知道这10条数据是10个不同的实例DDS就靠key字段做区分。如果IDL里不写keylist所有样本会被视为同一个实例的不同版本。3.2 发布端代码逐行拆解发布端的核心动作只有六个初始化运行时、创建DomainParticipant、注册类型、创建Topic、创建DataWriter、写入数据。看代码// publisher.cpp 关键片段 #include MessengerTypeSupportImpl.h #include dds/DCPS/Service_Participant.h #include dds/DCPS/Marked_Default_Qos.h int main(int argc, char* argv[]) { // 初始化DDS运行时argc/argv会被DDS内部的参数解析器扫描 DDS::DomainParticipantFactory_var dpf TheParticipantFactoryWithArgs(argc, argv); // 创建域参与者DomainId为42两端必须一致 DDS::DomainParticipant_var participant dpf-create_participant(42, PARTICIPANT_QOS_DEFAULT, 0, 0); // 注册消息类型第二个参数是类型注册名 Messenger::MessageTypeSupport_var ts new Messenger::MessageTypeSupportImpl(); ts-register_type(participant, Message); // 创建TopicMessageTopic是通道名Message必须与注册名保持一致 DDS::Topic_var topic participant-create_topic(MessageTopic, Message, TOPIC_QOS_DEFAULT, 0, 0); ... }TheParticipantFactoryWithArgs是一个宏展开后会从argc/argv中解析以-开头的DDS参数比如-DCPSConfigFile指定配置文件、-ORBLogFile指定日志文件不理解它会让命令行参数失效。create_participant的第一个参数42是DomainId相当于一个逻辑隔离域只有DomainId相同的参与者才能互相发现实际项目中这个值不建议硬编码应当从配置读取。继续看DataWriter的创建与数据写入// 创建发布者 DDS::Publisher_var publisher participant-create_publisher(PUBLISHER_QOS_DEFAULT, 0, 0); // 创建DataWriter并绑定到Topic DDS::DataWriter_var writer publisher-create_datawriter(topic, DATAWRITER_QOS_DEFAULT, 0, 0); // 窄化到具体的MessageDataWriter才能调用write(Message) Messenger::MessageDataWriter_var message_writer Messenger::MessageDataWriter::_narrow(writer); // 填充一条消息并发布 Messenger::Message message; message.subject Hello; message.subject_id 1; message.text World; message_writer-write(message, DDS::HANDSHAKE_INSTANCE_ID); // 给数据流出时间 ACE_OS::sleep(5); // 逆序清理先删实体再删参与者最后关闭服务 participant-delete_contained_entities(); dpf-delete_participant(participant); TheServiceParticipant-shutdown(); }窄化_narrow这步容易让人困惑。create_datawriter返回的是一个通用的DataWriter接口它只提供write(DDS::Data*)这类泛化方法。要写出具体的Message类型必须把泛化接口窄化到由IDL生成的MessageDataWriter之后才能调用write(const Message)。write的第二个参数传HANDSHAKE_INSTANCE_ID表示不手动指定实例ID交给DDS根据keylist自动管理。示例里写完后sleep(5)是为了让数据有足够时间被网络栈发送出去演示够用放到正式工程里属于反面教材第6章会用WaitSet取代它。3.3 订阅端代码与监听器回调订阅端与发布端前四步一样初始化、创建Participant、注册类型、创建Topic。区别在于后半段创建的是Subscriber和DataReader并且通过监听器接收数据。看核心回调// subscriber.cpp 关键片段 class MessageListener : public DDS::DataReaderListener { virtual void on_data_available(DDS::DataReader_ptr reader) { // 窄化到具体的MessageDataReader Messenger::MessageDataReader_var message_reader Messenger::MessageDataReader::_narrow(reader); Messenger::Message message; DDS::SampleInfo info; // 循环取出本次到达的所有样本 while (message_reader-take_next_sample(message, info) DDS::RETCODE_OK) { if (info.valid_data) { std::cout 收到消息: message.subject message.text std::endl; } } } };on_data_available是DDS的回调入口当有数据到达且满足DATA_AVAILABLE状态时被调用。DDS不会在回调里一次性只给你一条数据所以必须循环调用take_next_sample直到返回RETCODE_OK以外的状态。SampleInfo里的valid_data字段很重要它能区分这次到达的到底是真正的数据样本还是实例注册注销事件直接打印数据而不判断valid_data是新手最常犯的错误。主流程里创建Subscriber、DataReader并挂载监听器// 创建订阅者 DDS::Subscriber_var subscriber participant-create_subscriber(SUBSCRIBER_QOS_DEFAULT, 0, 0); // 创建DataReader绑定Topic DDS::DataReader_var reader subscriber-create_datareader(topic, DATAREADER_QOS_DEFAULT, 0, 0); // 设置监听器监听数据到达状态 DDS::DataReaderListener_var listener new MessageListener(); reader-set_listener(listener, DDS::DATA_AVAILABLE_STATUS);set_listener的第二个参数是状态掩码表示这个监听器关心哪些状态变化。示例只关心DATA_AVAILABLE。回调是DDS内部线程触发的不要在回调里做加锁、磁盘IO或阻塞型操作否则会拖慢整个接收通道严重时会把后续数据堵在队列里。注意到没有无论是发布端还是订阅端创建对象的顺序都是一致的——先DomainParticipant再Topic最后Publisher/Subscriber。这个顺序也决定了清理时要反着来。示例的清理代码虽然只有三行少了任何一行都可能让进程卡死在退出阶段。3.4 关键参数与QoS的默认行为把示例里出现过的关键参数列成一张表方便对照参数/常量作用调试要点DomainId示例为42域隔离同域才能互相发现两侧不一致必然收不到数据Topic名MessageTopic通道名订阅发布必须一致不一致时不会报错只静默匹配失败类型注册名Message与create_topic第二个参数对应名称差异会导致类型不匹配PARTICIPANT_QOS_DEFAULT使用DDS默认参与者QoS需要控制资源时再显式修改HANDSHAKE_INSTANCE_ID由DDS自动生成实例ID一般不需改动这些默认QoS在实践中意味着什么默认的可靠性是BEST_EFFORT默认的持久性是VOLATILE也就是说数据发出去后如果订阅者当时不在线这份数据不会积压给它补发。示例场景里发布者和订阅者几乎同时启动所以感知不到这些差异。一旦应用到真实业务就得按可靠性和持久性需求逐项调整QoS这是后话但值得提前知道。4. 踩坑实录编译、运行与联调的常见问题示例程序虽然简单但把它从源码变成两端互通一路上会遇到不少坑。以下几条是我在复现与修改示例时反复遇到过的按阶段列出来。4.1 configure阶段提示找不到ACE/TAO现象执行./configure时报告ACE或TAO not found或者提示版本过低随后退出。原因configure会先扫描系统已有的ACE/TAO扫描不到就尝试自动下载。在不少网络环境下下载会失败或者本地装的ACE版本确实太老。解决手动准备一份与OpenDDS版本匹配的ACETAO源码包解压到固定目录执行./configure --with-ace$HOME/ACE_wrappers让配置流程跳过自动获取。这里最容易踩的坑是ACE与TAO不是同一版本建议直接从OpenDDS官方文档给出的配套版本链接中一起拿不要分开乱配。4.2 编译阶段头文件或宏找不到现象make时fatal error: dds/DCPS/Service_Participant.h: No such file or directory或者报TheParticipantFactoryWithArgs、DDS::DomainParticipantFactory未定义。原因环境变量DDS_ROOT没有导出或者导出的路径不对Makefile生成时找不到头文件目录。另一种情况是执行mwc.pl时ACE_ROOT为空导致生成的Makefile里缺少include路径。解决三个环境变量ACE_ROOT、TAO_ROOT、DDS_ROOT必须全覆盖且路径不能带空格。改完环境变量后需要重新执行mwc.pl生成Makefile因为Makefile会把当时的路径烘焙进去。只改环境变量而不重新生成构建文件是过不去的。4.3 运行阶段动态库加载失败现象./publisher执行后立刻报error while loading shared libraries: libACE.so.7.0.0: cannot open shared object file。原因OpenDDS与ACE的库目录没有加到LD_LIBRARY_PATH。这个错出现在链接阶段之后编译器不会提示只有运行时才暴露。解决执行前确认导出顺序export LD_LIBRARY_PATH$ACE_ROOT/lib:$DDS_ROOT/lib:$TAO_ROOT/orbsvcs/lib注意Windows平台下等价做法是设置PATH环境变量或者把DLL拷贝到exe同目录别照着Linux的习惯在LD_LIBRARY_PATH上折腾半天。4.4 联调阶段发布订阅都正常但收不到数据现象publisher和subscriber各自正常启动终端不报任何错误但订阅端迟迟没有输出。原因排查顺序基本固定。先确认DomainId是否一致再看Topic名与类型注册名是否匹配最后检查防火墙。同一台机器上跑示例时前两者是主要因素跨机器联调时默认的IP多播传输很可能被交换机或防火墙拦截。解决在启动参数里加-DCPSConfigFile指定配置文件把传输切换为TCP并设置正确的网卡地址。配置文件的示意写法如下# 示意配置具体字段名以当前OpenDDS版本为准 [common] DCPSDebugLevel1 [transport] transport_typeThe_TCP_Transport这个方案可以绕开大部分交换机对多播的拦截。排查思路固定先改DomainId再测Topic名最后查网络。4.5 退出阶段进程结束后不退出现象main函数执行完了return 0之前程序卡住十几秒甚至几十秒不退出。原因DDS运行时还有后台线程在等数据TheServiceParticipant-shutdown()没有被调用或者删Participant之前没有删除它包含的Topic、Publisher、Subscriber等实体。解决严格按三步逆序清理participant-delete_contained_entities()先删除所有子实体再删除participant最后调用TheServiceParticipant-shutdown()。示例代码里注释写得清清楚楚但很多人觉得“退出而已无所谓”直接略过结果在压测场景里积累出一堆僵尸进程。5. 工程结构再审视MPC配置与IDL生成代码的时间线把示例跑通只是第一步。要动手改就得把工程结构看清楚。这一章从构建和代码生成两个角度把示例拆开看回答一个核心问题一段IDL是怎么变成可以被DataWriter直接使用的C类型的。5.1 示例目录里每个文件在承担什么角色以典型的OpenDDS示例为例目录结构大致如下文件/目录作用Messenger.idl数据结构定义DDS通信的蓝图Messenger.mpcMPC工程文件描述源文件、依赖库与编译选项publisher.cpp发布端入口程序subscriber.cpp订阅端入口程序common.h两端共享的常量与头文件Makefile由mwc.pl生成执行make时真正生效的构建脚本.mpc文件是理解整个构建体系的钥匙。它声明了publisher与subscriber依赖dds库、需要IDL编译器生成绑定代码等规则。如果你打算把示例复制到自己的工程里最低成本的做法是保留.mpc文件结构只替换里头的文件名与IDL名字然后重新执行mwc.pl。5.2 opendds_idl与TAO_IDL的两次代码生成示例编译时Messenger.idl要经历两次生成。第一次由OpenDDS的opendds_idl处理产出MessengerTypeSupport.idl和对应的TypeSupport实现第二次由TAO的IDL编译器处理生成C消息体代码。这两步由MPC生成的Makefile自动完成所以很多初学者编译完示例也没意识到背后发生了两次生成。生成结果大致包括MessengerC.h消息结构体的C表示与序列化编解码接口MessengerTypeSupportC.h类型注册、type support接口MessengerTypeSupportImpl.h具体实现包含register_type方法这些生成文件的命名规律是IDL文件名TypeSupport。publisher.cpp和subscriber.cpp里直接包含的MessengerTypeSupportImpl.h就是这次生成过程的产物。哪天你改了IDL但忘了重新构建就会碰到编译时用了旧类型的问题这种报错最隐蔽因为错误信息往往指向不存在的字段不会直接告诉你是IDL没重新生成。5.3 一次数据发布的完整链路把前面几章的内容串起来一次完整的发布过程是这样的发布端调用message_writer-write(message, HANDSHAKE_INSTANCE_ID)DataWriter把Message对象交给TypeSupport做序列化序列化后的字节流进入传输层按QoS决定投递方式订阅端DDS内部收到数据反序列化为Message对象监听器的on_data_available被触发回调里用take_next_sample取出Message业务处理这段链路里值得留意的是步骤4和5之间的隐性关系。DDS不会为每条数据单独回调一次而是把到达数据攒成一批后统一触发DATA_AVAILABLE状态所以on_data_available里必须用while循环把数据取干净。如果只取一次就返回剩下的数据会一直躺在队列里直到下一批数据触发回调才会被顺手取出造成数据延迟堆积。另外观察可以发现整个链路里没有网络编程的痕迹。DDS把传输层封得很死无论是本机共享内存传输还是跨机器TCP/UDP传输对业务代码都是透明的。这也是DDS这类中间件跟直接写socket最本质的区别。6. 二次开发把示例改造成自己的通信组件6.1 改出一个自定义消息类型把示例里的Message改成自己的业务结构最直接的做法是修改IDL。比如把subject改成设备IDtext改成传感器数值module Device { struct SensorData { string device_id; long sensor_type; double value; }; #pragma keylist SensorData device_id };改完IDL后删除旧的生成文件重新执行make。注意两点一是create_topic的第二个参数要和register_type注册的类型名保持一致二是订阅端的Topic名必须同步修改。示例里TypeSupportImpl.h是生成文件不要手工编辑否则下次重新生成会被覆盖。6.2 把固定sleep换成WaitSet事件驱动示例里用ACE_OS::sleep(5)等数据用来演示足够实盘场景这么做会错过大量事件。OpenDDS提供了WaitSet机制可以把阻塞等待替换成条件唤醒DDS::StatusCondition_var condition reader-get_statuscondition(); condition-set_enabled_statuses(DDS::DATA_AVAILABLE_STATUS); DDS::WaitSet_var waitset new DDS::WaitSet; waitset-attach_condition(condition); DDS::ConditionSeq active; DDS::Duration_t timeout { 5, 0 }; // 等最多5秒条件满足时立刻返回 waitset-wait(active, timeout);这段代码挂在主循环里配合监听器使用业务线程不需要无脑sleep也不会忙轮询浪费CPU。常见的做法是把它放在工作线程中当作信号量用数据到达时条件满足wait返回后立刻进入处理逻辑。最后说个小习惯。我最早改这个示例时为了省事跳过了shutdown三步曲结果在批量跑压力测试时堆了一堆不退出的僵尸进程排查了半天才发现是清理顺序的问题。从那以后我每次改完代码都会强制走一遍“清实体、删参与节点、shutdown”的流程再配合内存检测工具确认没有泄漏。希望你也能一开始就养成这个习惯少走我走过的弯路希望这次拆解能帮到你。本文还有配套的精品资源点击获取