結合代碼來看 Unity 3D 的跨平臺特性實現

圖片取自 Zoommy

Unity3D 的 1.0 版本在 2005 年 6 月 6 日發佈,是當前使用十分廣泛的一款遊戲引擎。它的使用廣泛離不開 Write once, run in everywhere的特性,那就是寫一次代碼,可以做多個平臺運行。這一特性使開發者的開發、維護成本變得更小,無需實現、維護多套不同平臺的代碼,直接使用 Unity3D 提供的 C# API 開發即可。

跨平臺問題

那 Unity3D 是如何實現跨平臺特性的?和我們熟知的 Java 跨平臺實現上是一致的嗎?

Java 跨平臺通過提供一箇中間層來解決跨平臺問題。JRE(Java 運行時)就是這裏的中間層。通過了解 Java 代碼編譯過程,這一點就比較容易理解。JRE 中的 JVM(Java 虛擬機)並不能直接運行 Java 代碼,而是運行編譯之後的 .class 文件內容,該文件中其實是 Java 字節碼(bytecode)。換句話說,Java 代碼是先編譯成字節碼,然後運行在 JVM 之上的,JVM 負責將字節碼編譯爲運行在 CPU 之上的機器碼。這裏的 JRE 幫我們屏蔽了硬件層面的區別,我們只需要在 macOS、 Windows 甚至塞班系統(過時的諾基亞手機系統)中嵌入 Java 運行時,我們的 Java 代碼就可以跑在這些平臺上了。

其實 Unity3D 的解決方案是相同的:增加一箇中間層來負責消除硬件的差別。我們知道 Unity3D 使用的語言是 C#,提到 C# 第一個想到的就是 .NET。.NET 相當於 C# 的運行時,C# 編譯生成 .dll 或 .exe 中間文件,而這些中間文件交由 .NET 來進而編譯成機器碼。其編譯流程如下圖。但這裏的和 JRE 不同的地方是,.NET 只能運行在 Windows 平臺。想要實現跨平臺特性,Unity3D 是不能使用 .NET 作爲 C# 運行時的。這裏就要提到 Mono。

C# 編譯流程

Mono 由 Xamarin(本質上也是 Microsoft) 公司贊助開發,遵循 C# 的 ECMA 標準和 CLI(Common Language Infrastructure),相當於 .NET 的跨平臺版本。Unity3D 也是採用 Mono + C# 來獲得跨平臺的能力的。

至於爲什麼 Unity3D 要使用 Mono + C#,那就不得不提到 Unit3D 起初使用的 C++ 語言。C++ 使引擎擁有了十分強悍的性能。但是帶來的問題是無法適應快速開發迭代的環境,C++ 是比較接近硬件的語言,開發效率不如一些高級語言。但是,純粹的使用高級語言帶來的問題是運行效率低下。這就導致了兩者結合的出現:引擎底層使用 C++ 開發,提供性能保障;上層提供高級語言 的 API,方便開發者;中間嵌入運行時,提供高級語言和 C++ 之間調用的橋樑。下列圖中介紹了三種方式的區別。

C++ 程序結構示意-高效

高級語言程序結構示意-低效

兩者結合的方式-優勢互補

Unity3D 也是採用這種方式,只是這裏的高級語言和運行時選用的是 C# 和 Mono 運行時。

接下來我們來實踐一下,來看看如何實現 C# 和 C++ 的互相調用,嘗試瞭解一下 Unity3D 遊戲內部運行的機理。

實踐

上文我們提到 C# 和 C++ 互相調用的關鍵是 Mono。我們先來看下 Mono 提供了哪些內容:

  • C# 編譯器
    將 C# 編譯爲中間文件,即 bytecode
  • Mono 運行時
    將 bytecode 編譯爲 native code(它是基於 CPU 架構,非跨平臺的)。此處的編譯方式有三類:JIT(Just In Time)、AOT(Ahead of time)、Full-AOT。
    JIT 是指應用運行時,進行編譯;AOT 代表運行之前,將大部分代碼編譯好,但其中小部分代碼仍會放在運行時編譯;Full-AOT 就是完全沒有 JIT 的 AOT。
  • 基礎類庫、Mono 類庫
    爲開發者提供的方便使用的類庫

然後我們來看一下,C# 代碼是如何嵌入到已有的 C++ 程序中執行的。
我們已有的 C/C++ 程序如下圖:

C/C++ 程序

將 Mono 運行時鏈接到已有的 C++ 程序中,其實是和 libmono 庫進行鏈接。鏈接後的地址空間如下圖:

鏈接 Mono 運行時之後

Mono 運行時給 C++ 部分提供了 API 調用,讓 C++ 具備獲取運行時環境和在其中運行的 C# 代碼的能力。當 Mono 運行時加載了 C# 編譯後的中間代碼之後,其地址空間如下圖:

加載 C# 中間代碼後

總的來說,我們先將提供了 Mono 運行時的庫和現有的 C++ 程序鏈接,同時因爲 Mono 運行時提供 C++ 獲取其內部 C# 中間代碼執行環境的能力,所以在 Mono 運行時加載中間代碼之後,就可以使用 C++ 調用 C# 了。我們代碼所實現的流程也基本如此。

接下來,我們以 macOS 平臺爲例,看一下如何根據上述流程,編寫一個 C++ 調用 C# 的程序。

流程概覽

這裏先整體說一下整個流程中需要操作的步驟,可能碰到的問題和解決方法可以下文找到。

  1. 確認 g++ 是否安裝,macOS 會自帶該程序,有下文輸出代表已經安裝
$ g++
clang: error: no input files
  1. 安裝 pkgconfig,用於鏈接 C++ 程序和 mono 庫
$ brew install pkg-config
  1. 安裝 Mono 環境,點擊下載後,雙擊安裝即可;安裝完成,終端輸入以下命令正常輸出版本號即可
$ mono --version
Mono JIT compiler version 6.4.0.208 (2019-06/07c23f2ca43 Wed Oct  2 04:52:23 EDT 2019)
  1. 編寫 C++、C# 代碼:CppInvokeCS.cpp、CppInvokeCS.cs
  2. 編譯、運行代碼
// 編譯 C++
$ g++ CppInvokeCS.cpp `pkg-config --cflags --libs mono-2`
// 編譯 C#
$ mcs CppInvokeCS.cs -t:library
// 運行
$ ./a.out
// 運行結果
Hello World

接下來我們看下具體的代碼實現。

代碼部分

這裏我們以 C++ 調用 C# 爲例,C# 調用 C++ 的代碼大同小異,在此將我編寫的 C++、C# 互相調用的源碼放出來給大家參考。

點擊查看 GitHub 源碼

代碼主要包括兩部分:CppInvokeCS.cpp、CppInvokeCS.cs。其內容如下,大家可以根據註釋來了解對應代碼的功能。

CppInvokeCS.cpp

#include "mono/jit/jit.h"
#include <mono/metadata/assembly.h>
#include <mono/metadata/class.h>
#include <mono/metadata/debug-helpers.h>
#include <mono/metadata/mono-config.h>

int main()
{
    // 初始化 Mono 運行時
    MonoDomain *domain = mono_jit_init("CppInvokeCS");
    // 配置 Mono 的位置和配置文件
    mono_set_dirs("/Library/Frameworks/Mono.framework/Home/lib", "/Library/Frameworks/Mono.framework/Home/etc");
    // 使用默認配置文件
    mono_config_parse(NULL);
    // 加載 CppInvokeCS.dll
    MonoAssembly *assembly = mono_domain_assembly_open(domain, "./CppInvokeCS.dll");
    MonoImage *image = mono_assembly_get_image(assembly);

    //獲取 MonoClass
    MonoClass *main_class = mono_class_from_name(image, "CppInvokeCS", "Main");
    // 獲取 MonoMethodDesc
    MonoMethodDesc *entry_point_method_desc = mono_method_desc_new("CppInvokeCS.Main:Log()", true);
    MonoMethod *entry_point_method = mono_method_desc_search_in_class(entry_point_method_desc, main_class);
    mono_method_desc_free(entry_point_method_desc);
    // 調用方法
    mono_runtime_invoke(entry_point_method, NULL, NULL, NULL);
    // 釋放運行時
    mono_jit_cleanup(domain);
    return 0;
}

CppInvokeCS.cs

namespace CppInvokeCS
{
    public static class Main
    {
        public static void Log()
        {
            System.Console.WriteLine("Hello World");
        }
    }
}

延伸思考

這裏不得不說的是,根據 C++ 代碼中調用 C# 方法的部分,我們會發現它的流程和 Java 反射的代碼流程是十分相似的:

  1. 獲取對應類
  2. 獲取要調用的方法
  3. 執行方法

我們上文提到,C# 和 Java 類似,通過提供中間層解決跨平臺問題。我們稍加探索可以發現,這是語言跨平臺技術形成的共識,就是將高級語言編譯爲 il(intermediate language)代碼,il 代碼是具有跨平臺特性的,然後提供運行時環境(runtime)在將 il 編譯爲基於硬件有差別的 native code,以此實現高級語言的跨平臺。我們將『提供中間層』這一思路想的更廣泛一點會發現,很多問題都是如此解決的。我們網絡需要分層來將網絡中各部分職責分離以簡化網絡硬件、軟件實現的複雜度,軟件開發時需要分層來分離代碼、降低複雜度、提高可維護性,公司的管理需要分層次來將職責劃分。

大家或許可以由此對 C++ 和 C# 提出更加深入的問題:C++ 調用 C# 內部的機制是怎樣的?跟 Java 反射機制從原理上講有什麼聯繫?其原理從底層上看是一樣的嗎?如果你對這個更加深入的話題感興趣,可以自行探索。

可能碰到的問題及其解決

找不到 mono-2

問題如下:

$ g++ CppInvokeC#.cpp `pkg-config --cflags --libs mono-2`
 
Package mono-2 was not found in the pkg-config search path.
Perhaps you should add the directory containing `mono-2.pc' to the PKG_CONFIG_PATH environment variable
No package 'mono-2' found
CppInvokeCS.cpp:1:10: fatal error: 'mono/jit/jit.h' file not found

解決方法:命令行輸入以下命令即可解決,其中 6.4.0 根據你安裝的版本號來動態修改

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig:/usr/lib/pkgconfig:/Library/Frameworks/Mono.framework/Versions/6.4.0/lib/pkgconfig:$PKG_CONFIG_PATH

Reference

版權聲明
本文首發於微信公衆號:AndroidRain
同步發於簡書,搜索作者 QinGeneral
同步發於CSDN博客,搜索作者 QinGeneral
無需授權即可轉載,請保留以上版權聲明;
轉載時請務必註明作者。

掃碼關注微信公衆號

掃碼關注微信公衆號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章