一、 调试器命令窗口
1、 简介
使用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 来中断它。
二、 控制调试目标的执行
这里的控制目标执行,主要是指如何让运行中的目标中断到调试器中,以及控制中断
的目标如何继续执行。
1. 中断调试目标
当调试目标处于运行状态时,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 这样直接中断运行中目标的方式之外,当调试目标发生异常、退
出或者遭遇断点等事件时,也会自动中断到调试器中。这时就不会出现额外的线程了。内
核调试时中断目标机的操作和用户模式下一样
2. 控制目标的执行
调试目标中断之后,就可以通过单步或者跟踪指令来控制它执行了。
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 选项所指定的数据长度必须是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 的内存类型、保护属性、拥有该地址的模块等
等。
dv 命令可以查看当前作用域下局部变量的类型和值:
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 文件中符号信息并不完全。