简介看.NET IL时最容易卡住的地方通常不是指令名字而是执行方式。例如这几行 ILldarg.0 ldarg.1 add ret如果按 C# 的写法去看会觉得很别扭没有变量名 没有 a b 这种表达式 add 也没有写操作数 ret 到底返回什么原因很简单.NET IL 是栈机模型。一句话概括栈机执行模型就是指令把数据压入计算栈再从栈顶弹出数据完成计算结果再压回栈顶。所以看 IL 的关键不是先背指令表而是先学会盯住一个东西Evaluation Stack中文一般叫计算栈 求值栈 操作数栈栈机到底是什么普通 C# 代码看起来像这样int result a b;表达式里直接写了两个操作数a b但 IL 的add指令不直接写操作数。它默认从计算栈顶拿数据栈顶弹出 b 栈顶弹出 a 计算 a b 把结果压回栈顶例如初始栈[] ldarg.0 - [a] ldarg.1 - [a, b] add - [a b] ret - 返回 a b这就是栈机的核心思路。栈机和寄存器机有什么区别现代 CPU 更接近寄存器机。寄存器机的指令通常会显式写出操作数位置mov eax, 10 add eax, 20这里的eax就是寄存器。栈机不这样写。栈机的指令更像push 10 push 20 addadd不写“加谁和谁”因为默认就是操作栈顶两个值。对比一下对比项栈机寄存器机操作数位置隐式在栈顶显式在寄存器或内存指令形式短一些操作数更明确编译器生成相对简单要考虑寄存器分配验证类型安全更容易更复杂最终执行仍会被 JIT 转成本机代码CPU 直接执行.NET IL 采用栈机模型不代表 CPU 最终也按 IL 栈机执行。真实流程是C# 源码 | v C# 编译器生成 IL | v CLR 加载程序集 | v JIT 把 IL 编译成本机代码 | v CPU 执行机器码所以栈机模型主要是 IL 这一层的执行抽象。三个容易混淆的“栈”讲栈机时经常会把几个概念混在一起。1. 调用栈调用栈记录方法调用关系。例如Main - CreateOrder - ValidateOrder每调用一个方法就会出现一个栈帧。栈帧里会有参数局部变量返回地址当前方法执行状态2. 计算栈计算栈是 IL 指令执行时用来放临时值的地方。例如ldc.i4.s 10 ldc.i4.s 20 add执行过程[] - [10] - [10, 20] - [30]计算栈属于当前方法的执行过程。3. 托管堆托管堆存放对象。例如var user new User();User对象在托管堆上局部变量里通常保存的是对象引用。三者关系可以简化成调用栈记录方法怎么一层层调用 计算栈记录当前方法里 IL 指令的临时操作数 托管堆存放对象实例第一个例子return a bC# 代码public static int Add(int a, int b) { return a b; }对应 IL 大致是ldarg.0 ldarg.1 add ret逐步看栈指令说明执行后计算栈初始空栈[]ldarg.0加载参数a[a]ldarg.1加载参数b[a, b]add弹出两个值相加后压栈[a b]ret返回栈顶值[]如果调用Add(10, 20)栈变化就是[] [10] [10, 20] [30] return 30第二个例子局部变量怎么参与C# 代码public static int Calc(int a, int b) { int sum a b; return sum * 2; }IL 大致是.locals init ([0] int32 sum) ldarg.0 ldarg.1 add stloc.0 ldloc.0 ldc.i4.2 mul ret这里出现了两个新东西stloc.0把栈顶值弹出存到第 0 个局部变量 ldloc.0把第 0 个局部变量加载到栈顶假设调用Calc(3, 4)执行过程指令计算栈局部变量初始[]sum ?ldarg.0[3]sum ?ldarg.1[3, 4]sum ?add[7]sum ?stloc.0[]sum 7ldloc.0[7]sum 7ldc.i4.2[7, 2]sum 7mul[14]sum 7ret[]返回14可以看到局部变量不是计算栈本身。 局部变量需要通过 ldloc 加载到栈上才能参与计算。第三个例子方法调用怎么走栈C# 代码public static int Double(int value) { return value * 2; } public static int Run() { return Double(10); }Run的 IL 大致是ldc.i4.s 10 call int32 Demo::Double(int32) ret栈变化指令说明计算栈初始空栈[]ldc.i4.s 10压入常量 10[10]call Double弹出参数调用方法把返回值压栈[20]ret返回栈顶值[]方法调用的关键点调用前参数按顺序压栈 调用时call 消耗参数 调用后如果有返回值返回值会压回栈顶实例方法里的 ldarg.0 是什么静态方法里ldarg.0 第一个参数 ldarg.1 第二个参数实例方法里ldarg.0 this ldarg.1 第一个业务参数 ldarg.2 第二个业务参数例如public class Counter { private int _value; public void Add(int amount) { _value amount; } }Add方法里要访问字段_value就得先加载thisldarg.0 ldarg.0 ldfld int32 Counter::_value ldarg.1 add stfld int32 Counter::_value ret这里的栈变化可以拆成ldarg.0 - [this] ldarg.0 - [this, this] ldfld _value - [this, 当前_value] ldarg.1 - [this, 当前_value, amount] add - [this, 新_value] stfld _value - []stfld需要两个东西对象引用 要写入的新值所以前面要先把this留在栈里。装箱也是栈操作C# 代码int number 123; object value number;IL 大致是ldc.i4.s 123 stloc.0 ldloc.0 box int32 stloc.1栈变化指令说明计算栈ldc.i4.s 123压入整数[123]stloc.0保存到number[]ldloc.0重新加载number[123]box int32装箱成对象引用[object]stloc.1保存到value[]box做的事情不是简单改类型名而是在托管堆上创建一个对象把值复制进去然后把对象引用压回计算栈。ret 返回什么ret不写返回值变量。返回值来自栈顶。例如返回int的方法执行到ret前计算栈顶必须有一个int32。ldc.i4.s 100 ret表示return 100;如果方法返回void执行到ret前计算栈通常应该是空的。call void [System.Console]System.Console::WriteLine(string) ret这也是 IL 验证要检查的重要内容之一返回值类型和栈顶类型必须匹配。.maxstack 是什么IL 方法里经常能看到.maxstack 2它表示这个方法执行过程中计算栈最多需要多深。例如ldarg.0 ldarg.1 add ret最大深度是 2ldarg.0 - [a] 深度 1 ldarg.1 - [a, b] 深度 2 add - [a b] 深度 1所以.maxstack 2就够了。现代工具和运行时通常能帮忙计算或验证但理解.maxstack对手写 Emit 很有帮助。可运行 Demo写一个小型栈机解释器下面写一个小型栈机模拟几条 IL 指令。支持这些指令ldarg ldc add sub mul stloc ldloc ret创建项目mkdir StackMachineDemo cd StackMachineDemo dotnet new console修改Program.csusing System; using System.Collections.Generic; using System.Linq; Instruction[] program [ new(OpCode.LdArg, 0), new(OpCode.LdArg, 1), new(OpCode.Add), new(OpCode.StLoc, 0), new(OpCode.LdLoc, 0), new(OpCode.Ldc, 2), new(OpCode.Mul), new(OpCode.Ret) ]; var machine new MiniStackMachine(argumentCount: 2, localCount: 1); int result machine.Execute(program, [3, 4]); Console.WriteLine($执行结果{result}); public enum OpCode { LdArg, Ldc, Add, Sub, Mul, StLoc, LdLoc, Ret } public readonly record struct Instruction(OpCode Code, int Operand 0); public sealed class MiniStackMachine { private readonly int[] _arguments; private readonly int[] _locals; private readonly Stackint _stack new(); public MiniStackMachine(int argumentCount, int localCount) { _arguments new int[argumentCount]; _locals new int[localCount]; } public int Execute(Instruction[] instructions, int[] arguments) { if (arguments.Length ! _arguments.Length) { throw new ArgumentException(参数数量不匹配); } arguments.CopyTo(_arguments, 0); for (int ip 0; ip instructions.Length; ip) { Instruction instruction instructions[ip]; Console.WriteLine($执行前{FormatStack(),-14} 指令{FormatInstruction(instruction)}); switch (instruction.Code) { case OpCode.LdArg: _stack.Push(_arguments[instruction.Operand]); break; case OpCode.Ldc: _stack.Push(instruction.Operand); break; case OpCode.Add: { int right _stack.Pop(); int left _stack.Pop(); _stack.Push(left right); break; } case OpCode.Sub: { int right _stack.Pop(); int left _stack.Pop(); _stack.Push(left - right); break; } case OpCode.Mul: { int right _stack.Pop(); int left _stack.Pop(); _stack.Push(left * right); break; } case OpCode.StLoc: _locals[instruction.Operand] _stack.Pop(); break; case OpCode.LdLoc: _stack.Push(_locals[instruction.Operand]); break; case OpCode.Ret: { int result _stack.Pop(); Console.WriteLine($执行后{FormatStack(),-14} 返回{result}); return result; } default: throw new NotSupportedException($不支持的指令{instruction.Code}); } Console.WriteLine($执行后{FormatStack()}); } throw new InvalidOperationException(缺少 ret 指令); } private string FormatStack() { return [ string.Join(, , _stack.Reverse()) ]; } private static string FormatInstruction(Instruction instruction) { return instruction.Code switch { OpCode.LdArg $ldarg.{instruction.Operand}, OpCode.Ldc $ldc.i4 {instruction.Operand}, OpCode.StLoc $stloc.{instruction.Operand}, OpCode.LdLoc $ldloc.{instruction.Operand}, OpCode.Add add, OpCode.Sub sub, OpCode.Mul mul, OpCode.Ret ret, _ instruction.Code.ToString() }; } }运行dotnet run输出类似执行前[] 指令ldarg.0 执行后[3] 执行前[3] 指令ldarg.1 执行后[3, 4] 执行前[3, 4] 指令add 执行后[7] 执行前[7] 指令stloc.0 执行后[] 执行前[] 指令ldloc.0 执行后[7] 执行前[7] 指令ldc.i4 2 执行后[7, 2] 执行前[7, 2] 指令mul 执行后[14] 执行前[14] 指令ret 执行后[] 返回14 执行结果14这个小 Demo 模拟的就是int sum a b; return sum * 2;用 DynamicMethod 生成真实 IL小型栈机是为了理解模型。下面用真实DynamicMethod生成同样的逻辑using System; using System.Reflection.Emit; Funcint, int, int calc CreateCalc(); Console.WriteLine(calc(3, 4)); static Funcint, int, int CreateCalc() { DynamicMethod method new( name: Calc, returnType: typeof(int), parameterTypes: new[] { typeof(int), typeof(int) }); ILGenerator il method.GetILGenerator(); LocalBuilder sum il.DeclareLocal(typeof(int)); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldarg_1); il.Emit(OpCodes.Add); il.Emit(OpCodes.Stloc, sum); il.Emit(OpCodes.Ldloc, sum); il.Emit(OpCodes.Ldc_I4_2); il.Emit(OpCodes.Mul); il.Emit(OpCodes.Ret); return method.CreateDelegateFuncint, int, int(); }输出14这段 Emit 和小型栈机 Demo 是同一套思路ldarg.0 ldarg.1 add stloc ldloc ldc.i4.2 mul ret区别只是小型栈机用 C# 模拟执行DynamicMethod生成真正 CLR 可以执行的 IL栈不平衡会怎样栈机有一个硬规则每条指令执行前栈上必须有足够的操作数。 方法返回时栈状态必须和返回类型匹配。例如这段 IL 思路就是错的ldarg.0 add retadd需要两个值但栈里只有一个值。再比如返回int的方法ret如果ret前栈是空的也不合法。小型栈机里也能看到类似错误。把前面的程序改成Instruction[] badProgram [ new(OpCode.LdArg, 0), new(OpCode.Add), new(OpCode.Ret) ];执行到Add时_stack.Pop()会因为栈里不够两个值而失败。真实 CLR 在加载、验证或 JIT 阶段也会检查类似问题。常见指令按栈行为分类指令栈行为说明ldarg.*压栈加载参数ldloc.*压栈加载局部变量ldc.i4.*压栈加载整数常量ldstr压栈加载字符串引用stloc.*弹栈保存到局部变量stfld弹栈写字段add弹两个压一个加法sub弹两个压一个减法mul弹两个压一个乘法call弹参数可能压返回值调用方法callvirt弹对象引用和参数可能压返回值调用实例方法box弹值类型压对象引用装箱newobj弹构造参数压对象引用创建对象ret弹返回值或空返回方法返回看 IL 时可以把每条指令都翻译成它压了什么 它弹了什么 它留下了什么为什么 C# 编译器喜欢这种模型栈模型有几个好处。1. IL 更接近语言无关格式.NET 不只支持 C#。还有 F#、VB.NET 等语言。这些语言都可以编译到 IL。栈机指令不绑定具体 CPU 寄存器更适合作为统一中间语言。2. 编译器生成更简单如果中间语言直接暴露寄存器编译器就要处理寄存器分配。栈机模型更像表达式求值先把操作数放到栈上 再执行操作 结果留在栈上这对前端语言编译器更友好。3. 验证更容易CLR 可以检查某条指令执行前栈深度够不够栈顶类型对不对方法返回值类型是否匹配分支跳转后的栈状态是否一致是否把错误类型传给方法调用例如add需要数值类型。如果栈顶是两个对象引用就不符合要求。4. JIT 还有优化空间栈机只是 IL 层的模型。JIT 编译成本机代码时会把这些栈操作映射到寄存器、内存和机器指令上。也就是说最终 CPU 执行时不一定真的按“压栈、弹栈”一步步运行。栈机模型和性能有什么关系栈机模型本身不是性能问题的直接答案。因为最终会经过 JIT 优化。但理解它有助于识别这些行为装箱和拆箱局部变量读写方法调用参数压栈虚调用和非虚调用结构体复制try/finally展开方式async/await状态机生成方式例如看到box int32就能知道这里发生了装箱。看到callvirt就能进一步判断是否涉及虚调用或空引用检查。常见误区1. 计算栈不是托管堆计算栈只是临时操作数栈。对象实例仍然在托管堆上。计算栈里经常保存的是对象引用而不是完整对象。2. 局部变量不等于计算栈局部变量有自己的槽位。要参与计算需要先用ldloc压入计算栈。要保存结果需要用stloc从计算栈弹出。3. ret 不是返回某个变量名ret返回的是栈顶值。源码里写的是return result;IL 里通常是ldloc.0 ret也就是先把result加载到栈顶再返回。4. 栈机模型不等于 CPU 最终执行方式IL 是栈机。JIT 后的机器码会使用真实 CPU 的寄存器和内存。所以不要把 IL 栈变化直接等同于最终机器码开销。5. callvirt 不一定只表示虚调用C# 编译器经常对普通实例方法也生成callvirt。原因之一是它可以顺带做空引用检查。所以看到callvirt时不能直接断定一定发生了多态虚派发。实战建议看 IL 时可以按这套顺序来先找方法签名 再看参数和局部变量 逐行跟踪计算栈 遇到 call 看它消耗哪些参数 遇到 ret 看栈顶是什么 遇到 box / unbox 看是否有值类型转换 遇到 branch 看不同路径的栈状态是否一致如果只是为了日常业务开发不需要背所有 IL 指令。但掌握栈机模型后再看这些内容会清楚很多IL 中间码Reflection.Emit表达式树编译JIT 优化装箱拆箱async/await 状态机动态代理和 ORM 底层实现总结.NET IL 的栈机执行模型可以用一句话记住数据先压栈指令从栈顶取数据结果再压回栈顶。理解这件事后很多 IL 指令都会变得直观ldarg是把参数压栈ldloc是把局部变量压栈stloc是把栈顶保存到局部变量add是弹出两个值再压回一个结果call是弹出参数并压回返回值ret是返回栈顶值栈机模型不是为了替代 C# 编程而是为了看懂 C# 编译后的真实形态。看懂这层模型后IL、Emit、JIT、装箱、动态代理这些内容都会更容易串起来。