gdb 调试入门,大牛写的高质量指南
没想到BrendanGregg这样的大牛,会写出这样一篇gdbtutorials文章:gdbDebuggingFullExample(Tutorial):ncurses。但可能正如文章开头所说,大牛对网上的gdb文章都不太满意,所以才有了这篇高质量指南,gdb入门者的福音。——何登成
如果你是系统管理员,但还不认识BrendanGregg,那网上流传甚广的3张Linux性能工具图(链接),你应该看过的。——伯小乐。
(BrendanGregg)
gdb调试ncurses全过程:
发现网上的“gdb示例”只有命令而没有对应的输出,我有点不满意。gdb是GNU调试器,Linux上的标配调试器。当我看GregLaw在CppCon2015上的演讲《给我15分钟,我将改变你的对GDB的认知》的时候,我想起了示例输出的不足,幸运的是,这次有输出!这15分钟太值了。
它也启发我去分享一个完整的gdb调试实例,包含输出和每个步骤,甚至钻牛角尖的情况。这不是一个特别有趣或奇怪的问题,只是常规的gdb调试会话。但它包含了基础的东西可以勉强作为教程使用,记住gdb里还有很多东西我这里没用到。
我会以root权限运行下面的命令,因为我在调试一个工具,它需要root权限(目前)。需要的时候可用sudo获取root权限。你也没必要通读全篇︰我已列出每一步,你可以浏览它们找感兴趣的看。
1.问题概述
BPF工具箱里的bcc工具集有一个对cachetop.py的pull请求,它通过程序使用top-likedisplay显示pagecache的统计。太好了!然而,当我测试它时,遇到了段错误︰
注意它说的是“段错误”,不是“段错误(核心已转储)”。我想要一个核心转储文件用来调试。(核心转储文件是进程内存的拷贝–这个名字来源于磁芯存储器时代–可用调试器分析)
分析核心转储文件是一种方法,但不是调试这个问题的唯一方法。我可以在gdb中运行此程序,来检查这个问题。我也可以在段错误发生时,用外部追踪器去抓数据和栈帧。我们从核心转储文件入手。
2.解决核心转储问题
我检查一下核心转储的设置:
ulimit-c显示核心转储文件大小的最大值,这里是零:禁止核心转储(对于本进程和它的子进程)。
/proc/…/core_pattern仅仅被设为“core”,表示会在当前目录下生成一个文件名为“core”的核心转储文件。目前这样就行了,但是我要演示如何把它设置为全局位置。
你可以进一步定制core_pattern;例如,%h为主机名,%t为转储的时间。这些选项被写在Linux内核源码Documentation/sysctl/kernel.txt中。
要使core_pattern保持不变,重启之后仍然有效,你可以通过设置/etc/sysctl.conf里的“kernel.core_pattern”实现。
再来一次:
好多了:我们有了自己的核心转储文件。
3.启动GDB
现在我要用gdb启动目标程序(用shell替换符,”`”,不过在你确定能用的情况下,也可指定完整路径),和核心转储文件:
最后两行很有趣:它告诉我们这个段错误发生在libncursesw库里doupdate()函数中。可以先在网上搜一下,以防这是个很常见的问题。我搜了一下,可是没发现一个常见的原因。
我已经猜到libncursesw是什么了,如果你对它很陌生,它在“/lib”目录下以“.so.*”结尾表明这是一个动态库文件,可能有man手册、网站、包描述等。
我是碰巧在Ubuntu上调试,但用什么Linux发行版对使用gdb并没有影响。
4.回溯
栈回溯显示我们是如何到达失败点的,通常足够帮助我们确定常见的问题。bt(backtrace的简写)常常是我在gdb中使用的第一条命令:
从下往上,按照从父函数到子函数的顺序看。有“??”的地方是因为符号解析失败。遍历栈–用来生成栈帧—也会失败。在这种情况下你可能会看到一个正常的栈帧,跟着一个小数值的假地址。如果符号或栈破损很严重,导致无法理解栈回溯,这里有几个常用的办法来修复:安装debuginfo包(给gdb提供更多的符号,让它来做基于DWARF的栈遍历),或者重新用源码编译(-fno-omit-frame-pointer-g)一个带帧指针和调试信息的版本。以上大多数“??”
可以通过安装python-dbg包来修复。
这些栈看起来不太有用:帧5到17(左边的索引)在Python内部,虽然还看不到Python方法。帧4是_curses库,然后就到了libncursesw。看起来调用顺序是wgetch()->wrefresh()->doupdate()。根据函数名来看,我猜是刷新窗口。为什么会导致核心转储呢?
5.反汇编
我从反汇编发生段错误的函数doupdate()开始:
部分输出。(我也可以只输入“disas”它会默认反汇编doupdate)
“=>”指向段错误地址,此处是一条mov指令mov0x10(%rsi),%rdi:从%rsi中指向内存地址的值加偏移量0x10处取值,送到%rdi寄存器中。接下来我会检查寄存器的状态。
6.查看寄存器
使用ir(inforegisters的简写)打印寄存器值:
哦,%rsi是零,这就是我们的问题所在!零不太可能是有效地址,并且解引用一个未初始化的指针或空指针引起的段错误是常见的软件bug。
7.内存映射
你可以使用iprocm(infoprocmappings的简写)核查零是不是有效地址:
第一个有效的虚拟地址是0x400000。任何小于它的地址都是非法的,这些地址如果被引用,就会引起段错误。
目前有几种不同的方式可做进一步分析。我先一步一步的看指令。
8.断点
先回到反汇编:
看这四条指令:好像是从栈中取东西放到%rax,然后解引用%rax到%rsi,再将êx置零(xor是一个优化,替换掉移动0的动作),最后将%rsi解引用再加一个偏移,不过我们知道%rsi是零。这几条指令用来访问数据结构。可能%rax会很有趣,但是它已经被前面的指令置零,所以我们在核心转储文件的寄存器里看不到它的值。
我可以在doupdate+289下个断点,然后逐条指令查看寄存器的值如何变化。首先,我需要启动gdb把程序跑起来:
现在用b(break的简写)来下断点:
哦。我想演示这个错误来解释为什么我们经常以在主函数设置断点作为开始,因为这时候符号可能被加载,可以设置感兴趣的断点。我直接在doupdate函数设断点,避开这个问题,一旦断点被触发就设置加了偏移的断点。
我们到了断点处。
如果你之前没有做这些,r(run)命令会把参数传给我们早先在命令行指定的gdb目标(python)。这样的话程序会以执行“pythoncachetop.py”结束。
9.单步调试
我跳到下一条指令(si,stepi的简写),然后检查寄存器:
又一条线索。所以我们解引用的空指针好像是一个叫“cur_term”的符号(p/a是print/a的简写,这里“/a”指以地址的形式)。考虑到这是ncurses,是我们的环境变量TERM设置有问题吗?
我试过将其设置为vt100并运行程序,还是遇到了同样的段错误。
注意我只是在doupdate()第一次被调用的时候查看了寄存器,但是它可以被多次调用,所以问题可能出在后边的调用中。我可以通过执行c(continue的简写)一步步到达出问题的地方。如果它被调用几次的话这样做是可行的,如果它被调用几千次的话我得用别的办法。(我会在15节的里介绍。)
10.回退
gdb有一个超棒的功能叫回退,GregLaw在他的演讲中提到过。这里有一个例子。
我再启动一个python会话,从头演示:
和之前一样我在doupdate下断点,一旦触发,我就启动recording,然后继续运行程序直到崩溃。Recording会增加相当大的开销,所以我不想在主函数里就将它打开。
这里我可以逐行或逐条指令的回退。它通过播放我们记录的寄存器状态来工作。我回退两条指令,然后打印寄存器值:
所以,又找到了“cur_term”的线索。我很想看这里的源代码,但我将从调试信息入手。
11.调试信息
这是libncursesw,我没有安装调试信息(Ubuntu):
我把它装上:
太好了,版本匹配。那么现在我们的段错误是什么样子呢?
栈回溯看起来不太一样:我们确实不在doupdate()里边,而是在ClrBlank()中,它内联在ClrUpdate()里,ClrUpdate()又内联在doupdate()中。
现在我真的要看源码了。
12.源代码
安装了调试信息之后,gdb可以同时列出源码和汇编:
好极了!看“=>”和它上边的代码。所以我们的段错误发生在“if(back_color_erase)”?看起来不可能。
这里我检查了一下,我的调试信息版本是对的,重新在gdb里边运行程序直到发生段错误。错误相同。
back_color_erase有什么特殊吗?我们现在在ClrBlank()中,我先列出源码:
啊,在这个函数里边没定义,难道是全局变量?
13.TUI
有必要看看这些代码在gdb的文本用户界面(TUI)里是什么样的,我用的不多,是看了Greg的演讲之后受到的启发。
你可以用–tui来启动:
它在抱怨没有Python源码。我可以搞定,但是我们是在libncursesw里边崩溃的。所以不管它敲回车让它完成加载,在发生错误的地方加载了libncursesw调试信息里的源码:
棒极了!
“>”指向发生崩溃的那行代码。更棒的是:用layoutsplit命令,我们可以在不同的窗口查看源代码和汇编代码。
Greg演示这个的时候,和这里的顺序相反,因此你可想像同时查看源代码和汇编的情景(这里我需要一个视频来演示)。
14.外部工具:cscope
我需要对back_color_erase有更多了解,我可以试试gdb的搜索命令,但是我发现用一个外部工具:cscope更快。cscope是一个基于文本的代码浏览器,诞生于80年代的贝尔实验室。如果你有喜欢的现代IDE,可以不用它。
安装cscope:
cscope-bqR 用来建立查找数据库。cscope-dq用来启动cscope。
查找back_color_erase的定义:
敲回车:
哦,一个宏定义。(作为宏定义的常见的形式,它们至少应该大写)
好吧,那么CUR是什么呢?用cscope查找定义易如反掌。
起码这个宏定义是大写的!
我们通过逐条查看指令和寄存器找更早定义的cur_term。它是什么呢?
cscope读取了/usr/include/term.h。好吧,更多的宏。我用加粗来突出这行代码,我认为它产生了影响。为什么这里会有“if0&&!0…elif0”?我不清楚(需要再读些代码)。有时程序员会在他们想要在产品中失效的调试代码附近使用“#if0”,可是,这个好像是自动生成的。
查找NCURSES_EXPORT_VAR发现:
…和NCURSES_IMPEXP:
…还有TERMINAL:
嗨!TERMINAL是大写的。和宏混在一起,这个代码不太好跟踪…
好吧,到底是谁给cur_term赋的值呢?记住我们的问题是它被赋值为零,也许因为它未被初始化或显式赋值。浏览给它赋值的代码路径可能会找到更多的线索,来回答为什么没被初始化,或为什么被赋值为零。使用cscope的第一个选项:
快速浏览项发现:
我加了高亮。甚至函数名称都被封装在宏里。但至少我们发现了cur_term如何被赋值的:通过set_curterm()。也许它没被调用?
15.外部工具:perf-tools/ftrace/uprobes
我稍后将介绍如何用gdb解决这个问题,可是我忍不住尝试我perf-tools工具箱里的uprobe工具,它使用Linux下的ftrace和uprobes。用tracers的一个好处是它不会终止目标进程,像gdb一样(尽管对于这里的cachetop.py没什么用)。另一个好处是追踪几个和几千个进程一样容易。
我应该能追踪libncursesw对set_curterm()的调用,甚至打印出它的第一个参数:
咦,没起作用。set_curterm()在哪?有很多方法可以找到它,比如gdb或objdump:
gdb表现的好些。此外如果仔细看源代码,我注意到它是为libtinfo构建的。
试着在libtinfo里边查找set_curterm():
找到了。所以set_curterm()被调用了,并且被调用了四次。最后一次被传了一个零,看起来这就是问题所在。
如果你觉得疑惑,我怎么就知道%di寄存器就是第一个参数呢,因为AMD64/x86_64ABI写着呢(假设这个库和ABI兼容)。这里有提示:
我还想知道调用arg1=0x0的堆栈信息,但是ftrace还不支持栈追踪。
16.外部工具:bcc/BPF
由于我们在调试bcc工具cachetop.py,值得注意的是bcc里的trace.py有和我的老工具uprobe类似的功能:
是的,我们在用bcc调试bcc!
如果你对bcc不熟悉,它值得一看。它为Linux4.x系列里的BPF新特性提供了Python和lua接口。总之,它能让很多以前不可能或昂贵以致无法运行的性能工具运行起来。我以前发过贴介绍如何在UbuntuXenial上运行它。
bcc的trace.py工具应该有一个开关来决定是否打印用户堆栈,因为内核从Linux4.6开始具备BPF堆栈功能,不过到写这篇文章的时候我们还没有加上这个开关。
17.更多的断点
我真的应该从在set_curterm()下了断点的gdb入手,可是我觉得我们走的弯路,使用ftrace和BPF的还是蛮有趣的。
回到实时运行模式:
好的,在这个断点我们可以看到set_curterm()被调用了,被传了一个termp=0x0的参数,多亏了debuginfo提供的信息。如果没有debuginfo,我只能在每个断点处打印寄存器值。
我打印栈帧出来,这样我们可以看到是谁将curterm设为零的。
(gdb)bt
#0 set_curterm(termp=0x0)at/build/ncurses-pKZ1BN/ncurses-6.0+20160213/ncurses/tinfo/lib_cur_term.c:80
#1 0x00007ffff5a44e75inllvm::sys::Process::FileDescriptorHasColors(int)()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#2 0x00007ffff45cabb8inclang::driver::tools::Clang::ConstructJob(clang::driver::Compilation&,clang::driver::JobActionconst&,clang::driver::InputInfoconst&,llvm::SmallVector<clang::driver::InputInfo,4u>const&,llvm::opt::ArgListconst&,charconst*)const()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#3 0x00007ffff456ffa5inclang::driver::Driver::BuildJobsForAction(clang::driver::Compilation&,clang::driver::Actionconst*,clang::driver::ToolChainconst*,charconst*,bool,bool,charconst*,clang::driver::InputInfo&)const()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#4 0x00007ffff4570501inclang::driver::Driver::BuildJobs(clang::driver::Compilation&)const()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#5 0x00007ffff457224ainclang::driver::Driver::BuildCompilation(llvm::ArrayRef<charconst*>)()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#6 0x00007ffff4396cdainebpf::ClangLoader::parse(std::unique_ptr<llvm::Module,std::default_delete<llvm::Module>>*,std::unique_ptr<std::vector<ebpf::TableDesc,std::allocator<ebpf::TableDesc>>,std::default_delete<std::vector<ebpf::TableDesc,std::allocator<ebpf::TableDesc>>>>*,std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>const&,bool,charconst**,int)()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#7 0x00007ffff4344314inebpf::BPFModule::load_cfile(std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>const&,bool,charconst**,int)()
from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#8 0x00007ffff4349e5einebpf::BPFModule::load_string(std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>const&,charconst**,int)()
from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#9 0x00007ffff43430c8inbpf_module_create_c_from_string()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
#100x00007ffff690ae40inffi_call_unix64()from/usr/lib/x86_64-linux-gnu/libffi.so.6
#110x00007ffff690a8abinffi_call()from/usr/lib/x86_64-linux-gnu/libffi.so.6
#120x00007ffff6b1a68cin_ctypes_callproc()from/usr/lib/python2.7/lib-dynload/_ctypes.x86_64-linux-gnu.so
#130x00007ffff6b1ed82in??()from/usr/lib/python2.7/lib-dynload/_ctypes.x86_64-linux-gnu.so
#140x00000000004b1153inPyObject_Call()
#150x00000000004ca5cainPyEval_EvalFrameEx()
#160x00000000004c2e05inPyEval_EvalCodeEx()
#170x00000000004def08in??()
#180x00000000004b1153inPyObject_Call()
#190x00000000004f4c3ein??()
#200x00000000004b1153inPyObject_Call()
#210x00000000004f49b7in??()
#220x00000000004b6e2cin??()
#230x00000000004b1153inPyObject_Call()
#240x00000000004ca5cainPyEval_EvalFrameEx()
#250x00000000004c2e05inPyEval_EvalCodeEx()
#260x00000000004def08in??()
#270x00000000004b1153inPyObject_Call()
#280x00000000004c73ecinPyEval_EvalFrameEx()
#290x00000000004c2e05inPyEval_EvalCodeEx()
#300x00000000004caf42inPyEval_EvalFrameEx()
#310x00000000004c2e05inPyEval_EvalCodeEx()
#320x00000000004c2ba9inPyEval_EvalCode()
#330x00000000004f20efin??()
#340x00000000004eca72inPyRun_FileExFlags()
#350x00000000004eb1f1inPyRun_SimpleFileExFlags()
#360x000000000049e18ainPy_Main()
#370x00007ffff7811830in__libc_start_main(main=0x49daf0<main>,argc=2,argv=0x7fffffffdfb8,init=<optimizedout>,fini=<optimizedout>,rtld_fini=<optimizedout>,
stack_end=0x7fffffffdfa8)at../csu/libc-start.c:291
#380x000000000049da19in_start()
好了,有了更多的线索…我认为。我们在llvm::sys::Process::FileDescriptorHasColors()里边。llvm编译器有问题?
18.外部工具:cscope,再来一次
代码较多的时候使用cscope查看,这次是llvm。FileDescriptorHasColors()函数:
这是较早版本中使用的代码:
用空指针调用set_curterm()变成了“愚蠢的舞蹈”。
19.写内存
作为实验,我要修改程序内存来避免set_curterm()被置零,用来探索可能的解决方法。
运行gdb,在set_curterm()下断点,跑到零调用的地方:
这里我用set命令来改写内存,把零换成在前面看到的set_curterm()参数0xbecb90,希望它仍是合法的。
警告:写内存不安全!gdb不会问你“你确定?”。如果你写错了或者敲错了,会搞坏程序。最好的情况是你的程序立即奔溃,你意识到自己做错了。最糟的情况,程序使用坏的数据继续运行几年之后被发现是错的。
这里,我在不用于生产的实验室机器上做试验,所以我继续。
我以16进制(p/x)的形式打印%rdi的值,然后将其设为之前的地址,再打印一次,最后打印所有寄存器的值:
(因为这里我已经安装了调试信息,因此不必使用寄存器,我可以设置传给set_curterm()的参数参数“termp”,而不是$rdi。)
现在%rdi被用到了,所以那些寄存器看起来还能继续用。
好的,在调用set_curterm()时程序没崩!但遇到另一个参数也是零的问题。我们故技重施:
啊。这就是我写内存的后果。所以这次试验以另一个段错误结束。
20.条件断点
在前面一节,我用了3个continues到达断点的正确调用处。如果有几百次调用的话,就得用条件断点了。这里有个例子。
和之前一样我运行程序,在set_curterm()下断点:
现在我要将1号断点变成条件断点,这样它只会在%rdi的值为零是被触发:
漂亮!cond是conditional的简写。为什么当我第一次创建“pending”断点的时候没有立即运行它呢?因为我发现在pending断点上条件不管用,至少在这个版本的gdb上是这样。(要么是我哪里做错了。)我也用ib(infobreakpoints)列出了断点信息。
21.返回命令
我曾经试过另一个改值的方法,但是这次我要改指令而不是数据。
警告:看前边的警告,这里也适用。
和之前一样我们来到set_curterm零断点处,然后敲入ret(return的简写),就会立即从此函数返回并且不执行这个函数。我想用不执行函数的方式让全局变量curterm不被置零。
又崩了。这是我搞砸的现场。
再试一次。在多看了一点代码之后,我想第二次尝试ret,以防父函数被卷进来。再来一次,这只是一次非常规试验:
[…]
(gdb)c
Continuing.
Breakpoint1,set_curterm(termp=0x0)at/build/ncurses-pKZ1BN/ncurses-6.0+20160213/ncurses/tinfo/lib_cur_term.c:80
80 {
(gdb)ret
Makeset_curtermreturnnow?(yorn)y
#0 0x00007ffff5a44e75inllvm::sys::Process::FileDescriptorHasColors(int)()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
(gdb)ret
Makeselectedstackframereturnnow?(yorn)y
#0 0x00007ffff45cabb8inclang::driver::tools::Clang::ConstructJob(clang::driver::Compilation&,clang::driver::JobActionconst&,clang::driver::InputInfoconst&,llvm::SmallVectorconst&,llvm::opt::ArgListconst&,charconst*)const()from/usr/lib/x86_64-linux-gnu/libbcc.so.0
(gdb)c
屏幕清空暂停…然后刷新:
哇!成功了!
22.更好的方案
我已经把调试输出发布到github,因为BPF首席工程师,AlexeiStarovoitov对llvm也很精通,问题的根源好像是llvm的一个bug。当我在用写内存和返回命令瞎搞的时候,他建议我在bcc加上llvm选项-fno-color-diagnostics,来避免这个问题。成功了!把它加到bcc里是一个解决办法。(我还是希望llvm的bug能被修复)
23.Python环境
至此问题已经解决了,但是你可能会好奇想看修复好的堆栈回溯。
安装python-dbg:
现在我回到gdb来看堆栈回溯:
没有“??”了,但也没什么大用。
python调试包给gdb加入了别的功能。现在我们可以看python的回溯:
…和Python源码:
它识别出了我们之前执行的python代码中的段错误。真是太棒了!
原先堆栈回溯的问题是我们看到了python内部在执行方法,却看不到方法本身。如果你调试别的语言,要取决于它的编译选项和运行环境,还有怎么结束执行代码。如果你在网上搜索“语言名”和“gdb”你可能会找到像Python一样的gdb扩展。如果没有的话,坏消息是你需要自己写,好消息是这样做是可行的!当它们可以用Python来写的时候,请搜索“addingnewGDBcommandsinPython”的资料。
24.更多命令
看起来好像我写了一个gdb的全面介绍,但我真的没有:gdb里还有很多命令我没提到。help命令列出了主要部分:
你可以对每一类命令执行help。例如,这是breakpoints类的全部清单:
这些帮助表明了gdb有很多功能,也说明了我在示例中用到的只是一小部分。
25.结语
好吧,这个问题有点恶心:一个LLVMbug破坏了ncurses并引起了Python程序的段错误。但是我用来调试的命令和步骤很常见:看堆栈,检查寄存器,下断点,逐步排查,看源码。
当我第一次使用gdb的时候(多年前),我真的不喜欢它。觉得它不灵活而且功能有限。从那之后gdb进步了很多,我也掌握了gdb的技巧,我现在认为它是一个强大的现代调试器。不同的调试器特性可能不同,但是gdb可能是目前基于文本的最强大的调试器,lldb正奋起直追。
我希望我分享的有完整输出的gdb示例和我提到的不同的警告,会对搜到它的人有帮助。有机会的话我会发布更多的gdb示例,特别是其他运行环境比如Java。
用q退出gdb。
译者简介 ( 点击→加入专栏作者 )
曹佳伟:aprogrammer,Doone’slevelbestandleavetheresttoGod’swill.Ilovethissaying.
打赏支持译者翻出更多好文章,谢谢!
