Perl 调试器
本文是关于 Perl5 源代码调试器的教程,并假设读者至少编写过一个或多个简单的 Perl 程序。最好在电脑前阅读,并跟随代码副本一起学习,代码副本可在 Linux Journal 的 FTP 站点获取(参见 资源)。我使用的 Perl 版本是 perl5.004_1,它带有 Perl 调试器级别 1。我注意到此版本与早期版本的调试器之间存在一些细微差别。如果此处讨论的某些内容对您不起作用,请考虑升级。
Perl 编程语言在万维网上作为通用网关接口 (CGI) 表单和交互式网页的后端,以及用于维护网站和 Unix 服务器的自动化脚本而被越来越多地使用。因此,越来越多的用户开始学习 Perl。
从概念上讲,调试器是一种工具,它允许程序员更大程度地控制程序的执行,而无需物理插入提供此控制的代码。调试器允许程序员逐步执行程序代码,必要时可以逐行执行。它允许查看程序变量的内容以及堆栈,堆栈基本上是为从程序的 main 部分到达当前执行点而调用的函数(在 Perl 术语中称为子例程)列表。
有许多不同的调试器。一些,例如 dbx 或 gdb,是独立的程序,可用于调试用 C、C++、Modula-2 或 FORTRAN 等语言编写的程序。(例如,gdb 可以处理 C、C++ 和 Modula-2,根据其手册页,在我系统上手册页日期为 1991 年,所以现在它可能涵盖 FORTRAN。)来自 Borland、Microsoft 和其他公司的编程环境可能在其窗口环境中内置了调试功能。
Perl 也已被移植到 Win32 系统,并且可以类似地调用。如果您的系统支持脚本的 #! 语法,您可以将其作为 Perl 脚本的第一行(假设您将 Perl 解释器保存在 /usr/bin 中)
#!/usr/bin/perl -d
此选项在 Win32 系统下不受支持(据我所知),但有一些方法可以模拟它。请参阅相应的文档。
通常,当使用用 Perl 编写的程序时,您正在使用 Perl 解释器调用该程序。Perl 编译器即将问世,但本文不会直接介绍它。(调试用于 Perl 编译器的代码最合乎逻辑的方法是使用标准调试器,直到代码“无错误”,然后再编译它。)
在正常情况下,Perl 解释器将读取 Perl 脚本,并进行一定量的编译,将您的 Perl 代码转换为一些高度优化的指令,然后进行解释。当使用调试器时,额外的 Perl 代码会在您的代码被交给解释器之前插入到您的代码中。此外,在当前版本中名为 perl5db.pl 的库文件在您的 Perl 脚本中是 必需的。最终的脚本被解释,从而使程序在调试环境中运行。
在 Perl 中编程时,您可能应该始终使用警告标志。使用它的方式与使用调试标志的方式相同,如下所示
perl -w
当您的程序出现奇怪的结果时,您绝对应该使用警告标志。警告标志会导致 Perl 发出关于您代码的警告。这些警告是关于非致命但可能导致问题的事情。您可以将这些警告视为对您的编码风格的批评。常见的警告是指示某个变量仅使用过一次(可能是拼写错误),或者找不到使用的包(可能是该包在您的系统上不可用或安装不正确)。
Perl 不会让您指定函数原型,并允许您在任何时候创建变量,因此您没有类型检查的优势,尽管,使用 Perl 5,您可以选择对子例程进行类型检查。
本教程涵盖了我发现最有用的调试器命令。perldebug 手册页包含完整的命令列表。
可以输入到调试器中的最重要的命令是 h,它会打印出帮助屏幕。这往往会滚出屏幕,因此输入 h h 以查看格式更好以适合您屏幕的帮助屏幕。或者,您可以输入 |h,这将把命令 h 的输出通过管道传输到分页器,例如 more 或 less。您可以通过将 PAGER 环境变量设置为您喜欢的任何分页器来定义要使用的分页器。我更喜欢使用 less。(您实际上可以通过在调试器提示符下输入以下内容从调试器内部执行此操作
$ENV{'PAGER'} = "/usr/bin/less"
在调试器提示符下。)这种管道机制不仅适用于 help 命令,因此如果您执行某些操作并且结果移出屏幕,请尝试在其前面加上管道。您可以通过键入 h command 来获得有关单个命令的帮助。
现在让我们看看使用调试器的实际示例。我们将从 列表 1 中简单的 Perl 代码片段开始,名为 p1.pl。请注意,我们正在使用 -d 选项对 perl 执行代码,从而调用调试器。在调试器下调用脚本后,我们将看到以下内容
Loading DB routines from perl5db.pl version 1 Emacs support available. Enter h or h h for help. main::(./p1.pl:3): if(0) { DB<1>
调试器已暂停 p1.pl 的正常执行,并正在等待命令。请注意,我们获得了一些关于我们在程序文本中位置的信息。字符串 main::(./p1.pl:3): 告诉我们,我们位于 Perl 代码的 main 部分,我们正在执行的程序是 ./p1.pl,并且我们位于代码的第三行。如果我们位于另一个 Perl 包的中间,则该包名称将在此处列出。我们还看到第三行是 if(0) {。当我们在某一行上看到代码时,我们尚未执行它;相反,它是代码中即将执行的行。下一行 DB<1> 是一个提示符,用于输入调试器的第一个命令。如果您输入一个命令并希望重复它,您可以输入 ! comnum,其中 comnum 是您希望重复的命令号。
我们可以通过键入 l 并按 enter 来查看更多周围的脚本。注意不要在此命令或任何其他命令之前放置空格。这样做会告诉调试器,以下内容不是命令。相反,调试器将尝试将代码作为正常的 Perl 代码执行,并将在被调试程序的当前上下文中对其进行评估。对于任何调试器无法识别为调试器命令的输入,调试器都会执行相同的操作。使用字符 ;(分号)结束命令是可选的。
输入 l(字母 l 代表 list)会导致以下行出现在屏幕上
3==> if(0) { 4: print "Can't get here!\n"; 5 } 6 7: while ($i < 10) { 8: $i++; 9 } 10 11: if($i >= 9) { 12: print "Hello, world!\n"; DB<1>
注意箭头 ==>。这表示当前代码行。在本例中,它是第 3 行,并且是 Perl 代码的第一个实际行。另请注意,所有实际包含可执行代码的行都用行号后的 :(冒号)标记。这很重要,因为稍后当我们进入断点和操作点时,我们将只能在这些行上设置它们。
再次输入 l 会产生以下输出
13 } 14 15: exit 0; DB<1>
不带任何参数的 l 会显示下一个 Perl 代码窗口。后续使用会显示下一个窗口和下一个窗口。有一个内部行指针,每次使用 l 时,该指针都会递增一个窗口。要后退一个窗口,请键入 -(连字符)并按 enter,然后再次按 l。
l 命令也有参数,用于处理根据行号指定要打印哪些行的各种方法。我们将在需要时使用其中一些。与 l 类似的是 w,它打印出程序文本的窗口。有关详细信息,请参阅 perldebug 手册页。
有两种执行代码的方法。我们知道当前行是第 3 行,并且是一个 if 语句。第一种方法 s 是逐语句单步执行代码。另一种方法是 n,代表 next,它也类似地单步执行代码;但是,如果当前语句是子例程调用(而不是内置函数或某种变量赋值),n 会将子例程视为内置函数,并会跳过子例程,就好像它是一个原子命令一样。相比之下,s 将进入子例程并单步执行子例程的每一行。对于在第一个子例程中遇到的任何子例程,它也会执行相同的操作。当我们知道子例程工作正常时,这可能会很烦人——因此有了 n 命令。对于这个简单的示例,我们没有子例程,n 的效果与 s 相同。输入 s 或 n 后,我们可以简单地按 enter,调试器将重新发出上一个 s 命令或上一个 n 命令。这对于快速浏览代码行很有用。按 s 会显示以下内容
main::(./p1.pl:7): while ($i < 10) {
请注意,我们已从第 3 行跳到第 7 行。输入 l 3+4。这将显示从第 3 行开始的四行。我们跳到第 7 行是因为第 3 行中的条件 if(0) 为假。因此,条件的 then 部分被忽略,而 else 部分被执行。
请注意,代码中有一个变量 $i。我们知道 while 循环的主体将一直执行,直到 $i 大于或等于 10。(输入 l 7+10 查看 while 循环的主体。)
那么 $i 现在有什么值呢?键入 p $i。打印命令是 p,没有参数;它将打印 magic Perl 变量 $_ 的内容。任何有效的 Perl 表达式都是 p 的有效参数。因为调试器无法识别为调试器命令的任何内容都会被评估为 Perl 代码,所以您也可以键入 print 而不是 p。不必担心已将标准输出重定向到屏幕以外的其他内容。调试器将确保您会看到一些输出。但是,键入 p 比键入 print 更快,正如任何优秀的程序员都知道的那样,懒惰是“程序员的优点”之一,另外两个是傲慢和不耐烦(Larry Wall,请参阅资源)。
键入 p $i 不会产生任何结果。不,我们没有做错任何事。$i 尚未设置为任何值,因此它获得默认值 nothing。键入 s(或仅按 enter)。再次尝试 p $i。它应该打印数字 1。再次按 enter 并再次键入 p $i。现在,我们可以继续这样做,但我们知道我们将在这个 while 循环中不断循环,直到条件返回 false,这在 $i 不再小于 10 之前不会发生。而且,正如我之前所说,不耐烦是程序员的另一个优点,所以我们会加快速度。输入 $i = 8,然后再次按 enter。再做一次,我们就摆脱了循环。
最后一个条件检查 $i 是否至少等于 9。因为它现在是,所以 if 语句的 then 部分将不会被执行。请注意,我们可以在执行最终 if 语句之前将 $i 设置回 2。结果将是在正常条件下(即,不使用调试器)永远不会发生的执行(假设计算机工作正常,并且内存中没有位被篡改)。
正如任何好的第一个程序应该做的那样,我们的第一个调试程序将 Hello, World! 打印到屏幕上。请注意,即使在调试器下,这种情况也会按预期发生。再次按 enter 将终止程序。
在 列表 2 中的代码是一段更复杂的代码,其中存在一个 bug。它应该递归地打印出当前目录和所有子目录中的每个常规文件。现在,它只打印当前目录中的文件,似乎没有深入到更深层的子目录中。
在具有一些子目录的目录中执行此程序,并在这些子目录中放置文件和更深层的子目录,以创建一个小而多样的层次结构。
此代码的输出(一旦 bug 被修复)从我运行它的目录中看起来像这样
./file1 ./dir1.0/file1 ./dir1.0/file2 ./dir1.0/file3 ./dir1.0/dir1.1/file1 ./dir1.0/dir1.1/file2 ./dir1.0/dir1.1/file3 ./dir2.0/file1 ./dir2.0/file2 ./dir2.0/file3 ./dir2.0/dir2.1/file1 ./dir2.0/dir2.1/file2 ./dir3.0/file1
列表代码命令 l 还有一种变体。它是通过键入 l sub 来列出子例程代码的能力,其中 sub 是子例程名称。
运行列表 2 中的代码返回
Loading DB routines from perl5db.pl version 1 Emacs support available. Enter h or h h for help. main::(./p2.pl:3): require 5.001; DB<1>
输入 l searchdir 允许我们查看 searchdir 的文本,这是该程序的核心。
22 sub searchdir { # takes directory as argument 23: my($dir) = @_; 24: my(@files, @subdirs); 25 26: opendir(DIR,$dir) or die "Can't open \" 27: $dir\" for reading: $!\n"; 28 29: while(defined($_ = readdir(DIR))) { 30: /^\./ and next; # if file begins with '.', skip 31 32 ### SUBTLE HINT ###正如您所看到的,我留下了一个微妙的提示。bug 是我在此处删除了一个重要的行。
如果我们要单步执行本应是递归的子例程中的每一行代码,那将花费一整天的时间。正如我之前提到的,列表 2 中的代码似乎只列出当前目录中的文件,并且忽略任何子目录中的文件。由于代码只打印当前初始目录中的文件,因此可能是递归调用不起作用。在调试器下调用列表 2 代码。
现在,设置一个断点。断点是一种告诉调试器我们希望程序正常执行,直到它到达代码中的特定点的方法。要指定调试器应停止的位置,我们插入一个断点。在 Perl 调试器中,有两种基本方法可以插入断点。第一种是按行号,语法为 b linenum。如果省略 linenum,则断点将插入到即将执行的下一行。但是,我们也可以通过子例程指定断点,方法是键入 b sub,其中 sub 是子例程名称。两种形式的断点都接受一个可选的第二个参数,即 Perl 条件。如果当执行流到达断点时,条件评估为 true,则调试器将在断点处停止;否则,它将继续。这提供了对执行的更大控制。
现在,我们将使用 b searchdir 在 searchdir 子例程处设置一个断点。设置断点后,我们将只执行直到我们到达子例程。为此,输入 c(代表 continue)。
查看列表 2 中的代码,我们可以看到对 searchdir 的第一次调用来自主代码。这似乎工作正常,否则将不会打印任何内容。再次按 c 继续执行对 searchdir 的下一次调用,这发生在 searchdir 例程中。
我们希望知道 $dir 变量中的内容,它表示将搜索文件和子目录的目录。具体来说,我们想知道每次循环代码时此变量的内容。我们可以通过设置操作来做到这一点。通过查看程序列表,我们看到到第 25 行,变量 $dir 已被赋值。因此,以这种方式在第 25 行设置一个操作
a 25 print "dir is $dir\n"
现在,每当第 25 行出现时,print 命令将被执行。请注意,对于 a 命令,行号是可选的,默认为要执行的下一行。
按 c 将执行代码,直到我们遇到断点,并执行沿途设置的操作点。在我们的示例中,连续按 c 将产生以下结果
main::(../p2.pl:3): require 5.001; DB<1> b searchdir DB<2> a 25 print "dir is $dir\n" DB<3> c main::searchdir(../p2.pl:23): my($dir) = @_; DB<3> c dir is . main::searchdir(../p2.pl:23): my($dir) = @_; DB<3> c dir is dir1.0 main::searchdir(../p2.pl:23): my($dir) = @_; DB<3> c dir is dir2.0 main::searchdir(../p2.pl:23): my($dir) = @_; DB<3> c dir is dir3.0 file1 file1 file1 file1 DB::fake::(/usr/lib/perl5/perl5db.pl:2043): 2043: "Debugged program terminated. Use `q' to quit or `R' to restart."; DB<3>
请注意,旧版本的调试器不会输出此处列出的最后一行,而是退出调试器。这个较新的版本很好,因为当程序完成时,它仍然让您拥有控制权,以便您可以重新启动程序。
似乎我们仍然没有进入任何子目录。输入 D 和 A 分别清除所有断点和操作,然后输入 R 重新启动。或者,在旧版本的调试器中,只需重新启动程序即可重新开始。
我们现在知道 searchdir 子例程没有为除第一级子目录以外的任何子目录调用。回顾程序的文本,请注意在第 44 行到第 46 行中,只有当 @subdirs 列表中有内容时,才会递归调用 searchdir 子例程。在第 42 行放置一个操作,该操作将通过输入以下内容来打印 $dir 和 @subdirs 变量
a 42 print "in $dir is @subdirs \n"
现在,在第 12 行设置一个断点以防止程序输出到我们的屏幕 (b 12),然后输入 c。这将告诉我们程序认为目录中存在的所有子目录。
main::(../p2.pl:3): require 5.001; DB<1> a 42 print "in $dir is @subdirs \n" DB<2> b 12 DB<3> c in . is dir1.0 dir2.0 dir3.0 in dir1.0 is in dir2.0 is in dir3.0 is main::(../p2.pl:12): foreach (@files) { DB<3>此程序看到“.”中有目录,但“.”中的任何子目录中都没有。由于我们在第 42 行打印 @subdirs 的值,因此我们知道 @subdirs 中没有元素。(请注意,在列出第 42 行时,行号后有一个字母“a”和一个冒号。这告诉我们这里有一个操作点。)因此,在第 37 行中,没有任何内容被分配给 @subdirs,但如果当前(如 $_ 中所持有的)文件是目录,则 应该 被分配。如果是,则应将其推入 @subdirs 列表。但这并没有发生。
我犯的一个错误(当然是故意的)是在第 38 行。没有 catch-all “else” 语句。我可能应该在此处放置一个错误语句。与其这样做,不如让我们放入另一个操作点。重新初始化程序,以便清除所有点并输入以下内容
a 34 if( ! -f $_ and ! -d $_ ) { print "in $dir: $_ is weird!\n" } b 12" c
这显示
main::(../p2.pl:3): require 5.001; DB<1> a 34 if( ! -f $_ and ! -d $_ ) { print "in $dir: $_ is weird!\n" } DB<2> b 12 DB<3> c in dir1.0: dir1.1 is weird! in dir1.0: dir2.1 is weird! in dir1.0: file2 is weird! in dir1.0: file3 is weird! in dir2.0: dir2.1 is weird! in dir2.0: dir1.1 is weird! in dir2.0: file2 is weird! in dir2.0: file3 is weird! main::(../p2.pl:12): foreach (@files) { DB<3>虽然程序可以读取(通过第 29 行的 readdir 调用)dir1.1 是 dir1.0 中的某种类型的文件,但 dir1.1 上的文件测试(-f 构造)表示它不是。
最好在出现问题的点(第 34 行)停止执行。我们可以使用我之前提到的条件断点来做到这一点。重新初始化或重新启动调试器,然后输入
b 34 ( ! -f $_ and ! -d $_ ) c p p $dir
您将获得如下所示的输出
main::(../p2.pl:3): require 5.001; DB<1> b 34 ( ! -f $_ and ! -d $_ ) DB<2> c main::searchdir(../p2.pl:34): if( -f $_) { # if its a file... DB<2> p dir1.1 DB<2> p $dir dir1.0 DB<3>第一行设置断点,下一个 c 执行程序,直到断点停止它。p 打印变量 $_ 的内容,最后一个命令 p $dir 打印 $dir。因此,dir1.1 是 dir1.0 中的一个文件,但文件测试 (-d 和 -f) 不承认它存在,因此 dir1.1 没有插入到 @subdirs 中(如果它是目录)或 @files 中(如果它是文件)。
现在我们回到了提示符,我们可以检查各种变量、子例程或任何其他 Perl 构造。为了避免您撞到显示器,从而保护您的头部和显示器,我将告诉您哪里出错了。
所有程序都有一个称为当前工作目录 (CWD) 的东西。默认情况下,CWD 是程序启动的目录。任何和所有文件访问(例如文件测试或文件和目录打开)都是从 CWD 引用进行的。我们的程序在任何时候都不会更改其 CWD。但是第 29 行的 readdir 调用返回的值只是相对于 readdir 正在读取的目录(在 $dir 中)的文件名。因此,当我们执行 readdir 时,$_ 被分配一个字符串,该字符串表示 $dir 中的目录内的文件(或目录)(这就是为什么它被称为子目录)。但是当运行 -f 和 -d 文件测试时,它们会在 CWD 的上下文中查找 $_。但它不在 CWD 中,而是在 $dir 表示的目录中。这个故事的寓意是我们应该使用 $dir/$_,而不仅仅是 $_。
###SUBTLE HINT###
应替换为
$_ = "$dir/$_"; # make all path names absolute总结一下。我们的问题是我们正在处理相对路径,而不是绝对路径(来自 CWD)。
将其放回我们的示例中,我们需要检查 dir1.0/dir1.1,而不是 dir1.1。为了检查这是否是我们想要的,我们可以放入另一个操作点。尝试键入
a 34 $_ = "$dir/$_" c
实际上,这暂时将纠正措施放入我们的代码中。操作点是要评估的行上的第一项。您现在应该看到程序执行的正确结果
DB<1> a 34 $_ = "$dir/$_" DB<2> c ./file1 ./dir1.0/file1 ./dir1.0/file2 ./dir1.0/file3 ./dir1.0/dir1.1/file1 ./dir1.0/dir1.1/file2 ./dir1.0/dir1.1/file3 ./dir2.0/file1 ./dir2.0/file2 ./dir2.0/file3 ./dir2.0/dir2.1/file1 ./dir2.0/dir2.1/file2 ./dir3.0/file1 DB::fake::(/usr/lib/perl5/perl5db.pl:2043): 2043: "Debugged program terminated. Use `q' to quit or `R' to restart."; DB<2>
现在我们已经调试了递归调用,让我们玩一下调用堆栈。给出命令 T 将显示当前调用堆栈。调用堆栈是在当前执行点和执行开始之间调用的子例程列表。换句话说,如果代码的主要部分执行子例程“a”,而子例程“a”又执行子例程“b”,而子例程“b”又调用“c”,那么在子例程“c”中间按下“T”将输出一个从“c”一直返回到“main”的列表。
启动程序并输入以下命令(如果您已修复我们在上一节中发现的 bug,请省略第二个命令)
b 34 ( $_ =~ /file2$/) a 34 $_ = "$dir/$_" c
这些命令设置一个断点,该断点仅在变量 $_ 的值以字符串 file2 结尾时才会停止执行。实际上,此代码将在程序中的任意点停止执行。按 T,您将得到这个
@ = main::searchdir('./dir1.0/file2') called from file '../p2.pl' line 45 @ = main::searchdir(.) called from file '../p2.pl' line 10
输入 c,然后再次输入 T
@ = main::searchdir('./dir1.0/dir1.1/file2') called from file `../p2.pl' line 45 @ = main::searchdir(undef) called from file '../p2.pl' line 45 @ = main::searchdir(.) called from file '../p2.pl' line 10
再做一次
@ = main::searchdir('./dir2.0/file2') called from file '../p2.pl' line 45 @ = main::searchdir(.) called from file '../p2.pl' line 10
如果您愿意,您可以继续,但我认为我们从我们采取的任意堆栈转储中获得了足够的数据。
我们在这里看到调用了哪些子例程、调试器对传递给子例程的参数的最佳猜测以及从哪个文件的哪一行调用了子例程。由于这些行以 @ = 开头,我们知道 searchdir 将返回一个列表。如果要返回标量值,我们会看到 $ =。对于哈希(也称为关联数组),我们将看到 % =。
我说“对传递的参数的最佳猜测”是因为在 Perl 中,子例程的参数被放入 @_ magic 列表中。但是,允许甚至鼓励在子例程的主体中操作 @_(或 $_)。当输入 T 时,堆栈跟踪被打印出来,并且 @_ 的当前值作为子例程的参数被打印出来。因此,当 @_ 更改时,跟踪不会反映实际作为参数传递给子例程的内容。
好吧,到现在您一定在想,“天哪,这个 Perl 调试器太棒了,有了它,我可以结束世界饥饿、学会弹钢琴并将我的生产力提高 300%!” 好吧,这是正确的态度。您现在正在展示程序员的第三个优点,傲慢。但是,有一些警告。
竞争条件是程序员的祸害。竞争条件是在某些特定情况下才会发生的 bug。这些情况通常涉及某些事件与其他事件相关联的时间。使用调试器来调试这些情况并非总是可行,因为使用调试器的行为可能会更改程序中事件的计时。这可能会导致在没有调试器的情况下出现症状,但在使用调试器时,症状可能会消失。bug 并没有消失,它只是没有被“触发”。
实际上没有任何现成的方法可以消除竞争条件。通常,需要对算法进行深入分析。有限状态图也可能有用,如果您有耐心的话。
当编写涉及多个进程的代码时(例如,如果您的代码使用“fork”系统调用或其等效项),使用调试器会变得非常困难。这是因为当 fork 发生时,您会留下两个(或更多)进程,所有进程都在调试器下运行。但由于调试器是交互式的,您必须与每个进程交互。结果是您必须单独处理每个进程,控制每个执行。所有进程都希望从控制终端读取调试命令,但一次只能有一个进程能够这样做。其他进程将被阻止,等待第一个进程完成。当第一个进程完成时,另一个进程将完成。顺便说一句,我们无法确定哪个进程将是第一个。这是上面提到的竞争条件的一个例子。
