本文讲解如何在调试器中显示函数调用栈,如下图所示:

[Win32]一个调试器的实现(十一)显示函数调用栈_寄存器

 

原理

首先我们来看一下显示调用栈所依据的原理。每个线程都有一个栈结构,用来记录函数的调用过程,这个栈是由高地址向低地址增长的,即栈底的地址比栈顶的地址大。ESP寄存器的值是栈顶的地址,通过增加或减小ESP的值可以缩减或扩大栈的大小。上一篇文章已经简略地介绍过在调用函数时线程栈上会发生什么事情,现在我们再来详细地看看这个过程:

①在栈上压入参数。

②执行CALL指令,在栈上压入函数的返回地址。

③压入EBP寄存器的值。

④将ESP寄存器的值赋给EBP寄存器。

⑤减小ESP寄存器的值,为局部变量分配空间。

⑥执行函数代码。

⑦将EBP寄存器的值赋给ESP寄存器,等于回收了局部变量的空间。

⑧弹出栈顶的值,赋给EBP,即将第③步中压入的值重新赋给EBP。

⑨执行RET指令,弹出栈顶的返回地址。如果被调用函数负责回收参数的空间,则需要增加ESP的值。

完成第③步的指令是push ebp,它是所有函数的第一条指令,因此每个函数在栈上都会保存有一个EBP值,标志了一个函数调用的开始,这就像分界线一样,将每个函数调用区分开来。从一个分界线开始,到下一个分界线之间的部分称作“栈帧”,一个栈帧代表一个函数调用。

在压入了EBP的值之后,第④步立即将ESP的值赋给了EBP,此时ESP和EBP的值都是刚刚压入的值的地址。从此之后,ESP的值随着指令的执行不断变化,而EBP的值在当前栈帧中永远不会改变,一直指向当前栈帧的起始地址,所以EBP也被称为“栈帧指针”。

函数返回的时候,第⑦步将EBP的值赋给ESP,此时ESP指向第③步压入的值,然后第⑧步弹出这个值,赋给EBP,恢复EBP在上一个函数调用中的值。

 

函数调用过程的第③步和第④步使得各个压入的EBP值形成了一个链表的结构,而EBP寄存器是链表的表头,如下图所示:

[Win32]一个调试器的实现(十一)显示函数调用栈_调试_02

正是这种链表结构的存在,使得获取函数调用栈成为可能。只要从EBP寄存器开始,沿着链表层层往上,就可以得到函数调用的轨迹。

由于EBP在当前函数调用中的不变性,调试版的程序都使用EBP作为变量和参数的基址,将EBP的值与一个偏移值相加就可以得到变量或参数的地址。有些发行版的程序会对函数的调用过程进行优化,省略了压入EBP的步骤,因此不能再使用EBP作为变量和参数的基址,也不能使用EBP链表来获取函数调用栈。

 

StackWalk64

在DbgHelp中主要使用StackWalk64函数来获取函数调用栈,该函数的声明如下:

BOOL WINAPI StackWalk64(
DWORD MachineType,
HANDLE hProcess,
HANDLE hThread,
LPSTACKFRAME64 StackFrame,
PVOID ContextRecord,
PREAD_PROCESS_MEMORY_ROUTINE64 ReadMemoryRoutine,
PFUNCTION_TABLE_ACCESS_ROUTINE64 FunctionTableAccessRoutine,
PGET_MODULE_BASE_ROUTINE64 GetModuleBaseRoutine,
PTRANSLATE_ADDRESS_ROUTINE64 TranslateAddress
);


该函数的参数比较多,这意味着灵活性,同时也意味着复杂性。事实上StackWalk64有多种不同的使用方式,使用何种方式由传入的参数决定。在这里我只介绍一种最简单的方式,这种方式已经足够了。如果想了解更多有关StackWalk64的信息,请参考MSDN。

 

MachineType参数指定CPU的类型,它的取值范围以及意义如下表所示(摘自MSDN):

Value

Meaning

IMAGE_FILE_MACHINE_I386

Intel x86

IMAGE_FILE_MACHINE_IA64

Intel Itanium Processor Family (IPF)

IMAGE_FILE_MACHINE_AMD64

x64 (AMD64 or EM64T)

调试器需要根据CPU的类型来设置该参数的值。在目前,大部分情况下都是设置为IMAGE_FILE_MACHINE_I386。

 

hProcess和hThread分别指定被调试进程的进程句柄以及线程句柄。而且在当前所使用的方式下,hProcess必须是符号处理器的标识符。如果在调用SymInitialize创建符号处理器时使用的就是进程的句柄,那么在这里不会有任何问题;如果不是使用进程句柄,那么就必须用另一种方式调用StackWalk64了。

 


StackFrame参数是一个 STACKFRAME64结构体的指针,在调用 StackWalk64之前需要初始化这个结构体,函数调用成功后,前一个栈帧的信息会保存到该结构体中;然后用这些信息再次调用 StackWalk64,以获取再前一个栈帧的信息……由此看出, StackWalk64的工作就是获取指定栈帧的前一个栈帧,所以,必须要在循环中获取所有栈帧。 STACKFRAME64结构体中需要初始化的字段有三个: AddrPC, AddrStack和 AddrFrame,它们分别表示程序计数器,线程栈顶以及栈帧指针,也是 EIP, ESP和 EBP的用途。这三个字段又分别是一个 ADDRESS64结构体,这个结构体可以表示多种不同类型的地址,但 Windows应用程序只会使用虚拟地址,所以 Mode字段应设为 AddrModeFlat, Offset字段设为上述寄存器的值。 STACKFRAME64结构体的初始化代码如下所示( context为 CONTEXT结构):

STACKFRAME64 stackFrame = { 0 };
stackFrame.AddrPC.Mode = AddrModeFlat;
stackFrame.AddrPC.Offset = context.Eip;
stackFrame.AddrStack.Mode = AddrModeFlat;
stackFrame.AddrStack.Offset = context.Esp;
stackFrame.AddrFrame.Mode = AddrModeFlat;
stackFrame.AddrFrame.Offset = context.Ebp;


第一次成功调用StackWalk64后,STACKFRAME64结构体的其它字段会被设置为适当的值,而上述的三个字段不会改变。从第二次调用开始才会真正获取前一个栈帧,这三个字段才会改变。

 

ContextRecord参数是指向CONTEXT结构体的指针,调用之前需要使用GetThreadContext初始化该结构体,StackWalk64函数会使用里面的值,并有可能会修改它。

 

ReadMemoryRoutine是一个回调函数的指针,当StackWalk64函数需要读取被调试进程的内存时会调用该函数。如果不想提供这样的函数,最简单的方法就是设置该参数为NULL,这样StackWalk64就会使用默认的函数,此时hProcess必须是一个有效的进程句柄。

 

FunctionTableAccessRoutine也是一个回调函数的指针,当StackWalk64需要访问函数表时会调用该函数。简单来说,函数表保存了每一个函数的信息,比如起始地址,长度等。这个参数不能为NULL,但是我们可以将它设置为一个已有的函数,这个函数就是SymFunctionTableAccess64。此时hProcess必须是符号处理器的标识符。

 

GetModuleBaseRoutine又是一个回调函数的指针,当StackWalk64需要获取模块的基地址时会调用该函数。将该参数设置为GetModuleBase64函数即可,此时hProcess也必须是符号处理器的标识符。

 

最后的参数TranslateAddress仍然是回调函数的指针,不过该参数只用于16位地址的转换,几乎不会用到,设置为NULL即可。

 

可以看到,选择StackWalk64的何种使用方式由后面的四个参数决定。最简单的使用方式就是直接使用NULL或DbgHelp提供的函数作为这几个参数的值,不过此时对hProcess和hThread的限制最大。我们也可以自己提供这几个回调参数,此时hProcess和hThread几乎没有什么限制,它们只是作为唯一标识符。

 

StackWalk64调用成功后,STACKFRAME64结构体被赋值,在众多的字段中,我们只需要关心AddrPC,它表示栈帧的返回地址(除了第一次调用StackWalk64之外),即CALL指令下一条指令的地址,本文开头的图片中显示的地址就是AddrPC.Offset的值。由于返回地址是指向前一个栈帧的,所以每次调用StackWalk64都会使STACKFRAME64结构体填充前一个栈帧的信息。StackWalk64只能获取用户模式下的栈帧,如果栈帧遍历完毕,它会返回FALSE。

 

获取函数名称

STACKFRAME64结构体的AddrPC字段的值肯定是某个函数内的地址,所以可以用这个字段的值来调用SymFromAddr获取函数的信息,包括函数名称。关于SymFromAddr函数的用法,前面的文章已经介绍过了,这里不再重复。

 

获取模块名称

显示函数调用栈时最好同时显示函数所在的模块,这样可以方便知道每个函数位于哪个模块。有一个

SymGetModuleInfo64函数可以获取模块的信息,但是却不可以获取模块的名称,而另一个 SymEnumerateModules64函数可以做到这点,虽然它的使用方式比较麻烦。 SymEnumerateModules64用于枚举所有已经加载到符号处理器中的模块,它的声明如下:

1 BOOL WINAPI SymEnumerateModules64(
2 HANDLE hProcess,
3 PSYM_ENUMMODULES_CALLBACK64 EnumModulesCallback,
4 PVOID UserContext
5 );

第一个参数是符号处理器的标识符。第二个参数是一个回调函数的指针,对于每个模块都会调用这个函数。该回调函数的声明如下:

1 BOOL CALLBACK SymEnumerateModulesProc64(
2 PCSTR ModuleName,
3 DWORD64 BaseOfDll,
4 PVOID UserContext
5 );


ModuleName是模块文件的绝对路径;BaseOfDll是模块的基地址;UserContext就是SymEnumerateModules64的第三个参数,可以通过这个参数给回调函数传递更多信息。

可以这样使用SymEnumerateModules64函数:使用STL的map创建模块的基址-名称映射表,在回调函数中往这个表中添加记录。然后使用SymGetModuleBase64函数获取模块的基地址,使用这个基地址从表中查找模块的名称。具体的做法请参考示例代码。

 

示例代码

MiniDebugger中新增了一个命令:

w

显示函数调用栈。

----------

本文是《一个调试器的实现》系列文章的最后一篇。实现一个调试器不是简单的事情,毕竟这不是主流的应用,相关的文档非常匮乏,只能靠自己不断地摸索前进。我在实现MiniDebugger的过程中遇到了非常多的困难,在解决这些困难的过程中有很多心得体会,于是将它们写成了这一系列文章,与大家分享我的经验,希望能让大家少走弯路,节省宝贵的时间。虽然最终实现的MiniDebugger非常丑陋,但是透过它所表达出来的技术原理,一定能帮助大家实现一个更优秀的调试器。对技术的追求,是我不断前进的动力。

作者:

​​Zplutor​​