翻译 from peter1745的《mono-guide》
强烈建议阅读Mono官方文档
1、加载程序集(Loading Assemblies)
在本节中,我们将介绍如何使用Mono加载C#程序集。首先,你应该知道在C#(以及.NET通常情况下),程序集可以是DLL或EXE文件。在我们开始编写加载代码之前,我们必须有一个要加载的程序集。
在本例中,我们将将C#项目构建为一个动态链接库(DLL)。在Visual Studio中,DLL的项目类型被称为“Class Library”,所以确保你的C#项目的“输出类型”设置为“Class Library”,如下图所示:
一开始,我们将简单地编写一些基本的C#代码,以确保我们的代码实际上能够运行。首先,我将创建一个名为 “CSharpTesting” 的C#类,并向其添加一些基本的数据和方法。
using System;
public class CSharpTesting
{
public float MyPublicFloatVar = 5.0f;
public void PrintFloatVar()
{
Console.WriteLine("MyPublicFloatVar = {0:F}", MyPublicFloatVar);
}
private void IncrementFloatVar(float value)
{
MyPublicFloatVar += value;
}
}
目前我们不会实际运行这些代码,但我们将在加载程序集后检查该类是否存在。此代码还将用于演示在使用Mono时需要记住的一些事项。
但现在,继续构建你的项目,你应该得到一个DLL文件,该文件的名称与你的项目名称相对应,可以在项目文件夹中的名为 “bin” 的文件夹中找到它。记住文件路径,因为我们加载DLL时会需要它。
C++代码
现在是时候编写实际将C# DLL加载到Mono中的代码了。通常在游戏引擎中,你需要加载两个DLL,一个包含游戏代码,另一个由引擎提供。
通常,引擎会提供一个C# DLL,游戏代码将与其链接,这是因为引擎开发人员可以为用户提供安全的API来进行交互。因此,我们将编写一个通用函数,它将简单地加载任何DLL,该函数将以DLL文件的路径作为参数,并返回指向MonoAssembly的指针。
游戏代码DLL包含了具体的游戏实现,而引擎DLL提供了引擎所需的基础设施。
- 游戏代码 DLL: 这是包含实际游戏逻辑和功能的代码的库。开发者编写的游戏逻辑通常存储在这个DLL中。游戏引擎加载这个DLL以运行游戏。
- 引擎提供的 DLL: 这是由游戏引擎开发人员提供的库,其中包含引擎的核心功能和API。这个DLL为游戏提供了与游戏引擎进行交互的接口。游戏代码DLL可能会链接到这个引擎DLL,以利用引擎提供的功能和服务。
现在,有很多方法可以使用Mono加载程序集,但首选的方法是将文件加载到字节数组中,然后将该字节数组直接传递给Mono。
我还将提供一个将文件加载到字节数组的函数:
char* ReadBytes(const std::string& filepath, uint32_t* outSize)
{
std::ifstream stream(filepath, std::ios::binary | std::ios::ate);
if (!stream)
{
// 打开文件失败
return nullptr;
}
std::streampos end = stream.tellg();
stream.seekg(0, std::ios::beg);
uint32_t size = end - stream.tellg();
if (size == 0)
{
// 文件为空
return nullptr;
}
char* buffer = new char[size];
stream.read((char*)buffer, size);
stream.close();
*outSize = size;
return buffer;
}
加载C#程序集的代码:
MonoAssembly* LoadCSharpAssembly(const std::string& assemblyPath)
{
uint32_t fileSize = 0;
char* fileData = ReadBytes(assemblyPath, &fileSize);
// 注意:我们不能对这个图像执行除了加载程序集以外的任何操作,因为这个图像没有对程序集的引用
MonoImageOpenStatus status;
MonoImage* image = mono_image_open_from_data_full(fileData, fileSize, 1, &status, 0);
if (status != MONO_IMAGE_OK)
{
const char* errorMessage = mono_image_strerror(status);
// 使用 errorMessage 数据记录一些错误消息
return nullptr;
}
MonoAssembly* assembly = mono_assembly_load_from_full(image, assemblyPath.c_str(), &status, 0);
mono_image_close(image);
// 不要忘记释放文件数据
delete[] fileData;
return assembly;
}
代码解释
首先,我们将C#程序集的字节读入char*缓冲区中。之后,我们需要将加载的数据提供给Mono,我们可以通过调用mono_image_open_from_data_full
来实现。前两个参数应该是不言而喻的,它们只是数据和数据的大小。第三个参数告诉Mono我们是否希望它复制数据,还是我们负责存储它,这里我们传递1,表示Mono将数据复制到内部缓冲区中。第四个参数是指向MonoImageOpenStatus
枚举的指针,我们可以使用此值确定Mono是否能够读取该数据,或者是否有问题。
最后一个参数也是一个布尔值,如果设置为true或1,表示Mono将以“反射模式”加载我们的图像,这意味着我们可以检查类型,但不能运行任何代码。如果你正在构建类似JetBrains
dotPeek
程序的应用程序,你很可能希望将此参数设置为true,但由于我们想要运行代码,我们将其设置为false或0。
mono_image_open_from_data_full
如果成功解释(因为是二进制的)我们的数据,将返回指向MonoImage
结构的有效指针,否则将返回nullptr
。在我们将数据加载到Mono之后,我们将检查status
变量是否设置为MONO_IMAGE_OK
,如果没有,我们将查询Mono获取描述发生了什么问题的错误消息,我们使用mono_image_strerror
来实现,它会将我们的status
变量转换为更友好的错误消息。
现在我们有一个有效的图像加载了,我们必须通过它创建一个MonoAssembly
,幸运的是,这非常容易,我们只需调用mono_assembly_load_from_full
并给它图像。如果此函数成功,我们将获得指向MonoAssembly
结构的指针,否则它将返回nullptr
。
此函数的第一个参数是我们从Mono获得的图像,第二个参数实际上只是一个名称,Mono可以在打印错误时使用,第三个参数是我们的status
变量,此函数将在发生错误时写入我们的status变量,但在这一点上真的不应该生成错误,所以我们不会检查它。
最后一个参数与mono_image_open_from_data_full
中的最后一个参数相同,因此如果在那里指定了1,你应该在此函数中也这样做,但在我们的情况下,我们将其设置为0。
在从Mono获得一个MonoAssembly
指针后,我们可以(也应该)关闭该图像,因为它仅用于获取MonoAssembly
指针,对于其他任何事情都是无用的。我会注意到MonoImages
在Mono中用于其他一些事情,我们稍后会涵盖这些内容,但这个图像是无用的,所以我们需要关闭它以减少引用计数。
就是这样!但因为我们是好的程序员,我们会确保释放我们加载的缓冲区,使用delete[] fileData
;。之后我们只需返回我们的assembly
指针!
现在你可能想要确保要加载的文件实际上存在,但在这个示例中,我们假设我们永远不会尝试加载磁盘上不存在的文件。
好了,现在我们有一个能够将C#程序集加载到Mono运行时的函数,所以现在是时候实际加载我们的程序集并验证我们的代码是否有效
2、程序集加载测试
现在我们有一个能够加载 C# 程序集的函数。现在我们所要做的就是确保它能正常工作。我们应该怎么做呢?当然,我们可以简单地检查我们是否得到了有效的 MonoAssembly
指针,但这并不严格意味着一切都按预期工作。
为了正确测试它,我们将遍历程序集中定义的所有类类型,这样我们就可以确切地看到其中有哪些类、结构体和枚举。我们通过迭代程序集元数据(metadata
)来实现这一点,通过获取对类型定义表的访问权限来实现。
2.1 测试代码
这段代码展示了如何迭代程序集中的所有类型定义
void PrintAssemblyTypes(MonoAssembly* assembly)
{
// 获取程序集图像
MonoImage* image = mono_assembly_get_image(assembly);
// 获取类型定义表信息
const MonoTableInfo* typeDefinitionsTable = mono_image_get_table_info(image, MONO_TABLE_TYPEDEF);
// 获取类型的数量
int32_t numTypes = mono_table_info_get_rows(typeDefinitionsTable);
// 迭代类型定义表中的每一行
for (int32_t i = 0; i < numTypes; i++)
{
// 当前行的列数据
uint32_t cols[MONO_TYPEDEF_SIZE];
mono_metadata_decode_row(typeDefinitionsTable, i, cols, MONO_TYPEDEF_SIZE);
// 从图像中获取命名空间和类型名称
const char* nameSpace = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAMESPACE]);
const char* name = mono_metadata_string_heap(image, cols[MONO_TYPEDEF_NAME]);
// 打印命名空间和类型名称
printf("%s.%s\n", nameSpace, name);
}
}
解释一下:
这段代码定义了一个函数 PrintAssemblyTypes
,该函数接受一个 MonoAssembly*
类型的参数,表示一个Mono程序集。函数首先通过 mono_assembly_get_image
获取了程序集的图像(image)。然后,通过 mono_image_get_table_info
获取了类型定义表(typeDefinitionsTable)的信息。接着,通过 mono_table_info_get_rows
获取了类型的数量。
接下来,通过一个简单的循环,该函数迭代了类型定义表中的每一行。通过调用 mono_metadata_decode_row
获取每一行的列数据,并从图像中获取命名空间和类型名称。最后,将命名空间和类型名称打印到控制台。
这样,函数就完成了遍历并打印程序集中所有类型定义的任务。这种方法可以让你查看程序集中包含的所有类、结构体和枚举。
2.2 表的行和列
在我们得到要迭代的表后,我们需要获取表中的行数,这种情况下是类型定义的数量。我们可以通过调用 mono_table_info_get_rows
并传递表信息指针来完成这个任务。
然后,我们遍历所有行,现在我们必须获取每一行的所有列值。所有列都将它们的数据存储为uint32_t
即无符号32位整数,因此我们首先分配一个名为 cols
的数组,并将数组的大小设置为我们正在迭代的表的最大列数。Mono提供了代表每个表所需的常量,所以在这种情况下,我们将数组的大小设置为 MONO_TYPEDEF_SIZE
。
为了填充数组,我们必须解码类型定义表中的当前行,我们可以通过调用 mono_metadata_decode_row
来完成这个操作,并传递一些参数。虽然我认为这些参数是不言自明的,但我意识到这对于每个人来说可能并非如此,因此我将逐一解释每个参数是什么。
第一个参数是我们要迭代的实际表。第二个参数是我们想要获取其列的行。第三个参数只是我们分配的列数组,最后一个参数是该数组的大小。
调用此函数后,我们的 cols 数组现在将包含一堆值,我们现在可以使用这些值来获取此类型的一些数据。
在我解释代码的其余部分之前,简要说明一下:根据列代表的是什么,应该以不同的方式使用此数组中存储的数据。在某些情况下,该值是我们想要的值,直接存储在数组中;在其他情况下,该值表示另一个内存中不同位置的数据结构的索引,在给定类型的命名空间和名称的情况下,列存储在字符串堆中的索引。
因此,有时你会像我们在这里做的一样,使用该值从字符串堆中获取一个字符串,有时你会直接使用该值,MONO_ASSEMBLYREF_MAJOR_VERSION
是一个很好的例子,如果你想要获取程序集的主版本号,你只需执行 uint32_t majorVersion = cols[MONO_ASSEMBLYREF_MAJOR_VERSION];
,假设你有正确的表。
2.3 获取命名空间和类型名
我将解释代码中的后面两行,你可以看到它们几乎相同,我们对两行都调用了 mono_metadata_string_heap
,并传递了图像和列的某个值。
首先,我们通过访问 MONO_TYPEDEF_NAMESPACE
列中存储的值来获取命名空间名称,再次强调,该值是字符串堆中索引,我们的命名空间名称就在那里。如果一个类型没有命名空间,意味着它在全局命名空间中,这个函数将简单地返回一个空字符串。
接下来,我们做了几乎完全相同的事情,只是这次我们获取的是 MONO_TYPEDEF_NAME
列中的值。
正如你从下面的图像中看到的,MONO_TABLE_TYPEDEF
表中仍然有一些其他列,我现在不打算在这里详细介绍它们,但我会确保在以后适当地介绍它们。
好的!如果你现在调用这个函数(在加载了程序集之后),你应该在控制台上看到打印出的存储在你的程序集中的所有类型。
2.4 模块类型
你可能注意到第一个打印的类型是<Module>
,而你可能意识到你的项目中并没有这个名字的类型,所以发生了什么?实际上,这是由C#编译器提供的一种类型,所有的C# dll和exe文件都有这个类型。
实际上,这个类型代表了整个程序集,你的程序集总是至少有一个模块,尽管有可能创建一个多文件程序集,它是包含多个模块的程序集。但无论如何,在这里并不重要,因为在本指南中我们永远不会使用 <Module>
类,而且如果你正在制作一个脚本引擎,你很可能永远不会用到它。
至此,这一节就结束了!如果你在控制台上看到了你的类型打印出来,那么可以安全地假设一切都按预期工作,你已经对C#程序集如何存储数据有了一些了解,现在我们可以继续进行一些有趣的操作。在下一节中,我们将创建一个 CSharpTesting
类的实例。