[Win32]一个调试器的实现(十)显示变量

上回介绍了微软的符号模型,有了这个基础知识,这回我们向MiniDebugger中添加两个新功能,分别是显示变量列表和以指定类型显示内存内容。显示变量列表用于列出当前函数内的局部变量或者全局变量;以指定类型显示内存内容用于读取指定地址处的内存内容,然后将这些二进制数据按照类型的格式解析成可读的内容并显示出来。如下面的截图所示:

 

使用lv命令显示局部变量时,每一列从左到右分别是:类型,名称,长度,地址,值。只有基本类型、枚举类型以及指针类型的变量才会显示它的值,对于数组类型和UDT类型的变量则不显示它的值。获取这些变量的值需要使用f命令,该命令遵守同样的显示规则。下面介绍这两个命令的实现方法。

 

枚举全局变量

DbgHelp提供了SymEnumSymbols函数来枚举符号,它既可以枚举全局的符号,也可以枚举当前作用域内的符号。我们首先来看一下如何使用它来枚举全局变量。该函数的声明如下:

1 BOOL WINAPI SymEnumSymbols(
2     HANDLE hProcess,
3     ULONG64 BaseOfDll,
4     PCTSTR Mask,
5     PSYM_ENUMERATESYMBOLS_CALLBACK EnumSymbolsCallback,
6     PVOID UserContext
7 );

 

hProcess参数是符号处理器的标识符。BaseOfDll参数指定模块的基地址,SymEnumSymbols函数会枚举该模块内的所有全局符号。为了获取当前模块的基地址,可以使用SymGetModuleBase64函数,该函数的声明如下:

1 DWORD64 WINAPI SymGetModuleBase64(
2     HANDLE hProcess,
3     DWORD64 dwAddr
4 );

第一个参数是符号处理器的标识符。第二个参数是当前EIP的值,可以使用GetThreadContext来获取。

 

Mask参数是一个字符串,只有名称与该字符串匹配的符号才会被枚举,在字符串中允许使用通配符*?

 

EnumSymbolsCallback参数是一个回调函数的指针,对于每个被枚举的符号都会调用该函数。该函数的声明如下:

1 BOOL CALLBACK SymEnumSymbolsProc(
2     PSYMBOL_INFO pSymInfo,
3     ULONG SymbolSize,
4     PVOID UserContext
5 );

第一个参数是指向SYMBOL_INFO的指针,有关符号的信息都在该结构体中。第二个参数是符号的长度,对于变量来说,SYMBOL_INFO结构体中的Size字段是无效的,这里的SymbolSize参数才是变量的长度。第三个参数UserContext其实就是SymEnumSymbols的最后一个参数,如果想要给SymEnumSymbolsProc传递额外的信息,可以通过这个参数来传递。SymEnumSymbolsProc必须返回TRUE,整个枚举过程才会成功,如果在某次回调SymEnumSymbolsProc的过程中返回FALSE,枚举过程就会中断,SymEnumSymbols也会返回FALSE

 

要注意的是,SymEnumSymbols会枚举所有符合条件的符号,包括函数,变量等。所以需要在回调函数中检查pSymInfo->Tag是否等于SymTagData

 

枚举局部变量

枚举局部变量也是使用SymEnumSymbols函数,不同的是,BaseOfDll参数要为0,而且要事先调用SymSetContext函数。该函数的作用是设置枚举符号时使用的作用域,它的声明如下:

1 BOOL WINAPI SymSetContext(
2     HANDLE hProcess,
3     PIMAGEHLP_STACK_FRAME StackFrame,
4     PIMAGEHLP_CONTEXT Context
5 );

第一个参数是符号处理器的标识符。第二个参数是这个函数的关键,它是一个指向IMAGEHLP_STACK_FRAME结构体的指针,这个结构体确定了一个作用域,它有多个字段,然而目前只有InstructionOffset字段会用到,将当前EIP的值赋给它即可,其它字段应设为0。第三个参数不会用到,设为NULL即可。

 

如果调用SymSetContext时指定的作用域与调用之前的作用域相同,函数会返回FALSE,而GetLastError()会返回ERROR_SUCCESS,此时并不意味着函数调用失败,所以在SymSetContext返回FALSE时还要进一步检查GetLastError()的返回值。

 

枚举局部变量时还有一个问题需要注意,在回调函数中,pSymInfo->Address的值并不是变量的虚拟地址,而是相对于某个寄存器的偏移地址,pSymInfo->Flags中的SYMFLAG_REGREL标志指明了这个情况。运行在Intel x86兼容架构下的调试版程序总是使用EBP寄存器的值作为基址。在汇编级别上,函数的调用过程大致如下所示:

1 push eax  ;在栈上压入数据,以传递参数
2 push ebx
3 call func  ;调用函数,在栈上压入函数的返回地址
4 
5 ; func函数的入口
6 push ebp  ;保存ebp的值
7 mov ebp, esp  ;将esp的值赋给ebp
8 sub esp, 8  ;为局部变量分配空间

执行完上面的汇编语句之后,线程栈的内容如下图所示(每个矩形代表4个字节):

 

在函数的执行过程中,ESP的值是不断变化的,而EBP的值一直不会改变(除非函数返回),所以使用EBP作为基址更方便。在SymEnumSymbolsProc函数中,需要检查pSymbolInfo->Flags字段是否含有SYMFLAG_REGREL标志,如果有,则将pSymbolInfo->AddressEBP相加,得到变量的虚拟地址。但是,在某些情况下有例外,当被调试进程刚刚执行了CALL语句时,进入了函数的作用域,此时SymEnumSymbols可以枚举到函数内的局部变量。但这时还未执行PUSH EBPMOV EBP, ESP指令,EBP仍然是上一个函数中的值,如果将EBPpSymbolInfo->Address相加,肯定得到错误的结果。为了避免这个问题,需要检查当前EIP是否指向函数的第一条指令,如果是,则不能使用EBP,而应该使用ESP-4作为基址。参考上图,在执行第一条指令之前,ESP-4就是执行了PUSH EBPMOV EBP, ESP之后EBP的值。另外,PUSH EBPMOV EBP, ESP总是在同一源代码语句中,所以源代码级别的调试器不用担心EIP指向MOV EBP, ESP指令的情况。下面的代码展示了如何获取变量的地址(在枚举全局变量和局部变量时都用到该函数):

 1 //获取符号的虚拟地址
 2 //如果符号是一个局部变量或者参数
 3 //pSymbol->Address是相对于EBP的偏移,
 4 //将两者相加就是符号的虚拟地址
 5 DWORD GetSymbolAddress(PSYMBOL_INFO pSymbolInfo) {
 6 
 7     if ((pSymbolInfo->Flags & SYMFLAG_REGREL) == 0) {
 8         return DWORD(pSymbolInfo->Address);
 9     }
10 
11     //如果当前EIP指向函数的第一条指令,则EBP的值仍然是属于
12     //上一个函数的,所以此时不能使用EBP,而应该使用ESP-4作
13     //为符号的基地址
14 
15     CONTEXT context;
16     GetDebuggeeContext(&context);
17 
18     //获取当前函数的开始地址
19     DWORD64 displacement;
20     SYMBOL_INFO symbolInfo = { 0 };
21     symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO);
22 
23     SymFromAddr(
24         GetDebuggeeHandle(),
25         context.Eip,
26         &displacement,
27         &symbolInfo);
28 
29     //如果是函数的第一条指令,则不能使用EBP
30     if (displacement == 0) {
31         return DWORD(context.Esp - 4 + pSymbolInfo->Address);
32     }
33 
34     return DWORD(context.Ebp + pSymbolInfo->Address);
35 }

 

变量的名称、地址、长度等属性可以直接在SymEnumSymbolsProc回调函数中获取,至于变量类型名称和值的获取则不是那么容易,下面分别讲解如何获取这两个属性。

 

获取变量类型名称

SYMBOL_INFO结构体的TypeIndex字段指明了变量的类型ID,通过这个ID就可以知道变量所属类型的所有信息。由于每种类型的属性都不相同,其处理方法也不同,所以要先获取类型的种类,然后根据它的种类进行不同的处理。获取类型种类的方法是调用SymGetTypeInfo,对第三个参数传入TI_GET_SYMTAGpInfo的类型是DWORD*。调用成功之后得到一个SymTagEnum值,该值可能是这些值中的一个:SymTagBaseTypeSymTagPointerTypeSymTagArrayTypeSymTagUDTSymTagEnumSymTagFunctionType。由上一篇文章介绍的符号模型可以知道,每种类型之间可能存在嵌套关系,即一种类型内可能包含一个或多个其它的类型,所以对类型的处理必然是一个递归的过程,如下面的代码所示:

 1 std::wstring GetTypeName(int typeID, DWORD modBase) {
 2 
 3     DWORD typeTag;
 4     SymGetTypeInfo(
 5         GetDebuggeeHandle(),
 6         modBase,
 7         typeID,
 8         TI_GET_SYMTAG,
 9         &typeTag);
10 
11     switch (typeTag) {
12         
13         case SymTagBaseType:
14             return GetBaseTypeName(typeID, modBase);
15 
16         case SymTagPointerType:
17             return GetPointerTypeName(typeID, modBase);
18 
19         case SymTagArrayType:
20             return GetArrayTypeName(typeID, modBase);
21 
22         case SymTagUDT:
23             return GetUDTTypeName(typeID, modBase);
24 
25         case SymTagEnum:
26             return GetEnumTypeName(typeID, modBase);
27 
28         case SymTagFunctionType:
29             return GetFunctionTypeName(typeID, modBase);
30 
31         default:
32             return L"??";
33     }
34 }

在每个GetXXXTypeName函数中,又可能会调用GetTypeName,以此形成一个递归过程。

 

下面介绍如何得到每种类型的名称,具体的方法请参考示例代码。

 

SymTagBaseType

基本类型没有名称,我们要根据它的属性判断出究竟是哪种C/C++基本类型(上一篇文章有介绍),然后查表找出它的名称。可以定义下面的枚举表示C/C++基本类型:

 1 enum CBaseTypeEnum {
 2    cbtNone,
 3    cbtVoid,
 4    cbtBool,
 5    cbtChar,
 6    cbtUChar,
 7    cbtWChar,
 8    cbtShort,
 9    cbtUShort,
10    cbtInt,
11    cbtUInt,
12    cbtLong,
13    cbtULong,
14    cbtLongLong,
15    cbtULongLong,
16    cbtFloat,
17    cbtDouble,
18    cbtEnd,
19 };

 

然后构造一张基本类型名称表:

 1 struct BaseTypeEntry {
 2    CBaseTypeEnum type;
 3    const LPCWSTR name;
 4 } g_baseTypeNameMap[] = {
 5    { cbtNone, TEXT("<no-type>") },
 6    { cbtVoid, TEXT("void") },
 7    { cbtBool, TEXT("bool") },
 8    { cbtChar, TEXT("char") },
 9    { cbtUChar, TEXT("unsigned char") },
10    { cbtWChar, TEXT("wchar_t") },
11    { cbtShort, TEXT("short") },
12    { cbtUShort, TEXT("unsigned short") },
13    { cbtInt, TEXT("int") },
14    { cbtUInt, TEXT("unsigned int") },
15    { cbtLong, TEXT("long") },
16    { cbtULong, TEXT("unsigned long") },
17    { cbtLongLong, TEXT("long long") },
18    { cbtULongLong, TEXT("unsigned long long") },
19    { cbtFloat, TEXT("float") },
20    { cbtDouble, TEXT("double") },
21    { cbtEnd, TEXT("") },
22 };

 

通过下面的代码来查找基本类型的名称:

 1 std::wstring GetBaseTypeName(int typeID, DWORD modBase) {
 2 
 3     CBaseTypeEnum baseType = GetCBaseType(typeID, modBase);
 4 
 5     int index = 0;
 6 
 7     while (g_baseTypeNameMap[index].type != cbtEnd) {
 8 
 9         if (g_baseTypeNameMap[index].type == baseType) {
10             break;
11         }
12 
13         ++index;
14     }
15 
16     return g_baseTypeNameMap[index].name;
17 }

 

SymTagPointerType

指针类型也没有名称,但是它的处理过程很简单,只要调用GetTypeName获取被指类型的名称,然后根据“是否引用”属性在名称的后面加上*或者&即可。

 

SymTagArrayType

数组类型同样没有名称,它的处理过程也是很简单的。首先调用GetTypeName获取数组元素的类型名称,然后获取数组元素个数,最后在名称的最后面加上一对方括号,方括号内是元素个数,例如int[5]

 

SymTagUDT

SymTagEnum

UDT类型和枚举类型都有名称,因此直接通过SymGetTypeInfo就可以获取到这两个类型的名称。

 

SymTagFunctionType

只有当处理函数指针的时候才会在GetTypeName中遇到这种类型。这种类型没有名称,必须先获取返回值和形参的类型名称(返回值和形参的获取方法在上一篇文章有介绍),然后将它们以某种方式组合到一起。我使用的组合方式仿照了Visual Studio 2010的方式,例如int (*pFunc)(int, int)这个函数指针变量,它的类型是int(int, int)*

 

最后说明一下,使用DbgHelp不能知道某个变量是否const,也就是说,定义类型为const int的变量,在调试器中显示的类型是int。对于这个问题,有人可能会觉得不能理解,但实际上忽略const是有道理的。const的作用是给变量施加一个只读的约束,在编译阶段可以找出非法的赋值操作,一旦通过了编译阶段,const就几乎没有作用了,因为此时不会有代码可以修改const变量。调试器更关注的是变量的实际类型和它的值,而不会关注它是否可以修改,所以有没有const对调试器没有什么影响。另外,忽略const可以减少调试符号所占用的空间,简化符号模型以及相关的API。同样的论述也适用于mutablevolatile变量。

 

另外,在调用SymEnumSymbols枚举变量时,如果遇到使用typedef类型定义的变量,pSymbolInfo->TypeIndex是实际类型的ID,而不是typedef类型的ID。例如,某个变量被定义为BOOL类型,在它被枚举的时候,pSymbolInfo->TypeIndexint类型的ID,而不是BOOL类型的ID。由于这个原因,在显示使用了STL类型的变量时会输出很长的类型名称,这的确是一个很大的遗憾,

 

获取变量的值

获取变量的值实质上是从指定地址处的内存中读取指定长度的数据,然后根据指定的类型将二进制值解析成可读的字符串。通过lv命令枚举变量时已经得到了变量的地址、长度和类型,而f命令需要由用户指定地址和类型,除此之外这两个命令几乎一模一样。这里先讲解一下如何根据类型名称获取类型信息,可以通过SymGetTypeFromName函数来实现这个目的,该函数的声明如下:

1 BOOL WINAPI SymGetTypeFromName(
2     HANDLE hProcess,
3     ULONG64 BaseOfDll,
4     PCTSTR Name,
5     PSYMBOL_INFO Symbol
6 );

第一个参数是符号处理器的标识符。第二个参数是符号所在模块的基地址,函数会从该模块中寻找名称匹配的类型。第三个参数指定类型的名称。最后一个参数是指向SYMBOL_INFO结构体的指针,类型的信息通过这个结构体来返回。在调用SymGetTypeFromName之前需要设置SYMBOL_INFOSizeOfStruct字段为sizeof(SYMBOL_INFO);如果不想获取类型的名称,则MaxNameLen也要设置为0。如果函数调用成功,SYMBOL_INFOTypeIndexIndexSizeModBase字段会被赋值。TypeIndexIndex的值相同,都是类型的IDSize是该类型在内存中的大小;ModBase是该类型所在模块的基地址。

 

SymEnumSymbols不同,SymGetTypeFromName可以获取typedef类型的信息。例如,调用SymGetTypeFromName时第三个参数传入”BOOL”,函数会成功返回,此时需要进一步获取实际类型的ID

 

要注意,SymGetTypeFromName只能获取到具有名称的类型,例如枚举类型、UDT类型和typedef类型。对于没有名称的类型,如基本类型、指针类型和数组类型,不能使用该函数。所以在实现f命令的时候,需要自定义这些类型的名称,具体做法请参考示例代码。

 

有了地址和长度就可以通过ReadProcessMemory读取内存。接下来的事情就是将二进制的内容解析成可读的字符串,这也是一个递归的过程,但为了避免输出太多内容,递归的深度应该限制为2。该函数的代码如下所示: 

 1 std::wstring GetTypeValue(int typeID, DWORD modBase, DWORD address, const BYTE* pData) {
 2 
 3     DWORD typeTag;
 4     SymGetTypeInfo(
 5         GetDebuggeeHandle(),
 6         modBase,
 7         typeID,
 8         TI_GET_SYMTAG,
 9         &typeTag);
10 
11     switch (typeTag) {
12         
13         case SymTagBaseType:
14             return GetBaseTypeValue(typeID, modBase, pData);
15 
16         case SymTagPointerType:
17             return GetPointerTypeValue(typeID, modBase, pData);
18 
19         case SymTagEnum:
20             return GetEnumTypeValue(typeID, modBase, pData);
21 
22         case SymTagArrayType:
23             return GetArrayTypeValue(typeID, modBase, address, pData);
24 
25         case SymTagUDT:
26             return GetUDTTypeValue(typeID, modBase, address, pData);
27 
28         case SymTagTypedef:
29 
30             //获取真正类型的ID
31             DWORD actTypeID;
32             SymGetTypeInfo(
33                 GetDebuggeeHandle(),
34                 modBase,
35                 typeID,
36                 TI_GET_TYPEID,
37                 &actTypeID);
38 
39             return GetTypeValue(actTypeID, modBase, address, pData);
40 
41         default:
42             return L"??";
43     }
44 }

 

下面介绍如何解析每种类型的值。

 

SymTagBaseType

对于基本类型,同样要先根据它的属性来确定是哪种C/C++基本类型,然后将二进制值转换成相应类型的值,再利用CC++提供的库函数将其转换成字符串。如下所示:

 1 std::wstring GetCBaseTypeValue(CBaseTypeEnum cBaseType, const BYTE* pData) {
 2 
 3     std::wostringstream valueBuilder;
 4 
 5     switch (cBaseType) {
 6 
 7         case cbtNone:
 8             valueBuilder << TEXT("??");
 9             break;
10 
11         case cbtVoid:
12             valueBuilder << TEXT("??");
13             break;
14 
15         case cbtBool:
16             valueBuilder << (*pData == 0 ? L"false" : L"true");
17             break;
18 
19         case cbtChar:
20             valueBuilder << ConvertToSafeChar(*((char*)pData));
21             break;
22 
23         case cbtUChar:
24             valueBuilder << std::hex 
25                          << std::uppercase 
26                          << std::setw(2
27                          << std::setfill(TEXT('0')) 
28                          << *((unsigned char*)pData);
29             break;
30 
31         case cbtWChar:
32             valueBuilder << ConvertToSafeWChar(*((wchar_t*)pData));
33             break;
34 
35         case cbtShort:
36             valueBuilder << *((short*)pData);
37             break;
38 
39         case cbtUShort:
40             valueBuilder << *((unsigned short*)pData);
41             break;
42 
43         case cbtInt:
44             valueBuilder << *((int*)pData);
45             break;
46 
47         case cbtUInt:
48             valueBuilder << *((unsigned int*)pData);
49             break;
50 
51         case cbtLong:
52             valueBuilder << *((long*)pData);
53             break;
54 
55         case cbtULong:
56             valueBuilder << *((unsigned long*)pData);
57             break;
58 
59         case cbtLongLong:
60             valueBuilder << *((long long*)pData);
61             break;
62 
63         case cbtULongLong:
64             valueBuilder << *((unsigned long long*)pData);
65             break;
66 
67         case cbtFloat:
68             valueBuilder << *((float*)pData);
69             break;
70 
71         case cbtDouble:
72             valueBuilder << *((double*)pData);
73             break;
74     }
75 
76     return valueBuilder.str();
77 }

上面的函数利用C++wostringstream类执行这个转换。ConvertToSafeCharConverToSafeWChar函数分别用来将charwchar_t字符转换成可以在控制台上显示的字符,这是因为有些字符不能在控制台上显示,如果不进行处理的话会造成一些麻烦。具体的转换方法请参考示例代码。

 

SymTagPointerType

对于指针类型来说,它的值其实就是地址,所以只要将二进制值转换成无符号整数,然后以十六进制输出即可。

 

SymTagArrayType

数组的值就是每个元素的值,需要对每个元素调用GetTypeValue。为了避免数组太大导致输出过多,最好设置一个上限,例如只输出前32个元素的值。剩下的元素要用f命令来查看。

 

SymTagEnum

对于枚举类型,首先要知道它是哪种基本类型,然后将二进制值转换成这种基本类型,与每个枚举值进行比较,如果找到相等的枚举值,则获取枚举值的名称并返回;如果没有找到,则返回基本类型的值。关于枚举类型在上一篇文章中已经有详细的介绍。

 

SymTagUDT

对于UDT类型,需要枚举所有成员,并输出成员变量的值。因为UDT实际上就是一个新的作用域,所以成员变量的输出方式与普通变量的输出方式应该相同,即依次输出类型,名称,长度,地址,对于基本类型、指针类型和枚举类型则要同时输出它的值。对于拥有基类的类类型,只输出它自己的成员变量,不必输出基类部分的成员变量,避免输出过多。在子类中有一个特殊的SymTagBaseClass类型的成员,输出这个成员以表示基类部分。具体做法请参考示例代码。

 

SymTagTypedef

上文提到SymGetTypeFromName会获取到typedef类型,所以要对这种类型进行处理。处理过程也非常简单,只要获取实际的类型ID,递归调用一次GetTypeValue即可。

 

本文的相关内容涉及到很多细节问题,由于个人表达能力有限,不能在文中一一细说,请大家参考示例代码。

 

示例代码

MiniDebugger新增了以下三个命令:

 

gv [expression]

显示当前模块内的全局变量。expression是一个通配符表达式,只有名称符合该表达式的变量才会被显示。如果省略expression,则显示所有全局变量。

 

lv [expression]

显示当前作用域内的局部变量和函数实参。expression的意义同上。

 

f address type [count]
以指定类型的格式显示指定地址处的内存。address指定十六进制的地址;type指定类型名称;count指定显示次数,用于数组的显示。如果type为“*”,则以指针格式显示内容,C/C++基本类型使用的名称如下表所示:

名称

C/C++基本类型

bool

bool

char

char

wchar

wchar_t

short

short

ushort

unsigned short

int

int

uint

unsigned int

long

long

ulong

unsigned long

llong

long long

ullong

unsigned long long

float

float

double

double

 

https://files.cnblogs.com/zplutor/MiniDebugger10.rar

posted on 2011-04-22 18:44  Zplutor  阅读(6911)  评论(5编辑  收藏  举报