1. 从一条报错说起:为什么要啃 Apollo 6.0 的 protobuf 定义
如果你在自动驾驶规划模块的开发或调试中,遇到过类似Cannot find field "planning.TrajectoryPoint.v"或者AttributeError: 'ADCTrajectory' object has no attribute 'debug'这样的报错,那你大概率已经踩到了同一个坑:Apollo 6.0 的 planning 模块 protobuf 定义,和你手头那份“祖传”的 proto 文件对不上。
Apollo 6.0 是百度 Apollo 开源自动驾驶平台一个相当关键的版本节点。相比 3.5、5.0,它在 planning 模块做了大量重构,尤其是 protobuf 消息结构的变化,直接影响了规划算法的输入输出、调试信息、场景判定等核心链路。很多同学拿到一份 planning 模块的代码,编译能过,但一跑仿真就崩,或者 debug 信息死活读不出来,根子往往就在 proto 定义没对齐。
这篇文章就是围绕Apollo 6.0 planning module 的 protobuf 定义展开的。我会把 planning 模块涉及的核心 proto 文件、消息结构、字段含义、版本差异、以及实际开发中怎么用、怎么改、怎么避坑,全部拆开讲清楚。不管你是刚接触 Apollo 的在校学生,还是正在做规划算法落地的工程师,只要你的工作涉及 planning 模块的数据流,这篇内容都能直接拿来参考。
需要提前说明的是,Apollo 的 proto 文件分布在多个目录,planning 模块相关的定义主要集中在modules/planning/proto/下,同时还会依赖modules/common/proto/、modules/canbus/proto/、modules/perception/proto/、modules/routing/proto/等模块的 proto。理解这些文件之间的依赖关系,比单纯背字段名重要得多。
2. Apollo 6.0 planning 模块 proto 文件全景梳理
2.1 核心 proto 文件清单与职责划分
先上一张表,把 planning 模块最核心的 proto 文件列出来。这张表是我自己整理代码时反复核对过的,建议你直接收藏。
| proto 文件 | 核心消息 | 主要职责 |
|---|---|---|
planning.proto | ADCTrajectory、TrajectoryPoint、EStop | 规划模块最终输出轨迹、急停信息 |
planning_config.proto | PlanningConfig、TaskConfig、ScenarioConfig | 规划模块全局配置、任务配置、场景配置 |
decision.proto | DecisionResult、MainDecision、ObjectDecision | 决策结果,包含对障碍物的行为决策 |
pad_msg.proto | PadMessage | 人工接管、调试指令输入 |
scenario.proto | Scenario、ScenarioType | 场景类型定义与场景状态 |
traffic_rule_config.proto | TrafficRuleConfig | 交通规则配置 |
learning_model.proto | LearningModelConfig | 学习型规划模型配置 |
reference_line.proto | ReferenceLine、ReferencePoint | 参考线定义 |
st_graph.proto | STGraphData、STBoundary | 速度规划用的 ST 图数据 |
sl_boundary.proto | SLBoundary | 障碍物在 SL 坐标系下的边界 |
path.proto | PathPoint、Path | 路径点与路径定义 |
这些文件不是孤立的。planning.proto里的ADCTrajectory会引用common/proto/pnc_point.proto中的TrajectoryPoint,而decision.proto又会引用perception/proto/perception_obstacle.proto中的障碍物定义。搞清楚引用链,才能避免“改了 A 文件,B 文件编译不过”的尴尬。
2.2 proto 文件之间的依赖关系
Apollo 的 proto 依赖是通过import语句建立的。举个例子,planning.proto的开头通常长这样:
syntax = "proto2"; package apollo.planning; import "modules/common/proto/header.proto"; import "modules/common/proto/pnc_point.proto"; import "modules/common/proto/geometry.proto"; import "modules/planning/proto/decision.proto"; import "modules/planning/proto/planning_config.proto"; import "modules/canbus/proto/chassis.proto";这里有几个关键点值得注意。第一,Apollo 6.0 的 proto 用的是proto2 语法,不是 proto3。这意味着字段默认是optional的,而且required字段一旦缺失会导致解析失败。第二,package声明为apollo.planning,所以在 C++ 里访问时要用apollo::planning::ADCTrajectory,Python 里则是apollo.planning.ADCTrajectory_pb2。第三,import 路径是相对于 Apollo 工作空间根目录的,编译时需要在BUILD文件里正确声明依赖。
提示:如果你在自定义模块里引用 planning 的 proto,务必在
BUILD文件的deps里加上对应的proto_library目标,否则会出现“找不到符号”的链接错误。
2.3 为什么 Apollo 6.0 的 proto 改动这么大
Apollo 6.0 相比 5.0,planning 模块的 proto 改动主要集中在三个方面。一是场景管理器的引入,scenario.proto新增了更细粒度的场景类型和状态机定义,支撑ScenarioManager的运作。二是决策信息的结构化,decision.proto里的ObjectDecision增加了更多行为标签,方便调试和可视化。三是配置文件的模块化,planning_config.proto把原本臃肿的PlanningConfig拆成了多个子配置,每个 task 和 scenario 都有独立的 config。
这些改动的背后逻辑其实很清晰:Apollo 6.0 想让 planning 模块从“一个大流程”变成“可插拔的任务编排”。proto 作为数据契约,必须先把结构定清楚,后面的插件化才有基础。理解了这一点,你再看那些新增字段,就不会觉得是“为了改而改”了。
3. 核心消息结构深度拆解
3.1 ADCTrajectory:规划模块的最终答卷
ADCTrajectory是 planning 模块最重要的输出消息,没有之一。它定义了自车在未来一段时间内的完整轨迹,下游的控制模块就是靠它来驱动车辆的。在 Apollo 6.0 里,它的结构大致如下:
message ADCTrajectory { optional apollo.common.Header header = 1; optional double total_path_length = 2; optional double total_path_time = 3; repeated apollo.common.TrajectoryPoint trajectory_point = 4; optional EStop estop = 5; optional apollo.planning.DecisionResult decision = 6; optional apollo.planning.PlanningDebug debug = 7; optional apollo.canbus.Chassis.GearPosition gear = 8; optional apollo.planning.TrajectoryPoint debug_point = 9; optional apollo.common.VehicleSignal vehicle_signal = 10; optional bool is_replan = 11; optional apollo.planning.Scenario scenario = 12; optional double replan_reason = 13; optional apollo.planning.LatencyStats latency_stats = 14; optional apollo.planning.TrajectoryPoint right_of_way_status = 15; }这里我重点讲几个容易踩坑的字段。trajectory_point是repeated的,每个点包含位置、速度、加速度、时间戳、相对时间等信息。注意 Apollo 6.0 里TrajectoryPoint的定义在common/proto/pnc_point.proto,不是 planning 自己的文件,很多人第一次找会找错地方。estop字段是急停信息,当规划模块判定需要紧急停车时,会在这里填充原因。debug字段是调试信息的入口,里面包含了每一个 task 的执行结果,是排查问题的金矿。
is_replan和replan_reason这两个字段在 6.0 里被强化了。当规划器决定重新规划时,is_replan置为 true,replan_reason会给出原因码。实际调试中,如果你发现轨迹频繁跳变,先看这两个字段,往往能快速定位是哪个场景触发了重规划。
3.2 DecisionResult:决策信息的结构化表达
DecisionResult是 planning 模块决策层的输出,它回答了“自车对周围障碍物打算怎么做”这个问题。在 Apollo 6.0 里,它的核心结构包括MainDecision、ObjectDecision和StopReason等。
message DecisionResult { optional MainDecision main_decision = 1; optional ObjectDecisions object_decision = 2; optional StopReason stop_reason = 3; optional apollo.common.VehicleSignal vehicle_signal = 4; }MainDecision描述自车自身的行为,比如是否变道、是否停车、是否避让。ObjectDecision则是对每一个感知到的障碍物给出决策标签,比如IGNORE、STOP、FOLLOW、YIELD、OVERTAKE。这些标签在可视化工具里会以不同颜色显示,调试时非常直观。
这里有个经验:ObjectDecision里的object_id必须和感知模块输出的障碍物 ID 一致,否则决策和感知就对不上。我在一次联调中就遇到过,感知模块升级后 ID 生成规则变了,导致 planning 的决策信息全部错位,可视化里障碍物颜色乱成一团。后来在perception_obstacle.proto里统一了 ID 定义才解决。
3.3 Scenario 与 ScenarioConfig:场景化规划的基石
Apollo 6.0 最显著的变化之一就是场景化规划。scenario.proto定义了Scenario消息和ScenarioType枚举,planning_config.proto里则定义了ScenarioConfig。
enum ScenarioType { UNKNOWN = 0; CRUISE = 1; CRUISE_URBAN = 2; CRUISE_HIGHWAY = 3; CHANGE_LANE = 4; INTERSECTION = 5; PARKING = 6; ... } message Scenario { optional ScenarioType scenario_type = 1; optional string scenario_name = 2; optional ScenarioConfig scenario_config = 3; optional StageType current_stage = 4; }ScenarioType枚举定义了所有支持的场景类型,Scenario消息则携带了当前场景的实例信息。current_stage字段表示场景状态机当前所处的阶段,比如变道场景可能分为LANE_CHANGE_PREPARE、LANE_CHANGE_EXECUTE、LANE_CHANGE_FINISH等阶段。
实际开发中,如果你想新增一个自定义场景,需要做三件事:在ScenarioType里加枚举值,在ScenarioConfig里加配置项,然后在ScenarioManager里注册对应的场景插件。少一步都不行,而且枚举值不能和已有的冲突,否则会出现场景误判。
3.4 PlanningConfig:配置体系的模块化设计
PlanningConfig是 planning 模块的总配置入口,在 Apollo 6.0 里它被拆得比较细:
message PlanningConfig { optional apollo.common.VehicleType vehicle_type = 1; optional TaskConfig default_task_config = 2; repeated ScenarioConfig scenario_config = 3; optional TopicConfig topic_config = 4; optional LatencyStatsConfig latency_stats_config = 5; optional LearningModelConfig learning_model_config = 6; optional TrafficRuleConfig traffic_rule_config = 7; }default_task_config是默认任务配置,scenario_config是各场景的独立配置。这种设计的好处是,不同场景可以覆盖默认配置,实现“场景定制”。比如高速巡航场景的跟车距离可以设得比城区大,通过scenario_config就能单独指定。
topic_config定义了 planning 模块订阅和发布的 topic 名称,这个在 6.0 里被独立出来,方便做多模块联调时统一管理。latency_stats_config则是延迟统计配置,用于监控规划耗时。
注意:修改
PlanningConfig后,一定要同步更新conf/目录下的.pb.txt配置文件,否则运行时加载的还是旧配置。这个坑我踩过不止一次,编译过了但行为没变,查半天才发现是配置文件没更新。
4. 实操:从零编译并验证 planning proto
4.1 环境准备与依赖检查
在动手之前,先确认你的 Apollo 环境是完整的。Apollo 6.0 推荐用 Docker 环境,官方提供了apolloauto/apollo:dev-x86_64-18.04-20200914_2052这类镜像。如果你是在裸机上编译,需要确保 protobuf 版本是 3.6.1 以上,g++ 版本不低于 7.5。
进入 Apollo 工作空间后,先跑一遍依赖检查:
cd /apollo bash docker/scripts/dev_start.sh bash docker/scripts/dev_into.sh进入容器后,检查 protobuf 编译器:
protoc --version如果输出libprotoc 3.6.1或更高,就没问题。低于这个版本,编译 planning proto 时可能会报Unrecognized syntax identifier "proto2"之类的错误。
4.2 单独编译 planning proto 的方法
Apollo 用的是 Bazel 构建系统。单独编译 planning 模块的 proto,可以这样操作:
cd /apollo bazel build //modules/planning/proto:planning_proto这条命令会编译modules/planning/proto/BUILD里定义的planning_proto目标。如果你想编译所有 planning 相关的 proto,可以用:
bazel build //modules/planning/proto/...编译成功后,生成的.pb.h和.pb.cc文件会在bazel-bin/modules/planning/proto/目录下。你可以进去看看,确认planning.pb.h里有没有你需要的消息类。
4.3 用 Python 验证 proto 解析
C++ 编译通过不代表 proto 定义就完全正确,最好用 Python 再验证一遍。Apollo 的 Python 环境里已经装好了 protobuf 库,你可以写个小脚本:
import sys sys.path.append('/apollo/bazel-bin/modules/planning/proto') from planning_pb2 import ADCTrajectory traj = ADCTrajectory() traj.total_path_length = 100.0 traj.total_path_time = 10.0 # 添加一个轨迹点 point = traj.trajectory_point.add() point.path_point.x = 1.0 point.path_point.y = 2.0 point.v = 5.0 # 序列化再反序列化 data = traj.SerializeToString() traj2 = ADCTrajectory() traj2.ParseFromString(data) print("Path length:", traj2.total_path_length) print("Point count:", len(traj2.trajectory_point)) print("First point v:", traj2.trajectory_point[0].v)这段脚本能跑通,说明 proto 定义和 Python 绑定都没问题。如果报ImportError,检查sys.path是否指向了正确的bazel-bin目录。如果报字段不存在,那就是 proto 版本对不上,需要回头核对文件。
4.4 常见编译错误与快速修复
| 错误信息 | 原因 | 修复方法 |
|---|---|---|
Import "xxx.proto" was not found | import 路径错误或依赖未声明 | 检查 BUILD 文件 deps,确认路径相对于工作空间根目录 |
Field number xxx has already been used | 字段编号冲突 | 检查同一 message 内是否有重复编号,重新分配 |
Expected "required", "optional", or "repeated" | proto3 语法混入 proto2 | 确认文件头是syntax = "proto2",字段加修饰符 |
Cannot find field "xxx" | 字段名拼写错误或版本不一致 | 对照 proto 文件核对字段名,注意大小写 |
protoc: command not found | 环境变量未配置 | 在 Docker 内执行,或手动安装 protoc |
这张表里的错误,我几乎每一个都遇到过。最坑的是字段编号冲突,因为 proto 的字段编号一旦被使用就不能复用,删掉字段后编号也不能给新字段用,否则老数据反序列化会出错。所以改 proto 时,删字段要谨慎,最好用reserved标记废弃编号。
5. 开发中高频踩坑与排查实录
5.1 字段编号冲突导致的序列化异常
前面提到字段编号冲突,这里展开讲一个真实案例。有一次我在decision.proto里加了一个新字段,随手给了个编号15,结果编译过了,但运行时报Protocol message DecisionResult has no field named "xxx"。查了半天才发现,15这个编号在另一个分支上已经被用过了,合并代码时冲突了。
Apollo 的 proto 文件在多人协作时,字段编号管理是个大问题。我的建议是:新增字段前,先全局搜索该 message 的所有字段编号,选一个最大的加一。如果团队有规范,最好维护一个编号分配表。另外,删除字段时一定要用reserved保留编号:
message DecisionResult { reserved 15, 16; reserved "old_field_name"; // 新字段从 17 开始 }这样即使有人误用了15,编译时也会直接报错,而不是等到运行时才出问题。
5.2 proto 版本不一致引发的运行时崩溃
Apollo 6.0 的 proto 定义和 5.0 有大量不兼容改动。如果你拿 5.0 的代码去解析 6.0 的 proto 数据,或者反过来,就会出现各种诡异问题。比如 5.0 的ADCTrajectory里没有scenario字段,用 5.0 的代码解析 6.0 的数据,scenario字段会被忽略,但不会报错,导致你以为场景信息丢了。
排查这类问题,最直接的方法是打印 proto 的 descriptor:
from planning_pb2 import ADCTrajectory print(ADCTrajectory.DESCRIPTOR.fields_by_name.keys())把字段列表打出来,和官方 6.0 的 proto 文件对比,一眼就能看出差异。如果发现字段缺失或多余,就说明版本对不上,需要统一 proto 文件。
5.3 配置文件与 proto 不匹配的排查思路
Apollo 的配置文件是.pb.txt格式的文本 proto,加载时会用 proto 定义去解析。如果配置文件里写了 proto 里不存在的字段,加载会失败。常见报错是Error parsing text-format apollo.planning.PlanningConfig: xxx: Cannot find field。
排查步骤很简单:第一,确认报错字段名;第二,在对应的 proto 文件里搜索这个字段;第三,如果找不到,说明配置文件是旧版本的,需要更新;如果找到了但类型不对,检查配置值的格式。比如枚举字段要写枚举名而不是数字,布尔字段要写true或false。
提示:Apollo 提供了
protoc --decode命令,可以把二进制 proto 数据解码成文本,方便对比。用法是protoc --decode=apollo.planning.ADCTrajectory planning.proto < data.bin。
5.4 调试信息读取的实用技巧
ADCTrajectory里的debug字段是排查规划问题的关键。它包含了每个 task 的执行结果、参考线信息、ST 图数据等。但debug字段默认可能不填充,需要在配置里打开。
在planning_config.pb.txt里,找到PlanningDebug相关的配置,确保enable_debug之类的开关是打开的。然后在代码里,规划结束后会把 debug 信息填进ADCTrajectory。读取时,可以用 Python 脚本把 debug 信息打印出来:
from planning_pb2 import ADCTrajectory with open('trajectory.bin', 'rb') as f: traj = ADCTrajectory() traj.ParseFromString(f.read()) if traj.HasField('debug'): debug = traj.debug print("Planning debug info:") print(debug) else: print("No debug info available")HasField是 proto2 特有的方法,用来判断 optional 字段是否被设置。proto3 里没有这个方法,所以如果你用的是 proto3 定义,需要换一种判断方式。
6. 自定义扩展:如何安全地修改 planning proto
6.1 新增字段的规范流程
在 planning proto 里加字段,不是随便找个编号写上就行。规范流程是这样的:第一,确定要加在哪个 message 里,尽量加在已有的扩展点,而不是新建 message。第二,选一个未被使用的字段编号,建议从当前最大编号加一。第三,字段类型要选对,能用optional就别用required,避免破坏兼容性。第四,加完后同步更新.pb.txt配置文件和相关代码。
举个例子,假设你想在ADCTrajectory里加一个planning_mode字段,表示当前规划模式:
message ADCTrajectory { // 已有字段... optional string planning_mode = 16; }编号选16,是因为前面最大用到15。类型用string,因为模式名是字符串。加完后,在代码里填充这个字段,在配置文件里不需要额外配置,因为它是运行时数据。
6.2 字段废弃与兼容性处理
废弃字段比新增字段更麻烦。直接删掉字段会导致老数据解析失败,所以正确做法是标记reserved,并保留字段名和编号。如果字段只是暂时不用,可以保留定义但不再填充,这样老代码还能编译,新代码忽略它即可。
Apollo 官方在版本升级时就是这么做的。你可以翻翻 6.0 的 proto 文件,里面有不少reserved标记,都是历史遗留字段。这些标记不能删,删了就可能破坏兼容性。
6.3 多模块联调时的 proto 同步策略
Planning 模块不是孤立的,它和 perception、prediction、routing、control 都有数据交互。联调时,proto 版本必须同步。我的经验是:在项目根目录维护一份 proto 版本清单,记录每个模块使用的 proto 文件哈希值。每次联调前,先核对清单,确保大家用的是同一版本。
如果做不到哈希核对,至少要做到:修改 proto 后,通知所有相关模块的负责人,并在代码仓库里打 tag。Apollo 的代码仓库比较大,proto 文件分散,靠自觉同步很容易出问题,最好有自动化检查工具。
7. 几个容易被忽略的细节
7.1 proto2 与 proto3 的混用陷阱
Apollo 6.0 的 planning proto 用的是 proto2,但有些依赖的 proto 可能是 proto3。proto2 和 proto3 在字段默认值、HasField方法、枚举处理上都有差异。混用时,最容易出问题的是枚举:proto3 的枚举第一个值必须是 0,而且不能有required字段。
如果你在 proto2 文件里 import 了一个 proto3 文件,编译通常能过,但运行时行为可能不符合预期。比如 proto3 的optional字段在未设置时返回默认值,而 proto2 的optional字段可以用HasField判断是否设置。混用时,HasField对 proto3 字段可能不可用。
注意:Apollo 6.0 里大部分 proto 是 proto2,但
common/proto/error_code.proto等少数文件是 proto3。引用时留意一下文件头的syntax声明。
7.2 枚举值扩展的注意事项
Planning 模块里有大量枚举,比如ScenarioType、StopReason、ObjectDecisionType。扩展枚举时,新值必须加在末尾,不能插在中间,否则会改变已有值的编号,导致老数据解析错误。另外,枚举值编号不能重复,命名要遵循大写下划线风格。
还有一个细节:proto2 的枚举允许有别名,即两个枚举名对应同一个编号,但需要设置allow_alias = true。Apollo 里有些枚举用了这个特性,改的时候要注意别破坏别名关系。
7.3 性能敏感场景下的 proto 使用建议
Planning 模块对实时性要求很高,proto 的序列化和反序列化开销不能忽视。几个优化建议:第一,避免在热路径上频繁创建和销毁 proto 对象,尽量复用。第二,repeated字段如果数据量大,考虑用packed = true提高编码效率。第三,调试信息字段在生产环境可以关闭,减少序列化开销。
实测下来,ADCTrajectory里trajectory_point数量在 100 左右时,序列化耗时在毫秒级,对规划周期影响不大。但如果 debug 信息全开,序列化耗时可能翻倍。所以生产环境建议只开必要的 debug 字段。
8. 我个人的一些实操体会
折腾 Apollo 6.0 planning proto 这段时间,最大的感受是:proto 定义不是“文档”,而是“契约”。它规定了模块之间怎么说话,一旦定下来,改动的成本很高。所以每次改 proto,我都会问自己三个问题:这个字段真的必要吗?编号选对了吗?下游模块知道吗?
另外,Apollo 的 proto 文件虽然多,但核心的就那么几个。把planning.proto、decision.proto、scenario.proto、planning_config.proto这四个吃透,planning 模块的数据流就基本清楚了。剩下的都是细节,遇到再查也不迟。
最后分享一个小技巧:Apollo 的bazel-bin目录里生成的.pb.h文件,其实是最好的“文档”。它把 proto 定义翻译成了 C++ 类,字段类型、方法名一目了然。比起翻 proto 文件,直接看生成的头文件有时候更快。当然,前提是你得先编译通过。