4、GDB调试实战:GDB基础使用、断点与单步调试、多线程调试、Core Dump分析

GDB,说白了就是嵌入式开发者的“照妖镜”。代码跑飞了、段错误了、逻辑不对了,你总不能全靠printf去猜吧?我刚开始做Tina Linux项目时,遇到一个诡异的死锁问题,加了二十多个打印都没定位到,最后用GDB单步跟了一遍,五分钟就找到了问题。嗯,从那以后,我再也不敢轻视GDB了。

这一章,咱们就直奔主题。我会把GDB最常用的三板斧教给你:基础操作、断点单步、多线程调试,还有最实用的Core Dump分析。你跟着走一遍,以后遇到问题至少知道从哪里下手。

本章核心脉络: GDB调试的本质是“让程序停下来,让你看清楚每一刻的状态”。断点是暂停键,单步是慢放键,Core Dump是事故回放键。
GDB调试实战知识体系 GDB调试器 基础使用 断点与单步 多线程调试 Core Dump分析 启动与附加进程 查看变量与内存 条件断点 单步步入/跳过 线程切换 锁与竞态分析 生成配置 回溯调用栈 调试的本质 断点 = 暂停程序 → 观察状态 → 单步推进 → 定位问题 Core Dump = 程序崩溃后的“黑匣子”,回放事故现场

4.1 GDB基础使用:启动、附加与基本命令

在Tina Linux环境下,GDB的使用场景主要有两种:一种是直接调试一个正在运行的进程,另一种是调试一个即将启动的程序。我个人更常用的是附加到进程的方式,因为很多问题是在程序跑起来之后才暴露的。

4.1.1 启动GDB的三种方式

方式 命令 适用场景
直接调试程序 gdb ./myapp 程序刚启动就出问题
附加到运行进程 gdb -p <PID> 程序运行中卡死或异常
调试Core Dump gdb ./myapp core 程序崩溃后分析现场

这里有个坑,我刚开始用的时候踩过:附加到进程时,目标程序必须是用-g编译的。否则你看到的全是汇编,根本没法跟源码对应。Tina Linux的编译选项里,记得加上 -g -O0,-O0是为了防止编译器优化导致代码跳来跳去。

小技巧: 在Tina的Makefile中,可以单独为调试版本设置CFLAGS:CFLAGS += -g -O0 -DDEBUG。发布版本再去掉-g,避免固件体积过大。

4.1.2 常用基础命令速查

你不需要记住GDB的所有命令,记住下面这几个就够了。我平时80%的调试场景,靠它们就能搞定。

  • run / r:启动程序,可以带参数,比如 r arg1 arg2
  • continue / c:继续运行,直到下一个断点
  • next / n:单步跳过(不进入函数内部)
  • step / s:单步步入(进入函数内部)
  • print / p:打印变量值,比如 p my_var
  • backtrace / bt:查看调用栈
  • list / l:查看源码上下文
  • quit / q:退出GDB

举个例子,假设你怀疑某个全局变量被意外修改了:

(gdb) p global_flag
$1 = 0
(gdb) watch global_flag
Hardware watchpoint 1: global_flag
(gdb) c
Continuing.
Hardware watchpoint 1: global_flag
Old value = 0
New value = 1
main() at test.c:45
45      if (global_flag) {

看到了吗?watch命令直接帮你抓到了是谁改了这个变量。我在项目中定位过一个内存越界问题,就是用watch找到的——一个野指针恰好踩到了关键标志位。

4.2 断点与单步调试:精准定位问题

断点不是随便打的。我见过很多新手一上来就在main函数打一个断点,然后一路next到底。这样效率太低了。好的做法是:先想清楚问题可能出在哪,再在可疑位置下断点。

4.2.1 断点的几种玩法

断点类型 命令示例 说明
行号断点 b test.c:42 在源文件第42行停下
函数断点 b my_function 进入函数时停下
条件断点 b test.c:42 if count > 5 满足条件才停下
临时断点 tb test.c:42 只停一次,自动删除

条件断点特别实用。比如你有一个循环执行1000次,只在第500次时出错。你总不能按500次next吧?直接 b loop.c:10 if i == 500,GDB会自动帮你跳过前499次。

注意: 在嵌入式平台上,硬件断点数量有限(通常4-8个)。如果你打了太多断点,GDB会提示“无法设置断点”。这时候可以用软件断点,或者减少断点数量。

4.2.2 单步调试的节奏感

单步调试不是机械地按n或s。我总结了一个“三步法”:

  1. 先看调用栈:用bt确认当前在哪个函数、谁调了它
  2. 再看关键变量:用p打印怀疑的变量,确认值是否符合预期
  3. 然后决定下一步:如果函数内部逻辑复杂,用s进去看;如果只是赋值操作,用n跳过

举个例子,你发现某个函数的返回值不对:

(gdb) b process_data
Breakpoint 1 at 0x1234: file app.c, line 80.
(gdb) c
Continuing.

Breakpoint 1, process_data (buf=0xbeff1234, len=1024) at app.c:80
80      int result = validate(buf, len);
(gdb) n
81      if (result < 0) {
(gdb) p result
$2 = -1
(gdb) s  # 进入validate函数看看为什么返回-1
validate (buf=0xbeff1234, len=1024) at app.c:120
120     if (buf[0] != HEADER_MAGIC) {

看到了吗?通过单步,你很快发现是数据包头部的魔数不对。这时候你就能顺着这个线索往前查——是谁传了错误的数据进来。

4.3 多线程调试:别让线程“打架”

多线程调试是GDB的进阶用法,也是很多人的噩梦。我记得有一次,一个Tina项目里两个线程同时操作一个链表,导致段错误。用GDB的多线程调试功能,我很快就锁定了问题。

4.3.1 线程相关命令

命令 作用
info threads 列出所有线程及其状态
thread <ID> 切换到指定线程
thread apply all bt 查看所有线程的调用栈
set scheduler-locking on 单步时只运行当前线程

这里重点说一下 scheduler-locking。默认情况下,你单步调试一个线程时,其他线程还在跑。这会导致你根本没法跟踪——你刚停住线程A,线程B已经把数据改了。设置成on之后,只有当前线程能执行,其他线程全部暂停。这样你才能看清楚每一步发生了什么。

实战场景: 怀疑两个线程同时写同一个全局变量。先用 thread apply all bt 看所有线程在干什么,然后切换到可疑线程,设置 scheduler-locking on,单步跟踪变量修改的路径。

4.3.2 死锁定位技巧

死锁的典型表现是程序卡住不动,CPU占用率很低。这时候用GDB附加到进程:

(gdb) thread apply all bt

Thread 2 (Thread 0xb6f00470):
#0  __lll_lock_wait () at .../lowlevellock.c:46
#1  _L_lock_854 () from /lib/libpthread.so.0
#2  pthread_mutex_lock (mutex=0x1234) at pthread_mutex_lock.c:78
#3  worker_thread (arg=0x0) at app.c:200

Thread 1 (Thread 0xb6f23470):
#0  __lll_lock_wait () at .../lowlevellock.c:46
#1  _L_lock_854 () from /lib/libpthread.so.0
#2  pthread_mutex_lock (mutex=0x5678) at pthread_mutex_lock.c:78
#3  main_thread (arg=0x0) at app.c:150

看到两个线程都在等锁,而且等的是不同的锁——这就是典型的死锁。接下来用 p mutex 查看锁的地址,再配合代码分析锁的获取顺序,就能找到问题。

4.4 Core Dump分析:事故现场回放

Core Dump是程序崩溃时系统保存的内存快照。说白了,就是事故现场的“黑匣子”。我最喜欢用Core Dump来排查段错误,因为它能直接告诉你程序死在了哪一行。

4.4.1 开启Core Dump

在Tina Linux上,默认可能没开Core Dump。你需要手动设置:

# 查看当前限制
ulimit -c

# 设置不限制大小
ulimit -c unlimited

# 设置Core文件路径(可选)
echo "/tmp/core-%e-%p-%t" > /proc/sys/kernel/core_pattern

这里 %e 是程序名,%p 是PID,%t 是时间戳。这样生成的Core文件不会重名,方便管理。

注意: 嵌入式设备的存储空间有限。Core文件可能很大(跟程序的内存占用一样大)。建议只在调试时开启,调试完就关掉。我曾经在一个产品上忘了关,结果用户跑着跑着存储满了……嗯,教训深刻。

4.4.2 分析Core Dump的步骤

拿到Core文件后,分析流程很固定:

  1. 加载Core文件:gdb ./myapp /tmp/core-myapp-1234-5678
  2. 查看崩溃位置:GDB会自动停在崩溃的那一行,或者用 bt 看调用栈
  3. 查看变量值:用 p 打印局部变量和参数
  4. 查看内存:用 x/10x &buf 查看内存内容

举个例子,一个典型的段错误分析:

Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x00012345 in process_packet (pkt=0x0) at network.c:85
85      memcpy(dst, pkt->data, pkt->len);

(gdb) p pkt
$1 = (struct packet *) 0x0
(gdb) bt
#0  process_packet (pkt=0x0) at network.c:85
#1  0x00012456 in handle_event (ev=0xbeff1234) at event.c:42
#2  0x00012567 in main_loop () at main.c:100

看到了吗?pkt 是空指针,导致 memcpy 访问了非法地址。调用栈显示是 handle_event 传进来的。接下来你只需要去 event.c:42 看看为什么传了空指针进来。

4.4.3 没有源码也能分析

有时候你拿到的Core文件是客户现场的,没有源码。这时候可以用 disassemble 看汇编,结合寄存器值来定位:

(gdb) info registers
r0: 0x00000000    # 这就是pkt的值,是0
r1: 0xbeff5678    # dst的地址
pc: 0x00012345    # 崩溃的指令地址

(gdb) disassemble $pc-16, $pc+16
Dump of assembler code from 0x12335 to 0x12355:
0x12344:  ldr r2, [r0, #4]    # 从r0+4取数据,r0是0,所以段错误
0x12348:  ldr r1, [r0, #8]    # 同上
0x1234c:  bl memcpy

从汇编可以确认,r0是0,导致取数据时访问了地址0。虽然没有源码,但你已经知道是哪个函数、哪个指令出了问题。配合符号表(如果有),还能反推出源码位置。

经验之谈: 发布固件时,保留一份带符号表的ELF文件,不要strip掉。Core Dump分析时用这个文件,就能看到完整的函数名和行号。我一般会在构建服务器上存档每个版本的符号文件,方便后续排查。

好了,GDB调试的实战内容就这些。从基础命令到断点单步,再到多线程和Core Dump,每一步都是我在项目中反复用过的。你把这些练熟了,Tina Linux上90%的软件问题都能自己搞定。