ILBC 运行时 (ILBC Runtime) 架构
本文是 VMBC / D# 项目 的 系列文章,
有关 VMBC / D# , 见 《我发起并创立了一个 VMBC 的 子项目 D#》(以下简称 《D#》) https://www.cnblogs.com/KSongKing/p/10348190.html 。
ILBC 运行时 架构图 如下:
为了便于讲解, 图中 一些位置 标注了 红色数字 。
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 的 架构如下:
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 怎么引用 ?