如何从 C/C++ 调用 C# Native AOT DLL
· 小村 豪 · 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 为前提。
另外,本文中出现的代码已作为可构建、可运行的完整示例(以 Native AOT 发布的 C# 库、C++ 的调用示例、单元测试)发布在 GitHub 上。
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
目录
- 先说结论(一句话)
- 先看使用区分
- 结构图
- 最小配置
- 4.1. C# 项目
- 4.2. 要导出的 C# 代码
- 4.3. 发布命令
- 4.4. C++ 一侧的调用示例
- 不易出问题的 API 形态
- 5.1. 向 C ABI 靠拢
- 5.2. 字符串以指针 + 长度 + 缓冲区容量的方式处理
- 5.3. 不让异常跨越边界
- 5.4. 固定调用约定
- 5.5. Export 方法要薄,主体另放
- 适合的场景
- 不适合的场景
- 容易踩坑的地方
- 总结
- 参考资料
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++ 一侧吸收 C++ 类型、所有权、异常、std::wstring 等 |
| 跨越 32bit / 64bit 或跨进程边界 | COM / IPC | 仅靠 in-process DLL 无法跨越 |
| 从 C/C++ 把 C# 逻辑当作原生 DLL 调用 | Native AOT + UnmanagedCallersOnly |
可以自行 export C 的 entry point |
这种结构最能派上用场的场景是「原生一侧是主角,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 都没关系,但对外暴露的一面要保持扁平。
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. 要导出的 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 来调用。这种形式能清楚地看出导出了什么、应该以什么样的签名来接收。
/* 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 运行时为分发前提
- 能把 export 出来的函数面控制得比较小
- 未来可能想从 Rust、Go 等其他语言,通过同一套 C API 来调用
尤其适合原生应用保持不变,只把易于替换的逻辑层用 C# 编写这种结构。UI 或设备控制仍用 C++,判断、计算、配置规则用 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,不要随意忽略它,这样更安全。
归根结底,能否以 C ABI 划清界限是分水岭。如果划不清,用别的桥梁会更干净。
8. 容易踩坑的地方
最后,整理一下 Native AOT export 中容易踩的坑。
- 带有
UnmanagedCallersOnly特性的方法必须是static。 - 不能放在 generic 方法或 generic 类中。
- 想要 named export 时要加上
EntryPoint。 - 不要使用
ref/in/out,最好改用指针参数来返回。 - 被导出的是 publish 目标程序集一侧的方法。给引用的库中的方法加上特性,并不会直接暴露出来。
- 调用方和 DLL 的 bitness 需要保持一致。
- publish 的 warning 相当重要。如果出现了 AOT / trimming 的 warning,最好先处理掉再说,这样更安全。
这些内容,大多是「知道了就会觉得理所当然」的事情。但如果在不知道的状态下踩一次坑,就会经历相当难熬的一段时间。
9. 总结
想从 C/C++ 调用 C# 时,最先想到的往往是 COM、C++/CLI,或者独立进程。这些都是正确的选项。
不过,如果想以 in-process 的原生 DLL 形式嵌入 C# 的处理,Native AOT + UnmanagedCallersOnly 是相当有趣的选择。
再重复一下要点:
- 不直接暴露 C#,而是把它 flatten 成 C ABI
- 以 handle 为基础明确管理寿命
- 用 error code 而不是异常来跨越边界
- 固定调用约定
- export 方法要薄,与内部逻辑分开
做的事情并不华丽。但这种「如何切分边界」的思考方式,会对后续的可维护性产生相当大的影响。当你想在保留原生资产的同时,把逻辑层的生产力交给 C# 时,这种结构值得记在心里。
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
- 从 C# 调用原生 DLL 时,C++/CLI 封装更有优势的原因 - KomuraSoft Blog
- 从 32bit 应用调用 64bit DLL 的方法 - COM 桥接派上用场的案例研究 - KomuraSoft Blog
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中通过 P/Invoke 调用 Win32 API 或原生 DLL 时的实务要点:DllImport 与 LibraryImport 的区别、用 CsWin32 自动生成签名、字符串封送的陷阱、用 SafeHandle 管理句柄、SetLastError ...
Windows 应用安全处理子进程的清单 - Job Object、退出传播、标准输入输出、watchdog 最佳实践
在 Windows 应用中安全处理子进程,比起选择启动 API,更重要的是设计进程树的所有权和结束流程。本文整理 Job Object、退出传播、标准输入输出与 watchdog。
串口通信应用的陷阱 - 直到重连与日志设计
围绕设备联动和仪器控制中应避免的串口通信陷阱,从帧边界、超时、RTS/CTS、DTR/RTS、重连到日志设计,用实务视角进行整理。
.NET 的 Native AOT 是什么 - 先厘清与 JIT、ReadyToRun、trimming 的区别
把 .NET 的 Native AOT 与 JIT、ReadyToRun、self-contained、single-file、trimming、source generator 放在一起厘清,并从启动、发布、依赖关系的角度整理它适合与不适合的场景,帮助读者判断该不该采用。
在桌面应用中使用 .NET Generic Host 与 BackgroundService 的理由
本文整理在 Windows 工具或常驻应用中,如何使用 Generic Host 与 BackgroundService 来梳理启动、定期处理、退出处理、日志、配置与 DI。
常见问题
汇总了咨询这一主题时常见的问题。
- 可以从 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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。