如何从 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」来使用。

不过,并不是什么都能就这样直接跨越边界。一旦让 stringList<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)

目录

  1. 先说结论(一句话)
  2. 先看使用区分
  3. 结构图
  4. 最小配置
    • 4.1. C# 项目
    • 4.2. 要导出的 C# 代码
    • 4.3. 发布命令
    • 4.4. C++ 一侧的调用示例
  5. 不易出问题的 API 形态
    • 5.1. 向 C ABI 靠拢
    • 5.2. 字符串以指针 + 长度 + 缓冲区容量的方式处理
    • 5.3. 不让异常跨越边界
    • 5.4. 固定调用约定
    • 5.5. Export 方法要薄,主体另放
  6. 适合的场景
  7. 不适合的场景
  8. 容易踩坑的地方
  9. 总结
  10. 参考资料

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。只要能接受这一点,它会是相当有趣的一件利器。

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. 结构图

cdecl 函数调用C / C++ 应用用 Native AOT 发布的 C# DLL带 UnmanagedCallersOnly 的 exportC# 的业务逻辑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* 加上长度

反过来,从一开始就不想泄漏到外面的是这些:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • C++ 的类,或 std::vectorstd::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# 写的代码吗?
可以调用。.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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表