用 Native AOT 把 C# 做成原生 DLL 的方法 - 用 UnmanagedCallersOnly 從 C/C++ 呼叫

· · C#, .NET, Native AOT, C++, Windows 開發, 原生互通

前一篇 在 C# 中呼叫原生 DLL 時,C++/CLI 包裝器是有力選擇的理由 整理了從 C# 呼叫 C++ 的交界面。這次把方向反過來,談的是從 C/C++ 呼叫 C# 的做法。

想把用 C# 寫的處理從既有 C/C++ 應用程式呼叫進來,但 P/Invoke 的方向剛好相反,而動用 C++/CLI 或 COM 又嫌太大——這種場面並不少見。尤其是想保留原生應用程式本體不動,只把判定邏輯、字串處理、設定解讀、計算規則這類部分搬到 C# 側的時候。

用 COM 也能架橋,但這次要介紹的是更 in-process、更像 DLL 的做法。.NET 的 Native AOT 可以把類別庫發行成原生共享程式庫,並以 UnmanagedCallersOnly 屬性的方法做為 C 的進入點對外公開。換句話說,可以把 C# 當成「被呼叫的那側的原生 DLL」來用。

不過不是什麼東西都可以就這樣跨過邊界。一旦 stringList<T>、例外、所有權洩漏到交界面,空氣馬上就會變差。本文以 Windows + C++ 的最小範例,整理什麼情境下這個構成很適合、以及要做成什麼樣的 API 形狀才不易壞掉。Linux / macOS 的思路幾乎相同,但程式範例以 Windows DLL 為前提。

1. 先說結論(一句話)

  • 若要從 C/C++ in-process 呼叫 C# 處理,Native AOT + UnmanagedCallersOnly 是相當有力的選擇。
  • 不過,對外 export 的終究是 C 函式的進入點。不是把 stringList<T> 直接暴露出去的世界。
  • 實務上建議落到 create / destroy / operate 這樣的扁平 C API,並明確處理壽命管理與錯誤碼,比較穩定。
  • 想自然地處理 C++ 的類別或 STL 就用 C++/CLI;需要註冊、自動化或跨行程就用 COM 更合適。

簡言之,可以把 C# 當成原生 DLL 的內容使用,但交界面要以 C ABI(而不是 .NET)來設計。只要把這點切割清楚,就會是一項很有趣的武器。

2. 先看的分流表

想做的事 有力候選 理由
從 C# 呼叫一群 C 函式 P/Invoke 方向直觀、最自然
從 C# 自然地使用 C++ 函式庫 C++/CLI C++ 型別、所有權、例外、std::wstring 等容易在 C++ 端吸收
跨 32bit / 64bit 或跨行程邊界 COM / IPC 光是 in-process DLL 無法跨越
從 C/C++ 把 C# 邏輯當原生 DLL 呼叫 Native AOT + UnmanagedCallersOnly 可以自行 export C 進入點

這個構成特別貼合的是,「原生側是主角,C# 則是被呼叫的零件」 的場面。這跟 P/Invoke、C++/CLI 的方向正好相反。

3. 構成圖

flowchart LR
    Cpp["C / C++ 應用程式"] -->|cdecl 函式呼叫| Dll["以 Native AOT 發行的 C# DLL"]
    Dll --> Exports["帶 UnmanagedCallersOnly 的 export"]
    Exports --> Core["C# 的業務邏輯"]
    Exports --> Store["handle 表 / 狀態管理"]

外觀很單純。重要的是,把交界面對齊到 C 的函式上。C# 內部實作可以是類別、集合、LINQ 都無所謂,但對外呈現的那一面要 flat。

4. 最小構成

此處用 C++ 端「建立一個累加器、加入數值、最後取得合計」的最小範例來說明。實務上換成判定引擎、設定解讀、簡易解析器都可以。簡言之就是 原生側持有一個 handle,依序呼叫操作函式 的形狀。

4.1. C# 專案

先準備一個類別庫。

<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PublishAot>true</PublishAot>
    <AllowUnsafeBlocks>true</AllowUnsafeBlocks>
  </PropertyGroup>
</Project>

重點有兩個。

  • 啟用 Native AOT publish
  • 因為會用到指標參數,允許 unsafe

本文範例以 net8.0 為前提,但思路本身在 .NET 9 / 10 上也相同。

4.2. 要 export 的 C# 程式碼

帶有 UnmanagedCallersOnly 的方法,就是原生端能看到的入口。這裡把 handle 以整數發放,內部狀態由 C# 端的 dictionary 管理。

// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace KomuraSoft.NativeAotSample;

internal static class NativeStatus
{
    public const int Ok = 0;
    public const int InvalidArgument = -1;
    public const int InvalidHandle = -2;
    public const int UnexpectedError = -3;
}

internal sealed class Accumulator
{
    public long Total { get; private set; }

    public void Add(int value)
    {
        Total += value;
    }
}

internal static class AccumulatorStore
{
    private static readonly object s_gate = new();
    private static readonly Dictionary<nint, Accumulator> s_instances = new();
    private static long s_nextHandle = 0;

    public static int Create(out nint handle)
    {
        try
        {
            var instance = new Accumulator();
            handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);

            lock (s_gate)
            {
                s_instances.Add(handle, instance);
            }

            return NativeStatus.Ok;
        }
        catch
        {
            handle = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Add(nint handle, int value)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    return NativeStatus.InvalidHandle;
                }

                instance.Add(value);
                return NativeStatus.Ok;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }

    public static int GetTotal(nint handle, out long total)
    {
        try
        {
            lock (s_gate)
            {
                if (!s_instances.TryGetValue(handle, out var instance))
                {
                    total = 0;
                    return NativeStatus.InvalidHandle;
                }

                total = instance.Total;
                return NativeStatus.Ok;
            }
        }
        catch
        {
            total = 0;
            return NativeStatus.UnexpectedError;
        }
    }

    public static int Destroy(nint handle)
    {
        try
        {
            lock (s_gate)
            {
                return s_instances.Remove(handle)
                    ? NativeStatus.Ok
                    : NativeStatus.InvalidHandle;
            }
        }
        catch
        {
            return NativeStatus.UnexpectedError;
        }
    }
}

public static unsafe class NativeExports
{
    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_create",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorCreate(nint* outHandle)
    {
        if (outHandle == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.Create(out var handle);
        *outHandle = handle;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_add",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorAdd(nint handle, int value)
    {
        return AccumulatorStore.Add(handle, value);
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_get_total",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorGetTotal(nint handle, long* outTotal)
    {
        if (outTotal == null)
        {
            return NativeStatus.InvalidArgument;
        }

        var status = AccumulatorStore.GetTotal(handle, out var total);
        *outTotal = total;
        return status;
    }

    [UnmanagedCallersOnly(
        EntryPoint = "km_accumulator_destroy",
        CallConvs = new[] { typeof(CallConvCdecl) })]
    public static int AccumulatorDestroy(nint handle)
    {
        return AccumulatorStore.Destroy(handle);
    }
}

做的事情相當樸素。

  • 對原生端只暴露 intptr_t 的 handle
  • 狀態本體由 C# 端保管
  • 把 create / add / get / destroy 拆成扁平函式
  • 回傳值是錯誤碼,輸出值透過指標參數回傳

做成這個形狀後,即使日後替換掉 C# 側的內部實作,C 側的 ABI 仍會相當穩定。

4.3. 發行指令

以共享程式庫 publish。

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

這樣在 bin/Release/net8.0/win-x64/publish/ 底下就會產出原生 DLL。Windows 就是 .dll、Linux 是 .so、macOS 是 .dylib

重要的是 每個 RID 都要 publish 一份。以 win-x64 做出來的東西不能拿去 win-arm64 用,而且呼叫端與 DLL 的 bitness 也必須一致。

4.4. C++ 側的呼叫範例

這次先把 import lib 的話題擺一邊,直接用 LoadLibrary / GetProcAddress 老實地呼叫。這種形式下會比較清楚看到什麼被 export、該用什麼簽章接收。

/* native_api.h */
#pragma once
#include <stdint.h>

enum km_status
{
    KM_STATUS_OK = 0,
    KM_STATUS_INVALID_ARGUMENT = -1,
    KM_STATUS_INVALID_HANDLE = -2,
    KM_STATUS_UNEXPECTED_ERROR = -3
};

typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>

#include "native_api.h"

template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
    FARPROC proc = ::GetProcAddress(module, name);
    if (proc == nullptr)
    {
        std::cerr << "GetProcAddress failed: " << name << '\n';
        std::exit(EXIT_FAILURE);
    }

    return reinterpret_cast<T>(proc);
}

int main()
{
    HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
    if (module == nullptr)
    {
        std::cerr << "LoadLibraryW failed" << '\n';
        return EXIT_FAILURE;
    }

    auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
    auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
    auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
    auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");

    intptr_t handle = 0;
    if (create(&handle) != KM_STATUS_OK)
    {
        std::cerr << "create failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 10) != KM_STATUS_OK)
    {
        std::cerr << "add(10) failed" << '\n';
        return EXIT_FAILURE;
    }

    if (add(handle, 20) != KM_STATUS_OK)
    {
        std::cerr << "add(20) failed" << '\n';
        return EXIT_FAILURE;
    }

    std::int64_t total = 0;
    if (getTotal(handle, &total) != KM_STATUS_OK)
    {
        std::cerr << "get_total failed" << '\n';
        return EXIT_FAILURE;
    }

    std::cout << "total = " << total << '\n';

    if (destroy(handle) != KM_STATUS_OK)
    {
        std::cerr << "destroy failed" << '\n';
        return EXIT_FAILURE;
    }

    handle = 0;

    // Native AOT 的共享程式庫不以卸載為前提使用。
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

在這個範例中,C++ 側能看到的只有「可以用函式指標呼叫的 C API」。幾乎不用去意識裡面是以 C# 寫的。

5. 不易壞掉的 API 形狀

Native AOT 可以 export 固然有趣,但實務上更重要的是 不要 export 什麼

5.1. 對齊到 C ABI

放到交界面上的型別,從一開始就應盡量落在下列範圍內,會比較平和。

  • int32_t / int64_t / double 等基本型
  • 固定版面配置的 struct
  • 相當於 intptr_t / void* 的 handle
  • uint8_t* 搭配長度

反之,從一開始就不希望洩漏到外面的有:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • C++ 的類別,或 std::vectorstd::wstring

若硬把這些直接跨過交界面,交界面的空氣馬上會變混濁。不要把 C# 的內部狀況漏到 C++,也不要把 C++ 的內部狀況過度漏到 C# 很重要。

5.2. 字串用指標 + 長度 + 緩衝區容量來處理

一想要交換字串,很容易就想直接把 string 露出來,但這裡最好先忍一忍。在函式庫交界上,建議落成像下面這樣會比較清楚。

int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);

簡而言之,就是把 字元編碼、長度、誰來配置緩衝區 先講好。因為是 Windows,對齊到 UTF-16 也是選項之一;但如果要連其他語言一起考慮,UTF-8 通常更好處理。

5.3. 不要讓例外跨越邊界

原生函式邊界對於例外並不友善。至少,不要設計成直接把 managed 例外漏給呼叫端 比較安全。

實務上:

  • 回傳值是 status code
  • 實際資料透過 out 緩衝區或指標參數
  • 必要時以 get_last_error 的形式取得額外資訊

用這樣的方式設計比較好操作。

不華麗,但這種樸素的設計後面會顯現價值。簡單說就是,不要在交界面突然開始格鬥技。

5.4. 固定呼叫約定

範例中明確指定了 CallConvCdecl。若省略則使用平台預設的呼叫約定,但若要把標頭或函式指標型別固定下來,自己明確寫出比較不會出事

尤其若可能要面對 x86,這一段曖昧就會在後面拖到。即使在 x64 上不易顯現,規則先訂好還是比較好。

5.5. Export 方法薄薄一層,本體另外放

UnmanagedCallersOnly 的方法並不是設計給一般 managed 程式碼直接呼叫用的。所以把業務邏輯全寫在那裡,測試也會難做。

範例中實體的管理放在 AccumulatorStore,而 export 的 NativeExports 只負責薄薄的進入點。這點相當重要。

  • export 方法:ABI 的窗口
  • 內部類別:一般的 C# 邏輯

做這樣的分工後,就能把「與 C++ 的交界」和「C# 本體程式碼」分開來思考。

6. 適合的情境

這個構成比較能發揮威力的是像下列這些場面。

  • 想保留既有的 C/C++ 應用程式,只把一部分業務邏輯搬到 C#
  • 不想把 .NET Runtime 事前安裝當成部署前提
  • 能把 export 的函式面積維持在小範圍
  • 未來也許會想從 Rust、Go 等其他語言以同一套 C API 呼叫

特別是 原生應用程式維持不動,只把容易替換的邏輯層用 C# 寫 的構成相性很好。UI 與裝置控制留在 C++,判定、計算、設定規則交給 C#,這樣的分工。

7. 儘管如此,不適合的情境

當然它不是萬能。下面這些場面就明顯不適合。

  • 想直接處理 C++ 類別、std::vector、例外
    • 這時候用 C++/CLI 或原生側包裝器會比較自然。
  • 想踏進 COM 註冊、VBA / Office 自動化、Explorer 擴充這類世界
    • 這邊比較適合以 COM 的脈絡思考。
  • 想跨接 32bit / 64bit,或跨行程邊界
    • 這應該用 COM / IPC / 另一個行程的組態,而不是 in-process DLL。
  • 想在事後卸載外掛
    • Native AOT 的共享程式庫不建議以卸載為前提使用。
  • 相依套件強烈依賴反射或動態程式碼產生
    • AOT publish 出 warning 時,不要隨便忽略那些 warning 會比較安全。

一言以蔽之,能否以 C ABI 切割乾淨 是分水嶺。切不開的話,換別條橋反而比較乾淨。

8. 容易踩到的點

最後,整理一下在 Native AOT export 上容易踩到的小細節。

  • 要加上 UnmanagedCallersOnly 的方法必須是 static
  • 不能放在 generic 方法或 generic 類別中。
  • 想要 named export 就加 EntryPoint
  • 不要用 ref / in / out,改用指標參數回傳會比較好。
  • Export 的是 publish 目標組件中的方法。對參考來源函式庫中的方法加屬性,不會直接出現在外面。
  • 呼叫端與 DLL 的 bitness 必須一致。
  • publish warning 相當重要。若出現 AOT / trimming 的 warning,先把它們處理掉會比較安全。

這些細節都是「知道後就覺得也對」的項目。但不知情踩到一次,會度過一段相當難受的時光。

9. 小結

從 C/C++ 想呼叫 C# 時,首先會想到的大概是 COM、C++/CLI 或獨立行程。這些都是正確的選項。

不過 若想以 in-process 原生 DLL 的身分把 C# 處理塞進來,Native AOT + UnmanagedCallersOnly 是相當有趣的一條路。

重點再整理一次:

  • 不要把 C# 原樣暴露,改把 C ABI flatten 出去
  • 以 handle 為基礎明確處理壽命管理
  • 不用例外而用 error code 跨越邊界
  • 固定呼叫約定
  • export 方法薄薄一層,與內部邏輯分離

做的事情不華麗。但「該怎麼劃邊界」對日後維護會有明顯影響。想在活用既有原生資產的同時,把邏輯層用 C# 的生產力帶進來時,這個構成記下來絕對不吃虧。

10. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽