ARTICLE DETAIL

资讯详情

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

Apollo Cyber RT入门:从ROS视角理解实时通信框架

Apollo Cyber RT入门:从ROS视角理解实时通信框架 2020 年我第一次打开 Apollo 的源码仓库第一反应是这哪里是一个自动驾驶项目这分明是一座代码迷宫。模块多得数不过来编译动辄几个小时启动脚本一把一把的里面还藏着一个叫 Cyber RT 的东西名字很唬人文档却很稀薄。后来我把 ROS 的东西全部忘掉重新按 Cyber RT 的套路思考才慢慢理出一条线来。这篇笔记专门写给两类人一类是刚接触 Apollo、想搞懂 Cyber RT 到底是干什么的初学者另一类是像我一样从 ROS 转过来的老机器人开发者想把 ROS 里的 Topic、Service、Node 这些熟悉的概念平移到 Cyber RT 里。我尽量用刚踩完坑的人的角度来讲不堆源码只讲清楚“为什么是它”、“它到底做了什么”以及在切到 Cyber RT 时哪几个地方最容易被 ROS 的惯性思维带沟里。在正式拆之前先点一下题Cyber RT 是百度 Apollo 在自动驾驶场景下自研的实时通信框架负责整个系统里模块之间的数据交换、消息调度、任务执行和资源管理。你可以把它理解为 ROS 的 Node 通信机制加上一个确定性调度器再加上一块共享内存最后再解决掉 ROS 里的序列化和进程调度开销问题。1. 先搞清楚 Cyber RT 在 Apollo 里的位置1.1 Apollo 这个大系统到底需要 Cyber RT 解决什么Apollo 是一个完整的自动驾驶软件栈里面有感知、定位、预测、规划、控制、高精地图、人机交互等几十个模块。这些模块之间的关系如果画成一张图会相当复杂感知模块要出障碍物列表预测模块要基于障碍物列表输出预测轨迹规划模块要根据预测结果和地图信息生成轨迹控制模块再执行轨迹。问题的关键在于这些模块不是简单地“按顺序调用”而是在不同频率、不同数据源、不同延迟要求下并行运行的。举例来说相机感知输出的是 30 帧每秒的障碍物信息激光雷达感知输出的是 10 帧每秒的点云检测结果而控制模块需要以 100 Hz 的频率向底盘发送指令。这些模块同时跑在一台工控机上数据要要快速流转起来不能出现某一帧数据因为等待而超时更不能因为某个模块阻塞就把整条链路卡死。传统 ROS 在这种场景下会遇到几个非常现实的问题。第一roscore 是所有节点的中心枢纽一旦它挂了整个系统全挂第二频繁的序列化和反序列化会在大吞吐量点云、图像下带来明显 CPU 开销第三ROS 默认的 TCPROS 传输走网络协议栈即便是本机通信也要过一遍 socket延迟高而且不稳定第四ROS 里的回调执行依赖单一进程内的多线程调度节点启动顺序不对、频率不匹配时经常丢消息。Autoware 等开源方案做过不少这方向的尝试但大多还是在 ROS 生态里打转实时性上不去。Cyber RT 就是冲着这些痛点去的。它把通信、调度、数据缓存三件事统一到了一个框架里不再强行区分“网络通信层”和“业务逻辑层”。每个模块被抽象成 Component订阅固定的 Channel当数据到达时由内部调度器唤醒对应的协程来执行而不是启动一个阻塞的线程。这个设计从根上决定了 Cyber RT 比 ROS 更适合无人车这种对延迟和确定性要求极高的场景。1.2 为什么不能用 ROS 直接改非要自研一套每次讲到这都会有人问ROS 1 不行那 ROS 2 呢ROS 2 把通信中间件换成了 DDS也解决了实时性、分布式、安全性这些 ROS 1 的历史问题而且社区更大、生态更完整。那 Apollo 为什么还要自己造轮子一个重要的原因是 DDS 的配置和学习成本实在高得离谱。ROS 2 本身没有绑定特定 DDS 实现但不同的 DDS 厂商Fast DDS、Cyclone DDS、RTI Connext、Eclipse Zenoh 等在 QoS 策略、共享内存支持、跨平台能力上差别巨大配置复杂到甚至需要专门的 DDS XML 文件。对于 Apollo 这种发布到量产车上的软件栈团队必须保证通信层完全可控——延迟曲线、内存占用、调度行为都要能精确测量出了问题还要能快速定位。把通信底座建立在自己完全掌控的代码上虽然前期投入大但后期在性能优化和问题排查上的收益是巨大的。还有一个现实原因是自动驾驶的数据量太大。一个 64 线激光雷达的点云一帧大约 4 MB如果每个 10 Hz 的模块间都用 socket 传一遍带宽和延迟都吃不消。Cyber RT 对大数据量通道默认走 SharedMemory 传输消息只拷贝一份进共享内存区读端直接拿指针性能非常可观。这种针对自动驾驶场景做深度定制的方案在通用机器人框架里是做不到的。当然这不意味着 ROS 专家在 Cyber RT 面前会从零开始恰恰相反Cyber RT 的很多核心概念几乎就是 ROS 换了个马甲。你懂 ROS就手握一张高频词汇对照表这就是下一篇笔记要讲的。2. Cyber RT 的核心概念和 ROS 一个一个对照2.1 节点Node、Writer 和 Reader对应 ROS 的 Publisher / Subscriber先说最基础的。在 ROS 里一个节点Node是一个独立的计算单元通过ros::Publisher往 Topic 上发布消息通过ros::Subscriber订阅消息。而在 Cyber RT 里最小的对象同样叫 Node不过它的发布订阅接口变成了 Writer 和 Reader。一个典型的 Cyber RT Node 长这样创建 Node用CreateWriterMessageT(channel_name, qos)得到一个 Writer用CreateReaderMessageT(channel_name, callback)得到一个 Reader。Writer 负责往指定 Channel 上写消息Reader 则在消息到达时被框架回调。从 API 使用上看Cyber RT 的 Node 比 ROS 的 NodeHandle 更直观因为 Reader 创建时要求传入回调函数而这个回调会真正在数据到达时被调度器调度而不是在一个内部 event loop 里排队。写代码的时候你不需要主动调spin()Cyber RT 的调度器会在后台统一管理所有回调的触发时机。重要区分ROS 里如果你用完 spy 之后不 spin回调永远不执行在 Cyber RT 里只要你创建了 Reader并在进程启动时调用了cyber::Init框架会默认调度所有回调不写 spin 也没关系。初学的时候经常忽略这一点导致总感觉回调没被触发。2.2 通道Channel心跳频率和数据方向都刻在里面了讲 Channel 之前必须强调一个常见的认知偏差如果用 Topic 的概念去套 Channel会丢掉一个关键信息——Channel 不仅定义了“数据流向哪里”还隐式定义了“数据的组织方式”。在 ROS 里/odom和/cmd_vel这种 Topic 名称本身是开放约定谁都能往上面发谁都能订阅。在 Cyber RT 中Channel 的语义有些不同它更像一个“强类型数据总线”。每个 Channel 关联一种消息类型通常由 Protobuf 定义发布端和订阅端必须匹配同一消息类型。如果你往/apollo/localization/pose这个 Channel 里塞了一个和定义的 Pose 类型不匹配的消息运行时会被拒绝。这也解释了为什么 Apollo 里所有 Channel 的命名都像被统一规范过一样比如/apollo/perception/obstacles、/apollo/planning/trajectory、/apollo/canbus/chassis。因为这一层强类型约束在系统初期建立得越早后面调试的成本越低。你会遇到的第一个大坑也在这——编译能过但两个模块之间 Channel 对不上或者类型对不上运行起来就是黑屏。2.3 服务Service与参数Parameter几乎没有 ROS 味道的通信三件套之一ROS 的 Service 长期是一个比较低级的话题因为它主要用来做同步请求/响应在复杂机器人系统里用得不如 Topic 多。Cyber RT 也有 Service接口上长得跟 ROS 几乎一样CreateServiceReq, Res(service_name, handler)、CreateClientReq, Res(service_name, response_handler)。但这里有个让你爽到的地方Cyber RT 的 Service 底层调用可以走共享内存。在小数据量的请求/响应场景下延迟可以被压到非常低不像是跨进程远程调用更像是进程内部的函数调用。而 ROS 1 的 service 是走 XML-RPC 的响应慢得让人怀疑人生。我第一次在 Cyber RT 里连续调用一个 service 1000 次测延迟的时候得出的数字比 ROS 1 低了两个数量级。Parameter 这块 Cyber RT 也照搬了 ROS 的思路提供一个全局参数服务可以通过/param服务来读取和设置参数。不过在实际 Apollo 项目里参数的配置更多依赖配置文件.conf、.pb.txt而非在线参数服务所以如果你是从 ROS 过来的初期不用在这上面花太多精力——先记住“有这个东西”就行真用到再翻。2.4 组件Component与协程调度CoroutineROS 里没有的硬核机制如果只停留在“Cyber RT 约等于 ROS 换了名字”那就大错特错了。Cyber RT 里最核心、最值得花时间理解的是 Component 机制和基于协程的调度器。Component 简单理解就是“把某个功能做成一个可以被框架自动加载和调度的对象”。你在 Cyber RT 里开发感知模块时不需要自己写main()函数不需要自己建线程跑 while 循环而是写一个继承cyber::ComponentMessageT的类实现Init()和Proc()两个方法。框架负责创建组件、订阅 Channel、在数据到达时自动调用Proc()。而 Proc 的执行不是在一个固定线程池里轮流分配的而是由 Cyber RT 的协程调度器来决定。协程Coroutine你可以通俗理解为“轻量级线程”它可以在用户态自由挂起和恢复不需要操作系统线程切换的开销。Cyber RT 里有一个全局协程池每个 Component 的回调会被封装成一个协程任务调度器按照优先级、依赖关系和数据到达顺序来调度它们。为什么要这么设计因为自动驾驶的模块间存在天然的依赖关系规划模块必须在感知结果到达之后才能运行但感知结果到达的时间又不是固定周期的可能早几毫秒、晚几毫秒。如果每个模块都用自己的线程死等整个系统会浪费大量线程在阻塞等待上还会因为线程切换产生不可控的延迟抖动。协程调度器可以“等到数据到了才真正执行”没有数据时就挂起CPU 资源可以得到极致的利用。我在读 Apollo 源码之前一直以为协程是个很高深的东西直到真的在 ComponentProc()里打日志对比时间戳才发现这条路子确实能把端到端延迟稳定在个位数的毫秒级别。ROS 1 里做同样的事要么自己手写线程同步要么靠开源库兜底复杂度和结果都是两码事。2.5 共享内存传输SharedMemory点云和图像为什么能跑这么快Cyber RT 的通信默认支持多种传输模式其中最有价值的是 SharedMemory。在 ROS 1 里传输一张大图或一帧点云最常干的事就是开一个 2 GB 的 TCP 缓冲区结果还是时快时慢。因为 TCP 要复制多次发送端用户态拷进内核态接收端从内核态拷到用户态中间还可能有 Nagle 算法引起的延迟累积。Cyber RT 的 SharedMemory 传输则简单粗暴发布端把消息序列化到一块共享内存映射区接收端直接在这块区域上反序列化或直接拿内存指针完全不经过内核网络栈。这里有个小细节不同的“传输类”是通过消息大小自动区分的小消息默认走 Intramessage大大消息走 SharedMemory。实测当 Channel 传输的数据超过几 KBSharedMemory 模式的优势就开始显现传输延迟和 CPU 占用都会明显低于同场景的 ROS 1 默认配置。不过有得必有失共享内存也引入了新的排查难点。比如崩溃后如果共享内存区没能正确清理新启的进程可能会读到脏数据。我在调试 Apollo 时碰到过一次偶发的点云错乱查了一下午发现是因为上一轮误杀进程导致共享内存段里残留了半帧旧数据。这个问题不常见但知道了有个心理准备遇到时能少掉一半头发。3. 从零写一个最小 Cyber RT 程序发布与订阅3.1 环境准备别再卡在装和编译上很多人的 Apollo 学习止步于 Docker 镜像拉不下来或者 bazel 编译一小时直接劝退。这里先给出我实际用的一套环境方案Ubuntu 20.04 或 22.04装好 Docker 和 NVIDIA Container Toolkit然后用 Apollo 官方提供的 dev 容器环境镜像里把 Cyber RT 以及依赖都带好了。关于装 Ubuntu 和 ROS 的这段路网上已经有大把保姆级教程比如“Windows 安装 Ubuntu ROS 全流程”那种双系统 / 虚拟机 / WSL 的图文指南还有鱼香 ROS 的一键安装脚本把 ROS 环境装好其实难度不大。装完 ROS 之后再来看 Apollo还得能接受另一套开发习惯Apollo 的代码编译和运行几乎都在 Docker 容器里完成依赖 bazel不是 ROS 的 catkin_make 或 colcon build。如果你是纯新手建议不要在自己的物理机上硬刚源码编译直接用 Apollo 的预构建镜像或 dev 容器在容器内体验 Cyber RT。等对框架有感觉了再去看源码。3.2 定义消息类型Cyber RT 是一个强类型系统Cyber RT 里做通信前必须先定义好消息格式用的不是 ROS 的.msg而是 Google Protocol BuffersProtobuf。打开 Apollo 源码cyber/proto或者各模块的proto目录到处都是.proto文件。写一个最小的学生成绩消息类型syntax proto2; package apollo.cyber.demo_base_proto; message Student { optional string name 1; optional uint64 id 2; optional double height 3; repeated double scores 4; }用optional和repeated这俩关键字是 Proto2 的特点Apollo 大量沿用 Proto2 风格和云原生那边普遍用 Proto3 的习惯不太一样。如果之前没写过 Protobuf建议先花十分钟熟悉optional、repeated、字段编号、包名这些概念不然后面看着一堆.pb.h会很晕。3.3 创建 Writer一个模拟成绩发布器写一个最朴素的 talker 程序。假设项目在 Apollo 源码里新建一个cyber/demo目录下面是发布端的核心代码#include cyber/cyber.h #include cyber/demo_base_proto/student.pb.h using apollo::cyber::cyber::demo_base_proto::Student; int main(int argc, char* argv[]) { apollo::cyber::Init(argv[0]); auto talker_node apollo::cyber::CreateNode(talker); auto talker talker_node-CreateWriterStudent(/apollo/demo/student, 10); uint64_t seq 0; apollo::cyber::Rate rate(1.0); while (apollo::cyber::OK()) { auto msg std::make_sharedStudent(); msg-set_name(zhangsan); msg-set_id(seq); msg-set_height(1.75); msg-add_scores(89.0); msg-add_scores(95.5); talker-Write(msg); rate.Sleep(); } return 0; }梳理一遍这里面的关键点第一cyber::Init(argv[0])是必须的不调用这个后面所有框架功能都会异常。它负责初始化日志、调度器、全局状态。第二CreateWriter的第二个参数是队列深度类似 ROS 里的 publisher queue size但这个队列是在 Writer 内部做缓冲的消息如果接收端来不及消费不会像 ROS 1 那样直接丢而是走协程调度器排队具体行为还和消息优先级和队列长度有关。第三apollo::cyber::Rate和 ROS 里的ros::Rate几乎一模一样用于控制循环频率单位是 Hz。3.4 创建 Reader一个简单的监听回调再看接收端#include cyber/cyber.h #include cyber/demo_base_proto/student.pb.h using apollo::cyber::cyber::demo_base_proto::Student; void MessageCallback(const std::shared_ptrStudent msg) { AINFO name: msg-name() id: msg-id() height: msg-height(); } int main(int argc, char* argv[]) { apollo::cyber::Init(argv[0]); auto listener_node apollo::cyber::CreateNode(listener); auto listener listener_node-CreateReaderStudent( /apollo/demo/student, MessageCallback); apollo::cyber::WaitForShutdown(); return 0; }这段代码里值得注意的有两个地方。一个是不需要手动执行 spin因为WaitForShutdown()会让主线程挂起而框架已经把你注册的MessageCallback挂到了listener对应的 Channel 上数据一到框架自动调度协程回调。另一个是打印日志用的是AINFO这是 Cyber RT 封装的日志宏对应 ROS 里的ROS_INFO。编译和运行方面在 Apollo 的 dev 容器里把两个文件放到cyber/demo目录后在cyber/demo/BUILD里加入对应的 bazel target然后执行bazel build cyber/demo:talker bazel run cyber/demo:talker另一个终端启动 listener就能看到打印输出的成绩信息了。这里用bazel build和bazel run不是唯一方式但对初学者来说最省心不用手动处理 LD_LIBRARY_PATH 和可执行文件路径。3.5 数据录制与回放用 recorder 代替 rosbag开发中还有一个高频需求是录制和回放消息对应 ROS 里的 rosbag。Cyber RT 提供了cyber_recorder工具基本用法和 rosbag 很像# 录制所有通道 cyber_recorder record -a # 回放 cyber_recorder play -f 20240801_120000.record用起来基本没什么心智负担。想单独回放某一路 Channel可以加-c /apollo/demo/student。在调试 Apollo 感知模块的时候我经常用这条命令反复回放真实路采点云数据调试效率比整车路测高太多。碰到偶发问题录一段数据回来离线复盘这种流程在 ROS 生态里也要靠 rosbag 完成两边思路完全一致。4. 关键差异不能只看表三个容易踩坑的思维转变4.1 整表速查Cyber RT 与 ROS 1 / ROS 2 对照直接把最核心的映射关系整理成一张表方便随时查阅。功能点ROS 1ROS 2Apollo Cyber RT最小单元NodeNodeComponent / Node发布端PublisherPublisherWriter订阅端SubscriberSubscriberReader数据通道TopicTopicChannel服务调用Service Server/ClientService Server/ClientService / Client参数服务Parameter ServerParameters基于 ROS 2 serviceParameter基于 service消息定义.msg.msg / .idl.protoProtobuf数据传输默认方式TCPROS/Unix socketDDS 内置共享内存等Intra / SharedMemory / Socket进程发现roscore中心化DDS Discovery分布式基于拓扑的中心化拓扑管理执行模型多线程 回调队列多线程 Executor协程池 确定性调度器录制工具rosbagros2 bagcyber_recorder日志打印ROS_INFORCLCPP_INFOAINFO / ADEBUG / AERROR这张表的关键信息量其实不在“名字叫什么”而在“执行模型”这一行。ROS 1 的多线程回调队列到了 ROS 2 演化成 Executor 模式但本质上还是系统线程排队执行回调Cyber RT 则直接用协程把上下文切换开销降到极低并把调度决策掌握在自己手里。这是很多从 ROS 2 转过来的人最不适应的一个点。4.2 思维转变一你不再需要手动管理生命周期ROS 1 里每个节点有两个非常折磨人的问题一是谁是 mastermaster 挂了怎么拉起二是节点退出时资源要不要显式清理。到了 ROS 2 因为 DDS 自带 discovery分布式问题解决了但节点生命周期依然由用户自己控制节点退出时如果不做好 QoS 匹配对端很容易收不到离线事件。Cyber RT 把这些收拢到了框架层。一个 Component 自身的生命周期由框架统一管理你只需要实现 Init 和 Proc节点间的依赖关系由 Channel 和调度器来维持而不是自己手写“等三秒确保对方已上线”。在实际开发里这意味着你写模块时不再需要关心“这个节点是否已经起来”只管往框架注册你要读的 Channel 和写的 Channel 就行。4.3 思维转变二数据调度优先级是显式的不是靠运气ROS 里如果两个 callback 同时触发执行顺序取决于线程的调度运气如果某个 callback 阻塞太久其他 callback 就会跟着倒霉。自动驾驶这种强实时场景这种不确定性是致命的。Cyber RT 里Channel 可以配置优先级调度器会按照优先级来安排协程执行高优先级的回调可以先于低优先级的数据处理被调度。这一点给开发者带来的思维变化很直接你需要在设计阶段就明确每个数据流的优先级而不是写完了再等它变成偶发 bug。比如底盘控制指令优先级最高感知点云和图像处理次之日志和标定数据可以放低优先级。这样的配置思路在 ROS 里很难精细表达因为 ROS 本身没有这种全局调度视图但在 Cyber RT 里调度器是系统的一等公民你对运行时的预期可以做到更强。4.4 思维转变三消息定义和代码生成是绕不开的一环ROS 的.msg文件非常友好用 Python 写两句就能跑通。Cyber RT 的消息定义则强依赖 Protobuf涉及protoc代码生成、bazel依赖声明、编译产物链接等多个环节。新手经常在这里被绊倒不是忘记在 BUILD 文件里加 proto 库依赖就是 proto 文件放在别的包导致 include 路径对不上。我的经验是先把一个最小 proto 从定义到编译成功的完整流程走通后面所有模块的消息都会顺畅很多。别急着看某个大型模块的调用链先自己加一个 proto message、编译产物、再在 talker / listener 里引用一遍跑通了再回到 Apollo 自带的各种通道去分析数据流心里就会踏实很多。5. 实战中常见的问题与排查心得5.1 编译慢、cache 丢失、依赖不匹配Apollo 用 bazel 构建第一次全量编译个把小时很正常后面增量编译会快很多。最容易遇到的是缓存失效问题比如你改了根目录下某个 proto导致下游所有依赖都需要重新生成编译时间一下子回到解放前。建议是尽量收敛 proto 改动不要频繁动公共消息定义添加新模块时尽量独立成包减少对全局 BUILD 的牵动。另外在 dev 容器里bazel的 cache 是放在容器内的容器一旦删掉重来所有编译缓存都会丢失又得等一轮全量编译。我后来习惯把 bazel 的 output base 挂载到宿主机目录比如bazel --output_user_root/apollo_bazel_cache build //...这一招能给经常重建容器的人省下大量时间。它是个特别土的方法但真的管用。5.2 Cyber RT 进程启动异常、Channel 数据看不到从 ROS 转过来的朋友第一反应大概率是用cyber_monitor去看通道数据。这个工具类似rostopic list加rostopic echo输入cyber_monitor后会列出所有活跃的 Channel按数字键可以查看消息内容。如果你启动了几个模块但 monitor 里看不到任何 Channel不要急着怀疑代码先检查两个东西一是cyber::Init是否被正确调用二是容器有没有正确使用宿主机的网络需要--nethost。还有一个非常隐蔽的问题Cyber RT 同一时刻只能有一个 master 拓扑多个进程如果各自初始化出了不同的拓扑环境即使都在同一个 Docker 容器里也可能互相看不见。常见的原因是某些服务把CYBER_IP或CYBER_DOMAIN_ID给设置乱了。我踩过一次这坑排查了大半天最后发现是环境变量没同步把两个进程的CYBER_IP指到了不同的网卡上。5.3 共享内存残留与消息“串台”前面提过共享内存的脏读问题。如果某个发布端进程被强杀共享内存段没有被正常释放新启动的进程可能会读到旧数据。怎么排查呢一个方法是看消息的时间戳如果读到的数据时间点明显早于当前时间大概率就是残留数据。更彻底的方法是清掉共享内存段再重启模块ipcs -m ipcrm -m shmid用到再临时处理就行不用写进自动化脚本。但心里有这个概念比出了问题才乱翻文档强。5.4 不要做个只听不做的人用源码和例子打底网上关于 Cyber RT 的系统性教程很少最靠谱的学习路径是读源码里的examples。打开 Apollo 仓库cyber/examples目录下有现成的 talker/listener、service/client、component 示例是理解框架最好的入门材料。还有一份《Apollo Cyber RT Developer Guide》藏在docs/cyber下官方文档虽不算特别完善但把概念和 API 过一遍配合源码里的例子基本够用了。结尾我个人在实际操作里体会到Cyber RT 真正难的地方不是 API 本身而是思维的切换。ROS 给你的是自由度让你想怎么搭就怎么搭Cyber RT 给你的是约束但约束换来的是更强的实时性和确定性。所以从 ROS 过来的人第一周会非常别扭第二周会开始适应第三周基本就能感受到协程调度和共享内存带给你的快感了。最后再分享一个小技巧学习阶段别急着把 Apollo 所有模块都跑起来不是所有算力都够也不是所有热情都能撑到全量编译结束。单开 Cyber RT用cyber_recorder回放一段 Apollo 官方提供的 demo 数据再写一个最小模块订阅你感兴趣的 Channel打印出来看看一层一层往里剥。这样做比每天看一遍源码目录要有效得多。
返回列表