基本调试操作
http://www.DbgTech.net
-
调试器命令窗口
-
简介
使用Windows调试工具进行调试,大部分和调试器之间的交互都是通过调试器命令窗口来进行的。命令的输入、输出都是在调试器命令窗口中显示出来。对WinDbg来说,调试器命令窗口是名为"Command"的窗口;对于KD、CDB和NTSD来说,整个命令行窗口就是调试器命令窗口。这里主要介绍WinDbg中的调试器命令窗口。
一般来说WinDbg运行之后都会打开一个标题为Command的子窗口,在没有调试目标的时候,这个窗口是不能接受输入输出的,这时WinDbg处于静止模式,只有在打开调试目标之后,才能够使用它和调试器交互。
窗口分为三个部分:
- 位于上部的面积最大的是命令输出窗口。所有的命令输出、目标程序的调试信息输出等等都会在里面显示出来。上一篇中介绍的调试器日志中记录的就是显示在这里的内容。
-
下半部分左边是提示符窗口。这里通过提示符能够快速知道调试器目前的状态。
上图中0:000>,冒号前的数字表示当前的进程号,同时调试多个进程时,每个进程都会被指派一个进程号;冒号后的000表示线程号。
进行内核调试时,如果是单处理器系统,提示符是kd>的形式;如果是多处理器系统,则是0: kd>的形式,前面的0表示处理器号。
提示符还可能是*BUSY*这样的字符串,以表示调试器正忙。也可以通过命令来自定义提示符。
- 下半部分右边是命令输入窗口。需要执行的命令就在这里输入。
调试器命令窗口中输入命令时可以使用一些快捷操作:
- 上下方向键可以查找先前的命令。
- ESC键用于清除当前行的命令。
- TAB键用于自动补完命令。例如一些符号可以只输入一部分,然后通过按下TAB一次或多次来找到需要的符号。
- 鼠标右键点击命令窗口,可以将剪贴板中的内容粘贴到命令输入框中。
- 直接按下ENTER键重复上一条命令。这个功能在WinDbg中可以通过命令来打开或关闭。
- 如果某条命令产生了很长的输出,可以按下CTRL+BREAK来中断它。
-
控制调试目标的执行
这里的控制目标执行,主要是指如何让运行中的目标中断到调试器中,以及控制中断的目标如何继续执行。
-
中断调试目标
当调试目标处于运行状态时,WinDbg是不能输入命令或者对它进行操作的。可以通过按下CTRL+BREAK或者 点击工具栏的按钮来中断它。下面我们继续用上一篇中的TestDebug1项目来说明。修改TestDebug1.cpp如下:
#include "stdafx.h"
#include <stdio.h>
int main(int argc, char* argv[])
{
int i = 0;
while( 1)
{
printf( "TestDebug1.cpp:%d\r\n", i);
}
return 0;
}
为了方便,这次使用Debug选项来重新编译它,这样就不用再设置编译选项和WinDbg选项来查看符号了。使用WinDbg菜单的File->Open Executable…打开TestDebug1.exe,中断下来之后F5继续运行。由于是个死循环,所以目标不会自己停止下来,可以看到WinDbg的调试器命令窗口一直处于禁用状态。在WinDbg窗口按下CTRL+BREAK,TestDebug1.exe就中断到调试器中了,使用u命令查看当前正在执行的代码,k命令查看当前调用堆栈:
看调用堆栈和反汇编出来的代码,似乎和TestDebug1.cpp中的代码没有任何关系,这是为什么呢?
注意到底部提示符位置显示的是0:001>,说明这是1号线程,而正常情况下线程编号都是从0开始的。我们继续用~命令来查看被调试进程中的线程信息,出现的是类似这样的输出:
0:001> ~
0 Id: 1998.1358 Suspend: 1 Teb: 7ffde000 Unfrozen
. 1 Id: 1998.17f8 Suspend: 1 Teb: 7ffdd000 Unfrozen
每一行是一个线程的信息。第一行中,0表示这个进程的编号;1998.1358是16进制数字,前者是当前进程的进程ID,后者是线程ID;后面的信息是线程状态和Teb地址。第二行的线程编号前有一个点号".",表示这是当前线程,也就是刚才使用u和k命令查看到的线程。
我们的代码中并没有任何创建线程的操作,为什么会多出一个线程来呢?这是由于WinDbg中断运行中的调试目标的方式造成的。按下CTRL+BREAK之后,WinDbg会在调试目标的进程中创建一个远线程,并在这个远线程中执行ntdll!DbgBreakPoint函数,即上面u命令所显示出来的内容。它会在目标进程中产生一次int3异常,这个异常被WinDbg捕获,所以TestDebug1.exe就中断到调试器中了。因此,当采用CTRL+BREAK这种方式中断目标之后,看到的代码是在这个远线程中的,如果要查看调试目标正在执行的代码就需要切换当前线程。可以使用~Thread s命令。如下:
这里就可以清楚看到在main函数中的print调用产生的调用堆栈了。
除了采用CTRL+BREAK这样直接中断运行中目标的方式之外,当调试目标发生异常、退出或者遭遇断点等事件时,也会自动中断到调试器中。这时就不会出现额外的线程了。内核调试时中断目标机的操作和用户模式下一样。
-
控制目标的执行
调试目标中断之后,就可以通过单步或者跟踪指令来控制它执行了。
WinDbg中的单步操作快捷键和Visual Studio调试器中相同。也是F5运行、F10逐过程单步、F11逐语句单步。需要注意的是,单步的定义在汇编模式调试和源码模式调试时是不一样的。汇编模式调试时,每次单步执行一条指令;源码模式调试时,每次单步执行一行源码。点击工具栏上的按钮或使用l-t命令来启用汇编模式;点击工具栏上的或使用l+t命令来启用源码模式。
控制目标执行的命令分为三大类。g*类的命令用于直接运行目标、p*类的命令用于单步执行、t*类的命令类似p*命令,但是当遇到call指令时会跟踪进去。下面是这些命令的列表,摘自WinDbg帮助文档:
-
使用断点
合理、巧妙的设置断点是软件调试中的一门艺术,好的断点能使调试工作事办功倍。WinDbg中提供了丰富的断点命令,下面通过示例对这些命令进行简单的介绍。
在上面的项目中,添加了一个dll项目,名为TestDebugDll1。修改一下上面的TestDebug1.cpp如下(整个项目可以下载附件):
#include "stdafx.h"
#include <stdio.h>
#include <windows.h>
class CTestClass
{
public:
CTestClass(){};
~CTestClass(){};
void SetChar( unsigned char ucChar)
{
m_ucTestChar = ucChar;
}
protected:
unsigned char m_ucTestChar;
};
int main(int argc, char* argv[])
{
typedef int (*pfnTestDllAdd)( int a, int b);
int i;
HMODULE hMod = LoadLibraryA( "TestDebugDll1.dll");
pfnTestDllAdd TestDllAdd = (pfnTestDllAdd)::GetProcAddress( hMod, "TestDllAdd");
if ( TestDllAdd)
{
i = TestDllAdd( 1, 2);
}
CTestClass objTestClass;
objTestClass.SetChar( 123);
return 0;
}
还是使用Debug选项,重新编译。用WinDbg打开TestDebug1.exe后会自动中断到初始断点。由于是Debug选项编译的,所以这里可以省去符号路径的设置就能识别符号。
bp命令是最常用的断点命令之一,它可以直接对某个代码地址设置断点。例如我们想中断到main函数,可以这样:
0:000> bp TestDebug1!main
前面的TestDebug1明确指定main符号所在的模块,这样通常可以减少搜索符号的时间,也避免了相同名字的符号可能造成的冲突。F5运行,就发现已经中断到main函数了,并且源码窗口会自动弹出来。
在源码窗口或者返汇编窗口中,可以将光标移动到要设置断点的行并用F9快捷键来设置断点。这和Visual Studio中一样。现在我们在HMODULE hMod = LoadLibraryA( "TestDebugDll1.dll");这一行处按下F9 设置一个断点。可以看到源码窗口中将当前正中断到的断点和未触发的断点用不同的颜色标识出来:
bl命令用于查看已存在的断点:
0:000> bl
0 e 00401030 [C:\Users\NetRoc\Desktop\TestDebug1\TestDebug1.cpp @ 23] 0001 (0001) 0:**** TestDebug1!main
1 e 0040105d [C:\Users\NetRoc\Desktop\TestDebug1\TestDebug1.cpp @ 27]
如上面命令输出中的第二行,1表示断点ID。当使用bd命令禁用断点、be命令重新启用断点或者其他命令来操作这个断点时,都需要用到这个ID;第二个"e"表示断点是启用的,如果是"d"则表示当前被禁用,如果带"u"则说明是后面将要介绍的未定断点;第三列的0040105d是该断点的地址;后面的内容是断点所在的源文件和行号。
有时候我们想要设置断点的模块还没有被加载到内存中,如这个例子中的TestDebugDll1.dll,只有在调用了LoadLibrary之后才会加载进来。如果使用bp来对这个模块中的函数设置断点,会找不到符号,这时就会被调试器自动转变成用bu命令来设置的未定断点。bu可以对还不能识别的符号设置断点,当系统中有新模块加载进来时,调试器会对未定断点再次进行识别,如果找到了匹配的符号则会设置它。现在我们首先用bc命令删除上面的1号断点,然后用bu TestDebugDll1!TestDllAdd命令对TestDebugDll1.exe中的TestDllAdd函数设置未定断点,结果如下:
第一个bl命令可以看到我们之前设置的两个断点,然后bc命令将1号断点删除。接下来使用了一次bp命令,系统提示找不到TestDebugDll1!TestDllAdd,将断点自动转换成未定断点。第三次,使用bu命令对TestDebugDll1!TestDllAdd成功设置了未定断点。最后查看存在的断点有三个。0号是最开始的断点,1号是bp命令失败后WinDbg自动转换的断点,2号是bu命令设置的。
接下来的程序会加载TestDebugDll1.dll并调用TestDllAdd函数,我们F5继续:
调试器自动打开了TestDebugDll1.dll的源文件,并且发现中断在TestDllAdd函数开头。
下面我们再试验一下对类成员函数下断和内存访问断点。继续上面的调试会话,源码中有一个类成员函数CTestClass:: SetChar(),可以直接使用符号对它设置断点。下面几条命令等效:
bp TestDebug1.exe!CTestClass::SetChar
bp TestDebug1.exe!CTestClass__SetChar
bp @@C++(TestDebug1.exe!CTestClass::SetChar)
Windows调试工具支持两种语法的表达式:MASM语法和C++语法。如果没有特别指明的话,默认是使用MASM表达式语法。一般来说,MASM语法的表达式用来表示地址比较方便,而C++表达式用来表示结构或者类成员比较方便。可以通过@@C++(…)或者@@masm(…)来包含表达式以明确指明所使用的语法。当使用MASM语法时,可以用双冒号(::)或者双下划线(__)来表示类成员;但是使用C++语法时则只能使用双冒号。
用上面的命令之一对CTestClass::SetChar设置断点并F5运行,可以看到成功中断到了CTestClass::SetChar函数处。
ba命令用于设置访问断点。访问断点可以在某个内存地址处的数据被读取、写入或者执行的时候中断下来。首先用.restart命令重新启动调试目标,并且用前面的方法之一中断到源代码中HMODULE hMod = LoadLibraryA( "TestDebugDll1.dll");这一行处。我们看到后面的代码对局部变量i有赋值操作。我们继续试着使用C++语法来使用命令,输入ba w4 @@C++(&i)命令。"&i"在C++语法中表示变量i的地址,"w"表示写入操作,"4"表示只处理&i地址处4字节的写入操作。F5运行,程序被成功中断下来:
输出中有几处值得注意的地方。第一个bl可以看到,之前已经存在了一个ID为1的断点,然后我们又使用ba设置了一个断点。在第二次bl输出重可以看到新加的断点ID为0、"w"表示是一个写断点、"4"表示写入的数据长度、要监控的内存地址为"0012ff38"。G命令之后,0号断点被触发,也就是刚才设置的数据断点。但是下面显示的当前指令却没有访问到我们设置断点的0x0012ff38。这里又涉及到WinDbg数据断点实现的原理。来通过VC的窗口看一看相关代码和对应的汇编代码:
图中的mov dword ptr [ebp-10h],eax才是对i赋值。但是断点触发后却中断到了赋值之后的下一条指令。
WinDbg的数据断点是通过CPU硬件断点实现的。而DRx寄存器所设置的内存访问断点属于陷阱(Trap)而不是错误(Fault),CPU对陷阱的处理是执行完该条指令后触发异常。因此WinDbg只能在之后的一条指令处断下来。
ba命令支持的断点种类有以下几个:
选项
|
行为
|
e (执行)
|
当CPU取指定地址的指令时中断到调试器。
|
r (读/写)
|
当CPU读写指定地址时中断到调试器。
|
w (写)
|
当CPU写指定地址时中断到调试器。
|
i (i/o)
|
(Microsoft Windows XP和之后版本、仅内核模式、仅x86系统)当指定Address的I/O端口被访问时中断到调试器。
|
e选项所指定的数据长度必须是1,即只能指定e1。r/w选项支持1、2、4的数据长度,在X64机器上可以支持8。
断点命令中可以设置一条或多条命令,当断点被触发时会自动执行它。接着上面的调试会话,使用下面的命令:
这里使用了bp CTestClass::SetChar ".echo This is the test string"命令。.echo是调试器命令的关键字,用于向调试器命令窗口输出一串字符串。这个命令的结果就是,在CTestClass:SetChar成员函数设置断点,并且在中断的时候执行.echo This is the test string命令。可以看到,g命令重新运行程序之后,断点触发时调试器命令窗口中出现了这个字符串。
WinDbg的条件断点也是采用这种方式的。通过"命令的命令"配合.if这样的命令关键字,就可以实现灵活多样的条件断点。
-
访问内存和寄存器
WinDbg可以通过命令或者GUI界面来访问内存和寄存器。常用的几条命令如下:
- 以d开头的d*系列命令用于查看内存值。命令的第二个字符用于指定按何种数据类型查看该内存中的数据,如db是按BYTE类型查看,dd是按DWORD类型查看。
重新中断到TestDebug1.exe的main函数处。用db 400000命令查看PE文件头的内容,在右边会自动列出对应的ASCII字符。直接使用d命令会按照上一次d*命令的方式来查看。如果不带地址参数,则从上一次显示结束的地方继续显示。
- ?表达式求值命令常常用来查看符号所代表的值。
- e*命令可以将值写入内存。命令第二个字符的定义和d*一样,用于指定数据类型。可以用一条命令按照顺序向指定地址写入多个值。
首先使用? i命令,它可以显示符号i对应的值,即局部变量i的地址。命令输出的等号两边分别是10进制数字和16进制数字。然后使用db 0012ff78查看变量i处的内存内容,目前的值是0x0012ffc4。eb 0012ff78 'a' 'b' 'c' 'd'命令会在从0012ff78开始的地址处依次写入后面的数值,命令执行时WinDbg会像C/C++一样自动将单引号中的ASCII字符转换为数字。最后,再通过db命令查看内存,可以看到刚才的"abcd"已经写入了。
- r 命令用于查看或者修改寄存器和伪寄存器。Windows调试工具定义了一些伪寄存器,他们不是机器上实际的寄存器,而是根据调试环境不同自动变化的值。详细可以查看帮助文档中的伪寄存器语法。
- dt命令用于查看结构。参考下面的命令序列:
首先用上一篇中介绍过的.symfix和.reload命令加载Windows符号,$peb是一个伪寄存器,调试器将它定义为当前进程的进程环境块地址。使用?或者r 命令都能看到它的内容。进程环境块是一个nt!_PEB结构,所以可以用dt来显示出当前进程的PEB内容。
- !address扩展命令可以显示指定的内存地址的信息。接着上面的调试会话,对PE文件头使用!address看看:
0:000> !address 400000
ProcessParametrs 002c14f0 in range 002c0000 002c4000
Environment 002c0808 in range 002c0000 002c4000
00400000 : 00400000 - 00001000
Type 01000000 MEM_IMAGE
Protect 00000002 PAGE_READONLY
State 00001000 MEM_COMMIT
Usage RegionUsageImage
FullPath TestDebug1.exe
这里可以看到指定的地址0x400000的内存类型、保护属性、拥有该地址的模块等等。
0:000> dv
argc = 2147328000
Type information missing error for argv
Type information missing error for objTestClass
i = 1684234849
Type information missing error for TestDllAdd
Type information missing error for hMod
main函数有些局部变量没有类型信息,这是因为VC6中默认的Debug选项编译出来之后,.pdb文件中符号信息并不完全。
-
查看调用堆栈
WinDbg中查看调用堆栈也可以通过GUI界面和命令两种方式。详细信息可以点击这里查看WinDbg帮助文档中的内容。
测试代码附件:
TestDebug1.rar