IL 代码底层运行机制之
** 循环处理 **
刘强
2003 年 10 月 22 日
上一篇文章我们讨论了 IL 代码的基本运行机制。在这篇文章里,我们将讨论 IL 代码是怎样处理 C# 中的循环。例子还涉及到数组处理,以及一些新涉及到的指令。虽然已经有人进行过相关问题的研究,我也看过几篇有关文章,不过我认为他们描述得并不是很清楚,所以在这里我借机重新整理成文,希望对大家学习理解 .net 会有所帮助,同时也希望对研究虚拟机机制的有关设计人员有所帮助。
同样,这里也先给出 C# 代码,然后再让我们详细研究其编译后的 IL 代码。下面是 C# 代码,它含有三个循环,分别是 for 、 while 、 foreach 循环:
public int LoopTest()
{
int i=3;
int j=9;
int s=0;
int k; file:// 以上各条语句定义变量并进行初始化
for(k=0;k<=i;k++)
{
s+=k;
} file://for 循环块
k=0;
while(k
1<j) '<privateimplementationdetails="" 'array',="" (0x65)="" ([0]="" )="" .locals="" .maxstack="" .method="" 101="" 3="" 8="" 9="" [1]="" [2]="" [3]="" [4]="" [5]="" [6]="" [7]="" [8]="" [mscorlib]system.int32="" a="" a,="" add="" array="" array)="" ble.s="" blt.s="" br.s="" c#="" cil="" cs$00000003$00000000,="" cs$00000007$00000001,="" cs$00000008$00000002="" dup="" field="" file:="" foreach="" foreach(int="" hidebysig="" i,="" il="" il_0000:="" il_0001:="" il_0002:="" il_0004:="" il_0005:="" il_0006:="" il_0007:="" il_0008:="" il_0009:="" il_000b="" il_000b:="" il_000c:="" il_000d:="" il_000e:="" il_000f:="" il_0010:="" il_0011:="" il_0012:="" il_0013="" il_0013:="" il_0014:="" il_0015:="" il_0017:="" il_0018:="" il_0019:="" il_001b="" il_001b:="" il_001c:="" il_001d:="" il_001e:="" il_001f:="" il_0020:="" il_0021:="" il_0022:="" il_0023="" il_0023:="" il_0024:="" il_0025:="" il_0027:="" il_0028:="" il_002d:="" il_002e:="" in="" init="" instance="" int32="" int32[]="" int[]="" j,="" k++;="" k,="" ldc.i4.0="" ldc.i4.1="" ldc.i4.3="" ldc.i4.8="" ldc.i4.s="" ldloc.0="" ldloc.1="" ldloc.2="" ldloc.3="" ldtoken="" looptest()="" managed="" newarr="" public="" return="" s+="a;" s,="" s;="" stloc.0="" stloc.1="" stloc.2="" stloc.3="" system.int32="" valuetype="" void="" while="" {="" }="" ’数组。="" 代码以实现循环处理的,或者说如何用="" 代码大小="" 代码:="" 值。如果函数为="" 创建长度为="" 在这里,我们要做的是搞清楚="" 型,则无此变量。="" 局部变量,存储数组引用,用于="" 局部变量,存储数组索引。专用于="" 循环。本例中对应‘="" 循环块="" 循环,由编译器维护。="" 数组。可以看出数组元素被映射到="" 的="" 类对象。="" 编译器是把源程序翻译成怎样的="" 语言中的循环。这对我们深入理解="" 语言实现="" 语言特性是很有帮助的。当然仅仅这一点还不够,以后我还会介绍更多的有关方面的问题。="" 跟函数返回值类型相同的局部变量,由编译器维护,专门用于存储返回="" 首先让我们看看这个函数被编译成什么样的="">'/'$$struct0x6000002-1'
2
3'<privateimplementationdetails>'::'$$method0x6000002-1'
4
5IL_0033: call void [mscorlib] System.Runtime.CompilerServices.RuntimeHelpers::
6
7InitializeArray(class[mscorlib]System.Array, valuetype [mscorlib] System.RuntimeFieldHandle)
8
9IL_0038: stloc.s 'array'
10
11IL_003a: ldloc.s 'array'
12
13IL_003c: stloc.s CS$00000007$00000001
14
15IL_003e: ldc.i4.0
16
17IL_003f: stloc.s CS$00000008$00000002
18
19IL_0041: br.s IL_0055
20
21IL_0043: ldloc.s CS$00000007$00000001
22
23IL_0045: ldloc.s CS$00000008$00000002
24
25IL_0047: ldelem.i4
26
27IL_0048: stloc.s a
28
29IL_004a: ldloc.2
30
31IL_004b: ldloc.s a
32
33IL_004d: add
34
35IL_004e: stloc.2
36
37IL_004f: ldloc.s CS$00000008$00000002
38
39IL_0051: ldc.i4.1
40
41IL_0052: add
42
43IL_0053: stloc.s CS$00000008$00000002
44
45IL_0055: ldloc.s CS$00000008$00000002
46
47IL_0057: ldloc.s CS$00000007$00000001
48
49IL_0059: ldlen
50
51IL_005a: conv.i4
52
53IL_005b: blt.s IL_0043
54
55IL_005d: ldloc.2
56
57IL_005e: stloc.s CS$00000003$00000000
58
59IL_0060: br.s IL_0062
60
61IL_0062: ldloc.s CS$00000003$00000000
62
63IL_0064: ret
64
65} // end of method Advanced::LoopTest
66
67关于函数话题如 .locals init 语句等,请参见文章〈函数相关〉。这里我对其中的一些指令做出解释,主要是与本文相关的条件转移指令( b*.s )等。其他指令以后我会作适当的介绍。如下所示:
68
69指令
70
71|
72
73意义
74
75|
76
77记忆方法( * )
78
79---|---|---
80
81br.s
82
83|
84
85绝对跳转,相当于 jmp
86
87|
88
89blt.s
90
91|
92
93小于转
94
95|
96
97Lower Than
98
99ble.s
100
101|
102
103小于等于转
104
105|
106
107Lower or Equals
108
109ldlen
110
111|
112
113取得数组长度
114
115|
116
117ldelem.i4
118
119|
120
121根据索引取得数组项
122
123|
124
125这里我们可以看到 .locals init 伪指令给出了同源程序相同变量名称。这是因为在反汇编时,相同目录下有调试信息文件( *.pdb ),否则的我们看到的结果变量以 V_x 形式(如 V_1 、 V_2 等)表示。有关函数局部变量的话题,请参见《函数相关》一文。
126
127如果你有 WIN32 汇编程序设计经验,可能都熟悉怎样实现循环控制。如,要实现从 10 加至 100 的功能,我们可能会这样做:
128
129mov ecx, 100 file://ecx 寄存器存放循环计数
130
131xor eax, eax file:// 给 eax 和标志寄存器清零
132
133loop: add eax, ecx file:// 实现相加并将结果存 eax
134
135dec ecx file:// 计数减一
136
137cpr: cmp ecx, 9 file:// 判断 ecx>=10 或 ecx>9
138
139jg loop file:// 如果判断结果为真(大于)的话,则转 loop
140
141这跟高级语言( C/C++/JAVA/C# )不一样, for 循环中的循环条件在程序首部给出,而顺序执行的低级语言如 MASM 都习惯是在循环末尾测试循环条件的。那么 C# 编译器又是怎样处理 C# 循环条件位置与一般汇编中循环条件测试语句位置的不一致,用 IL 来实现循环条件检测并正确实现循环的呢?首先,在这里我要说明,在顺序执行的汇编语言中,测试循环条件是完全可以放在循环首部的。如上例的 IL 版为:
142
143.locals init([0] int32 eax, [1] int32 ecx , [2] int32 RET_VAL)
144
145ldc.i4 100
146
147stloc.1 file://mov ecx, 100
148
149ldc.i4.0
150
151stloc.0 file://xor eax, eax 或 move eax, 0
152
153L_0000: ldloc.1
154
155ldc.i4 10 file://
156
157blt.s L_0003 // ecx < 10 ? Yes-> jmp L_0001 : No -> go on
158
159L_0001: ldloc.0
160
161ldloc.1
162
163add
164
165stloc.0 file:// 这几句实现 eax=eax+ecx
166
167ldloc.1
168
169ldc.i4.1
170
171sub
172
173stloc.1 file:// 这几句实现 ecx=ecx-1
174
175L_0002: br.s L_0000
176
177L_0003: …
178
179其次,我要说明不这样做的理由。理由有二,其一是破坏了正常逻辑,这一点是从编译器层面上来说的。比如,对于语句 if(k<j) k="" k<j="" )的比较,为真向前跳转转,为假继续执行即可。而放在首部的话,则要进行的是(="" ,如果将比较放在循环尾部,进行的是(="">=j )的比较,如真则向下跳出循环区域,如假则继续执行;在循环的末尾还要设置绝对跳转语句,以跳转到首部的比较指令处。由 (k<j) (k="" 向="">=j) 转变,对于我们人来说是很简单、直观的事情,可对编译器来说还要做更多的工作才能实现。更何况还有更复杂的布尔表达式呢,如( k>j ) && (k>34) || (j<=56) 。这就增加了编译器实现的负担——虽然不是很大的负担。而且,因为还增加了跳转语句,给编译器对跳转位置的定位增加了难度。大家知道,汇编器在处理、计算汇编语言中的标号与跳转指令的偏移量时要进行至少两次的扫描,高级语言就更复杂了。因此,采用前一种方法既容易理解,又容易实现。
180
181下面我们来看看例子中三种循环的具体实现。有关 IL 代码的基本运行机制,请参看《 IL 代码底层运行机制》一文。 IL_0027 到 IL_003f 是进行数组初始化的,比较难懂一点。我们暂且放下,以后我还会介绍。
182
1831. for 语句
184
185可以看出,程序段中 IL_0000 到 IL_0008 是执行变量初始化工作的。从 IL_0009 开始,就是循环体了。 IL_00009 是一条直接(绝对)跳转语句,跳转到 IL00_13 。我们看看这里的内容:
186
187IL_0013: ldloc.3
188
189IL_0014: ldloc.0
190
191IL_0015: ble.s IL_000b
192
193加载局部变量 3 (也即 k ),再加载局部变量 0 (即 j )。后面是一条比较转移指令 ble.s 。不难看出,这三条语句用于比较 k 与 j 的大小。如果比较结果为真(小于等于),则转入循环体内( IL_000b 处),为假则继续执行直接出循环体。过程如行云流水,简洁直观,不多作解释。从这里我们也可以看出, for 语句是先进行条件测试,后执行循环体的。
194
195... ldloc.3 ldloc.0 ble.s
196
197---
198
199top
200
201…
202
203---
204
205top
206
207k
208
209…
210
211---
212
213top
214
215j
216
217k
218
219…
220
221---
222
223top
224
225…
226
227load 指令将变量逐个加至程序栈。 ble.s 指令进行比较。值得我们注意的是, ble.s 还要进行清栈操作。不仅是 ble.s ,其他条件转移指令也都是如此。
228
2292. foreach 语句
230
231foreach 语句和 for 语句处理过程大致相当。我们感兴趣的是 foreach 怎样处理边界条件。从 IL0041 开始,就进入了 foreach 循环体。同样一条直接跳转指令把我们带到了 IL_0055 处,让我们看看这里是什么。
232
233IL_0055: ldloc.s CS$00000008$00000002
234
235IL_0057: ldloc.s CS$00000007$00000001
236
237IL_0059: ldlen
238
239IL_005a: conv.i4
240
241IL_005b: blt.s IL_0043
242
243前面我介绍过 CS$00000008$00000002 是存储数组索引的, CS$00000007$00000001 是数组‘ array ’的引用。 IL0055 到 IL005b 的过程操作是这样的:首先向程序栈加载当前索引,再加载数组引用( 32 位的 HashCode )。 ldlen 指令根据数组引用取得数组长度( 64 位长整型)并将之转换成 32 为整型,将索引与此长度进行比较。如果小于,则转入循环体继续执行;否则出循环。从这里我们也可以看出, IL 对数组操作给予了很强的支持,直接为它提供了相应的指令。
244
2453. while 语句和 do-while 语句
246
247从例子中可以看出, while 和 for 循环处理方式是一样的。这里没有给出 do-while 例子,但是可以想见它跟 for 语句处理是一样的。但是, do-while 循环要注意,在其循环首部没有像 for 和 foreach 循环那样的直接跳转指令跳转到条件测试代码处。因此,不管什么情况, do-while 循环都是至少执行一次的。
248
249在这篇文章中,我介绍了几条有关条件跳转指令,以及 C# 编译器是怎样处理 C# 语言中的循环的。其实,本文不能完全算是 IL 底层机制相关文章,但是要深入了解 IL ,这点基础还是必要的。</j)></j)></privateimplementationdetails></j)>