简介:面向自动驾驶开发者,这份资源围绕在VSCode环境中使用GDB调试Apollo项目展开,专门解决Apollo源码构建后如何快速启动断点调试的配置难题。压缩包共5个文件,其中4个为JSON配置,覆盖launch.json启动规则、tasks.json编译任务、settings.json编辑器设置以及c_cpp_properties.json编译器路径指引,另有1个HTML文档详解VSCode中Apollo代码调试的具体方法,整体体积仅5KB,轻量易用。已有1141人学习过该资源,适用于熟悉Apollo环境搭建、但需要便捷调试入口的中级开发者。借助里面的配置样例与步骤说明,可以省去手动摸索launch.json参数的时间,直接获得可运行的中断点调试、单步执行、变量观察与调用栈查看能力,也能避开GDB与Apollo编译产物路径匹配的常见坑。文档还针对条件断点、watch窗口等技巧给出了提示,帮助使用者更高效地跟踪Apollo模块运行状态。
1. 在 VSCode 里调试 Apollo 代码:断点打不中不是手气问题,是配置没对齐
用 VSCode 调试 Apollo(那个基于 Cyber RT 的开源自动驾驶平台)的代码时,很多开发者都遇到过这种状况:红点明明打上了,点击调试却一路执行到底,断点跟没打一样;或者单步执行时,指针一头扎进 STL 容器的内部实现,半天跳不回来。我一开始也以为是 Apollo 的抽象层太深、模板太多导致的,后来才发现九十以上的问题都出在编译参数、launch.json 配置和符号表路径这几个地方。这篇文章就专门讲透这些,适合正在做 Apollo 感知、规划或控制等模块二次开发的人。如果你已经会用 VSCode,但对 gdb 的 attach 和 launch 两种模式还分不清边界,这篇能直接给你一套能落地的方案,顺带排掉几个真正能卡你半天的坑。
2. 调试前的准备:编译选项、调试符号与 launch.json 的选型
2.1 确认 Apollo 的编译模式:Debug vs Release,符号表去哪找
Apollo 的工程默认用 bazel 作为构建系统,和常见的 CMake 项目不太一样。bazel 对编译模式的处理很直接,-c dbg表示编译调试版本,-c opt表示编译优化版本。麻烦就麻烦在,Apollo 的镜像和预编译产物里,很多模块是用 opt 模式发布的,符号表基本被 strip 掉了,gdb 自然找不到任何函数名,断点只能打在基址上,等于没用。
我一般拿到一批 Apollo 的二进制,第一件事就是用file命令做体检:
file /opt/apollo/bin/perception如果输出里有with debug_info,说明符号还在;如果出现stripped,那这个文件无法直接用于 gdb 断点调试。接下来再用nm确认关键函数符号:
nm -C /opt/apollo/bin/perception | grep LidarObstacle-C参数让 gdb 输出 C++ 修饰后的可读名称,方便你对照源码里的类名和函数名。如果输出为空,基本可以判断是符号被清理干净了。
至于符号表去哪找,Apollo 的 bazel 构建会把产物放到bazel-bin/modules/...下,这个路径是软链接,真正的编译产物在.cache目录里。你需要在 workspace 根目录执行:
bazel build -c dbg //modules/perception/perception:perception这里的-c dbg会对应到 gcc 的-g参数,生成完整调试符号。注意:Apollo 里很多模块依赖 cyber 框架,编译前最好确认环境变量CYBER_PATH已经指向bazel-bin/cyber,否则链接时可能找不到库。我习惯在docker/scripts/dev_start.sh之后的开发容器里执行这条命令,宿主机直接编容易因为文件系统权限和库路径踩坑。
另外要留意的是,Apollo 的代码大量使用模板和宏,即使有符号,函数名也可能带很长的命名空间前缀。用nm查到的名字,和源码里的名字在 gdb 里是能对的上的,但断点可以用正则缩写,这个后面讲。
2.2 配置 launch.json:gdb 附加还是启动进程,这两者差别很大
VSCode 调试 Apollo,本质上是把 gdb 作为后台调试器。launch.json 里的请求类型直接决定了你的调试方式。常见的两种:
- attach:附加到已经运行的进程
- launch:启动一个新进程并附加
Apollo 的 Cyber RT 是典型的模块化多进程架构,每个模块(感知、规划、控制)各自是一个独立进程,所以在绝大多数情况下,我们用的是 attach 模式。好处是你不需要在 VSCode 里管环境变量,Apollo 的启动脚本已经把所有上下文准备好,调试器只要“贴”上去就行。
下面是一个我常用的 attach 配置:
{ "version": "0.2.0", "configurations": [ { "name": "Attach to Apollo Process", "type": "cppdbg", "request": "attach", "program": "/opt/apollo/bin/perception", "processId": "${command:pickProcess}", "MIMode": "gdb", "miDebuggerPath": "/usr/bin/gdb", "sourceFileMap": { "/apollo": "${workspaceFolder}" } } ] }这里几个参数很关键。processId设为${command:pickProcess},启动调试时 VSCode 会弹一个进程列表让你选,比手动填 PID 灵活得多。program要指向真实的二进制文件,注意如果这个路径是软链,gdb 可能解析失败,最好用readlink -f先拿到真实路径。sourceFileMap解决源码路径漂移问题,Apollo 在容器内编译时记录的是/apollo/...,宿主机工作区可能是/home/user/apollo,这个映射就是告诉 gdb 把/apollo/modules/...映射到本地对应文件。
launch 模式在什么场景用?当你自己写了一个独立的 Cyber 组件,想从 main 函数开始单步调试,比如调试某个 IPC 订阅积分器,这时候 launch 更合适。配置上需要额外注意环境变量:
{ "name": "Launch Cyber Component", "type": "cppdbg", "request": "launch", "program": "${workspaceFolder}/bazel-bin/modules/my_component/my_component", "args": ["--flagfile=conf/my_component.flags"], "cwd": "${workspaceFolder}", "environment": [ { "name": "LD_LIBRARY_PATH", "value": "${workspaceFolder}/bazel-bin/cyber:${workspaceFolder}/bazel-bin/modules" }, { "name": "CYBER_PATH", "value": "${workspaceFolder}", } ] }Apollo 里很多模块启动时依赖--flagfile参数,这个参数如果不指定,程序直接报错退出。cwd 也很重要,Apollo 习惯把配置文件和日志放在运行目录下,不设置 cwd 会导致读取不到 conf 文件。
2.3 调试符号的验证:用 file 和 nm 命令确认符号存在
很多人在 VSCode 里配好 launch.json,启动调试时发现自己根本没有错,但断点还是灰色的。这部分不是配置问题,是 binary 真的是光板的。验证方法两步走。
先确认二进制类型:
file /opt/apollo/bin/perception # 期望输出:ELF 64-bit ... not stripped如果输出是stripped,那不管 VSCode 怎么配置都白费。此时需要回到第 2.1 节的 bazel 命令重新编译带符号的版本。
然后验证具体的函数符号是否存在,用nm:
nm -C /opt/apollo/bin/perception | grep -i "Obstacle"注意:Apollo 的二进制经过链接后,很多模板函数会被内联,所以某些小函数查不到是正常的,你只要确认关键入口函数(比如main、模块的Init)有符号就行。有些文件用了__attribute__((visibility("hidden"))),符号被隐藏,nm 默认也能看到,除非-D选项才看动态符号表。如果只有一个弱符号,gdb 也能断,因为断点按地址走。
有一个更直观的检验方式:在 VSCode 调试控制台执行:
-exec info functions obstacle以下内容不需要重新输入,它会刷出所有包含 obstacle 的函数。能看到,就说明符号加载正常;看不到,就按 2.1 的步骤重编。
3. 把断点打进 Apollo 进程:从 attach 模式开始实战
3.1 找到正确的进程 PID:别用名字搞混,容器里外要分清
Apollo 的模块繁多,光cyber核心就有好几个守护进程。如果你直接用pgrep -f perception,可能找到好几个同名的,因为 Cyber 派生的子进程名字会带-s参数。我一般在容器外先查:
ps -aux | grep -E "apollo|perception|planning"注意第 2 列就是 PID。这里的坑在于:容器内的进程和宿主机进程是隔离的,如果你用宿主机 VSCode 附加容器内进程,光靠 PID 是识别不了的。VSCode 的${command:pickProcess}会列出宿主机能看到的全部进程,容器里跑的程序虽然 PID 能看到,但直接 attach 权限不够,会报Operation not permitted。所以当你用 pickProcess 选中一个带Docker字样的进程时,要意识到这大概率不可用。
我的习惯是:先进入容器,再确认进程:
docker exec -it apollo_dev bash ps -ef | grep perception拿到容器内的 PID 后,如果使用 Remote-Containers 插件,VSCode 会自动帮你处理容器内进程附加权限,直接用 pickProcess 也没问题。如果没走 Remote,那你在宿主机 attach 之前要在容器里启动gdbserver,这个后面第四章细讲。
还有一个容易混淆的点:cyber_recorder类工具其实也是 Cyber 进程,如果你在调试一个实时模块,需要保证把断点打在目标模块的进程上,而不是打在命令行的前端工具里。
3.2 设置断点:函数断点、文件行号断点、条件断点怎么下
VSCode 的行号断点简单,在源码里点红点就行。但 Apollo 里,一个函数经常被多个地方调用,如果你只想在特定的请求条件下停下来,需要用到条件断点。右键红点,选择“编辑条件”,输入表达式:
if (frame_count > 1000) { // 这里很容易断 }条件断点在 gdb 里其实就是break <location> if <condition>。我把常用的三种断点方式列一下:
| 断点类型 | VSCode 操作 | gdb 等价命令 | 适用场景 |
|---|---|---|---|
| 行号断点 | 点行号左侧 | break file.cpp:12 | 常规调试 |
| 函数断点 | 调试控制台输入 | break ClassName::Method | 多态函数,不同实现 |
| 条件断点 | 右键红点设条件 | break file.cpp:12 if x>5 | 循环海量次数时 |
Apollo 中函数断点特别要注意命名空间:
-exec break apollo::perception::lidar::LidarObstacleDetection::TransformPoint这种方法能直接定位,因为在 C++ 项目里,函数名修饰后的名字在源码里看不到,但 gdb 的自动补全会帮你找。如果你只输入break TransformPoint,gdb 可能提示多个位置,得用break+ Tab 循环选择。
条件断点的表达式支持调用函数,但注意调试时调用函数可能改变程序状态,特别是 C++ 的 const 成员函数没问题,普通函数慎用。另外,Apollo 里有些表达式用了Eigen::Vector3d这类重载运算符,条件写起来有点复杂,可以用简单的成员字段判断。
3.3 单步跟踪的技巧:step into、next、finish 的使用边界
进入单步后,最大的问题是 Apollo 的代码里 STL 容器、智能指针、Eigen 数学库满天飞。如果你直接点“单步跳过”(Step Over),会发现在Eigen::Matrix相关的函数里翻来覆去。原因是 gdb 的单步是基于指令的,就算你用 Step Over,它也会在一个函数内部一步一步跑,直到遇到下一个函数调用。
我常用的做法是配合调试控制台:
-exec set step-mode on这能在单步时先显示下一个即将执行的源代码行,不会直接钻进被调函数。如果你已经钻进标准库,用快捷键Shift+F11(Step Out)或者调试控制台输入finish,可以一口气执行完当前函数返回调用方。
在 Apollo 这种大量使用std::shared_ptr的代码里,Step Into 很容易进入到 shared_ptr 的引用计数代码。一个重要技巧就是给 gdb 设置 skip 文件:
-exec skip file /usr/include/c++/** -exec skip file /opt/apollo/third_party/**这个命令只影响单步行为,不影响断点。设完之后,Step Into 会自动跳过这些目录,直接到你的业务代码。我每次都先把 git submodule 里那些第三方库目录加进 skip list,效果立竿见影。
4. 容器与本地混合调试:Apollo 开发者最常见的调试环境
4.1 在 Docker 容器里启动 Apollo,宿主机 VSCode 怎么连
Apollo 的开发环境基本都是容器化了,直接在宿主机上编译和运行是不推荐的。所以 VSCode 调试 Apollo 的第一道坎就是如何连进容器。我强烈推荐 VSCode 官方插件“Remote Development”,它下面包含 Remote-Containers,可以把整个 VSCode 从宿主机直接“搬”进容器。
具体操作:先启动 Apollo 的容器,然后用 VSCode 命令面板执行Remote-Containers: Attach to Running Container,选择那个 dev 容器,VSCode 会重新打开一个新窗口,左下角显示容器名。这时你看到的目录结构就是容器内的文件系统,点击 debug 组件时,VSCode 自动用容器内的 gdb 做调试,宿主机不需要做任何额外配置。
这个方案的前提是容器里已经安装了 gdb。Apollo 的 dev 镜像一般自带 gdb,如果没有,在容器内执行:
apt update && apt install -y gdb装完 VSCode 可能需要重载一下窗口。如果不想用 Remote-Containers,另一条路是让容器跑一个 sshd 服务,然后 VSCode 用 Remote-SSH 连容器,但这多绕一圈,我不建议。
4.2 容器内外文件路径映射:sourceMap 与 symbol 定位问题
进入 Remote-Containers 之后,VSCode 的工作区目录就是容器内的目录,例如/apollo。但如果你编译时用的路径是/apollo/...,而容器内挂载的路径也是/apollo,那源码映射天然是一样的,断点可以直接命中。
麻烦出在挂载目录不一致时。比如你拿宿主机/home/user/apollo挂载到容器/apollo,在宿主机上用 VSCode 调试容器内二进制时,gdb 读取编译记录里的源码路径是/apollo/modules/perception/...,但 VSCode 打开的本地文件是/home/user/apollo/modules/perception/...。这时断点命中后会显示“无法找到源文件”。
解决办法在 launch.json 里设置sourceFileMap。前面 2.2 的配置里我已经写出了关键字段:
"sourceFileMap": { "/apollo": "${workspaceFolder}" }如果你用 Remote-Containers,${workspaceFolder}就是容器内的/apollo,所以这个映射可以省略。如果是在宿主机调试容器,就必须写。还有一种根治手段:在 gdb 里设置替代路径:
-exec set substitute-path /apollo /home/user/apollo这个命令对 gdb 是全局的,设置后日志、堆栈中的路径都会被替换,比你改 launch.json 更底层一点。
4.3 用 gdbserver 在容器内跑,宿主机 VSCode 远程调试的配置
如果既不想用 Remote-Containers,又想保留宿主机 VSCode 的流畅调试体验,那就用 gdbserver。流程拆解几步,先启动一个可以被 debugging 的模块。进入容器:
gdbserver host.docker.internal:2345 /opt/apollo/bin/perception --flagfile=conf/perception.flags注意这里host.docker.internal是容器内访问宿主机的一个特殊域名,如果你用的 docker 网络是 bridge 模式,这个域名默认可用。宿主机侧配置 launch.json:
{ "name": "Remote GDB", "type": "cppdbg", "request": "launch", "program": "/home/user/apollo/bazel-bin/modules/perception/perception", "miDebuggerServerAddress": "localhost:2345", "sourceFileMap": { "/apollo": "/home/user/apollo" } }这里的program必须是宿主机上能够访问到的二进制,即使你并不用它启动程序,gdb 也需要从它读符号。miDebuggerServerAddress指向 gdbserver 监听的端口。启动调试后,VSCode 显示出调试控制台,表示已经通过 gdb 客户端连接到了容器内的 gdbserver。
一个常见坑:gdbserver 启动的程序如果通过--flagfile读取不到配置,程序会直接退出,VSCode 可能报“连接中断”。这时候先不加断点,让程序跑起来,再 VSCode attach,成功率更高。我一般把程序启动放在容器里,让它保持监听状态,然后用宿主机 VSCode 附加。
5. 避坑 / 常见问题 / 排查:调试 Apollo 时的五个高频翻车点
5.1 断点打上但没生效:符号表加载偏移问题
现象:红点旁边出现一个小空心圆,gdb 提示Pending breakpoint,怎么点都不命中。
原因:程序运行时某个共享库还没有被加载。Apollo 的很多模块会在启动后通过dlopen加载插件库,这些库的符号表不在主程序二进制里,主程序启动后 gdb 找不到目标文件就挂起断点。
解决:先让程序跑起来,然后用调试控制台执行:
-exec info sharedlibrary确认库被加载后,再用break <file>:<line>设置断点。或者更省事的是用rbreak正则断点,它会在每次动态库加载后重新尝试解析。
5.2 源码路径对不上:VSCode 打开的目录结构和编译目录不一致
现象:断点命中后,VSCode 下方弹出“无法找到源文件”,只能看到反汇编窗口。
原因:Apollo 在用 bazel 编译时,源码路径记录的是编译机上的绝对路径。如果你的工作区不在/apollo下,gdb 就找不到了。
解决:在 launch.json 里配置sourceFileMap,例如将/apollo映射到本地工作区。或者在调试控制台执行:
-exec set substitute-path /apollo /home/user/apollo注意 manifest 路径也可能有问题,set substitute-path能同时替换多个前缀路径,末尾不要加斜杠。
5.3 附加进程提示权限不足:ptrace_scope 的坑
现象:Failed to attach: Operation not permitted,VSCode 弹出权限错误。
原因:Linux 内核的/proc/sys/kernel/yama/ptrace_scope默认是 1,只允许父进程 tracer 附加子进程。Apollo 这种多进程架构下,VSCode 未必是目标进程的父进程,因此被内核拒绝。
解决:临时修改内核参数:
sudo sysctl kernel.yama.ptrace_scope=0容器场景下,启动容器时增加--cap-add=SYS_PTRACE或直接--privileged,这样才能在容器内附加其他进程。如果不想修改全局,也可以在容器内用prlimit --pid <PID> --pids=0调整,但前提是容器有权限。
5.4 launch.json 里 program 路径写成软链,调试器找不到文件
现象:启动调试时提示“program path does not exist”或者“unable to find executable”。
原因:Apollo 的 bazel 产物中bazel-bin是指向.cache的软链接,VSCode 的 cppdbg 调试器对软链的解析逻辑和 gdb 并不完全一致。有些版本会在解析软链后找不到真实文件。
解决:用readlink -f拿到绝对真实路径,然后配置到program。例如:
readlink -f /opt/apollo/bin/perception把这个输出填到 program 字段里,问题就消失了。
5.5 多线程断点乱跳:gdb 的调度锁定设置
现象:断点命中后,单步执行一会,停顿的位置突然跳到另一个线程的代码里,调试器上下文全乱了。
原因:Apollo 的 Cyber RT 本身就是多线程调度框架,每个模块有专门的线程池。gdb 默认在多线程环境下,单步时如果命中了其他线程的断点,就会自动切换线程。VSCode 的“所有线程停止”模式只能保证所有线程停住,不能保证单步时只走当前线程。
解决:在调试控制台执行:
-exec set scheduler-locking on设置之后,单步只针对当前线程,其他线程保持挂起。等调试完再恢复off。用这个命令还能避免在 CRT 内部切换线程导致找不到变量。
6. 让断点调试效率翻倍的三个习惯:条件断点、调用栈与变量监视
6.1 条件断点:给循环一百次才停的代码加表达式
Apollo 里处理点云数据时经常要过滤掉前 1000 帧无效数据。你总不能一帧帧手动点继续。给断点加上条件:
seq_num > 1000 && lidar_point_cloud->size() > 100设置位置在 VSCode 断点编辑区的条件栏。gdb 在每命中一次会计算表达式,如果是 false 就自动继续走。注意条件表达式的语法必须满足 gdb,不能用 C++ 的智能指针直接解引用,要写point_cloud.get()。
6.2 用调用栈定位死循环:bt 命令与 VSCode 的栈面板
调试中遇到程序卡死,先暂停进程。VSCode 左侧“调用与会话”面板里能看到当前线程的调用栈,但只有一条线程。想定位死循环,就得加一条所有线程的调用栈:
-exec thread apply all bt有一次我在调试 Apollo 的 planning 模块时发现卡死,执行这条命令后看到两个线程都在futex_wait上,基本可以断定是互斥锁死锁。调用栈列表里有std::lock_guard的析构函数,对照源码找到持锁顺序,问题就解决了。
6.3 变量监视的实时刷新:用 watch 与 VSCode 的监视窗口
VSCode 的监视窗口可以直接输入变量名,但局部变量会随着作用域销毁自动清空,有时你还想跟踪全局变量的变化,需要 gdb 的watch命令。
在调试控制台输入:
-exec watch counter之后 counter 的值一改变,gdb 都会停下来,指向写这个变量的代码。这在排查 Apollo 里那些被多个线程同时读写的共享内存字段时尤其好用。如果你需要跟踪指针指向的内容:
-exec watch *ptr注意 watch 很影响性能,慎用大数组。我一般先用 VSCode 监视窗口盯几个关键变量,只有确实需要知道“谁改了它”才上 watch。
从那以后,我每次编译 Apollo 的模块都会强迫自己先跑一遍file和nm,确认符号还在才敢进 VSCode;遇到多线程乱跳,不再怀疑人生,直接set scheduler-locking on。只要这些步骤养成习惯,Apollo 的断点调试其实比想象中的更有安全感。希望帮到你。
本文还有配套的精品资源,点击获取