
以前带新人上手 Linux 下的 C/C 开发十个人里至少有八个第一次用 GDB 调试时会问我同一个问题这黑窗口怎么不显示源码然后我一般会反问一句你编译的时候加-g了吗十有八九对面一脸茫然。这不是夸张。GDB 作为 Linux 生态里最老牌也最可靠的调试工具真正的门槛从来不在你背了多少命令而在你有没有把编译阶段、调试习惯、崩溃现场这三件事串成一条完整的链路。命令只需要查链路却需要练。这篇文章就把这些年我用 GDB 排查问题的完整思路、常用命令和踩过的坑整理出来适合刚接触 Linux 调试的新人也适合已经会打断点、但总在优化掉和符号缺失上卡壳的进阶用户。1. 先把底子打好-g 编不出来就别谈调试1.1 能跑和可调是两码事很多新手上来就问 GDB 怎么用这个问题其实问错方向了。在谈 GDB 命令之前先要搞清楚一个前提你手里的这个二进制到底带没带调试信息。举个例子。你在终端敲gcc main.c -o main编出来一个程序能跑输出也对。但把它丢进 GDB 想在某一行打断点、想看某个变量的值你会发现list的时候没有源码print变量的时候报No symbol x in current context。这不是 GDB 不好用是你的二进制里压根没有调试符号。调试符号就是一张地址到源码的映射表。它记录了哪一行 C 代码对应哪一段机器指令、哪个寄存器或哪个栈地址存的是哪个变量。没有这张表GDB 看到的就是一堆裸的地址、寄存器、汇编指令就像你拿着一张没有坐标的地图去爬山。所以调试的第一步根本不在 GDB 里面而在编译阶段。用 gcc/g 编译时一定要加-g选项。我常用的调试编译命令是gcc -g -Wall -Wextra -O0 -o myapp main.c util.c这里-g表示生成调试信息-O0表示不优化。这两个选项要组合起来用原因下面细说。1.2 优化等级与调试符号的博弈-g和优化等级的关系是很多从 Windows 转到 Linux 开发的人最容易忽略的点。在 Windows 上用 Visual Studio 调试时Debug 配置默认就是关闭优化、带完整调试符号。但 Linux 下一切靠命令行没人帮你默认配置。你如果偷懒直接gcc -O2 -g问题就来了-O2优化器会做常量折叠、函数内联、指令重排、变量复用寄存器很多你写的变量在优化后的机器码里根本不存在了或者生命周期被压缩到几条指令之间。我见过最典型的场景代码里打了一堆断点跑起来断点命中了但print某个局部变量时GDB 提示value has been optimized out。这不是变量没被赋值而是它在优化后的代码里被挪进了寄存器且这个寄存器在断点那一刻还得复用来放别的数据。所以在需要精细调试的开发阶段老老实实-O0。如果项目目标就是用 O2 跑那调试就变成一场不对称战争——办法也不是没有比如给编译器加-fno-inline、-fno-omit-frame-pointer这类子选项能保留更多调试线索但不保证所有变量都能看。最稳的还是单独维护一份 Debug 构建。关于调试信息本身可以这样选-g标准调试信息DWARF 格式日常调试够用-g3额外包含宏定义等信息代码里宏多的时候用-ggdb生成 GDB 专用格式包含更丰富的描述信息。另外说明一点加-g不改变程序行为只是往可执行文件里额外塞了一段调试信息。所以不用担心加了 -g 程序就变慢运行时性能基本没影响代价只是文件体积大一圈。1.3 一份可以直接抄的调试环境配置拿到一个新的 C/C 项目我第一步不是去看代码而是先把编译脚本里的 CFLAGS 改了。如果项目用 Makefile我会全局搜 CFLAGS把-O2换成-O0确认-g在。如果项目用 CMake在 cmake 命令行直接指定cmake -DCMAKE_BUILD_TYPEDebug ..Debug 构建类型在 CMake 里默认就是-g加关闭优化省心很多。还有一个小建议在 home 目录准备一个.gdbinit文件。GDB 每次启动都会自动读取它把常见的干扰项先关掉。我的.gdbinit长这样set pagination off set confirm off set print pretty on set print thread-events onpagination off是很多人会漏掉的一个设置——GDB 默认输出一屏会停下来等回车在日志很长或者需要脚本接管输出时非常烦人。confirm off让delete breakpoints这类操作不再反复追问。print pretty让结构体按字段换行打印读起来舒服得多。这一节总结成一句话调试工作从编译参数开始。参数不对后面全是白费功夫。2. 断点、单步、变量调试三板斧的正确用法2.1 断点的几种形态与打断点的姿势GDB 里最基础的命令是break简写b。但断点不是只有b main这一种实际排错中我经常这样打b file.c:42在某文件的某行打断点多文件项目里避免同名函数互相干扰b func_name在函数入口打断点最简单直观b *0x401000在绝对地址打断点适合没有符号表的情况比如调试第三方库rbreak sort_用正则表达式批量打断点比如我要看所有名字带 sort 的函数。打断点之后用info b查看所有断点状态。每一行会列出断点编号、类型、地址、所在文件和行号。这些信息在断点很多时特别重要我养成习惯每次批量打断点后先info b确认一遍编号后面delete 2、disable 1操作才不会删错。断点还有一个容易被忽略的分类普通断点、临时断点tbreak、硬件断点。普通断点命中一次后会继续存在临时断点命中一次就自动删除适合你只关心函数第一次被调用的情况。硬件断点一般由watch这类命令触发后面第四节细说。进入断点之后最常干的一件事就是看调用栈。bt命令打印当前断点处的完整调用链。不要小看它它是所有程序为什么走到这里类问题的最佳答案。你发现某函数被调用了但想确认是谁调了它bt一出从 main 到当前函数之间的每一层都清清楚楚。2.2 next、step、finish、until 到底怎么选进入断点后怎么逐步走完代码是新人最混乱的地方。我见过有人在 for 循环里用next按了十分钟把每一条语句都走了一遍。其实 GDB 的单步命令有明确分工next简写n单步执行遇到函数调用直接跳过整个函数。适合不关心被调函数内部逻辑时使用step简写s单步执行遇到函数调用会进入函数内部。适合排查被调函数内部的问题finish执行完当前函数并返回到调用处。你在某个函数里钻得太深、发现这个函数没问题想快点出去时用它until往前走直到达到指定行或当前循环结束。在循环里想快速走到某个特定语句时非常好用比如until 42就是直接跑到第 42 行。说一个实际例子。我曾经调试一个排序程序排序结果不对。我先在排序函数入口处打断点然后用s进入函数一行一行跟交换逻辑。走到交换语句时用print打印交换前后的两个值发现有一处把索引写反了。这种问题如果用next就会把整个排序函数当黑盒跳过根本定位不到。反过来的场景也有某个计算函数逻辑复杂但我很确定问题不在它。这时我会在调用点用next跳过或者进去之后立刻finish出来把精力集中在真正可疑的调用链上。2.3 print、x、display 把程序状态摊开看print命令简写p能力比表面看起来强得多。p variable打印变量值是最基本用法p *ptr打印指针指向的内容p array[0]打印数组元素。如果一次想看多个东西可以p x, p y, p z也可以直接用display命令让 GDB 每次停在断点时自动打印某些表达式。我在处理链表相关 bug 时有个习惯用display head、display tail把链表的头尾指针一直挂着每次单步停下都自动刷新不用反复敲p。当一个变量会随着代码推进不断变化而你又恰好需要盯着它看的时候display比手动print省心太多。对于指针和内存x命令examine更强大。x/10x addr表示从 addr 开始以十六进制格式打印 10 个存储单元x/10s addr表示把这段内存按字符串打印。排查缓冲区问题时x/64bx buffer这种命令几乎是标配它会以字节形式把 buffer 前 64 个字节完整铺开越界写入的值一眼就能看出来。还有一个我特别推荐给新手的命令ptype。它用来打印变量的类型定义。当你面对一个复杂的结构体指针记不清它有哪些字段时ptype mystruct直接把定义打出来省去翻头文件的功夫。常用的命令汇总如下建议刚上手的人把这张表存下来命令简写作用breakb打断点nextn下一步跳过函数steps下一步进入函数finish-执行到当前函数返回printp打印变量/表达式x-查看内存内容display-断点停下时自动打印ptype-打印变量类型定义backtracebt打印调用栈info-查看断点/线程/局部变量等信息3. 崩溃现场core dump 才是最可靠的线索3.1 先把手动开 core dump 的流程走通前面两节讲的都是程序还在跑我来慢慢看的场景。但真正的线上事故往往是程序在凌晨三点崩了日志只有一句 Segmentation fault然后进程退出。这时候重启没用因为问题还会复现唯一的线索就是 core dump 文件。core dump 是进程崩溃时系统把整个内存映像、寄存器、调用栈保存下来的快照文件。你可以把它理解为飞机上的黑匣子。但默认情况下很多 Linux 发行版是不生成 core 文件的原因很简单core 文件可能非常大动辄几个 GB服务器磁盘扛不住。在开发机上我习惯先把开关打开ulimit -c unlimited这条命令把 core 文件大小限制改成无限。注意ulimit是 shell 内建命令只对当前 shell 会话有效所以一般要写进.bashrc。生产服务器怎么配置是另一套话题但至少开发机、测试机上这个开关应该常开。另外还有一个关键参数决定 core 文件放哪儿/proc/sys/kernel/core_pattern。这个文件里存的是 core 文件的命名和路径模板。默认情况下很多系统会写成core或者带 pid 的格式我一般配置成sudo sysctl -w kernel.core_pattern/tmp/core_%e.%p这个配置会把 core 文件放到/tmp下文件名带程序名和进程号防止多个 core 文件互相覆盖。sysctl命令是即时的重启失效想持久化得写进/etc/sysctl.conf。配置完之后怎么验证最简单的方法是自己制造一次崩溃kill -s SIGSEGV pid或者干脆写一个访问空指针的小程序跑一遍。确认/tmp下生成了core_xxx.pid文件就说明链路通了。3.2 拿到 core 之后的第一步bt full有了 core 文件接下来的操作极其标准gdb ./myapp /tmp/core_myapp.12345进入 GDB 后不要犹豫直接敲bt。如果崩溃时的调用栈很深bt只显示函数名往往不够我一般直接敲bt full它会把每一帧的局部变量也打出来。这是判断崩溃原因的第一手资料。举个处理过的例子。某个服务在高峰时段崩溃core 里的bt full显示调用栈顶部是一个字符串拼接函数再往上是日志打印函数再往上才是业务处理。崩溃点落在strlen上报段错误。看到这个栈第一反应是某个字符串指针是空的或者指向了非法内存。于是我看bt full里该函数帧的参数果然发现日志模块传入了一个空指针。加一个判空检查之后问题消失。拿到栈之后还需要用frame命令切换栈帧。bt显示的每一帧都有编号0 是最内层崩溃点越往上越靠外层。frame N切到编号 N 那帧然后info locals查看该帧的局部变量。很多人崩溃后只看第 0 帧其实很多崩溃的根因在调用者那一侧变量值往往在中间某帧才能找到答案。还有一个实用命令组合up和down可以在相邻帧之间快速移动配合info args查看函数参数。遇到空指针崩溃我通常会把调用链上每一层的参数都过一遍哪个参数是空指针或者非法地址根因基本就浮现了。3.3 没有 core 文件时的实时救援打法有时候生产环境确实没法开 core dump但程序还在反复崩溃怎么办这种情况下我会用 GDB 直接附加到正在运行的进程上gdb -p PID这条命令把 GDB 挂到指定进程上挂上之后进程会被暂停。这时第一步是执行(gdb) thread apply all bt把进程里所有线程的调用栈全部打出来。多线程程序崩溃前通常不会给你正常退出机会你能看到的是它卡在某个状态或者即将崩溃前的样子每个线程的栈都很重要。如果是偶现崩溃还有一种方式在 GDB 里把程序跑起来让它在可疑代码附近停下来然后逐步观察变量变化。但这种方式的局限性也很明显问题太偶发时效率太低。我个人的经验是与其在实时进程上撞运气不如先把 core dump 开关打开比啥都管用。有个实用的附加技巧值得记一下如果程序会 fork 子进程GDB 默认只调试父进程。想在子进程里打断点需要提前设置(gdb) set follow-fork-mode child反过来如果程序是 daemon启动后马上 fork 然后父进程退出那就必须用set follow-fork-mode child配合set detach-on-fork off否则子进程一下子就跑飞了你根本追不上。4. 进阶实战条件断点、观察点与多线程定位4.1 条件断点与命令列表让 GDB 替你跑完十万次循环新手调试循环问题时习惯做法是打断点跑看变量continue跑再看变量循环。但如果这个循环要执行十万次你光按 continue 都要按到崩溃。条件断点就是干这个的。它只在条件满足时才停下来。语法很简单(gdb) break file.c:100 if i 5000这样只有当i等于 5000 时才会停在第 100 行。条件里可以写任何 C 表达式比如if strcmp(name, admin) 0或者if len 1024 flag。条件断点的价值不只是省按键时间更关键的是它能把你从反复看同一段代码的机械劳动里解放出来。我再分享一个用法如果断点已经打好了想补一个条件可以用condition命令单独设置比如condition 1 i 5000其中 1 是断点编号。这样不用删掉重打。还有一种更强力的组合断点命令列表。在断点命中时自动执行一组 GDB 命令。写法是(gdb) commands 2 silent print x continue end这段配置的意思是断点 2 命中时不打印命中消息silent自动打印x的值然后继续执行。这样你就拥有一个数据采集器——每次经过这个断点GDB 自动帮你记录关键变量的变化轨迹。对复现那些偶发问题尤其有用你不需要守在屏幕前GDB 自己会把证据链一条条录下来。4.2 watch 观察点谁改了变量一抓一个准条件断点是满足条件才停观察点watch则是这个变量一变就停。两者互补。(gdb) watch xwatch x会给变量x设置一个观察点当x的值被任何代码修改时GDB 立即停下来并告诉你是哪一行代码改的。rwatch监听变量被读取awatch监听变量被读写。这对排查变量莫名其妙被改了这种问题几乎是量身定做。说一个我印象深刻的例子。某个网络设备的配置模块配置文件里的一个字段偶尔会被改成垃圾值。代码审查看不出问题因为所有读写该字段的地方都检查过逻辑上都对。最后我用watch监听这个字段GDB 在一个完全没有引用该字段的函数里停了下来——原来是某个结构体越界写把隔壁的内存覆盖了刚好覆盖到这个字段。没有watch这种问题几乎无法定位。不过watch有个限制需要提它依赖硬件调试寄存器数量有限一般只有 4 个。如果一次watch太多变量GDB 会提示硬件观察点用尽。另外watch局部变量时要注意当变量走出作用域后观察点会自动失效需要在合适的位置重新设置。用完后info watchpoints可以查看所有观察点及时删除不用的。4.3 多线程调试的三种关键操作多线程程序的崩溃用单线程的思路去调经常会怀疑人生。因为崩溃可能发生在任何一个线程里而你单步调试时其他线程还在乱跑。所以调多线程问题的第一个原则是先把线程停下来。GDB 默认情况下程序在某线程停在断点时其他线程也会被暂停。这是好消息坏消息是 GDB 默认只会把控制权显示在当前线程上而你不知道当前线程是不是你关心的那个。所以进入断点后第一件事就是info threads看看当前进程里所有线程的编号和状态。切换线程用thread N。如果你想看所有线程的调用栈用thread apply all bt。这在我处理死锁问题时是必用命令所有线程都阻塞在pthread_mutex_lock上且互相持有对方需要的锁一看栈就能定位死锁的两个线程分别卡在哪一行。调试锁相关 bug 还有一个参数值得记set scheduler-locking on。设置之后单步调试当前线程时其他线程不会切换执行避免你在step的时候上下文跳到别的线程。用完之后记得set scheduler-locking off恢复正常。多线程还有一个细节有时打了断点但程序就是不停或者停的位置乱七八糟。这往往是断点打在主线程而你要调的是子线程。给指定线程单独打断点可以用(gdb) break file.c:line thread 3这个功能特别适合有 8 个 worker 线程但我只排查第 3 个的场景不用手动一次次切换线程。5. 实战心得优化掉、符号对不上、何时该换工具5.1 value has been optimized out 的真相每次调试会话开始前我都要检查一件事编译时的优化等级。前面说了O2 下value has been optimized out不是 GDB 的问题是优化器把变量消灭了。这个问题发生频率之高超出很多人的想象而且最容易出现在你想看核心数据的时候。另一个容易踩的坑是你改了源码重新 make但 Makefile 只重建了一部分文件剩下的还是旧代码编出来的二进制。GDB 加载时提示Source file is more recent than executable就是这种情况。出现这个提示时我的处理方式是make clean后重新编译宁可慢一点也要保证二进制和源码完全对应。给一个实用建议每次编译完顺手看一眼编译日志里有没有那个文件没被重新编译。我见过太多人调了半天最后发现崩溃原因在自己还没改到的那行代码上——不是代码没问题是二进制根本没更新。5.2 符号表对不上的处理流程有时候你拿到一个 core 文件bt打出来的调用栈全是? ?。这种情况十有八九是二进制文件是新的core 是旧版本崩的符号表对不上。你可以用file命令查看 core 文件的生成时间或者用info files查看 GDB 加载的符号信息跟崩溃时间是否匹配。还有一种情况是依赖的动态库版本不匹配。程序运行时加载的.so和开发时用的.so不一致GDB 加载的符号跟实际内存布局对不上也会导致调用栈解析失败。我遇到过一次本地用新版本库调试core 是线上用旧版本库崩的bt全部失真。后来用set solib-search-path指定旧的库目录才把栈还原出来。这里给一条保命建议每次发布前把对应的可执行文件和依赖库用sha256sum记录成一张清单。出问题时拿 core 和清单一比立刻知道该用哪份二进制去调省掉无数猜谜时间。这个习惯救过我很多次。5.3 GDB 的边界什么场景该换工具GDB 是 C/C 调试的主力但不是什么都能干硬着头皮用只会浪费时间。内存泄漏这种问题GDB 只能帮你看到泄漏点的调用栈但更直接的是valgrind --leak-checkfull它能把泄漏链完整画出来。性能问题GDB 的采样能力有限perf top、perf record才是正确工具。系统调用层面的行为追踪strace比 GDB 更轻量不需要打断点就能看到程序调用了哪些 syscall、传了什么参数。我自己的分工习惯是这样的GDB 负责程序内部状态strace负责程序与外界的交互valgrind负责内存安全与泄漏perf负责性能热点。四者各司其职组合使用才能覆盖大部分疑难杂症。另外也别忘了 GDB 自身的边界它不能修改正在运行的进程的代码。你改完代码想看到效果必须重新编译重启进程。对于那种线上不能重启只能走热修复的场景GDB 帮不上忙得靠别的机制解决。这不是 GDB 的缺陷而是调试器的天然边界——知道什么时候该换工具本身也是一种调试能力。最后说个我自己的习惯每次到一个新开发环境第一件事不是装各种软件而是敲三行命令——ulimit -c unlimited、把core_pattern指到 /tmp、确认gdb --version能跑起来。这三样准备好后续不管遇到多诡异的崩溃都不至于手足无措。GDB 这个工具几十年没变过模样但只要你把它用顺了它就是 Linux 排错桌上最趁手的那把扳手。