Debug
调试
调试就像在一部犯罪电影中当侦探,而你也是凶手。(Filepe Fortes,2013)
当一个程序出现问题时,调试就是找出问题原因的过程。有多种方法可以找到程序中的错误,最粗暴的一种是通过输出语句进行调试。如果你有一个概念,那就是产生错误的地方,你可以编辑你的代码,插入输入语句,重新编译,重新运行,看看输出是否给你建议。这样做有几个问题:
- 编辑/编译/运行循环是很耗时的。
- 特别是由于早期的代码部分引起的错误,需要你反复地编辑、编译和运行。
- 此外,你的程序产生的数据量可能太大,而导致无法有效显示和检查。
- 而且如果你的程序是并行的,你可能需要输出所有处理器的数据,这使得检查过程非常乏味。
由于这些原因,最好的调试方法是使用交互式调试器,能让你监测和控制运行中的程序。在本节中,我们将熟悉 gdb 和 ldb,它们分别是 GNU 和 clang 项目的开源调试器。其他调试器是专有的,通常与编译器配套出现。另一个区别是,gdb 是一个命令行的调试器;还有一些图形化的调试器,如 ddd( gdb 的前端)、DDT 和 TotalView(并行代码的调试器)。我们只讨论 gdb ,因为它包含了所有调试器所共有的基本概念。
在本教程中,你将用 gdb 和 valgrind 调试一些简单的程序,这些文件可以在资源库tutorials/debug_tutorial_files目录下找到。
调用调试器
有三种使用 gdb 的方法:用它来启动一个程序,把它添加到一个已经运行的程序上,或者用它来检查一个core dump,我们只考虑第一种情况。
启动调试器
gdb | lldb |
---|---|
$ gdb program (gdb) run | $ lldb program (lldb) run |
下面是一个例子,说明如何用没有参数的程序启动 gdb( Fortran 用户使用 hello.F)。
*tutorials/gdb/c/hello.c*
#include <stdlib.h>
#include <stdio.h>
int main() {
printf("hello world\n");
return 0;
}
%% cc -g -o hello hello.c
# regular invocation:
%% ./hello
hello world
# invocation from gdb:
%% gdb hello
GNU gdb 6.3.50-20050815 # ..... version info
Copyright 2004 Free Software Foundation, Inc. .... copyright info ....
(gdb) run
Starting program: /home/eijkhout/tutorials/gdb/hello
Reading symbols for shared libraries +. done
hello world
Program exited normally.
(gdb) quit
%%
重要提示:该程序是用调试标志 -g 编译的,这将导致符号表(即从机器地址到程序变量的转换)和其他调试信息会被包含在二进制文件中。这将使你的二进制文件比严格意义上需要空间的要大,但也会使它更慢,比如编译器将不执行某些优化等原因。
为了说明符号表的存在执行以下命令
%% cc -g -o hello hello.c
%% gdb hello
GNU gdb 6.3.50-20050815 # ..... version info
(gdb) list
并与不使用 -g 标志的情况进行比较。
%% cc -o hello hello.c
%% gdb hello
GNU gdb 6.3.50-20050815 # ..... version info
(gdb) list
对于一个有命令行输入的程序,我们给运行命令的参数( Fortran 用户使用 say.F)。
*tutorials/gdb/c/say.c*
#include <stdlib.h>
#include <stdio.h>
int main(int argc,char **argv) {
int i;
for (i=0; i<atoi(argv[1]); i++)
printf("hello world\n");
return 0;
}
%% cc -o say -g say.c
%% ./say 2
hello world
hello world
%% gdb say
.... the usual messages ...
(gdb) run 2
Starting program: /home/eijkhout/tutorials/gdb/c/say 2
Reading symbols for shared libraries +. done
hello world
hello world
Program exited normally.
查找错误
C 程序
// square.c
int nmax,i;
float *squares,sum;
fscanf(stdin,"%d",nmax);
for (i=1; i<=nmax; i++) {
squares[i] = 1./(i*i); sum += squares[i];
}
printf("Sum: %e\n",sum);
%% cc -g -o square square.c
%% ./square
5000
Segmentation fault
存储器区块错误(其他信息也有可能)表明我们正在访问我们不允许访问的内存,导致了程序中止,调试器会很快地告诉我们这种情况发生在哪。
%% gdb square
(gdb) run
50000
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x000000000000eb4a
0x00007fff824295ca in __svfscanf_l ()
显示栈轨迹 |
---|
gdb | lldb |
---|---|
(gdb) where | (lldb) thread backtrace |
显然,错误发生在一个是我们写的 __svfscanf_l 函数中,而是一个来自系统的函数。使用backtrace(或 bt ,也有 where 或 w)命令,我们显示了调用栈,这通常可以让我们找出错误所在。
(gdb) backtrace
#0 0x00007fff824295ca in __svfscanf_l ()
#1 0x00007fff8244011b in fscanf ()
#2 0x0000000100000e89 in main (argc=1, argv=0x7fff5fbfc7c0) at square.c:7
我们仔细看一下第7行,发现我们需要将 nmax 改为 &nmax 。
我们的程序中仍有一个错误:
(gdb) run
50000
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_PROTECTION_FAILURE at address: 0x000000010000f000
0x0000000100000ebe in main (argc=2, argv=0x7fff5fbfc7a8) at square1.c:9
9 squares[i] = 1./(i*i); sum += squares[i];
我们进一步调查:
(gdb) print i
$1 = 11237
(gdb) print squares[i]
Cannot access memory at address 0x10000f000
(gdb) print squares
$2 = (float *) 0x0
然后很快就会发现忘记了分配 squares 。
如果我们有一个合法的数组,但我们访问它的边界外,也会发生内存错误。
// up.c
int nlocal = 100,i;
double s, *array = (double*) malloc(nlocal*sizeof(double));
for (i=0; i<nlocal; i++) {
double di = (double)i;
array[i] = 1/(di*di);
}
s = 0.;
for (i=nlocal-1; i>=0; i++) {
double di = (double)i;
s += array[i];
}
Program received signal EXC_BAD_ACCESS, Could not access memory.
Reason: KERN_INVALID_ADDRESS at address: 0x0000000100200000
0x0000000100000f43 in main (argc=1, argv=0x7fff5fbfe2c0) at up.c:15
15 s += array[i];
(gdb) print array
$1 = (double *) 0x100104d00
(gdb) print i
$2 = 128608
Fortran 程序
编译并运行以下程序:
*tutorials/gdb/f/square.F*
Program square
real squares(1)
integer i
do i=1,100
squares(i) = sqrt(1.*i)
sum = sum + squares(i)
end do
print *,"Sum:",sum
End
它会以 “非法指令”这样的信息中止,在 gdb 中运行该程序会很快告诉你问题所在:
(gdb) run
Starting program: tutorials/gdb//fsquare
Reading symbols for shared libraries ++++. done
Program received signal EXC_BAD_INSTRUCTION,
Illegal instruction/operand.
0x0000000100000da3 in square () at square.F:7
7 sum = sum + squares(i)
我们仔细看了一下代码,发现没有正确地分配 squares。
内存调试
编程中的许多问题都源于内存错误, 我们从最常见类型的排序描述开始,然后讨论如何使用帮助你检测它们的工具。
内存错误的类型
无效的指针
引用一个没有指向对象的指针会导致错误。如果你的指针指向到有效的内存中,你的计算将继续进行,但结果是不正确的。
然而,更有可能的是,你的程序会因为段违规或总线错误而终止。
越界错误
在已分配对象的范围之外寻址,不太可能使你的程序崩溃,而更有可能的是带来错误的结果。
越界的量足够大时将再次出现段违规,但是,少量的超出边界可能会读取无效的数据,或者破坏其他变量的数据,从而产生可能很长一段时间不被发现的错误结果。
内存泄漏
如果所分配的内存变得无法访问,我们称这种情况为内存泄漏。例子如下:
if (something) {
double *x = malloc(10*sizeofdouble);
// do something with x
}
在 if 条件语句后,分配的内存没有被释放,但指向的指针已经消失了。
尤其是最后一种类型,可能很难发现,内存泄漏只有在你的程序耗尽时才会出现。因为你的分配会失败,所以可以通过此来发现,经常检查 malloc 和 allocate 返回的结果是一个好习惯。
内存工具
Valgrind
在你的程序中插入下面的 squares 分配:
squares = (float *) malloc( nmax*sizeof(float) );
编译并运行你的程序,尽管程序不正确。输出很可能是正确的。你能看到问题吗?
要发现这种微妙的内存错误,你需要一个不同的工具:内存调试工具。流行的工具是 valgrind(因为开源);常见的商业工具是 purify 。
tutorials/gdb/c/square1.c
#include <stdlib.h>
#include <stdio.h>
int main(int argc,char **argv) {
int nmax,i;
float *squares,sum;
fscanf(stdin,"%d",&nmax);
squares = (float*) malloc(nmax*sizeof(float));
for (i=1; i<=nmax; i++) {
squares[i] = 1./(i*i);
sum += squares[i];
}
printf("Sum: %e\n",sum);
return 0;
}
用 cc -o square1 square1.c 编译这个程序,用 valgrind square1 运行它(你需要输入变量值)。你会得到很多输出,首先是:
%% valgrind square1
==53695== Memcheck, a memory error detector
==53695== Copyright (C) 2002-2010, and GNU GPL’d, by Julian Seward et al.
==53695== Using Valgrind-3.6.1 and LibVEX; rerun with -h for copyright info
==53695== Command: a.out
==53695==
10
==53695== Invalid write of size 4
==53695== at 0x100000EB0: main (square1.c:10)
==53695== Address 0x10027e148 is 0 bytes after a block of size 40 alloc’d
==53695== at 0x1000101EF: malloc (vg_replace_malloc.c:236)
==53695== by 0x100000E77: main (square1.c:8)
==53695==
==53695== Invalid read of size 4
==53695== at 0x100000EC1: main (square1.c:11)
==53695== Address 0x10027e148 is 0 bytes after a block of size 40 alloc’d
==53695== at 0x1000101EF: malloc (vg_replace_malloc.c:236)
==53695== by 0x100000E77: main (square1.c:8)
Valgrind 尽管提供的信息丰富,但信息晦涩难懂,因为它是在裸露的内存上工作,而不是变量。因此,这些报错信息需要一些解释。第10行中,在分配了40字节的块之后,立即写入了一个4字节的对象。换句话说:这段代码是在分配的数组的边界外写入的。你能知道代码中的问题是什么吗?
注意,Valgrind 也会在程序运行结束时报告有多少内存仍在使用,也就是没有适当释放的内存。
如果你修复了数组的出界问题,并重新编译并运行程序,Valgrind 仍然会报错:
==53785== Conditional jump or move depends on uninitialised value(s)
==53785== at 0x10006FC68: __dtoa (in /usr/lib/libSystem.B.dylib)
==53785== by 0x10003199F: __vfprintf (in /usr/lib/libSystem.B.dylib)
==53785== by 0x1000738AA: vfprintf_l (in /usr/lib/libSystem.B.dylib)
==53785== by 0x1000A1006: printf (in /usr/lib/libSystem.B.dylib)
==53785== by 0x100000EF3: main (in ./square2)
虽然没有给出行号,但提及 printf 表明了问题的所在。提到 “未初始化的值”又令人费解:唯一被输出的值是 sum ,而这并不是未被初始化:它已经被添加了几次。你知道为什么 Valgrind 说它未被初始化了吗?
Electric fence
electric fence库是许多提供具有调试支持的新 malloc 的工具之一,这些工具被连接起来,代替了标准 libc 的 malloc。
cc -o program program.c -L/location/of/efence -lefence
逐步调试程序
通常情况下,程序中的错误足够隐蔽,你需要详细调查程序的运行情况。编译以下程序:
tutorials/gdb/c/roots.c
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
float root(int n)
{
float r;
r = sqrt(n);
return r;
}
int main() {
feenableexcept(FE_INVALID | FE_OVERFLOW);
int i;
float x=0;
for (i=100; i>-100; i--)
x += root(i+5);
printf("sum: %e\n",x);
return 0;
}
然后运行程序:
%% ./roots
sum: nan
像以前一样在 gdb 中启动它:
%% gdb roots
GNU gdb 6.3.50-20050815
Copyright 2004 Free Software Foundation, Inc.
....
但在你运行程序之前,你在 main 处设置一个断点。在主程序中,这告诉执行器停止执行,或者说是 “中断”。
(gdb) break main
Breakpoint 1 at 0x100000ea6: file root.c, line 14.
现在,程序将在 main 的第一个可执行语句处停止:
(gdb) run
Starting program: tutorials/gdb/c/roots
Reading symbols for shared libraries +. done
Breakpoint 1, main () at roots.c:14
14 float x=0;
大多数时候,你会在一个特定的行上设置断点:
在一行中设立断点 |
---|
gdb | clang |
---|---|
break foo.c:12 | breakpoint set -f |
如果执行在断点处停止,你可以做各种事情,如发出 step 命令:
Breakpoint 1, main () at roots.c:14
14 float x=0;
(gdb) step
15 for (i=100; i>-100; i--)
(gdb)
16 x += root(i);
(gdb)
(如果你只是按了回车键,先前发出的命令会被重复)。连续执行若干步骤按回车键,你注意到这个函数和循环是什么了吗?
从执行 step 切换到执行 next ,现在你注意到这个循环和这个函数是什么了吗?
设置另一个断点:break 17,然后执行 cont 。会发生什么?
在你在调用 sqrt 的那一行设置断点后,重新运行程序,当执行停止时在那执行where 和 list 。
观察变量值
在 gdb 中再次运行之前的程序:在实际调用 run 之前,在调用 sqrt 的那一行设置一个断点。当程序运行到第8行时,你可以执行 print n。执行 cont,程序在哪里停止?
如果你想修复一个变量,你可以做 set var=value 。改变变量 n 并确认新值的平方根被计算出来,你要执行哪些命令?
断点
如果在一个循环中出现问题,不断地输入 cont ,用 print 检查变量会很繁琐。相反,你可以在现有的断点上增加一个条件。首先,你可以使断点受到条件约束:通过
condition 1 if (n<0)
只有当 n<0 为真时,断点1才会被遵从。
你也可以有一个只由某些条件激活的断点。例如以下语句
break 8 if (n<0)
意味着在遇到条件 n<0 后,8号断点会(无条件地)激活。
另一种可能是使用 ignore 1 50,这样就可以不停地执行断点 1 在接下来的五十次。
删除现有的断点,用 n<0 的条件重新定义断点,然后重新运行你的程序。当程序中断时,找出哪个值是循环变量,你所使用的命令的顺序是什么?
你可以通过各种方式设置断点:
break foo.c 当达到某个文件的代码时停止。
break 123 在当前文件的某一行停止。
break foo 在子程序 foo 处停止。
或各种组合,如 break foo.c:123 。
最后,如果你设置了多断点,你可以用 info breakpoints 来找出它们。
你可以用 delete n 删除断点,n 是断点的编号。
如果你在不退出 gdb 的情况下用 run 重启你的程序,断点会一直有效。
如果你退出 gdb,断点将被删除,但你可以保存它们:save breakpoints \
。在下一次运行 gdb时使用 source \ 来读入它们。 在有 exceptions 的语言中,比如 C++ 中你可以设置一个 catchpoint:
| gdb | clang | | :—————-: | :————————: | | catch throw | break set -E C++ |
最后,你可以在断点处执行命令:
break 45
command
print x
cont
end
这说明在第45行要输出变量 x,并立即继续执行。
如果你想在同一个程序上重复运行 gdb 会话,你可能想保存和重新加载断点。可以用
save-breakpoint filename
source filename
并行调试
并行调试比顺序调试更难,因为我们遇到只由于进程间的交互而产生的错误,例如死锁;见2.6.3.6节。
考虑这段 MPI 示例代码:
MPI_Init(0,0);
// set comm, ntids, mytid
for (int it=0; ; it++) {
double randomnumber = ntids * ( rand() / (double)RAND_MAX );
printf("[%d] iteration %d, random %e\n",mytid,it,randomnumber);
if (randomnumber>mytid && randomnumber<mytid+1./(ntids+1))
MPI_Finalize();
}
MPI_Finalize();
每个进程都计算随机数,直到某个条件得到满足,然后退出。然而,考虑引入一个 barrier(或类似于barrier 的东西,如 reduction):
for (int it=0; ; it++) {
double randomnumber = ntids * ( rand() / (double)RAND_MAX );
printf("[%d] iteration %d, random %e\n",mytid,it,randomnumber);
if (randomnumber>mytid && randomnumber<mytid+1./(ntids+1))
MPI_Finalize();
MPI_Barrier(comm);
}
MPI_Finalize();
现在执行会挂起,这不是由于任何特定的进程造成的:每个进程都有一个代码路径从 init 到 finalize 且不会产生任何内存错误或其他运行时错误的代码路径。然而,一旦一个进程到达条件中的 finalize 调用时,它就会停止,而所有其他进程将在 barrier 等待。
图27.1 在这段代码停止的地方展示了 Allinea DDT 调试器的主要显示( http://www.allinea.com/product/ddt)。在源面板的上方,你看到有16个进程,并给出了进程1的状态。在底部显示中,你看到16个进程中15个进程在第19行调用 MPI_Barrier ,而一个进程在第18行。在右边的显示中,你可以看到一个列表的局部变量:进程1的特定值。一个基本的图表显示了处理器上的进程的值:ntids 的值是恒定的,mytid 的值是线性增加的,除了一个进程外,其他都是恒定的。
练习 27.1 编写并运行 ring_1a 程序。该程序没有终止,也不会崩溃。在调试器中,你可以中断执行,并看到所有进程都在执行一个接收语句。这可能是一个死锁的案例,诊断并修复这个错误。
练习 27.2 ring_1c 的作者对 MPI 的工作原理非常迷惑。运行该程序,虽然它的终止没有问题,但输出是错误的。设置一个断点在发送和接收语句中设置断点,以弄清发生了什么。
延伸阅读
教程:http://www.dirac.org/linux/gdb/ 参考手册:http://www.ofb.net/gnu/gdb/gdb_toc.html