|
|
有爱心的海龟 · vue-cli 3.x 开发插件并发布到 ...· 2 年前 · |
|
|
大方的长颈鹿 · 新手如何快速用vue导入GLTFLoader ...· 2 年前 · |
|
|
英姿勃勃的绿豆 · 「datatable重复行」相关问答|文档| ...· 2 年前 · |
|
|
读研的橡皮擦 · c# - DataGridView set ...· 3 年前 · |
这是一篇由
Liam Huang
翻译的译文,原文是
Brendan Gregg
所作的
gdb Debugging Full Example (Tutorial): ncurses
。转载请保留本段文字,尊重作者和译者的权益。
The author of this work is
Brendan Gregg
and this work was firstly posted on
gdb Debugging Full Example (Tutorial): ncurses
. This is the translation of the original work, by
Liam Huang
. Please keep this information at the very top of your reprint, for the rights of the author and the translator.
当我尝试在网上寻找「GDB 范例」时,我发现大多数文章只是贴出了命令,而没有讲解相关输出。GDB 是 GNU 调试器(GNU Debugger),亦是 Linux 系统上的标准调试器。在听 Greg Law 在 CppCon 2015 上关于 GDB 的演讲时( Give me 15 minutes and I'll change your view of GDB ),我发现 Law 给出了相关输出,从而意识到了上述不足。
Law 的演讲,也让我意识到应该分享一个使用 GDB 解决问题的完整范例:包括命令输出、各个步骤,以及一些死胡同。也就是说,这篇文章将分享使用 GDB 调试查错的一般步骤,而不是其他特别的东西。这篇文章介绍了 GDB 的基本使用方法,因此可以作为教程使用。不过,还有很多东西没有介绍,请谨记在心。
以下命令,均是以
root
权限执行的。这是因为我调试的程序目前需要以
root
权限执行。在实际使用中,并不是所有所需的命令都需要
root
权限。此外,本文罗列了解决问题的每一个步骤,你可以只浏览你感兴趣的部分。
bcc 工具集是 BPF 工具箱的一部分。有人提起了一个 PR (Pull Request),修改了其中的 cachetop 以使用 top-like display 显示 page cache 的统计。当我对这个 PR 进行测试时,程序提示段错误(segfault)
1 |
# ./cachetop.py |
需要注意的是,Linux 提示程序遇到「段错误
Segmentation fault
」,而不是「段错误(核心已转储)
Segmentation fault (Core Dumped)
」。而此处,我希望能通过核心转储文件,对错误进行调试。(核心转储文件是进程内存空间的拷贝,因此可以用于调试;这个术语源自早年的磁性核心记忆体)
分析核心转储文件,是调试查错的手段之一。不过,也有其他的调试办法。比如,在程序执行时,使用 GDB 检查问题;又或者使用外部工具,收集段错误发生时的数据和堆栈信息。不过,此处我们从核心转储文件的分析开始。
1 |
# ulimit -c |
ulimit -c
是 Linux 的系统设置之一,用于限制生成核心转储文件的最大体积。此处,提示不允许转储进程内存。
另一方面,
/proc/sys/kernel/core_pattern
的内容是
core
。这意味着,发生核心转储时,Linux 会将进程内存空间转储到当前目录下名为
core
的文件当中。对于解决当前问题,这样的设置没什么问题。不过,我会希望将其存在某个固定的目录中。
1 |
# ulimit -c unlimited |
你也可以进一步调整
core_pattern
。比如说,
%h
表示机器名称,
%t
表示转储发生的时间。这些 Linux 内核参数的文档在
这里
。
如果想要永久更改
core_pattern
的设置,你可以修改
/etc/sysctl.conf
文件中的
kernel.core_pattern
。
如此,再试试看。
1 |
# ./cachetop.py |
这样一来,我们就有核心转储文件了。
现在,我可以启动 GDB,调试目标程序和核心转储文件了。(这里使用了
`which python`
的方式获取系统中
python
的绝对路径)
最后两行包含有趣的信息:它告诉我们,段错误发生在
libncursesw
库的
doupdate()
函数中。这类问题,可以考虑在网上检索,看看是否是已知问题。当然,我遇到的这个问题,没有其他人报告过。
对我来说,我大概能猜到
libncursesw
是做什么的。不过,如果对你来说这很陌生的话,首先你应该知道
/lib
开头并以
*.so
结尾说明它是一个共享对象(动态库)。接下来,你可以尝试通过
man
命令、网页、软件包说明等方式,弄清楚它是做什么的。
1 |
# dpkg -l | grep libncursesw |
我这里是在 Ubuntu 上调试,不过,在其他 Linux 发行上调试也基本没差。
1 |
(gdb) bt |
这里,自底向上的方向即是调用者
->
被调用的方向。其中
??
表示符号转义失败。遍历调用栈生成栈回溯记录的过程也有可能失败。这种情况下,你可能会看到一个地址值很小的栈帧,当然,这个地址通常是错误的。如果这些问题很严重,那么就要考虑修复这些问题:安装相应软件包的调试信息包(为 GDB 提供更多符号,且允许 GDB 做基于 DWARF 的遍历),或是在编译程序时禁止编译器优化栈帧指针并包含调试信息(
-fno-omit-frame-pointer -g
)。这里的
??
在安装
python-dbg
软件包之后,大都都能解决。
仅从调用栈来看,信息似乎不太够:
#5
至
#17
是在 Python 内部,而后经由
_curses
库调用进入
libncursesw
。在
libncursesw
中,
wgetch()->wrefresh()->doupdate()
调用顺序看起来进行了一次窗口刷新。但是,为什么这会引发核心转储呢?
接下来,我要反汇编导致段错误的函数
doupdate()
。
1 |
(gdb) disas doupdate |
这里的输出做了适当的截断。(我也可以直接输入
disas
,以查看当前函数的汇编代码)
=>
箭头标注的位置,即是产生段错误的指令地址。该指令中(
mov 0x10(%rsi),%rdi
),
%rsi
寄存器保存了一个内存地址,之前的
0x10
表示在这个内存地址的基础上做
0x10
的偏移;
%rdi
则是另一个寄存器。该指令的作用,是将上述偏移过的内存地址中保存的内容,拷贝到
%rdi
寄存器当中。接下来,我们要看看寄存器的状态。
使用命令
i r
(
info registers
的缩写)可以打印寄存器状态。
1 |
(gdb) i r |
好吧,
%rsi
这个寄存器中,保存的是一个空指针(
0x0
)。这就怪不得要出问题了。解引用一个未初始化的指针,或是解引用空指针,是段错误的常见原因。
你可以再确认一下此时
0x0
是否是有效的地址。在 GDB 中使用
i proc m
(
info proc mappings
的缩写)可以查看内存映射状态。
1 |
(gdb) i proc m |
如此可以看到,虚存空间有效地址的最低位是
0x400000
。因此,显而易见
0x0
此时是一个非法的地址,所以使用它会导致段错误。
至此为止,继续深入进行调试的方法有多重。我将从指令的角度继续深入。
1 |
0x00007f0a37aac401 <+289>: mov 0x20cb68(%rip),%rax # 0x7f0a37cb8f70 |
从汇编指令来看,代码似乎首先从栈上将某个信息拷贝到
%rax
寄存器当中。而后解引用
%rax
寄存器里的内容,并拷贝到
%rsi
寄存器。而后通过
xor
将
%eax
寄存器中的内容置零。最后执行到产生段错误的代码。在这里,
%eax
有可能提供更多的信息。(译注:原作者写的是
%rax
,这应该是手误)但是在段错误之前,它已经被置零了,因此我们看不到更多的信息。
我可以将断点设置在
doupdate+298
处,而后顺着指令单步调试,看看各个寄存器的值是如何变化的。为此,首先我得启动 GDB,以便在其中实时执行程序。
而后,使用
b
命令(
break
的缩写)设置断点。
1 |
(gdb) b *doupdate + 289 |
啊,出错了……这是我有意呈现的错误。通常,我们会将断点首先设置在
main
处,因为彼时符号应该都加载完毕了。而后,在程序遇到断点并暂停时,我们可以设置其他断点。此处,我需要关注
doupdate
函数,因此,我将断点首先设置在这个函数被调用的地方。
1 |
(gdb) b doupdate |
如此,我们就来到了目标断点位置。
这里,
r
(
run
) 命令将参数传给被 GDB 跟踪的程序(这里是
python
),以便执行目标 Python 脚本。因此,这里相当于执行了
python cachetop.py
。
至此,我使用
si
(
stepi
) 命令,让程序单步地向前执行一条指令,而后检查寄存器状态。
1 |
(gdb) si |
新的线索出现了。看起来,我们对空指针解引用的操作,发生在一个名为
cur_term
的符号当中。(
p/a
是
print/a
的缩写,其中
/a
表示随后传入的是一个内存地址)考虑到这是在调试 ncurses,那么,是不是我们的
TERM
环境设置有问题呢?
1 |
# echo $TERM |
我尝试将其设为
vt100
,但是执行程序依旧得到了相同的段错误。
注意,此处我检查的是
doupdate
函数第一次被调用的情形,但实际上它可能被多次调用,而且问题可能出在后续的调用中。为此,我们可以让程序持续执行(
c
命令),直到撞见我们期待的那次调用。然而,调用次数少的话,可能还好。若是调用多次,这就不好办了。(第 15 节将讨论这个问题)
单步回退是 GDB 的重要特性之一,Law 在它的演讲中也有提到。我们看一个例子。
我会重启整个 GDB 会话,从头开始演示。
而后,我将断点设置在
doupdate
函数上;当遇到之后,我会打开记录功能,直到进程崩溃。这种记录功能,对系统影响是很大的。所以,最好不要从
main
函数就开始记录。
1 |
(gdb) b doupdate |
至此,我就可以在代码行或者指令的意义上单步回退了。单步回退的原理,是从记录中,回放寄存器的状态。此处,我回退两个指令,而后打印寄存器状态。
1 |
(gdb) reverse-stepi |
这次确定无疑,我们又一次定位到了
cur_term
这条线索。此时,我很想读读源码,但是首先我会试着看到更多的调试信息。
这里,我们需要(在 Ubuntu 上)安装
libncursesw
的调试信息包。
1 |
# apt-cache search libncursesw |
赞~!此处的调试信息包与动态库的版本是一致的。现在的段错误看起来是怎样的呢?
1 |
# gdb `which python` /var/cores/core.python.30520 |
调用栈看起来稍微有些不一样了。发生段错误的地方,实际是在
ClrBlank()
函数当中。它首先被内联到
ClrUpdate()
当中,最后内联到
doupdate()
当中。
至此,我真的要读读代码了。
1 |
(gdb) disas/s |
好,依旧有
=>
作为出问题指令的标记,以及指令对应的代码打印在其上。那么,程序产生段错误,是因为
if(back_color_erase)
这一行代码吗?看起来似乎不太可能。段错误产生的原因是对指向非法地址的指针进行解引用,例如
a->b
或者
*a
。但在此处,
back_color_erase
仅只是普通地访问变量,没有解引用的动作,是不会引起段错误的。
为此,我反复检查了调试信息包的版本是否匹配,而后重新在 GDB 里执行程序,但无有收获——段错误发生在同一位置。
那么,是不是
back_color_erase
本身有什么特别之处呢?现在我们在
ClrBlank
函数中,我试着列出它的源代码。
1 |
(gdb) list ClrBlank |
呃……
back_color_erase
在函数里是未定义的,看起来是一个全局变量?
TUI 是 text user interface(文本用户界面)的缩写。这一界面我甚少使用,我也是听过 Law 的讲座之后受到的启发。
为此,你需要在 GDB 启动的时候,传入
--tui
参数。
GDB 抱怨说没能找到 Python 的源代码。诚然,我可以去解决这个问题,然而,现在进程崩溃的位置是在
libncursesw
,费劲去解决这个问题就没必要了。因此,按下回车,使其继续加载,并读入
libncursesw
的调试信息。
1 |
┌──/build/ncurses-pKZ1BN/ncurses-6.0+20160213/ncurses/tty/tty_update.c──────┐ |
箭头
>
指向的代码就是程序崩溃的位置。使用
layout split
命令,可以让汇编指令和源代码分开显示。
1 |
┌──/build/ncurses-pKZ1BN/ncurses-6.0+20160213/ncurses/tty/tty_update.c──────┐ |
Law 在单步回退中展示了如何使用 TUI。你可以想象一下,在代码执行的过程中,同时呈现汇编码和源代码是怎样的光景。
cscope
首先,设置
cscope
。
1 |
# apt-get install -y cscope |
此处
cscope -bqR
创建了查找数据库,而
cscope -dq
则启动之。
搜索
back_color_erase
的定义:
1 |
Cscope version 15.8b Press the ? key for help |
1 |
[...] |
好吧……这是一个用
#define
定义的宏变量。(就不能大写吗?摔!)
那么,
CUR
又是什么呢?我们继续搜索。
1 |
#define CUR cur_term->type. |
好嘛,至少这个
#define
定义的宏是大写的。
碰见
cur_term
了——之前我们在单步调试汇编指令和检查寄存器状态的时候有看到过它。那么它是啥咧?
1 |
#if 0 && !0 |
cscope
在
/usr/include/term.h
中找到了它。我在我认为真正起作用的定义处,用注释做了标记。为什么会有
if 0 && !0 ... elif 0
这种那奇怪的写法,我也不知道……(但总之要阅读更多的代码)有时,程序员会使用
#if 0
使调试用代码在生产环境中不生效。但这部分代码,看上去是自动生成的。
继续搜索
NCURSES_EXPORT_VAR
,会有新的发现。
1 |
# define NCURSES_EXPORT_VAR(type) NCURSES_IMPEXP type |
以及
NCURSES_IMPEXP
……
1 |
typedef struct term { /* describe an actual terminal */ |
哈!这回
TERMINAL
是大写的了。在这坨宏当中,这算是容易追踪的了。
好了,现在到底是谁设置了
cur_term
呢?想想看,我们遇到的问题,是因为
cur_term
被设置为
0x0
,这可能是没有初始化导致的,也有可能是显式设置导致的。顺着代码检查,可能会有新的线索。
1 |
Find this C symbol: cur_term |
按下回车,得到结果。
1 |
NCURSES_EXPORT(TERMINAL *) |
同样,我对实际生效的部分做了标记。尽管函数名字被包在宏变量当中,但是我们至少知道
cur_term
是如何设置的了——通过
set_curterm()
函数。这个函数是没有被调用吗?
perf-tools/ftrace/uprobes
我稍后将介绍如何使用 GDB 解决这个问题,但我想试着用我的
perf-tools
工具集当中的
uprobe
工具来解决问题。它使用了 Linux 提供的
ftrace
以及
uprobes
两个工具。使用跟踪器的好处是不需要像 GDB 那样暂停目标进程——当然,在这个例子里这没所谓就是了。此外,使用跟踪器追踪一个事件和追踪成百上千个事件是一样的。
为此,我需要追踪
set_curterm
的调用,并且打印其首个参数。
1 |
# /apps/perf-tools/bin/uprobe 'p:/lib/x86_64-linux-gnu/libncursesw.so.5:set_curterm %di' |
好吧,在
libncursesw
里没见着有
set_curterm
。那么它在哪里呢?我们可以用 GDB 或者
objdump
查找一下。
1 |
(gdb) info symbol set_curterm |
显而易见,在此处 GDB 更好使些。如果你足够仔细的话,你会发现,这个函数定义在
libtinfo
当中。那么,让我们尝试在
libtinfo
中去追踪
set_curterm
。
1 |
# /apps/perf-tools/bin/uprobe 'p:/lib/x86_64-linux-gnu/libtinfo.so.5:set_curterm %di' |
这会没问题了。显而易见,
set_curterm
确实是有被调用的,并且被调用了 4 次。在最后一次调用时(之后进程就崩溃了),第一个参数是
0x0
,看起来似乎就是问题所在了。
至于为什么要打印
%di
这个寄存器中的值,是因为我们的程序运行在
x86_64
平台。使用
man syscall
可以看到有用的信息。
1 |
# man syscall |
我还想去看看为什么会在调用
set_curterm
时传入
0x0
作为参数,不过当前
ftrace
不支持调用栈的查询。
bcc
/BPF
考虑到我们正在调试
bcc
工具
cachetop.py
,那么使用
bcc
提供的
trace.py
来追踪函数调用是个不错的选择。它和刚才的
uprobe
工具有类似的功能。
1 |
# ./trace.py 'p:tinfo:set_curterm "%d", arg1' |
没错!我们在用
bcc
来调试
bcc
。
如果你之前未曾使用过
bcc
,那么我推荐你试试看。它有面向 Python 和 Lua 的接口,提供了在 Linux 4.x 系列中的 BPF 跟踪特性。简单来说,它让很多以前无法或者难以实现的性能工具变得可行。在
Ubuntu Xenial
上有我关于此的介绍。
我本该用 GDB 给
set_curterm
设置断点的,但是绕道去
ftrace
和 BPF 也时很有趣的事情。
回到 GDB。
1 |
# gdb `which python` |
好了,在断点处,我们看到
set_curterm
被传入了参数
termp=0x0
。幸好能有这些调试信息,否则,我就不得不在各个断点去打印寄存器状态了。
现在,查看逆向追溯调用栈,应当可以看到是哪个函数将
0x0
传给了
set_curterm
。
1 |
(gdb) bt |
唔……这回有更多信息了。
set_curterm
是被
llvm::sys::Process::FileDescriptorHasColors()
调用的,
llvm
编译器有问题?
cscope
这回,我们需要使用
cscope
看看
llvm
的代码。
FileDescriptorHasColors
函数是酱婶的。
1 |
static bool terminalHasColors(int fd) { |
在早先的版本里,该函数是这样定义的。
1 |
static bool terminalHasColors() { |
给
set_curterm
传入
nullptr
着实是个坏主意。
为了确定嫌疑,同时试着看看有没有可能的临时解决方案,我会尝试在异常调用
set_curterm
时复写修改内存。
首先,我们在 GDB 里跟踪程序,并在
set_curterm
处设置断点,直到调用它时传入了
0x0
作为参数。
1 |
# gdb `which python` |
此时,我要使用
set
命令,复写相关的内存:使用之前传入
set_curterm
并正确执行的参数
0xbecb90
替代
0x0
——当然,希望这一地址仍旧是合法的。
警告 ,复写内存是十分危险的!GDB 不会向你确认,「你确定吗」。如果你搞错了,或者输入时手滑了,那么进程就会崩溃。好一点的情况,进程当时就崩溃了。糟糕的情况,可能在如果按时间后才崩溃,而谁也不知道是怎么了。
此处,我是在一台试验用机器上进行调试。因为没有敏感数据,所以我冒险一试。此处,我将用
p/x
以十六进制的形式打印寄存器
%rdi
中的内容,并用
set
命令将其设置为之前可用的值,并检查寄存器状态。
1 |
(gdb) p/x $rdi |
(当然,因为有调试信息,所以我本没必要直接操作寄存器
%rdi
的值,我可以直接设置函数参数
termp
的值)
现在
%rdi
的值已经更新,其他寄存器看上去也没什么问题。于是我们让程序继续执行。
1 |
(gdb) c |
赞!当前对
set_curterm
的调用通过了,没有引发段错误。不过,下一次调用
set_curterm
又传入了
0x0
,我们故技重施。
1 |
(gdb) set $rdi=0xbecb90 |
啊哈!这次修改内存得到了另一个段错误。不过,虽然如此,当前的问题至少是解决了。
在之前的章节中,我不得不连续使用 3 次
continue
以便到达我真正感兴趣的那次函数调用。如果相关函数被调用了上百次,那么我会考虑使用条件断点。以下是一个示例。
首先,我会如常启动程序并为
set_curterm
设置断点。
1 |
# gdb `which python` |
现在,我要将断点 1 转换为条件断点,使其只在
%rdi
的值为
0x0
时生效。
1 |
(gdb) cond 1 $rdi==0x0 |
吼啊!通过使用
cond
(
conditional
),我将断点 1 设置为条件断点。因此,当断点 1 下一次生效时,就是
set_curterm
接受的第一个参数值为
0x0
的时候。此外,我用了
i b
(
info breakpoints
) 列出了所有断点的信息。
这里需要考虑,为什么我不在设置断点后的第一时间就将其设置为条件断点。这是因为,我发现对于程序尚未执行时设置的被延迟的断点来说,这些条件不会生效——至少当前的 GDB 版本是这样。(也有可能是我搞错了)
我也曾尝试了另一个和复写内存类似的方案。不过,这次我不打算修改内存中的数据,而是修改指令。
警告 :先前的警告在此处同样适用。
我将如先前一样,停在
set_curterm
的
0x0
调用处,而后适用 GDB 的
ret
(
return
) 命令跳过函数的执行,直接返回。此处的考量是,如果函数未执行,则全局变量
curterm
不会被设置为
0x0
。
1 |
[...] |
好嘛,进程又挂了……这是进程崩掉之后的状态。
度过更多代码之后,我决定再试试看。我想试着连续执行两次
ret
,以免
set_curterm
的调用者被不完整的调用搞坏了。再次强调:这只是一个非常 hacky 的实验,在生产环境中请慎重执行。
1 |
[...] |
这回,整个屏幕都白了,然后停住……之后显示出了如下结果。
1 |
07:44:22 Buffers MB: 61 / Cached MB: 1246 |
帅!炸!搞定啦!
我将调试过程的结果提交在
github
上。这是因为,一方面 BPF 的首席工程师 Alexei Starovoitov 也是 llvm 方面的专家,另一方面这个问题的根源似乎是 llvm 的一个 bug。当我在把指令和数据搞得乱七八糟时,他建议我在编译
bcc
时加入 llvm 的参数
-fno-color-diagnostics
即可绕过这一问题。搞定!这确实能解决问题。因此我将其加入了
bcc
的代码库作为暂时的解决方案,并期待 llvm 解决这个 bug。
至此,我们已经解决了问题。不过,(译注:有强迫症的)你可能还想要让整个调用栈看起来完好。
为此,你需要安装
python-dbg
软件包。
1 |
# apt-get install -y python-dbg |
此时,再打开 GDB 看看调用栈。
1 |
# gdb `which python` /var/cores/core.python.30520 |
此时,所有的
??
都不见了,然而对解决我们的问题并没有什么 X 用……
此外,Python 调试软件包还加强了 GDB 的功能。我们现在可以看看 Python 的调用栈。
1 |
(gdb) py-bt |
以及,我们可以看看 Python 代码是怎样的。
1 |
(gdb) py-list |
这能指出,在我们的 Python 代码中,究竟是那一行触发了段错误。这看起来非常棒!
之前我们在回溯调用栈时遇到的问题,其原因在于我们看到了 Python 内部在执行的方法(methods),但却看不到对应方法的符号。如果你在调试别的语言,类似的问题取决于它的编译选项和运行环境,以及执行代码是如何结束的。如果你在网上检索「语言名 GDB」,你可能会找到类似
python-dbg
这样的扩展。如果没有的话,坏消息是你必须自己写这样的软件包,但好消息是这样做是可行的。如果你是在调试 Python,那么请在网上检索「add new GDB commands in Pyhon」。
看起来,我似乎是要写一个完整的 GDB 指南,但显然我不是:GDB 还有很多我没讲到的东西。你可以在 GDB 中使用
help
命令,分门别类查看 GDB 的其他功能。
1 |
(gdb) help |
对于具体某个命令来说,你也可以用
help
命令查看具体用法。例如说,你可以查看所有和
breakpoints
相关的命令。
1 |
(gdb) help breakpoints |
显而易见,GDB 还有很多功能,而我仅只是在解决问题的过程中使用了其中很小一部分。
当我在几年前第一次使用 GDB 时,我真的不怎么喜欢它。它看起来又笨拙又难用。然而,GDB 已经改进了很多,同时也有了一些 GDB 调试查错的技巧之后,现在我认为它是一个强大的现代调试器。不同调试器有不同的技能,但是 GDB 必是其中最强大的文本调试器——当然
lldb
正在迎头赶上。
在此,我希望所有查找 GDB 调试范例的人会从我的例子和警示中学到东西。当然,以后有机会我也会分享更多关于 GDB 的经验——尤其是诸如 JAVA 的运行时问题。
哦对了,退出 GDB 的方法是
q
(
quit
) 命令。(译者注:你也可以使用
Ctrl + D
来退出 GDB 调试器。)