從 C/C++ 呼叫 C# Native AOT DLL 的方法

· 更新日期: · · C#, .NET, Native AOT, C++, Windows 開發, 原生互通

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22279369)
補上了日文原文中已有的諮詢引導(consultation_services)。內文沒有改動。 查看更新前的版本 (DOI: 10.5281/zenodo.21616268)
初次發布
引用本文(DOI: 10.5281/zenodo.21616267)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈從 C/C++ 呼叫 C# Native AOT DLL 的方法〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616267 https://comcomponent.com/zh-TW/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/

DOI(最新版本)
10.5281/zenodo.21616267
DOI(此版本)
10.5281/zenodo.22297097

上一篇 從 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 為前提。

上一篇與這一篇的方向差異上一篇談的是從C#呼叫原生DLL的交界面,這一篇把方向反過來,談從C/C++應用程式以in-process方式呼叫用Native AOT發行的C#原生DLL的圖。上一篇: C#呼叫C++C++/CLI包裝層的主題這一篇: C/C++呼叫C#用Native AOT把C#做成DLLUnmanagedCallersOnly是入口

圖 1: 這次的方向和 P/Invoke、C++/CLI 相反,C# 成為「被呼叫的那一側的原生 DLL」。

另外,本文出現的程式碼已整理成可建置、可執行的完整範例(以 Native AOT 發行的 C# 函式庫、C++ 的呼叫範例、單元測試),公開在 GitHub 上。

csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

目錄

  1. 先講結論(一句話)
  2. 先看的取捨對照
  3. 構成圖
  4. 最小構成
    • 4.1. C# 專案
    • 4.2. 要 export 的 C# 程式碼
    • 4.3. 發行指令
    • 4.4. C++ 端的呼叫範例
    • 4.5. 確認是否真的有 export
    • 4.6. 想用 import lib 靜態連結時
  5. 不易壞掉的 API 形狀
    • 5.1. 把交界面收攏成 C ABI
    • 5.2. 字串以指標 + 長度 + 緩衝區容量處理
    • 5.3. 不讓例外跨越邊界
    • 5.4. 固定呼叫慣例
    • 5.5. Export 方法做薄,本體另外放
  6. 適合的情境
  7. 仍然不適合的情境
  8. 常見陷阱
  9. 總結
  10. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 22 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

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 來設計。只要能把這一點的取捨想清楚、劃清界線,它就會是一項很有意思的武器。

交界面要以C ABI設計C#的內部維持類別與集合的樣子沒關係,但對外的交界面不是string或List,而是落成create / destroy / operate這種扁平的C API,並明確處理生命週期管理與錯誤碼的圖。內部是一般的C#對外的面是扁平的C API生命週期用handle明確表示錯誤以代碼回傳不攤出string或List〔T〕

圖 2: 要劃清界線的只有一點。不是「把 .NET 原樣攤出來」,而是把交界面落成 C ABI。

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 的 entry point

這套構成特別合用的是 「原生端是主角,C# 被當成零件呼叫」 的場面。這裡的方向剛好和 P/Invoke、C++/CLI 相反。

主角方向的差異P/Invoke與C++/CLI是C#當主角、把原生端叫進來的方向,而本文的Native AOT構成是原生端當主角、把C#邏輯當成零件呼叫,方向剛好相反的圖。呼叫原生端把C#當零件呼叫C#是主角P/Invoke或C++/CLI原生端是主角用Native AOT export

圖 3: 橋要按方向挑。本文的構成是「原生端是主角,C# 是零件」的方向。

3. 構成圖

cdecl 的函式呼叫C / C++ 應用程式以 Native AOT 發行的 C# DLL帶 UnmanagedCallersOnly 的 exportC# 的業務邏輯handle 表 / 狀態管理

圖 4: 從 C/C++ 應用程式看過去,只有 UnmanagedCallersOnly 的 export 是可見的 C 函式。

外觀很單純。重要的是 把交界面對齊成 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 拆成 flat 的函式
  • 傳回值是錯誤碼,輸出值以指標引數回傳

做成這個形狀之後,就算日後抽換 C# 端的內部實作,C 端的 ABI 仍然相當穩定。

以handle為基礎的扁平API用create發放handle,帶著handle呼叫add或get這類操作函式,最後用destroy收尾。狀態本體由C#端保管,傳回值是錯誤碼,輸出值以指標引數回傳的圖。create: 發放handleadd或get: 帶handle操作destroy: 收尾狀態本體由C#端保管傳回值是錯誤碼

圖 5: 對原生端只呈現 handle 與操作函式。就算抽換內部實作,ABI 仍然穩定。

關於 handle 的編號發放,補充一點。範例中用 long 保管編號用的計數器,並把 Interlocked.Increment 的結果轉型成 nint。這裡有兩個性質值得先弄清楚。

  • 不會發出 0。 計數器從 0 開始,而 Increment 傳回的是加完之後的值,所以第一個 handle 是 1。C++ 端之所以能拿 intptr_t handle = 0; 當成「還沒拿到」的記號,就是因為這一點。
  • 在 32 位元上會發生截斷。 nint 是指標寬度,64 位元時就是 64 位元,但 32 位元建置時是 32 位元。從 long 轉型成 nint 會默默丟掉高位位元,所以編號一旦超過 2^32,值就會繞回來。如果是連續 24 小時反覆 create / destroy 的用法,理論上是走得到那裡的。

繞回來之後會發生什麼事,這裡請確實弄清楚。s_instances.Add(handle, instance) 的重複索引鍵例外,只有在同一個值的 handle「現在還活著」時才幫得上忙。這個 API 一般的用法是不斷重複 create 與 destroy,而已經 destroy 的 handle 早就從字典裡消失了。也就是說,繞了一圈回到同一個值時,字典裡沒有那個索引鍵,Add 會成功。結果就是 C 端還握著的舊 handle,開始指向一個毫不相干的新執行個體。既不會拋出例外,也不會傳回錯誤碼,只有值靜悄悄地壞掉。

還有一點,2^32 這個剛好的值,低 32 位元全都是 0,所以 原本要當成「還沒拿到」記號的 0 會被發出去。

因此,不要把重複索引鍵的檢查當成安全裝置來依賴。如果有可能要面對 32 位元,就從下面兩種做法擇一。

  • 把世代編號埋進 handle。 低位放流水號,高位放世代,每次 destroy 就把世代往前推。就算同一個流水號繞回來,值也不會一致
  • 用完之後就永久失敗。 編號一旦達到上限,之後的 create 全部回傳錯誤。持續運轉的設備會需要重新啟動,但總比靜悄悄地壞掉好處理

不論採取哪一種,都要一併守住:編號計數器本身用 nint 保管,不讓它超過 nint 的寬度,而且不發出 0。

handle編號繞回一圈時的壞法與對策在32位元上編號繞回一圈後,已destroy而從字典消失的值仍能Add成功,C端還握著的舊handle會指向不相干的新執行個體並靜悄悄地壞掉。對策是埋入世代編號,或在達到上限後永久失敗的圖。32位元上編號繞回一圈同一個值的Add竟然成功舊handle指向新執行個體沒有例外也沒有錯誤碼就壞掉對策: 埋入世代編號對策: 用完就讓它失敗

圖 6: 重複索引鍵例外當不了安全裝置。繞回一圈的壞法很安靜,對策要在設計端先埋好。

4.3. 發行指令

先講一個前提。Native AOT 的 publish 另外需要原生的工具鏈。 只加了 PublishAot 就直接打 dotnet publish,會在最後的原生連結階段失敗,而不是在 C# 的編譯階段。這裡是第一道關卡。

環境 需要的東西
Windows Visual Studio 2022 以後的版本。安裝「使用 C++ 的桌面開發」工作負載,而且要把預設的元件全部裝進去
Ubuntu 18.04 以後 sudo apt-get install clang zlib1g-dev
Alpine 3.15 以後 sudo apk add clang build-base zlib-dev
Fedora 39 以後 / RHEL 8 以後 sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel
macOS Xcode 的 Command Line Tools(.NET 8 之後支援)

本文以 Windows + C++ 為前提,所以實際上就是請先檢查「Visual Studio 的 C++ 工作負載有沒有裝進去」。 找不到連結器、在 link.exe 附近失敗之類的錯誤,多半都出在這裡。

確認之後,再以共用程式庫的形式 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 的位元數也必須一致。

publish的第一道關卡Native AOT的publish另外需要原生的工具鏈,沒有裝就打dotnet publish,會在最後的原生連結階段失敗而不是在C#的編譯階段。每個RID都要各publish一份,位元數也要一致的圖。沒有執行dotnet publish有沒有原生工具鏈在最後的原生連結階段失敗每個RID各產出原生DLL讓呼叫端與位元數一致

圖 7: 失敗的是連結階段,不是 C# 的編譯。第一道關卡是有沒有工具鏈。

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# 寫的這件事,幾乎不必去在意。

把 publish 出來的 DLL 放到與 main.exe 同一個資料夾再執行,因為加的是 10 和 20,標準輸出就只有這一行。

total = 30

中途失敗時,std::cerr 那一側會印出是在哪個階段失敗的。LoadLibraryW failed 就是根本沒找到 DLL,GetProcAddress failed: km_accumulator_add 則是 DLL 讀得到但找不到 export,可以這樣縮小範圍。

呼叫不到時如何縮小範圍LoadLibraryW失敗代表DLL沒有載入,要先懷疑路徑、位元數與相依DLL不足;GetProcAddress失敗代表DLL讀得到但找不到export;兩關都通過就能用函式指標呼叫的圖。失敗成功失敗成功LoadLibraryW成功?DLL沒有載入懷疑路徑、位元數、相依DLLGetProcAddress成功?找不到export可以當成C API呼叫

圖 8: 從在哪個階段失敗,就能分辨是 DLL 載入的問題還是 export 的問題。

4.5. 確認是否真的有 export

「呼叫不到」的時候,先看 DLL 那一側是不是真的有出現名稱。用 Visual Studio 的 Developer Command Prompt 執行 dumpbin 最快。

dumpbin /exports NativeAotSample.dll

只要 km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy 這四個排在 name 的清單裡,C# 端的發行就成功了。想用名稱過濾的話,這樣寫。

dumpbin /exports NativeAotSample.dll | findstr km_

這裡沒有出現名稱就是 C# 端的問題,出現了但 GetProcAddress 仍然失敗就是呼叫端的問題,可以這樣分辨。

GetProcAddress 傳回 NULL 時,要查看的地方大致是下面的順序。

  1. dumpbin /exports 裡有沒有出現名稱(沒有出現就是 C# 端的事)
  2. 寫在 EntryPoint 的字串,與傳給 GetProcAddress 的字串是否完全一致(大小寫也有區別)
  3. 呼叫端 EXE 與 DLL 的位元數是否一致
  4. 加了 UnmanagedCallersOnly 的方法是不是 static,而且沒有放在 generic 裡面
  5. 那個屬性是不是寫在 publish 目標的組件那一側(寫在被參考的函式庫裡不會出現在外面)

另外,LoadLibraryW 本身就失敗的話,那就不是 export 的問題。請先懷疑 DLL 的路徑、位元數,以及相依 DLL 不足。

GetProcAddress傳回NULL時的檢查順序依序檢查dumpbin的exports清單裡有沒有名稱、EntryPoint的字串與傳入的字串是否完全一致、位元數是否一致、方法是否為static且不在generic裡、屬性是否寫在publish目標組件那一側的圖。沒有出現dumpbin裡有沒有名稱字串是否完全一致位元數是否一致是static且在generic之外嗎屬性寫在publish目標那一側了嗎當成C#端的問題來查

圖 9: 先看 export 裡有沒有出現名稱,就能決定是 C# 端的問題還是呼叫端的問題。

4.6. 想用 import lib 靜態連結時

到這裡為止都是用 LoadLibrary / GetProcAddress 的做法寫的。因為這樣比較容易看清楚什麼被 export 了,以及該用什麼簽章來接。

另一方面,實務上「include 標頭檔之後就直接呼叫函式」的需求應該也很多。這種情況會變成使用 import library 的靜態載入。步驟如下:

  1. publish 的輸出裡如果有 import library(.lib),就連結它
  2. 如果沒有,就準備一個列出 export 名稱的 .def 檔,再用 lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 產生 import library
  3. 標頭檔那一側不要用函式指標型別,改寫成一般的函式宣告
/* native_api_static.h */
#pragma once
#include <stdint.h>

#ifdef __cplusplus
extern "C" {
#endif

int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);

#ifdef __cplusplus
}
#endif

做成這個形狀之後,呼叫端的程式碼會相當直接了當。代價是找不到 DLL 時會 在處理程序啟動的當下 就失敗,因此很難做到「本體照常運作,只有這個功能不能用」的維運方式。想像外掛那樣插進去的話,維持 LoadLibrary 的做法比較好處理。

動態載入與靜態連結的取捨LoadLibrary的做法在執行階段才載入,適合像外掛那樣插進去;用import library靜態連結雖然include標頭檔就能直接呼叫,但少了DLL會在處理程序啟動的當下失敗的圖。像外掛那樣插進去用標頭檔直接呼叫想怎麼組進去LoadLibrary做法用import lib靜態連結沒有DLL就在啟動當下失敗

圖 10: 寫起來直接了當的是靜態連結,但能在「只少這個功能」的狀態下照常運作的是動態載入。

另外,以靜態程式庫(NativeLib=Static)形式發行並沒有官方支援,所以最好不要指望那一條路。

5. 不易壞掉的 API 形狀

Native AOT 能夠 export 這件事很有意思,但實務上更重要的是 不要 export 什麼

5.1. 把交界面收攏成 C ABI

先把用語梳理一下。本文的核心是「交界面要以 C ABI 而不是 .NET 來設計」,而這裡的 ABI 是 Application Binary Interface 的縮寫,指的是 已編譯的二進位檔之間,在執行階段要如何互相咬合的約定。請把它想成不是原始碼層級,而是機器碼層級的約定。內容主要有三項。

約定 決定什麼 這裡不遵守會怎樣
呼叫慣例(calling convention) 引數要用暫存器還是堆疊、怎麼傳,傳回值放在哪裡,以及 呼叫結束後由呼叫端還是被呼叫端把堆疊復原 引數會錯開,回來的瞬間堆疊就毀損
型別的版面配置 每個型別佔幾個位元組,struct 的成員排在哪些位置(填補與對齊) 從結構的中途開始就讀不到正確的值
名稱與連結 被 export 的函式名稱怎麼拼,有沒有裝飾(decoration) GetProcAddress 找不到名稱

cdeclstdcall 是其中第一項「呼叫慣例」的名字。C++ 的類別與例外,這三項約定會因編譯器而異,所以直接放到交界面上就咬合不起來。反過來說,只限縮到 C 的函式與基本型別,約定夠單純,就容易咬合。所謂「把交界面收攏成 C ABI」,意思就是把交界面壓到這個單純約定的範圍內。

ABI這個約定的內容ABI是已編譯的二進位檔之間在執行階段如何咬合的約定,由決定引數與傳回值傳遞方式的呼叫慣例、型別的版面配置、export名稱與裝飾這三項組成的圖。ABI〔機器碼層級的約定〕呼叫慣例型別的版面配置名稱與連結cdecl和stdcall是這裡的名字

圖 11: 所謂「把交界面收攏成 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#

放到交界面的型別與不外漏的型別放到交界面上的型別收攏到基本型別、固定版面配置的struct、handle、指標與長度,而string、object、List、Task以及C++的類別與STL都不漏到外面的圖。放到交界面的型別基本型別與固定struct與handle指標與長度不漏到外面的型別string或List〔T〕或TaskC++的類別或STL

圖 12: 實務上重要的是「不要 export 什麼」。不要把雙方的內情漏到交界面上。

為了減少照抄時的失誤,這裡也放一份對照表。加了 UnmanagedCallersOnly 的方法,簽章裡只能用 blittable 的型別,所以實際上就落在這個範圍內。

C# 端 C / C++ 端 補充
byte / sbyte uint8_t / int8_t  
short / ushort int16_t / uint16_t  
int / uint int32_t / uint32_t  
long / ulong int64_t / uint64_t C++ 的 long 在 Windows 上是 32 位元,在 Linux 的 LP64 上是 64 位元,所以不要寫 long,用 int64_t 比較安全
nint / nuint intptr_t / uintptr_t 指標寬度。32 位元建置時會變成 32 位元
float / double float / double  
bool 不使用 它不是 blittable。改用 int32_t 傳遞 0 / 1
char / string 不使用 字串依 5.2 的做法,以指標 + 長度處理
T*(unsafe 指標) T* 輸出值用這個回傳
固定版面配置的 struct 相同版面配置的 struct 成員的順序、型別、填補一定要在兩側保持一致

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 的形式取得額外資訊

做成這樣會比較好處理。

這不華麗,但這種不起眼的設計之後才會顯現價值。意思是不要在交界面上突然開打。

不讓例外跨越邊界的錯誤設計不把受管理的例外原樣漏給呼叫端,傳回值用status code,實際資料放在out緩衝區或指標引數,需要時以get_last_error的形式取得額外資訊的圖。C#內部的例外在邊界內側攔下傳回值是status code實際資料用指標引數額外資訊用get_last_error形式

圖 13: 不讓例外跨越邊界,改翻譯成 status code 與指標引數的世界再回傳。

5.4. 固定呼叫慣例

範例中明確指定了 CallConvCdecl。省略的話會採用平台預設的呼叫慣例,但如果想把標頭檔或函式指標型別固定下來,自己明確寫出來比較不容易出事

尤其是有可能要面對 x86 時,這裡含糊帶過,之後就會很難收拾。就算在 x64 上不容易浮上檯面,規則還是先在一開始訂好比較好。

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

加了 UnmanagedCallersOnly 的方法,並不是設計成給一般受管理程式碼直接呼叫的。所以一旦開始把業務邏輯全部寫在那裡,測試也會變得難做。

範例中也是把實體的管理放在 AccumulatorStore,被 export 的 NativeExports 只留下薄薄一層入口。這一點相當重要。

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

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

export做薄,本體另外放帶UnmanagedCallersOnly的export方法只當ABI的薄窗口,實體的管理與業務邏輯放在內部類別,這樣就能把邊界與本體分開思考,測試也更容易的圖。export方法〔薄窗口〕內部類別〔一般的C#〕只負責ABI的檢查與轉換可以當成一般C#來測試

圖 14: 不要開始在 export 裡寫業務邏輯。窗口與本體的分工能讓維護與測試都輕鬆。

6. 適合的情境

這套構成用起來特別順手的是下面這些場面。

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

特別是 原生應用程式維持原樣,只把容易抽換的邏輯層用 C# 寫 的構成,特別合適。UI 與裝置控制留在 C++,判定、計算、設定規則交給 C#,這樣劃分職責。

合適的職責劃分UI與裝置控制留在C++,只把判定、計算、設定規則這類容易抽換的邏輯層用C#寫,再用小小的C API面連起來的構成最合用的圖。既有的C/C++應用程式UI與裝置控制留在C++判定、計算、設定規則交給C#用小小的C API面連起來其他語言也能用同一面呼叫

圖 15: 原生端維持主角地位,只把 C# 的生產力帶進容易抽換的邏輯層。

7. 仍然不適合的情境

當然,這並不是萬能的。明顯不適合的場面也是有的。

  • 想直接處理 C++ 的類別、std::vector 或例外
    • 這種時候用 C++/CLI 或原生端的包裝層會比較自然。
  • 想踏進 COM 註冊、VBA / Office 自動化、Explorer 擴充這類世界
    • 這一塊放在 COM 的脈絡裡思考比較好。
  • 想橋接 32bit / 64bit,或想跨越處理程序邊界
    • 這時候不要用 in-process DLL,改用 COM / IPC / 另一個處理程序的構成,方向比較對。
  • 想在事後把外掛卸載掉
    • Native AOT 的共用程式庫最好不要以卸載為前提使用。
  • 相依的函式庫強烈依賴 reflection 或動態程式碼產生
    • 如果 AOT publish 出現 warning,不要隨手忽略那些 warning 會比較安全。

說到底,能不能在 C ABI 的範圍內劃清界線 就是分水嶺。劃不清的話,換另一座橋反而乾淨。

不適合的情境與替代的橋想直接處理C++的類別或例外就走C++/CLI,要COM註冊或自動化的世界就走COM,要跨越位元數或處理程序邊界就走COM或IPC,而以卸載為前提的外掛則不適合這套構成的圖。直接用C++的型別或例外註冊與自動化的世界跨位元數或跨處理程序想事後卸載想要的是什麼走C++/CLI或包裝層走COM的脈絡走COM或IPC或另一個處理程序這套構成不符合前提

圖 16: 分水嶺是「能不能在 C ABI 的範圍內劃清界線」。劃不清的話,換另一座橋反而乾淨。

8. 常見陷阱

最後梳理一下在 Native AOT export 上不起眼卻容易卡關的地方。

  • 要加上 UnmanagedCallersOnly 的方法必須是 static
  • 不能放在 generic 方法或 generic class 裡面。
  • 想做成 named export 就加上 EntryPoint
  • 不要用 ref / in / out,改成用指標引數回傳比較好。
  • 被 export 的是 publish 目標組件那一側的方法。就算對被參考函式庫裡的方法加上屬性,也不會就這樣出現在外面。
  • 呼叫端與 DLL 的位元數必須一致。
  • publish warning 相當重要。如果出現 AOT / trimming 的 warning,先把那邊處理掉會比較安全。

這些都是「知道之後就覺得本來就該這樣」的東西。但在不知情的狀態下踩到一次,接下來會是一段很難收拾的時間。

9. 總結

想從 C/C++ 呼叫 C# 時,最先想到的通常是 COM、C++/CLI 或另一個處理程序。這些都是正確的選項。

不過,如果想把 C# 的處理當成 in-process 的原生 DLL 插進去,Native AOT + UnmanagedCallersOnly 是相當有意思的選項。

把重點再列一次。

  • 不要把 C# 原樣攤出來,而是 flatten 成 C ABI
  • 以 handle 為基礎明確處理生命週期管理
  • 不用例外,改用 error code 跨越邊界
  • 固定呼叫慣例
  • export 方法做薄,與內部邏輯分開

做的事情不華麗。但這種「邊界要怎麼劃」的取捨,對日後的可維護性影響相當大。想在活用既有原生資產的同時,只把 C# 的生產力帶進邏輯層時,記住這套構成絕不吃虧。

不易壞掉的邊界五條flatten成C ABI、以handle為基礎的生命週期管理、用error code跨越邊界、固定呼叫慣例、把export做薄並與內部邏輯分開,這五點造就不易壞掉的邊界的圖。不易壞掉的C#原生DLLflatten成C ABI用handle明示生命週期用error code跨越邊界固定呼叫慣例export做薄並與本體分開

圖 17: 總結的五條。不起眼的邊界設計,對日後的可維護性最有用。

10. 參考資料

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

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

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

常見問題

整理諮詢這個主題時常見的問題。

可以從 C++ 呼叫 C# 的程式碼嗎?
可以。.NET 的 Native AOT 能把 C# 的類別庫發行成原生共用程式庫,並把加上 UnmanagedCallersOnly 的方法公開成 C 的進入點。也就是說,可以把 C# 當成「被呼叫的那一側的原生 DLL」,從 C/C++ 以 in-process 的方式使用。
這套構成適合什麼樣的場面?
適合的是保留原生應用程式本體不動,只把判定邏輯、字串處理、設定解讀、計算規則這類部分集中到 C# 的場面。特徵是「原生端是主角,C# 被當成零件呼叫」的方向。反過來說,從 C# 呼叫一群 C 函式適合 P/Invoke,想自然處理 C++ 的型別與所有權適合 C++/CLI,要跨越 32bit/64bit 或處理程序邊界則適合 COM/IPC。
設計 API 時要注意什麼?
被 export 的終究只是 C 的函式入口,所以不能把 string、List<T> 或例外原樣攤在交界面上。要落成 create / destroy / operate 這種扁平的 C API,明確處理生命週期管理與錯誤碼,字串以指標+長度+緩衝區容量處理,不讓例外跨越邊界,並固定呼叫慣例。重點在於交界面要以 C ABI 而不是 .NET 來設計。
有可以實際執行的範例程式碼嗎?
有。GitHub 的 komurasoft-blog-samples 儲存庫上公開了一整套可建置、可執行的範例,包含以 Native AOT 發行的 C# 函式庫、C++ 的呼叫範例與單元測試。程式碼範例以 Windows 的 DLL 為前提,但思路在 Linux / macOS 上幾乎相同。

作者檔案

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

Go Komura

小村軟體有限公司 代表

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

回到部落格一覽