作者:edelweiss 日期:2020年3月3日
参考书目:ISBN 978-7-121-14048-8 DEBUG HACKS 中文版
1. 热身准备
1 调试是什么
2 Debug hacks 的地图
3 调试的心得
2. 调试前的必知必会
4 获取进程的内核转储
获取内核转储 (core dump) 的最大好处是,它能保存问题发生时的状态。只要有问题发生时程序的可执行文件和内核转储,就可以知道进程当时的状态。比如,在不清楚 bug 复现方法的情况下,或是 bug 极其罕见,又或者 bug 只在特定机器上发生的情况等,只要获取内核转储,那么即使手头没有复现环境,也能够进行调试。
启用内核转储
大多数 Linux 发行版默认情况下关闭了内核转储功能。用 ulimit 命令可以查看当前的内核转储功能是否有效。
-c 选项 表示内核转储文件的大小限制。上例中限制为 0 ,表示内核转储无效。按照以下方式执行 ulimit 命令,即可开启内核转储。
hanhan@ubuntu:~/DEBUG$ ulimit -c
0
hanhan@ubuntu:~/DEBUG$ ulimit -c unlimited #这个命令的意思是不限制内核转储的空间大小
hanhan@ubuntu:~/DEBUG$ ulimit -c
unlimited
在设置成无限制之后,发生问题时进程的内存就可以全部转储到内核转储文件中。在调试大量消耗内存的进程时,可能会希望设置内核转储文件的上限,这是直接在参数中指定大小即可。例如,下面的命令设置上限为 1 GB。
$ ulimit -c 1073741824
样例程序
segfault.c
#include <stdio.h>
int main(void)
{
int *a = NULL;
*a = 0x1;
return 0;
}
使用 gcc -g 编译并加入调试信息。
dongyu@During:~/DEBUG$ gcc -g segfault.c
在开启内核转储之后,执行上面的程序,确认能否生成内核转储。
dongyu@During:~/DEBUG$ ./a.out
Segmentation fault (core dumped)
dongyu@During:~/DEBUG$ ls
a.out core segfault.c
当前目录下已经生成了 core 文件,要用 GDB 调试生成的内核转储文件,应当使用以下方式启动 GDB。
dongyu@During:~/DEBUG$ gdb -c core ./a.out
...
[New LWP 19862]
Core was generated by `./a.out'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0 0x000056464918d60a in main () at segfault.c:6
6 *a = 0x1;
(gdb)
segfault.c 的第六行收到了 SIGSEGV 信号。用 GDB list 命令可以查看附近的源代码。
(gdb) l 5
1 #include <stdio.h>
2
3 int main(void)
4 {
5 int *a = NULL;
6 *a = 0x1;
7 return 0;
8 }
(gdb)
由于指针变量的值为 NULL ,所以在解引用空指针的时候收到了信号。
这是个最简单的例子,但调试复杂程序时,从内核转储入手十分有效。程序越复杂,就越难判断接收信号的时候程序作了什么操作。此外,如果 bug 很难复现,那么单靠跟踪源代码会很难确定原因。在这些情况下,内核转储能将问题发生时的状态原原本本地保存下来,有助于确定 bug 原因。
在专用目录中生成内核转储
在使用大型文件系统时,会希望将内核转储放在固定的位置。默认情况下会在当前目录中生成,但可能难以弄清文件到低在哪里生成。此外,大量生成的内核转储文件可能会给系统的磁盘空间造成压力。这种情况下可以准备内核转储专用分区,并在该分区中设置内核转储文件,这样就方便很多。转储保存位置的完整路径可以通过 sysctl 变量 kernel.core_pattern 设置。假设在 /etc/sysctl.conf 中这样设置。
/etc/sysctl.conf
1 # DEBUG HACKS
2 kernel.core_pattern = /var/core/%t-%e-%p-%c.core
3 kernel.core_uses_pid = 0
dongyu@During:~/DEBUG$ sudo sysctl -p
在该状态下 sudo 执行刚才的 a.out 程序,就会在 /var/core下生成内核转储文件。
dongyu@During:~/DEBUG$ sudo ./a.out
Segmentation fault
dongyu@During:~/DEBUG$ ls /var/core/
1583486733-a.out-20519-18446744073709551615.core
dongyu@During:~/DEBUG$ sudo ./a.out
Segmentation fault
dongyu@During:~/DEBUG$ ls
a.out segfault.c
dongyu@During:~/DEBUG$ ls /var/core/
1583486733-a.out-20519-18446744073709551615.core
dongyu@During:~/DEBUG$
kernel.core_pattern 中可以设置的格式符
格式符 | 说明 |
---|---|
%% | %本身 |
%p | 被转储的进程 ID (PID) |
%u | 被转储的进程的真实用户 ID (read UID) |
%g | 被转储进程的真实组 ID (real GID) |
%s | 引发转储的信号编号 |
%t | 转储时刻 (从 1970年 1 月 1 日 0:00 开始的秒数) |
%h | 主机名 (同 uname (2) 返回的 nodename) |
%e | 可执行文件名 |
%c | 转储文件的大小上限 (内核版本 2.6.24 以后可以使用) |
上例中设置了 kernel.core_uses_pid=0, 是因为我们改变了文件中 PID 的位置。如果设置该值为 1,文件名末尾就会添加.PID。
使用用户编写的程序自动压缩内核转储文件
启动整个系统的内核转储功能
利用内核转储掩码排除共享内存
5 GDB 的基本使用方法一
GDB 的基本使用流程
(1) 带着调试选项编译、构建调试对象
(2) 启动调试器 (GDB)
(2-1) 设置断点
(2-2)显示栈帧
(2-3)显示值
(2-4)继续执行
准备
uname.c
1 #include <stdio.h>
2 #include <stdlib.h>
3
4
5 int main(int argc, char* argv[])
6 {
7 int a = 10;
8 int b = 20;
9 int c = a + b;
10
11 printf("c = %d\n", c);
12
13 for (int i = 1; i < argc; i++ )
14 {
15 printf("%s\n", argv[i]);
16 }
17
18 return 0;
19 }
~
通过 gcc 的 -g 选项生成调试信息
$ gcc -wall -O2 -g 源文件
如果用 configure 脚本生成 makefile, 可以这样用。
$ ./configure CFLAGS="-Wall -02 -g"
本例实际操作
dongyu@During:~/DEBUG$ gcc -o uname -Wall -g -O2 uname.c
dongyu@During:~/DEBUG$ ls
uname uname.c
启动
dongyu@During:~/DEBUG$ gdb uname
...
(gdb)
设置断点
可以在函数名和行号上设置断点。程序运行后,到达断点就会自动暂停运行。此时可以查看该时刻的变量值、显示栈帧、重新设置断点或重新运行等。断点命令 (break) 可以简写为 b 。
格式:
break 函数名
break 行号
break 文件名:行号
break 文件名:函数名
break + 偏移量
break *地址
(gdb) break main
Breakpoint 1 at 0x5a0: file uname.c, line 6.
(gdb) break 9
Breakpoint 2 at 0x5c1: file uname.c, line 9.
设置好的断点可以通过 info break 确认
(gdb) info break
Num Type Disp Enb Address What
1 breakpoint keep y 0x00000000000005a0 in main at uname.c:6
2 breakpoint keep y 0x00000000000005c1 in main at uname.c:9
(gdb)
运行
用 run 命令开始运行。不加参数只执行 run, 就会执行到设置了断点的位置暂停下来。可以简写为 run 。
在 run 命令后面加上可执行程序的参数
(gdb) run hello world #参数 1 hello 参数 2 world
经常用到的一个操作是在 main() 上设置断点,然后执行到 main()函数。start 命令能达到同样的效果。
显示栈帧
backtrace 命令可以在遇到断点而暂停时显示栈帧。该命令简写为 bt 。此外,backtrace 的别名还有 where 和 info stack (简写为 info s)。
格式:
backtrace:显示所有栈帧。
backtrace N:只显示开头 N 个栈帧。
backtrace full N :只显示最后 N 个 栈帧。
显示变量
print 命令可以显示变量。print 可以简写为 p。
(gdb) print argc
$1 = 3
(gdb) print argv
$2 = (char **) 0x7fffffffe578
(gdb) print argv
$3 = (char **) 0x7fffffffe578
(gdb) print argv[0]
$4 = 0x7fffffffe7bf "/home/dongyu/DEBUG/uname"
(gdb) print argv[1]
$5 = 0x7fffffffe7d8 "hello"
(gdb)
显示寄存器
info registers 可以显示寄存器,简写为 info reg。
在寄存器名之前添加 $,即可显示各个寄存器的内容。
格式:
p/格式 变量名
显示寄存器可使用的格式
格式 | 说明 |
---|---|
x | 显示为十六进制数 |
d | 显示为十进制数 |
u | 显示为无符号十进制数 |
o | 显示为八进制数 |
t | 显示为二进制数, t 的由来是 two |
a | 地址 |
c | 显示为字符 (ASCII) |
f | 浮点小数 |
s | 显示为字符串 |
i | 显示为机器语言 (仅在显示内存的 X 命令中可用) |
单步执行
单步执行的意思是根据源代码一行一行地执行。
执行源代码中一行的命令为 next (简写为 n)。执行时如果遇到函数调用,可能想执行到函数内部,此时可以使用 step (简写为 p ) 命令。
next 命令和 step 命令都是执行源代码中的一行。如果要逐条执行汇编指令,可以分别使用 nexti 和 stepi 命令。
nexti 命令不会进入函数内部执行,而 stepi 命令会。
继续运行
调试时,可以使用 continue (简写为c) 命令继续运行程序。程序会在遇到断点后再次暂停运行。如果没有遇到断点,就会一直运行到结束。
格式:
continue
continue 次数
指定次数可以忽略断点。例如,continue 5 则 5 次遇到断点不停止,第6次遇到断点是才暂停执行。
被调试的程序通常为以下几种情况之一:
- 可以正常结束。
- 由于某种原因异常结束 (发生内核转储、非法访问等)
- 无法结束 (死循环等)
- 被挂起 (停止响应、死锁等)
除了正常结束之外,其他情况都需要从头开始继续执行,以寻找问题的原因 (调试)。
监视点
大型软件或大量使用指针的程序中,很难弄清楚变量在什么地方被改变。要想找到变量在何处被改变,可以使用 watch 命令 (监视点, watchpoint)。
格式:
watch <表达式>
<表达式> 发生变化时暂停运行。
此处 <表达式> 的意思是常量或变量等。
格式:
awatch <表达式>
<表达式> 被访问、改变时暂停运行。
格式:
reatch <表达式>
<表达式> 被访问时暂停运行。
删除断点和监视点
用 delete (简写为d)命令删除断点和监视断点。
格式:
delete <编号>
删除 <编号> 指示的断点或监视点。
其他断点
硬件断点 (hbreak), 适用于 ROM 空间等无法修改的内存区域中的程序。在有些架构中无法使用。
临时断点 (tbreak)和临时硬件断点 (thbreak), 与断点 (硬件断点)相同,都会在运行到该处时暂停,不同之处就是临时断点 (临时硬件断点)会在此时被删除,所以在只需要停止一次时用起来很方便。
遗憾的是并没有临时监视点。
改变变量的值
格式:
set variable <变量>=<表达式>
该功能可以在运行时随意修改变量的值,因此无需修改源代码就能确认各种值的情况。
生成内核转储文件
使用 generate-core-file 可将调试中的进程生成内核转储文件。
有了内核转储文件和调试对象,以后就能查看生成转储文件时的运行历史 (寄存器值、内存值等)。
6 GDB 的基本使用方法二
attach 到进程
要调试守护进程 (daemon process )等已经启动的进程,或者调试陷入死循环而无法返回控制台的进程时,可以使用 attach 命令。
格式:
attach pid
执行这一命令就可以 attach 到进程 ID 为 pid的进程上。
条件断点
有一种断点只在特定条件下中断。
格式:
break 断点 if 条件
这条命令将测试给定的条件,如果为真则暂停运行。
删除断点触发条件
格式:
condition 断点编号
添加断点触发条件
格式:
condition 断点编号 条件
反复执行
格式:
igore 断点编号 次数
在编号指定的断点、监视点(watchpoint)或捕获点(catchpoint)忽略指定的次数。
continue 命令与 ignore 命令一样,也可以指定次数,达到指定次数前,执行到断点时不暂停,二者的意义是相同的。
删除断点和禁用断点
用 clear 命令删除已定义的断点。
只想临时禁用断点的话,可以使用disable 命令。将禁用的断点重新启用,则可使用 enable 命令。
断点命令
断点命令 (commands)可以定义在断点中断后自动执行的命令。
7 GDB 的基本使用方法三
值的历史
通过 print 命令显示过的值会记录在内部的值历史中。这些值可以在其他表达式中使用。
变量 | 说明 |
---|---|
$ | 值历史的最后一个值 |
$n | 值历史的第 n 个值 |
$$ | 值历史的倒数第 2 个值 |
$$n | 值历史的倒数第 n 个值 |
$_ | x 命令显示过的最后的地址 |
$__ | x 命令显示过的最后的地址的值 |
$_exitcode | 调试中的程序的返回代码 |
$bpnum | 最后设置的断点编号 |
变量
可以随意定义变量。变量以$开头,有英文字母和数字组成。
命令历史
可以将命令历史保存到文件中。保存命令历史后,就能在其他调试会话中重复利用这些命令(通过箭头查找以前的命令),十分方便。默认命令历史文件位于./.gdb_history。
初始化文件
Linux 环境下的初始化文件为.gdbinit。如果存在.gdbinit 文件,GDB 就会在启动之前将其作为命令文件运行。
命令定义
利用 define 命令可以自定义命令,还可以使用 document 命令给自定义命令添加说明。用 "help 命令名" 可以查看定义的命令。
8 intel 架构的基本知识
作为调试的基本知识,这里简单介绍一下 CPU 架构。
字节序
所谓 Endian,就是多字节数据在内存中的排列方式。
例如 0x12345678 这个数据,地位数据排在内存低地址,高位数据排在内存高地址,这就叫做小端。
0003 | 0002 | 0001 | 0000 |
---|---|---|---|
0x12 | 0x34 | 0x56 | 0x78 |
32 位环境中的寄存器
通用寄存器有 8 种,分别是 EAX、EBX、ECX、ESI、EDI、EBP、ESP,用于逻辑运算、数学运算、地址计算、内存指针等。
ESP 寄存器用于保存栈指针
某些命令使用特定的寄存器。例如,字符串将 EXC、ESI 和 EDI 寄存器作为操作数使用。
通用寄存器的主要用途
寄存器 | 用途 |
---|---|
EAX | 操作数的运算、结果 |
EBX | 指向 DS 端中数据的指针 (主要端寄存器的用途见表 2-8) |
ECX | 字符串操作或循环的计数器 |
EDX | 输入输出指针 |
ESI | 指向 DS 寄存器所指示的段中某个数据的指针,或者是字符串操作中字符串的复制源 (source) |
EDI | 指向 ES 寄存器所指示的端中某个数据的指针,或者是字符串操作中字符串的复制目的地 (destination) |
ESP | 栈指针 (SS 段) |
EBP | 指向栈上数据的指针 (SS 段) |
CS | 代码段 |
DS | 数据段 |
SS | 堆栈段 |
ES | 数据段 |
FS | 数据段 |
GS | 数据段 |
程序代码放在代码段中,数据放在数据段中,程序所用的栈放在堆栈段中。
但是,通用寄存器的用途并不限于上面所述,也可以用于一般用途,所以上表只能作为参考。寄存器的结构见下图。
EFLAGS 寄存器中包含状态标志 (status flag)、控制标志 (confrol flag)、系统标志 (system flag) 等。
EIP (Instruction Pointer)寄存器是 32 位指令指针
其他寄存器有控制寄存器 (CR0~CR4)、GDTR、IDTR、TR、LDTR、调试寄存器 ( DR0/DR1/DR2/DR3/DR6/DR7 )、内存类型范围寄存器 MTPP、MSR(Model Specific Register)寄存器、机器检查寄存器 (Machine Check Register)、性能监视计数器 (Performance Monitoring Counter)等。
64 位环境中的寄存器
地址
CPU 可以通过内存总线访问到的地址称为物理地址。
平坦模型 (flat model)中,内存可看做单一、平坦的连续地址空间,称为线性地址空间。Linux 采用这种内存模型。
分段式内存模型 (segment model) 中,将内存看做被称为“段”(segment)的独立地址空间的集合。通过段选择器和偏移量组成的逻辑地址来访问段内地址。首先用段选择器识别出要访问的段,然后通过偏移量找到该段的地址空间中的内存 (图 2-10)。32位模式下最多能指定 16383 个段,各段的最大大小为 2的32次方 字节。
64 位模式采用了平坦模型,因此可以使用 64 位线性地址。不能使用分段式内存模型。
数据类型
基本数据类型包括字节 (8 比特)、字(16 比特)、双字(32 比特)、四字(64比特)、和双四字(128比特)。
9 调试时必须的栈知识
栈 (strack) 是程序存放数据的内存区域之一,其特征是 LIFO (Last In First Out)式数据结构,即后放进去的数据先被取出。向栈中存储数据的操作称为 PUSH, 从栈中取出数据的操作称为 POP 。在保存动态分配的自动变量时需要使用栈。此外在函数调用时,栈还用于传递函数参数,以及用于保存返回地址和返回值。
示例程序:这个程序将命令行参数传递过来的数字作为终值,计算 0 到终值之前所有正数的总和。
#include <stdio.h>
#include <ctype.h>
#include <stdlib.h>
#define MAX (1UL << 20)
typedef unsigned long long u64;
typedef unsigned int u32;
u32 max_addend = MAX;
u64 sum_till_MAX(u32 n)
{
u64 sum;
n++;
sum = n;
if (n < max_addend)
sum += sum_till_MAX(n);
return sum;
}
int main(int argc, char** argv)
{
u64 sum = 0;
if ((argc == 2) && isdigit(*(argv[1])))
max_addend = strtoul(argv[1], NULL, 0);
if (max_addend > MAX || max_addend == 0){
fprintf(stderr, "Invalid number is specified\n");
return 1;
}
sum = sum_till_MAX(0);
printf("sum(0..%lu) = %llu\n", max_addend, sum);
return 0;
}