本文将带你沿着从源码到机器码的完整路径,拆解C#程序集的结构,揭开元数据的神秘面纱,深入CLR的运行时对象体系,并追踪JIT编译器如何将IL一步步转化为可执行的机器码

流程概述

我们自己写的C#代码以及引用的基础类库会被C#编译器编译为IL中间代码,同时根据C#代码生成元数据MetaData,再加上程序头(CLR Header和PE Header),由四者以及其他内容组成一个程序集

【IL和MetaData都是什么?】其中,中间代码IL可以理解为方法体的中间代码表示,由指令+到元数据索引(Token)组成,用于描述程序的执行逻辑;元数据MetaData本质是一套结构化的表,用于描述类型、方法、字段等信息

【IL和MetaData是如何被使用的?】运行时,JIT编译器 按需将IL中间代码翻译为可执行的机器码,在遇到MetaData Token时,会通过加载器ClassLoader根据元数据构建出类型系统的运行时对象,基于这些运行时对象中的信息,生成机器码并做缓存

下面,详细展开


具体展开

元数据

【什么是MetaData元数据?】

在C#代码层面,其中的每一个变量、常量、方法、类都是一个类型,在编译后,这些类型的基本信息会被转换为MetaData,所以其就是描述类型、方法、字段的结构信息,本质是一套结构化的表

这些表通过Token来进行索引,每个Token都是一个4字节的整数,其高8位表示表类型,低24位表示具体的行号,比如0x06000003,0x06对应MethodDef表,000003对应该表的第3行。IL中有对应的Token,最终会索引到对应的类型信息

比如,对于类这个类型来说,其所有的信息都通过TypeDef来描述,表中每一行记录下类的名字、命名空间、类中字段信息的Token、类中方法信息的Token等

对于方法这个类型,其所有的信息都通过MethodDef来描述,表中每一行记录的就是一个方法,包括方法签名、参数信息(在Param中的索引)等

这些数据信息都只是静态数据,可以简单理解为把C#各种类型序列化为了JSON字符串,MetaData就是这种结构化数据的二进制版本。

{
  "TypeDef": [
    { "Name": "UserLogin", "Fields": [1,2], "Methods": [3] }
  ],
  "FieldDef": [
    { "Name": "username", "Type": "System.String" },
    { "Name": "password", "Type": "System.String" }
  ],
  "MethodDef": [
    { "Name": "Login", "Signature": "(string,string)->bool" }
  ]
}

【MetaData的作用是什么?是如何被使用的?】

  • 在加载程序集时,会读取MetaData用于构建真正的运行时对象

  • 在JIT编译IL为真正的机器码时,会通过Token查找类型信息

  • 在反射时,其获取的类型信息的最根本来源就是MetaData

下面,我们顺着JIT下C#从源码到机器码的过程,看看MedaData是怎么被使用的


运行时对象

【运行时对象是什么?】

元数据MetaData是C#编译器生成的静态数据,在程序集内部,只用于描述程序中有什么,但是没有说明运行时如何处理这些数据。 比如说,只提供基本的结构信息,比如类型、字段的名称,函数的参数类型和返回类型等

相比于只记录“静态”类型信息的MetaData,CLR运行时会根据MetaData生成运行时对象,其记录了更多的运行时信息。比如对于函数,函数入口地址,有几个虚表槽,有几个参数……对于字段来说,则告知字段在对象中的偏移量……这些信息都会记录在对应的运行时对象中

更形象地来说——元数据负责告诉 CLR “有一个方法叫 AddHp”;“运行时对象”负责告诉 CLR “AddHp 的真实入口地址在 0x7FFE32….,放在虚表槽 3,参数有 1 个,JIT 优化级别为 Tier1”。


【运行时对象都有哪些?】

运行时类型对象不是一个单一类型的对象,而是一整套完整的体系结构,核心包括以下几种:

其中最关键的是MethodTable,描述了一个类的运行时信息,在层次关系上可以理解为包含了FieldDesc(字段信息)和MethodDesc(方法信息)

【JIT下,运行时对象在什么时候被创建?】

  • 对于运行时对象,是在某个类型第一次被使用时,才会由ClassLoader通过元数据创建对应的运行时对象,即加载该类型
  • 对于机器码,是在第一次访问某个函数时,才会将IL转换为机器码,并填写到MethodDesc中

相比之下,AOT会在程序开始运行前,就会生成所有的运行时对象并把IL全部编译为机器码,不再依赖于运行时生成


【具体如何根据MataData构建运行时对象的?】

在CLR加载一个类型时,会做下面的步骤:

1.CLR根据Token找到TypeDef找到对应的类型的记录,比如

TypeDef Name Namespace Extends FieldList MethodList
#17 UserLogin Example 0x01000003 #5 #9

此时,可以知道该类类型的名称、基类、有哪些字段和方法

2.根据该TypeDef类型信息构建EEClass和MethodTable,此时二者都是一个空骨架,实际信息还未填充,比如方法槽位表,类型大小等。接下来CLR为了完善MethodTable,会根据TypeDef中的信息先去构建FieldDesc和MethodDesc

3.CLR根据MetaData中的FieldDef构建字段布局FieldDesc,计算每个字段在对象中的偏移值,是否为引用类型等信息。构建完成后,向MethodTable中填写各个字段的FiledDesc

FieldDef Name Signature
#5 username string
#6 password string

4.CLR继续读取MethodDef表,得到该方法的签名、方法名、以及参数数量等信息,构建好MehtodDesc运行时对象,并将MethodDesc插入到MethodTable的方法槽中,如果该方法是个虚方法,则会覆盖基类的槽位

MethodDef Name Signature RVA
#9 Login (string,string)->bool 0x2030
MethodTable:
    Slot[0] -> MethodDesc*
    Slot[1] -> MethodDesc*
    Slot[2] -> MethodDesc*

//MethodDesc 初始状态:
MethodDesc:
    entryPoint = Prestub

注意,MehtodDesc还没有填写实际的方法的入口地址,填充的只是一个stub预存根,因为现在是ClassLoader的工作,真正将该方法的调用地址确定下来时是在JIT第一次调用到该函数时才可以得知并填写。运行时对象是在类型第一次访问时创建,而具体的函数方法是在第一次被执行调用时才进行JIT编译

具体来说,在编译IL,通过Token访问MethodTable找到MehtodDesc时,发现存储的是一个Prestub,便会触发JIT,生成机器码,回填MethodDesc

MethodDesc:
    entryPoint = 0x7ffd12345678  // 真正机器码地址

5.此时MethodTable就是一个完整的运行时结构了,其拥有了字段布局信息、方法槽位、类型大小、基类和接口信息

【在运行时,运行时对象是怎么和IL交互的?】

其实上面已经提到了一部分,IL本身并不直接操作内存和函数,其通过Token间接地引用元数据。

ldarg.0
ldfld int32 Player::hp   // ← 这里有 token
ldarg.1
add
stfld int32 Player::hp   // ← 这里也有 token

在JIT编译IL时,如果遇到了Token,则会根据这些Token索引到对应的元数据,如果元数据对应的运行时对象已经存在,则直接获取到对应的信息,比如字段偏移、函数入口地址等,并将这些信息直接嵌入到生成的机器码中,此后,执行这段程序时就会直接使用机器码,而不是重新走这套流程。

过程:JIT解析Token—>找元数据—>找运行时对象—>生成机器码

如果发现运行时对象MethodDesc中的入口是PreStub,则会触发JIT编译,先将函数生成对应的机器码,并将函数的入口地址填入到MethodDesc中,并内嵌到机器码中

如果发现元数据对应的运行时对象并不存在,则会先根据这些元数据转换生成为对应的运行时对象(MethodDesc/MethodTable…),再从运行时对象中获取想要的数据信息,内嵌到机器码中


下面以一个完整的例子再走一遍整个流程:

public class Player
{
    public int hp;

    public Player(int hp)
    {
        this.hp = hp;
    }

    public void AddHp(int value)
    {
        hp += value;
    }

    public override string ToString()
    {
        return $"HP={hp}";
    }
}

生成的IL:

.method public hidebysig instance void AddHp(int32 value) cil managed
{
    IL_0000: ldarg.0           // 加载 this
    IL_0001: ldarg.0
    IL_0002: ldfld int32 Player::hp  // 加载字段 hp  注意是Token
    IL_0007: ldarg.1           // 加载 value
    IL_0008: add
    IL_0009: stfld int32 Player::hp  // hp = hp + value 注意是Token
    IL_000E: ret
}

【编译阶段】

当C#写了AddHp函数时,经过C#编译器后,程序集中会生成:IL方法体(AddHp 的指令序列)、MethodDef #2(AddHp 的签名、名称等)、FiledDef#3(hp 字段的类型信息)、TypeDef#2(Player 类的结构和成员列表),后三者属于元数据,前者属于IL中间代码。

【运行时】

JIT编译IL代码时,发现Player类没有被加载,于是ClassLoader会先读取元数据(表结构),创建对应的运行时对象,计算字段布局,提供运行时信息(字段偏移、虚表槽等)。比如对于MethodDef #2、FiledDef#3、TypeDef#2会分别创建AddHp 方法描述对象(MethodDesc),hp 字段描述(FieldDesc)和Player 类型对象。再根据运行时对象中的信息,将IL指令变为真正可执行的机器码

这些运行时对象的主要作用是,填补了元数据中没有的执行时信息,比如字段在对象中的偏移量,虚表的槽位、方法的入口点等等。同时还会建立起Token和这些新创建出来的运行时对象的映射关系(即Token—>MethodDesc/FiledDesc/…),用于后续JIT执行IL时快速定位。

【函数运行调用 JIT触发】

当AddHp函数第一次被调用时,会发现对应的MethodDesc中为PreStub,于是将AddHp函数IL编译为机器码,并将函数入口地址其填入到MethodDesc中,后续再次调用时可直接获取。随后,将该函数的入口地址嵌入到调用处的机器码中

从IL到机器码被执行的过程中,IL提供执行逻辑,元数据提供结构,CLR运行时提供执行语义,三者合作实现全过程


总结

至此,我们已经完整走完了C#程序从源码到机器码的整个旅程。

整个过程可以概括为一句话:IL定义了“做什么”,元数据描述了“有什么”,运行时对象决定了“怎么做”,而JIT则负责“真正去做”。这三者环环相扣,缺一不可,共同构成了.NET托管执行环境的核心骨架。