过程本来想按照 sos 的帮助文件上命令的分类逐步介绍 WinDbg 下使用 sos 调试 CLR 程序,但发现这样实在不够直观。索性改成根据我分析 CLR 的实际案例,step by step 介绍功能,这样结构上虽然混乱一点,但更加直观,也易于上手 :P
前面两篇文章里面分别介绍了 WinDbg 的调试配置和线程的基本概念,这篇文章将针对 JIT 编译对象方法的流程进行分析,逐步介绍如何使用 WinDbg 调试 CLR 程序。
用WinDbg探索CLR世界 [1] - 安装与环境配置
用WinDbg探索CLR世界 [2] - 线程
首先写一个简单的例子程序 demo.cs 并编译为 demo.exe,使用配置好的 WinDbg 打开之:
以下为引用:
using System;
namespace flier
{
class EntryPoint
{
public void m1()
{
System.Console.Write("EntryPoint.m1()");
}
public void m2()
{
System.Console.Write("EntryPoint.m2()");
}
public static void Main()
{
EntryPoint ep = new EntryPoint();
ep.m1();
ep.m2();
}
}
}
WinDbg 会在载入 demo.exe 后中断执行。此时可以使用 .load sos 命令加载 sos.dll 命令扩展,并用 .chain 验证加载是否成功;然后用 ld demo 命令加载 demo.exe 的调试符号文件,用 lm 命令验证加载是否成功。
然后用 ld kernel32 加载 Kernel32 的调试符号文件,并用 bp kernel32!LoadLibraryExW "du poi(esp+4)" 命令在载入 DLL 的函数入口加上断点。接下来就是一路 g 指令,直到 mscorwks.dll 被加载。这个 mscorwks.dll 就是类似 JVM 中 jvm.dll 的虚拟机实现代码,我们要了解的大部分功能都在其中。详细的解释可以参看我以前的一篇文章《.Net平台下CLR程序载入原理分析》
在 mscorwks.dll 被载入后用 ld mscorwks 命令载入其调试符号库,就可以正式开始我们的探索工作了 :D
目前使用到的 WinDbg 命令如下
以下为引用:
.load sos // 加载 sos 调试扩展模块,可使用 .chain 命令验证
ld demo // 加载 demo.exe 调试符号库,可使用 lm 命令验证
ld kernel32 // 加载 kernel32.exe 调试符号库
bp kernel32!LoadLibraryExW "du poi(esp+4)" // 设置断点监视何时 mscorwks.dll 被载入
g // 执行直到 mscorwks.dll被加载
bd 0 // 清除前面设置的断点,开始对 mscorwks.dll 进行处理
ld mscorwks // 加载 mscorwks.dll 调试符号库
Don Box 在《.NET本质论 第1卷:公共语言运行库》的第六章介绍了方法调用的内部实现流程。其中提到方法表在 JIT 之前,保存的都是 call mscorwks.dll!PreStubWorker 调用,直到第一次使用时,才会对目标 IL 代码进行 JIT 编译,并调用之。因此我们第一步可以在此函数上设置断点(bp mscorwks!PreStubWorker),看看系统是如何调用此函数的。
以下为引用:
0:000> bp mscorwks!PreStubWorker
0:000> g
ModLoad: 70ad0000 70bb6000 E:\WINDOWS\WinSxS\x86_Microsoft.Windows.Common-Controls_6595b64144ccf1df_6.0.100.0_x-ww_8417450B\comctl32.dll
ModLoad: 79780000 79980000 e:\windows\microsoft.net\framework\v1.1.4322\mscorlib.dll
ModLoad: 79980000 79ca6000 e:\windows\assembly\nativeimages1_v1.1.4322\mscorlib\1.0.5000.0__b77a5c561934e089_ed6bc96c\mscorlib.dll
ModLoad: 79510000 79523000 E:\WINDOWS\Microsoft.NET\Framework\v1.1.4322\mscorsn.dll
Breakpoint 1 hit
eax=0012f7c0 ebx=00148c60 ecx=04aa112c edx=00000004 esi=0012f784 edi=0012f9a8
eip=791d6a4a esp=0012f764 ebp=0012f79c iopl=0 nv up ei pl zr na po nc
cs=001b ss=0023 ds=0023 es=0023 fs=0038 gs=0000 efl=00000246
mscorwks!PreStubWorker:
791d6a4a 55 push ebp
断点被激活就代表函数被调用。我们先使用 k 看看函数被调用时的上下文环境。
以下为引用:
0:000> k
ChildEBP RetAddr
0012f760 0014930e mscorwks!PreStubWorker
WARNING: Frame IP not in any known module. Following frames may be wrong.
0012f79c 791da434 0x14930e
0012f8b4 791dd2ec mscorwks!MethodDesc::CallDescr+0x1b6
0012f96c 79240405 mscorwks!MethodDesc::Call+0xc5
0012fa18 79240520 mscorwks!AppDomain::InitializeDomainContext+0x10f
0012fa7c 7923d744 mscorwks!SystemDomain::InitializeDefaultDomain+0x11c
0012fd60 791c6e73 mscorwks!SystemDomain::ExecuteMainMethod+0x120
0012ffa0 791c6ef3 mscorwks!ExecuteEXE+0x1c0
0012ffb0 7880a53e mscorwks!_CorExeMain+0x59
0012ffc0 77e1f38c mscoree!_CorExeMain+0x30 [f:\dd\ndp\clr\src\dlls\shim\shim.cpp 5426]
0012fff0 00000000 KERNEL32!BaseProcessStart+0x23
这里可以看到从 mscoree!_CorExeMain 一路执行下来的步骤,而那个警告说明这个 stack frame 不在任意一个已知模块中。这是很正常的,因为这个栈帧实际上是指向由 JIT 动态生成的代码。我们监视的 mscorwks!PreStubWorker 函数只是作为方法表中函数的入口 stub,系统启动时还会通过其他方式调用 JIT 完成代码的编译执行。
接下来用 SOS 的 !clrstack 命令看看 CLR 的调用堆栈,显示如下:
以下为引用:
0:000> !clrstack
succeeded
Loaded Son of Strike data table version 5 from "E:\WINDOWS\Microsoft.NET\Framework\v1.1.4322\mscorwks.dll"
Thread 0
ESP EIP
0012f784 791d6a4a [FRAME: PrestubMethodFrame] [DEFAULT] [hasThis] Void System.AppDomain.SetupDomain(ValueClass System.LoaderOptimization,String,String)
0012f9a8 791d6a4a [FRAME: GCFrame]
0012fad0 791d6a4a [FRAME: DebuggerClassInitMarkFrame]
0012fa94 791d6a4a [FRAME: GCFrame]
如果需要更为详细的详细,可以使用 -p, -l 或 -r 参数分别显示参数、局部变量和寄存器,当然前两者需要调试符号库的支持才行。
如此一路 g; !clrstack 执行下去,直到 flier.EntryPoint.m1 函数需要被处理为止:
以下为引用:
0:000> !clrstack
Thread 0
ESP EIP
0012f68c 791d6a4a [FRAME: PrestubMethodFrame] [DEFAULT] [hasThis] Void flier.EntryPoint.m1()
0012f69c 06d90080 [DEFAULT] Void flier.EntryPoint.Main()
0012f9b0 791da717 [FRAME: GCFrame]
0012fa94 791da717 [FRAME: GCFrame]
此时用 !dumpstackobjects 命令可以查看当前线程堆栈中使用的所有对象
以下为引用:
0:000> !dumpstackobjects
ESP/REG Object Name
ecx 04aa1a90 flier.EntryPoint
0012f678 04aa1a90 flier.EntryPoint
0012f67c 04aa1a90 flier.EntryPoint
0012f680 04aa1a90 flier.EntryPoint
这里的 flier.EntryPoint 对象地址 0x04aa1a90 就是我们要分析的对象在内存中的位置。
这一阶段使用到的 WinDbg 命令如下:
以下为引用:
bp mscorwks!PreStubWorker // 设置代码断点
g // 继续运行至断点
k // 查看函数调用时的 Native 堆栈调用
!clrstack // 查看函数调用时的 CLR 堆栈调用
!dumpstackobjects // 查看线程堆栈中使用到的所有对象
知道地址后,就可以用 !dumpobj 命令查看对象的详细信息
以下为引用:
0:000> !dumpobj 04aa1a90
Name: flier.EntryPoint
MethodTable 0x009750a8
EEClass 0x06c632e8
Size 12(0xc) bytes
mdToken: 02000002 (D:\Temp\demo.exe)
信息包括对象的类型名字(Name)和类型信息的地址(EEClass),以及对象的大小(Size)和 Token (mdToken),而方法表 (MethodTable) 正是我们分析方法调用的目标。我们可以用 !dumpclass 命令先进一步查看对象的类型信息:
以下为引用:
0:000> !dumpclass 0x6c632e8
Class Name : flier.EntryPoint
mdToken : 02000002 ()
Parent Class : 79b7c3c8
ClassLoader : 00153850
Method Table : 009750a8
Vtable Slots : 4
Total Method Slots : 8
Class Attributes : 100000 :
Flags : 1000003
NumInstanceFields: 0
NumStaticFields: 0
ThreadStaticOffset: 0
ThreadStaticsSize: 0
ContextStaticOffset: 0
ContextStaticsSize: 0
可以发现其信息与对象信息有很多符合之处,正如 Don Box 所说,一个对象引用指向一个类型 EEClass 实例,而方法表为类型所有,其对象共有。我们可以使用 !dumpmt 命令进一步查看方法表的信息,-md 参数表示需要查看每个方法描述 (MethodDesc):
以下为引用:
0:000> !dumpmt -md 0x09750a8
EEClass : 06c632e8
Module : 0014e090
Name: flier.EntryPoint
mdToken: 02000002 (D:\Temp\demo.exe)
MethodTable Flags : 80000
Number of IFaces in IFaceMap : 0
Interface Map : 009750f4
Slots in VTable : 8
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
79b7c4eb 79b7c4f0 None [DEFAULT] [hasThis] String System.Object.ToString()
79b7c473 79b7c478 None [DEFAULT] [hasThis] Boolean System.Object.Equals(Object)
79b7c48b 79b7c490 None [DEFAULT] [hasThis] I4 System.Object.GetHashCode()
79b7c52b 79b7c530 None [DEFAULT] [hasThis] Void System.Object.Finalize()
0097506b 00975070 None [DEFAULT] [hasThis] Void flier.EntryPoint.m1()
0097507b 00975080 None [DEFAULT] [hasThis] Void flier.EntryPoint.m2()
0097508b 00975090 None [DEFAULT] Void flier.EntryPoint.Main()
0097509b 009750a0 None [DEFAULT] [hasThis] Void flier.EntryPoint..ctor()
可以看到方法表中共有8个表项,其中前4个已经绑定到使用 ngen 预编译好的静态函数上
以下为引用:
0:000> u 79b7c4eb
mscorlib_79980000+0x1fc4eb:
79b7c4eb e8909cfeff call mscorlib_79980000+0x1e6180 (79b66180)
79b7c4f0 0000 add [eax],al
79b7c4f2 0080d86206c0 add [eax+0xc00662d8],al
79b7c4f8 06 push es
79b7c4f9 00fc add ah,bh
79b7c4fb e8809cfeff call mscorlib_79980000+0x1e6180 (79b66180)
79b7c500 07 pop es
79b7c501 0010 add [eax],dl
后四个则作为可被覆盖的虚方法在方法表中,这也是为什么在查看类型信息时 Vtable Slots = 4 而 Total Method Slots = 8 的原因。
对方法表的每个项目,可以用 !DumpMD 命令查看详细描述,如
以下为引用:
0:000> !DumpMD 0x00975070
Method Name : [DEFAULT] [hasThis] Void flier.EntryPoint.m1()
MethodTable 9750a8
Module: 14e090
mdToken: 06000001 (D:\Temp\demo.exe)
Flags : 0
IL RVA : 00002050
IL RVA 说明此方法的 IL 代码相对虚拟地址(IL RVA),也就是说此方法还没有被 JIT,仍以 IL 代码形式存在。对于已经完成 JIT 的方法,将显示其 JIT 后函数体代码的虚拟地址(Method VA):
以下为引用:
0:000> !DumpMD 0x009750a0
Method Name : [DEFAULT] [hasThis] Void flier.EntryPoint..ctor()
MethodTable 9750a8
Module: 14e090
mdToken: 06000004 (D:\Temp\demo.exe)
Flags : 0
Method VA : 06d900a8
这一阶段使用到的 WinDbg 命令如下:
以下为引用:
!dumpobj 04aa1a90 // 查看对象的详细信息
!dumpclass 0x6c632e8 // 查看类型的详细信息
!dumpmt -md 0x09750a8 // 查看方法表的详细信息
!dumpmd 0x00975070 // 查看方法表项的方法描述的详细信息
u 0x79b7c4eb // 反汇编指定地址的指令
我们反汇编一下 !DumpMT 命令列出的几个方法,就会发现正如 Don Box 所说,已经被 JIT 的代码指向一个jmp指令,直接跳转到编译后的方法体,如:
以下为引用:
0:000> u 0097509b
0097509b e908b04106 jmp 06d900a8
而没有被 JIT 的函数,则指向一个call指令,调用一个 prolog 代码,间接调用 mscorwks!PreStubWorker 函数完成实际 JIT 工作,如:
以下为引用:
0:000> u 0x0097506b
0097506b e878427dff call 001492e8
0:000> u 0x0097507b
0097507b e868427dff call 001492e8
这个 prolog 代码很简单,负责构造 mscorwks!PreStubWorker 所需的调用堆栈
以下为引用:
0:000> u 0x001492e8
001492e8 52 push edx
001492e9 68f0301b79 push 0x791b30f0
001492ee 55 push ebp
001492ef 53 push ebx
001492f0 56 push esi
001492f1 57 push edi
001492f2 8d742410 lea esi,[esp+0x10]
001492f6 51 push ecx
001492f7 52 push edx
001492f8 648b1d2c0e0000 mov ebx,fs:[00000e2c]
001492ff 8b7b08 mov edi,[ebx+0x8]
00149302 897e04 mov [esi+0x4],edi
00149305 897308 mov [ebx+0x8],esi
00149308 56 push esi
00149309 e83cd70879 call mscorwks!PreStubWorker (791d6a4a)
0014930e 897b08 mov [ebx+0x8],edi
00149311 894604 mov [esi+0x4],eax
00149314 5a pop edx
00149315 59 pop ecx
00149316 5f pop edi
00149317 5e pop esi
00149318 5b pop ebx
00149319 5d pop ebp
0014931a 83c404 add esp,0x4
0014931d 8f0424 pop [esp]
00149320 c3 ret
而这段 prolog 代码是由类似 ROTOR 中的 GeneratePrestub 函数(vm\i386\cgenx86.cpp:1829) 动态生成的,完成对 PreStubWorker 函数调用的封装。而 PreStubWorker 函数会调用 JIT 完成真正的函数编译工作,并将方法表的入口改为指向编译后函数体的 jmp 指令。具体的流程请参考Don Box 在《.NET本质论 第1卷:公共语言运行库》的第六章中的介绍,这里就不再罗嗦了。以后有机会再写篇文章详细分析一下 JIT 的工作流程。
在 JIT 处理 flier.EntryPoint.m1 时,用 g 命令执行,再回头来分析 m1 函数的入口,就会发现如前面所述,调用 JIT 过程的 call 指令变成了直接调用 Native 函数体的 jmp 指令。:D
这一小节,我们介绍了使用 WinDbg 跟踪调试 CLR 程序的一遍流程,并了解了对堆栈、对象和类信息进行分析的 SOS 命令,希望大家能够借此开始探索 CLR 内部世界的旅程。 :P
Jason Zander在其 BLog 的一篇文章,SOS Debugging with the CLR (Part 1),里面也详细介绍了使用 WinDbg 和 SOS 调试 CLR 程序的部分方法,值得一看。
用WinDbg探索CLR世界 [3] 跟踪方法的 JIT 过程
80酷酷网 80kuku.com