程序停下来之后,最常做的事就是「看看现在变量是多少」。调试器在这方面比 printf 强太多:随时想看哪个就看哪个,结构体、容器一次全展开,可以按十六进制、二进制显示,可以算表达式,可以每走一步自动打印,甚至可以直接改掉变量的值、调用程序里的函数,看看「如果是这样会怎样」。

这是「C++ 调试实战」系列的第 3 篇。示例程序仍然是第 00 篇的 orders.cpp。前两篇讲了断点和单步,这一篇讲停下来之后怎么看数据。

本篇大部分操作都停在 line_total 的第 16 行 return total;(break orders.cpp:16,然后 run),第一次命中时处理的是 apple(3 件 × 2.5)。

一、print:打印变量和表达式

1.1 基本用法

1
2
3
4
(gdb) print o
$1 = (const Order &) @0xaaaaaaad3020: {name = "apple", quantity = 3, price = 2.5}
(gdb) print o.quantity * o.price
$2 = 7.5

print(缩写 p)后面可以跟任何合法的 C++ 表达式:变量、成员访问、算术、比较、取地址、解引用、类型转换都行。输出里的几个部分:

部分 含义
$1` | 值历史编号,后面可以用 `$1 引用这个结果
(const Order &) 类型:o 是一个 const Order& 引用
@0xaaaaaaad3020 引用所绑定对象的地址
{name = "apple", ...} 对象内容,std::string 自动显示成字符串

1.2 一次看全部参数和局部变量

1
2
3
4
(gdb) info args
o = @0xaaaaaaad3020: {name = "apple", quantity = 3, price = 2.5}
(gdb) info locals
total = 7.5

刚停下来、还不知道该看什么的时候,先 info args 和 info locals 扫一眼,非常高效。

1.3 LLDB:frame variable 与 p

LLDB 有两套查看变量的命令,这一点和 GDB 不同:

1
2
3
4
5
6
7
(lldb) frame variable
(const Order &) o = 0x00000001005f9740 (name = "apple", quantity = 3, price = 2.5)
(double) total = 7.5
(lldb) v o.name
(std::string) o.name = "apple"
(lldb) p o.quantity * o.price
(double) 7.5
命令 作用 特点
frame variable(缩写 v) 读取变量 不执行任何代码,直接按调试信息读内存,快且安全;只支持变量名、成员 .、->、下标 [] 等简单形式
p / expression(expr) 计算表达式 真正编译并在目标进程里执行一段代码,功能强,但可能失败(见第九节)

frame variable 不带参数时,等于 GDB 的 info args + info locals。日常看变量优先用 v,需要算表达式或调函数时再用 p。

二、格式化输出

2.1 GDB:print/格式

1
2
3
4
5
6
7
8
(gdb) print/x o.quantity
$1 = 0x3
(gdb) print/t o.quantity
$2 = 11
(gdb) print/c 65
$3 = 65 'A'
(gdb) print/x 255
$4 = 0xff
格式 含义
/x 十六进制
/d 有符号十进制
/u 无符号十进制
/t 二进制
/o 八进制
/c 字符
/f 浮点数
/a 地址(顺便显示它指向哪个符号)

看位标志(flags)、位掩码、网络字节序的时候,/x 和 /t 非常好用。

2.2 LLDB:-f

1
2
3
4
(lldb) v -f x o.quantity
(int) o.quantity = 0x00000003
(lldb) v -f b o.quantity
(int) o.quantity = 0b00000000000000000000000000000011

-f 的常用取值:x(hex)、d(decimal)、b(binary)、c(char)、f(float)。p 也支持同样的写法,例如 p/x o.quantity。

2.3 结构体换行显示

结构体字段多了挤在一行很难看,GDB 可以打开「漂亮打印」:

1
2
3
4
5
6
7
(gdb) set print pretty on
(gdb) print orders[1]
$6 = {
name = "banana",
quantity = 12,
price = 1
}

LLDB 默认就是多行显示结构体。

三、查看类型:ptype 与 whatis

1
2
3
4
5
6
7
8
(gdb) ptype o
type = const struct Order {
std::string name;
int quantity;
double price;
} &
(gdb) whatis total
type = double

whatis 只告诉你类型名,ptype 把类型展开,列出所有成员(对类还会列出成员函数)。碰到 auto 推导、模板实例化,想知道「这个变量到底是什么类型」时很有用。

LLDB 用 type lookup,或者 v -T 在输出里带上类型:

1
2
3
4
5
6
(lldb) type lookup Order
struct Order {
std::string name;
int quantity;
double price;
}

顺带一个有意思的对比:

1
2
(gdb) print sizeof(Order)
$7 = 48
1
2
(lldb) p sizeof(Order)
(unsigned long) 40

同一个结构体,Linux 上 48 字节、macOS 上 40 字节。原因是 GCC 的 libstdc++ 里 std::string 占 32 字节,而 Clang 的 libc++ 里只占 24 字节。

四、STL 容器

4.1 自动展开

用 up 回到 sum_orders 这一帧(第 04 篇细讲),打印整个 vector:

1
2
3
4
5
6
7
8
(gdb) print orders
$4 = std::vector of length 3, capacity 3 = {{name = "apple", quantity = 3, price = 2.5}, {name = "banana", quantity = 12, price = 1}, {name = "cherry", quantity = 5, price = 4}}
(gdb) print orders[1]
$5 = {name = "banana", quantity = 12, price = 1}
(gdb) print orders[1].name
$6 = "banana"
(gdb) print orders.size()
$7 = 3
1
2
3
4
5
6
7
8
(lldb) v orders
(std::vector<Order>) orders = size=3 {
[0] = (name = "apple", quantity = 3, price = 2.5)
[1] = (name = "banana", quantity = 12, price = 1)
[2] = (name = "cherry", quantity = 5, price = 4)
}
(lldb) v orders[1]
(Order) orders[1] = (name = "banana", quantity = 12, price = 1)

之所以能显示成 std::vector of length 3 而不是一堆内部指针,是因为调试器带了针对标准库的格式化器:GDB 用的是 libstdc++ 附带的 Python pretty printer,LLDB 内置了 libc++ / libstdc++ 的数据格式化器。map、set、unordered_map、optional、variant、shared_ptr 等常见类型都支持。

如果你想看容器的「真实内部结构」(比如调试自定义分配器),GDB 用 print/r orders(raw,不用 pretty printer),LLDB 用 v --raw orders。

4.2 @:把连续内存当数组看

GDB 的 @ 运算符表示「从这个元素开始,连续取 N 个」:

1
2
(gdb) print orders[0]@2
$1 = {{name = "apple", quantity = 3, price = 2.5}, {name = "banana", quantity = 12, price = 1}}

对于 C 风格数组或 new 出来的指针(int* p = new int[100];),print *p@10 可以看前 10 个元素,这是 @ 最常见的用途。LLDB 对应的是 parray 10 p。

五、值历史与便利变量

每次 print 的结果都会存进 $1`、`$2……,$ 表示上一个结果:

1
2
3
4
5
6
(gdb) print orders.size()
$1 = 3
(gdb) print $ * 2
$2 = 6
(gdb) print $1 + 10
$3 = 13

之前 finish 显示的 Value returned is $2 = 7.5,也是存进了值历史,可以接着用。

LLDB 里要用 expr 才会生成 $0`、`$1 这样的结果变量(较新的 LLDB 里 p 对简单变量走的是 frame variable 的路子,不产生结果变量):

1
2
3
4
(lldb) expr total * 2
(double) $0 = 76.599999999999994
(lldb) expr $0 + 1
(double) $1 = 77.599999999999994

GDB 还可以自己定义便利变量,比如 set $limit = 10`,然后在条件断点里用 `break line_total if o.quantity >= $limit。

六、display:每次停下都自动打印

单步调试时,如果每走一步都要手动 print sum,很快就会烦。display 让调试器每次停下都自动打印:

在 sum_orders 的第 22 行下断点,第一次停下时(此时 o 是 apple)加两个自动显示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) display sum
(gdb) display/x o.quantity
(gdb) info display
Auto-display expressions now in effect:
Num Enb Expression
1: y sum
2: y /x o.quantity
(gdb) next
21 for (const auto& o : orders) {
1: sum = 7.5
2: /x o.quantity = 0x3
(gdb) next

Breakpoint 1, sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:22
22 sum += line_total(o);
1: sum = 7.5
2: /x o.quantity = 0xc

每次停下(不管是单步还是命中断点),两个表达式都自动打印。第二次停下时 o 已经换成了 banana,数量 12 显示成十六进制 0xc。

交互式 GDB 在执行 display 的那一刻就会先打印一次当前值。

不需要了就 undisplay 1(删除 1 号),或者 disable display 1 暂时关掉。

LLDB 的 display 其实是用「停止钩子」(stop hook)实现的:

1
2
3
4
5
6
7
(lldb) display sum
Stop hook #1 added.
(lldb) next
(double) $2 = 100
Process 4506 stopped
...
(lldb) undisplay 1

每次进程停下,钩子就执行一次 expr sum。(这里的 sum 是 100 而不是 7.5,是因为这段 LLDB 会话里先做了下一节要讲的「修改变量」。)

七、修改变量:set var

调试器不仅能看,还能改。假设你怀疑「如果 apple 的小计是 100,后面的逻辑会不会出问题」,不用改代码重新编译,直接改:

1
2
3
4
5
6
7
(gdb) set var total = 100
(gdb) print total
$1 = 100
(gdb) finish
0x0000aaaaaaaa1a5c in sum_orders (orders=std::vector of length 3, capacity 3 = {...}) at orders.cpp:22
22 sum += line_total(o);
Value returned is $2 = 100

函数返回了 100 而不是 7.5,程序会带着这个「假数据」继续跑下去。删掉断点后 continue,程序输出的就是 total = 130.8(100 + 10.8 + 20)。

LLDB 直接用表达式赋值:

1
2
3
4
5
6
7
8
(lldb) expr total = 100
(double) $0 = 100
(lldb) p total
(double) 100
(lldb) finish
Process 4506 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = step out
Return value: (double) $1 = 100

常见用途:

  • 验证修复思路:把一个错误的值改成正确的,看后续逻辑是否就对了;
  • 强制走某个分支:把条件变量改掉,测试平时难以触发的错误处理代码;
  • 跳过耗时操作:把循环计数器直接改到接近结束。

GDB 里 print total = 100 也能改值,但推荐用 set var:set 后面直接跟变量名时,如果变量名恰好和 GDB 的某个设置项同名(比如 width),会被当成 GDB 设置。

八、调用程序里的函数

print 里可以直接调用被调试程序的函数:

1
2
(gdb) print line_total(orders[2])
$6 = 20

cherry:5 × 4.0 = 20,没打折。这等于在当前现场「插入」了一次函数调用,特别适合:

  • 测试某个函数对特定输入的返回值;
  • 调用你专门写的调试辅助函数,比如 dump_tree(root),在调试时打印复杂数据结构。

8.1 坑一:被调用的函数里有断点

如果被调函数里有断点,调用会在断点处停下,GDB 会放弃这次表达式求值:

1
2
3
4
5
6
7
8
(gdb) print line_total(orders[2])

Breakpoint 1, line_total (o=...) at orders.cpp:16
16 return total;
The program being debugged stopped while in a function called from GDB.
Evaluation of the expression containing the function
(line_total(Order const&)) will be abandoned.
When the function is done executing, GDB will silently stop.

此时你停在了一个「嵌套」的调用里。解决办法:调用前先 disable 掉相关断点。LLDB 默认在表达式求值时忽略断点,不会遇到这个问题:

1
2
(lldb) p line_total(o)
(double) 7.5

8.2 坑二:函数被内联了,根本不存在

这是 C++ 调试里最常见、也最让人困惑的错误:

1
2
(gdb) print *orders.data()@2
Cannot evaluate function -- may be inlined

orders.size()、orders[1] 都能用,orders.data() 却不行。原因是:std::vector 的成员函数都是模板,只有程序里真正用到的才会被实例化、编译出函数体。我们的代码从来没调用过 data(),程序里根本没有这个函数的机器码,调试器自然调不了。至于 size() 和 operator[] 为什么可以,要么是程序里恰好实例化了,要么是 GDB 用 libstdc++ 提供的 xmethod(见下表)顶替了真正的函数调用。

LLDB 在 macOS 上更明显。libc++ 的很多小函数被标记成了总是内联,即使 -O0 也不会生成独立的函数体:

1
2
3
4
5
6
7
8
(lldb) p orders[1]
error: Couldn't look up symbols:
$__lldb_func::0x1:0x3110:_ZNSt3__16vectorI5OrderNS_9allocatorIS1_EEEixB9nqe220106Em
Hint: The expression tried to call a function that is not present in the target, perhaps because it was optimized out by the compiler.
(lldb) p o.name.size()
error: Couldn't look up symbols:
$__lldb_func::0x1:0xd6a:_ZNKSt3__112basic_stringIcNS_11char_traitsIcEENS_9allocatorIcEEE4sizeB9nqe220106Ev
Hint: The expression tried to call a function that is not present in the target, perhaps because it was optimized out by the compiler.

p orders[1] 失败,是因为对 vector 用 [] 在表达式里其实是调用 operator[] 这个函数,而这个函数不存在。但同样的写法换成 v 就没问题:

1
2
3
4
(lldb) v orders[1]
(Order) orders[1] = (name = "banana", quantity = 12, price = 1)
(lldb) v orders[1].name
(std::string) orders[1].name = "banana"

因为 frame variable 不执行代码,它的 [1] 走的是格式化器提供的「合成子元素」,直接读内存。

遇到「函数不存在」的错误时,按这个顺序尝试:

办法 说明
LLDB 换用 v 容器下标、成员访问都能用 v 完成
直接看字段 不调 size(),看容器格式化器显示的 length / size=
GDB 的 xmethod 较新的 libstdc++ 为 vector::size()、operator[] 等提供了 Python 实现的「xmethod」,GDB 在函数不存在时会用它顶替
在代码里显式用一次 调试辅助代码里写一句 (void)v.data(); 强制实例化(不推荐常用)

九、看原始内存:x 与 memory read

有时需要绕过类型,直接看内存里的字节,比如排查越界写、字节序、结构体填充:

1
2
3
4
(gdb) print &orders[0].quantity
$2 = (int *) 0xaaaaaaad3040
(gdb) x/2dw &orders[0].quantity
0xaaaaaaad3040: 3 0

x/2dw 的意思是:从这个地址开始,显示 2 个单元,按 d(十进制)显示,每个单元 w(word,4 字节)。第一个是 quantity = 3;第二个 4 字节是 quantity 和 price 之间的填充(double 要 8 字节对齐),值没有意义。

x 参数 可选值
数量 任意正整数
格式 x 十六进制、d 十进制、u 无符号、c 字符、s 字符串、i 指令
单元大小 b 1 字节、h 2 字节、w 4 字节、g 8 字节

LLDB:

1
2
3
(lldb) memory read -s4 -fd -c2 &o.quantity
0x10063d758: 3
0x10063d75c: 1

-s4 单元大小 4 字节、-fd 十进制、-c2 两个单元。LLDB 也兼容 GDB 的写法:x/2dw &o.quantity。这里第二个值是 1,同样是填充字节里的残留值,和 GDB 那边的 0 一样没有意义——填充字节的内容是不确定的。

十、小结

需求 GDB LLDB
打印变量 / 表达式 print x / p a + b v x / p a + b
所有参数和局部变量 info args / info locals frame variable / v
十六进制 / 二进制 p/x x / p/t x v -f x x / v -f b x
查看类型 ptype x / whatis x type lookup T / v -T x
结构体换行显示 set print pretty on 默认如此
连续 N 个元素 p *ptr@N / p arr[0]@N parray N ptr
每步自动打印 display x / undisplay 1 display x / undisplay 1
修改变量 set var x = 1 expr x = 1
调用函数 p f(arg) p f(arg)
看内存 x/4xw &x memory read -s4 -fx -c4 &x

最后记住一条经验:LLDB 里 v 看变量,p 算表达式;遇到「函数不存在」「may be inlined」,别怀疑人生,是模板没实例化或被内联了。

C++ 调试实战系列第 3 篇完。下一篇:调用栈与栈帧——backtrace、frame、up / down,搞清楚「我是怎么走到这里的」。