用WinDbg探索CLR世界 [3] 跟踪方法的 JIT 过程

http://www.blogcn.com/blog/cool/main.asp?uid=flier_lu&id=1678453

本来想按照 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 程序的部分方法,值得一看。

Published At
Categories with Web编程
Tagged with
comments powered by Disqus