读懂 .NET IL 代码
2021 年 7 月 28 日 · 2 分钟读完
IL 是什么
IL(Intermediate Language,中间语言,也叫 MSIL 或 CIL)是 .NET 程序编译后的产物。C# 代码的编译过程是两步:

- 语言编译器:不管 C# 还是 VB,都先被编译成 IL 和元数据,放进 dll 或 exe;
- JIT 编译器:程序运行时,再根据当前的 CPU 和系统把 IL 编译成机器码。
为什么要了解 IL:
- 对运行结果有疑问时,可以透过语法糖看本质,比如 字符串拼接、
foreach、yield、async编译后到底是什么; - IL 是理解 CLR 的基础;
- 很多深入分析的文章都以 IL 为例,看得懂才能读得下去。
把 ILDasm 加到 VS 里
ILDasm.exe 随 Windows SDK 安装,先找到它的位置:

在 VS 里 工具 → 外部工具,添加一项,命令填 ildasm.exe 的路径,参数填 $(TargetPath):

之后在工具菜单里点它,就能直接打开当前项目编译出的程序集:

各种图标的含义:

读之前要知道的几个概念
- 托管堆(Managed Heap):存放引用类型对象,由 GC 管理。
- 调用栈(Call Stack):按调用顺序记录正在执行的方法。
- 计算栈(Evaluation Stack):IL 里所有计算都在这个栈上进行,指令把值压进去(push)、弹出来(pop),算完只剩结果。
- 局部变量表(Record Frame):方法的局部变量,按索引 0、1、2…… 访问。
常见指令的命名规律:ld 是 load(压栈),st 是 store(出栈存起来),loc 是局部变量,c.i4 是 int32 常量,elem 是数组元素。
实例:逐条看一个 for 循环
string test = "hello word";
char[] c = test.ToCharArray();
byte[] b = new byte[c.Length];
for (int i = 0; i < c.Length; i++)
{
b[i] = Convert.ToByte(c[i]);
Console.WriteLine("{0:X} {1}", b[i], c[i]);
}
Console.ReadKey();
对应的 IL(注释以第一次循环为例):
.locals init ([0] string test, [1] char[] c, [2] uint8[] b, [3] int32 i, [4] bool V_4)
IL_0000: nop
IL_0001: ldstr "hello word" // 字符串压栈
IL_0006: stloc.0 // 弹出,存到局部变量 0(test)
IL_0007: ldloc.0 // test 压栈
IL_0008: callvirt instance char[] System.String::ToCharArray()
IL_000d: stloc.1 // 结果存到局部变量 1(c)
IL_000e: ldloc.1
IL_000f: ldlen // 数组长度压栈
IL_0010: conv.i4 // 转成 int32
IL_0011: newarr System.Byte // 新建 byte 数组
IL_0016: stloc.2 // 存到局部变量 2(b)
IL_0017: ldc.i4.0 // 常量 0 压栈
IL_0018: stloc.3 // i = 0
IL_0019: br.s IL_0047 // 先跳到循环条件判断
IL_001b: nop // ---- 循环体开始 ----
IL_001c: ldloc.2 // b
IL_001d: ldloc.3 // i
IL_001e: ldloc.1 // c
IL_001f: ldloc.3 // i
IL_0020: ldelem.u2 // 取 c[i]
IL_0021: call uint8 System.Convert::ToByte(char)
IL_0026: stelem.i1 // b[i] = 结果
IL_0027: ldstr "{0:X} {1}"
IL_002c: ldloc.2
IL_002d: ldloc.3
IL_002e: ldelem.u1 // 取 b[i]
IL_002f: box System.Byte // 装箱成 object
IL_0034: ldloc.1
IL_0035: ldloc.3
IL_0036: ldelem.u2 // 取 c[i]
IL_0037: box System.Char // 装箱:数据放到托管堆,栈上只留引用
IL_003c: call void System.Console::WriteLine(string, object, object)
IL_0041: nop
IL_0042: nop
IL_0043: ldloc.3
IL_0044: ldc.i4.1
IL_0045: add // i + 1
IL_0046: stloc.3 // 存回 i
IL_0047: ldloc.3 // ---- 循环条件 ----
IL_0048: ldloc.1
IL_0049: ldlen
IL_004a: conv.i4
IL_004b: clt // i < c.Length ? 1 : 0
IL_004d: stloc.s V_4
IL_004f: ldloc.s V_4
IL_0051: brtrue.s IL_001b // 为真就跳回循环体
IL_0053: call valuetype System.ConsoleKeyInfo System.Console::ReadKey()
IL_0058: pop // 丢掉 ReadKey 的返回值
IL_0059: ret
几个看点:
for循环编译后是「先跳到条件判断,满足再跳回循环体」;nop和V_4这种临时变量是 Debug 编译才有的,Release 下会被优化掉;WriteLine(string, object, object)的两个参数都要box,值类型传给 object 参数就会装箱。
运行结果:

参考:
延伸
- 现在看 IL 更方便的工具:SharpLab 在浏览器里实时显示 C# 编译成的 IL、反编译的 C# 和 JIT 后的汇编,验证语法糖最快;桌面上可以用 ILSpy 或 dnSpy,能直接把 dll 反编译回 C#。
- 跨平台的 ILDasm:.NET Core 以后可以用
dotnet tool install -g dotnet-ildasm。 - JIT 之外还有 AOT:.NET 7 起可以发布成 Native AOT,编译时就把 IL 转成机器码,启动更快、不依赖运行时,但反射和动态加载会受限制。
- 想看 JIT 生成的汇编:设置环境变量
DOTNET_JitDisasm=Main再运行(.NET 7 起),或者在 SharpLab 里切到 JIT Asm。
原文首发于 blog.csdn.net