Apollo VSCode GDB调试链路:断点、符号与容器attach实战
简介:围绕 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-add=SYS_PTRACE,某些环境还要配--security-opt seccomp=unconfined,否则 attach 会直接报ptrace: Operation not permitted。二是宿主机上的cat /proc/sys/kernel/yama/ptrace_scope,值为 1 时只允许 attach 自己的子进程,跨进程 attach 会被拒。这两点不解决,gdb 命令行也一样挂不上,不是 VSCode 的问题。
2.3 一份能直接抄的 launch.json 和前置任务
配置文件我一般放在工作区的.vscode/launch.json,attach 和 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 默认走 fastbuild,Apollo 的构建脚本在不同版本里默认目标也不完全一样,如果你只跑过普通构建,产物很可能带优化甚至被 strip,gdb 加载后info functions空空如也,断点自然绑不上。
# 只编目标模块,比全量 debug 构建快得多 bazel build -c dbg --strip=never //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_mode=dbg,会给编译器加-g并把默认优化级别降到-O0;--strip=never保证链接后不剥离符号表,这一步经常被漏掉,尤其是模块自己的 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,少一层 fork,pid 好找:
# 在容器内的 /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 stripped,readelf -S数debug_段数量,为 0 就重新用-c dbg --strip=never编。如果确认是 BUILD 里写死优化级别,别硬顶,把断点降级到函数入口,用bt和p看状态。函数级断点在优化构建下依然可靠,因为函数符号很少被完全消除。
5.2 attach 报 ptrace 权限错误
现象:VSCode 点下 attach 立刻弹ptrace: Operation not permitted,命令行 gdb 也一样。
原因:容器缺SYS_PTRACE能力,或者ptrace_scope限制了跨进程 attach,或者调试用户和被调试进程的用户不是同一个。
解决:容器启动参数补--cap-add=SYS_PTRACE --security-opt seccomp=unconfined;宿主机上看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 模块的同名函数,调用栈完全对不上。
原因:仓库里多个模块有同名源文件,进程内同时加载了多份带符号的 .so,gdb 按文件名匹配时选中了先加载的那一份。
解决:用绝对路径下断点,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/detach,MI 通道有时会残留状态。
6. 进阶:把调试配置沉淀成工作区模板
配通一次不算本事,下次换模块还能三分钟挂上才算。我现在的做法是把.vscode目录和一份 gdb 脚本一起放进工作区,谁拉到这份代码谁就有现成的调试入口。
具体是三层结构。第一层launch.json里放三份配置:attach 到 mainboard、gdbserver 连容器、以及一份只用于启动单模块的最小 launch,名字统一带前缀方便搜索。第二层tasks.json里挂一个编目标的 task,把bazel build -c dbg --strip=never ${input:target}包进去,用inputs让用户在调起时输入目标标签,避免手敲。第三层是一份.debug/attach.gdb,只放路径替换、打印格式和两三个高频断点模板,具体行号留空让人自己填。
几个参数我固定下来不再改,列在下面备查。
| 配置项 | 取值 | 为什么这么定 |
|---|---|---|
MIMode | gdb | 容器里几乎不会装 lldb,统一 gdb 少一次判断 |
miDebuggerPath | /usr/bin/gdb | 写绝对路径,避免 PATH 里被别的版本抢占 |
externalConsole | false | 外部终端拿不到 MI 输出,排查全靠它 |
stopAtEntry | false | mainboard 入口停住没意义,直接等断点 |
验证方式也很朴素:随便挑一个组件 module,从改一行代码开始,重新编译、重启、attach、下断点,看能不能在三十秒内停下来。停不下来就按第 5 章那五条挨个过一遍,通常两三条之内就能定位。这套流程我一般当作新人上手的第一课,比读十页文档管用。
一个具体技巧:把断点位置的判断从“看图标”改成“看控制台那行 Breakpoint N at 0x...”,养成习惯之后,灰圈和不灰圈就不需要猜了——有地址就是绑上了,没地址就是没绑上,问题立刻收敛到符号和路径两个方向。从那以后我每次换模块调试,都强制先跑一遍“编译、确认 not stripped、查 /proc/maps、看 Breakpoint 地址”这四步,再动手下断点。绕开这四步直接点红点的结果,往往就是半小时的无效排查。希望帮到你。
本文还有配套的精品资源,点击获取