c#コンポーネントをネイティブアプリ(c++)から利用したかったので、最近wineに寄贈されたmonoについて調べていたのですが、最新の.NET SDKで作成したアセンブリは実行できないことから、.NETランタイムをホストして利用してみることにしました。
※ monoに含まれないAPIなどを利用しなければ一応実行はできますが・・・

.NETランタイムのホストにはhostfxrを利用します。
Godotエンジンなんかはmonoからhostfxrに移行したようです。

Current state of C# platform support in Godot 4.2 – Godot Engine
How the transition to a unified .NET has impacted platform support, and re-adding the ability to port to mobile.

利用する環境は以下の通りです。

  • macos 15.3.2 arm64
  • .NET SDK 8.0.407

また、ドキュメントは以下を参照しました。

runtime/docs/design/features/native-hosting.md at main · dotnet/runtime
.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. - dotnet/runtime

※本ポスト内のプログラムはwindows等を考慮しません。

hostfxrを利用する準備

以下のヘッダファイルとライブラリファイルを準備する必要があります。
インストール済みの.NET SDKのディレクトリ内に以下のヘッダ・ライブラリがあるので、これを参照する形でプロジェクトを作成していきます。

  • nethost.h
  • hostfxr.h
  • coreclr_delegates.h
  • libnethost.a or libnethost.dylib(こちらの名称はOSによって異なります)

自分の環境では以下に配置されていました。

  • /usr/local/share/dotnet/packs/Microsoft.NETCore.App.Host.osx-arm64/8.0.14/runtimes/osx-arm64/native

※ ヘッダファイルに関しては以下の.NETランタイムのリポジトリからダウンロードすることもできます。

GitHub - dotnet/runtime: .NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. - dotnet/runtime

.NETライブラリを作成する

ネイティブコードから利用するc#ライブラリを作成します。
名前はSampleLibとします。

namespace SampleLib;

public class Lib
{
    [UnmanagedCallersOnly]
    public static int Add(int a, int b)
    {
        return a + b;
    }
}
<Project Sdk="Microsoft.NET.Sdk">
    <PropertyGroup>
        <TargetFramework>net8.0</TargetFramework>
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
        <EnableDynamicLoading>true</EnableDynamicLoading>
    </PropertyGroup>
</Project>

ライブラリプロジェクトのためruntimeconfig.jsonを出力するためにEnableDynamicLoadingtrueを設定しています。
※ runtimeconfig.jsonはホスティング時に必要となります。

ちょっと面倒ですが、今回はビルドの成果物をプロジェクトディレクトリにコピーして利用します。

hostfxrを利用してメソッドを呼び出す

cmakeの設定とc++プログラムを作成する。

cmake_minimum_required(VERSION 3.31)

project(dotnet_host_sample)

set(CMAKE_CXX_STANDARD 20)

add_executable(app main.cpp)

target_include_directories(
    app
    PRIVATE
    /usr/local/share/dotnet/packs/Microsoft.NETCore.App.Host.osx-arm64/8.0.14/runtimes/osx-arm64/native
)

target_link_libraries(
    app 
    PRIVATE
    /usr/local/share/dotnet/packs/Microsoft.NETCore.App.Host.osx-arm64/8.0.14/runtimes/osx-arm64/native/libnethost.a
)
#ifndef MAIN_HPP
#define MAIN_HPP

#include <iostream>
#include <stdexcept>
#include <string>
#include <dlfcn.h>

#include <nethost.h>
#include <hostfxr.h>
#include <coreclr_delegates.h>

#define MAX_PATH 260

class Hostfxr {
public:
    Hostfxr(const char* conf_path);
    ~Hostfxr();

    Hostfxr(const Hostfxr&) = delete;
    Hostfxr& operator=(const Hostfxr&) = delete;

    bool isValid() const;
    bool initialized() const;
    std::string getError();

    int loadAssembly(const char *assembly_path);
    void* getFunctionPointer(const char *type_name, const char *method_name);

private:
    void *_lib = nullptr;
    hostfxr_handle _handle = nullptr;
    bool _initialized = false;
    bool _valid = true;
    std::string _error;

    template<typename T>
    T getSymbol(const char *symbol_name) const;

    hostfxr_initialize_for_runtime_config_fn hostfxr_init;
    hostfxr_close_fn hostfxr_close;
    hostfxr_get_runtime_delegate_fn hostfxr_get_delegate;

    load_assembly_fn hostfxr_load_assembly;
    get_function_pointer_fn hostfxr_get_function_pointer;
};

inline Hostfxr::Hostfxr(const char* conf_path) {
    // hostfxrのパスを取得する
    char_t buffer[MAX_PATH];
    size_t buffer_size = sizeof(buffer) / sizeof(char_t);
    if (get_hostfxr_path(buffer, &buffer_size, nullptr) != 0) {
        _error = "hostfxrのパスを取得できませんでした。";
        _valid = false;
        return;
    }

    _lib = dlopen(buffer, RTLD_LAZY | RTLD_LOCAL);
    if (!_lib) {
        _error = "hostfxrをロードできませんでした。";
        _valid = false;
        return;
    }

    hostfxr_init = getSymbol<hostfxr_initialize_for_runtime_config_fn>("hostfxr_initialize_for_runtime_config");
    hostfxr_get_delegate = getSymbol<hostfxr_get_runtime_delegate_fn>("hostfxr_get_runtime_delegate");
    hostfxr_close = getSymbol<hostfxr_close_fn>("hostfxr_close");

    // hostfxrを初期化する
    if (hostfxr_init(conf_path, nullptr, &_handle) != 0) {
        _error = "hostfxrの初期化に失敗しました。";
        _valid = false;
        return;
    }

    hostfxr_get_delegate(_handle, hdt_load_assembly, (void**)&hostfxr_load_assembly);
    hostfxr_get_delegate(_handle, hdt_get_function_pointer, (void**)&hostfxr_get_function_pointer);
    if(!hostfxr_close || !hostfxr_init || !hostfxr_get_delegate || !hostfxr_load_assembly || !hostfxr_get_function_pointer) {
        _error = "関数を取得できませんでした。";
        _valid = false;
        return;
    }

    _initialized = true;
}

inline Hostfxr::~Hostfxr() {
    if (_handle) hostfxr_close(_handle);
    if (_lib) dlclose(_lib);
}

template<typename T>
inline T Hostfxr::getSymbol(const char *symbol_name) const {
    return reinterpret_cast<T>(dlsym(_lib, symbol_name));
}

inline bool Hostfxr::isValid() const { return _valid; }
inline bool Hostfxr::initialized() const { return _initialized; }
inline std::string Hostfxr::getError() { 
    std::string s = _error;
    _error.clear();
    return s;
}

inline int Hostfxr::loadAssembly(const char *assembly_path) {
    if (hostfxr_load_assembly(assembly_path, nullptr, nullptr) != 0) {
        _error = "アセンブリのロードに失敗しました。";
        return 1;
    }

    return 0;
}

inline void* Hostfxr::getFunctionPointer(const char *type_name, const char *method_name) {
    void* fn = nullptr;

    int rc = hostfxr_get_function_pointer(
        type_name,
        method_name,
        // .net8ではnullptrではなくUNMANAGEDCALLERSONLY_METHODを指定する必要がある
        // nullptr,
        UNMANAGEDCALLERSONLY_METHOD,
        nullptr,
        nullptr,
        &fn
    );

    if (rc != 0) {
        _error = "関数ポインタの取得に失敗しました。";
        return nullptr;
    }

    return fn;
}


#endif // MAIN_HPP
#include <iostream>
#include <filesystem>

#include <dlfcn.h>
#include <nethost.h>

#include "main.hpp"

int main(int argc, char** argv) {
    std::string base_path = std::filesystem::current_path().c_str();
    std::string conf_path = base_path + "/SampleLib.runtimeconfig.json";
    std::string asem_path = base_path + "/SampleLib.dll";

    // hostfxrをロードする
    Hostfxr hostfxr { conf_path.c_str() };
    if (!hostfxr.isValid()) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    }

    // アセンブリをロードする
    if (hostfxr.loadAssembly(asem_path.c_str()) != 0) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    };

    // 呼び出すメソッドの関数ポインタを取得する
    void* method = nullptr;
    if (!(method = hostfxr.getFunctionPointer("SampleLib.Lib, SampleLib", "Add"))) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    }

    // メソッドを呼び出す。
    int result = reinterpret_cast<int(*)(int, int)>(method)(1, 2);
    std::cout << result << std::endl;

    return 0;
}

こんな感じで、hostfxrをロードして、hostfxr関連の関数を利用することでアセンブリのロードや、メソッドの取得・呼び出しが可能になります。

c#クラスのインスタンスメソッドを呼び出す

Addメソッドを呼び出してみた、では何も面白くないので、C#クラスのインスタンスをネイティブアプリ側で保持し、メソッドを呼び出す、みたいなことをやってみたいと思います。

using System.Runtime.InteropServices;

namespace SampleLib;

public class GameObject
{
    public virtual void Start()
    {
        Console.WriteLine("start");   
    }

    public virtual void Update(float deltaTime)
    {
        Console.WriteLine($"update, delta time: {deltaTime}");
    }

    public virtual void OnDestroy()
    {
        Console.WriteLine("destroy");
    }
}

public class Lib
{
    [UnmanagedCallersOnly]
    public static IntPtr CreateInstance(IntPtr typeNamePtr)
    {
        string? typeName = Marshal.PtrToStringAnsi(typeNamePtr);
        if (typeName is null)
        {
            return IntPtr.Zero;
        }
        
        var type = Type.GetType(typeName);
        if (type is null)
        {
            return IntPtr.Zero;
        }
        
        var instance = Activator.CreateInstance(type);

        if (instance is null)
        {
            return IntPtr.Zero;
        }

        var handle = GCHandle.Alloc(instance, GCHandleType.Normal);
        
        return GCHandle.ToIntPtr(handle);
    }

    [UnmanagedCallersOnly]
    public static void CallStart(IntPtr handlePtr)
    {
        if (handlePtr == IntPtr.Zero) return;
        
        var inst = GCHandle.FromIntPtr(handlePtr).Target as GameObject;

        if (inst is null) return;
        
        inst.Start();
    }
    
    [UnmanagedCallersOnly]
    public static void CallUpdate(IntPtr handlePtr, float deltaTime)
    {
        if (handlePtr == IntPtr.Zero) return;

        var inst = GCHandle.FromIntPtr(handlePtr).Target as GameObject;

        if (inst is null) return;
        
        inst.Update(deltaTime);
    }
    
    [UnmanagedCallersOnly]
    public static void CallDestroy(IntPtr handlePtr)
    {
        if (handlePtr == IntPtr.Zero) return;

        var inst = GCHandle.FromIntPtr(handlePtr).Target as GameObject;

        if (inst is null) return;
        
        inst.OnDestroy();
        
        var handle = GCHandle.FromIntPtr(handlePtr);
        handle.Free();
    }
}
int main(int argc, char** argv) {
    std::string base_path = std::filesystem::current_path().c_str();
    std::string conf_path = base_path + "/SampleLib.runtimeconfig.json";
    std::string asem_path = base_path + "/SampleLib.dll";

    // hostfxrをロードする
    Hostfxr hostfxr { conf_path.c_str() };
    if (!hostfxr.isValid()) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    }

    // アセンブリをロードする
    if (hostfxr.loadAssembly(asem_path.c_str()) != 0) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    };

    // 呼び出すメソッドの関数ポインタを取得する
    void *create = nullptr, *start = nullptr, *update = nullptr, *destroy = nullptr;

    create = hostfxr.getFunctionPointer("SampleLib.Lib, SampleLib", "CreateInstance");
    start = hostfxr.getFunctionPointer("SampleLib.Lib, SampleLib", "CallStart");
    update = hostfxr.getFunctionPointer("SampleLib.Lib, SampleLib", "CallUpdate");
    destroy = hostfxr.getFunctionPointer("SampleLib.Lib, SampleLib", "CallDestroy");

    if (!create || !start || !update || !destroy) {
        std::cerr << hostfxr.getError() << std::endl;
        return 1;
    }

    // メソッドを呼び出す。
    void* instance = reinterpret_cast<void*(*)(const char*)>(create)("SampleLib.GameObject, SampleLib");

    // startメソッドを呼び出す
    reinterpret_cast<void(*)(void*)>(start)(instance);

    // updateメソッドを呼び出す
    float deltaTime = 1 / 60.0f;
    for (int i = 0; i < 5; ++i) {
        reinterpret_cast<void(*)(void*, float)>(update)(instance, deltaTime);
    }

    // OnDestroyメソッドを呼び出す
    reinterpret_cast<void(*)(void*)>(destroy)(instance);

    return 0;
}

hostfxrの制限として静的メソッドしか取得することができません。
そのため、インスタンスのメソッドを呼び出すための静的メソッドを準備しています。
GCHandleの取り回しで無駄がありますが、この辺りはどうすると効率がいいのだろう。
有識者に教えてもらいたい。

デバッグ

アプリケーションプロセスにIDE等のデバッガをアタッチすればデバッグすることが可能です。

最後に

coreclrhost.hは何故非推奨になってしまったんだろう。
monoくらい使いやすそうなAPIが実装されてくれると嬉しいのですが...