C# 如何运行:从源码到执行的跨年代全链路
系列:C# 与常用数据结构源码剖析 · 历史与演化篇
文章定位:从 .NET Framework 到现代 .NET、Mono 与 Unity IL2CPP 的执行模型概览
版本边界:语言示例使用 C# 12;现代 .NET 示例以 .NET 8 为基线;私有实现随版本、平台和部署模型变化
一、同一份 C#,并不只有一条执行路线
下面这段代码可以出现在 .NET Framework 桌面程序、现代 .NET 服务、Mono 应用或 Unity 游戏中:
var values = new List<int> { 10, 20, 30 };
Console.WriteLine(values.Sum());
“C# 编译为 IL,再由 JIT 编译成机器码”是经典解释,却只覆盖其中一种路线。现代项目还可能使用 ReadyToRun(R2R)预编译体、NativeAOT 原生发布,或 Unity IL2CPP 的 IL 到 C++ 转换。即使同为 JIT,.NET Framework CLR、现代 CoreCLR 与 Mono 的加载器、GC、优化管线和部署规则也不是同一个实现。
跨年代仍然稳定的共同主线是:
C# source
-> compiler parses and binds language semantics
-> compiler emits CLI metadata + method bodies, usually IL in a PE file
-> host selects and initializes a runtime/deployment model
-> loader resolves assemblies, types, methods and dependencies
-> JIT/AOT/native toolchain supplies executable code
-> runtime services coordinate GC, exceptions, threads and interop
这张图刻意不写 MethodTable 字段偏移、JIT 固定阈值或 GC 代数,因为它们不是 C# 的公共执行契约。本文负责历史坐标与全链路;具体实现可继续阅读 01 章的 Roslyn、类型加载、JIT、PGO、GC、AOT 和 Mono 专篇。
二、时间线:变化的是后端与部署,共同语言不断扩大
| 时期 | 代表模型 | 关键变化 |
|---|---|---|
| 2002 起 | C# 1.x + .NET Framework CLR | Windows 上以程序集、CIL、元数据、JIT、AppDomain 和 GAC 为核心 |
| 2004 起 | Mono | 在多平台实现 CLI/C# 生态,逐步发展 JIT、解释器与 AOT 能力 |
| 2014–2015 起 | Roslyn、.NET Core 开源化 | 编译器平台化;CoreCLR/CoreFX 走向跨平台、应用本地部署 |
| .NET Core 1.x—3.x 时期 | ReadyToRun 与早期 crossgen | 为 CoreCLR 程序提供构建期预编译入口,同时保留运行时 JIT;具体发布节点按对应 runtime release/tag 核验 |
| .NET 5 以后 | crossgen2 逐步成为 SDK ReadyToRun 工具链 | 使用托管实现推进跨平台 R2R 编译;预览、默认采用和各平台支持不是同一个时间点,应按目标 SDK/release note 锁定 |
| 2015 起持续演进 | Unity IL2CPP | 将托管程序集转为 C++,再由目标平台原生工具链 AOT 构建 Player |
| .NET 7/8 现代产品化 | NativeAOT | .NET SDK 支持闭世界原生发布,不在运行时携带 JIT |
时间线不表示每项技术突然从某一天完整出现。ReadyToRun 格式、早期 crossgen 与后来的 crossgen2 也不是同一个历史节点;精确首发、预览和默认采用时间必须由目标 release note/tag 支撑。Mono AOT、CoreRT、Unity IL2CPP 与 NativeAOT 同样经历多年演化;“NativeAOT 是 Unity 与 .NET 团队共同开发出来的 IL2CPP 替代品”没有可靠历史依据。今天名字相近的 AOT 方案也不能相互推导实现。
语言版本、编译器、目标框架、运行时与 IDE 是五条独立轴。C# 12 代码能否编译由编译器与 LangVersion 决定;能否调用某 API 由 target framework/reference assemblies 决定;最终如何执行由部署产物和 runtime 决定。Unity 还要加上 Editor 版本、API Compatibility、脚本后端与目标 Player 平台。
三、编译器前端:Binder 负责“理解”,Emitter 负责“写出”
3.1 从字符到语法树
Roslyn 首先进行词法与语法分析,把源码构造成保留 trivia 的语法树。树描述“写了什么”,即使代码含错误也能支持 IDE 着色、补全和增量分析。
const string source = "int Add(int x, int y) => x + y;";
Microsoft.CodeAnalysis.SyntaxTree tree =
Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(source);
Console.WriteLine(tree.GetRoot().Kind());
Roslyn 的 red/green tree 是编译器实现设计,不是 ECMA C# 语言规范要求其他编译器复制的 ABI。
3.2 Binding 不是 CIL 代码生成
语义绑定把名称和表达式联系到符号与类型,并执行作用域查找、重载决议、泛型类型推断、转换分类、可访问性检查等工作。对于 values.Add(42),Binder 要回答:values 的类型是什么、候选 Add 有哪些、整数常量如何转换、最终绑定到哪个方法。
Binder 的产物可理解为已绑定语义表示,后续 lowering 会把高级语言结构变换为更接近可发射形式的表示,emit 阶段才写方法体、元数据和调试信息。把流程写成“Binder 直接生成 CIL opcode”会混淆职责,也会掩盖 async、迭代器、模式匹配、插值字符串等特性的 lowering。
概念流程如下:
parse -> syntax tree
declare symbols -> bind names/types/operations
lower high-level constructs -> executable representation
emit metadata + IL/native-related artifacts + debug information
Release 优化同样不能简单归为“Roslyn 完成内联”。常量折叠与控制流简化可能发生在前端,跨方法内联通常是 JIT/AOT 后端根据目标与上下文做出的决定。
3.3 callvirt 不等于一定做虚分派
对某些实例方法,C# 编译器会使用 callvirt 获得空引用检查语义,即使目标方法不是虚方法;真正虚分派还取决于 token、方法属性、约束调用和运行时优化。因此看到一条 IL 指令后,应先按 ECMA-335 解释语义,再结合目标 runtime 的 JIT/AOT 输出判断机器调用形态。
四、ECMA-335 共同语言:CIL、元数据与验证规则
C# 规范定义语言,ECMA-335 定义 Common Language Infrastructure(CLI),其中包括类型系统、元数据、CIL 指令、程序集和虚拟执行系统。C# 并不等于 CLI:F#、VB 等语言也可生成 CLI 工件;C# 的某些规则比 CLI 更严格,unsafe 代码则可能超出 verifiable IL 子集。
下面方法常见的 IL 形状是:
static int Add(int left, int right) => left + right;
// 示意输出;token、签名显示和短指令由工具/构建决定
.method private hidebysig static int32 Add(int32 left, int32 right) cil managed
{
ldarg.0
ldarg.1
add
ret
}
CIL 是基于求值栈的中间语言。ldarg 把参数压栈,add 消耗两个值并压回结果,ret 返回。方法体还可包含局部变量签名、最大栈深、分支以及异常处理区间。
元数据 token 是“表编号 + 行标识”的句柄,用于引用当前模块或通过 TypeRef/MemberRef/AssemblyRef 间接引用外部定义。token 不是进程内地址,也不是跨重新构建稳定的业务 ID。
五、PE、metadata 与 IL:文件记录声明,不预言对象布局
托管程序集通常使用 PE/COFF 容器承载 CLI header、元数据流、方法体、资源和调试关联信息,即使最终运行在非 Windows 平台。关键元数据表包括:
TypeDef/TypeRef/TypeSpec:定义、引用与构造类型;MethodDef/MemberRef/MethodSpec:方法定义、引用与泛型实例化;Field、Param、Property、Event:成员声明;Assembly/AssemblyRef/Module:身份与依赖;GenericParam/GenericParamConstraint:泛型参数与约束;- custom attributes:以构造器引用和序列化 blob 表达的附加信息。
普通自动布局类型的 Field 表记录字段名称、签名和 flags,不会替每个 runtime/architecture 固定计算托管对象字段偏移。布局可能受 StructLayout、显式/顺序布局、泛型实例化、指针大小、对齐和具体运行时影响。即使 metadata 中存在 FieldLayout,也只应按相应布局契约解释,不能据此臆测所有引用类型的对象头和私有运行时字段。
using System;
using System.IO;
using System.Reflection.Metadata;
using System.Reflection.PortableExecutable;
using FileStream stream = File.OpenRead(args[0]);
using var pe = new PEReader(stream);
Console.WriteLine($"Has metadata: {pe.HasMetadata}");
MetadataReader reader = pe.GetMetadataReader();
foreach (TypeDefinitionHandle handle in reader.TypeDefinitions)
{
TypeDefinition type = reader.GetTypeDefinition(handle);
Console.WriteLine($"{reader.GetString(type.Namespace)}.{reader.GetString(type.Name)}");
}
这个例子读取声明,不会触发目标程序集类型初始化,也不需要把目标程序集载入执行上下文,适合做静态审计工具。生产工具还要检查文件格式、受控内存占用和恶意元数据。
六、宿主与加载:不同年代的隔离模型不同
6.1 .NET Framework:AppDomain 是加载/隔离边界之一
.NET Framework CLR 支持一个进程内多个 AppDomain,用于程序集隔离、配置与卸载场景,GAC 和 binding policy 也是经典部署体验的一部分。但 AppDomain 不等于每个域拥有一套独立 GC 堆。GC 管理进程/runtime 实例中的托管堆并理解跨域/runtime root;不能用“卸载域 = 销毁独立堆”解释内存行为。
CAS、Remoting、透明代理和复杂 AppDomain 边界塑造了早期框架,却不应当作现代 .NET 默认架构。
6.2 现代 .NET:host + deps/runtimeconfig + AssemblyLoadContext
现代 .NET 应用由 apphost 或 dotnet host 解析 runtime configuration、framework 与依赖,然后初始化 CoreCLR。依赖解析主要由发布目录、.deps.json、共享框架和 host policy 协作完成;NuGet 全局包目录主要是构建/还原输入,不是可简单描述为运行时任意探测目录。
现代 .NET 只有默认 AppDomain 语义,动态加载与可卸载隔离主要由 AssemblyLoadContext 表达。可卸载 ALC 是协作式的:只要线程、静态字段、delegate、GCHandle 或外部引用仍指向其对象,卸载就不能完成。
6.3 加载不是一次性“读取所有类型”
加载器建立模块与元数据视图、解析依赖,并按需把类型和方法推进到可执行状态。类型加载可包含父类型、接口、泛型约束、布局、虚槽与 GC 描述等工作,但何时完成哪些阶段属于 runtime 实现。
在 CoreCLR 中,MethodTable、EEClass、MethodDesc、TypeHandle 等是理解类型系统的重要私有结构,却不能简化成“一个 TypeDef 永远对应一个 MethodTable 和一个 EEClass,且二者一对一”。泛型定义与多个实例化、数组/指针/byref/函数指针类型、共享数据和按需分配都会打破这种课堂式映射。
深入固定版本类型加载请转到 01-03:类型加载器 与 01-02:MethodTable。历史篇只保留职责边界,不把私有结构当稳定 ABI。
七、JIT 执行:按需供码,但没有一条永久固定的 Prestub 剧本
在 CoreCLR JIT 模型中,方法被调用前必须获得可执行入口。入口可能来自 ReadyToRun、首次 JIT、预码/stub、泛型共享 thunk、虚调用 dispatch 或已有代码版本。经典资料常用“所有方法最初都指向 Prestub,第一次调用 backpatch”教学;它能解释延迟编译思想,却不是现代 CoreCLR 每类调用的逐步公共路径。
现代 CoreCLR 还可能启用:
- tiered compilation:快速供给初始版本,再优化热点;
- dynamic PGO:用运行时 profile 改善类型推测、内联和布局;
- OSR:在适合的长循环中切换到优化代码;
- R2R:先使用合格的预编译体,热点仍可能重新 JIT;
- generic code sharing:多个实例化共享代码并通过上下文取得类型信息。
不存在应写进教材的固定“调用 30 次升 Tier 1”规则。阈值、计数机制、延迟、是否重编译会随版本、环境变量、PGO 和方法形态变化。也不存在所谓通用的“Reverse COM+ 代码版本”作为失败后回退阶段。
JIT 的公共责任可以概括为:验证/导入可执行 IL、构造 IR、优化、选择指令和寄存器、生成机器码,并产生 GC safe point、栈展开与异常处理所需信息。具体阶段和次序请见 01-04:RyuJIT 管线、01-05:JIT 优化 和 01-06:分层编译与 PGO。
八、AOT 分叉:三条管线,三个边界
8.1 ReadyToRun 仍是 CoreCLR 应用
R2R/crossgen2 在发布时为一部分方法生成可供 CoreCLR 使用的原生代码。产物通常仍保留 IL,运行时仍带 JIT,用于没有可用预编译体的代码、某些泛型或热点重优化。R2R 可以减少部分启动 JIT,却不保证固定百分比的启动提升,也不意味着没有 tiering/PGO。
dotnet publish -c Release -r linux-x64 \
-p:PublishReadyToRun=true \
--self-contained false
8.2 NativeAOT 是现代 .NET 的闭世界原生发布
.NET 8 NativeAOT 在发布时分析可达代码,生成原生程序并链接所需 runtime 组件;运行时不含 JIT。反射并非全部禁止,但构建期不可见的成员和动态闭合泛型会与 trimming/AOT 分析发生冲突,动态 IL 生成也缺少执行后端。
NativeAOT 的产品化来自 .NET 生态中 CoreRT、ILCompiler 与 runtime 多年的演进,不应虚构为“与 Unity 联合开发”。它与 Unity IL2CPP 的转换链、运行时和配置文件彼此独立。
dotnet publish -c Release -r linux-x64 \
-p:PublishAot=true
8.3 Unity IL2CPP 经 C++ 与平台原生工具链
Unity 2022.3 IL2CPP 的概念路径是:C# 程序集经托管代码裁剪/分析,IL2CPP 生成 C++ 和运行时元数据/支持代码,再由 Clang、MSVC 或主机平台工具链编译链接为 Player。
不能写成“一 C# 类对应一 C++ 类”。generic sharing、invoker、interop wrapper、metadata table、原生编译器内联/合并与 dead stripping 都会改变生成形态。也不能由“使用 IL2CPP”推出固定分代 GC;GC 行为应按 Unity 版本、平台和 Player Settings 核验。
R2R、NativeAOT、IL2CPP 对比及反射/泛型边界详见 01-10:AOT vs JIT。
九、Mono:不是“旧版 CoreCLR”,而是另一条实现谱系
Mono 长期服务于 Linux、移动端、嵌入式、Xamarin 和 Unity 等环境,支持的执行策略可随产品与平台组合为 JIT、AOT、解释器或混合模式。相同 IL 的公共语义应保持兼容,但 GC、类型结构、调用 stub、泛型共享、异常和诊断实现并不与 CoreCLR逐字段相同。
Unity Editor 使用的 Mono、Unity Player 的 Mono backend、Xamarin/移动 .NET 的 Mono runtime 也不能只靠“Mono”这个名字互换结论。研究时至少固定产品版本、架构、API profile 和运行模式。
历史意义在于:CLI 的中间语言与元数据让多个 runtime 实现共享生态;但可移植的是规范与库契约,不是 CoreCLR 的 MethodTable 偏移或某段 x64 JIT 汇编。实现对比见 01-11:Mono 运行时。
十、运行时服务:GC、异常与栈信息共同支持托管语义
10.1 GC 与 AppDomain/编译方式是不同维度
托管执行需要识别 roots、对象内引用和机器码安全点。JIT/AOT 编译器为具体代码生成 GC 信息,运行时在分配与写引用时执行相应协议。写屏障的实际指令数、卡表粒度和快慢路径不是固定 C# 成本;值类型也可能包含引用,不能说“struct 一律无写屏障、无需 GC 扫描”。
JIT、R2R 或 AOT 决定代码何时产生,GC 算法决定内存如何管理,二者相关但不等价。NativeAOT 可使用 .NET runtime 的 GC 能力;IL2CPP 使用 Unity 集成的内存管理;AppDomain 则是加载/隔离概念,不代表独立堆。
10.2 异常处理不应标固定毫秒成本
CLI 方法体可携带 catch、finally、fault、filter 等 EH 区域。编译器与 runtime 生成查找、栈展开、GC 和调试所需信息。没有抛出时,EH 区域仍可能影响优化、代码布局与寄存器分配,因此“try 正常路径绝对零成本”也过于绝对;真正 throw 时通常还包含异常对象、handler 搜索、栈展开、finally、stack trace 等成本。
耗时取决于栈深、异常类型、过滤器、是否收集 stack trace、优化、OS unwinder、JIT/AOT 和硬件,不能写成“每次固定毫秒级”。异常适合表达异常失败,不适合把可预期分支全改成 throw;判断依据应是语义与测量。
10.3 栈图是 GC 与异常的桥梁
机器代码需要描述在安全位置哪些寄存器/栈槽持有托管引用,以及如何恢复上层 frame。GC stack walk、异常展开、调试器和 profiler 都会消费这类信息,但目的和时机不同。AOT 只是提前生成这些信息,不是绕开托管运行时服务。
GC 深挖见 01-07:GC。
十一、Span、struct 与数组:语言形态不决定唯一存储位置
跨年代介绍最容易把高层类型直接翻译成“栈/堆”:
struct是值类型语义,不等于始终位于线程栈;它可内联在对象/数组中、装箱到堆,也可能只存在寄存器;class实例通常由 GC 管理,但引用局部本身可能处于寄存器或栈槽;Span<T>是ref struct,受 ref safety 限制,不能装箱或作为普通类字段;这不等于其底层数据在栈上;- Span 可指向数组内部、字符串只读数据、
stackalloc或 native memory,JIT/GC 必须按 byref 规则追踪合法托管引用; List<T>.Enumerator是 struct,可避免某些直接枚举路径的分配,但经接口使用时可能装箱,JIT 也可能改变最终存储位置。
int[] owner = { 1, 2, 3, 4 };
Span<int> view = owner.AsSpan(1, 2); // Span 局部受栈语义限制,数据仍属于堆上的数组
view[0] = 99;
Console.WriteLine(owner[1]); // 99
因此数据结构性能不能仅凭 class/struct 或 stack/heap 标签判断。要看对象数量、元素布局、引用扫描、复制、装箱、局部性、生命周期、调用形态与具体后端。
十二、公共可验证实验:同一代码观察四个层次
创建一个最小项目:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<LangVersion>12</LangVersion>
<Nullable>enable</Nullable>
<Optimize>true</Optimize>
</PropertyGroup>
</Project>
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
Console.WriteLine(RuntimeInformation.FrameworkDescription);
Console.WriteLine(RuntimeInformation.RuntimeIdentifier);
Console.WriteLine(typeof(Program).Assembly.Location);
int[] values = { 10, 20, 30 };
Console.WriteLine(Sum(values));
static int Sum(ReadOnlySpan<int> values)
{
int sum = 0;
foreach (int value in values)
sum = checked(sum + value);
return sum;
}
12.1 看 IL 与元数据
可使用 SDK/平台可得的 ildasm、ILSpy/dotPeek 或 System.Reflection.Metadata。工具版本与显示格式不同,但应能确认:
- 程序集引用与 target framework 属性;
Sum的方法签名如何表示ReadOnlySpan<int>;- 数组初始化与调用对应哪些 IL;
- token 指向哪些 TypeRef/MemberRef/MethodSpec。
不要从反编译 C# 反推原始源码。反编译器是在重建可读表达,局部变量名、语法糖和控制流可能与源文件不同。
12.2 验证 IL,但理解工具年代
peverify 是 .NET Framework/Windows SDK 时代的典型验证工具,适合观察可验证 IL 与元数据错误;它不是现代跨平台 .NET SDK 的完整兼容认证器。现代项目还应依赖编译器、运行时加载、ILVerify(若工具链适用)、trim/AOT analyzers 与实际发布测试。
unsafe、byref-like、泛型和新 IL 形态可能受到工具版本限制。某工具报错时先核对它是否理解目标 runtime 与 reference assemblies,不要立即断言程序集非法。
12.3 对比发布形态
按真实 RID 执行,下面的 linux-x64 只是示意:
# CoreCLR JIT
dotnet publish -c Release -r linux-x64 --self-contained false
# ReadyToRun + CoreCLR
dotnet publish -c Release -r linux-x64 --self-contained false \
-p:PublishReadyToRun=true
# NativeAOT
dotnet publish -c Release -r linux-x64 \
-p:PublishAot=true
记录每个产物的全部文件、构建日志、冷启动、首调用、稳态、RSS、异常栈和动态能力,不只比较主可执行文件大小。R2R 用 runtime 诊断确认预编译体与 tiered JIT;NativeAOT 保存符号/map 并清理 trim/AOT warnings。
12.4 Unity build matrix
同一算法移植 Unity 时,至少建立:
| 维度 | 记录值 |
|---|---|
| Unity | 2022.3.x 完整版本 |
| 后端 | Editor Mono、Player Mono(若平台支持)、IL2CPP |
| 平台 | OS、CPU、设备、Development/Release |
| 托管 | API Compatibility、Managed Stripping Level |
| 原生 | C++ compiler、LTO、符号和异常设置 |
| 性能 | Profiler 开关、Burst/Jobs 设置、预热与统计方法 |
运行最终 Player,验证程序集裁剪、反射、泛型、序列化、异常和性能。Editor 能运行只证明 Editor 环境成功,不证明 IL2CPP AOT 代码和目标平台元数据完整。
十三、故障定位:沿链路问问题
| 现象 | 首先检查 | 不应立即归因于 |
|---|---|---|
| 编译器找不到类型/API | TFM、引用程序集、包、using、LangVersion | “运行时没加载” |
| 启动时缺程序集 | deps/runtimeconfig、部署目录、ALC、版本/架构 | JIT 优化 |
TypeLoadException | 元数据签名、基类/接口、依赖、泛型约束 | 固定 MethodTable 偏移 |
| NativeAOT 反射失败 | trim/AOT warnings、类型数据流、保留元数据/代码 | “AOT 禁止所有反射” |
| IL2CPP Player 独有失败 | stripping、泛型组合、平台 API、原生链接 | Editor Mono 的 GC |
| 首次调用慢 | 加载、JIT/R2R fixup、静态初始化、I/O | 固定 Prestub 指令数 |
| throw 路径慢 | 栈深、过滤器、stack trace、平台 unwinder | 固定毫秒常数 |
这张表的价值是区分阶段。编译期问题不会靠 GC 调参解决,裁剪问题也不应靠扩大所有反射保留来掩盖。先找出失败属于语言、工件、加载、供码还是运行时服务,再用对应工具观察。
十四、审查清单与结论
写作或评审“C# 如何运行”时,逐项检查:
- 是否区分 C#、Roslyn、ECMA-335、BCL、runtime 与部署模型?
- 是否把 Binder 正确描述为语义绑定,而非直接 CIL 发射器?
- 是否误称 metadata 保存所有对象字段的固定运行时偏移?
- 是否把 AppDomain 当作独立 GC 堆,或把 ALC 当作安全沙箱?
- 是否把 MethodTable/EEClass 描述成跨所有类型的一对一稳定结构?
- 是否写死首次调用一定经过某个 Prestub 路径或固定 Tier 阈值?
- 是否区分 R2R + CoreCLR、NativeAOT 与 Unity IL2CPP?
- 是否虚构 NativeAOT 与 Unity 的历史关系?
- 是否用“一 C# 类对应一 C++ 类”解释 IL2CPP?
- 是否给异常抛出写固定毫秒成本,或声称
try绝对无影响? - 是否把 struct/Span/ref struct 直接等同于“数据永远在栈上”?
- 是否用最终发布物和目标 Player 验证,而不是只看 Debug/Editor?
C# 的长寿并非依赖唯一运行时,而是把语言语义与 CLI 工件、加载系统和执行后端分层:.NET Framework CLR 建立经典模型,Mono 将它带到更多平台,现代 CoreCLR 发展出 tiering、PGO 与 R2R,NativeAOT 提供闭世界原生发布,Unity IL2CPP 则接入游戏目标的 C++ 工具链。掌握共同链路,再为具体年代和后端补上版本边界,才不会把某个实现瞬间误写成 C# 永久真相。

39

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



