Authors: | Kenneth Lee |
---|---|
Version: | 0.1 |
Date: | 2022-08-30 |
Status: | Draft |
开始看这一章的时候,我预计你已经可以写一些程序了。那我也就开始预期你有调试的需 要了。
我们上一章说过,程序本质上是一个线程的顺序变化过程。那这每步的变化是什么样的, 我怎么知道呢?最好是走一步,停下来,让我看看内存里面的那些数变成多少,再走一步, 又停下来,再让我看看内存里面的数变成多少了。
这样我们就知道程序哪里写错了。
更高级一点的,我们也没有时间每次看一步,我们可以走到某个变量变成某个值的时候再 开始看,或者跑到某条指令上的时候再开始看。这些手段和行为,就称为“调试”。
其实调试用起来还是用Word那样的图形界面最好,这会比较容易看,但学习我觉得还是先 学最基本的命令行比较好,因为这样你会对自己在干什么有比较清楚的了解,有了这个基 础,到时学图形界面(叫IDE,Integrated Development Environment)就很简单了,基本 上看见就会用。
g++配套的调试器叫gdb,gnu debugger。如果你要深入学,我建议直接看它的手册:
但你也可以直接通过本文学习最基本的方法,其他东西等熟悉以后再学。
你写好一个程序,比如my_app,要运行它,你在命令行上敲::
./my_app
如果你要调试它,你需要敲::
gdb ./my_app
这样会用gdb来运行你的my_app,就可以用各种手段来一步步运行和检查它了。
但在这之前,我们要检查一下你的程序里面是不是带有调试信息。还记得第二章我们说编 译方法的时候的命令吗?::
g++ -Wall -g my_application.cc my_function.cc -o my_app
你需要保证你带了这个-g,如果没有-g,gdb其实也能够用,但因为g++编译的时候没有留 下变量放在哪里啦,函数叫什么名字啦,这些信息,有很多东西都查不到。我们一开始学 就不玩这些高难度的动作了,所以,要确认你的程序编译的时候是带着-g的。
然后就可以运行上面的命令了。
还有一个值得注意的编译参数是-O,这是Optimization的缩写,表示优化,一个正式的产 品通常会用-O2作为参数,表示进行2级优化,这样优化以后,编译器不会你写一句C,就 翻译几个汇编,再写一句C,又翻译几个汇编,而是只要保证你的功能是对的,帮你合并 几个一些步骤,重新排布执行的顺序等等。这样优化的程序跑得很快,但调试起来会让你 觉得莫名其妙的,因为实际的汇编就不是你看到的那些C代码那样执行的。所以你如果只 是要调试你的程序的逻辑,你最好加上-O0参数,表示禁止大部分优化,这样调试起来会 容易很多,但程序的效率就低很多了。当然,以现代计算机的速度,一般学习者的那些程 序,通常感受不到区别。
gdb用起来和其他命令行一样的(比如你原来学习的Python),进入gdb后,你就进入gdb的命 令行。这时你的my_app还没有运行。
要运行它,你可以用run命令::
(gdb) run
这会一路运行你的程序,直到结束。这和不调试没有区别。你可以再运行run,再跑一次。 这是最基本的用法。
quit命令用于退出::
(gdb) quit
现在我们开始看调试,假定我们调试之前的这个程序:
我们从头开始,让这个程序在main这个地方先停下来。我们用这个命令::
(gdb) break main
这是在main这个位置设置一个“断点”,这时你再运行run,gdb就会让这个程序停在这个位 置了::
(gdb) break main Breakpoint 1 at 0x118e: file my_application.cc, line 16. (gdb) run Starting program: /home/kenny/work/test/ccpp_jaingcheng/my_app Breakpoint 1, main () at my_application.cc:16 16 { (gdb)
每次碰到一个断点,gdb都会把你的程序停下来,你就可以用print命令去打印你那些变量 (现在你应该已经知道变量是什么了)的值了。这些有用的命令包括这样一些:
1. next:向下走一步,遇到函数把整个函数当作一步 1. step:向下走一步,遇到函数跳到函数里面去 2. cont:继续向下运行 3. print: 打印变量的值 4. set var: 修改变量的值 5. kill:停掉程序 6. list: 显示现在的程序的源代码
一般的调试,知道这几个就够了,反正你能停下程序,能看变量的值,可以继续程序,你 的程序的流程基本上就都知道了。
gdb的命令都自动匹配的,只要没有重复,只写一部分就够,比如前面的next,写n就够了, print,写p就行了。gdb还运行你通过回车直接重复上一条命令,所以,你用next向下走一 步,然后你还想再走一部,就不需要敲命令了,直接回车就行了。
如果想知道每个命令可以带什么参数,可以用help命令看,比如::
(gdb) help break
这会显示break的用法,break的用法挺多的比如::
(gdb) break my_application.cc:16
这是在my_application.cc的第16行停下来。如果你总共就一个文件,或者当前调试就是某 个文件,你也可以省略前面的文件名(和冒号),这样写::
(gdb) break 16
如果你设置了很多的断点,你可以用info命令来看设置在哪里了::
(gdb) info breakpoints Num Type Disp Enb Address What 1 breakpoint keep y 0x000055555555518e in main() at my_application.cc:16 breakpoint already hit 1 time 2 breakpoint keep y 0x0000555555555149 in test_sum() at my_application.cc:5
然后你可以用delete命令删掉其中一些,比如你可以这样删掉第一个断点::
(gdb) delete breakpoint 1
注意:
info和delete命令有一个共同的特征,都是在后面加一个类型,然后再制定更相信的信息的。可以先输入info或者delete,空格,然后按两次tab,让它联想有些什么类型,从而看对应的信息。
查看变量用print命令,比如你有一个变量叫a,现在想知道a等于几了。你可以::
(gdb) p a
print命令可以带格式要求,比如你可以用下面的方法按二进制,八进制,十进制,十六进 制,甚至当作浮点,输出a::
(gdb) p/t a (gdb) p/o a (gdb) p/d a (gdb) p/x a (gdb) p/f a
gdb一定程度上甚至可以直接通过这种方法调用一个函数,比如你有这个变量a,你还有一 个做加法的函数add,你可以这样::
(gdb) p add(a, 3)
gdb会先调用add(a, 3),然后把它的结果打印出来。
gdb有一个数组操作符@,你可以放在变量后面,把它当作一个数组输出,比如,你有一个 int a,你要看a这个内存后面10个int的内容,你可以这样写的::
(gdb) p a@10
a后面的内容是啥就不管了,这是你的问题。
变量可以在运行中修改,比如这样::
(gdb) set var a=3
这可以在运行中改掉a的值,但一般调试我们不建议这样,因为这样程序完全不按设计的方 法来运行了。当然,你知道你自己在干什么就行。
也许你已经注意到了,每次你运行p命令,gdb都会显示一个$n的变量出来,比如这样::
(gdb) p t $1 = 3 (gdb) p tp $2 = (int *) 0x0
这是gdb生成的临时变量,你可以直接用的。比如,跟踪到后面,t的值修改了,你想把它改回去, 你可以看看它的历史,然后把t设置回去::
(gdb) show values $1 = 3 $2 = (int *) 0x0 (gdb) set var t = $1
和p类似的还有一个命令x,它和p的主要区别是它是从内存的角度解释后面的变量(当作一 个地址),比如你想输出前面的变量a的内容,你可以这样::
(gdb) x/x &a
&a取a的地址,x要求输出x的内容,x是禁止。如果你要真的看内存里面的内容是怎么放的, 你可以用这个命令。
和p命令不同,x命令是不看a的类型的,所有东西给它,它都当作指针,无条件解释里面的内容, 所以你可以按不同的长度来运行它,比如下面的命令分别按字节,双字节,四字节,八字 节,字符,字符串的方式解释它::
(gdb) x/b &a (gdb) x/h &a (gdb) x/w &a (gdb) x/g &a (gdb) x/c &a (gdb) x/s &a
此外,由于这是内存,你可以决定输出多少个成员,所以,一个完整的x命令可以是这样的::
(gdb) x/10tb &a (gdb) x/20xw &a (gdb) x/5og &a
这分别表示:
- 按字节为单位,输出10个二进制内容
- 按4字节为单位,输出20个16进制内容
- 按8字节为单位,输出5个八进制内容
还有一个用来看数据的命令叫display,可以让你每次停下来自动打印变量的内容,这样可 以省不少事,这些你试一下就会了。
display的删除和breakpoint一样,可以用delete display <id>来删除。
C/C++的标识符(变量或者函数都是标识符)都有作用域,add函数的i和sub函数的i,就不 是同一个。所以,使用这些变量的时候要注意当前的作用域在什么位置上,如果你调用了多层 的函数,每层函数的i都是不一样的。想象一下,你的main调用了add,add调用了sub。每 个函数都有一个i,然后你在sub里面遇到一个断点,用p i看i的值,你会看到谁的i?
当然是sub的。
但是,如果你现在想看add的i怎么办呢?这需要bt和frame命令。你首先运行bt,输出结果 是这样的::
#0 sub (a=3, b=-4) at test2.c:4 #1 0x0000555555555184 in add (a=3, b=4) at test2.c:8 #2 0x00005555555551a8 in main () at test2.c:14
这个#0, #1, #2叫做当前断点的“帧栈”,frame stack。每个函数叫做一个frame(帧), 越早调用的函数就压在最下面(所以叫一个栈,Stack)。如果你想看其他函数的变量,就 需要切换到那边去,比如我想看main的i等于多少,我可以这样::
(gdb) frame 2 (gdb) p i
这是先把帧切换到2这个位置,然后看这个上下文的i了。
那如果我们在main里面先调用了add,再调用sub(而不是在add里面调用sub),但我们在 sub里面断住了,我们还能访问add里面的i吗?
当然不能了,因为函数退出,函数自己的变量就不存在了。frame stack之所以可以存在, 只是因为stack里面的每个函数都还没有退出而已。
其他的命令,等你编的程序变得很复杂再学吧。
这个小节我们根据需要深入讲一些可能有用的独立技巧,刚开始学可以跳过不看。
很多人第一次接触gdb等调试工具后,会觉得非常Cool,离开gdb就不会调试程序了。好像 觉得自己可以看到程序的所有变量,可以控制程序执行的每一步,仿佛掌控了整个程序。
所以他们每次程序出了错,都想单步一次,觉得这样就会发现错误了。
但这样常常是浪费时间的。
你能看到所有的变量不错,但你有空看完一个a[100][100]的数组吗?——不要尝试和计算机 比精力,你没有计算机的精力。还记得吗?我们比计算机强的是抽象逻辑能力。
所以,我们要从逻辑分析上思考整个程序的工作原理,看看它如果正常运行的时候,到底 应该“呈现”成什么样。然后根据需要甚至断点,并有目的地去看特定的变量,这样才会真 正发现bug在什么地方。否则就会出现不少初学者常见的那样,一遍遍跟踪程序,觉得自己 在“调试”程序,但无论跟踪多少次,都发现不了问题在哪里。
理解这一点,你也会发现,很多时候你不需要用gdb,用好cout就可以了。想明白你的逻辑, 然后在关键的地方把相关的信息打印出来(这种情况下,一般会用cerr代替cout,表示输出 到错误输出控制台上),这样也可以完成调试。
总之,调试的本质是暴露更多信息让我们判断程序的逻辑有没有错,关键在于想清楚你要 什么信息,不要把调试变成反反复复的单步执行的过程。
很多时候,我们调试到后面了,错误出现在程序的后面,我们懒得每次都运行gdb,然后设 置这个断点,那个断点的。正如我们一开始说的,程序员会让一切重复的行为自动化。
所以gdb也是支持初始化脚本的,就好像bash有.bashrc,vim有.vimrc一样,gdb也有一个 .gdbinit的脚本,你调试哪个程序,就在那个程序的目录下放这个脚本,把你希望启动 gdb后每次都要运行的命令放进去,下次就不用再弄一次了。
比如我们要调试程序my_app.exe,我们希望每次进入gdb以后,自动给add和sub函数设置一 个断点,我们只要这样写一个.gdbinit就可以了::
file ./myapp.exe # 这是相当与gdb ./myapp.exe break add break sub run
之后你直接在这个目录中运行gdb,程序就会直接运行到add或者sub上就停下来。
设置断点和打印输出是gdb的核心功能,正文我们主要相信介绍了打印,这里我们深入讲一 下break的指定方法,不过其实你自己用help break也可以看到,我这里只是用中文总结一 下罢了。
下面是一组指定断点的例子,仿着做就行了::
(gdb) break main # 在main函数上加断点 (gdb) break 15 # 在当前文件15行的地方加断点 (gdb) break +2 # 在往下两行的地方加断点 (gdb) break + # 重复前一个break +n指令 (gdb) break -2 # 在往前两行的地方加断点 (gdb) break my_app.cpp:15 # 在my_app.cpp的15行加断点 (gdb) break my_app.cpp:test # 在my_app.cpp的test函数上加断点 (gdb) break # 在当前行设置断点 (gdb) break 15 if a > 0 # a大于0的时候才断 (gdb) tbreak test # 在test函数上设置断点,但一旦触发就删除
要注意,断点是只执行那一行之前断,不是执行完才断。
删除断点的方法我们前面说过,可以用delete,你一般先用info breakpoints看看每个断 点的id,然后用delete breakpoints <id>来删除某个断点。但其实还有另一个命令,叫 clear,也可以做一样的事情。
它和delete的区别是指定的不是id,而是当初请求设置断点的命令本身。
比如,你用break main设置了一个断点,然后你又用break main再设置了一个断点。这会 产生两个id,类似这样::
(gdb) break main Breakpoint 7 at 0x555555555192: file test2.c, line 13. (gdb) break main Note: breakpoint 7 also set at pc 0x555555555192. Breakpoint 8 at 0x555555555192: file test2.c, line 13. (gdb) info breakpoints Num Type Disp Enb Address What 7 breakpoint keep y 0x0000555555555192 in main at test2.c:13 8 breakpoint keep y 0x0000555555555192 in main at test2.c:13
要删除它们,你要运行两次delete命令::
(gdb) delete breakpoints 7 (gdb) delete breakpoints 8
你也可以用clear一次把它们都删了::
(gdb) clear main Deleted breakpoints 7 8
断点还可以临时打开和关闭::
(gdb) disable breakpoints 7 (gdb) enable breakpoints 7
如果你不是要删除它,只是临时不想开,就可以用这种方法临时处理一下。
我们用编辑器来看代码,不需要用gdb,如果你用命令行,可以考虑学习一下tmux命令的 用法,这个工具可以让你在命令行中同时看多个窗口,我这里就不深入讲了。
但如果你只是要临时看一下代码,或者知道现在代码跑到哪里了,可以用tui命令::
(gdb) tui enable # 开tui界面 (gdb) tui diable # 关tui界面
这会多开一个窗口,可以让你看到代码的位置。
如果你不想老看到这个窗口,可以用前面提到的list命令,它会从当前断点开始列出代码 的内容,让你临时看看代码,多次运行list可以把后面的内容也列出来。如果你列着列着 忘了现在运行到哪里了,可以用bt看。
list也可以带参数,下面是一些例子::
(gdb) list 3,10 # 列出3到10行的代码 (gdb) list my_app.cpp:13 # 里出my_app.cpp的13行开始的内容 (gdb) list
这个小结我根据需要增加一些原理性的信息。
我们用gdb调试一个程序,实际上涉及两个线程(其实是进程,进程是一种特殊的线程,这 里我们统一按线程来理解),一个是gdb,一个是被调试那个程序(我们这里叫它app)。
Note
线程和进程的关系:一般来说,操作系统为了容易管理,会把你运行每个程序创建为一 个进程,进程有自己的执行线索,正如我们在前面的章节中说过,执行线索,就是一个 有先后顺序一步步执行的序列。这本质就是一个线程。除此以外,操作系统还在内存上 把每个进程和其他进程隔开了。保证一般情况下,一个进程不能读写另一个进程的空间。 这样,你的Word就不能修改你的Excel的内容。这样比较安全。所以,进程其实有两个要 素,就是内存隔离和线程。这是为什么我们在很多描述中,不怎么区分线程和进程。
但两者确实是有区别的,我们可以在进程里面创建更多的线程,这些线程可以互相访问 对方的内存的。它们是同一个进程的不同线程。那个我们以后要学,但这里我们可以不 管它们。
当你开始用gdb调试app的时候,实际上一定程度上可以认为它们是轮流占用cpu的,当app 运行的时候,gdb是不能动的,当gdb动的时候,app是不能动的。
所以,一旦你遇到一个断点,其实你的app是完全没有反应的,如果你在做cin,那么它的 控制台上也是不能输入内容的。这个时候你可以查看app的内存,可以设置更多的断点, 然后你再做cond或者next等操作,这时控制权回到app了,这时gdb是没有反应的(但在 gdb里面按Ctrl-c可以强行抢app的控制权,让控制权回到gdb),直到app遇到下一个断点 的时候控制权才能重新回到gdb。
这一点,即使在DevC++中也是成立的,因为DevC++本来就是调用gdb工作的。所以,如果你 在一个包含cin的函数上执行单步(相当于gdb的next),这个函数没有结束前,控制权会 保留在app手上,这时你在DevC++上也是看不到当前运行到哪里的标记的,因为现在控制权 根本不在DevC++或者gdb的手上。