CH 02 · 世界观

编译与运行模型

C++ 程序从源码到运行,是一条"一条路走到机器码"的流水线。 C# 不是。它在中途岔开了一道:编译成中间语言,再由运行时在跑的时候翻译成机器码。

这一道岔,解释了 C# 的跨平台、反射、GC、JIT 优化、慢启动……几乎所有"运行时行为"。所以它必须先讲。

读完本章,你应该能回答
  • C++ 和 C# 从源码到执行,各自的流水线长什么样?差别在哪一步?
  • 什么是 IL、程序集、元数据?"托管代码"到底托管了什么?
  • 为什么 C# 没有头文件?为什么它编译那么快?
  • 部署时 framework-dependent / self-contained / AOT 分别是什么?

2.1 两条流水线,一次对照

C++ 流水线 源码 .cpp 预处理 编译 → 汇编 汇编 .obj 链接 机器码 全部发生在「编译期」——目标平台在编译时就被写死。 C# 流水线 源码 .cs C# 编译器 Roslyn (csc) IL + 元数据 程序集 .dll / .exe CLR 运行时 JIT 按需把 IL 翻成当前平台机器码 机器码 机器码 同一份 IL → Windows / Linux / macOS / 不同 CPU 各自的机器码
C++ 在编译期就定好了目标机器;C# 把"翻译成机器码"推迟到了运行期,由 CLR 完成。
一句话抓住差别

C++ 的产物是"机器码";C# 的产物是"IL + 一份自我描述的说明书(元数据)"。 IL 不是给 CPU 看的,是给 CLR 看的。真正执行时,CLR 才把 IL 变成机器码。

2.2 回顾 C++ 侧:预处理 → 编译 → 链接

你熟悉这条链。挑几个"C# 里没有的"环节,因为正是它们的缺席造成了后面的差异:

C++ 环节 作用 C# 里的对应物
预处理 #include 文本包含头文件,重复声明 没有。没有头文件、没有文本包含
#define / 宏 文本替换,可做元编程 只剩 #if 条件编译等少量指令,不能定义宏
编译单元 .cpp 独立编译的最小单位 没有。文件之间不需要"声明"关系
链接器 把 .obj 拼成可执行文件、解析符号 由 CLR 在运行时按元数据解析类型与成员
ABI 兼容 同一编译器 / 版本才能混用二进制 IL 有统一规范,不同版本 / 语言可互通
为什么 C++ 编译那么慢,而 C# 通常快很多?点开看答案

C++ 慢,很大一部分不是"编译代码"慢,而是:
① 预处理:每个 .cpp 都要把它 include 的所有头文件(可能几万行)重新展开一遍;
② 模板实例化:模板在编译期展开,实例化爆炸时编译时间指数增长;
③ 优化:把机器码优化到极致要花很多时间。

C# 那边:没有文本包含(编译器直接读元数据)、泛型不在编译期展开(见第 06 章)、 而且 JIT 可以边跑边优化热点代码,把一部分优化成本挪到了运行期。

2.3 C# 侧:IL + 元数据 + CLR

IL(Intermediate Language,中间语言)

你写的 C# 被编译成一种类似汇编、但与具体 CPU 无关的指令集。它不是机器码,是"虚拟机的指令"。 你可以把它想成一套"所有平台都认识的通用指令"。

C++ · 源码
int Add(int a, int b) {
    return a + b;
}
C# · 源码
int Add(int a, int b) {
    return a + b;
}

两者源码几乎一样。但编译后差异巨大 —— 下面是 C# 那行编译出的 IL (示意,可用 ildasm / ilspy 查看):

IL · Add 方法的中间语言
.method int32 Add(int32 a, int32 b) {
    .maxstack 2
    ldarg.0        // 把参数 a 压入求值栈
    ldarg.1        // 把参数 b 压入求值栈
    add            // 弹出两个数相加,结果压回栈
    ret            // 返回栈顶的值
}
有个细节值得注意

IL 是基于栈的:它不直接操作寄存器,而是往一个"求值栈"上压 / 弹。 所以 IL 天生平台无关 —— 寄存器有多少、叫什么,是具体 CPU 的事,JIT 时才决定。

程序集(Assembly)与元数据(Metadata)

C# 编译产物叫程序集(.dll 或 .exe)。 注意:这里的 .dll 和 C++ 的 DLL 不是一回事 —— 它不是"动态链接库", 而是"装着 IL 和元数据的托管包"。

每个程序集里都带一份元数据:这个类叫什么、有哪些方法、参数类型是什么、引用了谁…… 相当于把 C++ 头文件里的信息,自动嵌进了二进制里。 这就是 C# 能反射、能跨语言、能不需要头文件的根本原因。

一个程序集(.dll)里面装了什么 MyApp.dll (程序集) ① IL 代码 你写的所有方法,翻译成中间语言 ② 元数据 有哪些类 / 方法 / 字段 / 参数(相当于内嵌的头文件) ③ 清单 版本号、依赖了哪些其它程序集
元数据让程序"自我描述" —— 这是反射、序列化、依赖注入等一切的基石。

2.4 JIT 到底在做什么

程序启动时,CLR 加载程序集;方法第一次被调用时,JIT(Just-In-Time,即时编译) 把它对应的 IL 翻译成当前机器的机器码,之后就一直用这份缓存。

JIT 的"按需编译" 方法 A 被调用 第一次 JIT 编译 A 的 IL → 生成机器码并缓存 执行机器码 这次用了编译时间 方法 A 再次调用 第 N 次 跳过 JIT 直接用缓存的机器码,速度等同原生 进阶:分层编译(Tiered Compilation) 先快速编译一版(启动快);等某方法被反复调用、确定 是"热点"后,再用更激进的优化重编译一次。
JIT 的代价是"启动时的编译开销",收益是"可以针对真实硬件优化、可以边跑边优化热点"。
对比项 C++(AOT / 提前编译) C#(默认 JIT)
编译发生时间 部署前,全部编译完 运行时,用哪个编哪个
启动速度 快(已经是机器码) 略慢(要 JIT,首次调用有开销)
峰值性能 取决于编译器优化 可针对当前 CPU 指令集优化,热点可反复优化
能否边跑边优化 不能 能(分层编译 / PGO)
能否利用运行期信息 不能 能(知道真实类型、可做去虚拟化)
那 C# 就永远慢半拍吗?

不是。今天 .NET 提供了 AOT 编译(ReadyToRun、NativeAOT):可以提前把 IL 编译成机器码, 启动快、体积小、不需要用户装运行时。所以"C# 一定比 C++ 慢"是过时的印象 —— 真实情况取决于场景:长驻服务里 JIT 的优化甚至能反超静态编译; 而极低延迟、极短任务里原生 AOT 更合适。

2.5 "托管"(Managed)托管了什么

"托管代码"是 C# 的关键词。它不是营销词,指的是一系列由运行时替你负责的事情:

谁负责 C++(非托管) C#(托管)
内存分配与释放 你(new / delete / 智能指针) 运行时(GC 自动回收)
类型安全 靠你自觉,可强转成任何东西 运行时 + 编译器强制检查
数组越界 不检查(UB) 检查并抛 IndexOutOfRangeException
对象生命周期 你精确控制(析构时机确定) GC 决定(时机不确定)
异常传播 可选、有代价,跨 ABI 麻烦 语言一等公民、统一处理
线程调度 / 并发原语 靠 OS API 或第三方库 BCL 统一封装,且有 async/await
程序集加载 / 版本 你自己搞定 DLL 与版本 运行时按清单解析、可并行加载多版本
代价也要知道

托管换来安全与效率,但确实有代价:① 你无法精确控制对象何时销毁 (对文件句柄、数据库连接这类"稀缺资源"要用 IDisposable,见第 09 章); ② GC 会带来暂停(现代 GC 已非常优秀,但极端实时场景仍需小心); ③ 某些底层操作需要"逃出"托管世界 (用 unsafe / P/Invoke,见第 07 章)。

2.6 没有头文件的世界

这一条差异会显著改变你写代码的习惯,单独拎出来讲。

C++ · 声明与定义分离
// --- math_utils.h (头文件:只声明)---
#pragma once
int Add(int a, int b);

// --- math_utils.cpp (源文件:定义)---
#include "math_utils.h"
int Add(int a, int b) { return a + b; }

// --- main.cpp (使用者:include)---
#include "math_utils.h"     // 必须手动包含
int main() { return Add(1, 2); }
C# · 一个文件就够
// MathUtils.cs (既是声明也是定义)
public class MathUtils {
    public static int Add(int a, int b) {
        return a + b;
    }
}

// Program.cs (使用者:什么都不用包含)
int r = MathUtils.Add(1, 2);
// 只要在同一个项目里,编译器自己就能找到
概念 C++ C#
声明 / 定义 必须分离,靠头文件共享 合一,写一次即可
引入别的文件 #include 文本包含 不需要;同一项目自动可见
引入外部库 #include 头 + 链接 .lib/.dll 引用程序集(IDE 里点一下 / csproj 一行)
避免重复定义 #pragma once / include guard / inline 天然不存在这个问题
命名冲突 namespace + 头文件组织 namespace + using,且可起别名
一个类分成多份 不行(一个类通常写在一个文件) 可以:partial class 分部类
顺带认识:partial(分部类)

C# 允许把一个类拆到多个文件里,用 partial 标记,编译时自动合并。 这在"自动生成的代码 + 手写的代码"要放在同一个类里时特别有用 (比如界面设计器生成的代码 + 你的业务逻辑)。C++ 里没有直接对应物。

2.7 部署模型:三种发布方式

C++ 里你纠结的是"静态链接还是动态链接";C# 里对应的是这三种:

方式 含义 目标机器需要什么 典型场景
框架依赖
framework-dependent
只发布你的程序,运行时由系统的 .NET 提供 要装 .NET Runtime 服务器、内部工具(体积小)
自包含
self-contained
把运行时和你的程序一起打包 什么都不用装 发给普通用户的桌面软件(体积大,几十 MB 起)
NativeAOT 发布前就编译成原生机器码 什么都不用装,也没 JIT 追求启动速度 / 体积 / 无运行时的场景
一个和 C++ 的关键差异

C++ 里"动态链接运行库"意味着用户机器上要有对应的 VC++ Redistributable, 版本不对就报"缺少 xxx.dll"。C# 的自包含发布把这类问题从根上消掉了 —— 依赖跟着程序走,不会互相污染。这正是当年解决 DLL 地狱思路的延续。

小测验

Q1:C# 的 .dll 和 C++ 的 .dll,是一回事吗?点开看答案

不是。C++ 的 .dll 是"动态链接库" —— 一堆可直接执行的机器码 + 导出表, 由操作系统加载器加载,受 ABI 约束。
C# 的 .dll 是"程序集" —— 里面装的是 IL + 元数据,不是机器码,由 CLR 加载,不受平台 ABI 约束。
两者扩展名一样,本质完全不同。容易混,记住这一条。

Q2:同一个 C# 程序,在 Intel 机器和 ARM 机器上,是用同一份文件跑吗?点开看答案

如果发布的是框架依赖 / 自包含(非 AOT)版本:是的,同一份程序集(IL)可以在两种 CPU 上运行 —— 因为 IL 是平台无关的,各自平台的 CLR 会把它 JIT 成各自的机器码。
如果用了 NativeAOT / ReadyToRun:那就要为每个目标平台(RID)分别发布, 因为里面已经是特定平台的机器码了。
这刚好对应 C++ 里"源码跨平台 vs 二进制跨平台"的区别。

Q3:为什么 C# 能"反射"(在运行时知道一个对象有哪些方法和字段),C++ 默认不行?点开看答案

因为 C# 编译产物里带着完整的元数据(类型名、成员、参数、特性……),运行时随时可以查。 C++ 编译成机器码后,这些"结构信息"大多丢失了(除非开 RTTI,但 RTTI 也只提供很有限的信息)。 没有元数据,就没有反射、没有自动序列化、没有依赖注入 —— 而这些是 C# 生态的日常。


这一章留下的线索

记住这条分界线:C++ 的复杂度花在"编译期",C# 的复杂度花在"运行期"。 后面几章几乎都在展开它的后果 —— 内存交给 GC(第 03 章)、类型系统分值与引用(第 04 章)、 泛型不在编译期展开(第 06 章)、稀缺资源要靠 Dispose(第 09 章)。

返回 C# 专栏 返回目录