简介:面向使用 VS Code 调试 Apollo 自动驾驶项目的开发者,这份 zip 资源围绕 GDB 断点调试场景整理,适合已具备 C++ 与 Bazel 基础、希望快速跑通调试链路并理解工程运行流程的初中级人员。包内共 5 个文件,由 4 个 JSON 配置文件和 1 个 HTML 说明页组成,压缩包约 5KB;JSON 覆盖调试、任务、编辑器及 C/C++ 属性等配置,HTML 页用于承载方法说明,方便按实际路径与编译产物对照修改。内容重点涉及调试参数填写、断点设置、单步执行、变量与调用栈查看、条件断点使用以及日志参数开启等环节,可帮助读者在 Apollo 工程中定位问题、观察调用关系并验证修改效果;遇到复杂调用时,也可结合集成终端输出逐步缩小排查范围。已有 1143 人学习,适合作为搭建 VS Code 联合 GDB 调试环境的配置参考与排错清单。
1. 断点为什么是空心的:vscode gdb调试 apollo 的第一道坎
第一次在容器里对着 apollo 代码做 vscode gdb调试,最先出现的往往不是变量窗口,而是行号左边一个灰色空心圈。鼠标悬上去提示 Unverified breakpoint,按下 F5 之后程序照跑不误,断点纹丝不动。这套环境的门槛不在命令条数,而在三层结构叠在一起:宿主机上的编辑器、Docker 容器里的编译产物、Bazel 沙箱生成的调试信息路径。更麻烦的是 apollo 的模块以组件形式被 mainboard 动态加载,你要断的对象不是一个可执行文件,而是一个进程加几十个 .so,库没加载之前断点必然是空的。装不好的结果很直接:日志翻天也看不出数据流,只能靠 GLOG 一行行打,改一个分支重编二十分钟。这套流程适合正在改规划、感知模块,却被断点卡住的开发者。
2. 工具链对齐:gdb、Bazel 与 debug 构建先配好
在编辑器里折腾 launch.json 之前,得先确认底层三件事是通的:宿主机撑得住、构建产物带符号、容器里的 gdb 能被 MI 接口驱动。这三件事任何一件没做,后面所有配置都是白费。我见过太多人在 launch.json 里改了七八遍 sourceFileMap,最后发现根因是 bazel-bin 这个软链还指着 release 输出。
检查顺序建议从物理资源往上走,因为编译失败和断点失败的表现很像,都是「什么都没发生」,容易把 OOM 当成配置问题。
2.1 宿主机上先确认四件事
Apollo 全量编译的峰值内存不低,机器不够时 bazel 会被内核直接干掉,终端里只剩一个Killed,看起来特别像配置错误。磁盘也是同理,Docker 镜像加上 bazel 输出目录,几十 G 很容易就满了。先把这几个数看清楚再动手。
lsb_release -a # 发行版信息,20.04 LTS 优先 free -g # 内存看 available 一列,建议 16G 起 df -h /var/lib/docker # 镜像和构建缓存的落脚点,留 60G 以上 docker version --format '{{.Server.Version}}' # Docker 服务端版本free -g要盯的是 available 而不是 free,Linux 会把缓存算进去,单看 free 会误判。df -h /var/lib/docker看的是 Docker 根目录所在的挂载点,如果你的 Docker 数据目录被改到别的盘,这条命令的路径要跟着换。Docker 版本太低会影响到后面 Remote 连接的稳定性,20.10 之后的版本基本没毛病。
Ubuntu 18.04 也不是不能跑,只是新版 VS Code 的 remote server 对 glibc 有最低要求,老系统上偶尔会出现扩展装到一半失败,提示缺 GLIBC 版本。真要在 18.04 上做,把 C/C++ 扩展回退一到两个大版本通常就能起来,这是我在老机器上最常用的一招。
内存不足又暂时加不了内存条的,挂一块 swap 能救急,代价是编译时间翻倍。我一般会先./apollo.sh build全量跑一遍看看机器扛不扛得住,扛不住再退到只编目标模块。
2.2 编译带符号:-c dbg 与 apollo.sh 的 debug 入口
断点能不能绑定,取决于目标文件里有没有调试信息。release 产物默认会被剥符号或者用高等级优化,gdb 拿到之后连行号都算不准。所以第一件事是把 debug 版本编出来,而不是急着开编辑器。
Apollo 仓库根目录一般带一个 apollo.sh,先看它支持哪些子命令,不同分支命名不太一样:
cd /apollo ./apollo.sh --help 2>&1 | head -30 # 找 build / build_dbg / build_opt 这类入口有 debug 入口就直接用,没有就落到 bazel 命令上。两种写法都行,看仓库习惯:
# 写法一:脚本里的 debug 构建 ./apollo.sh build_dbg # 写法二:直接调 bazel,只编目标模块和宿主进程 bazel build -c dbg --copt=-g --copt=-O0 \ //modules/planning/... \ //cyber/mainboard/...-c dbg是 Bazel 的编译配置开关,它会把输出切到独立的bazel-out/...-dbg/bin目录,同时给 C++ 编译加上-O0 -g。之所以还要显式再写一遍--copt=-g --copt=-O0,是因为不少项目的 BUILD 文件里硬编码了copts = ["-O2"]或者-DNDEBUG,这些写在 BUILD 里的参数会盖掉一部分命令行默认值。命令行--copt排在后面,能压住前面,但也不是万能的——如果 BUILD 里用的是linkopts或者features,就得直接改文件。
只编//modules/planning/...和//cyber/mainboard/...是刻意的,全量编一次四十分钟起步,只编目标模块加宿主进程通常十分钟内能完。这里有个前提:你要断的模块和 mainboard 必须都是 debug 版,其他模块保持 release 不影响调试,因为它们只是被调用方,不出现在你的断点路径上。
编完必须验一下符号到底在不在,这一步别省:
file bazel-bin/modules/planning/libplanning_component.so readelf -S bazel-bin/modules/planning/libplanning_component.so | grep -c debug ls -l bazel-bin # 看软链指向哪个配置目录file的输出里应该能看到with debug_info, not stripped,如果只有not stripped而没有 debug_info,说明-g没生效。readelf数出来的 debug 段数量大于 0 才正常,常见的段有.debug_info、.debug_line、.debug_str。最后那条ls -l bazel-bin特别关键,它是软链,指向 release 还是 dbg 目录一眼就能看出来,很多「断点不生效」的根因就在这条软链上。
2.3 容器内 gdb 自检三条命令
编辑器和调试器之间走的是 MI 接口,gdb 太老会出现变量显示错乱、std::string 打不开、断点命令解析失败这些玄学问题。老镜像里带 7.x 的情况不少见,能换镜像就换,换不了就在容器内装上新的。
gdb --version # 建议 8.1 以上,9.x / 10.x 更稳 gdb -q -ex "set confirm off" -ex quit # 能否正常启动,有没有初始化脚本报错 ldd --version | head -1 # glibc 版本,和宿主机差太多时留意路径映射第二条命令看着像凑数,实际很有用:容器里的/etc/gdbinit或者用户目录的.gdbinit如果写了奇怪的脚本,gdb 会在启动阶段就报错,而 VS Code 里只会表现成「调试会话启动失败」,看不到真正原因。先手动起一次,把这类干扰排掉。
还得确认容器有没有 ptrace 权限,attach 模式全靠它:
capsh --decode=$(awk '/CapEff/{print $2}' /proc/self/status) | tr ',' '\n' | grep -i ptrace有输出说明CAP_SYS_PTRACE在,没输出就得在启动容器时补上--cap-add=SYS_PTRACE。这条检查我一般放在进容器后的第一分钟,比事后对着Operation not permitted猜要快得多。
3. 编辑器接进容器:Dev Containers 与 C/C++ 扩展的装法
Apollo 的代码和编译产物都在容器里,宿主机上只放源码目录的话,路径、符号、库依赖全对不上。所以编辑器本身也得「住进」容器,而不是在宿主机上装个插件去够容器的文件。
主流的做法是 Dev Containers 扩展 attach 到运行中的容器,VS Code 会在容器里装一份 server 和扩展,再把界面转发回宿主机。这条路的好处是所有路径都是容器内视角,/apollo就是/apollo,不用做任何映射。
3.1 扩展只装三个,别贪多
扩展装多了会互相抢调试会话,尤其是同时装了 C/C++ 和别的调试器扩展时,F5 按下去弹出来一串调试器选项,选错了还以为是配置问题。我一般只留这几个:
| 扩展 | 用途 | 注意事项 |
|---|---|---|
| Dev Containers | attach 到运行中的容器 | 容器必须在运行状态,退出了就 attach 不上 |
| C/C++ | 提供 cppdbg 调试类型 | 版本要跟客户端匹配,老 glibc 镜像需回退 |
| C/C++ Extension Pack | 语法高亮和代码跳转 | 可选,不影响调试 |
C/C++ 扩展是重点。它在容器内会跑一个独立的 server 进程,如果容器的 glibc 版本低于 server 的最低要求,表现是扩展界面上一直转圈、调试配置点不开。这种情况不用去改 glibc,直接把扩展回退一个版本,或者把 VS Code 客户端降级,是最省事的做法。
装完扩展别急着配 launch.json,先确认容器内的 server 起来了:VS Code 左下角会显示Dev Container: <容器名>,点开能看到容器内的进程列表。状态不对的话,命令面板里执行一次 Reopen in Container 让它重装一遍 server。
3.2 把 /apollo 挂成工作区
这一步看着简单,实际决定了断点能不能解析。attach 上容器之后,一定要用 Open Folder 打开/apollo,而不是宿主机上的源码目录。原因是 bazel 编译时记录的调试信息路径,是相对于容器内 execroot 的,工作区根目录不对,cpptools 拿到路径后找不到对应文件,断点就退回灰色空心圈。
如果宿主机源码目录通过-v挂到了容器的/apollo,那么.vscode目录放在宿主机源码根下就行,容器里看到的是同一份文件,改完立刻生效,不用来回拷。
工作区结构建议是这样:
/apollo ├── .vscode │ ├── launch.json # 调试配置,四种模式可以都放这里 │ └── settings.json # 关掉不相关的提示,减少干扰 ├── modules/ # 业务模块源码 ├── cyber/ # 框架和 mainboard └── bazel-bin -> ... # 软链,注意指向 dbg 还是 opt.vscode放在源码根下还有一个隐性好处:调试时 cpptools 会把${workspaceFolder}解析成/apollo,写 sourceFileMap 的时候直接能对上,不用做二次换算。
3.3 装完先验一次:Remote 终端里手动跑 gdb
配置写一堆不如先手动跑一遍。在 VS Code 的 Remote 终端里(注意是容器内终端,不是宿主机终端),直接起 gdb 加载 mainboard,看能不能列出源码、能不能在 main 上停住:
cd /apollo gdb -q bazel-bin/cyber/mainboard/mainboard进去之后依次敲:
(gdb) info sources (gdb) break main (gdb) run -d /apollo/modules/planning/dag/planning.daginfo sources会列出所有带调试信息的源文件,如果这里空空如也,说明 mainboard 编的是 release 版本,回去重编。break main报No symbol table is loaded也是同一个原因。能成功停在 main 上,说明从可执行文件到源码这一整条链路是通的,剩下的只是把同一套参数翻译成 launch.json。
手动这条路径还有个附带价值:出现问题时你知道该看哪一层。终端里跑得通、VS Code 里跑不通,问题就在编辑器配置;终端里就跑不通,问题在编译产物或者路径,跟编辑器半点关系没有。
4. launch.json 四段骨架:program、args、setupCommands、sourceFileMap
调试配置里真正影响成败的就四段:用哪个可执行文件、传什么参数、给 gdb 下什么初始命令、源码路径怎么映射。其余字段多数是锦上添花,先把这个骨架搭稳,再谈优化。
Apollo 的特殊之处在于模块是 .so,所以「用哪个可执行文件」这一项的答案永远是 mainboard,而不是你以为的 planning 二进制。这个认知一旦反了,后面所有配置都会歪。
4.1 launch 模式:让 mainboard 直接拉起一个 dag
launch 模式是 F5 之后由编辑器启动进程,适合想从进程第一行开始跟的场景。下面这份配置可以直接抄,注释是为了对照,实际文件里删掉:
{ "version": "0.2.0", "configurations": [ { "name": "Apollo: mainboard + planning.dag", "type": "cppdbg", "request": "launch", // 可执行文件是 mainboard,不是目标模块自己 "program": "/apollo/bazel-bin/cyber/mainboard/mainboard", // -d 后面跟 dag 文件,组件由 dag 描述 "args": ["-d", "/apollo/modules/planning/dag/planning.dag"], // cwd 必须是 /apollo,很多模块用相对路径读 conf "cwd": "/apollo", "stopAtEntry": false, "environment": [ {"name": "GLOG_logtostderr", "value": "1"}, {"name": "GLOG_v", "value": "3"} ], "externalConsole": false, "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", "setupCommands": [ // 关键:.so 未加载时断点先挂起,等 dlopen 后自动绑定 {"description": "pending 断点", "text": "set breakpoint pending on", "ignoreFailures": true}, {"description": "STL 美化输出", "text": "-enable-pretty-printing", "ignoreFailures": true}, {"description": "结构体展开", "text": "set print pretty on", "ignoreFailures": true}, // SIGPIPE 在 cyber 通信里常见,默认会让 gdb 停下来 {"description": "忽略 SIGPIPE", "text": "handle SIGPIPE nostop noprint pass", "ignoreFailures": true} ], "sourceFileMap": { "/apollo": "${workspaceFolder}" } } ] }program指向 mainboard 是这份配置的核心。Apollo 的组件通过 dag 文件描述,mainboard 读 dag、dlopen 出 .so、注册组件,整个过程中真正的业务代码在 .so 里。把 program 写成业务模块的路径,gdb 会告诉你文件不存在。
args里的-d是 mainboard 加载 dag 的参数,路径给绝对路径最保险。cwd设成/apollo是有原因的:不少模块的配置文件用./conf/xxx.conf这种相对路径,工作目录不对会直接读不到配置,表现为进程启动后立刻退出,日志里只有一句加载失败。
environment里的GLOG_logtostderr=1让日志直接打到调试控制台,GLOG_v=3是日志级别。这两个不设也能调,但排查时少了日志会很难受,我一般默认带上。
setupCommands里set breakpoint pending on是重中之重,没有它,所有打在 .so 里的断点在启动瞬间都会被拒绝。handle SIGPIPE nostop noprint pass也是实战里加的,Apollo 的通信层在断点停住时容易触发 SIGPIPE,不忽略的话 gdb 会被这个信号打断,调试体验极差。
4.2 attach 模式:挂到已经在跑的 mainboard 上
更多时候模块已经用cyber_launch跑起来了,不想为了调一个分支重启整个系统。这时候用 attach,直接挂到现有进程上:
{ "name": "Apollo: attach mainboard", "type": "cppdbg", "request": "attach", // attach 模式下 program 的作用是提供符号表,必填 "program": "/apollo/bazel-bin/cyber/mainboard/mainboard", // pickProcess 会列出容器内进程供选择 "processId": "${command:pickProcess}", "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", "setupCommands": [ {"text": "set breakpoint pending on", "ignoreFailures": true}, {"text": "-enable-pretty-printing", "ignoreFailures": true}, {"text": "set print pretty on", "ignoreFailures": true} ], "sourceFileMap": { "/apollo": "${workspaceFolder}" } }attach 模式下program字段经常被忽略,很多人以为只有 launch 才需要。实际上它是给 gdb 提供符号表的主可执行文件,不填的话 gdb 只能靠 pending 断点慢慢等 .so 加载,变量视图也经常是空的。
processId用${command:pickProcess}会弹一个进程列表,选mainboard就行。进程多的时候列表很长,可以在容器里先ps -ef | grep mainboard拿到 pid,然后改成固定值配合 inputs 使用:
"inputs": [ { "id": "mainboardPid", "type": "promptString", "description": "mainboard 进程 PID", "default": "" } ]attach 之后进程是停住的还是继续跑,取决于 gdb 的默认行为。cppdbg 一般 attach 完就 resume,你在编辑器里下的断点会在下次执行到的时候生效。如果发现 attach 上去之后系统卡住了,先检查是不是有残留的断点把所有线程都拦住了,把断点全删掉再 continue 一次。
4.3 sourceFileMap 与 substitute-path:Bazel 路径对不上的补救
Bazel 在沙箱里编译,调试信息里记录的路径常见三种形态:/apollo/modules/...这种绝对路径、modules/...这种相对 execroot 的路径、以及带bazel-out/k8-dbg/bin/...前缀的生成文件路径。工作区对不上的时候,断点就会变灰。
sourceFileMap支持多个映射,按需加:
"sourceFileMap": { "/apollo": "${workspaceFolder}", "../..": "${workspaceFolder}", "bazel-out": "/apollo/bazel-out" }第一条覆盖绝对路径的情况,第二条处理相对 execroot 的记录方式,第三条一般用不上,只有断点落在生成代码或者 protobuf 产物里才需要。
映射加完还不行,就用 gdb 的 substitute-path 兜底,在 setupCommands 里追加:
{"text": "set substitute-path /build /apollo", "ignoreFailures": true}具体把哪个前缀替换成哪个,不能靠猜。断点变灰的时候打开 DEBUG CONSOLE,里面会有一行No source file named xxx,它要的路径就写在里面,照着改就行。这个信息比任何文档都准,我一般在第一次配环境时就盯着它改,两次就能配对。
还有一种情况是断点落在头文件里,头文件的路径和源文件不在同一个前缀下,需要单独加一条映射。加映射不用担心加多了有副作用,gdb 只会在路径匹配失败时才用,匹配不上就忽略。
4.4 .so 组件断点:pending 与 sharedlibrary 的配合
Apollo 的模块是 dlopen 进来的,mainboard 启动的那一刻,planning 的 .so 根本还没进内存。这时候下断点,gdb 找不到符号,cpptools 就画一个灰圈,看起来像配置错了,其实是时序问题。
正确姿势是让断点处于 pending 状态,等库加载后自动绑定。set breakpoint pending on已经在 setupCommands 里配了,剩下要做的是确认库确实加载了。调试控制台里敲:
-exec info sharedlibrary planning有输出说明libplanning_component.so已经在内存里,此时 pending 断点应该已经变成实心红点。没输出说明 dag 里没加载到 planning,或者 mainboard 启动参数不对,问题不在调试器上。
想在库加载的瞬间就停住,可以临时开一个开关:
-exec set stop-on-solib-events 1但别长期开着。Apollo 一次启动会加载几十个库,每加载一个就停一次,按 continue 能按到手软。我一般只在排查「断点为什么一直灰」的时候开一下,确认库的加载顺序和路径之后就关掉。
另外,如果改动了 .so 但没重启 mainboard,gdb 里看到的还是旧符号,断点位置可能对不上源码行。改完代码重新编译之后,要么重启进程,要么在调试控制台里-exec sharedlibrary让 gdb 重新读一遍,但重新读符号表对已有的断点位置不一定友好,稳妥起见还是重启。
5. 排查清单:断点灰、attach 失败、变量 optimized out
调试环境的问题有个共同点:现象都很像,原因完全不在一个层。断点灰可能是时序问题、路径问题、符号问题,光看提示文字分不出来。下面这几条是我踩过的、也帮别人定位过的,按「现象 → 原因 → 解决」记下来,遇到时按顺序对一遍,比乱改配置快得多。
5.1 现象:断点是灰色空心圈,F5 之后照跑不误
原因通常有两个:目标 .so 还没被 dlopen,或者调试信息里的源码路径与工作区对不上。前者是时序问题,后者是路径问题,两者表现一模一样。
先用调试控制台区分:敲-exec info sharedlibrary planning,如果库已经加载但断点还是灰的,那就是路径问题,去 DEBUG CONSOLE 里找No source file named那行,按它要的路径补 sourceFileMap。如果库压根没加载,说明 dag 里没有这个组件,或者 mainboard 启动参数写错了,先去看进程日志,别在调试器上耗时间。
还有一种少见的:断点下在了头文件的 inline 函数里,被内联之后没有独立地址。这种情况把断点挪到 .cc 里的实际函数上就行。
5.2 现象:attach 报 "ptrace: Operation not permitted"
原因是容器没有CAP_SYS_PTRACE能力,或者宿主机的 yama ptrace_scope 限制策略挡住了跨进程 attach。容器默认只给一小撮 capability,ptrace 不在其中。
先看容器内的限制状态:
cat /proc/sys/kernel/yama/ptrace_scope # 非 0 时有额外限制 capsh --decode=$(awk '/CapEff/{print $2}' /proc/self/status) | grep -i ptrace解决方式分两种。容器还能重开的话,启动时补参数最干净:
docker run --rm -it \ --cap-add=SYS_PTRACE \ --security-opt seccomp=unconfined \ --pid=host \ -v /path/to/apollo:/apollo \ <apollo-image> /bin/bash--cap-add=SYS_PTRACE给的是 ptrace 能力,--security-opt seccomp=unconfined放开系统调用过滤,--pid=host让容器看到宿主机进程命名空间。第三个参数不是必须的,但调试时想从容器里 attach 宿主机上的进程就得加。
容器不方便重开,就在容器内以 root 写 ptrace_scope:
echo 0 > /proc/sys/kernel/yama/ptrace_scope这条要求容器是特权模式,普通容器写不进去。写不进去又重开不了容器,就退回 launch 模式——由 gdb 自己 fork 出进程,不需要 attach 权限,能绕开这个限制。
5.3 现象:断点命中了,变量窗口全是 optimized out
原因是编译时开了优化,变量被寄存器复用或者直接消除,调试信息里没有它的位置。这是 release 构建的典型表现,跟编辑器没关系。
确认方法很直接:
ls -l /apollo/bazel-bin file /apollo/bazel-bin/modules/planning/libplanning_component.so | grep -o 'with debug_info'软链如果指向-opt目录,那就是编错配置了,重编 dbg 版本。有 debug_info 但变量还是 optimized out,说明 BUILD 文件里的-O2覆盖掉了命令行的-O0,去模块的 BUILD 文件里搜copts、-O2、-DNDEBUG,临时注释掉重编。
还有一种情况是变量本身被编译器判定为无用,尤其是一些只用于日志的中间变量。这种变量在源码里存在,但运行时确实没有存储位置,断点停住也看不到值,属于正常现象,改用打印或者看调用栈里的其他变量。
5.4 现象:F5 后程序秒退,提示 exited with code 1
原因基本集中在两处:cwd 不对导致配置读不到,或者 dag 里引用的 .so 路径不存在。mainboard 对这两类错误的提示都很含蓄,日志里往往只有一行加载失败。
先在 Remote 终端里手跑同一条命令,把错误原文拿到:
cd /apollo ./bazel-bin/cyber/mainboard/mainboard -d /apollo/modules/planning/dag/planning.dag终端里的报错信息比调试控制台完整得多,缺哪个文件、哪个库加载失败都会写清楚。确认之后回到 launch.json,把cwd固定成/apollo,program和args里的路径全部改成绝对路径,别用相对路径省事。
dag 文件里module_library字段指向的 .so 路径也要对一遍,有些分支里写的是bazel-bin/modules/...,如果 bazel-bin 软链指到了别的配置目录,这个路径就会失效。这种问题在切过编译配置之后特别容易出现。
5.5 现象:断点一停,其他模块全部超时,整个系统像卡死
原因是 gdb 停住的是 mainboard 的线程,而 cyber 的调度、通信、定时器都跑在这个进程里。断点一命中,消息不再被消费,别的模块发出来的数据堆在队列里,超时日志就开始刷屏。
这不是配置错误,是调试分布式系统的固有代价。缓解办法有几个:断点数量控制在个位数,别在每个回调里都下;能用条件断点就别用普通断点,比如只在某个 channel 名字匹配时才停;调试时只启动目标模块,别把感知、规划、控制全拉起来;看够数据立刻 continue,别让进程停着去翻代码。
真需要长时间观察某个变量的变化,用-exec watch设数据断点,比在每行下断点精确得多,停下来的次数也少。数据断点同样受优化影响,变量被优化掉的话 watch 会失败,还是得回到 debug 构建。
6. 验证跑通:
本文还有配套的精品资源,点击获取