ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FastDDS Python绑定实战:打通C++主控与Python算法的DDS通信

FastDDS Python绑定实战:打通C++主控与Python算法的DDS通信 去年在实验室给一台AGV小车做传感器数据回传的时候我被自研通信协议折磨得不轻两个节点之间要维护心跳、要做断线重连、要处理粘包丢包代码越写越像一个迷你操作系统。项目组后来决定切换成FastDDS Python的组合通信这块直接从骨架代码变成了几个配置项我才算把精力放回算法本身。这篇东西就是那段经历的完整复盘覆盖FastDDS Python绑定的安装、IDL建模、发布订阅Demo、QoS配置和实测踩坑适合正在做机器人、自动驾驶、工业控制尤其是主控C、算法Python这种混合架构的团队参考。如果你只是想验证一下DDS能不能满足你的场景照着跑一遍就够了。1. 为什么主控和算法节点之间我最终选了FastDDS1.1 从Socket自研通信说起真正的痛点是别让我写通信框架一开始我图省事用Python的socket直接写了收发逻辑。数据格式自己定用JSON序列化消息头里塞长度字段再加一个seq做丢包检测。单节点对单节点确实能跑但一旦节点变多问题就全冒出来了每个新节点都要知道其他节点的IP和端口上线顺序错了就得重连某个节点崩溃之后还得等超时才能感知数据发送策略想从实时覆盖旧值改成补偿重发代码得大改。这些本质上不是我的业务问题但我必须为它们买单。DDS模型把这些问题全部挪到了中间件层。你不需要知道数据从哪来、发到哪去只管往全局数据空间里写,按主题名订阅感兴趣的数据。节点上线、下线、类型匹配、可靠性策略全部由DDS的发现协议和QoS机制自动处理。这个抽象对我来说最大的价值不是解耦而是省掉了我维护通信框架的隐性工时。1.2 DDS的全局数据空间一个会过滤的微信群如果用一段话来描述DDS的工作方式我通常会跟同事说它就像一个按主题分群的微信群。群里每个人都可以发消息也可以只接收特定类型的信息群外的陌生人看不到内容群成员也完全不需要关心发消息的人现在用的是哪台机器、哪个端口。发布者哪怕中途换了IP订阅者也不需要改配置重新发现就行了。落到协议层面DDS定义了实体DomainParticipant、主题Topic、发布器Publisher、订阅器Subscriber、数据写入器DataWriter和数据读取器DataReader这几类核心角色。数据按主题分类类型用OMG IDL定义跨平台、跨语言保持一致。RTPSReal-Time Publish-Subscribe协议负责底层发现和数据传输。FastDDS就是这套标准的开源实现而且是ROS 2的默认DDS中间件之一社区活跃度和生产验证程度都比较高。1.3 FastDDS为什么适合C主控 Python算法的组合我做项目时已经离不开Python算法验证、数据处理、模型推理全是Python跑通之后的部分逻辑又需要跟C主控交互。最原始的方案是C和Python各维护一套基于socket的通信代码两边的消息结构很容易漂移字段对不上很难排查。FastDDS官方提供了完整的Python绑定使用体验接近C API类型通过同一个IDL文件生成到两种语言这样两边拿到的数据结构天然一致。这样主控节点用C写算法节点用Python写数据交换走DDS我就不必再用胶水代码维护两套协议了。同时FastDDS是Apache 2.0协议商用、学习和二次开发都比较自由这是它成为我选择的重要原因。2. FastDDS Python绑定的安装比想象中容易也比想象中容易踩坑2.1 动手前的环境检查清单安装之前先确认一下你的环境别等到pip报错再回头查操作系统Ubuntu 20.04 / 22.04 都是很顺的路径Windows也能装但编译依赖多建议先用WSL2。Python版本推荐3.8以上我在3.10和3.11下都跑通过。编译工具链pip安装FastDDS时会触发本地编译需要CMake 3.16、gcc/g。Ubuntu可以直接用apt install build-essential cmake补上。虚拟环境强烈建议先用venv或conda建一个独立环境避免污染系统Python。我自己最开始就是在系统Python里直接pip结果和一个旧项目的依赖冲突浪费了一晚上。后来老老实实每次新项目都建虚拟环境通信库这种底层组件一旦被别的包搞坏版本排查成本远高于虚拟环境那几MB磁盘。2.2 两种安装路线pip快速路径与源码编译路径最省事的方式是直接安装官方发布到PyPI的包pip install fastdds这条命令会自动拉取FastDDS C核心库和Python绑定并完成编译。实测在干净的Ubuntu 22.04虚拟环境里整个过程大概几分钟。但要注意pip安装依赖本地编译环境所以先安装build-essential和cmake是必须的。如果你不想动系统Python先执行python3 -m venv fastdds_env source fastdds_env/bin/activate pip install fastdds如果你的项目需要定制FastDDS底层特性比如修改传输插件或打补丁那就得走源码路径从GitHub拉取eProsima的Fast-DDS和Fast-DDS-python仓库按官方README用CMake构建。日常原型验证真的不需要走到这一步我建议大部分读者先跑通pip版本确认场景可行再决定要不要源码定制。2.3 验证安装与两个经典报错安装完成后验证是否可用import fastdds print(fastdds.__version__)能打印出版本号说明基础绑定已经正常。我遇到过的两个经典报错这里提前排掉报ModuleNotFoundError多半是没在正确环境里pip装的包和当前Python解释器不属于同一个虚拟环境。报OSError: libfastdds.so.X: cannot open shared object file说明Python绑定库找到了但运行时缺C动态库。这个一般是因为FastDDS C库被装到了非系统默认搜索路径。解决办法是找到libfastdds.so.X所在目录然后写入环境变量export LD_LIBRARY_PATH/path/to/fastdds/lib:$LD_LIBRARY_PATH2.4 IDL编译工具fastddsgen准备只装Python绑定还不算完整因为实际项目里要用IDL定义消息类型需要准备编译工具。FastDDS官方提供了一个名为fastddsgen的IDL编译器可以从GitHub仓库获取发布包依赖Java运行环境因为底层基于ANTLR。如果你发现直接在命令行执行fastddsgen没反应先确认JDK版本。这一步容易被忽略但一定别跳。后面消息类型是否跨语言一致全靠它生成的代码来保证。3. 最小可运行订阅发布Demo从IDL到两端代码3.1 为什么一定要从IDL开始一开始我也想过直接用Python的字典当消息传不香吗但DDS的发现协议要求通信双方明确知道数据类型如果两边定义不一致它们在发现阶段就匹配失败。而IDL定义的数据类型既能编译成C结构体也能编译成Python类天然避免字段名拼错、类型对不上这种低端问题。这就像两份合同按同一份模板起草签之前就确保条款一致而不是签完再逐字对比。3.2 定义消息类型并生成Python代码我们以传感器数据为例新建一个SensorData.idlstruct SensorData { long sensor_id; string frame_id; unsigned long long timestamp; sequencedouble values; };这个结构可以描述某个传感器在某个时刻采集的一组浮点数据足够覆盖激光雷达点云、IMU序列、温度曲线等常见场景。然后用fastddsgen生成Python绑定fastddsgen -python SensorData.idl不同版本的fastddsgen参数写法可能略有差异如果你执行时发现命令不对看一下fastddsgen -help里的Python选项。生成后目录里至少会出现SensorData.py数据类和SensorDataPubSubTypes.py类型支持类后面的代码都会用到它们。3.3 发布端代码逐段拆解创建publisher.pyimport time import random import fastdds from SensorData import SensorData from SensorDataPubSubTypes import SensorDataPubSubType class WriterListener(fastdds.DataWriterListener): def on_publication_matched(self, writer, info): print(发现订阅端当前匹配数量:, info.current_count) def main(): factory fastdds.DomainParticipantFactory.get_instance() participant factory.create_participant(0, fastdds.PARTICIPANT_QOS_DEFAULT) type_support SensorDataPubSubType() participant.register_type(type_support) topic participant.create_topic( sensor_data_topic, type_support.get_name(), fastdds.TOPIC_QOS_DEFAULT ) writer_qos fastdds.DataWriterQos() writer_qos.reliability.kind fastdds.RELIABLE_RELIABILITY_QOS writer_qos.history.kind fastdds.KEEP_LAST_HISTORY_QOS writer_qos.history.depth 10 listener WriterListener() publisher participant.create_publisher(fastdds.PUBLISHER_QOS_DEFAULT) writer publisher.create_datawriter(topic, writer_qos, listener) seq 0 while True: data SensorData() data.sensor_id 1 data.frame_id lidar data.timestamp int(time.time() * 1000) data.values [random.random() for _ in range(10)] writer.write(data) seq 1 print(发布第, seq, 帧) time.sleep(1) if __name__ __main__: main()简单拆解几个关键点create_participant(0, ...)的第一个参数是domain_id0号是默认域。通信双方必须使用同一个domain_id否则互相发现不了。register_type把自定义类型的类型支持注册到参与者上FastDDS才能识别这个类型。writer_qos.reliability.kind fastdds.RELIABLE_RELIABILITY_QOS意思是可靠传输底层会做应答和重传适合控制指令或重要状态消息。on_publication_matched回调很重要当订阅端上线时你会立刻看到日志这是排查为什么没收到数据的第一个观察点。3.4 订阅端代码逐段拆解创建subscriber.pyimport time import fastdds from SensorData import SensorData from SensorDataPubSubTypes import SensorDataPubSubType class ReaderListener(fastdds.DataReaderListener): def __init__(self): super().__init__() self.reader None def on_data_available(self, reader): info fastdds.SampleInfo() data SensorData() if reader.take_next_sample(data, info): if info.valid_data: print( sensor_id:, data.sensor_id, frame:, data.frame_id, timestamp:, data.timestamp, values前3个:, data.values[:3] ) def main(): factory fastdds.DomainParticipantFactory.get_instance() participant factory.create_participant(0, fastdds.PARTICIPANT_QOS_DEFAULT) type_support SensorDataPubSubType() participant.register_type(type_support) topic participant.create_topic( sensor_data_topic, type_support.get_name(), fastdds.TOPIC_QOS_DEFAULT ) reader_qos fastdds.DataReaderQos() reader_qos.reliability.kind fastdds.RELIABLE_RELIABILITY_QOS reader_qos.history.kind fastdds.KEEP_LAST_HISTORY_QOS reader_qos.history.depth 10 listener ReaderListener() subscriber participant.create_subscriber(fastdds.SUBSCRIBER_QOS_DEFAULT) reader subscriber.create_datareader(topic, reader_qos, listener) listener.reader reader print(等待数据按CtrlC退出...) try: while True: time.sleep(1) except KeyboardInterrupt: pass if __name__ __main__: main()这里最核心的是on_data_available回调。注意take_next_sample返回后必须检查info.valid_data因为回调可能意味着数据到达也可能只是缓存状态变化。如果不加这个判断你会在刚启动时读到一堆空样本。3.5 运行效果与关键观察开两个终端先跑订阅端再跑发布端python subscriber.pypython publisher.py如果一切正常发布端终端会立刻打印发现订阅端订阅端终端也会按每秒一条的频率打印接收到的数据。这个先后顺序本身也说明DDS的一个特性订阅端晚于发布端启动也没关系它们会通过发现协议自动建立连接。就冲这一点Socket方案就比不了。如果订阅端没数据先把两个终端的日志拉出来看有没有匹配日志再检查QoS配置而不是一上来就怀疑网络。4. QoS配置决定这套通信系统上限的隐形参数4.1 QoS一旦不匹配消息就可能悄悄消失很多第一次用DDS的人会问数据不是应该全网可见吗为什么我的订阅端收不到答案往往是QoS配置不兼容。DDS在发现阶段会检查发布端和订阅端的QoS参数如果有一项不兼容两个实体就不会建立通信链路。这种静默失败是最迷惑人的因为你的进程日志里没有任何报错两端都在跑但订阅端就是收不到数据。所以理解QoS不是进阶要求而是用DDS的基本功。4.2 四组最常用QoS策略的实战搭配我整理了一份自己常用的配置对照表按场景选就好QoS策略关键取值适用场景我的习惯RELIABILITYRELIABLE / BEST_EFFORT控制指令用RELIABLE高频传感器数据用BEST_EFFORT控制通道RELIABLE传感器点云BEST_EFFORTDURABILITYTRANSIENT_LOCAL / VOLATILE晚加入的订阅端是否需要历史数据静态地图用TRANSIENT_LOCAL实时数据用VOLATILEHISTORYKEEP_LAST depthN / KEEP_ALL控制历史缓存条数一般KEEP_LASTdepth取10~100DEADLINE指定时间周期要求发布端周期性发数据心跳类消息必须设以上四组参数是生产环境里最常调整的。RELIABILITY决定要不要重传DURABILITY决定后加入的节点能不能拿到旧数据HISTORY决定缓存多少历史样本DEADLINE决定发布节奏上限。举个例子控制指令这类消息丢失一条都可能导致设备行为异常我用RELIABLE KEEP_LAST(10) VOLATILE。对于激光雷达点云这种高频、丢一帧无所谓的传感器数据我用BEST_EFFORT KEEP_LAST(1)这样只保留最新一帧延迟最低也不会堆积旧数据。4.3 TRANSIENT_LOCAL与VOLATILE一个实战差异这里提一个最常见的我以为你收到了其实你没有的场景。发布端先启动用VOLATILE发布了一条数据订阅端后启动那么这条数据订阅端是收不到的因为VOLATILE代表没有历史数据留存。如果你希望晚加入的订阅端也能拿到当前值比如机器人导航节点启动后需要立刻知道当前位置而不是等下一帧那就把发布端和订阅端都配成TRANSIENT_LOCAL。注意这是两端配合的发布端要允许存历史订阅端要声明自己需要历史。两边都满足时DDS才会在匹配后立刻将缓存的历史样本推给新订阅端。我最早做这个配置时只改了发布端订阅端还是VOLATILE结果匹配成功了但依然没有历史数据后来查文档才明白是双方共同决定的行为。这不是FastDDS的坑而是DDS标准的语义只是初学时容易忽略。4.4 TypeObject、domain_id与跨进程发现的关联QoS之外还有两个容易忽略的隐形开关。第一个是TypeObject。FastDDS会通过RTPS协议交换类型信息通信双方不仅对比类型名称还会对比类型结构。你用旧版本IDL生成的代码发送数据对端用新版本IDL编译的代码接收即使类型名称一样TypeObject校验不过匹配也会失败。这个机制在上线阶段很安全但在开发迭代期很折腾后面我专门踩过一次。第二个是domain_id。它就像一个完全隔离的微信群编号不同domain_id的参与者不会互相发现。很多人调试了半天发现订阅端收不到数据结果打开代码一看发布端domain_id是0订阅端domain_id是1。这类问题日志里通常不会很明显排查时先确认两边的domain_id完全一致。5. 性能实测与避坑记录5.1 小消息时延与吞吐量的直观数据我在一台i5-12600K、Ubuntu 22.04的机器上做了个简单测试消息总长度不到200字节走本地回环网络发布端和订阅端是同一个host上的两个独立Python进程。实测下来单次端到端时延大概在0.2ms到0.8ms之间波动偶尔会跳到1ms以上。这个量级对大部分控制周期在10ms以上的机器人应用完全够用。FastDDS C版本可以做得更低但Python绑定最大的开销其实不在DDS本身的网络栈而在消息序列化和Python对象转换过程。关于吞吐量我用1KB左右的消息连续发布Python绑定实测大约能跑到两三百Mbps。这个数字在Socket方案里看起来不起眼但对于Python层的算法验证工作已经完全够用。如果你的Python节点要处理点云这样的大消息建议让C主控直接做转发和压缩Python侧别碰大数据通道。5.2 我踩过的三个坑GIL、IDL变更静默失败、大消息超时第一个坑Python多线程发布吞吐不升反降。FastDDS的Python绑定底层虽然是C但数据写入前需要把Python对象序列化成字节流这段逻辑仍然受Python的GIL限制。我一开始天真地开了四个线程每个线程一个DataWriter分别发布不同主题结果总吞吐几乎没有提升。后来的做法是保持单一发布线程其他业务线程通过队列把数据交给发布线程统一发送。第二个坑IDL字段变更后两端匹配成功但收不到数据。有一次我给SensorData加了一个字段重新生成了发布端代码但忘了重新生成订阅端代码。结果日志里显示双方发现了彼此订阅端的on_data_available也一直在触发但valid_data始终为False。后来才反应过来是TypeObject结构不匹配序列化出来的数据订阅端解析不了。从那以后我把改IDL后必须同步重新生成两端代码写进了项目检查清单里。第三个坑大消息丢数据。当一条消息大到接近UDP传输上限时RTPS协议会给消息分片。在小数据量测试时一切正常一旦把完整的点云数组塞进去订阅端开始偶发收不到完整数据。我最后的解决方案不是无限调大缓冲区而是调整传输设计大数据不直接通过DDS消息体传而是先在共享内存或文件系统里落盘DDS只传元数据和路径。这个模式在工业场景里很常见可以少踩很多坑。5.3 调试DDS问题时的工具箱如果你也遇到能匹配但收不到数据或者偶发丢消息按这套顺序排查会快很多打开日志详细级别设置环境变量FASTDDS_LOG_LEVELInfo再运行程序重点看Discovery阶段的输出。用Wireshark抓包过滤RTPS协议可以直观看到两端是否发送了数据和ACK包。用一个极简的发布订阅Demo最小化QoS配置先确认发现链路是通的再逐步加上别的策略。检查两边的domain_id、topic名、类型名是否完全一致这是最低级的错误但也是最常见的。5.4 一点关于架构选择的题外话FastDDS Python这个组合让我最满意的地方不是某一个指标特别能打而是它把通信从我的项目里变成了一个稳定的基础件。自研Socket方案每次加需求都要改收发逻辑DDS方案里这些需求基本只是改一块配置。当然代价是引入了一套新概念QoS、domain_id、TypeObject这些都得理解但我个人觉得这笔前期成本是值得的。最后分享一个我目前仍在用的实践所有Python端的DDS封装代码保持薄薄一层只做类型注册和数据读写业务逻辑全部放在外层。这样哪天需要把某个节点换成C版本只需要替换接口实现业务代码几乎不用动。这也是我在做完这个项目之后对FastDDS Python组合最大的信任来源。
返回列表