用 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」來用。
不過不是什麼東西都可以就這樣跨過邊界。一旦 string、List<T>、例外、所有權洩漏到交界面,空氣馬上就會變差。本文以 Windows + C++ 的最小範例,整理什麼情境下這個構成很適合、以及要做成什麼樣的 API 形狀才不易壞掉。Linux / macOS 的思路幾乎相同,但程式範例以 Windows DLL 為前提。
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)來設計。只要把這點切割清楚,就會是一項很有趣的武器。
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*搭配長度
反之,從一開始就不希望洩漏到外面的有:
stringobjectList<T>TaskSpan<T>- C++ 的類別,或
std::vector、std::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. 參考資料
- 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
- 在 C# 中呼叫原生 DLL 時,C++/CLI 包裝器是有力選擇的理由 - 合同会社小村ソフト Blog
- 32bit 應用程式呼叫 64bit DLL 的方法 - COM 橋樑派上用場的案例研究 - 合同会社小村ソフト Blog
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows 應用安全處理子行程的 checklist - Job Object、結束傳播、標準輸入輸出、watchdog 的最佳實務
在 Windows 應用上安全處理子行程,關鍵不在挑啟動 API,而是設計行程樹的擁有者與結束流程。本文整理 Job Object 的 KILL_ON_JOB_CLOSE、GUI 與 console 的 graceful shutdown、stdio 平行抽乾與 EOF、w...
序列通訊應用的陷阱 - 先釐清 1 byte 單位、逾時、流控、重連、USB 轉換、UI 凍結
從設備整合與儀器控制的實作現場出發,整理序列通訊應用最容易踩到的陷阱。把訊息邊界、逾時語意、流控線設定、single writer、session 重連與 hex dump 日誌一一拆開,幫助讀者把「偶爾才壞」的 byte 序列處理改造成可預測且容易調查的結構。
使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
整理在同一機器內以共享記憶體交換大型資料時的陷阱與設計要點。把 control plane 和 data plane 分離、縮小並行模型、用固定寬度整數和標頭設計 ABI、以 offset 取代指標、明示 commit protocol、為當機復原放入 generation...
將 .NET Framework 遷移到 .NET 之前該確認的事 - 在著手前就決定勝負的實戰檢核表
整理將 .NET Framework 業務應用程式遷移到現代 .NET 之前必須先盤點的論點。涵蓋著地版本、Windows 專用前提的取捨、不再支援的 API、共用函式庫切法、第三方部件、運營與 CI/CD,幫助在著手前釐清範圍並降低遷移風險。
.NET 的 Native AOT 是什麼 - 先釐清與 JIT、ReadyToRun、trimming 的差異
把 .NET 的 Native AOT 與 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起釐清,並從啟動、發布、相依性的角度整理它合適與不合適的情境,幫助讀者判斷該不該採用。
相關主題
與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。
Windows 技術主題
彙整 KomuraSoft LLC 關於 Windows 開發、故障調查與既有資產活用文章的主題中心。
32 位元 / 64 位元互通
整理 32 位元 / 64 位元互通、原生邊界與相關 Windows 設計判斷的主題頁面。
與本主題相關的服務
本文連結到以下服務頁面,歡迎從最接近的入口查看。
Windows 應用程式開發
支援包含常駐處理、設備連動、運作日誌與可維護結構的 Windows 桌面應用程式。
Windows 軟體維護 & 現代化
支援既有 Windows 軟體的階段性升級、功能追加、64 位元就緒以及可維護性的重構。
作者檔案
本文作者的個人檔案頁面。
Go Komura
小村軟體有限公司 代表
以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。