C# 代码如何变成机器码:编译与执行全链路
系列:C#与常用数据结构源码剖析 · 运行时底层剖析
阅读时间:约 45 分钟
前置知识:C# 基本语法、IL 基础概念
一、引言
当你在 IDE 中敲下 var list = new List<int>() 并按下 F5 时,这行代码经历了一段漫长的旅程才能变成 CPU 能理解的机器指令。这段旅程至少穿越四个关键站点:Roslyn 编译器(C# → IL)、CLR 加载器(IL → 运行时类型)、JIT/AOT 编译器(IL → 机器码)、以及运行时执行引擎(机器码 + GC + 异常处理)。
对于理解数据结构的行为而言,这个全链路的重要性怎么强调都不为过。比如,你写了一个 struct Enumerator 来避免 foreach 的 GC 分配——这个优化是否生效,取决于 JIT 是否对枚举器做了去虚拟化。你在 IL2CPP 下使用 dynamic 关键字——它会直接报错,因为 AOT 编译根本不可能生成动态代码。
本篇文章是这个专栏的"地基"——在深入每一个数据结构的源码之前,我们需要先建立"代码如何运行"的完整心智模型。我们将从编译器前端一路追踪到 CPU 执行单元,并在每个阶段标注关键源码位置,方便后续深入查阅。
二、第一阶段:Roslyn 编译器——从 C# 到 IL
2.1 Roslyn 的架构设计
Roslyn 是 .NET 的"编译器即服务"(Compiler-as-a-Service)实现。与传统的"黑盒"编译器不同,Roslyn 将编译过程的每个阶段都暴露为公开 API,使得 IDE 的智能提示、代码分析器、重构工具可以直接使用编译器的内部数据。
Roslyn 的编译流水线分为四个核心阶段:
-
语法分析(Parser):将源代码文本解析为一棵语法树(SyntaxTree)。每个 token(关键字、标识符、运算符、字面量)被分类为
SyntaxToken,并按语法规则组织为SyntaxNode树。例如,var x = 1 + 2;会被解析为LocalDeclarationStatementSyntax节点,其子节点包括EqualsValueClauseSyntax和BinaryExpressionSyntax。 -
语义分析(Binder):在语法树的基础上构建语义模型(SemanticModel)。这个阶段进行符号解析(
x是什么类型的变量?List是指哪个命名空间中的类?)、类型检查、重载决议。SemanticModel 回答了"这段代码是什么意思"的问题。 -
绑定与 Lowering:将语法树转换为 BoundTree——一个更接近 IL 的中间表示。在这个阶段,高级语法糖被"降级"为更基础的表达形式。例如,
using语句被展开为try-finally,foreach被转换为while+ 枚举器模式,async方法被重写为状态机类。 -
IL 发射(Emit):将 BoundTree 转换为 IL 字节码,并写入 PE(Portable Executable)格式的 .dll 或 .exe 文件。同时写入完整的元数据表(Metadata Tables)——包括类型定义(TypeDef)、方法定义(MethodDef)、字段定义(FieldDef)等。
2.2 实战:从语法树到 IL 的亲眼见证
我们以一个最简单的 List<int>.Add 调用为例:
var list = new List<int>();
list.Add(42);
在 Roslyn 的 Syntax Visualizer 中,list.Add(42) 这行会被解析为:
InvocationExpressionSyntax
├── MemberAccessExpressionSyntax
│ ├── IdentifierNameSyntax "list"
│ └── IdentifierNameSyntax "Add"
└── ArgumentListSyntax
└── ArgumentSyntax
└── NumericLiteralExpressionSyntax "42"
经语义分析后,编译器知道 list 是 List<int> 类型,Add 是 List<T>.Add(T) 方法,42 作为 int 参数。
最终发射的 IL 大致为:
// new List<int>()
newobj instance void class [System.Collections]System.Collections.Generic.List`1<int32>::.ctor()
stloc.0 // 存储到局部变量 0
// list.Add(42)
ldloc.0 // 加载 list
ldc.i4.s 42 // 加载常量 42
callvirt instance void class [System.Collections]System.Collections.Generic.List`1<int32>::Add(!0)
注意几个关键细节:
newobj指令负责在托管堆上分配对象(调用 GC 的分配器),并调用构造函数stloc.0将栈顶的值存储到局部变量槽位 0callvirt用于虚方法调用(即使Add不是虚方法,对于引用类型的实例方法,编译器也使用callvirt——因为它附带空引用检查)!0是泛型参数占位符,表示List<T>的第一个类型参数(即int)
2.3 程序集的结构
编译产物是一个 PE 格式的文件。其内部结构如下:
PE Header
├── CLI Header
├── Metadata Tables
│ ├── Module (0x00)
│ ├── TypeDef (0x02) ← 你定义的每个类
│ ├── MethodDef (0x06) ← 每个方法的签名
│ ├── FieldDef (0x04) ← 每个字段
│ ├── MemberRef (0x0A) ← 引用的外部类型/方法
│ └── TypeRef (0x01) ← 引用的外部类型
├── IL Code Stream
└── Resources
元数据表存储了程序集中所有类型的完整描述。当 JIT 编译器需要知道一个方法的参数类型、一个字段的偏移量、或者一个类的继承链时,都是通过查询这些元数据表获得的。
2.4 编译器源码位置
如果你想深入 Roslyn 的源码,核心入口在:
-
C# 编译器前端:
dotnet/roslyn/src/Compilers/CSharp/Portable/Parser/— 语法分析器Binder/— 语义绑定器BoundTree/— 绑定树节点定义CodeGen/— IL 发射器
-
编译入口点:
CSharpCompilation.Create()→CompileAndEmit()→Emit()
三、第二阶段:运行时加载——从 PE 文件到运行时类型
3.1 CLR 加载器的工作流程
当 .NET 运行时首次遇到一个类型引用时(例如 JIT 编译某个方法时发现它调用了 List<int>),加载器被触发:
-
Assembly 加载:
Assembly.Load()根据程序集名称查找并加载 PE 文件。对于强命名程序集,会进行版本校验和签名验证。CoreCLR 使用AssemblyLoadContext来隔离不同上下文的程序集加载。 -
Module 解析:每个程序集包含一个或多个 Module(通常只有一个)。Module 是元数据的作用域。CLR 创建内部数据结构
Module对象来持有元数据表。 -
类型加载(TypeLoader):这是最复杂的阶段。类型加载器采用分级加载策略——不是一次性加载所有信息,而是按需、分步加载。具体机制将在下一篇文章(类型加载器)中详述。关键数据结构有:
MethodTable:类型的运行时"身份证",存储 vtable、接口映射、GC 布局信息EEClass:类型的"冷数据",只在类型加载和反射时使用MethodDesc:方法的运行时表示,包含入口点(Entry Point)指针
-
方法入口点的建立:对于每个方法,CLR 会创建一个 stub(一小段跳转代码),这个 stub 在方法首次被调用前指向 JIT 编译器的入口。当方法被首次调用时,stub 跳转到 JIT,JIT 编译完成后将 stub 更新为指向编译后的机器码——这就是"首次调用触发 JIT"的机制。
3.2 类型的运行时层次结构
TypeHandle (抽象)
├── MethodTable (普通类型、泛型闭合类型)
│ ├── Parent MethodTable (父类型)
│ ├── Interface Map (实现的接口)
│ ├── VTable Slots (虚方法表)
│ └── GC Layout Info
└── TypeDesc (指针、byref、泛型参数变量)
├── ParamTypeDesc (T[], T&, T*)
├── FunctionTypeDesc (delegate*)
└── TypeVarTypeDesc (泛型方法中的 T)
这个层次结构决定了所有类型相关的运行时操作——对象分配(newobj 需要知道对象大小)、方法分派(callvirt 需要查 vtable)、GC 扫描(需要知道哪些字段是引用)——都是通过 MethodTable 完成的。
四、第三阶段:JIT 编译——IL 到机器码
4.1 JIT 的触发与接口
JIT 编译的最典型触发场景是:方法首次被调用。具体流程:
- 调用方执行
call或callvirt指令 - 目标方法的入口点(Entry Point)指向一个 Precode Stub
- Precode Stub 调用 JIT 编译器的
compileMethod()入口 - JIT 将 IL 编译为机器码,写入可执行内存
- Precode Stub 被改写(backpatch)为指向新生成的机器码
- 后续调用直接跳转到机器码,不再经过 JIT
JIT 编译器与运行时之间通过两个接口交互:
-
ICorJitCompiler(JIT 端):运行时调用 JIT,定义在
src/coreclr/inc/corjit.hcompileMethod():核心入口,接收 IL 字节码,返回机器码
-
ICorJitInfo(运行时端):JIT 回调运行时,定义在
src/coreclr/inc/corinfo.hgetMethodDefFromMethod():获取方法元数据getClassAttribs():获取类型属性getFieldOffset():获取字段在对象中的偏移量
4.2 RyuJIT 的编译阶段概览
现代 .NET 使用 RyuJIT 编译器。它的编译流水线分为 前端、中端、后端 三大阶段,包含超过 20 个子阶段:
前端 (Frontend)
├── Importation : IL → GenTree IR
├── Inlining : 内联候选方法的 IR
├── Morph : 形态变换(字段访问→指针算术)
中端 (Middle-end)
├── SSA Construction : 构建静态单赋值形式
├── Value Numbering : 值编号(消除重复计算)
├── CSE : 公共子表达式消除
├── Range Analysis : 范围分析(推导数组索引范围)
├── Bounds Check Elim : 边界检查消除
├── Loop Optimization : 循环优化(克隆、展开、IV 优化)
├── Dead Store Elim : 死存储消除
后端 (Backend)
├── Rationalization : HIR → LIR(线性化)
├── Lowering : 暴露控制流和寄存器需求
├── Register Alloc : 线性扫描寄存器分配
└── Code Generation : 遍历 IR 生成机器码
每个阶段的具体细节将在后续文章(JIT 编译管线、JIT 优化全景)中详细展开。这里先建立一个全局视图:
- GenTree IR 是贯穿整个 JIT 的核心数据结构。前端使用树形节点(父-子链接),后端使用线性节点(prev-next 链接)。
- 内联(Inlining) 是最有影响力的优化——将小方法的代码直接嵌入调用方,消除调用开销并启用更多的跨方法优化。
- SSA(静态单赋值) 是 JIT 分析的数据基础——每个变量只被赋值一次,使得数据流分析变得精确。
- 值编号(Value Numbering) 将语义相同的表达式映射到同一个编号,使得 CSE 能识别并消除冗余计算。
4.3 源码位置
JIT 编译器源码全部在 dotnet/runtime/src/coreclr/jit/ 目录下:
| 文件 | 内容 |
|---|---|
compiler.cpp / compiler.hpp | Compiler 主类 |
gentree.cpp / gentree.h | GenTree IR 定义 |
morph.cpp | 形态变换 |
inline.cpp | 内联决策和实现 |
ssa.cpp | SSA 构建 |
valueNum.cpp | 值编号 |
rangecheck.cpp | 范围分析和边界检查消除 |
loopcloning.cpp / loopunroll.cpp | 循环优化 |
rationalize.cpp | IR 理性化 |
lower.cpp / lsra.cpp | Lowering 和寄存器分配 |
codegen*.cpp | 代码生成 |
五、第四阶段:AOT 编译——另一种执行路径
5.1 为什么需要 AOT
JIT 编译的优势在于:可以利用运行时的实际类型信息做特化优化、可以动态生成代码。但它有两个致命弱点:
- 冷启动慢:应用启动时需要编译大量代码
- 需要运行时可写可执行内存:iOS 等平台不允许 JIT
AOT(Ahead-of-Time)编译在应用部署之前就将 IL 预编译为机器码,解决了这两个问题——但代价是失去了 JIT 的灵活性。
5.2 三种 AOT 方案
.NET 生态中有三种主要的 AOT 方案:
ReadyToRun(R2R)
- 发行版中包含预编译的机器码 + IL(作为回退)
- 运行时会利用 R2R 代码加速启动,但仍可用 JIT 处理未预编译的方法
- 需要 JIT 运行时仍然存在
- 源码位置:
dotnet/runtime/src/coreclr/tools/crossgen2/
NativeAOT
- 将整个应用完全预编译为原生代码
- 包含一个精简的运行时(GC、异常处理、线程管理),但没有 JIT
- 不支持
System.Reflection.Emit、Assembly.Load动态加载 - 需要提前注册泛型实例化
- 源码位置:
dotnet/runtime/src/coreclr/nativeaot/
IL2CPP(Unity 专用)
- 工作流:C# → IL DLL → IL2CPP 转换器 → C++ 源码 → 平台 C++ 编译器 → Native
- 泛型处理:编译时分析代码引用,为值类型泛型生成特化代码,引用类型泛型使用共享代码
- 对数据结构的直接影响:缺失的泛型实例化会在运行时抛
MissingMethodException - IL2CPP 本身的源码属于 Unity 的专有技术,不完全开源
5.3 AOT 对数据结构的限制
AOT 编译下,以下与数据结构相关的特性受到限制:
| 特性 | JIT | AOT (NativeAOT/IL2CPP) |
|---|---|---|
dynamic 关键字 | ✅ 运行时动态绑定 | ❌ 需要 JIT 编译器 |
Reflection.Emit | ✅ | ❌ 完全不可用 |
MakeGenericMethod(typeof(X)) | ✅ | ⚠️ 仅限已注册的类型 |
Assembly.Load | ✅ | ❌ 不支持动态程序集 |
List<MyStruct> | ✅ 运行时 JIT 特化 | ⚠️ 需在编译期确定 |
六、机器码执行时的运行时服务
编译得到的机器码不是孤立运行的。它在每一步执行中都得到运行时的服务:
6.1 GC 的协作
JIT 编译器生成的机器码中嵌入了 GC 信息(GC Info):
- 哪些寄存器/栈位置在代码的哪个点持有活动 GC 引用
- GC 安全点(Safe Point):可以安全暂停线程进行 GC 的代码位置
- 写屏障(Write Barrier):当引用字段被赋值时,JIT 在赋值指令后插入写屏障代码——标记跨代引用的卡表(Card Table)
写屏障是 GC 高性能的关键。如果没有它,每次 Minor GC(仅回收 Gen0)都需要扫描所有 Gen2 对象的引用——那将是灾难性的性能开销。有了写屏障,GC 只需扫描卡表中标记的被修改过的内存页。
6.2 异常处理
C# 中的 try-catch-finally 在 IL 层面是通过受保护区域(Protected Region)表达的,在机器码层面则映射到平台的展开表(Unwind Table)。当异常发生时,运行时根据展开表找到对应的方法帧,执行正确的 catch 或 finally 处理。
6.3 空引用检查
C# 中访问空引用成员会抛出 NullReferenceException。这不是编译器插入的显式检查——而是利用硬件的页面保护机制:地址 0 附近的页面被标记为不可访问,任何对空指针的解引用都会触发硬件异常(Access Violation),运行时捕获这个异常并转换为 NullReferenceException。这个设计使得空引用检查几乎零性能开销。
七、Unity 中的特殊编译链路
Unity 开发者面对的是一个混合的编译世界:
7.1 编辑器模式
[编辑器模式]
C# 源码 → Roslyn → .NET DLL → Mono Runtime (JIT)
├── 编辑器中的脚本编译极快(增量编译)
├── Play Mode 进入时无预编译等待
└── GC 使用 Boehm(保守式),非分代
编辑器模式的体验很流畅,脚本修改后几乎立即生效。但要注意,编辑器中的 JIT 行为和真机的 AOT 行为可能有很大差异——某些在编辑器正常运行的功能(如 dynamic、Reflection.Emit),在 IL2CPP 真机 build 中会直接崩溃。
7.2 IL2CPP 构建模式
[IL2CPP 构建模式]
C# 源码 → Roslyn → .NET DLL → IL2CPP 转换器 → C++ 源码
→ 平台 C++ 编译器 (Xcode/MSVC/Clang) → Native Binary
├── 构建时间长(C++ 编译 + 链接)
├── 不支持 JIT 依赖特性(dynamic、Reflection.Emit)
└── 泛型需要提前确定所有实例化
Unity 6 的 C# 版本支持:
- 官方支持 C# 9.0
- 编译器使用 Roslyn
- API 兼容级别:.NET Standard 2.1
这意味着 C# 10/11/12 的新特性(如全局 using、主构造函数、集合表达式、list patterns)在 Unity 中默认不可用。但有部分特性可以通过手动配置 csc.rsp 文件来尝试启用——前提是它们不依赖新的运行时特性(例如集合表达式需要 C# 12 的 CollectionBuilderAttribute)。
八、关键源码索引
下表汇总了本篇涉及的所有关键源码位置:
| 组件 | 仓库 | 关键路径 |
|---|---|---|
| Roslyn C# 编译器 | dotnet/roslyn | src/Compilers/CSharp/Portable/ |
| CoreCLR 运行时 | dotnet/runtime | src/coreclr/vm/ |
| RyuJIT 编译器 | dotnet/runtime | src/coreclr/jit/ |
| CoreCLR GC | dotnet/runtime | src/coreclr/gc/gc.cpp |
| NativeAOT | dotnet/runtime | src/coreclr/nativeaot/ |
| Mono 运行时 | dotnet/runtime | src/mono/ |
| BOTR 架构文档 | dotnet/runtime | docs/design/coreclr/botr/ |
| Roslyn 架构文档 | dotnet/roslyn | docs/compilers/CSharp/ |
| IL2CPP (Unity) | Unity 内部 | 非开源 |
九、总结
从 var list = new List<int>() 这行代码到 CPU 真正执行 List<T>.Add 的机器码,中间经历了:
- Roslyn 将它解析为语法树 → 绑定为语义模型 → 发射为 IL 字节码
- CLR 加载器 将它注册为运行时类型,创建 MethodTable 和 MethodDesc
- JIT 编译器(首次调用时)将 IL 编译为最优机器码,经过 20+ 个优化阶段
- 或者 AOT 编译器在构建期就将它预编译为机器码(以牺牲灵活性换取启动速度)
- 运行时在执行时持续提供 GC 协作、异常处理和安全检查
理解这个全链路,就像打通了任督二脉。后续每一篇关于 List<T> 扩容、Dictionary 哈希碰撞、Span<T> 零分配的文章,都会反复回到这个链路中的某个环节来解释"为什么会这样设计"和"为什么性能是这样"。

94

被折叠的 条评论
为什么被折叠?



