ILBC 规范
本文是 VMBC / D# 项目 的 系列文章,
有关 VMBC / D# , 见 《我发起并创立了一个 VMBC 的 子项目 D#》(以下简称 《D#》) https://www.cnblogs.com/KSongKing/p/10348190.html 。
ILBC 系列文章 收录在 《ILBC 白皮书》 https://www.cnblogs.com/KSongKing/p/11070978.html 。
ILBC 规范:
加载程序集:
ILBC 程序集 有 2 种,
1 Byte Code 程序集, 扩展名 为 .ilb, 表示 “ILBC Byte Code” 。
2 Native Code 程序集, 扩展名 遵循 操作系统 定义的 动态链接库 规范, 比如 Windows 上就是 .dll 文件,
Native Code 程序集 就是 操作系统 定义的 动态链接库 。
假设 操作系统 是 Windows, 程序集 名字 是 A, 加载 A 的 过程 是:
在 当前目录 下 先查找 A.ilb, 若存在 则 JIT 编译 A.ilb 为 本地代码 A.dll, 加载 A.dll,
若找不到 A.ilb, 则找 A.dll, 若存在 则 加载 A.dll 。
加载 本地库 A.dll 的 方式 遵循 操作系统 定义的 动态链接 规范 。
JIT 编译 A.ilb 为 本地代码 并 加载 的 过程 可以在 内存中 完成, 不一定要 生成 文件 A.dll (如果 技术上 可以实现 在 内存 中加载的话)。
高级语言(D#) 编译 的 过程:
高级语言(D#) 编译 有 2 种方式,
1 AOT, 高级语言(D#) 编译器 先根据 高级语言(D#) 源代码 生成 C 语言 中间代码, 再由 InnerC (InnerC to Byte Code) 编译为表达式树, 再由 InnerC(Byte Code to Native Code) 把 表达式树 生成为 Native Code 。 Native Code 是一个 本地库, 比如 .dll 。
2 JIT , 高级语言(D#) 编译器 先根据 高级语言(D#) 源代码 生成 C 语言 中间代码, 再由 InnerC (InnerC to Byte Code) 编译为表达式树, 把 表达式树 序列化 得到 Byte Code, 将 Byte Code 保存为 ilb 文件 即 得到 Byte Code 程序集(.ilb) 。
.ilb 在 运行的时候 由 ILBC 运行时 的 InnerC (Byte Code to Native Code) 把 Byte Code 反序列化 为 表达式树, 再把 表达式树 编译为 Native Code 。
把 Native Code 程序集 加载到 应用程序 后, ILBC 运行时 会 调用 程序集 的 ILBC_Load() 函数, ILBC_Load() 会 创建一个 ILBC_Assembly 结构体, 并返回这个 结构体 的 指针, ILBC_Assembly 结构体 包含了 程序集 的 元数据 信息, 类似 .Net / C# 中 的 System.Reflection.Assembly 。
元数据 就是 一堆 结构体(Struct), 这些 Struct 及 ILBC_Load() 函数 的 代码是由 高级语言(D#)编译器 生成, 代码如下:
struct ILBC_Assembly
{
ILBC_ClassLoader classLoaderList [ n ] ; // n 是 程序集 中 Class 的 数量, 由 高级语言(D#) 编译器 在 编译时 指定
// classLoader 包含了 加载 Class 的 函数 的 函数指针 (保存在 load 字段 里)
// 每个 Class 有一个 classLoader,
// classLoaderList 是 保存 classLoader 的 数组,
// 在 ILBC 运行时 加载 Class 时 会调用 classLoader.load 保存的 函数指针 指向 的 函数, 具体内容见下文
// Class 加载完成得到的 Type 对象 保存在 type 字段 里
}
struct ILBC_ClassLoader
{
char * className ; // Class 名字
void * load ; // 加载 Class 的 函数 的 函数指针
ILBC_Type * type = 0 ; // 加载 Class 完成后把 Type 对象 保存在这里
}
struct ILBC_Type
{
char * name ; // Class 名字
int size ; // Class 占用的 空间大小(字节数)
ILBC_Field fieldList [ n ] ; // n 是 Class 中 Field 的 数量, 由 高级语言(D#) 编译器 在 编译时 指定
int fieldCount ; // C 语言数组 的 长度 需要 自己记录
ILBC_Method methodList [ n ] ; // n 是 Class 中 Method 的 数量, 由 高级语言(D#) 编译器 在 编译时 指定
int methodCount ; // C 语言数组 的 长度 需要 自己记录
}
struct ILBC_Field
{
char name [ n ] = "字段名" ; // n 应和 字段名字符串 的 字节数 相等, n 由 高级语言(D#) 编译器 在 编译时 指定
int size; // 字段 占用的 字节数
int offset; // 字段 相对于 ILBC_Field 结构体 的 首地址 的 偏移量
// ILBC_Type * type ;
char * type ; // type 不能 声明为 ILBC_Type 或者 ILBC_Type * 类型, 因为会造成 Type 和 Field 之间的 循环引用,
// 所以先声明为 char * (字符串), 保存 Type 的名字, 通过 GetFieldType() 之类 的 方法 来返回 Type 对象,
// Type 对象 就相当于 这里的 ILBC_Type 或者 ILBC_Type * 。
}
struct ILBC_Method
{
char name [ n ] = "方法名"; // n 应和 方法名字符串 的 字节数 相等, n 由 高级语言(D#) 编译器 在 编译时 指定
ILBC_Argument * argList [ n ] ; // n 是 方法 中 参数 的 数量, 由 高级语言(D#) 编译器 在 编译时 指定
Type * returnValue ; // 返回值 类型
void * funcPtr ; // Method 对应的 函数指针
}
struct ILBC_Argument
{
char name [ n ] = "参数名"; // n 应和 参数名字符串 的 字节数 相等, n 由 高级语言(D#) 编译器 在 编译时 指定
ILBC_Type * type; // 参数类型
}
看到这里, 是不是跟 C# 反射里的 AssemblyInfo, Type, FieldInfo, MethodInfo 很像 ?
是的, ILBC 也要支持 完整的 元数据 架构, 元数据 用于 动态链接 和 反射 。
接下来 是 ILBC_Load() 相关的 代码:
假设 程序集 名字 是 B, 包含了 Person 类 和 Animal 类 2 个 类, Person 类 有 2 个字段 name, age, 有 2 个方法 Sing(0, Smile() ,
void * ILBC_ClassLoaderList_B [ 2 ] ; // 数组长度 2 表示 B 程序集 包含了 2 个 类
ILBC_Assembly * ILBC_Load()
{
ILBC_Assembly * assembly = ILBC_gcNew( sizeof ( ILBC_Assembly ) ) ;
assembly.classLoaderList [ 0 ].className = "Person" ;
assembly.classLoaderList [ 0 ].load = & ILBC_LoadClass_B_Person ;
assembly.classLoaderList [ 1 ].className = "Animal" ;
assembly.classLoaderList [ 1 ].load = & ILBC_LoadClass_B_Animal ;
return assembly ;
}
ILBC_Type * ILBC_LoadClass_B_Person()
{
ILBC_Type * type = ILBC_gcNew ( sizeof ( ILBC_Type ) );
// ILBC_gcNew( ) 是 ILBC 提供的一个 库函数, 用于 在 堆 里申请一块空间, 这里是在 堆 里 创建一个 ILBC_Type 结构体
type.name = "Person";
type.size = 8; // Class 占用的 空间大小(字节数), name 字段是 char * 类型, 假设 指针 是 32 位 地址, 占用 4 个 字节, age 是 int 类型, 假设是 32 位整数, 占用 4 个字节, 那么 Class 的 占用字节数 就是 4 + 4 = 8, 即 size = 8; , size 是由 编译器 计算决定的
type.fieldList [ 0 ].name = "name";
type.fieldList [ 0 ].size = // String 是 引用类型, 所以这里是 引用 的 Size
type.fieldList [ 0 ].type = "String"; // 假设 基础库 提供了 String 类型
type.fieldList [ 1 ].name = "age";
type.fieldList [ 1 ].size = 4; // 假设 int 是 32 位 整数类型
type.fieldList [ 1 ].type = "Int32"; // 假设 int 是 32 位 整数类型, 且 基础库 提供的 32 位 整数类型 是 Int32
type.methodList [ 0 ].name = "Sing";
// 因为 Sing() 方法 没有 参数, 所以 argList [ 0 ] 长度为 0, 不用 初始化
type.methodList [ 0 ].funcPtr = & ILBC_Class_B_Sing; // ILBC_Class_B_Sing 是 Sing() 方法 对应的 函数, 由 编译器 生成
type.methodList [ 1 ].name = "Smile";
// 因为 Smile() 方法 没有 参数, 所以 argList [ 0 ] 长度为 0, 不用 初始化
type.methodList [ 1 ].funcPtr = & ILBC_Class_B_Smile; // ILBC_Class_B_Smile 是 Smile() 方法 对应的 函数, 由 编译器 生成
return type;
}
ILBC_LoadClass_B_Animal() 函数 和 ILBC_LoadClass_B_Person() 函数 类似 。
当 程序 中 第一次 用到 程序集 时, ILBC 运行时(调度程序) 才会 加载 程序集,
第一次 用到 程序集 是指 第一次 用到 程序集 里的 类,
第一次 用到 类 是指 第一次 创建对象( new 类() ) 或者 第一次 调用静态方法( 类.静态方法() ) 、 第一次 访问静态字段( 类.静态字段 ) 这 3 种情况 。
类 也是 在 第一次 用到 时 加载,
当然, 第一次 加载 程序集 是 一定会 加载一个 类, 但 其它 的 类 会在 用到 时 才加载 。
加载类 完成时 会调用 类 的 静态构造函数 。
调度程序 加载完 程序集 后, 会把 程序集 的 ILBC_Load() 返回的 ILBC_Assembly 结构体 的 指针 保存到一个 名字是 ILBC_AssemblyList 的 链表 里,
新加载 的 程序集 的 ILBC_Assembly 结构体 的 指针 会 追加到 这个 链表 里 。
ILBC_AssemblyList 是 调度程序 里 的 一个 全局变量:
ILBC_LinkedList * ILBC_AssemblyList ;
ILBC_LinkedList 是一个 链表 实现, ILBC_LinkedList 本身是一个 结构体, 定义见下文, 再配合一些 向链表追加元素 、删除元素 等函数 就是 一个 链表 实现, 函数 的 部分 略 。
struct ILBC_LinkedList
{
ILBC_LinkedListNode * first ; // 链表 头指针
ILBC_LinkedListNode * last ; // 链表 尾指针
}
struct ILBC_LinkedListNode
{
ILBC_LinkedListNode * before ; // 上一个 节点
ILBC_LinkedListNode * next ; // 下一个 节点
void * element ; // 节点包含的元素, 就是 实际存放 的 数据
}
假设 有 A 、B 2 个 程序集, A 引用了 B,
B 中 包含 Class Person, Person 有 构造函数 Person() { } , 那么, A 中 new Person() 的 代码 会被 编译成:
void * ILBC_Class_Person_Constructor = 0 ; // 这是 A 里的 全局变量, 表示 Person 的 构造函数 的 函数指针, 0 表示 空指针, 也表示 未初始化
……
// 代码 中 调用 Person 类 构造函数 的 代码
// ILBC_Class_Person 是 高级语言(D#) 编译器 生成的 表示 Person 类 的 Struct, 包含了 Person 类 的 字段
if ( ! ILBC_ifClassInit_Person )
{
ILBC_Init_Linked_Class_Person() ; // 初始化 Person 类
}
// ILBC_Linked_ClassSize_Person 是一个 全局变量, 表示 Person 类 占用的 空间大小(字节数)
void * person = ILBC_gcNew( ILBC_Linked_ClassSize_Person );
// Person 类 初始化 后, 构造函数 指针 ILBC_Linked_Class_Person_Constructor 就被 初始化 了(填入了 Person 构造函数 的 地址), 就可以调用了
ILBC_Linked_Class_Person_Constructor ( person ); // 调用 Person 类 构造函数, 把 person 结构体 指针 传给 构造函数 进行 初始化
调用 Person 类的 静态字段 和 静态方法 的 代码 和 上面 类似, 只需要把 最后一句 代码 换成:
字段类型 变量 = * ILBC_Linked_Class_Person_静态字段名 ; // 访问 静态字段
ILBC_Linked_Class_Person_静态函数名 ( 参数列表 ) ; // 调用 静态函数
ILBC_ifClassInit_Person 是一个 全局变量, 表示 Person 类 是否 已经 初始化, 定义如下:
char ILBC_ifClassInit_Person = 0 ;
B 程序集 的 Person 类 在 A 程序集 里的 “初始化” 是指 完成了 Person 类 在 A 里的 链接工作, 初始化 完成后, A 的 代码 就可以 访问 Person 类 了 。
访问 Person 类 包括 创建对象(new Person() )、调用函数 、访问字段 。
链接工作 包括
类链接, 向 A 里定义好的 保存 Person 类 的 占用空间大小(Size (字节数)) 的 全局变量 写入 类 的 占用空间大小(Size (字节数)),
字段链接 是 向 A 里定义好的 保存 Person 类的 各个字段的偏移量 的 变量 写入 字段的偏移量,
函数链接 是 向 A 里定义好的 保存 Person 类 的 各个方法 的 函数地址(函数指针) 的 变量 写入 函数地址, 包括 构造函数 和 成员函数 。
ILBC_Linked_Class_Person_Constructor 是 一个 全局变量, 表示 Person 类 的 构造函数 的 函数指针,定义如下:
void * ILBC_Linked_Class_Person_Constructor ;
ILBC_Init_Linked_Class_Person () 的 代码如下:
ILBC_Init_Linked_Class_Person ()
{
lock ( ILBC_ifClassInit_Person )
{
if ( ! ILBC_ifClassInit_Person )
{
ILBC_Type * type = ILBC_Runtime_GetType( "B", "Person" ) ; // 参数 "B" 表示 程序集 名字, "Person" 表示 类 名
ILBC_Linked_ClassSize_Person = type.size ;
// ILBC_Linked_Class_Person_name 是 保存 Person 类 name 字段 偏移量 的 全局变量, 由 编译器 生成, 值 需要在 加载类 的 时候 初始化, 也就是 下面的 代码 里 初始化
// ILBC_Linked_ClassFieldType_Person_name 是 保存 Person 类 name 字段 类型(类型名字) 的 常量, 由 编译器 生成, 值 由 编译器 给出, 值 就是 name 字段 的 类型 的 名字
ILBC_Init_Linked_Class_Field( & ILBC_Linked_Class_Person_name, ILBC_Linked_ClassFieldType_Person_name, "name", type ); // 初始化 name 字段 的 偏移量
ILBC_Init_Linked_Class_Field( & ILBC_Linked_Class_Person_age, ILBC_Linked_ClassFieldType_Person_age, "age", type ); // 初始化 age 字段 的 偏移量
// 如果有 静态字段, 也是 同样的 初始化, 不过 静态字段 应该 不是 初始化 偏移量, 而是 直接 是 地址,
// 静态字段 的 指针变量 比如 “变量类型 * ILBC_Linked_Class_Person_静态字段名 ;”
ILBC_Init_Linked_Class_Person_Constructor( type ); // 初始化 构造函数 的 函数指针
ILBC_Init_Linked_Class_Method( & ILBC_Linked_Class_Person_Sing, "Sing", type ); // 初始化 Sing() 函数 的 函数指针
ILBC_Init_Linked_Class_Method( & ILBC_Linked_Class_Person_Smile "Smile", type ); // 初始化 Smile() 函数 的 函数指针
// 如果有 静态方法, 也是 同样的 初始化, 静态方法 的 指针变量 比如 “void * ILBC_Init_Linked_Class_Person_静态方法名 ;”
ILBC_ifClassInit_Person = 1 ;
}
}
}
void ILBC_Init_Linked_Class_Field( int * fieldOffsetVar, char * fieldType, char * name, ILBC_Type * type )
{
for (int i = 0; i<type.fieldCount; i++)
{
ILBC_Field * field = & type.fieldList [ i ];
if ( field.name == name ) // 这句代码是 伪码 , 意思是 判断 2 个字符串 是否相等
{
// 我们这里 判断 类型 是否 相同 是 不严格的, 只是 判断 了 名字
// 这里 涉及到 类型检查 和 类型安全, 详细讨论 见 文章 最后 总结 部分
if ( field.type ! = fieldType ) // 这句代码是 伪码 , 意思是 判断 2 个字符串 是否相等
throw new Exception ( "名字为 " + name + " 的 字段 的 类型 与 引用 的 元数据 里的 类型 不符 。" ); // 这句代码 是 伪码, 应该是 函数 增加一个 errorCode 参数, 通过 errorCode 参数返回异常
* fieldOffsetVar = field -> offset;
return ;
}
}
throw new Exception( "找不到名字是 " + name + " 的 字段 。" ); // 这句代码 是 伪码, 应该是 函数 增加一个 errorCode 参数, 通过 errorCode 参数返回异常
}
void ILBC_Init_Linked_Class_Method ( void * funcPtrVar, char * name, ILBC_Type * type )
{
for (int i = 0; i<type.methodCount; i++)
{
ILBC_Method * method = & type.methodList [ i ];
if ( method.name == name ) // 这句代码是 伪码 , 意思是 判断 2 个字符串 是否相等
{
* funcPtrVar = method -> funcPtr;
return ;
}
}
throw new Exception( "找不到名字是 " + name + " 的 方法 。" ); // 这句代码 也是 伪码, 应该是 函数 增加一个 errorCode 参数, 通过 errorCode 参数返回异常
}
相关的 全局变量 / 常量 总结如下:
char ILBC_ifClassInit_Person = 0 ; // Person 类 是否 已 初始化
int ILBC_Linked_ClassSize_Person ; // Person 类 占用的 空间大小(字节数), 值 由 编译器 在 编译 A 项目时 根据 B 的 元数据 给出
int ILBC_Linked_Class_Person_name ; // Person 类 name 字段 的 偏移量
int ILBC_Linked_Class_Person_age ; // Person 类 age 字段 的 偏移量
const char * ILBC_Linked_ClassFieldType_Person_name ; // Person 类 name 字段 的 类型(类型名字)
const char * ILBC_Linked_ClassFieldType_Person_age ; // Person 类 age 字段 的 类型(类型名字)
void * ILBC_Linked_Class_Person_Constructor ; // Person 类 的 构造函数 函数指针
void * ILBC_Linked_Class_Person_Sing ; // Person 类 的 Sing 方法 函数指针
void * ILBC_Linked_Class_Person_Smile ; // Person 类 的 Smile 方法 函数指针
看到这里, 大家可能会问, 如果 构造函数 和 方法 有 重载 怎么办 ?
确实 有这个问题, 这个 需要 再作 进一步 的 细化设计, 现在 先 略过 。
ILBC_Runtime_GetType() 函数 的 定义如下:
ILBC_Type * ILBC_Runtime_GetType( char * assemblyName, char * typeName )
{
先在 ILBC_AssemblyList 中查找 名字 是 assemblyName 的 程序集 是否已存在,
如果 不存在, 就先 加载 程序集,
加载程序集 的 过程 上文 中 提过, 就是 先把 程序集 加载 到 应用程序, 再调用 程序集 的 ILBC_Load() 函数, 返回一个 ILBC_Assembly 结构体 的 指针,
调度程序 把 这个 结构体 指针 保存 到 ILBC_AssemblyList 这个 链表 里 。
找到 程序集 后, 再在 assembly.classLoaderList 里 找 名字 是 className 的 classLoader,
找到 classLoader 以后, 看 classLoader.type 字段 是否是 空指针(0), 如果是, 就说明 Class 还没有 加载,
就 加载 Class, 加载 Class 得到的 Type 对象 就存放在 classLoader.type 字段 里 。
加载 Class 的 过程 上文中 讲述过, 假设 加载 B 程序集 的 Person 对象,
就是调用 B 程序集 里的 ILBC_LoadClass_B_Person() 函数, 该 函数 加载 Person 类, 并返回 表示 Person 类 的 Type 对象 的 ILBC_Type 结构体 的 指针 。
调用 类 的 静态构造函数 ************* 这里 加个 着重号, 类 加载 完成后 调用 类 的 静态构造函数
返回 ILBC_Type 结构体 的 指针 。
}
访问 Person 对象 的 字段 的 代码 是:
void * person ;
……
char * name = * ( person + ILBC_Linked_Class_Person_name ) ;
int age = * ( person + ILBC_Linked_Class_Person_age ) ;
调用 Person 对象 的 方法 的 代码 是:
void * person ;
ILBC_Linked_Class_Person_Sing ( person ) ; // 调用 Sing() 方法, person 参数 是 this 指针
ILBC_Linked_Class_Person_Smile ( person ) ; // 调用 Smile() 方法, person 参数 是 this 指针
总结一下:
ILBC 的 链接 是 类似 .Net / C# 的 动态链接,
ILBC 的 链接 以 程序集 为 单位, 采用 延迟加载(Lazy Load) 的方式, 只有用到 程序集 的时候才加载, “用到” 是指 第一次 用到 程序集 里的 类(Class) 。
将 程序集 加载 到 应用程序 以后, 对 程序集 里的 类(Class) 也采用 延迟加载(Lazy Load) 的方式,
第一次 用到 类 的 时候才会 初始化 类 的 链接表, 链接表 初始化 完成后, 就 可以 调用 类 了, 包括 创建对象,访问 字段 和 方法 。
链接表 不是 一个 “表”, 而是 一堆 全局变量 / 常量, 就是 上文 中 列举出的 全局变量 / 常量, 这里再列举出来看看:
char ILBC_ifClassInit_Person = 0 ; // Person 类 是否 已 初始化
int ILBC_Linked_ClassSize_Person ; // Person 类 占用的 空间大小(字节数), 值 由 编译器 在 编译 A 项目时 根据 B 的 元数据 给出
int ILBC_Linked_Class_Person_name ; // Person 类 name 字段 的 偏移量
int ILBC_Linked_Class_Person_age ; // Person 类 age 字段 的 偏移量
const char * ILBC_Linked_ClassFieldType_Person_name ; // Person 类 name 字段 的 类型(类型名字)
const char * ILBC_Linked_ClassFieldType_Person_age ; // Person 类 age 字段 的 类型(类型名字)
void * ILBC_Linked_Class_Person_Constructor ; // Person 类 的 构造函数 函数指针
void * ILBC_Linked_Class_Person_Sing ; // Person 类 的 Sing 方法 函数指针
void * ILBC_Linked_Class_Person_Smile ; // Person 类 的 Smile 方法 函数指针
这些 全局变量 是 A 里 定义 的, 是 A 里 引用 B 的 链接表 。
注意, Class 的 加载 是 在 ILBC 运行时 里 进行的, 一个 Class 的 加载 对于 整个 应用程序 只进行一次,
Class 的 链接表 初始化(Init) 是 和 程序集 相关的, 假设有 A 、B 、C 3 个 程序集 引用了 D 程序集,
那么 当 A 用到 D 的时候, 会 初始化 A 里 引用 D 的 链接表,
当 B 用到 D 的时候, 会 初始化 B 里 引用 D 的 链接表,
当 C 用到 D 的时候, 会 初始化 C 里 引用 D 的 链接表 。
链接表 是 属于 程序集 的, 假设 A 引用了 B C D, 那么 A 里 会有 B C D 的 链接表,
也就是说 上面的 全局变量 会在 A 里 声明 3 组, 分别 对应 B C D 程序集 。
说到这里, 我们会发现, 上面的 全局变量 的 命名 没有 包含 程序集 的 名字, 比如 ILBC_Linked_Class_Person_name,
这个 表示 Person 类 的 name 字段 的 偏移量,
但是 并没有 表示出 Person 类 是 哪一个 程序集 的 。
所以, 应该 给 变量 增加一个 分隔符(连接符) 来 分隔(连接) 各项信息,
我们规定, InnerC 应支持 在 变量名 里 使用 "<>" 字符串, 这样可以使用 "<>" 来 分隔(连接) 各项信息 。
注意, 是 "<>" 字符串, 不是 "<", 也不是 ">" , 也不是 "< …… >" ,
比如, a<>b 这个 变量名 是 合法的, a<b 是 不合法 的, a>b 是 不合法的, a<b>c 这个变量名 也是 不合法的 。
ILBC_Linked_Class_Person_name 可以 这样 来 表示:
ILBC_Linked<>B<>Person<>name , 这表示 链接(引用) 的 B 程序集 的 Person 类 的 name 字段 的 偏移量
"<>" 字符串 在 D# 里 是 不能用于 程序集 名字空间 类 字段 方法 的 名字 的, 所以可以在 C 中间语言 里 用在 变量名 里 作为 分隔符(连接符) 。
ILBC 运行时 调度程序 应提供 以下 函数:
ILBC_Type * ILBC_Runtime_GetType( char * assemblyName, char * typeName )
该函数用于 返回 指定的 程序集名 的 程序集 中 指定的 类名 的 类 的 Type 对象
ILBC_Type 是 调度程序 中 定义的 结构体, 为了能让 程序集 访问, 需要 高级语言(D#)编译器 引用 调度程序 发布 的 头文件(.h 文件),
这个 头文件 我们 可以命名为 ILBC_Runtime.h , 里面 会 包含 ILBC_Assembly 、ILBC_ClassLoader 、ILBC_Type 、ILBC_Field 、ILBC_Method 、ILBC_Argument 等 结构体 定义 。
void * ILBC_Runtime_heapNew ( int size )
该函数用于 从 堆 里 分配 一块 指定大小 的 内存块, 参数 size 是 内存块 大小(字节数) 。 返回值 是 内存块 指针 。
ILBC 运行时 自己实现了一个 堆 和 GC 。
当然 对应的 还会有一个 void ILBC_Runtime_heapFree ( void * ptr, int size ) 函数,
C 语言 里的 void free(void *ptr); 是没有 size 参数的, So 。
没事, 这个可以保留讨论 。
ILBC 程序集 应提供 以下 函数:
ILBC_Assembly * ILBC_Load()
该函数 在 ILBC 运行时 调度程序 加载 程序集 时 调用, 负责 程序集 的 初始化 工作,
包括 创建一个 ILBC_Assembly 结构体, 并 初始化 ILBC_Assembly 结构体 的 classLoaderList 字段, 可以参考 上文 代码 。
ILBC 运行时 调度程序 接收到 程序集 的 ILBC_Load() 函数 返回的 ILBC_Assembly 结构体 指针 后, 会 将 该指针 保存到 ILBC_AssemblyList 中,
ILBC_Assembly 是 调度程序 里的一个 全局变量, 是一个 链表 。
说到 链表, 调度程序 里 保存 Assembly 的 列表 ILBC_AssemblyList 是 链表,
Assembly 里 保存 Type 的 列表 classLoaderList 是 数组,
Type 里 保存 Field 、Method 的 列表 fieldList, methodList 也是 数组,
而 上文 中 根据 名字 查找 Field 、Method 的算法是 遍历 数组, 查找 Assembly 、Type 的部分虽然没有直接用代码写出来, 但应该是 遍历 链表 / 数组 。
从 性能优化 的 角度 来看, 根据 名字 查找 成员(Assembly, Type, Field, Method 等) 应该 优化 为 查找 Hash 表,
这个 优化 关系 到 加载 程序集 和 类 的 效率, 也是 反射 的 效率 。
动态链接 程序集, 加载 程序集 和 类, 就是一个 反射 的 过程 。
相传 .Net 2.0 对 反射 性能 进行了优化, 使得 反射 性能 得到了 明显的 提升, 大概 也是 加入了 Hash 表 吧 ! 哈哈哈 。
而 .Net 对 反射 进行了 优化, 理论上 本身 就是 提升了 动态链接 程序集 、加载 程序集 和 类 的 效率, 也就是 提升了 .Net 运行 应用程序 的 效率 。
在 .Net / C# 里, Hash 表 可以使用 Dictionary, 但在 IL 里, 估计 得 自己写一个 。
不过 这也是一件 好玩的事情,
我接下来 会 写一篇 文章 《自己写一个 Hash 表》 。
《自己写一个 Hash 表》 这篇文章已经写好了, 见 https://www.cnblogs.com/KSongKing/p/10425152.html 。
调度程序 的 ILBC_Runtime_GetType() 、 ILBC_Runtime_heapNew() 、 ILBC_Runtime_heapFree() 和 程序集 的 ILBC_Link() 这 4 个 函数 是 操作系统 动态链接库 规范 定义 的 动态链接库 导出函数 。
这么考虑 主要是 之前 并未打算 自己实现一个 C 编译器,
但 现在 既然 我们要自己 实现一个 C 编译器(InnerC), 那么 这些就 不成问题了,
这 4 个 函数 可以 用 我们自己 定义的 规则 来 访问 。
比如, 我们可以 定义 在 调度程序 的 开头 的 一段字节 来 保存 ILBC_Runtime_GetType() 、 ILBC_Runtime_heapNew() 、 ILBC_Runtime_heapFree() 这 3 个 函数 的 地址, 在 程序集 的 开头 的 一段字节 来 保存 ILBC_Link() 函数 的 地址 。
这样, 调度程序 和 程序集 之间 就可以通过 函数指针 来 调用 接口函数, 速度很快 。
但 如果要这样的话, 调度程序 和 程序集 应该是 同构 的, 同构 是指 同一种语言 、同一个编译器 编译 产生的 本地代码 。
所以, 调度程序 也应该是 用 InnerC 编写 和 编译 生成的 。
这么一来, InnerC 的 地位 就 很重要了 。 ^^
InnerC 是 ILBC 的 基础 。
不过 这样一来, InnerC 可能也需要 支持 结构体, 不然 不好写 。 呵呵 。
这样的话, ILBC 本地代码 程序集 就 不需要 是 操作系统 定义的 动态链接库, 而是 按照 ILBC 规范 编译成的 本地代码, 我们可以把 这种 按照 ILBC 规范 编译成的 本地代码 程序集 的 扩展名 命名为 “.iln”, 表示 “ILBC Native Code” 。
关于 泛型, 突然想到, 泛型 纯粹 是 编译期 检查, 除此以外 什么 都 不用做, 顶多为 每个 泛型类型 生成一个 具体类型, 通过 具体类型 可以获取 泛型参数类型 就可以了 。
但 泛型 确实能 提高性能, 因为 泛型 不需要 运行期类型转换(Cast),
运行期 类型转换 就是 一堆 if else ,
我们可以看看 编译后 生成的代码,
源代码:
B b = new B();
A a = (A) b ;
编译后的代码:
B b = new B();
A a;
Type aType = typeof(A) ;
Type bType = typeof(B);
if ( aType == bType )
a.ptr = b.ptr ; // 这句是 伪码, 表示 b 引用 的 指针值 赋给 a 引用
else if ( aType 是 bType 的 父类)
a.ptr = b.ptr ;
else if ( 其它 转型 规则 )
a.ptr = b.ptr ; // 或者 其它 转型方式, 比如 拆箱装箱
else
throw new CastException( "无法将 " + bType + " 的 对象 转换为 " + aType + " 。" ) ;
而 泛型 是这样:
List<string> strList = new List<string>();
strList [ 0 ] = "aa" ;
string s = strList [ 0 ];
编译后的代码:
List<string> strList = new List<string>();
strList [ 0 ] = "aa" ;
string s;
s.ptr = strList [ 0 ].ptr; // 指针 直接 赋值
因为 编译期 已经做过 类型检查, 所以 引用 的 指针直接赋值, 所以 泛型 没有 性能损耗 。
当然, JIT 编译器 需要为 泛型类型 生成 具体类型, 使得 泛型类型 可以按照 CLR 的 规则 “是一个 正常的 类型”, 通过 具体类型 可以获取 泛型参数类型 。
泛型类型? 具体类型? 泛型参数类型?
有点绕 。
假设有 class A<T> ,
那么, A<T> 叫 泛型类型,
A<string> 叫 具体类型,
T , 叫 泛型参数类型, 比如 A<string> 的 泛型参数类型 是 string 。
对于 ILBC, 具体类型 可以在 C 中间代码 里 生成 。
再来看看 基础类型,
基础类型 包括 值类型 、数组 、String,
ILBC 会 内置实现 基础类型,
值类型 包括 int, long, float, double, char 等, 这些 类型 在 C 语言 里 都有 对应的类型, 但是为了实现 “一切皆对象”, 即 所有类型, 包括 值类型 和 引用类型 都从 object 继承 这个 架构, 还需要 对 C 语言 里的 int, long, float, double, char 等 做一个包装, 用一个 结构体(Struct) 来把 int, long, float, double, char 等 包起来 。
包起来以后, 为了提高执行效率, 编译器 还需要 对 代码 进行一些 优化, 对于 栈 里 分配 的 int, long, float, double, char 等 的 加减乘除 等 运算 就 直接用 C 语言 的 int, long, float, double, char 等 的 加减乘除 等 运算, 即 不用 结构体 包起来, 而是 直接编译为 C 语言 里 的 int, long, float, double, char 等 。
而 对于
void Foo( object o )
{
Type t = o.GetType() ;
}
这样的代码, 因为 参数 o 可能是 任意类型, 所以 传给 参数 o 的 int 类型 就 应该是 包装过的 int, 也就是 一个 结构体, 比如:
struct Int32
{
int val ; // 值
string typeName ; // 类型名字, 或者 广义的来说, 这个 字段 表示 类型信息
}
Object 的 GetType() 方法 通过 这个 字段 返回 Type 对象 。
而 对于 typeof(int) 则 可以在 编译器 编译为 Hard Code 返回 Int32 的 Type 对象 。
又比如 对于 Convert.ChangeType( object o, Type t ) 方法,
假设 参数 o 要传一个 int 类型的话, 也需要 传 包装过的 int 类型, 也就是 上文 定义的 struct Int32 。
所以, InnerC 的 InnerC to Byte Code 模块, 除了 语法分析器, 又增加了一个模块, 优化器 。
So ……
语法分析器 产生表达式对象树 后, 把 表达式树 传给 优化器, 优化器 可以 阅读 表达式树, 发现可以优化 的 地方 可以修改 表达式树,
修改后的 表达式树 就是 优化后的 表达式树, 再 传给 Byte Code to Native Code, 编译为 本地代码 。
可以把 优化后 的 表达式树 再 逆向为 C 代码, 这样就可以 看到 优化后 的 C 中间代码 。
InnerC 的 InnerC to Byte Code 可以提供 逆向 的 功能 。
再来看 结构体(Struct),
D# / ILBC 不打算 提供 结构体, 因为 结构体 没什么用 。 ^^
提供 结构体 会让 ILBC 的 设计 变得 复杂, 增加了 研发成本 。
当然 结构体 使用 栈空间, 减少了 堆 管理 和 GC 的 工作, 但是 从 线程 的角度来看, 栈 比较大的话 线程切换 的 性能消耗 可能 也 比较大 。 看你怎么看了 ~ 。
出于 动态链接 的 要求, .Net / C# 的 结构体 应该不是 在 编译期 静态分配内存空间 的, 而是 在 运行期 分配空间, 因为 结构体 保存 在 栈 里, 所以 是 动态分配 栈 空间 。
所以, .Net / C# 里 创建 结构体 也是用 new 关键字 。
D# / ILBC 的 DateTime 类型 是一个 引用类型(Class), 是一个 可以用 D# 写的 普通的 引用类型(Class) 。
.Net / C# 的 DateTime 是 值类型, 我估计 .Net / C# 现在 想把 DateTime 改成 Class, 但是 改不过来了 。 哈哈哈哈。
如 上文所述, D# / ILBC 提供 的 基础类型 是 基础类型 值类型 、数组 、String, 值类型 包括 int, long, float, double, char 等,
基础类型 由 D# / ILBC 内置实现 。
其它类型 由 D# 编写, 包括 DateTime 及 基础库 里的 各种类型 。
说到 基础库, 就会想到 和 本地代码 的 交互性, 就是 访问 本地代码,
在 .Net / C# 里, 托管代码 和 本地代码 之间 的 交互 使用 P / Invoke ,
对于 D# / ILBC, 会提供这样一些接口:
1 指针
2 申请一段 非托管内存, 非托管内存 不会由 GC 回收, 需要 手动回收
3 回收一段 非托管内存
有了 这 3 个 接口, 基本上就够了, 可以 访问 非托管代码 了 。
非托管内存 和 托管内存 同属一个堆, 只是 GC 不会回收 非托管内存 。
再来看 类型检查 和 类型安全,
上文中 初始化 链接表 的 字段偏移量 时 会对 字段类型 进行 检查, A 程序集 在 运行期 链接 的 B 程序集 的 Person 类 的 字段类型 应该 和 A 程序集 在 编译期 引用 的 B 程序集 的 Person 类 的 类型一致, 否则 认为 类型不匹配, 不允许链接, 也就是 不允许 使用 现在 的 Person 类 。
为什么要进行 类型检查 ?
如果 类型不匹配, 会发生 访问了不该访问的内存 的 错误, 这种 错误 难以排查, 产生的 结果 是 意想不到 的,
这也是 java, .Net 这类 虚拟机(运行时) 出现 要 解决的 问题 吧 !
java, .Net 这类 虚拟机(运行时) 通过 运行期 类型检查 来 实现 类型安全, 避免 类型错误 导致 访问了错误的内存 。
.Net / C# 对 类型 的 检查 是 严格准确 的, 所有类型 最终会 归结到 基础类型(值类型 数组 String),
而 基础类型 都是 .Net 内置类型, 是 强名称 的, 可以 严格 的 检查,
推而广之, .Net 基础库 都是 强名称 的, 可以 准确 的 检查 类型,
对于 开发人员 自己编写 的 类, 也可以 根据 字段 逐一校验, 实际加载 的 程序集 的 类 的 字段 应包含 大于等于 编译时 引用的 程序集 的 类 的 字段, 字段 名字 和 类型 必须 匹配, 比如 编译时 引用 的 Person 类 的 name 字段 是 String 类, 那么 运行期 加载的 B 的 Person 类 也应该要有 name 字段, 且 类型 应该是 String, 否则 认为 类型 不匹配 。
我们 上文 对 字段 类型 的 检查 是 不严格 的, 只是 检查 类型 的 名字 。
应该注意的是, 强名称 类型检查 不代表 内存安全, 强名称 只是 验证 程序集(类) 的 身份, 但是 类 如果 本身 存在 Bug, 也会发生 访问了 自身对象 以外 的 内存 的 问题 。
但是, 由于 数组 作为 基础类型 提供, 数组 中 会判断 “索引 是否 超出 数组界限”, 所以, 开发者 写的 代码 一般 应该不会发生 访问内存越界(访问了 自身对象 以外 的 内存) 的 问题 。
当然 这仅限于 托管代码, 对于 非托管代码, 因为 指针 的 存在, 所以有可能发生 访问内存越界 的 问题 。
.Net / C# 解决 这个问题的做法是, 把 指针 用 IntPtr 类型 封装起来, 不允许修改, 只是作为一个 常量数值 传递 。
另一方面, 如果 Class Size(类占用的空间大小(Size)) 、 字段偏移量 、 方法的函数地址 这 3 项 元数据 都是 动态链接 的话,
类型检查 其实 也没什么 好查的 。 ^^
因为 这 3 项 元数据 都是 来源于 同一个 类, 是 自洽 的, 如果发生了 访问内存越界 的问题, 是 类 自身代码 的 逻辑问题 。
强名称 检查 是 验证 程序集(类) 的 身份 。
为什么要 动态链接 Class Size(类占用的空间大小(Size)) 、 字段偏移量 ?
这是为了 兼容性, 比如, B 程序集 的 Person 类 现在有 name, age 2 个 字段, 后来又加了一个 favour 字段, 这样就改变了 Class Size,
name, age 的 偏移量 也可能会发生改变,
但是 应该 让 原来 引用了 B 程序集 的 应用程序 能 继续 正常 使用 Person 类,
所以 需要 动态链接 Class Size 和 字段偏移量 。
考虑到 软件 被 攻击 和 破解 的 风险, 可以考虑 加入 像 .Net / C# 一样的 强名称程序集 的 功能 。
不过如果 是 AOT 编译 的话, 即使没有 强名称, 要 破解 也没有那么容易, 因为 AOT 编译 生成的是 本地代码 。 ^^
我们上面说 程序集 和 类型 的 名字, 比如 调用 ILBC_Runtime_GetType( "B", "Person" ) 函数 返回 Person 的 ILBC_Type 结构体 指针,
"B" 是 程序集 名字, "Person" 是 类 名,
这段代码 是 举例, 我们给 程序集 名字 和 类型 的 名字 下一个 定义:
程序集 名字 是 程序集 文件 的 文件名(不包含 扩展名),
类型 的 全名(Full Name) 是 “名字空间.类名”, 这个 和 C# 一样 。
假设 名字空间 是 “B”, 则 Person 类 的 全名 是 “B.Person”,
上文 调用 ILBC_Runtime_GetType( "B", "Person" ) 函数 的 类名 应该是 类 的 全名 “B.Person” 。
如果 D# / ILBC 支持 强名称 程序集, 则 对于 强名称 程序集, Full Name 中 还会包含 强名称 版本信息, 可以认为 和 .Net / C# 一样 。
我们再详细说明一下 高级语言(D#)编译 的 过程,
高级语言(D#) 编译 会生成 2 个文件,
1 元数据 文件,
2 程序集 文件
上文中 没有 交代 元数据 文件,
元数据 文件 保存了 程序集 的 元数据 信息, 包括 类, 类的字段(字段名 、字段类型), 方法(方法签名),
高级语言(D#) 编译器 可以 根据 元数据 知道 程序集 有 哪些成员(类, 类的字段, 类的方法),
这样可以用于 开发时 的 智能提示, 以及 编译时 的 类型检查 。
最重要 的 是 高级语言(D#) 编译器 需要 根据 元数据 生成 程序集 中 加载 Class 的 代码,
加载 Class 的 代码 就是 上文中的 ILBC_Type * ILBC_LoadClass_B_Person() 函数 ,
这个 函数 就是 “Class Loader”, 是 保存在 ILBC_Assembly 结构体 的 classLoaderList 字段中,
classLoaderList 是 一个 数组, 元素 是 ILBC_ClassLoader 结构体, ILBC_ClassLoader 结构体 的 load 字段 就是 保存 “Class Loader” 函数 的 函数指针 的 字段 。
程序集 文件 可能是 Byte Code 程序集, 也可能是 本地代码 程序集,
如果是 JIT 编译方式, 就是 Byte Code 程序集,
如果是 AOT 编译方式, 就是 本地代码 程序集,
高级语言(D#) 编译器 编译时 只需要 元数据 文件, 不需要 程序集 文件,
应用程序 运行的时候 只需要 程序集 文件, 不需要 元数据 文件 。
元数据 文件 就像是 C 语言 的 头文件 。
所以, ILBC 涉及的 文件 会有 这么几种:
1 元数据 文件
2 C 中间代码 文件, 这个 不是 必需 的, 但是 作为 调试 研究 学习, 可以生成出来 。
3 Byte Code 程序集 文件,
4 本地代码 程序集 文件,
我们 可以 对 这 4 种 文件 命名 扩展名:
1 元数据 文件, 扩展名 “.ild”, 表示 “ILBC Meta Data”,
2 C 中间代码 文件, 扩展名 “.ilc”, 表示 “ILBC C Code”,
3 Byte Code 程序集 文件, 扩展名 “.ilb”, 表示 “ILBC Byte Code”,
4 本地代码 程序集 文件, 扩展名 “.iln”, 表示 “ILBC Native Code”,
好的, ILBC 规范 暂时 就写这么多 ,
接下来的 计划 是 堆 、 GC 、 InnerC 语法分析器 。
有 网友 提出 不需要 沿袭 传统的 面向对象 方式, 而是可以用和 Rust 相似的方式,
我下面 写一段代码 把这种方式 描述一下:
class C1
{
int f1;
string f2;
}
void M1( C1 this )
{
……
}
void M2( C1 this)
{
……
}
这就是 C1 类 的 定义, 方法 定义在 外面, 类似 C# 的 扩展方法,
这相当于 传统的 面向对象 里 C1 类 有 2 个 方法(M1(), M2()),
我们在 定义 一个 C2 类, 让 C2 “继承” C1 类:
class C2 : C1
{
}
再把 M1() 的 定义 改一下:
void M1( C2 C1 this )
{
……
}
this 参数 的 类型 加入了 C2, 由 C2 C1 共同作为 this 参数 的 类型,
这样 C2 就 继承 了 C1 的 M1() 方法,,, 注意 只 继承了 M1() 方法, 没有 继承 M2() 方法 。
C2 可以 添加 自己 的 字段, 也可以 多继承, 当然 如果 “父类” 之间有 重名 的 字段, 就 不能 同时继承 有 重名 字段 的 父类 。
C2 也可以 添加 自己 的 方法, 事实上 这也不能 说是 自己 的 方法, 这个 方法 不仅仅 能在 “父子” 类 之间 共享,
也能在 “毫无关系” 的 类 之间 共享, 只要 方法 内 对 this 引用 的 字段 在 类 里 存在就行 。
这种 做法 确实 挺 呵呵 的, 但也 很爽 。
这种做法 我称之为 “静态绑定”, 因为 和 Javascript 的 “动态绑定” 相似, 只不过 这是 在 编译期 进行的, 所以叫 “静态绑定” 。
同时, 从 编译期 “静态” 的 角度, 又和 泛型 很像 。
网友 说 这种做法 “只需要 结构体 和 扩展方法 就行, 不需要 类 。” ,
确实, 就是这样, 只要有 结构体 和 扩展方法 就可以 。
说的 直 一点, 只要有 结构体 和 函数 就可以 。
我要 呵呵 了, 这算是 面向过程 -> 面向对象 -> 面向过程 么 ?
经过后来的 讨论 和 思考, D# 还是不打算这样做, D# 的 目标 是 实现一个 经典 的 简洁 的 面向对象 语言 。
D# 会 支持 简洁 的 面向对象 和 函数式 。
简洁 的 面向对象 包括 单继承 、接口 、抽象类 / 抽象方法 / 虚方法,
函数式 是 闭包 。
不过, 关于 上述 的 “静态绑定” 的 做法, 倒是 讨论清楚 了, “绑定” 有 3 种:
1 静态绑定, 在 编译期 为 每个 绑定 生成一份 方法(函数) 代码, 每一份 函数 代码 逻辑相同, 区别是 访问 对象 字段 的 偏移量 。
2 静态绑定, 方法(函数) 只有一份, 但在 编译期 为 每个 绑定 生成一段 绑定代码, 绑定代码 的 逻辑 是 把 对象 字段 的 偏移量 转换为 函数 里 对应的 偏移量 。
3 动态绑定, 在 运行期 为 绑定 生成 绑定代码 。
关于 堆 和 GC, 我的 想法 是这样:
GC 根据 2 张 表 来 回收 对象(内存),
1 引用表
2 对象表
这 2 张表 实际上是 链表,
每次 new 对象 的 时候, 会把 对象 添加 到 对象表 里,
每次 给 引用 赋值 的 时候, 会把 引用 添加 到 引用表 里,
每次 引用 超出 作用域, 或者 引用 被赋值 为 null 时, 会 将 引用 从 引用表 里 删除, 当然 这段代码 是 编译器 生成的 。
这样, GC 回收 对象(内存) 的 时候, 就 先 扫描 引用表, 对 引用表 里 的 引用 指向 的 对象, 在 对象表 里 做一个标记, 表示 这个 对象 还在使用,
扫描完 引用表 后, 扫描 对象表, 如果 对象 未被标记 还在使用, 就表示 已经没有 引用 在 指向 对象, 可以 回收对象 。
而 要 在 每次 给 引用 赋值 的 时候 把 引用 添加到 引用表, 需要 lock 引用表, 把 对象 添加到 对象表 也需要 lock 对象表 。
lock 会 带来 性能损耗, 通过 测试 可以看到, C# 中 lock 的 时间 花费 大约 是 new 的 3 倍 (new 应该要 查找 和 修改 堆表, 所以 应该 也有 lock),
执行次数 比较小时, 小于 3, 比如 10 万次,
执行次数 比较大时, 大于 3, 比如 1 亿次,
所以, 看起来, C# 的 new 的 lock 的 效率 比 lock 关键字 的 lock 的 效率 高,
或者说, 如果 我们 用 上述 的 架构, 给 引用 赋值 时 把 引用 添加到 引用表, 使用 lock 关键字 来 实现 lock,
这样 对 性能 的 影响 很大, 只要 想想 给 引用 赋值 的 性能花费 比 new 还大 就 知道 了,
从 测试结果 上来看, new 的 执行 应该是 指令级 的, 大概在 5 个 指令 以内 就可以完成,
对于 .Net / C# 这样有 GC 的 语言, 应该 只需要 从 剩余空间 中 分配 内存块 就可以, 不需要 像 C / C++ 那样 用 树操作 查找 最接近 要 分配 的 内存块 大小 的 空闲空间,
再加上 lock 的 时间, 全部加起来 大概 在 5 个 指令 以内,
lock 大概 占 2 个 指令, 开始 lock 占 1 个 指令, 结束 lock 占 1 个 指令,
当然 这些 是 估算 。
所以 可以看出来, .Net / C# 的 new 操作 对 堆表 的 lock 是 指令级 的, 不是调用 操作系统 的 lock 原语,
这样 的 目的 是 让 new 的 操作 很快, 接近 O(1),
对于 ILBC 而言, 如果 采用 给 引用 赋值 时 修改 引用表, new 对象 时 修改 对象表,
那么, 修改 引用表 和 对象表 的 操作 也应该 接近 O(1), 就是 像 .Net / C# 的 new 一样, 这样才有足够的效率 。
这就是说, 修改 引用表 和 对象表 的 lock 也要像 .Net / C# 的 new 对 堆表 的 lock 一样, 是 指令级 的 。
这就需要 我们 自己 来 实现一个 lock, 而不是使用 操作系统 的 lock 原语 。
怎么来 实现 自己的 一个 lock ?
根据 网上 查阅 的 结果, 光从 软件 层面 是 不行 的, 光从 C 语言 层面 也不行, 需要 硬件 的 支持 和 汇编 编程 。
可以参考 《聊聊C++中的原子操作》 https://baijiahao.baidu.com/s?id=1609585581486387645&wfr=spider&for=pc ,
《java并发中的原子变量和原子操作以及CAS介绍》 https://blog.csdn.net/wxw520zdh/article/details/53731146 ,
文中提到 “CAS …… 虽然看似复杂,但却是 Java 5 并发机制优于原有锁机制的根本。” ,
而 CAS 是 通过 CPU 提供的 CMPXCHG 指令 支持, 可以参考 《cpu cmpxchg 指令理解 (CAS)》 https://blog.csdn.net/xiuye2015/article/details/53406432 ,
所以 我们可以 用 CMPXCHG 指令 来实现 lock , 原理 是 这样:
在 内存 里用一个 字 来 存储 lock 标志(flag), 如果 是 64 位 处理器, 则 字长 是 64, 即 8 个 字节(Byte),
简化起见, 我们 就 不 考虑 32 位 处理器 了, 只 考虑 64 位 处理器 。
当要 lock 时, 用 CMPXCHG 指令 比较 flag 是否 等于 0, 如果相等 则 将 当前线程 ID 复制到 flag, 这表示 当前线程 获得了 锁, 接着执行 锁 里 要执行 的 操作 就行 。
如果 不等于 0, 则 CMPXCHG 指令 会把 当前 flag 的 值 复制到 指定 的 寄存器 里, 检查 寄存器 里 的 flag 值 是否 是 当前线程 ID, 如果 是, 表示 在 当前线程 的 锁 范围内, 接着执行 锁 里 要 执行 的 操作 就行 。
如果 flag 值 不等于 当前线程 ID, 表示 当前锁 由 别的 线程 占有, 则 当前线程 挂起, 挂起前 会把 指令计数器 再次指向 上述 检查锁 的 指令, 下次 恢复运行 时, 会 重新执行 上述 检查锁 的 操作 。
我们可以用 多个 字 来表示 多个 lock, 比如 用 一个字 表示 引用表 lock, 一个字 表示 对象表 lock, 一个字 表示 堆表 lock, 等等 。
当然, 为了提高效率, 对象表 lock 和 堆表 lock 大概 可以 合为一个 lock, 因为 修改 对象表 和 堆表 都 发生在 new 操作 的 时候, 可以把 new 操作 作为一个 原子操作, 只用 一个 lock, 这样, new 操作 包含的 2 个步骤 修改 对象表 和 修改 堆表 都在 一个 lock 里 进行 。
这种做法 相比 操作系统 的 lock 原语, 可能更简单, 但是 功能 也 相对局限, 比如 不能支持 嵌套 lock, 以及 必须 预先 为 每一种 lock 分配一个 字, 而 操作系统 lock 是 可以 动态 lock 的, 比如 C# 中 只要 调用 Monitor.Enter() 方法 就可以 开始 lock, 通常 我们 是用 lock 关键字, 这在 编译期 被 编译器 处理为 Monitor.Enter() 和 Monitor.Exit() 方法对, 但是 如果 在 运行期 调用 Monitor.Enter() 方法, 也是 可以 开始 lock 的 。
操作系统 的 lock 可能 是 利用了 虚拟内存, 或者说 存储管理部件, 只需要 在 存储管理 的 锁表 里 设置 要锁定 的 地址, 存储管理 部件 会判断 是否允许 访问 该地址 。
设置 锁表 的 原理 是, 在 锁表 里 设置 当前线程 ID 和 要锁定的地址, 如果 相同 的 线程 ID + 锁定地址 已经 存在, 则 设置失败, 设置失败 则 线程挂起, 等下次 恢复运行 时 再接着设置 。
设置成功 则 表示 当前线程 获得 对 指定地址 的 锁, 存储管理部件 将 只允许 当前线程 访问 指定地址, 不允许 其它线程 访问 指定地址 。
事实上, 我们 用 CMPXCHG 指令 的 做法 也可以 实现 和 操作系统 类似 的 效果, 包括 动态的锁定 任意 的 对象(不需要 预先 分配字), 也 支持 嵌套 lock,
这需要 在 object 类(所有 引用类型 的 基类) 里 加入一个 lock 字段, 当我们 lock 某个 对象 时, 会先看 lock 字段 是否等于 0, 如果 等于 0, 则 写入 当前线程号, 这样 就 获得了 对 该 对象 的 锁, 如果 不等于 0, 则 比较 是否等于 当前 线程 ID, 如果 等于, 表示 对象 被 当前对象 锁定, 于是接着执行 锁定 里 的 操作, 如果 不等, 表示 对象 被 其它线程 锁定, 则 当前线程 挂起, 等下次 恢复运行 时, 重复上述过程 。
这个过程 和 上面叙述的 利用 CMPXCHG 指令 实现 锁 的 过程 是一样的, 但不用 预先 分配 字, 用 object 的 lock 字段 作为 这个 “字” 就可以 。
判断 object 的 lock 字段 是否 等于 0, 若 等于 则 写入 当前 线程号, 返回 true, 否则 lock 字段不变, 返回 false, 这个操作是 “原子操作”, 这个 原子操作 就是 CMPXCHG 指令 实现的 。
但 用 我们的 做法 有一个条件, 就是 需要在 所有 (可能 并发) 访问 对象 的 地方 都 加上 lock,
而 操作系统 的 锁 则 不必需, 操作系统 由于是利用 虚拟内存(存储管理部件) 实现的, 所以 在 代码 的 a 处 加了 lock, b 处 不加 lock, 但 a 处 锁定 对象, 则 b 处 将不能访问 。
虽然如此, 我们在 使用 操作系统 lock 的 时候, 通常 也会在 a 处 和 b 处 都 加上 lock, 这是为了 设计意图 的 需要, 我们 需要 a 和 b 严格的 同步(互斥)通信, 就 需要 给 a 处 和 b 处 都 加上 lock 。
我把 我们 的 做法 称为 “IL Lock” , 用 关键字 illock 表示,
把 操作系统 的 lock 称为 “System Lock”, 用 关键字 syslock 表示,
在 D# 中, 使用 IL Lock 可以这样写:
illock ( obj )
{
……
}
使用 System Lock 可以这样写:
syslock ( obj )
{
……
}
理论上, 我们可以提倡 使用 IL Lock, 这样可以 获得 比 System Lock 更高 的 性能 。 ^^
好的, 堆 和 GC 的 部分 基本 理清 了, 接下来 会开始 InnerC 语法分析器 。
到 目前为止, InnerC 在 ILBC 的 地位 变得重要, InnerC 会是 ILBC 的 内核模块 。
InnerC 支持 基础类型(int, long, float, double, char), if else, for, while, 函数, 指针, 数组, 结构体,
InnerC 不保证 支持 Ansi C 的 全部标准,
InnerC 还会有一些 新的 特性:
1 对 void * 类型 的 函数指针 不检查 函数签名, 可以调用任意的参数列表 和 返回任意的返回值, 当然调用了 不匹配 的 参数列表 就 会发生 错误, 可能导致 程序 崩溃, 这个 特性 是用在 C 中间代码 里, 不建议 开发人员 使用 。
对于 声明了 函数签名 的 函数指针, 仍然 会 检查 调用的参数列表 及 返回值 是否 符合 函数签名(指针类型), 开发人员 应使用 这种方式, 保证 安全性 。
2 为了便于实现一些 动态特性 和 对 本地代码 访问 的 灵活性, InnerC 支持 用 函数指针 调用 动态的参数列表, 参数列表 是 一个 数组, 类似 .Net / C# 的 反射, 把 参数 放在 数组 里 传给 MethodInfo.Invoke( object[] args ) 方法 。
初步构想 可以 增加一个 invoke 关键字, 可以用于 函数指针 的 函数调用, 比如:
void * funcPtr ;
void * args ;
……
( * funcPtr ) ( invoke args ) ; // 调用 funcPtr 指向 的 函数, 参数列表 是 args
3 新增 casif 关键字 以 支持 casif 语句 。
casif 语句 类似 if 语句, 但 判断条件 是 通过 CMPXCHG 指令 实现的 CAS 原子操作, CAS 全称 “Compare and Swap” 。
casif 语句 格式 如下:
casif ( 参数1, 参数2, 参数3 )
{
语句块 1
}
else
{
语句块 2
}
参数1 是一个 变量 或者 常量, 参数2 是 一个 指针, 参数3 是 一个 变量 或者 常量,
当 参数1 和 参数2 指向 的 值 相等 时, 把 参数3 的 值 复制到 参数2 指向 的 存储单元, 并认为 判断条件 成立, 执行 语句块 1 。
否则 认为 判断条件 不成立, 执行 语句块 2 。
其实 上面说的 用 CMPXCHG 指令 实现 IL Lock 的 做法 还有一点问题, 其实 不需要 向 对象 的 lock 字段 写入 当前线程 ID, 只要 写入 1 就可以, 1 表示 对象 被 锁定, 0 表示 对象 未被锁定 。
这样 逻辑 就 更 简化了 。
对 引用表 对象表 堆表 的 lock 都会 统一使用 IL Lock 。
暂时先写到这里, ILBC 目前计划 发展 2 门 高级语言, D# 和 c3 , c3 由 一位 网友 提出, 参考《c3 语言草案》 https://note.youdao.com/ynoteshare1/index.html?id=bec52576b45ec0d918a95f75db0ea68e&type=note#/ 。
内容有点多, 所以后面的内容放到了 《ILBC 规范 2》 https://www.cnblogs.com/KSongKing/p/10440001.html 。