方法调用: 第一部分 (普通调用)
译者:我们都知道.NET托管代码如C#、VB.NET写成的代码,都是先被编译成中间语言(IL,Intermediate Language,在运行时,再由即时编译器(JIT,Just-In-Time)编译成本机代码。那么这个神秘的过程是怎么进行的呢,JIT会在什么时机编译你的代码呢,下面这篇翻译文章将给大家介绍这个过程,大家不要被开始复杂的工具和命令吓到,只要你坚持读下去,一定会有所收获
在接下来的几篇".net 揭密"系列文章中,我将会介绍大多数人认为理所当然的东西——代码调用,到底代码调用是怎么工作的(注意在这篇文章中我们要讨论的是非常基础的"调用"过程,虽然看起来十分浅显,实际上这确实十分重要的,因为他可以极大的影响代码的效率,并且让你深刻的认识ClR的工作方式
首先让我们建立一个测试代码
代码清单 1: 用于讨论的简单代码
为了运行这一小段代码JIT必须解决许多问题,让我们进入他的工作流程,更好的了解到底发生了什么.
在程序的控制权没有交给我们的代码之前,Main方法首先被编译,控制权就被交给了Main方法,代码的反编译源如下
static void Main(string[] args) { Foo f = new Foo(); 00000000 push esi 00000001 mov ecx,913080h 00000006 call FFB21FAC 0000000b mov esi,eax f.Test(); 0000000d mov ecx,esi 0000000f cmp dword ptr [ecx],ecx 00000011 call dword ptr ds:[009130B8h] f.Test(); 00000017 mov ecx,esi 00000019 cmp dword ptr [ecx],ecx 0000001b call dword ptr ds:[009130B8h] f.Test(); 00000021 mov ecx,esi 00000023 cmp dword ptr [ecx],ecx 00000025 call dword ptr ds:[009130B8h] 0000002b pop esi } 0000002c ret
代码清单 2: main方法的反编译源
我们可以看到生成的代码通过间接寻址(译者注:指的是上面00000011 call dword ptr ds:[009130B8h],我在这里给不熟悉这个术语的人解释一下间接寻址:通过内存地址中的地址来找到实际地址的寻址方法叫做间接寻址.听起来很复杂,其实你可以这样理解,我叫你找一个人,直接给你他的地址,然后你通过这个地址找到这个人,叫直接寻址,那么我给你一个地址,告诉你这个地址住的人知道你要找的人在哪里,这就是间接寻址,在程序里,内存地址就相当于我给你的地址,内存地址里存储的值才是你最终需要的地址)来发起调用,这样做当然是有原因的,在我们解释这个问题之前,先打开值得我们信赖的挚友——调试器,不过你可能需要先阅读这篇文章,怎样用Visual Studio查看非托管代码,并初步了解SOS(Son Of Strike)(译者注:SOS是一个VS自带的调试非托管代码的辅助模块,如果不了解,并不妨碍你理解本文的主要原理)
我将用粗体标识所有的调试器命令,并以普通字体标识其输出
在代码的第一行打上一个断点并开始调试,所有的SOS命令都需要在VisualStudio的"立即窗口"(Immediate window)中输入(译者:通过在命令窗口(command window)中输入immed并回车,就可以进入立即窗口)
.load SOS(译者注:此命令加载SOS模块)
extension C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\SOS.dll loaded
This first command loads the SOS debugging tool making it available for our use.
!Name2EE ConsoleApplication29.exe ConsoleApplication29.Foo.Test
PDB symbol for mscorwks.dll not loaded
Module: 00912c14 (ConsoleApplication29.exe)
Token: 0x06000001
MethodDesc: 00913070
Name: ConsoleApplication29.Foo.Test()
Not JITTED yet. Use !bpmd -md 00913070 to break on run.
这个命令给出了有关我们的方法的丰富信息,上面有许多有用的信息,但是其中最重要的莫过于方法描述(MethodDesc)的地址,我们可以用这个地址找到更多信息
!DumpMD 00913070
Method Name: ConsoleApplication29.Foo.Test()
Class: 009113b8
MethodTable: 00913080
mdToken: 06000001
Module: 00912c14
IsJitted: no
m_CodeOrIL: ffffffffffffffff
在这里我们可以获得"方法列表"(method table)的地址,我们可以通过这个地址得到方法列表
!DumpMT -md 00913080
EEClass: 009113b8
Module: 00912c14
Name: ConsoleApplication29.Foo
mdToken: 02000002 (C:\Documents and Settings\Greg\My Documents\Visual Studio 2005\Projects\ConsoleApplication29\ConsoleApplication29\bin\Release\ConsoleApplication29.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
79354bec 7913bd48 PreJIT System.Object.ToString()
793539c0 7913bd50 PreJIT System.Object.Equals(System.Object)
793539b0 7913bd68 PreJIT System.Object.GetHashCode()
7934a4c0 7913bd70 PreJIT System.Object.Finalize()
009130c8 00913070 NONE ConsoleApplication29.Foo.Test()
009130d4 00913078 NONE ConsoleApplication29.Foo..ctor()
很多人已经注意到我们的方法还没有被JIT编译,这就是为什么我们通过间接引用来调用方法的原因之一 :在main方法编译他之前,程序并不知道需要到哪里去调用.这就引出了一个有趣的问题
JIT怎么知道何时编译一个方法?
本质上来说,JIT是延迟加载我们的模块,通过一种被叫做"thunk"(块)的技术,JIT能捕获到我们对方法的第一次调用,所谓thunk是一小段非托管代码,当我们第一次加载某个类型的时候,由CLR通过emit生成thunk.thrunk简单的包含对JIT的调用
图1 : JIT的编译过程
图一中的过程看起来过于简单,但是在实际运用中效率太低.实际系统和途中呈现的流程的差别主要体现在对决策判断上,由于图片的误导,我们似乎觉得trunk中有分支出现;实际上是没有分支的,取而代之的是JIT使用一种叫做back patching的技术
术语"back patching"可能挺眼熟的,因为在GC垃圾处理中也用到了他,这个术语主要的意思是通过更新一个指针来反映信息的变化,当一个方法第一次被调用的时候,调用方从MethodTable中读取指向一个代码块(thunk)的地址,然后调用这个thunk,thunk,接着调用JIT.关键的地方在于,当JIT完成了编译后,将改变MethodTable,使其直接指向已经被JIT编译过的代码,图2和图3反映了这个过程完成前后的对比.注意在图中,方法被直接调用,而实际上是通过读取一个经过变化的内存地址来完成的.(译者注:也就是说无论代码是否被JIT编译,对方法的调用都是通过调用MethodTable中方法地址来实现的,若代码尚未编译,则这个地址指向一个代码块(thunk,),他会帮助你编译代码,然后修改MethodTable中的指针,指向实际代码)
图2:JIT编译前
图3:JIT编译后
现在我们对这一切已经有了一个大概的认识,让我们通过查看调试器来印证我们的知识.你可以使用memory window(debug->windows->memory)输入方法调用的内存地址(i.e. 列表中的009130B8h) ,或者使用registers window (debug->windows->registers (请确认有效地址选项已经打开))来查看所需的数据 .
给你的朋友展示这些玩意,无庸置疑的表现你是办公室里的顶级高手
用调试器单步进入line 0011(第一次调用),我们可以看到在内存地址009130b8中(间接寻址的地址)包含着009130c8,这个地址也许看起来会挺熟悉,这就是指向Thunk的指针,通过!u反编译这个地址,我们甚至可以查看这段非托管代码.
!u 009130C8
Unmanaged code
009130C8 B870309100 mov eax,913070h
009130CD 89ED mov ebp,ebp
009130CF E938EEA2FF jmp 00341F0C
009130D4 B878309100 mov eax,913078h
009130D9 89ED mov ebp,ebp
009130DB E92CEEA2FF jmp 00341F0C
009130E0 0000 add byte ptr [eax],al
009130E2 0000 add byte ptr [eax],al
009130E4 0000 add byte ptr [eax],al
009130E6 0000 add byte ptr [eax],al
这段代码可能看起来有些令人迷惑,因为这里实际上有两段连接在一起的thunk,接着什么都不做,913070是否也有些面熟呢?他是我们的Method desc(代码描述)的地址,它被放入EAX,作为JIT编译器的变量传送给JIT(这样JIT才知道需要编译什么代码).我们在方法内上断点,并在断点停下,看看什么发生了变化
!DumpMT -md 00913080
EEClass: 009113b8
Module: 00912c14
Name: ConsoleApplication29.Foo
mdToken: 02000002 (C:\Documents and Settings\Greg\My Documents\Visual Studio 2005\Projects\ConsoleApplication29\ConsoleApplication29\bin\Release\ConsoleApplication29.exe)
BaseSize: 0xc
ComponentSize: 0x0
Number of IFaces in IFaceMap: 0
Slots in VTable: 6
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
79354bec 7913bd48 PreJIT System.Object.ToString()
793539c0 7913bd50 PreJIT System.Object.Equals(System.Object)
793539b0 7913bd68 PreJIT System.Object.GetHashCode()
7934a4c0 7913bd70 PreJIT System.Object.Finalize()
00de00b0 00913070 JIT ConsoleApplication29.Foo.Test()
009130d4 00913078 NONE ConsoleApplication29.Foo..ctor()
可以看到方法现在已经被JIT编译了,并且Method table也被更新,以反映这个变化(00de00b0).这就是JIT编译后的非托管代码入口,通过查看当前执行堆栈能够证实我们的想法
!CLRStack
OS Thread Id: 0x8e8 (2280)
ESP EIP
0012f47c 00de00b0 ConsoleApplication29.Foo.Test()
0012f480 00de0087 ConsoleApplication29.Program.Main(System.String[])
0012f69c 79e88f63 [GCFrame: 0012f69c]
从函数里跳出后,测试代码会又一次执行同样的调用,不过这次,他不会在经过thunk,而是直接进入已经产生好的非托管代码
以上就是代码调用的一般机制,除非代码发生"颠簸"(pitched),被反复调入调出,欲知详情,请听下回分解
源文地址:
http://codebetter.com/blogs/gregyoung/archive/2006/07/20/147512.aspx
怎样用Visual Studio调试非托管代码
http://www.cnblogs.com/yizhu2000/archive/2007/08/08/848160.html