怎样用VisualStudio查看非托管代码
(译者:这篇文章作者是一位美国的MVP,这是他的系列文章"Under the cover"的第一篇,文章的本意从最底层的角度来优化代码的性能,并作为阅读作者其他文章的技术基础,这种通过这样的做法虽然初看起来有些过分,但是对读者了解.Net许多底层运作是十分有益的)
我们从使用visual studio进行非托管代码调试的基础开始,以便大家可以更容易的学习今后的例子,并让这篇文章作为我以后文章的基础,虽然我也使用windbg,但visual studio已经成为了一个功能强大的调试工具,对于简单的代码优化问题反而更容易使用
当我们需要校调对性能要求很高的代码时,查看IL通常不是最好的做法,因为JIT优化器会默默的优化我们的代码,使用reflector或者ildasm你能很快发现release和debug模式下产生的IL代码几乎完全相同,那么是什么让release模式的代码运行起来如此迅速呢?这就是JIT优化的结果,通过查看managed代码(IL代码),我们没有办法看到这些优化,所以我们将通过native code(本地代码)来查找蛛丝马迹。
必须说明我不提倡大家经常这样做,我不赞成过早的进行优化,你必须使你的代码先工作起来,你必须清楚的知道哪些代码是不值得优化的,当你的代码完成后再来找那些需要提速的地方,当你发现有的地方10% 的代码却使用了70%的时间的时候,再回过头去优化那10%的代码.同时你总是应该把判断的依据建立在对速度的实际测量上,而非仅仅是阅读代码,最后,其实数据结构的选择比底层的优化重要的多
当然话又说回来,了解隐藏在.Net底层的秘密是非常有趣的,那就让我们开始设置visualstudio,并动手试验一个简单的例子
首先我们需要一些试验代码
static void Main(string[] args) {
for (int i = 0; i < 10; i++) {
Console.WriteLine("Hello World!");
}
}
为了打开非托管代码调试,我们需要对visual studio进行设置.打开项目的属性并进入Debug Tab,选择该页上的“Enable unmanaged code debugging”复选框
(注意,这个选项只对当前使用的配置有效,因此我们应该为我们使用的所有配置设置这个选项.)在循环的开始处插入一个断点,并运行程序,你将会像往常一样击中一个断点。这时你的屏幕应该看起来如图二(译者:原文缺图)如果你没有stack窗口,可以通过menu -> windows -> call stack (或者 ctrl + d c)将其呼出,打开call stack后,我们就可以通过右击鼠标,选择go to disassembly进入下面的代码
static void Main(string[] args) { 00000000 push ebp 00000001 mov ebp,esp 00000003 push edi 00000004 push esi 00000005 push ebx 00000006 sub esp,38h 00000009 xor eax,eax 0000000b mov dword ptr [ebp-10h],eax 0000000e xor eax,eax 00000010 mov dword ptr [ebp-1Ch],eax 00000013 mov dword ptr [ebp-3Ch],ecx 00000016 cmp dword ptr ds:[00912DC8h],0 0000001d je 00000024 0000001f call 792B228E 00000024 xor esi,esi 00000026 xor edi,edi 00000028 nop for (int i = 0; i < 10; i++) { 00000029 xor esi,esi 0000002b nop 0000002c jmp 0000003D 0000002e nop Console.WriteLine("Hello World!"); 0000002f mov ecx,dword ptr ds:[022B303Ch] 00000035 call 785D9074 0000003a nop } 0000003b nop for (int i = 0; i < 10; i++) { 0000003c inc esi 0000003d cmp esi,0Ah 00000040 setl al 00000043 movzx eax,al 00000046 mov edi,eax 00000048 test edi,edi 0000004a jne 0000002E } 0000004c nop 0000004d lea esp,[ebp-0Ch] 00000050 pop ebx 00000051 pop esi 00000052 pop edi 00000053 pop
我们正在查看的就是JIT为我们的代码产生的native code(本地代码),我们可以看到简单的循环在native code层次上怎么运行的,如果你从来没有研究过native code,这些本来很普通的代码可能看起来相当的奇怪,让我们来自己看看这里发生了什么
00000029 xor esi,esi
0000002b nop
0000002c jmp 0000003D
上面代码初始化我们在ESI中的计数器,ESI是一个索引寄存器,可以用来索引数组,你可以看到这里用了一个很古老的"把戏"来把计数器清0,代码没有使用把0值放入寄存器,而是让寄存器自己异或(xor)自己来达到清0的目的,接下来的一行Nop,意思是"没有操作",而他们的作用就和他们的名字一样,什么也不做,代码接下来立即跳转到3D.有时候像这样的跳转使得我们的代码不是自上而下的运行(就象许多高级语言比如c,vb,c#里面一样),如果跟着这个跳转进入这个循环的另外一个部分,就可以继续分析我们的代码
0000003c inc esi
0000003c后面第一个指令把ESI中的计数器加一(通过register窗口或者组合键ctrl+D R 你可以看到它的值),在第一次循环时代码会跳过这行,因为上面的跳转指令直接指向了0000003D
0000003d cmp esi,0Ah
00000040 setl al
00000043 movzx eax,al
00000046 mov edi,eax
00000048 test edi,edi
0000004a jne 0000002E
从0000003D开始到4a,代表于循环停止值的实际比较和跳转如果我们没有达到这个值(i<10),最后一行会跳转到2e(译者注:原著这里为4a,是个笔误)继续这个循环,也就是循环体开始的地方
0000002f mov ecx,dword ptr ds:[022B303Ch]
00000035 call 785D9074
上面的第一条行将会把字符串从从内存装在到ECX 寄存器 (这是一个通用寄存器), 一般ECX总是用作把第一个参数传给方法,在实例的方法中,ECX将总是包含this,紧接着是包含第二个参数的EDX,然后是一系列的push,用于把其他参数入栈
下一条语句执行实际的调用。我们待会再来探讨怎么去查找所调用的方法,但是现在我们可以从VisualStudio给出的源代码看到,这毫无疑问就是 Console.WriteLine ,代码接着执行索引的自增,并返回来继续执行loop循环内部的代码
然而,我们的微不足道的例子中已经产生产生了明显的浪费。下面是一个例子
00000009 xor eax,eax
0000000b mov dword ptr [ebp-10h],eax
0000000e xor eax,eax
00000010 mov dword ptr [ebp-1Ch],eax
我们在一行里两次对EAX置0,这时因为我们正运行在debug模式下,调试模式下是不进行优化的,换句话说,这段代码只是被JIT执行,但是却没有允许JIT作任何智能优化
下面让我们来看看经过优化的代码:
这里有一些关于查看优化代码的问题
1)是调试器默认关闭了JIT的优化(我自己就曾经在大半夜花了很长时间才意识到自己一直在看没有被优化的代码)
2)是必须处理"Just My Code"选项对优化代码的影响
我最初在Vance Morrison的帖子上看到了解决这个问题的办法(谢谢 Vance,我已经被整个问题困扰了很长一段时间,并最终使用直接查看没有源码的原始assemble的方式).
要搞定这个问题,清跟着以下的步骤作
1) 打开 Tools -> Options -> Debugging -> General
2)确保 ‘Suppress JIT optimization on module load’没有被选中
3)也确保‘Enable Just My Code’没有被选中
Vance 也建议我们进入advanced build设置release dll为pdb only,这时我们可以用前面同样的方式运行这段代码
用 JIT 看我们的代码另外一种方式是使用release模式,使用Start the executable without the debugger,再附加visualstudio到进程进行调试.
使用任一方法我们都能让 JIT 将代码优化了。得到优化的代码如下
for (int i = 0; i < 10; i++) { 00000000 push esi 00000001 xor esi,esi Console.WriteLine("Hello World!"); 00000003 cmp dword ptr ds:[02271084h],0 0000000a jne 00000016 0000000c mov ecx,1 00000011 call 786FC654 00000016 mov ecx,dword ptr ds:[02271084h] 0000001c mov edx,dword ptr ds:[0227307Ch] 00000022 mov eax,dword ptr [ecx] 00000024 call dword ptr [eax+000000D8h] for (int i = 0; i < 10; i++) { 0000002a add esi,1 0000002d cmp esi,0Ah 00000030 jl 00000003 00000032 pop esi } } 00000033 ret
哇,这次的代码比第一次少多了,JIT 优化确实工作的很好,这就是为什么查看实际的反编译代码而非IL是这样重要,因为JIT经常会通过识别IL中的模式来进行优化,机敏的读者可能注意在我们的循环的内部事实上产生了更多的密码。初看起来这是非常可怕的,但其实这说明优化器已经帮助我们inline了Console.WriteLine方法,实际是节省了很多代码在接下来的帖子中我会谈到inline,但是大家先明白这是一个很重要的优化
我们这时已经准备好了怎样在调试器中去欣赏优化的和没有优化的代码,我想这是一个好的开始,下面的几个帖子我会为更深入的了解JIT的一般优化过程而打好基础,我们也可以接触一些工具,看看他们会怎样帮助我们得到更好的代码
希望能在那里见你。
原文地址
http://codebetter.com/blogs/gregyoung/archive/2006/06/09/146298.aspx
原文的一些资源: