Debug

调试

调试就像在一部犯罪电影中当侦探,而你也是凶手。(Filepe Fortes,2013)

当一个程序出现问题时,调试就是找出问题原因的过程。有多种方法可以找到程序中的错误,最粗暴的一种是通过输出语句进行调试。如果你有一个概念,那就是产生错误的地方,你可以编辑你的代码,插入输入语句,重新编译,重新运行,看看输出是否给你建议。这样做有几个问题:

  • 编辑/编译/运行循环是很耗时的。
  • 特别是由于早期的代码部分引起的错误,需要你反复地编辑、编译和运行。
  • 此外,你的程序产生的数据量可能太大,而导致无法有效显示和检查。
  • 而且如果你的程序是并行的,你可能需要输出所有处理器的数据,这使得检查过程非常乏味。

由于这些原因,最好的调试方法是使用交互式调试器,能让你监测和控制运行中的程序。在本节中,我们将熟悉 gdbldb,它们分别是 GNUclang 项目的开源调试器。其他调试器是专有的,通常与编译器配套出现。另一个区别是,gdb 是一个命令行的调试器;还有一些图形化的调试器,如 dddgdb 的前端)、DDTTotalView(并行代码的调试器)。我们只讨论 gdb ,因为它包含了所有调试器所共有的基本概念。

在本教程中,你将用 gdbvalgrind 调试一些简单的程序,这些文件可以在资源库tutorials/debug_tutorial_files目录下找到。

调用调试器

有三种使用 gdb 的方法:用它来启动一个程序,把它添加到一个已经运行的程序上,或者用它来检查一个core dump,我们只考虑第一种情况。

启动调试器

gdb lldb
$ gdb program (gdb) run $ lldb program (lldb) run

下面是一个例子,说明如何用没有参数的程序启动 gdbFortran 用户使用 hello.F)。

  1. *tutorials/gdb/c/hello.c*
  1. #include <stdlib.h>
  2. #include <stdio.h>
  3. int main() {
  4. printf("hello world\n");
  5. return 0;
  6. }
  7. %% cc -g -o hello hello.c
  8. # regular invocation:
  9. %% ./hello
  10. hello world
  11. # invocation from gdb:
  12. %% gdb hello
  13. GNU gdb 6.3.50-20050815 # ..... version info
  14. Copyright 2004 Free Software Foundation, Inc. .... copyright info ....
  15. (gdb) run
  16. Starting program: /home/eijkhout/tutorials/gdb/hello
  17. Reading symbols for shared libraries +. done
  18. hello world
  19. Program exited normally.
  20. (gdb) quit
  21. %%

重要提示:该程序是用调试标志 -g 编译的,这将导致符号表(即从机器地址到程序变量的转换)和其他调试信息会被包含在二进制文件中。这将使你的二进制文件比严格意义上需要空间的要大,但也会使它更慢,比如编译器将不执行某些优化等原因。

为了说明符号表的存在执行以下命令

  1. %% cc -g -o hello hello.c
  2. %% gdb hello
  3. GNU gdb 6.3.50-20050815 # ..... version info
  4. (gdb) list

并与不使用 -g 标志的情况进行比较。

  1. %% cc -o hello hello.c
  2. %% gdb hello
  3. GNU gdb 6.3.50-20050815 # ..... version info
  4. (gdb) list

对于一个有命令行输入的程序,我们给运行命令的参数( Fortran 用户使用 say.F)。

  1. *tutorials/gdb/c/say.c*
  1. #include <stdlib.h>
  2. #include <stdio.h>
  3. int main(int argc,char **argv) {
  4. int i;
  5. for (i=0; i<atoi(argv[1]); i++)
  6. printf("hello world\n");
  7. return 0;
  8. }
  9. %% cc -o say -g say.c
  10. %% ./say 2
  11. hello world
  12. hello world
  13. %% gdb say
  14. .... the usual messages ...
  15. (gdb) run 2
  16. Starting program: /home/eijkhout/tutorials/gdb/c/say 2
  17. Reading symbols for shared libraries +. done
  18. hello world
  19. hello world
  20. Program exited normally.

查找错误

C 程序

  1. // square.c
  2. int nmax,i;
  3. float *squares,sum;
  4. fscanf(stdin,"%d",nmax);
  5. for (i=1; i<=nmax; i++) {
  6. squares[i] = 1./(i*i); sum += squares[i];
  7. }
  8. printf("Sum: %e\n",sum);
  9. %% cc -g -o square square.c
  10. %% ./square
  11. 5000
  12. Segmentation fault

存储器区块错误(其他信息也有可能)表明我们正在访问我们不允许访问的内存,导致了程序中止,调试器会很快地告诉我们这种情况发生在哪。

  1. %% gdb square
  2. (gdb) run
  3. 50000
  4. Program received signal EXC_BAD_ACCESS, Could not access memory.
  5. Reason: KERN_INVALID_ADDRESS at address: 0x000000000000eb4a
  6. 0x00007fff824295ca in __svfscanf_l ()
显示栈轨迹
gdb lldb
(gdb) where (lldb) thread backtrace

显然,错误发生在一个是我们写的 __svfscanf_l 函数中,而是一个来自系统的函数。使用backtrace(或 bt ,也有 wherew)命令,我们显示了调用栈,这通常可以让我们找出错误所在。

  1. (gdb) backtrace
  2. #0 0x00007fff824295ca in __svfscanf_l ()
  3. #1 0x00007fff8244011b in fscanf ()
  4. #2 0x0000000100000e89 in main (argc=1, argv=0x7fff5fbfc7c0) at square.c:7

我们仔细看一下第7行,发现我们需要将 nmax 改为 &nmax

我们的程序中仍有一个错误:

  1. (gdb) run
  2. 50000
  3. Program received signal EXC_BAD_ACCESS, Could not access memory.
  4. Reason: KERN_PROTECTION_FAILURE at address: 0x000000010000f000
  5. 0x0000000100000ebe in main (argc=2, argv=0x7fff5fbfc7a8) at square1.c:9
  6. 9 squares[i] = 1./(i*i); sum += squares[i];

我们进一步调查:

  1. (gdb) print i
  2. $1 = 11237
  3. (gdb) print squares[i]
  4. Cannot access memory at address 0x10000f000
  5. (gdb) print squares
  6. $2 = (float *) 0x0

然后很快就会发现忘记了分配 squares 。

如果我们有一个合法的数组,但我们访问它的边界外,也会发生内存错误。

  1. // up.c
  2. int nlocal = 100,i;
  3. double s, *array = (double*) malloc(nlocal*sizeof(double));
  4. for (i=0; i<nlocal; i++) {
  5. double di = (double)i;
  6. array[i] = 1/(di*di);
  7. }
  8. s = 0.;
  9. for (i=nlocal-1; i>=0; i++) {
  10. double di = (double)i;
  11. s += array[i];
  12. }
  13. Program received signal EXC_BAD_ACCESS, Could not access memory.
  14. Reason: KERN_INVALID_ADDRESS at address: 0x0000000100200000
  15. 0x0000000100000f43 in main (argc=1, argv=0x7fff5fbfe2c0) at up.c:15
  16. 15 s += array[i];
  17. (gdb) print array
  18. $1 = (double *) 0x100104d00
  19. (gdb) print i
  20. $2 = 128608

Fortran 程序

编译并运行以下程序:

  1. *tutorials/gdb/f/square.F*
  1. Program square
  2. real squares(1)
  3. integer i
  4. do i=1,100
  5. squares(i) = sqrt(1.*i)
  6. sum = sum + squares(i)
  7. end do
  8. print *,"Sum:",sum
  9. End

它会以 “非法指令”这样的信息中止,在 gdb 中运行该程序会很快告诉你问题所在:

  1. (gdb) run
  2. Starting program: tutorials/gdb//fsquare
  3. Reading symbols for shared libraries ++++. done
  4. Program received signal EXC_BAD_INSTRUCTION,
  5. Illegal instruction/operand.
  6. 0x0000000100000da3 in square () at square.F:7
  7. 7 sum = sum + squares(i)

我们仔细看了一下代码,发现没有正确地分配 squares

内存调试

编程中的许多问题都源于内存错误, 我们从最常见类型的排序描述开始,然后讨论如何使用帮助你检测它们的工具。

内存错误的类型

无效的指针

引用一个没有指向对象的指针会导致错误。如果你的指针指向到有效的内存中,你的计算将继续进行,但结果是不正确的。

然而,更有可能的是,你的程序会因为段违规或总线错误而终止。

越界错误

在已分配对象的范围之外寻址,不太可能使你的程序崩溃,而更有可能的是带来错误的结果。

越界的量足够大时将再次出现段违规,但是,少量的超出边界可能会读取无效的数据,或者破坏其他变量的数据,从而产生可能很长一段时间不被发现的错误结果。

内存泄漏

如果所分配的内存变得无法访问,我们称这种情况为内存泄漏。例子如下:

  1. if (something) {
  2. double *x = malloc(10*sizeofdouble);
  3. // do something with x
  4. }

if 条件语句后,分配的内存没有被释放,但指向的指针已经消失了。

尤其是最后一种类型,可能很难发现,内存泄漏只有在你的程序耗尽时才会出现。因为你的分配会失败,所以可以通过此来发现,经常检查 mallocallocate 返回的结果是一个好习惯。

内存工具

Valgrind

在你的程序中插入下面的 squares 分配:

  1. squares = (float *) malloc( nmax*sizeof(float) );

编译并运行你的程序,尽管程序不正确。输出很可能是正确的。你能看到问题吗?

要发现这种微妙的内存错误,你需要一个不同的工具:内存调试工具。流行的工具是 valgrind(因为开源);常见的商业工具是 purify

  1. tutorials/gdb/c/square1.c
  1. #include <stdlib.h>
  2. #include <stdio.h>
  3. int main(int argc,char **argv) {
  4. int nmax,i;
  5. float *squares,sum;
  6. fscanf(stdin,"%d",&nmax);
  7. squares = (float*) malloc(nmax*sizeof(float));
  8. for (i=1; i<=nmax; i++) {
  9. squares[i] = 1./(i*i);
  10. sum += squares[i];
  11. }
  12. printf("Sum: %e\n",sum);
  13. return 0;
  14. }

cc -o square1 square1.c 编译这个程序,用 valgrind square1 运行它(你需要输入变量值)。你会得到很多输出,首先是:

  1. %% valgrind square1
  2. ==53695== Memcheck, a memory error detector
  3. ==53695== Copyright (C) 2002-2010, and GNU GPLd, by Julian Seward et al.
  4. ==53695== Using Valgrind-3.6.1 and LibVEX; rerun with -h for copyright info
  5. ==53695== Command: a.out
  6. ==53695==
  7. 10
  8. ==53695== Invalid write of size 4
  9. ==53695== at 0x100000EB0: main (square1.c:10)
  10. ==53695== Address 0x10027e148 is 0 bytes after a block of size 40 allocd
  11. ==53695== at 0x1000101EF: malloc (vg_replace_malloc.c:236)
  12. ==53695== by 0x100000E77: main (square1.c:8)
  13. ==53695==
  14. ==53695== Invalid read of size 4
  15. ==53695== at 0x100000EC1: main (square1.c:11)
  16. ==53695== Address 0x10027e148 is 0 bytes after a block of size 40 allocd
  17. ==53695== at 0x1000101EF: malloc (vg_replace_malloc.c:236)
  18. ==53695== by 0x100000E77: main (square1.c:8)

Valgrind 尽管提供的信息丰富,但信息晦涩难懂,因为它是在裸露的内存上工作,而不是变量。因此,这些报错信息需要一些解释。第10行中,在分配了40字节的块之后,立即写入了一个4字节的对象。换句话说:这段代码是在分配的数组的边界外写入的。你能知道代码中的问题是什么吗?

注意,Valgrind 也会在程序运行结束时报告有多少内存仍在使用,也就是没有适当释放的内存。

如果你修复了数组的出界问题,并重新编译并运行程序,Valgrind 仍然会报错:

  1. ==53785== Conditional jump or move depends on uninitialised value(s)
  2. ==53785== at 0x10006FC68: __dtoa (in /usr/lib/libSystem.B.dylib)
  3. ==53785== by 0x10003199F: __vfprintf (in /usr/lib/libSystem.B.dylib)
  4. ==53785== by 0x1000738AA: vfprintf_l (in /usr/lib/libSystem.B.dylib)
  5. ==53785== by 0x1000A1006: printf (in /usr/lib/libSystem.B.dylib)
  6. ==53785== by 0x100000EF3: main (in ./square2)

虽然没有给出行号,但提及 printf 表明了问题的所在。提到 “未初始化的值”又令人费解:唯一被输出的值是 sum ,而这并不是未被初始化:它已经被添加了几次。你知道为什么 Valgrind 说它未被初始化了吗?

Electric fence

electric fence库是许多提供具有调试支持的新 malloc 的工具之一,这些工具被连接起来,代替了标准 libcmalloc

  1. cc -o program program.c -L/location/of/efence -lefence

逐步调试程序

通常情况下,程序中的错误足够隐蔽,你需要详细调查程序的运行情况。编译以下程序:

  1. tutorials/gdb/c/roots.c
  1. #include <stdlib.h>
  2. #include <stdio.h>
  3. #include <math.h>
  4. float root(int n)
  5. {
  6. float r;
  7. r = sqrt(n);
  8. return r;
  9. }
  10. int main() {
  11. feenableexcept(FE_INVALID | FE_OVERFLOW);
  12. int i;
  13. float x=0;
  14. for (i=100; i>-100; i--)
  15. x += root(i+5);
  16. printf("sum: %e\n",x);
  17. return 0;
  18. }

然后运行程序:

  1. %% ./roots
  2. sum: nan

像以前一样在 gdb 中启动它:

  1. %% gdb roots
  2. GNU gdb 6.3.50-20050815
  3. Copyright 2004 Free Software Foundation, Inc.
  4. ....

但在你运行程序之前,你在 main 处设置一个断点。在主程序中,这告诉执行器停止执行,或者说是 “中断”。

  1. (gdb) break main
  2. Breakpoint 1 at 0x100000ea6: file root.c, line 14.

现在,程序将在 main 的第一个可执行语句处停止:

  1. (gdb) run
  2. Starting program: tutorials/gdb/c/roots
  3. Reading symbols for shared libraries +. done
  4. Breakpoint 1, main () at roots.c:14
  5. 14 float x=0;

大多数时候,你会在一个特定的行上设置断点:

在一行中设立断点
gdb clang
break foo.c:12 breakpoint set -f

如果执行在断点处停止,你可以做各种事情,如发出 step 命令:

  1. Breakpoint 1, main () at roots.c:14
  2. 14 float x=0;
  3. (gdb) step
  4. 15 for (i=100; i>-100; i--)
  5. (gdb)
  6. 16 x += root(i);
  7. (gdb)

(如果你只是按了回车键,先前发出的命令会被重复)。连续执行若干步骤按回车键,你注意到这个函数和循环是什么了吗?

从执行 step 切换到执行 next ,现在你注意到这个循环和这个函数是什么了吗?

设置另一个断点:break 17,然后执行 cont 。会发生什么?

在你在调用 sqrt 的那一行设置断点后,重新运行程序,当执行停止时在那执行wherelist

观察变量值

gdb 中再次运行之前的程序:在实际调用 run 之前,在调用 sqrt 的那一行设置一个断点。当程序运行到第8行时,你可以执行 print n。执行 cont,程序在哪里停止?

如果你想修复一个变量,你可以做 set var=value 。改变变量 n 并确认新值的平方根被计算出来,你要执行哪些命令?

断点

如果在一个循环中出现问题,不断地输入 cont ,用 print 检查变量会很繁琐。相反,你可以在现有的断点上增加一个条件。首先,你可以使断点受到条件约束:通过

  1. condition 1 if (n<0)

只有当 n<0 为真时,断点1才会被遵从。

你也可以有一个只由某些条件激活的断点。例如以下语句

  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++ |

最后,你可以在断点处执行命令:

  1. break 45
  2. command
  3. print x
  4. cont
  5. end

这说明在第45行要输出变量 x,并立即继续执行。

如果你想在同一个程序上重复运行 gdb 会话,你可能想保存和重新加载断点。可以用

  1. save-breakpoint filename
  2. source filename

并行调试

ddt2

并行调试比顺序调试更难,因为我们遇到只由于进程间的交互而产生的错误,例如死锁;见2.6.3.6节。

考虑这段 MPI 示例代码:

  1. MPI_Init(0,0);
  2. // set comm, ntids, mytid
  3. for (int it=0; ; it++) {
  4. double randomnumber = ntids * ( rand() / (double)RAND_MAX );
  5. printf("[%d] iteration %d, random %e\n",mytid,it,randomnumber);
  6. if (randomnumber>mytid && randomnumber<mytid+1./(ntids+1))
  7. MPI_Finalize();
  8. }
  9. MPI_Finalize();

每个进程都计算随机数,直到某个条件得到满足,然后退出。然而,考虑引入一个 barrier(或类似于barrier 的东西,如 reduction):

  1. for (int it=0; ; it++) {
  2. double randomnumber = ntids * ( rand() / (double)RAND_MAX );
  3. printf("[%d] iteration %d, random %e\n",mytid,it,randomnumber);
  4. if (randomnumber>mytid && randomnumber<mytid+1./(ntids+1))
  5. MPI_Finalize();
  6. MPI_Barrier(comm);
  7. }
  8. MPI_Finalize();

现在执行会挂起,这不是由于任何特定的进程造成的:每个进程都有一个代码路径从 initfinalize 且不会产生任何内存错误或其他运行时错误的代码路径。然而,一旦一个进程到达条件中的 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