
简介围绕 VS Code 与 GDB 调试 Apollo 代码的配套资料面向已具备 C 与 Bazel 基础、希望定位自动驾驶工程运行问题的开发者。它把编辑器调试环境配置与 Apollo 项目实践结合起来帮助读者减少手工试错快速建立可复用的断点调试流程。压缩包共 5 个文件由 4 个 JSON 配置文件和 1 个 HTML 说明页构成整体约 5KB体积轻便便于直接放入工程目录对照使用。已有 1143 人学习或下载说明其配置思路对实际开发具有一定参考价值。资料中可获取调试配置模板、C 环境参数、构建任务衔接方式以及断点设置、F5 启动、单步执行、条件断点、watch、调用栈和变量窗口的使用说明同时涉及 Apollo 可执行文件路径、Bazel 产物目录和调试日志参数调整等排错思路适合作为搭建调试会话与理解代码流程的速查材料。1. 断点总打在“隔壁”函数上为什么要给 Apollo 配一套 VSCode 调试链路第一次在容器里调试 Apollo 的 component我把断点打在回调函数第一行模块跑起来后断点一直在灰色状态日志里也不打印任何内容换到命令行 gdb敲完b、c、p三个命令回头就忘了自己停在哪一层线程里。这类问题最终要靠“vscode gdb 调试 apollo”这条链路解决源码里点红点、从进程列表里选中 mainboard、左边变量树把智能指针里的字段逐层展开。它解决的是具体问题让断点停在你想看的那行而不是停在相邻的模板实例化代码或汇编桩上。适合已经在 Apollo 里把某个模块跑通过、但被 printf 折腾到烦的人没编译过工程的新手建议先构建跑通再来。2. 打通调试链路launch.json、gdb 与 bazel 产物三方对齐2.1 先搞清进程模型真正被 gdb 挂住的是谁配 launch.json 之前如果不把进程模型想清楚后面基本只能靠猜测。常见做法是cyber_launch 读 launch 文件fork 出真正的 mainboard有的版本叫 cyber_main进程dag 里声明的每个 component 在运行时才被 dlopen 成 .so 加载进来。也就是说被 gdb 挂住的宿主进程是 mainboard你的 component 只是一个动态库。所以gdb ./libperception_component.so必然失败——它没有入口点gdb 会直接告诉你没有可执行文件。# 看当前有哪些 mainboard 在跑-d 后面跟的就是 dag 路径 ps -ef | grep mainboard | grep -v grep # 精准定位某个 dag 对应的进程-a 打印完整命令行比 ps 干净得多 pgrep -af mainboard -d modules/perception # 确认这个进程实际加载的是哪一份 .so排查「改了代码没生效」全靠它 PID$(pgrep -f mainboard -d modules/perception | head -n1) grep -i perception /proc/$PID/maps | head -n 5pgrep -af的两个参数含义要记牢-a把完整命令行一起打出来方便区分同时跑着的多个 dag-f让匹配作用于整条命令行而不是仅进程名。/proc/$PID/maps那一步是我每次怀疑“断点没生效”时做的第一件事它直接告诉你进程里那份 .so 的绝对路径——是bazel-bin下的 dbg 产物还是某个被 strip 过的旧文件一目了然。还有一个容易忽略的细节cyber_launch start xxx.launch本身是父进程它 fork 出来的子进程才是真正运行 component 的那个。如果你用pgrep -f cyber_launch拿到 pid 再 attach挂住的是一个几乎不执行用户代码的壳断点当然永远不触发。判断方法很简单看 ppid父进程是 cyber_launch子进程才是目标。2.2 attach 与 launch 的取舍容器里跑着的进程怎么接三条路各有明确的适用边界。第一条是 attach模块已经在容器里跑起来gdb 挂上去不动启动方式。优点是改动最小适合“数据正在流、我只想抓某一帧”的场景缺点也明显——挂上之前发生的一切都看不到而且 gdb 默认会暂停被调试进程的所有线程同一个 mainboard 里其他 component 会跟着卡住下游模块收不到消息就开始不断输出超时日志。我自己踩过一次调试一个障碍物融合模块attach 之后忘了continue十分钟后下游规划模块的日志已经写满好几屏。第二条是 launch让 gdb 直接拉起 mainboard从头开始控制。这条路要绕过 cyber_launch手写mainboard -d modules/xxx/dag/xxx.dag同时把 launch 文件里那些环境变量和参数补回来少一个就可能因为找不到 conf 目录而退出。好处是能从第一条消息开始下断点适合复现“初始化阶段就崩”的问题。第三条是 gdbserver后端在容器里跑gdbserver :1234 mainboard -d ...前端 VSCode 通过miDebuggerServerAddress连上去。前后端分离容器重启不影响编辑器跨机器调试也走得通缺点是多一层网络断点响应比本地 attach 慢一点。不管走哪条先确认两件事。一是 ptrace 权限容器启动时需要--cap-addSYS_PTRACE某些环境还要配--security-opt seccompunconfined否则 attach 会直接报ptrace: Operation not permitted。二是宿主机上的cat /proc/sys/kernel/yama/ptrace_scope值为 1 时只允许 attach 自己的子进程跨进程 attach 会被拒。这两点不解决gdb 命令行也一样挂不上不是 VSCode 的问题。2.3 一份能直接抄的 launch.json 和前置任务配置文件我一般放在工作区的.vscode/launch.jsonattach 和 gdbserver 两条各写一份用的时候在调试面板下拉切换。{ version: 0.2.0, configurations: [ { name: attach: mainboard 容器内, type: cppdbg, request: attach, program: /apollo/bazel-bin/cyber/mainboard, processId: ${command:pickProcess}, MIMode: gdb, miDebuggerPath: /usr/bin/gdb, stopAtEntry: false, setupCommands: [ { text: -enable-pretty-printing, description: 打开 STL 美化打印, ignoreFailures: true }, { text: set pagination off, description: 关闭分页避免卡在 --More--, ignoreFailures: true }, { text: set substitute-path /apollo /home/dev/workspace/apollo, description: 源码路径替换, ignoreFailures: true }, { text: set scheduler-locking off, description: 不锁定线程保持其他线程可跑, ignoreFailures: true } ], sourceFileMap: { /apollo: /home/dev/workspace/apollo }, externalConsole: false, logging: { engineLogging: true } }, { name: gdbserver: 连容器 1234, type: cppdbg, request: launch, program: /apollo/bazel-bin/cyber/mainboard, miDebuggerServerAddress: 127.0.0.1:1234, miDebuggerPath: /usr/bin/gdb, cwd: /apollo, args: [-d, modules/perception/dag/perception.dag], MIMode: gdb } ] }几个字段值得单独说。processId用${command:pickProcess}会弹出进程列表让你选但列表里只能看到当前用户、当前命名空间下的进程——如果你在宿主机开 VSCode、进程在容器里这个列表是空的必须先把编辑器通过远程方式挂进容器或者改用 gdbserver 那条配置。program在 attach 模式下其实不是必须的但填上 mainboard 的路径能让 gdb 提前加载主程序的符号栈回溯时函数名会完整很多。sourceFileMap和setupCommands里的substitute-path是两套机制前者作用于 VSCode 的源码定位后者作用于 gdb 自己的符号解析两个都写不冲突反而是最稳的写法。还有个血泪教训setupCommands里的text字段对${workspaceFolder}这类变量的展开行为在不同版本里不一致我遇到过原样传进去导致 substitute-path 指向一个字面量目录的情况。所以这里直接写死绝对路径宁可换机器时改一次也别指望变量展开。logging.engineLogging打开后调试控制台会输出 gdb/MI 的原始收发报文。连接失败、断点不生效、程序启动即退这三类问题靠这段日志基本都能定位到具体是哪一步返回了 error算是个黑匣子级别的后悔药调通之后可以关掉不然输出很吵。3. 让断点变实心调试符号、编译模式与源码路径3.1 -c dbg 与 opt断点灰掉的根因VSCode 里断点的图标状态是有明确含义的很多人误以为是玄学其实对照一下就能判断问题在哪。断点外观含义常见原因实心红点已解析行号有效正常灰色空心圈未绑定到任何地址符号缺失、文件未加载、路径不匹配红点带感叹号条件表达式求值失败变量在该作用域不存在变成小箭头已命中并停住正常灰圈最常见的原因就是编译模式。bazel 默认走 fastbuildApollo 的构建脚本在不同版本里默认目标也不完全一样如果你只跑过普通构建产物很可能带优化甚至被 stripgdb 加载后info functions空空如也断点自然绑不上。# 只编目标模块比全量 debug 构建快得多 bazel build -c dbg --stripnever //modules/perception:perception_component # 确认产物里到底有没有调试信息 file bazel-bin/modules/perception/libperception_component.so # 期望输出里带 with debug_info, not stripped readelf -S bazel-bin/modules/perception/libperception_component.so | grep -c debug_ # 返回 0 就说明没有调试段别急着怀疑 VSCode参数逐个解释-c dbg等价于--compilation_modedbg会给编译器加-g并把默认优化级别降到-O0--stripnever保证链接后不剥离符号表这一步经常被漏掉尤其是模块自己的 BUILD 文件里写了 strip 规则的时候//modules/perception:perception_component是 bazel 的目标标签冒号前是包路径、冒号后是目标名具体名字以你仓库里 BUILD 文件为准。还有一类更隐蔽的情况某个模块的 BUILD 里硬写了--copt-O2命令行上加的--copt-O0不一定压得住因为 bazel 的选项合并顺序并不总按你写的先后。这种模块的表现是——断点能绑上但单步时行号乱跳、变量显示optimized out。遇到这种我一般退一步不追求行级断点直接在函数入口下断点靠bt看调用栈和参数值够用就行别跟编译器优化较劲。3.2 sourceFileMap 与目录映射容器内外两套路径符号有了断点还是灰的八成是路径问题。容器里源码在/apollo/modules/...你编辑器打开的可能是宿主机上的另一个绝对路径gdb 报出来的文件名和你打开的文件对不上VSCode 就无法把断点映射到具体行。判断方法很直接attach 成功后在调试控制台敲info source它会打印当前栈帧对应的源文件绝对路径。拿这个路径和你编辑器里打开的文件路径比一比不一致就是映射没生效。修法有两个层次。前端用sourceFileMap把容器路径映射到本地路径这是 VSCode 层的后端用set substitute-path /apollo /home/dev/workspace/apollo让 gdb 自己在解析符号时就把路径换掉这是调试器层的。两层都配的好处是即使某次 gdb 没有读到 setupCommands比如用命令行 attach 时前端映射还能兜住。另一个常被忽略的点是同名文件。Apollo 里不同模块经常有xxx_component.cc这种重名文件如果进程里同时加载了多份 .so断点可能绑到了错误的那一份上。这时候info sharedlibrary会列出所有已加载的动态库及其符号状态看到Yes才是符号已读入看到No说明那份库没带调试信息或者还没加载——后者在懒加载场景下很常见得先让模块收到第一条消息触发加载断点才会真正绑定。3.3 从零给一个 component 打断点的完整流程把上面几节串成一条可复现的路径按顺序走一遍。第一步用 debug 模式编译目标模块确认file输出带not stripped。第二步启动模块我习惯直接用 mainboard 而不是 cyber_launch少一层 forkpid 好找# 在容器内的 /apollo 目录下执行dag 路径按实际模块替换 ./bazel-bin/cyber/mainboard -d modules/perception/dag/perception.dag # 拿到 pid 后确认加载的是刚编的那份库 PID$(pgrep -f mainboard -d modules/perception | head -n1) grep perception /proc/$PID/maps第三步VSCode 里选 attach 配置pickProcess选中上面那个 pid等左下角状态变成已连接。第四步在源码里下断点然后看调试控制台的输出——正常情况下会有一行类似Breakpoint 1 at 0x7f...: file /apollo/modules/perception/xxx_component.cc, line 128.这行里有具体的十六进制地址说明绑定成功只有Pending没有地址说明符号或路径还有问题回上一节排查。第五步触发消息。可以用cyber_channel echo /perception/obstacles看通道有没有数据或者直接让上游模块发一帧。断点命中后调用栈面板会显示当前线程的完整回溯变量面板能展开智能指针this指针里的成员按需展开即可。有个小技巧如果模块跑得非常快断点刚命中就被别的线程带过去了可以先在调试控制台的“监视”里加一个$_thread命中时能看到是哪个线程停的配合后面第 4 章的条件断点能把噪声压到很低。4. 高频回调与多进程断点停不住的场景怎么破4.1 条件断点与命中计数把断点收窄到一次感知、定位这类模块的 Proc 回调动辄几十上百赫兹无条件断点等于让程序原地卡死。VSCode 里右键断点选“编辑断点”可以填条件表达式比如msg-header().sequence_num() 300只有第 300 帧才停。这里有个性能陷阱要提前说条件表达式是 gdb 在被调试进程里求值的每命中一次就执行一遍。如果你在条件里写msg-ShortDebugString().find(xxx) ! std::string::npos这种会分配内存、拼字符串的调用模块的实时率会被拖垮甚至因为持有锁导致其他线程超时。我的习惯是条件里只放基本类型字段的直接比较指针判空也放在前面短路掉。不想改条件的时候用命中计数更省事。调试控制台里ignore 1 500表示 1 号断点前 500 次命中都跳过第 501 次才停。配合info breakpoints看当前命中次数适合那种“跑到某个稳定状态之后才出问题”的场景。还有一种土办法但极其有效在代码里临时加一个static int cnt 0; if (cnt 300) { int a 0; }然后断在那一行。虽然不优雅但在高频回调里比任何条件断点都稳因为判断发生在编译后的代码里不经过 gdb 求值。4.2 观察点、catch throw 与线程调度断点解决“执行到某行”观察点解决“某个值变了”。watch -l frame_-size()会在成员变量被写时停下-l表示监视具体地址而不是表达式重算对智能指针成员尤其必要。硬件观察点数量有限一般四个超了 gdb 会退化成软件观察点速度骤降所以别一次性挂一堆。catch throw用来抓异常抛出点。Apollo 里不少模块用异常做边界处理日志里只看到一句“caught exception”根本不知道从哪抛的catch throw一挂栈回溯直接指到源头。用完记得delete掉否则每次异常都停干扰很大。多线程是另一个坑。默认情况下 gdb 停在断点时会暂停所有线程单步时其他线程也不动看起来很方便但对实时系统来说等于人为制造了一次全局停顿。set scheduler-locking on让只有当前线程跑单步更干净代价是其他线程彻底冻住调完务必detach而不是直接关编辑器否则进程可能停在暂停状态再也起不来。这一点我吃过亏——调试完直接关窗口第二天发现某个模块一直没输出查了半天才想起是进程还挂在 gdb 的控制下。protobuf 消息在变量面板里展开体验很差嵌套几层就找不着北。调试控制台里p msg-ShortDebugString()能直接打出可读文本前提是这个函数符号还在、没被内联掉如果提示找不到符号说明编译优化把它干掉了那就退回逐个字段看或者临时在代码里加一行日志再编一次。4.3 用 gdb 脚本把 attach 流程固化下来每次 attach 都要重新敲 substitute-path、重新下断点重复劳动迟早出错。把常用操作写成 gdb 脚本attach 时用-x带进去。# /apollo/.debug/apollo-attach.gdb set pagination off set confirm off set width 0 set substitute-path /apollo /home/dev/workspace/apollo set print pretty on set print elements 64 # 断在回调入口命中后打印线程号和四层栈然后继续跑 break perception_component.cc:128 commands silent printf hit Proc, thread%d\n, $_thread bt 4 continue end # 自定义命令用来快速看一帧消息的关键字段 define show_msg print $arg0-header().sequence_num() print $arg0-header().timestamp_sec() end脚本里的commands ... end块是关键silent抑制 gdb 默认的那一大段停住提示printf打你要的信息continue自动放行整个流程不用人工干预。$_thread是 gdb 的便利变量返回当前线程号排查“到底哪个线程在跑这段代码”非常好用。set print elements 64限制容器类打印的元素个数不加的话一个几百帧的 vector 能把控制台刷爆。配合一行 shell 就能一键挂上PID$(pgrep -f mainboard -d modules/perception | head -n1) gdb -p $PID -x /apollo/.debug/apollo-attach.gdb如果是纯粹的一次性采集不需要交互可以再加-batch -ex detach -ex quit让 gdb 采完自动退出并把进程放回去。这套组合我一般做成一个 shell 函数放在.bashrc里参数化 pid 和脚本路径比每次开 VSCode 点选快。5. 避坑与排查五个把断点“吞掉”的现场5.1 断点灰掉、停进汇编层现象attach 成功源码里下了断点图标一直是灰色空心圈偶尔变成实心命中后却停在一片汇编里变量面板全是optimized out。原因设备上跑的是 opt 产物或者产物虽然带符号但模块 BUILD 里写死了高优化级别。gdb 找不到行号信息只能按地址下断。解决file看产物是否not strippedreadelf -S数debug_段数量为 0 就重新用-c dbg --stripnever编。如果确认是 BUILD 里写死优化级别别硬顶把断点降级到函数入口用bt和p看状态。函数级断点在优化构建下依然可靠因为函数符号很少被完全消除。5.2 attach 报 ptrace 权限错误现象VSCode 点下 attach 立刻弹ptrace: Operation not permitted命令行 gdb 也一样。原因容器缺SYS_PTRACE能力或者ptrace_scope限制了跨进程 attach或者调试用户和被调试进程的用户不是同一个。解决容器启动参数补--cap-addSYS_PTRACE --security-opt seccompunconfined宿主机上看cat /proc/sys/kernel/yama/ptrace_scope是 1 就临时调成 0 或者在同一个 shell 会话里起进程再 attach最后确认ps -o user -p $PID和whoami一致。三条里任何一条不满足报错都是一样的得逐个排除。5.3 断点命中后整个模块卡死、下游刷超时现象断点停住的那几十秒里下游模块日志疯狂刷超时松开之后上游数据大量积压。原因gdb 默认暂停被调试进程的所有线程mainboard 里的其他 component 跟着一起停。cyber 的通道有队列长度上限停久了就丢帧。解决短时间调试用set scheduler-locking off让其他线程继续跑只停当前线程调试完用detach而不是直接关窗口确保进程恢复。如果是长时间分析干脆别 attach 到生产进程改成 gdbserver 起一个独立实例用回放数据喂它。5.4 断点绑到了同名文件上现象断点明明打在 A 模块的xxx_component.cc停下来却进了 B 模块的同名函数调用栈完全对不上。原因仓库里多个模块有同名源文件进程内同时加载了多份带符号的 .sogdb 按文件名匹配时选中了先加载的那一份。解决用绝对路径下断点break /apollo/modules/perception/xxx_component.cc:128或者先info sharedlibrary确认目标库已加载且符号为Yes再sharedlibrary libperception_component单独加载。养成写全路径的习惯能省掉大量误判。5.5 改了代码断点没变化现象修改了源文件、重新编译attach 上去断点位置还是旧的甚至停在了已经被删掉的行上。原因进程加载的是旧 .so新产物根本没被替换或者调试器缓存了旧的符号地址。解决grep 模块名 /proc/$PID/maps看实际加载路径和bazel-bin下的产物路径对一下时间戳确认后重启进程再 attach。VSCode 这边记得先断开再重连别在同一个会话里反复 attach/detachMI 通道有时会残留状态。6. 进阶把调试配置沉淀成工作区模板配通一次不算本事下次换模块还能三分钟挂上才算。我现在的做法是把.vscode目录和一份 gdb 脚本一起放进工作区谁拉到这份代码谁就有现成的调试入口。具体是三层结构。第一层launch.json里放三份配置attach 到 mainboard、gdbserver 连容器、以及一份只用于启动单模块的最小 launch名字统一带前缀方便搜索。第二层tasks.json里挂一个编目标的 task把bazel build -c dbg --stripnever ${input:target}包进去用inputs让用户在调起时输入目标标签避免手敲。第三层是一份.debug/attach.gdb只放路径替换、打印格式和两三个高频断点模板具体行号留空让人自己填。几个参数我固定下来不再改列在下面备查。配置项取值为什么这么定MIModegdb容器里几乎不会装 lldb统一 gdb 少一次判断miDebuggerPath/usr/bin/gdb写绝对路径避免 PATH 里被别的版本抢占externalConsolefalse外部终端拿不到 MI 输出排查全靠它stopAtEntryfalsemainboard 入口停住没意义直接等断点验证方式也很朴素随便挑一个组件 module从改一行代码开始重新编译、重启、attach、下断点看能不能在三十秒内停下来。停不下来就按第 5 章那五条挨个过一遍通常两三条之内就能定位。这套流程我一般当作新人上手的第一课比读十页文档管用。一个具体技巧把断点位置的判断从“看图标”改成“看控制台那行 Breakpoint N at 0x...”养成习惯之后灰圈和不灰圈就不需要猜了——有地址就是绑上了没地址就是没绑上问题立刻收敛到符号和路径两个方向。从那以后我每次换模块调试都强制先跑一遍“编译、确认 not stripped、查 /proc/maps、看 Breakpoint 地址”这四步再动手下断点。绕开这四步直接点红点的结果往往就是半小时的无效排查。希望帮到你。本文还有配套的精品资源点击获取