简介:面向智能驾驶系统研发人员,依据GB/T汽车驾驶自动化分级,适用于2级及以上自动驾驶系统,聚焦决策规划功能模块的服务接口标准化设计。包体为1个PDF文件,大小约1MB,内容编排专业,便于按章节快速查阅。文档系统规定了任务导航、行为决策、轨迹规划三类核心服务接口,并详细定义标准元数据头、几何描述、位姿、速度、加速度、车道级导航信息、点到点任务决策信号、动态驾驶任务决策信号、目标决策信号、轨迹点信号等数据结构,为上层应用与功能软件层的数据交互提供统一约定。文档还附有编制说明及多家头部整车与科技企业参编信息,体现行业协同制定过程。已有80人学习,适合智能驾驶软件架构师、算法工程师及系统集成人员参考,可帮助理清接口边界、统一数据格式,从而降低集成成本并缩短开发周期,为后续功能开发与测试提供依据。
1. 决策规划接口规范:让算法团队和集成团队不再互相甩锅
做智能驾驶功能软件平台的人,迟早会撞上一个尴尬场景:决策规划算法在仿真里跑得好好的,一上域控制器,要么收发消息的频率对不上,要么字段的坐标系定义不一致,要么上游感知的数据还没到齐规划模块就开始输出轨迹,最后在联调现场反复扯皮。这个标题给的东西,本质上就是用来终结这种混乱的——它把决策规划模块对外的服务接口固化成一套契约,包括服务分类、消息字段、交互时序、异常处理和版本兼容规则。它不关心你内部用的是规则还是模型,也不关心你跑在什么芯片上,只关心别人怎么调用你、你怎么回应别人。
这套规范适合谁?正在搭跨平台系统的软件架构师、负责决策规划模块交付的算法工程师、做系统集成的中间件开发者,以及被上下游数据格式折磨的测试人员。它能解决的问题非常具体:接口字段谁来定、坐标系怎么声明、轨迹点用什么频率推、故障码怎么传、版本更新了老节点会不会崩。今天这篇就顺着一个可落地的方案把全链路拆开,从接口边界划分讲到字段设计,再讲到用通信中间件实际跑通、参数怎么调,最后把联调中最容易翻车的几个坑一次说清。
2. 先划清楚决策规划模块的服务边界:接口规范的第一步不是写接口,而是定边界
2.1 上下游关系:左边接什么,右边送什么
决策规划模块在功能软件平台里的位置,决定了它的接口不是自己说了算,而是被上下游逼出来的。左边是感知融合、定位、地图这些上游服务,右边是车辆控制、执行器这些下游服务。我一般会先画一张数据流向图,把每个方向上的信息类别列出来:上游输入包括障碍物列表、自车状态、高精地图拓扑、交通信号灯状态、定位结果;下游输出包括期望轨迹、目标车速、转向灯请求、紧急停车请求;除此之外还有一条双向通道,用来做诊断和状态上报,比如规划失败原因、降级模式切换、硬件资源占用情况。
这个边界划分看起来很朴素,但现实中大量联调事故的根源就是边界不清。感知团队觉得“障碍物我已经发了,你规划怎么没避障”,规划团队觉得“你发得太晚、字段含义我理解错了”。所以规范里必须明确三件事:第一,决策规划只消费上游的通用服务接口,不直接去读传感器的裸数据;第二,决策规划只输出控制请求和驾驶意图,不直接下发刹车或转向指令——那些是控制模块的事;第三,诊断通道独立于业务通道,不允许规划模块把业务异常当诊断信息塞给下游。
2.2 服务切分粒度:三个典型服务怎么划分才不打架
决策规划模块内部通常可以拆成三个服务:全局路径规划服务、行为决策服务、运动规划服务。这三个服务在接口层面应该是分开的,原因很简单——它们的生命周期完全不同。全局路径规划是低频触发,一次导航可能只在车道级拓扑变化时才重算;行为决策是中频事件驱动,碰到变道、超车、礼让这类场景才跳转;运动规划是高频周期执行,一般以 10Hz 到 20Hz 的频率持续输出轨迹。如果把它们揉成一个服务,要么高频调用把低频逻辑也拖累进去,要么低频服务频繁唤醒浪费算力。
接口规范里我会给出这样的切分建议:每个服务独立暴露一组操作,服务之间内部可以调用,但对外部节点只暴露本服务的职责范围。全局路径规划服务暴露“请求路径”和“取消路径”,行为决策服务暴露“请求驾驶意图”和“上报状态”,运动规划服务暴露“下发期望轨迹”和“立即制动”。这样划分的好处是,任何一路服务更新或崩溃,上下游的依赖关系都是可控的。而且这符合中间件通信的微服务风格,方便用动态发现机制做服务注册,也方便在仿真环境里单独 stub 掉一路服务来做测试。
3. 接口内容怎么定:从服务原语到消息字段的一步步拆解
3.1 服务原语的四个类别:请求、订阅、通知、诊断
接口的本质是一组服务原语的集合。我按功能把原语分成四类。第一类是请求-响应型,用于一次性查询或触发,比如“请求全局路径”,客户端发送起始点和终点,服务端返回规划后的路径序列。第二类是订阅-发布型,用于周期性数据流,比如运动规划服务以固定频率对外发布期望轨迹,任何下游节点订阅这个主题就能持续拿到数据。第三类是事件通知型,用于关键状态变化,比如“进入紧急停车状态”“检测到规划不可行”,这类消息不需要请求,事件发生后主动推出去。第四类是诊断型,专门承载错误码、降级模式和资源状态,这类消息有独立的 topic 或服务名,优先级最高。
这个分类直接决定接口定义的组织方式。我在做接口文件时会先列一张服务清单表,把服务名、原语类型、调用方向、频率或触发条件都填进去。表建好了再开始写字段,就不会出现“这个数据不知道该放哪个消息里”的问题。很多团队一上来就写 protobuf 文件,写到一半发现某个字段既要在业务消息里用又要在诊断消息里用,最后两边都塞一份,版本一迭代就漏改,这就是原语分类没做好的典型症状。
3.2 核心消息字段设计:一个轨迹消息应有的自我修养
运动规划服务发布的期望轨迹,是整套接口里最重要的消息。它的字段设计我能列出十几个必选项:时间戳、轨迹 ID、规划周期序号、坐标系标识、坐标系原点、轨迹点数组、每个轨迹点的横向/纵向位置、航向角、曲率、期望速度、期望加速度、期望转向角、轨迹有效性标志、生命周期终点标志。每个字段都得有明确的单位定义,单位混用是联调事故的重灾区——上游给的纵向位置是米,规划内部算的是毫米,目标速度是 km/h,输出接口却写 m/s,这种错误在仿真里跑不出来,一上实车就出大事。
我建议在消息定义里把单位直接写进字段注释,并且在代码层面对入参做显式转换校验。比如下面这个轨迹消息定义,单位全部标注在注释里:
syntax = "proto3"; message TrajectoryPoint { double x = 1; // 纵向位置,单位:米,坐标系由Trajectory.coordinate_frame指定 double y = 2; // 横向位置,单位:米 double theta = 3; // 航向角,单位:弧度 double curvature = 4; // 曲率,单位:1/米 double velocity = 5; // 期望速度,单位:米/秒 double acceleration = 6; // 期望加速度,单位:米/秒^2 double steer_angle = 7; // 期望转向角,单位:弧度 } message Trajectory { uint64 timestamp_us = 1; // 消息生成时间戳,单位:微秒 uint32 trajectory_id = 2; // 轨迹标识,每次规划周期唯一 uint32 plan_sequence = 3; // 规划周期序号,单调递增 string coordinate_frame = 4; // 坐标系标识,如"vehicle"或"map" double origin_x = 5; // 坐标系原点经度/纵向偏移,单位:米 double origin_y = 6; // 坐标系原点纬度/横向偏移,单位:米 repeated TrajectoryPoint points = 7; // 轨迹点序列,按时间/空间顺序排列 bool is_valid = 8; // 轨迹是否可用于控制执行 bool is_final = 9; // 是否为生命周期终点轨迹 }这段定义看起来简单,但有三个参数值得细说。plan_sequence是单调递增的序号,下游收到序号回退的数据可以直接丢弃,防止缓存里取到过期轨迹;coordinate_frame和origin_x、origin_y必须同时出现,只给坐标系类型不给原点,等于没给坐标系,下游根本没法做坐标变换;is_valid标志决定了下游要不要执行这条轨迹,如果规划模块检测到轨迹不可行,不是不发消息,而是发一条is_valid=false的轨迹并把trajectory_id带上,这样下游能明确感知到“规划还在运行但当前无有效输出”,而不是误判为通信中断。这些设计都是血泪换来的,多一个字段少一次推断,下游就不用在黑匣子里猜上游意图了。
3.3 坐标系与时空基准:必须写在规范里的约定
坐标系定义是接口规范里最容易被忽略、但一出错就是方向性错误的东西。决策规划模块会同时涉及多个坐标系:全局坐标系、局部坐标系(通常是车辆坐标系)、传感器安装坐标系。接口里传输的路径和轨迹必须显式声明用的是哪个坐标系,并且要附带坐标系之间的变换关系。我碰到过一个真实事故——某团队在接口里传了全局坐标系的轨迹,但忘了改坐标系标识,下游控制模块按局部坐标执行,车辆直接横着开了出去。仿真里没暴露,因为仿真器对坐标系不敏感,实车测试第一圈就出问题。
所以我在接口规范里会立一条规矩:凡是带位置信息的消息,必须同时携带坐标系标识和变换矩阵版本号。变换矩阵一般由定位模块统一维护,规划模块不要自己算,而是订阅定位服务下发的坐标系变换关系。时间同步方面,所有消息必须带统一时间戳,基准用平台级的系统时钟。这样在分析问题时,只需要按时间戳对齐不同 topic 的消息,就能复现出上下游的时序关系。
4. 把接口落进跨平台系统:通信中间件选型与最小可运行骨架
4.1 用通用中间件定义接口文件:IDL 是唯一真相来源
接口规范写得再漂亮,最终要让它成为代码里唯一可信的依赖源,就得把接口定义文件当作整个系统的单一事实来源。我现在常用的一套做法是:用 protobuf 定义全部服务接口和消息类型,然后由构建系统根据平台架构自动生成对应的 C++ 或 Python 绑定代码。这样跨系统集成时,只要大家用的是同一份 proto 文件,字段错位、类型不匹配的问题就从根上被消灭了。
定义服务接口的关键在于方法签名的设计。方法入参出参都必须是已定义的 message 类型,不允许透传裸数据结构。下面是决策规划服务对外暴露的接口定义示例:
service MotionPlanningService { rpc GetCapability(EmptyRequest) returns (PlanningCapability); rpc SetRoute(SetRouteRequest) returns (SetRouteResponse); rpc SubscribeTrajectory(SubscribeRequest) returns (stream Trajectory); rpc ReportStatus(empty) returns (stream PlanningStatus); } message SetRouteRequest { string route_id = 1; // 路由唯一标识 repeated Waypoint waypoints = 2; // 路径点序列 bool force_reroute = 3; // 是否强制重新规划 } message PlanningCapability { float max_deceleration = 1; // 最大舒适减速度,单位:米/秒^2 float max_steering_angle = 2; // 最大转向角,单位:弧度 float planning_hz = 3; // 规划频率,单位:赫兹 bool supports_emergency_stop = 4;// 是否支持紧急停车服务 } message PlanningStatus { enum State { IDLE = 0; PLANNING = 1; WAITING_FOR_SENSOR = 2; DEGRADED = 3; FAULT = 4; } State state = 1; uint32 fault_code = 2; // 故障码,0表示无故障 string description = 3; // 可读状态描述 }这套接口定义的逻辑值得展开讲。GetCapability是个容易被忽略但很有用的原语——下游在执行前先拉取当前规划模块的能力参数,比如最大减速度、规划频率,这样不同车型、不同版本之间可以自适应协作,而不是硬编码一套参数。SubscribeTrajectory用了流式返回,对应前面说的订阅-发布模型,规划服务持续推送轨迹给控制模块。ReportStatus也是流式的,但是单独的服务方法,不和业务数据混在一起,便于诊断工具单独订阅。这里的EmptyRequest不能省,统一了所有无入参方法的签名风格,后续要加参数时不需要破坏接口。
4.2 服务注册与生命周期管理:新节点上线怎么被其他人发现
光定义了接口文件还不够,服务要能被上下游发现并调用,还需要一套生命周期管理机制。常见做法是在平台层引入服务注册表 —— 每个服务启动时向注册中心上报自己的服务名、实例 ID、通信地址、能力参数;服务下线时主动注销或被健康检查踢掉。决策规划模块启动时应该先注册MotionPlanningService,然后等待上游服务就绪,再进入待规划状态。
启动时序一般是:感知和定位服务先注册并发布数据,规划模块订阅到定位有效后开始注册自身服务,最后控制模块发现规划服务可用后建立订阅关系。这个顺序不能反过来。我见过有团队先注册规划服务再等定位,结果控制模块在规划还没就绪时就开始订阅,拿到一堆前端时刻的空轨迹消息。规划模块启动时还要发布一个PlanningStatus状态为IDLE,等到环境就绪、上游数据有效后,再切到PLANNING状态。用状态机管理生命周期是最可靠的,不要让服务处在“既没注册成功又没报错”的模糊地带。
4.3 端到端连通性自检:一套跑通的最小步骤
接口定义好了,中间件选好了,下一步是在某个跨平台系统里把最小链路跑通。我一般按下面几步做连通性自检:
第一步,启动本地通信中间件守护进程,确认通信域正常。第二步,编译部署决策规划服务的模拟实例和下游消费的模拟节点。第三步,确认模拟节点能通过服务发现机制查询到规划服务。第四步,模拟节点发起GetCapability调用,校验返回的能力参数是否符合预期。第五步,模拟节点订阅轨迹 topic,规划服务发布 10Hz 的虚拟轨迹,确认订阅端能稳定收到消息且时间戳单调递增。
实际执行时用cyber_monitor或类似的工具做实时可视化监控,能直观看到消息的收发频率和数据内容。如果依赖的工具不支持的话,可以写一段简单的检查脚本直接订阅打印统计信息:
import threading import time from interface.motion_planning import Trajectory received_count = 0 last_timestamp_us = 0 def trajectory_callback(trajectory: Trajectory): global received_count, last_timestamp_us received_count += 1 if trajectory.timestamp_us < last_timestamp_us: print(f"[WARN] non-monotonic timestamp: {trajectory.timestamp_us}") last_timestamp_us = trajectory.timestamp_us def monitor_hz(duration_sec=10.0): start_count = received_count time.sleep(duration_sec) actual_hz = (received_count - start_count) / duration_sec print(f"[INFO] actual frequency: {actual_hz:.2f} Hz") if __name__ == "__main__": # 初始化通信并订阅轨迹topic # init_middleware() # register_trajectory_callback(trajectory_callback) monitor_thread = threading.Thread(target=monitor_hz, args=(10.0,)) monitor_thread.start()这段脚本做两件最基础的自检:一是检查消息频率是否达到规划的服务约定值(通常 10Hz 或 20Hz),二是检查时间戳是否单调递增。消息频率如果掉到个位数,说明规划模块的计算耗时超标或者通信中间件配置不当;时间戳如果回退,说明上游消息乱序或者存在多个时钟源。这两项通过之后,再展开实质性的路径规划仿真测试。需要注意init_middleware和register_trajectory_callback需要对接你实际用的中间件 API,不同系统略有差异,但检查逻辑是一致的。频率测试建议跑至少 10 秒,短时间样本容易被调度抖动干扰。
5. 接口联调避坑指南:字段、时序、版本三类问题的排雷记录
5.1 上游消息晚到:规划模块用了上一帧的障碍物数据,车没刹住
现象:仿真场景里前车静止,本车规划轨迹却反复穿过前车位置,偶尔能避障、偶尔不能,无固定规律。
原因:规划模块订阅感知话题,但感知发布周期是 20Hz,规划是 10Hz,感知偶尔晚几十毫秒,规划模块取不到当前帧就复用了缓存里上一帧的障碍物列表。感知数据的时间戳和规划执行时刻的时间戳对不上,按消息到达时间判断新旧,而不是按数据内的时间戳判断。
解决:在接口里强制要求每个感知消息带上传感器采集时间戳,规划模块内部做时间戳对齐。取不到比当前规划时刻更新或等值的数据时,宁可阻塞一个规划周期等待最新数据,也不要复用过期数据。另外要在规划状态里上报WAITING_FOR_SENSOR,这样下游知道当前不是正常规划状态,可以提前做降级准备。
5.2 坐标系声明不一致:定位发的是 WGS84 经纬度,规划按平面坐标算,轨迹偏出去几百米
现象:低速园区场景下,车辆正常直行,但规划输出的轨迹在仿真器里逐渐偏离真实道路,误差累计到几米后猛打方向修正。
原因:接口文档里写了“地图坐标系”,但定位模块实际发布的是 WGS84 经纬度,而规划模块内部按 UTM 平面坐标做计算,两边没对齐。更隐蔽的是,有些人把坐标系信息写在了消息的 description 字段里,而不是结构化的coordinate_frame字段,下游根本没法程序化识别。
解决:规范里强制将坐标系信息作为消息的必填字段,并统一提供坐标变换服务。规划模块不直接消费原始经纬度,而是从坐标变换服务里拿投影后的平面坐标。测试用例里必须加一个坐标系漂移的干扰项——人为在定位输出里改变坐标系标识,验证下游是否会误执行。这类问题实车测试前不容易暴露,因为仿真器通常对坐标系不敏感,所以必须在仿真阶段就做坐标系一致性检查。
5.3 服务版本不兼容:接口新增字段后,老节点收到新消息直接崩溃
现象:某次版本更新后,规划消息里新增了一个可选字段,结果下游一个已经三个月没动的老模块出现解析异常,偶尔还会段错误。
原因:老模块的 IDL 绑定代码没有用最新版本重新生成,解析消息时按旧的字段编号规则读取,遇到新增字段时内存布局错位,读取了非法地址。protobuf 虽然向后兼容,但前提是双方都遵守字段编号冻结规则,并且重新生成绑定代码。
解决:版本管理规则必须写在接口规范里:字段编号一旦发布就永久冻结,不允许修改用途,不允许删除,新增字段只能分配新编号。模块升级时全部重新生成绑定代码并做接口兼容性回归测试。我在实践中还会加一个启动时版本握手机制——两个服务建立连接后先交换 IDL 版本号,版本不匹配直接拒绝连接,而不是运行到一半才崩溃。这比在文档里写“请使用 XX 版本”靠谱得多。
5.4 同一条轨迹被多个下游重复消费:控制模块和监控模块各处理一遍,资源浪费
现象:CPU 占用率整体偏高,但单独看每个模块负载都不大,系统总资源消耗比预估高出 30%。
原因:多个下游节点都直接订阅了轨迹 topic,每个节点各自做消息反序列化和坐标变换。决策规划服务发布的轨迹消息在大数据块传输时被复制了多次,内存带宽成了瓶颈。
解决:在接口设计阶段明确消息的生产者-消费者关系,对同一份数据有多个消费者的场景,考虑在中间件层做共享内存传输或引入消息转发节点。注意,这是架构层面的选择,接口字段不需要改,但通信质量策略要在规范里规定清楚。另一个常见做法是让控制模块作为轨迹的唯一直接消费者,监控模块通过控制模块的代理接口间接获取数据,减少重复反序列化开销。
5.5 故障码含义模糊:上报了 FAULT,但查日志看不出哪个环节断了
现象:系统汇报“决策规划模块故障”,但状态里只有一个fault_code=7,排查了半天也不知道这个 7 代表什么,翻文档发现是三个月前加的代码,文档早就没更新了。
原因:故障码是临时在代码里定义的,没有在接口规范层面统一登记。不同开发者各自定义了一堆故障码,数字还会冲突,同一个数字在不同模块版本里的含义完全不同。
解决:接口规范里必须预留故障码注册表,每个故障码对应模块名、子状态、可能原因和恢复策略。诊断消息里除了数字故障码还要带可读的描述字段。同时建议故障码分段管理,比如规划模块的故障码范围是 1000 到 1999,传感器数据处理是 2000 到 2999,这样看码就知道问题出在哪个域。更重要的是,在日志里把故障发生时的关键输入状态快照一并打出来,否则故障码再清晰也查不到上下文。
6. 把接口规范变成活的:仿真回放和大规模一致性验证技巧
规范发布只是起点,真正让规范有约束力的是验证机制。我最后一步习惯是建一个接口回放测试池——录制真实道路测试时的原始输入消息序列,包括感知、定位、地图、车辆状态完整的时间对齐数据,然后在每次接口变更后,把录制好的数据重新灌给规划模块。回放测试的价值在于,它能在没有实车的情况下确定性复现问题。某次夜间场景下感知漏检导致规划误判,实车复测要等夜间窗口,回放测试当天就复现了,定位到是上游消息缺失时段规划模块没有进入等待状态的问题。
回放的关键是文件格式里必须保留每条消息的采集时间戳和到达时间戳,两个时间戳缺一不可。采集时间戳用来重放时对齐不同 topic 的消息,到达时间戳用来模拟时序延迟抖动。验证脚本会统计重放前后规划输出的差异率,超过预设阈值(比如轨迹点偏差超过 0.5 米)就标记为行为不一致。这种自动化回归测试,比人工对着仿真画面判断靠谱得多。
一致性验证里还建议加入接口契约测试。每个服务发布时自动跑一套契约用例,检查所有字段是否在声明范围内、必填字段是否有值、单位是否在合理范围内。不合理的数据在入口就要拦截并计入诊断统计,而不是流到下游。例如速度字段突然变成-100 m/s、轨迹点曲率超过车辆物理极限,这些一眼假的数据如果能在接口层拦住,很多低级事故根本到不了实车阶段。
最后分享一个经验:接口规范这个事,前期的边界划分讨论比代码编写重要得多。花两周时间和上下游团队逐条对齐字段含义和异常行为,后面能省下两个月的联调周期。我自己也被“先写代码后补文档”坑过很多次,后来养成习惯——每次接口变更先改 IDL 文件再写实现代码,代码 review 时只认 IDL 的 diff,任何人私下改字段定义都视为无效变更。每次实车测试回来,第一件事就是复盘接口日志里有没有非预期消息,有的话当天就修掉,绝不留给下一个版本。这套规矩看起来繁琐,但经历过一次夜间实车因坐标系问题跑偏之后,你就明白规范不是束缚,是后悔药。希望帮到你。
本文还有配套的精品资源,点击获取