ILBC 运行时 (ILBC Runtime) 架构

时间:2024-12-15 17:02:57

本文是 VMBC / D# 项目 的 系列文章,

有关 VMBC / D# , 见 《我发起并创立了一个 VMBC 的 子项目 D#》(以下简称 《D#》)  https://www.cnblogs.com/KSongKing/p/10348190.html   。

ILBC 运行时       架构图    如下:

ILBC 运行时 (ILBC Runtime) 架构

为了便于讲解,   图中 一些位置 标注了 红色数字 。

ILBC 运行时  包含  3 个 部分:   调度程序 、 InnerC(Byte Code to Native Code) 、 GC  。

1 处,  调度程序 调用 入口程序集 的  ILBC_Main()  函数, 开始执行程序 。

如果 入口程序集 是 ILBC 程序集, 就会 调用  InnerC(Byte Code to Native Code) 编译  ILBC 程序集 为 本地程序集(2 处) 。

ILBC 程序集 就是  ILBC Byte Code 程序集,  本地程序集 就是 本地代码 程序集  。

如果 入口程序集 是 ILBC 程序集, 就直接调用  ILBC_Main()  函数, 开始执行程序 。

3 处 表示  A 程序集 引用了  B 程序集,  在 调度程序 加载 A 程序集 的 时候, 会调用 A 本地程序集 的  ILBC_GetAssembly()  函数,

ILBC_GetAssembly()  函数   之前没有提到, 现在补充上来 。

ILBC_GetAssembly()  函数   会返回 A 程序集 引用 的 程序集 列表,  包含了 这些 程序集 的 名字 。

程序集 列表 是一个 数组, 数组元素 是 一个 字符数组 的 首地址,  这个 字符数组 就是  程序集 的 名字 。

调度程序 会 根据 程序集列表 去 加载 列表 里的 程序集,

假设 A 程序集 引用了 B 程序集, 则 程序集 列表 里有 B,   调度程序 会先把 B 加载到内存, 如果 B 是 本地代码程序集, 则 直接加载到内存, 如果 B 是 ILBC 程序集, 则 先 JIT 编译 为 本地代码程序集, 再加载到内存 。

4 处 表示 ILBC 程序集 JIT 编译 为 本地程序集 后 投入使用 。

把 B 加载到 内存后, 调用 B 的  ILBC_GetMethodList()  函数,  返回 B 的 函数表 首地址, 另一方面, 调度程序 会 调用 A 的  ILBC_GetMethodListList()  函数, 返回  “函数表 列表”  的 首地址,   “函数表 列表”  是  一个数组, 数组元素 是 函数表 首地址,  所以是  “函数表 的 列表”  。

这样, 把 B 的 函数表 首地址 存到     函数表 列表 中  B  的 位置,   加载 A 和 “依赖项” B 的 过程 就完成了 。

如果 A 还引用了 其它 程序集,  或者 B 引用了 其它 程序集,  也是 按照 这个 过程 依次加载  。

上面这个 过程 说的有点啰嗦, 没事, 我们先来看一下   InnerC  的架构,  等下再把这个流程 总结一遍  。

InnerC  的 架构如下:

ILBC 运行时 (ILBC Runtime) 架构

InnerC 分为  2 个 模块 :

1   InnerC  to  Byte Code

2   Byte Code  to  Native Code

InnerC  to  Byte Code   的 职责 是 语法分析 和 类型检查,  语法分析 包含了 语法检查 。

通过 语法分析, 把  C 代码 解析 为 表达式对象树,  然后 对 表达式对象树 进行 类型检查,

类型检查 通过后,  就可以 返回 表达式对象树 了,

表达式对象树 可以直接 传给   Byte Code  to  Native Code,

Byte Code  to  Native Code   负责 将 表达式 生成为 目标代码 和 链接(链接外部库), 最终 生成 本地库,

这就是 AOT 编译  。

表达式对象树 也可以 序列化, 序列化 得到的 byte 数组(byte [ ]) 就是 Byte Code, Byte Code 保存为 文件 就是  ILBC 程序集 。

ILBC 程序集 可以 读取为 byte 数组(byte [ ]),  byte 数组 反序列化 就是   表达式对象树,  表达式对象树  传给  Byte Code  to  Native Code  编译为 本地库,

这就是 JIT 编译  。

C 代码 是 第一级 中间代码,   Byte Code 是 第二级 中间代码  。

这就是  InnerC  的 架构,  以及  AOT 编译  和  JIT 编译 的 原理  。

我们可以把   C 中间代码 文件 的 扩展名 定义为  .ilc , 意思是 “ILBC C Code”,

把   ILBC 程序集 (Byte Code 文件) 的 扩展名 定义为  .ilb,  意思是 “ILBC Byte Code” 。

本地代码 程序集 的 扩展名 遵循 操作系统 的 规定, 比如 Windows 上 就是 动态链接库  .dll,  因为 本地程序集 就是 操作系统 定义的 动态链接库 。

我们 接下来 把     ILBC 运行时  加载 程序集 和 运行 应用程序 的 流程 总结一下 :

1   调度程序 加载 入口程序集, 如果 入口程序集 是 本地程序集, 就 直接加载到内存,

如果 入口程序集 是 ILBC 程序集, 则 先 JIT 编译, 把 入口程序集 编译为 本地程序集 再加载到内存 。

2   调度程序 调用 入口程序集 的   ILBC_GetAssemblyList() 函数 ,  ILBC_GetAssemblyList() 函数  返回   AssemblyList   首地址  。

AssemblyList  是一个 数组,  数组元素 是一个  char 数组(char [ ]) 的 首地址,  表示   Assembly 的 名字 (文件名, 不包含扩展名)  。

3   调度程序 用  Assembly 名字 查找 当前目录下 的 程序集, 先查找 本地程序集, 比如  “程序集名字.dll”,  如果找到,  直接加载到内存,

如果找不到 本地程序集, 就找  ILBC 程序集,  比如  “程序集名字.ilb”,   如果找到,   先 JIT 编译 为 本地程序集, 再把 本地程序集 加载到内存 。

如果  ILBC 程序集 也没有找到,  就 报错  “找不到 某某 程序集 。”  。

怎么把 本地程序集 加载到内存 ?    这 遵循 操作系统 提供的 方式,   比如   Windows  把  .dll 库 加载到 应用程序 里的 方式  。

总的来说,  加载程序集 的 流程 如上,  从 入口程序集 开始依次加载,  加载完成后,  调用 入口程序集 的  ILBC_Main()  开始 执行程序 。

另外, ILBC_GetMethodListList() 函数   应该是   ILBC_InitializeMethodListList() ,  具体 逻辑 不长, 但讲起来烦琐, 之后看 Demo 代码就清楚了 。

可以看到,  ILBC 运行时 加载 程序集 会 将 所有 引用到的 程序集 全部加载 完成,  才会开始 执行程序 。

这是 和   .Net / C#  不同的 ,    .Net / C#  应该是 用到 这个 程序集 的时候 才会 加载,  用到这个 程序集 是指 第一次 调用到 这个 程序集 里的 类 的时候  。

实际上,   .Net / C#   的 动态加载 的 粒度 可能 更细,  可能是  Class  这一级别 的,

我们在 调试 .Net / C# 程序 的 时候 可以 观察到,  只有 第一次 用到 某个 Class 的 时候,  这个 Class 的 静态构造函数 才会被 调用  。

从这一点上来看,   .Net / C#  的 动态性 比   ILBC   更强,  更加动态  。

进一步,   ILBC 加载 的 单位 是 整个 程序集,  而不是 类(Class),  如果是 本地程序集, 则将  整个 本地程序集 加载到内存,

如果 是  ILBC 程序集, 则 对 整个 ILBC 程序集 进行  JIT 编译,  编译为  本地程序集  后, 再把 整个 本地程序集  加载到内存  。

也因此,   D# / ILBC  不提供  类 的 静态构造函数,   而是 提供一个    ILBC_AssemblyLoad()  函数,    ILBC 运行时 会在 加载 程序集 完成时 调用   ILBC_AssemblyLoad()  函数,  整个程序集 所有 类 的 初始化 工作 可以在   ILBC_AssemblyLoad()   里 来 完成  。

.Net / C#  的  动态性 需要 更加 复杂 的 设计 和 实现,   这不是    ILBC    的   定位 。

我们可以 探讨 一下, 如果要实现 .Net / C#  的 动态性,  比如     第一次 new 类的对象   或者    第一次调用类的静态方法   时,  加载类(如果 Assembly 未加载 则 先加载 Assembly 再加载 Class) 并 调用 类的静态构造函数  这个 动态加载 怎么实现:

我们可以写一段 伪码:

简单起见,  我们假设 Assembly 已经加载了,  只要 判断 类 是否已加载, 若未加载 则 加载 类  。

编译器 会 把 new 类 的 对象, 以及 调用 类的 静态方法 的 代码 处理成 一段 临时代码, 我们称之为 “链接代码”,

假设 该 类 是  A Class,

伪码如下:

bool   ifAClassLoad   =   false;

if (  !   ifAClassLoad   )

{

lock (  ifAClassLoad  )

{

if (   !   ifAClassLoad    )

{

加载 A Class    ;

调用 类 的 静态构造函数    ;

ifAClassLoad    =    true  ;

}

}

}

new ()    或者    A.静态方法()       ;

按照这个 代码 的 逻辑,   第一次 new A()  或者  调用  A.静态方法()  时, 会 判断 A Class 是否已加载,  如果未加载, 会有一个 线程 通知 CLR 加载 A Class, 其它 线程 等待(如果 有 其它线程 也在 new A()  或者  调用  A.静态方法()  的话),   CLR  加载完成后,   就执行 真正的 new A() 或者 A.静态方法() ,

之后, 再  new A()   或者   调用 A.静态方法()    的时候,  在  链接代码  的 第一句,

if (  !   ifAClassLoad   )

就可以 判断 出来 A Class 已经加载,  于是就直接执行   new A()  或者  A.静态方法()   。

但 这样的 做法,  每次     new A()  或者  A.静态方法()     都要有 一个 判断,  虽然 只是一个 判断, 但从 微观 上来说, 也造成了 性能消耗  。

这样的 性能消耗, 应该是  “应该被优化掉的”  。

如果  .Net / C#  已经 把 这个 判断 优化掉了,  那么 应该用到了  “修改已经编译好的本地代码”  的 操作, 形象的讲, 就是给 “已经编译好的本地代码” 做了个 “微创手术”  。

具体就是 在 第一次 加载 成功后,  .Net CLR  会 把 这段  “链接代码”  替换掉,  替换为  new A()  和   A.静态函数()   的 代码,

在 新的    new A()   和   A.静态函数()    代码中,   A()  构造函数   和    A.静态方法()    已经替换为   A Class  加载后的 实际的 函数地址 。

这样,  替换后的 代码 和 访问 同一个 程序集 中的 类 的  代码 是 一样的 。

性能 也和  访问 同一个 程序集 中的 类 一样 。

顺便加一句,  本来 链接代码 中  new A()  和  A.静态函数()   的 部分 还有一个 类似 调用 虚函数 的 查函数表 的 操作,  也被这个 替换 优化掉了 。

这个 技术 很底层,  ILBC 不打算 涉及 这个 技术,

ILBC  仍然 把  C 语言 和  C 编译器(InnerC)  看作一个整体,  不会  介入  C 编译器 的 工作细节 。

不过,  从上面的讨论也可以知道,  如果   ILBC  想实现 和   .Net / C#   一样的  “动态特性”,  比如 用到  A Class  的时候 才 加载  A Class,  如果 A Class 所在的 程序集 未加载 则 先加载程序集 再 加载 A Class,

如果要做到 这样 的 动态特性 的话,  简单点 也可以用 上面的  “链接代码”  的 做法,  只是每次调用  new A() 构造函数 和 A.静态方法()  都要多一个

if (  !   ifAClassLoad   )

的 判断 了 。

还有 就是 查函数表 的 操作 也是要有的 。

当然, 即使不实现这个 “动态特性”,  查函数表 的 操作 也是有的 。

ILBC  的 动态链接 就 相当于 调用 虚函数  。

不过 即使用了 上面  “链接代码”  的 方式, 也只能 “用到某个 程序集 的 时候 才加载 程序集”, 还不能达到 Class 的 粒度,

因为 上文 也说了,    ILBC  是 把 整个  ILBC 程序集 编译成  本地程序集 的,

这是因为   ILBC 程序集 是  C 语言 写的,  C 语言 只能 整个项目(程序集) 一起编译, 不能把 里面的  .c 文件 一个一个 拿出来编译 。

就算能把   若干   .c 文件 任意 的 拿出来 编译,  根据 ILBC 规范,  这些 单独 拿出来的  .c 文件 编译成的 程序集 里 必须要 提供    ILBC_GetAssemblyList(), ILBC_InitializeMethodList(),   ILBC_Link()   函数,  这就乱套了 。  因为 原本的程序集 已经 为 原本的整个项目 生成了 一份 这些 函数 。

假设 A 引用 B,  A 里 编译好的 逻辑 是 引用 B,  现在 把 B 拆成了 若干个 小程序集, 你让 A 怎么引用 ?