如果你和我一样是从ROS的开发习惯里摸进自动驾驶这个领域的第一次打开Apollo的代码仓库十有八九会被Cyber RT这个名字卡一下。ROS早就成了机器人界的事实标准百度为什么要在Apollo里放弃ROS另起炉灶做一套自己的运行时通信框架这篇学习笔记是我梳理Cyber RT的第一篇适合两类人一类是有ROS底子、想快速切入Apollo的开发者另一类是没有ROS基础、但想理解自动驾驶底层通信框架到底在解决什么问题的读者。我会把Cyber RT的来历、基本概念、和ROS的对照关系以及一个最小组件的运行体验放在一起讲尽量说人话不堆概念。1. 为什么百度要抛掉ROS另起炉灶Cyber RT诞生的背景1.1 自动驾驶对通信框架的“硬要求”到底有多硬先别急着对比API我们先看看自动驾驶车端软件要面对什么样的通信压力。一辆装了多线激光雷达、前视/环视相机、毫米波雷达、IMU/GNSS的测试车每秒产生的原始数据量轻松到几百MB甚至上GB。感知模块要把点云和图像融合出障碍物列表预测模块要实时推算行人轨迹规划模块要输出一条毫秒级可执行轨迹控制模块再紧跟其后把指令发到线控底盘——整个过程是一个以10Hz、20Hz甚至更高频率不断循环的闭环。这意味着通信框架不能只是“能把数据传过去”而已它还必须在高吞吐下保持低延迟、低抖动调度要确定进程异常不能拖垮整个系统。更重要的是自动驾驶软件是多个团队协作开发的感知、定位、预测、规划、控制每个团队都在产出和消费大量消息框架得像一根足够粗、足够有序的数据总线把所有这些环节串起来。ROS1在这件事上确实吃力。我最早用ROS做机器人时其实没太大感觉因为传感器量级小几个话题来回传也没问题。但到了自动驾驶场景ROS1的集中式Master节点就成了单点依赖roscore挂了整个系统拓扑发现就瘫了。加上ROS1的话题通信走TCP默认使用TCPROS大数据量、高频场景下带宽和延迟都不好看。还有它的回调模型每个订阅者来一条消息就触发一次回调线程上下文切换开销在高频通信下会被放大。ROS2后来用DDS解决了去中心化和QoS的问题但Apollo确定换底座的时机其实很早大概在Apollo 3.5版本Cyber RT就正式替代了ROS成为默认运行时框架。回头去看这是个很清醒的决策不是ROS不好而是自动驾驶场景对框架有更特殊的要求。1.2 Cyber RT的设计定位不是“又一个ROS”既然要自研Cyber RT到底想解决什么我在官网文档里看到的定位很直白面向自动驾驶场景的高性能、低延迟、可扩展的运行时框架。它有四个关键词值得划重点高性能用协程调度替代传统线程回调降低上下文切换开销支持Shared Memory传输减少大数据拷贝。确定性调度器能感知组件之间的数据依赖关系把执行顺序排好而不是等消息到了再随机触发。模块化/组件化系统被拆成标准Component每个组件声明自己订阅什么、发布什么、依赖什么框架据此统一管理。去中心化没有单一Master节点服务发现分布在节点之间完成避免roscore那种单点故障模式。我自己的理解是ROS像一台家用SUV日常通勤省心省力生态也够丰富而Cyber RT更像为一条24小时运转的快递分拣线定制的传送带和调度系统它要的是可控、确定、高吞吐。所以大家在接触Cyber RT时不要抱着“只不过换一套通信API”的心态它连执行模型都换掉了。2. 先把Cyber RT的四个基本概念焊死在脑子里2.1 ComponentCyber世界的“最小业务单元”如果你从ROS过来最容易找到的类比就是把Component当作Node。但严格来说它们并不完全一样Node是一个更自由的执行实体你可以自己决定里面做什么、怎么调用而Component是The Cyber RT框架管理的最小业务单元它的生命周期和调度都由框架接管你只需要实现几个约定好的接口。一个Component通常是这样组织起来的构造函数里做最基础的初始化实现一个Init()返回true表示组件初始化成功实现一个Proc()每收到一帧订阅数据就会回调它用宏CYBER_REGISTER_COMPONENT注册组件框架才能动态加载。这里有一个很关键的设计逻辑组件在启动时就把自己“订阅哪个channel、发布哪个channel”声明清楚了所以调度器在运行前就能构建出一张完整的数据流依赖图提前规划好协程的执行顺序。用ROS写节点时我们很少会声明自己的依赖关系反正消息来了就触发回调但Cyber RT把这件事显式化其实更接近工业级框架的做法。2.2 Channel 与 Reader/WriterChannel是Cyber RT里数据通信的核心载体按名字区分类似ROS里的Topic。发布者叫Writer订阅者叫Reader对应ROS的Publisher和Subscriber。消息格式使用Protobuf定义也就是说数据不是任意序列化的而是有明确的schema约束。这个设计带来的好处是跨模块的接口清晰可查模块间对字段的增删改都有迹可循编译期就能发现不兼容。实际写代码的时候一个组件里可以同时持有多个Writer和多个Reader。比如规划组件订阅感知结果、订阅当前轨迹再发出规划轨迹也有的组件只订阅不发布比如可视化模块还有的组件只发布不订阅比如某个虚拟时钟发生器。Channel的传输机制在不同场景下会走不同的路径进程内消息通过内存传递跨进程的通信可以使用Shared Memory来避免反复拷贝必要时也会落到网络传输。这些底层细节对上层Component是透明的但性能差距很可观稍后讲ROS对照时会细说。2.3 Service 和 Parameter消息通信能覆盖大部分异步场景但总有需要“请求-应答”的地方比如地图模块被问到“这个坐标附近有车道线吗”控制模块被问到“当前速度是多少”。这个机制在Cyber RT里直接叫Service语义和ROS的Service几乎一致一个客户端发起请求服务端处理后返回响应。写法和ROS的srv定义有点像但底层用的是Protobuf Service定义。Parameter则是用来做运行时参数配置的对应ROS的Parameter Server。以前你用rosparam set改参数在Cyber RT里通过Parameter接口做类似的事。很多老司机刚迁移时会忽略Parameter的重要性以为反正配置写在配置文件里就行但实际上Cyber RT里很多组件的动态配置比如开关、阈值、模式切换都是通过Parameter在运行时修改的学会它能省去大量重启调试的时间。2.4 协程与调度器Cyber的高性能秘密如果不理解协程就很难真正理解Cyber RT为什么快。你可以把线程想象成一个大厨每个大厨同时只能炒一盘菜但切菜、炒菜的过程里总有一部分时间是手闲着等锅热。如果每个灶台都配一个厨师人力成本太高如果只让一个厨师炒完一盘再炒下一盘锅又多的时候效率太低。协程的思路是一个线程厨师可以同时管理很多个协程灶台上的菜哪个菜需要翻炒了就去处理哪个不需要的时候立刻切到另一个任务上切换成本比线程切换低得多。Cyber RT的调度器就是干这个的。它根据组件的订阅关系和数据依赖把Proc协程交给不同线程去执行哪个就绪了就调度哪个避免“来一条消息就开一个线程”的粗放玩法。你可以在自己的组件里用cyber::Async或Concurrent等接口去派发异步任务但绝大多数情况下框架已经帮你把该干的事干好了。理解了这个模型之后你会更容易理解为什么Cyber RT能支撑起感知模块同时订阅多个大流量传感器数据而CPU开销还可控。3. 与ROS的详细对照不只是换了个名字3.1 一张表看清Cyber RT和ROS的对应关系很多学习资料会把Cyber RT说成“翻版ROS”我自己对照完之后发现概念映射其实只对了一半至少这张表你要先存一份功能/概念ROS以ROS1为参照Cyber RT系统初始化roscorecyber::Init最小执行单元Node节点Component组件进程/组件管理每个节点一个进程较自由组件可配置加载到进程框架统一管理异步消息通道Topic话题Channel信道消息发布/订阅Publisher / SubscriberWriter / Reader同步请求/应答Service服务Service服务参数配置Parameter Server参数服务器Parameter参数消息定义.msg / .srv.protoProtobuf启动方式roslaunch .launchcyber_launch .launch数据录制/回放rosbagcyber_recorder*.record可视化工具rviz / rqtcyber_visualizer / cyber_monitor节点发现依赖roscore分布式服务发现无中心节点执行模型线程回调协程调度器这张表能帮你节省大量“这东西ROS里叫什么”的搜索时间。但我也要提醒你如果你以为Cyber RT只是给ROS概念换了个马甲那接下来读源码的时候你会感觉很割裂因为名称只是表象底层机制是两套东西。3.2 名字背后的机制差异这才是真正的偷师点第一处差异是去中心化的节点发现。ROS1依赖roscore做节点相互认识的中介而Cyber RT用的是分布式服务发现机制即使某一台计算节点的服务发现协议出现问题系统不至于全盘不可用。这在车端多计算平台自动驾驶一般不止一台工控机的场景里非常重要。第二处差异是执行模型。ROS里你写一个订阅回调消息来了线程直接执行回调多个消息高频涌入时线程切换非常频繁。Cyber RT把每个组件的Proc逻辑包成协程调度器在少量线程上协作式调度大量协程协程切换是用户态的操作开销比操作系统线程切换小一截。数据量越大这个优势越明显。第三处差异是共享内存传输。ROS1的消息传输默认走TCP即使在同一台机器上也要经过内核协议栈的拷贝带宽和CPU都在烧。Cyber RT在同机场景下支持共享内存通道点云和图像这种动辄几MB的消息不需要反复跨进程拷贝可以极快地投递给多个订阅者。我在实际跑数据时直观感受就是同样一份点云话题在ROS1里订阅者一多带宽立刻报警而Cyber RT在同机场景下明显从容得多。第四处差异是构建和集成方式。ROS有自己的catkin/colcon工作空间节点可以独立编译运行Cyber RT则鼓励开发者按照Apollo的模块体系用统一的构建系统把组件、proto消息、配置打包成可被动态加载的库。刚开始我也觉得麻烦但用顺手之后发现模块间依赖清晰到可以“复制一个模块改一改”就完成新功能这在多人协作的团队里非常有用。所以面对Cyber RT时不要只把它当“另一个ROS”要把它当成一套“为自动驾驶定制的组件化运行框架”来学。从ROS带过来的思维方式可以帮助你入门但最终要放下“节点里什么都能写”的随意拥抱“组件该声明依赖、框架负责调度”的秩序感。4. 第一次用Cyber RT写组件踩坑记录和上手体验4.1 环境准备先跑起来再说我最初看的Apollo版本是基于Docker的开发环境官方提供了预先构建好的开发容器镜像。不用自己在本机裸装一套Cyber RT那个依赖链能让人崩溃。常规流程是在工程根目录找到docker/scripts/dev_start.sh启动开发容器dev_into.sh进入容器里面已经装好了Cyber RT运行环境和编译工具链编译时Apollo的构建系统从早期Bazel演进了后来的buildtool方案不同版本命令略有不同我记得6.0以后多用./apollo.sh build或buildtool build --packages cyber这种形式具体以你的官方文档为准。当时我花了一下午才意识到Cyber RT不是独立工程它是Apollo运行时的重要组成部分不能从Apollo源码里完全切出去单独研究。想快速验证最省力的办法是直接跑官方examples里的cyber模块示例比如cyber/demo下面就有现成的talker/listener例子或者找一个已有的组件跑起来看看日志确认环境通了再动手写自己的。4.2 一个最小可运行的Component骨架环境通了之后我按官方照猫画虎写了一个最小组件订阅一个Hello消息然后打印出来。整个流程涉及四个文件头文件、源文件、DAG配置文件、Launch启动文件。我用的是类似这样的一副骨架头文件和源文件的核心代码// my_component.h #pragma once #include cyber/component/component.h #include modules/examples/my_hello/proto/my_hello.pb.h class MyHelloComponent : public apollo::cyber::ComponentMyHelloMsg { public: bool Init() override; bool Proc(const std::shared_ptrMyHelloMsg msg) override; };// my_component.cc #include modules/examples/my_hello/my_component.h bool MyHelloComponent::Init() { AINFO [MyHelloComponent] init success; return true; } bool MyHelloComponent::Proc(const std::shared_ptrMyHelloMsg msg) { AINFO [MyHelloComponent] recv: msg-content(); return true; } CYBER_REGISTER_COMPONENT(MyHelloComponent)DAG配置大概是这个样子它指定了加载哪个动态库、实例化哪个组件、订阅哪个Channelmodule_config { module_library : lib/lib_my_hello_component.so components { class_name : MyHelloComponent config { name : my_hello readers { channel : /apollo/my_hello } } } }Launch文件则是框架层面的入口它告诉Cyber RT要加载哪个DAG文件cyber module namemy_hello_component/name dag_confconf/my_hello.dag/dag_conf /module /cyber这里最值得一说的是CYBER_REGISTER_COMPONENT这个宏它把类名注册到Cyber的component工厂里之后DAG文件才能通过class_name动态实例化这个组件。如果漏了这行宏编译不会报错但运行时框架加载不到你的组件什么都不输出——这个坑我后面单讲。4.3 实战中我踩过的三个坑提前帮你排掉第一次跑通这个流程我踩了三个记忆深刻的坑第一个坑就是上面说的忘了注册组件。我编译生成so文件后改好Launch和DAG启动之后日志干干净净没有错误也不跑我的逻辑。当时我一度怀疑是DAG路径不对后来翻到官方示例逐字对比才发现少了CYBER_REGISTER_COMPONENT。这算是Cyber RT和ROS最大的“手感差”之一ROS里你编译一个可执行文件直接运行就行Cyber里你写的组件不是一个独立进程而是要被框架动态加载的库注册宏就是你和框架之间的“接缝”漏了它系统根本不知道你的类存在。第二个坑是proto消息一致性问题。我自定义了一个proto消息类型并在组件里通过Reader订阅它结果启动后组件一直收不到数据。排查了一圈原因是我发布端使用的消息类型和订阅端使用的消息类型虽然名字一样但是在两个不同模块里分别generate出来的proto的package路径对不上导致消息描述符不匹配。后来我把消息定义统一放在一个公共proto目录下发布端和订阅端共用同一个生成目标问题才解决。在Cyber RT里消息类型不是“只要字段一样就行”而是要求proto定义完全一致这点比ROS的.msg体系更严格。第三个坑是关于调试手段。我当时习惯性地想rosbag play那种玩法实际上Cyber RT用cyber_recorder和cyber_monitor来干这活儿而且我第一次用cyber_monitor时没加任何参数启动后界面上确实能看到所有Channel列表数据频率也一目了然但我的自定义Channel迟迟不出现那是因为我的组件根本没有启动成功。Cyber RT里“消息有没有在流动”和“组件有没有在跑”是两件可以分开确认的事cyber_monitor看Channelcyber_recorder录制/回放数据运行日志里找Init和Proc的打印。把这些工具用顺了排查效率完全不一样。还有一个小技巧cyber_recorder可以指定录制某些Channel玩数据集回放时非常有用。我之前试过把公开的rosbag数据转成record格式需要注意消息定义与目标模块的proto定义保持一致否则回放时部分Channel订阅不到消息。这个转换过程并不像rosbag那样开箱即用建议先从官方示例的record数据开始练手。4.4 一个小小的心得从“仿照示例”到“理解机制”跑通这个最小组件之后我开始有底气去看Cyber RT源码里调度器和协程的设计思路了。回头再总结这段经历我觉得第一次上手Cyber RT最高效的路径不是从头把文档啃一遍而是先改一个最小示例让它跑起来的全过程中密集接触这些核心概念Component、Writer/Reader、DAG文件、Launch文件、注册宏、cyber_monitor、cyber_recorder。每一步的失败和成功都比单纯读概念记得更牢。这里也多说一句如果你想拿ROS的习惯去套Cyber RT的组件开发会有一段别扭的适应期。ROS里你可以很随意地在节点里开线程、同步等待、自己做消息队列Cyber RT则更希望你“声明式”地描述这个组件依赖什么、产出什么剩下的事情交给框架调度。它不是限制你的自由而是把自动驾驶系统最需要的确定性带给整个架构。想通这一点那些看似繁琐的配置和注册过程其实都是整个系统稳定性的基石。
阅读完成 · 觉得有帮助?