概述
macOS 默认使用 LLDB 来进行 C/C++ 程序的调试, LLDB 能够逐行调试程序,使开发者能够了解程序的变量值以及堆栈是如何变化的,一旦学会之后使用起来也比 printf 更加方便和简单,赶紧学起来吧。
LLDB 实现原理
在此之前,请考虑如何实现一个能够监听其他程序(被监听者称为 Client)运行情况的程序(监听者称为 Server)。
第一种方式是 Server 拷贝 Client 的代码来模拟 Client 运行,并且在运行的过程中,Server 通过在模拟过程中使用额外的指令从而能够查看和修改 Client 的运行堆栈和数据信息,其中,Valgrind 就是这样实现的。这种方式的优点是无需预先编译 Client 程序,缺点是因为需要运行额外的指令所以 Server 的运行会比 Client 慢很多(Valgrind 大概会会原程序慢 20-50 倍)。
第二种方式是使用操作系统的 ptrace 系统调用,这也是 LLDB 的实现方式。ptrace 系统调用可以让 A 进程监听和控制 B 进程的内存和寄存器。ptrace 系统调用有以下几个主要功能:
- 捕获 exec 系统调用并阻止程序的运行。
- 查询 CPU 的寄存器来获取当前的指令,数据和栈地址。
- 监听 clone/fork 事件来判断是否创建新的线程。
- 读取或者修改 Client 内存变量。
- 也就是说利用 ptrace 系统调用,Client 运行的每一行代码的情况 Server 都能知道。
常用指令
这里我们先简单列出来 LLDB 的常见指令,接下来的例子会介绍如何使用(其中括号中的为指令的缩写,例如 break main 可以缩写为 b main):
1 | breakpoint set (b) - 设置断点,也就是程序暂停的地方 |
示例
C 标准库中的 strlen 函数的作用是找到字符串 s 的长度,例子如下:
1 |
|
如果你不熟悉 C/C++ 的话,可能不太理解 strlen
函数的实现方式,这时候就是 LLDB 大显身手的时候了,使用 LLDB 调试以下程序之前,有几个步骤:
- 把上面的例子保存为
test.c
- 在终端运行
gcc test.c -g -o test
(这里的 -g 参数保证 LLDB 显示的是源代码而不是汇编代码) - 终端运行
lldb test
,这是告诉 LLDB 要调试哪个程序,没有问题的话,终端会输出:/path $ lldb test (lldb) target create "test" Current executable set to 'test' (x86_64).
4, 运行程序: 这时候 LLDB 已经在监听 test 程序了,test 的一举一动都逃不过 LLDB 的法眼。最基础的命令是 run,这条指令会开始运行 test 程序。终端会输出:
1 | 1. Process 9782 launched: '/path/test' (x86_64) |
这里,第一行标示了进程的 ID,第二行是 test 程序的输出,也就是 str 字符串的长度。最后的是程序的返回值,在这里 0 则为正常结束。当然,像这样仅仅有一个输出和返回值对我们调试没有什么帮助。因为程序运行得太快一下子就结束了,我们还没有来得及理解这个程序。LLDB 对于 printf 的优点在于可以逐步调试,我们可以选择一行行地运行程序,然后输出我们需要的堆栈信息以及变量值。让我们重新开始,我们先使用 Control + C 退出 LLDB 重新运行 lldb test ,然后运行 break main,这句指令代表我们在 main 函数的开头打上断点,(break 11 也能得到相同的结果,这里 11 是 main 的行号)代表让 test 程序在运行到 main 函数的时候暂停,这时候再次运行 run, 程序就会在 12 行停止。
1 | (lldb) breakpoint set main |
那么 breakpoint 指令是怎么实现的呢?为什么可以让程序在特定的地方暂停呢?简单来说:
breakpoint set
指令 会在参数所在地写入一个无效的地址值,在例子中,则是 main 函数。- 因为地址无效,所以
test
程序运行出错,抛出异常,系统会传送 SIGTRAP 信号给 LLDB。 - LLDB 这时候可以查看需要的堆栈信息或者变量值。
- LLDB 把正确的下一条指令重新写入到 test 程序中。
箭头指向的 12 行是下一条要执行的指令,这时候 str 还没进行定义,使用 print 指令来验证。
1 | (lldb) print *str |
要运行 12 行 的代码,我们试试 next
指令:
1 | (lldb) next |
再次查看 str 的值:
1 | (lldb) p *str |
使用 expr 之后,str 的值已经变成 “Aello World” 了。下一行要运行的代码是 13 行,这个表达式包含了一个函数调用,当运行 13 行的时候,strlen 函数会被压到 test 程序的栈顶,如下图。
使用 step 进入函数内部(如果使用 next 的话我们会运行到 14 行,这时候 strlen 函数已经执行完毕了)。
1 | (lldb) **step** |
看到箭头指向的是 6 行,我们已经进入到 strlen 函数内部了。使用 backtrace 来显示当前的有效函数信息,可以看到我们当前 fram #0 也就是当前在 strlen。
1 | (lldb) backtrace |
从 6 行的代码我们可以看到程序一直在 for 循环中运行,每次运行都会判断 sc 是否已经到达 s 字符串的结尾,如果是则停止 for 循环,strlen 函数最后返回 sc 和 s 的距离。strlen 函数结束后,返回值被赋予到 main 函数的 length 中。继续运行 next 命令,箭头指向 14 行,
(lldb) n
Process 15186 stopped
- thread #1, queue = ‘com.apple.main-thread’, stop reason = step over
frame #0: 0x0000000100000f1f test`main at test.c:14:41
11 int main() {
12 char str[] = “Hello World”;
13 int length = strlen(str);
-> 14 printf(“The length of str is %d\n”, length);
15 return 0;
16 }
这时候 length 的值已经更新了,我们可以通过 frame variable 来验证:
(char [12]) str = “Aello World”
(int) length = 11
再次使用 next 命令,终端输出了 The length of str is 11,这也是我们想要的结果。
总结
LLDB 的基本用法已经介绍结束了,虽然 LLDB 的命令非常多,不过关键的就是 break, next, step, frame, 这几个,更多的使用例子可以参考官方文档:https://lldb.llvm.org/use/map.h