更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(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」來用。
不過,並不是什麼東西都能原封不動地跨過邊界。一旦把 string、List<T>、例外、所有權漏到交界面上,氣氛馬上就會變差。本文以 Windows + C++ 的最小範例,梳理這套構成在什麼時候特別合用,以及 API 做成什麼形狀才不容易壞。Linux / macOS 的思路幾乎相同,但程式碼範例以 Windows 的 DLL 為前提。
flowchart TB
accTitle: 上一篇與這一篇的方向差異
accDescr: 上一篇談的是從C#呼叫原生DLL的交界面,這一篇把方向反過來,談從C/C++應用程式以in-process方式呼叫用Native AOT發行的C#原生DLL的圖。
prev["上一篇: C#呼叫C++"] --> wrap["C++/CLI包裝層的主題"]
now["這一篇: C/C++呼叫C#"] --> aot["用Native AOT把C#做成DLL"]
aot --> entry["UnmanagedCallersOnly是入口"]
圖 1: 這次的方向和 P/Invoke、C++/CLI 相反,C# 成為「被呼叫的那一側的原生 DLL」。
另外,本文出現的程式碼已整理成可建置、可執行的完整範例(以 Native AOT 發行的 C# 函式庫、C++ 的呼叫範例、單元測試),公開在 GitHub 上。
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
目錄
- 先講結論(一句話)
- 先看的取捨對照
- 構成圖
- 最小構成
- 4.1. C# 專案
- 4.2. 要 export 的 C# 程式碼
- 4.3. 發行指令
- 4.4. C++ 端的呼叫範例
- 4.5. 確認是否真的有 export
- 4.6. 想用 import lib 靜態連結時
- 不易壞掉的 API 形狀
- 5.1. 把交界面收攏成 C ABI
- 5.2. 字串以指標 + 長度 + 緩衝區容量處理
- 5.3. 不讓例外跨越邊界
- 5.4. 固定呼叫慣例
- 5.5. Export 方法做薄,本體另外放
- 適合的情境
- 仍然不適合的情境
- 常見陷阱
- 總結
- 參考資料
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 22 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
1. 先講結論(一句話)
- 想從 C/C++ 以 in-process 的方式呼叫 C# 的處理,Native AOT +
UnmanagedCallersOnly是相當有力的選項。 - 不過,export 出去的終究只是 C 的函式入口。這裡不是能把
string或List<T>原樣攤出來的世界。 - 實務上,落成
create/destroy/operate這種扁平的 C API,並明確處理生命週期管理與錯誤碼,會比較穩定。 - 想自然地處理 C++ 的類別或 STL 就用 C++/CLI;需要註冊、自動化或跨處理程序時,COM 比較適合。
簡單說就是,可以把 C# 當成原生 DLL 的內容來用,但交界面要以 C ABI 而不是 .NET 來設計。只要能把這一點的取捨想清楚、劃清界線,它就會是一項很有意思的武器。
flowchart TB
accTitle: 交界面要以C ABI設計
accDescr: C#的內部維持類別與集合的樣子沒關係,但對外的交界面不是string或List<T>,而是落成create / destroy / operate這種扁平的C API,並明確處理生命週期管理與錯誤碼的圖。
inner["內部是一般的C#"] --> face["對外的面是扁平的C API"]
face --> h["生命週期用handle明確表示"]
face --> e["錯誤以代碼回傳"]
face -.-> ng["不攤出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 相反。
flowchart TB
accTitle: 主角方向的差異
accDescr: P/Invoke與C++/CLI是C#當主角、把原生端叫進來的方向,而本文的Native AOT構成是原生端當主角、把C#邏輯當成零件呼叫,方向剛好相反的圖。
cs["C#是主角"] -->|"呼叫原生端"| n1["P/Invoke或C++/CLI"]
nat["原生端是主角"] -->|"把C#當零件呼叫"| n2["用Native AOT export"]
圖 3: 橋要按方向挑。本文的構成是「原生端是主角,C# 是零件」的方向。
3. 構成圖
flowchart LR
Cpp["C / C++ 應用程式"] -->|cdecl 的函式呼叫| Dll["以 Native AOT 發行的 C# DLL"]
Dll --> Exports["帶 UnmanagedCallersOnly 的 export"]
Exports --> Core["C# 的業務邏輯"]
Exports --> Store["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 仍然相當穩定。
flowchart TB
accTitle: 以handle為基礎的扁平API
accDescr: 用create發放handle,帶著handle呼叫add或get這類操作函式,最後用destroy收尾。狀態本體由C#端保管,傳回值是錯誤碼,輸出值以指標引數回傳的圖。
create["create: 發放handle"] --> op["add或get: 帶handle操作"]
op --> destroy["destroy: 收尾"]
op -.-> state["狀態本體由C#端保管"]
op -.-> err["傳回值是錯誤碼"]
圖 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。
flowchart TB
accTitle: handle編號繞回一圈時的壞法與對策
accDescr: 在32位元上編號繞回一圈後,已destroy而從字典消失的值仍能Add成功,C端還握著的舊handle會指向不相干的新執行個體並靜悄悄地壞掉。對策是埋入世代編號,或在達到上限後永久失敗的圖。
wrapd["32位元上編號繞回一圈"] --> add["同一個值的Add竟然成功"]
add --> alias["舊handle指向新執行個體"]
alias --> silent["沒有例外也沒有錯誤碼就壞掉"]
silent --> g1["對策: 埋入世代編號"]
silent --> g2["對策: 用完就讓它失敗"]
圖 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 的位元數也必須一致。
flowchart TB
accTitle: publish的第一道關卡
accDescr: Native AOT的publish另外需要原生的工具鏈,沒有裝就打dotnet publish,會在最後的原生連結階段失敗而不是在C#的編譯階段。每個RID都要各publish一份,位元數也要一致的圖。
pub["執行dotnet publish"] --> q{"有沒有原生工具鏈"}
q -->|"沒有"| fail["在最後的原生連結階段失敗"]
q -->|"有"| out["每個RID各產出原生DLL"]
out -.-> match["讓呼叫端與位元數一致"]
圖 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,可以這樣縮小範圍。
flowchart TB
accTitle: 呼叫不到時如何縮小範圍
accDescr: LoadLibraryW失敗代表DLL沒有載入,要先懷疑路徑、位元數與相依DLL不足;GetProcAddress失敗代表DLL讀得到但找不到export;兩關都通過就能用函式指標呼叫的圖。
s1{"LoadLibraryW成功?"}
s1 -->|"失敗"| f1["DLL沒有載入"]
f1 -.-> f1a["懷疑路徑、位元數、相依DLL"]
s1 -->|"成功"| s2{"GetProcAddress成功?"}
s2 -->|"失敗"| f2["找不到export"]
s2 -->|"成功"| ok["可以當成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 時,要查看的地方大致是下面的順序。
dumpbin /exports裡有沒有出現名稱(沒有出現就是 C# 端的事)- 寫在
EntryPoint的字串,與傳給GetProcAddress的字串是否完全一致(大小寫也有區別) - 呼叫端 EXE 與 DLL 的位元數是否一致
- 加了
UnmanagedCallersOnly的方法是不是static,而且沒有放在 generic 裡面 - 那個屬性是不是寫在 publish 目標的組件那一側(寫在被參考的函式庫裡不會出現在外面)
另外,LoadLibraryW 本身就失敗的話,那就不是 export 的問題。請先懷疑 DLL 的路徑、位元數,以及相依 DLL 不足。
flowchart TB
accTitle: GetProcAddress傳回NULL時的檢查順序
accDescr: 依序檢查dumpbin的exports清單裡有沒有名稱、EntryPoint的字串與傳入的字串是否完全一致、位元數是否一致、方法是否為static且不在generic裡、屬性是否寫在publish目標組件那一側的圖。
c1["dumpbin裡有沒有名稱"] --> c2["字串是否完全一致"]
c2 --> c3["位元數是否一致"]
c3 --> c4["是static且在generic之外嗎"]
c4 --> c5["屬性寫在publish目標那一側了嗎"]
c1 -.->|"沒有出現"| cs["當成C#端的問題來查"]
圖 9: 先看 export 裡有沒有出現名稱,就能決定是 C# 端的問題還是呼叫端的問題。
4.6. 想用 import lib 靜態連結時
到這裡為止都是用 LoadLibrary / GetProcAddress 的做法寫的。因為這樣比較容易看清楚什麼被 export 了,以及該用什麼簽章來接。
另一方面,實務上「include 標頭檔之後就直接呼叫函式」的需求應該也很多。這種情況會變成使用 import library 的靜態載入。步驟如下:
- publish 的輸出裡如果有 import library(
.lib),就連結它 - 如果沒有,就準備一個列出 export 名稱的
.def檔,再用lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64產生 import library - 標頭檔那一側不要用函式指標型別,改寫成一般的函式宣告
/* 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 的做法比較好處理。
flowchart TB
accTitle: 動態載入與靜態連結的取捨
accDescr: LoadLibrary的做法在執行階段才載入,適合像外掛那樣插進去;用import library靜態連結雖然include標頭檔就能直接呼叫,但少了DLL會在處理程序啟動的當下失敗的圖。
q{"想怎麼組進去"}
q -->|"像外掛那樣插進去"| dyn["LoadLibrary做法"]
q -->|"用標頭檔直接呼叫"| stat["用import lib靜態連結"]
stat -.-> risk["沒有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 找不到名稱 |
cdecl 和 stdcall 是其中第一項「呼叫慣例」的名字。C++ 的類別與例外,這三項約定會因編譯器而異,所以直接放到交界面上就咬合不起來。反過來說,只限縮到 C 的函式與基本型別,約定夠單純,就容易咬合。所謂「把交界面收攏成 C ABI」,意思就是把交界面壓到這個單純約定的範圍內。
flowchart TB
accTitle: ABI這個約定的內容
accDescr: ABI是已編譯的二進位檔之間在執行階段如何咬合的約定,由決定引數與傳回值傳遞方式的呼叫慣例、型別的版面配置、export名稱與裝飾這三項組成的圖。
abi["ABI〔機器碼層級的約定〕"] --> a1["呼叫慣例"]
abi --> a2["型別的版面配置"]
abi --> a3["名稱與連結"]
a1 -.-> ex["cdecl和stdcall是這裡的名字"]
圖 11: 所謂「把交界面收攏成 C ABI」,就是把交界面壓到這三項約定都很單純的範圍內。
在此之上,放到交界面上的型別,一開始就收攏到下面這些範圍會比較平和。
int32_t/int64_t/double這類基本型別- 固定版面配置的 struct
- 相當於
intptr_t/void*的 handle uint8_t*與長度
反過來說,一開始就不希望漏到外面的是下面這些。
stringobjectList<T>TaskSpan<T>- C++ 的類別,或
std::vector、std::wstring
想讓這些東西原封不動地跨過邊界,交界面馬上就會變混濁。重要的是 不要把 C# 這邊的內情漏給 C++,也不要把 C++ 那邊的內情過度漏給 C#。
flowchart TB
accTitle: 放到交界面的型別與不外漏的型別
accDescr: 放到交界面上的型別收攏到基本型別、固定版面配置的struct、handle、指標與長度,而string、object、List<T>、Task以及C++的類別與STL都不漏到外面的圖。
edge["放到交界面的型別"] --> ok1["基本型別與固定struct與handle"]
edge --> ok2["指標與長度"]
keepx["不漏到外面的型別"] --> ng1["string或List〔T〕或Task"]
keepx --> ng2["C++的類別或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的形式取得額外資訊
做成這樣會比較好處理。
這不華麗,但這種不起眼的設計之後才會顯現價值。意思是不要在交界面上突然開打。
flowchart TB
accTitle: 不讓例外跨越邊界的錯誤設計
accDescr: 不把受管理的例外原樣漏給呼叫端,傳回值用status code,實際資料放在out緩衝區或指標引數,需要時以get_last_error的形式取得額外資訊的圖。
exc["C#內部的例外"] --> stop["在邊界內側攔下"]
stop --> code["傳回值是status code"]
stop --> outp["實際資料用指標引數"]
stop -.-> last["額外資訊用get_last_error形式"]
圖 13: 不讓例外跨越邊界,改翻譯成 status code 與指標引數的世界再回傳。
5.4. 固定呼叫慣例
範例中明確指定了 CallConvCdecl。省略的話會採用平台預設的呼叫慣例,但如果想把標頭檔或函式指標型別固定下來,自己明確寫出來比較不容易出事。
尤其是有可能要面對 x86 時,這裡含糊帶過,之後就會很難收拾。就算在 x64 上不容易浮上檯面,規則還是先在一開始訂好比較好。
5.5. Export 方法做薄,本體另外放
加了 UnmanagedCallersOnly 的方法,並不是設計成給一般受管理程式碼直接呼叫的。所以一旦開始把業務邏輯全部寫在那裡,測試也會變得難做。
範例中也是把實體的管理放在 AccumulatorStore,被 export 的 NativeExports 只留下薄薄一層入口。這一點相當重要。
- export 方法:ABI 的窗口
- 內部類別:一般的 C# 邏輯
做這樣的分工之後,就能把與 C++ 的邊界和 C# 的本體程式碼分開來思考。
flowchart TB
accTitle: export做薄,本體另外放
accDescr: 帶UnmanagedCallersOnly的export方法只當ABI的薄窗口,實體的管理與業務邏輯放在內部類別,這樣就能把邊界與本體分開思考,測試也更容易的圖。
exp["export方法〔薄窗口〕"] --> core["內部類別〔一般的C#〕"]
exp -.-> abi["只負責ABI的檢查與轉換"]
core -.-> test["可以當成一般C#來測試"]
圖 14: 不要開始在 export 裡寫業務邏輯。窗口與本體的分工能讓維護與測試都輕鬆。
6. 適合的情境
這套構成用起來特別順手的是下面這些場面。
- 想保留既有的 C/C++ 應用程式,只把一部分業務邏輯集中到 C#
- 不想把事先安裝 .NET Runtime 當成散發的前提
- 能把 export 的函式面積維持得很小
- 將來也許會想從 Rust、Go 等其他語言用同一套 C API 呼叫
特別是 原生應用程式維持原樣,只把容易抽換的邏輯層用 C# 寫 的構成,特別合適。UI 與裝置控制留在 C++,判定、計算、設定規則交給 C#,這樣劃分職責。
flowchart TB
accTitle: 合適的職責劃分
accDescr: UI與裝置控制留在C++,只把判定、計算、設定規則這類容易抽換的邏輯層用C#寫,再用小小的C API面連起來的構成最合用的圖。
app["既有的C/C++應用程式"] --> keepn["UI與裝置控制留在C++"]
app --> logic["判定、計算、設定規則交給C#"]
logic --> api["用小小的C API面連起來"]
api -.-> multi["其他語言也能用同一面呼叫"]
圖 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 的範圍內劃清界線 就是分水嶺。劃不清的話,換另一座橋反而乾淨。
flowchart TB
accTitle: 不適合的情境與替代的橋
accDescr: 想直接處理C++的類別或例外就走C++/CLI,要COM註冊或自動化的世界就走COM,要跨越位元數或處理程序邊界就走COM或IPC,而以卸載為前提的外掛則不適合這套構成的圖。
q{"想要的是什麼"}
q -->|"直接用C++的型別或例外"| cli["走C++/CLI或包裝層"]
q -->|"註冊與自動化的世界"| com["走COM的脈絡"]
q -->|"跨位元數或跨處理程序"| ipc["走COM或IPC或另一個處理程序"]
q -->|"想事後卸載"| ng["這套構成不符合前提"]
圖 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# 的生產力帶進邏輯層時,記住這套構成絕不吃虧。
flowchart TB
accTitle: 不易壞掉的邊界五條
accDescr: flatten成C ABI、以handle為基礎的生命週期管理、用error code跨越邊界、固定呼叫慣例、把export做薄並與內部邏輯分開,這五點造就不易壞掉的邊界的圖。
goal["不易壞掉的C#原生DLL"] --> p1["flatten成C ABI"]
goal --> p2["用handle明示生命週期"]
goal --> p3["用error code跨越邊界"]
goal --> p4["固定呼叫慣例"]
p1 -.-> p5["export做薄並與本體分開"]
圖 17: 總結的五條。不起眼的邊界設計,對日後的可維護性最有用。
10. 參考資料
- 本文的完整範例程式碼(C# 函式庫、C++ 呼叫範例、單元測試) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- DUMPBIN /EXPORTS - Microsoft Learn
- LIB Reference - Microsoft Learn
- 從 C# 呼叫原生 DLL:C++/CLI 包裝層 vs P/Invoke - KomuraSoft Blog
- 從 32bit 應用呼叫 64bit DLL 的 COM 橋接案例 - KomuraSoft Blog
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
引數為什麼會壞掉 ── Windows 命令列引數的規則
在 Windows 上並不存在引數的陣列,傳給 CreateProcess 的是一條字串,分割由接收端負責。本文說明 CommandLineToArgvW・CRT・.NET 的分割規則,以及 .NET 的 ArgumentList 與 C++ 中正確的組裝方式。
Arm 版 Windows 能執行業務應用程式嗎 ── x64 模擬(Prism)與原生 DLL・COM 的現實
本文為開發者・資訊系統部門解答「Arm 版 Windows 能執行業務應用程式嗎」這個問題,整理 x64 模擬(Prism)的運作原理、驅動程式等無法執行的層級、.NET 的 AnyCPU 與 P/Invoke 問題,以及 Arm 對應檢查清單。
Time Travel Debugging ── 把長期運轉中無法重現的問題「錄下來」再倒回去
一個月才出現一次的問題,當機傾印只拍得到結果。本文說明如何用 WinDbg 的 Time Travel Debugging(TTD) 錄下執行並倒回,涵蓋 TTD.exe 的錄製設計、環形緩衝區、TTD.Calls 查詢,以及和傾印的分工。
父處理程序消失之後還剩下什麼 —— 用 Job Object 圈養子處理程序
為什麼強制結束 UI 之後,SDK 的輔助處理程序仍然殘留,一直佔著攝影機或 COM 連接埠?本文從量測應用的角度,說明如何用 Job Object 把處理程序樹變成一個單位,並借助 KillOnJobClose 與完成埠來設計子處理程序的壽命。
具名管道實務 ── 從設計到安全,看懂 Windows 行程間通訊的標準做法
以實務角度說明 Windows 行程間通訊的標準做法——具名管道。依據一手資料整理位元組模式與訊息模式的取捨、同時接受多個用戶端的伺服器結構、ACL 與模擬的安全設計,以及 .NET 的具名管道串流。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
這是實作 C# 與 C/C++ 交界面的主題,直接關係到 Windows 應用程式開發 的設計與實作諮詢。
既有資產活用 & 遷移支援
在如何搭起既有原生資產與 .NET 之間的橋樑這一點上,也很適合 既有資產活用與遷移支援。
常見問題
整理諮詢這個主題時常見的問題。
- 可以從 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 上幾乎相同。