引用本文(DOI: 10.5281/zenodo.21615448)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《如何从 C/C++ 调用 C# Native AOT DLL》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615448 https://comcomponent.com/zh-CN/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/
- DOI(最新版本)
- 10.5281/zenodo.21615448
- DOI(此版本)
- 10.5281/zenodo.22281962
在上一篇 从 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. 要导出的 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++ 一侧吸收 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 的方向相反。
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 都没关系,但对外暴露的一面要保持扁平。
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 也能保持相当稳定。
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;表示“还没有持有”,正是因为这一点。 - 在 32bit 下会发生截断。
nint是指针宽度,64bit 下是 64bit,但 32bit 构建下就是 32bit。从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 会被分发出去。
因此,不要把重复键检查当作安全装置来指望。如果有可能面对 32bit,就采用下面两种做法之一。
- 把代次号(generation)嵌入 handle。 低位放连续编号,高位放代次号,每次 destroy 就让代次号前进。即使相同的连续编号又转回来,整体的值也不会一致
- 用尽之后就永久失败。 编号分配达到上限之后,让后续的 create 全部返回错误。对于持续运行的设备来说这意味着需要重启,但比静悄悄地出错要好处理得多
无论采用哪种做法,都要一并遵守:把分配计数器本身就保存为 nint 以免超出 nint 宽度,并且不分发 0。
flowchart TB
accTitle: handle编号回绕时的出错方式与对策
accDescr: 在32bit下编号回绕一圈后,已经destroy并从字典中删除的值的Add会成功,C一侧一直握着的旧handle会指向毫不相干的新实例,静悄悄地出错。对策是嵌入代次号,或者在达到上限之后永久失败。
wrapd["32bit下编号回绕一圈"] --> 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 的 bitness 也需要保持一致。
flowchart TB
accTitle: publish的第一道关卡
accDescr: Native AOT的publish需要另外准备原生工具链,没有它就执行dotnet publish时,失败的不是C#的编译,而是最后的原生链接阶段。要按RID分别publish,并保持bitness一致。
pub["执行dotnet publish"] --> q{"是否有原生工具链"}
q -->|"没有"| fail["在最后的原生链接阶段失败"]
q -->|"有"| out["按RID生成原生DLL"]
out -.-> match["与调用方保持bitness一致"]
图7:失败的不是 C# 的编译,而是链接阶段。第一道关卡是有没有工具链。
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# 写的这件事,几乎可以不用去在意。
把 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没有被加载,要怀疑路径、bitness和依赖DLL缺失;如果GetProcAddress失败,说明DLL已经读进来但没有找到export;两步都通过就可以用函数指针调用。
s1{"LoadLibraryW成功了吗?"}
s1 -->|"失败"| f1["DLL没有被加载"]
f1 -.-> f1a["怀疑路径、bitness和依赖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 这 4 个名称都排在 name 一栏里,就说明 C# 一侧的发布是成功的。想按名称过滤的话,这样写。
dumpbin /exports NativeAotSample.dll | findstr km_
如果这里没有出现名称,那就是 C# 一侧的问题;如果出现了但 GetProcAddress 仍然失败,那就是调用方的问题,这样就能把范围划分开。
GetProcAddress 返回 NULL 时,大致按下面的顺序去看。
dumpbin /exports里有没有出现名称(没有出现的话就是 C# 一侧的事)- 写在
EntryPoint里的字符串,与传给GetProcAddress的字符串是否完全一致(大小写也区分) - 调用方 EXE 与 DLL 的 bitness 是否一致
- 带
UnmanagedCallersOnly特性的方法是不是static,有没有放进 generic 里 - 这个特性是不是写在 publish 目标程序集一侧(写在被引用的库里不会暴露出来)
另外,如果连 LoadLibraryW 本身都失败,那就不是 export 的问题。请先怀疑 DLL 的路径、bitness 和依赖 DLL 缺失。
flowchart TB
accTitle: GetProcAddress返回NULL时的确认顺序
accDescr: 依次确认dumpbin的exports列表里是否出现名称、EntryPoint的字符串与传入的字符串是否完全一致、bitness是否一致、方法是否为static且不在generic中、特性是否写在publish目标程序集一侧。
c1["dumpbin里是否出现名称"] --> c2["字符串是否完全一致"]
c2 --> c3["bitness是否一致"]
c3 --> c4["是否static且在generic之外"]
c4 --> c5["特性是否写在publish目标一侧"]
c1 -.->|"没有出现"| cs["按C#一侧的问题去排查"]
图9:先看 export 里有没有出现名称,就能确定是 C# 一侧的问题还是调用方的问题。
4.6. 想用 import lib 做静态链接时
到这里为止用的都是 LoadLibrary / GetProcAddress 的写法。因为这种写法能清楚地看出导出了什么、应该以什么样的签名来接收。
另一方面,在实际项目中,“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 的缩写,指的是编译好的二进制之间在运行时如何咬合的约定。可以理解成这不是源代码层面的约定,而是机器码层面的约定。它的内容主要有 3 项。
| 约定 | 决定什么 | 在这里没守住会怎样 |
|---|---|---|
| 调用约定(calling convention) | 参数是通过寄存器还是栈、以什么方式传递,返回值放在哪里,调用结束后由调用方还是被调用方来恢复栈 | 参数错位,或者刚返回栈就被破坏 |
| 类型布局 | 各个类型占多少字节,struct 的成员排在什么位置(填充与对齐) | 结构体从中途开始读不出正确的值 |
| 名称与链接 | export 出来的函数名怎么拼写,有没有修饰(decoration) | GetProcAddress 找不到名称 |
cdecl 和 stdcall 就是其中第 1 项“调用约定”的名称。C++ 的类和异常,这 3 项约定在各家编译器之间都不一样,所以直接放到边界上是咬合不上的。反过来说,只限定在 C 的函数和基本类型上,约定就足够简单,也就容易咬合。所谓“向 C ABI 靠拢”,意思就是把边界面落到这套简单约定的范围之内。
flowchart TB
accTitle: ABI这个约定的内容
accDescr: ABI是编译好的二进制之间在运行时如何咬合的约定,由决定参数与返回值传递方式的调用约定、类型布局、export名称与修饰这三项构成。
abi["ABI〔机器码层面的约定〕"] --> a1["调用约定"]
abi --> a2["类型布局"]
abi --> a3["名称与链接"]
a1 -.-> ex["cdecl和stdcall是这里的名称"]
图11:所谓“向 C ABI 靠拢”,就是把边界面落到这 3 项约定都足够简单的范围之内。
在此基础上,暴露在边界上的类型,最好从一开始就靠向下面这些,日子会好过一些。
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 上是 32bit,在 Linux 的 LP64 上是 64bit,所以不要写 long,用 int64_t 更安全 |
nint / nuint |
intptr_t / uintptr_t |
指针宽度。32bit 构建下会变成 32bit |
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: 不把managed异常直接泄漏给调用方,而是让返回值是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 特性的方法,并不是设计给普通 managed 代码直接调用的。所以如果把业务逻辑全部写进去,测试也会变得困难。
示例中也是把实体的管理放在 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 运行时为分发前提
- 能把 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,不要草率地忽略它,这样更安全。
归根结底,能不能用 C ABI 划清界限才是分水岭。如果划不清,用别的桥梁会更干净。
flowchart TB
accTitle: 不适合的场景与替代的桥梁
accDescr: 想直接处理C++的类和异常就用C++/CLI,想进入COM注册和自动化的世界就用COM,想跨越bitness或进程边界就用COM或IPC,而以卸载为前提的插件本身就不适合这种结构。
q{"想要的是什么"}
q -->|"直接使用C++的类型和异常"| cli["转向C++/CLI或封装层"]
q -->|"注册与自动化的世界"| com["转向COM的语境"]
q -->|"跨bitness或跨进程"| ipc["转向COM、IPC或独立进程"]
q -->|"之后想卸载"| ng["这种结构不符合前提"]
图16:分水岭是“能不能用 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#,而是把它 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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
在 C# 中安全调用 Win32 API —— P/Invoke 实务指南(DllImport / LibraryImport / CsWin32)
本文整理在 C# 中通过 P/Invoke 调用 Win32 API 或原生 DLL 时的实务要点:DllImport 与 LibraryImport 的区别、用 CsWin32 自动生成签名、字符串封送的陷阱、用 SafeHandle 管理句柄、SetLastError ...
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
业务系统的编码设计 ── 商品编码・客户编码的确定方法与校验位
确定商品编码・客户编码等业务系统编码体系的实践指南。整理了有意义编码与无意义流水号的判断表、JAN・Luhn等校验位算法及C#实现、Excel开头零丢失的应对方法,直至位数溢出与迁移。
DLL・COM 接口的向后兼容性 ── 判断哪些改动会破坏调用方的对照表
DLL 或 COM 组件的哪些改动会破坏调用方?本文整理二进制兼容、源代码兼容、行为兼容这三层概念,给出按改动类型划分的判断表、COM 接口不可变的铁律,以及 semver 的实务运用方法,作为一份实务指南。
业务应用数据库架构的版本管理 ── 防止「每个客户数据库都不一样」的迁移实践
一份为分散在各客户处的业务应用数据库架构做版本管理的实践指南。整理了 PRAGMA user_version 与前向迁移的 C# 实现、EF Core Migrations・DbUp・自行实现的判断表,以及两阶段发布策略。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
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 上基本相同。