Как вызвать C# Native AOT DLL из C/C++

· · C#, .NET, Native AOT, C++, Разработка Windows, Нативная интеграция

В прошлой статье Почему при использовании нативных DLL на C++ из C# стоит делать обёртку на 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 подход почти такой же, но примеры кода рассчитаны на DLL под Windows.

Код, который встречается в этой статье, опубликован на GitHub как полный набор сборно-запускаемых примеров (библиотека C#, публикуемая через Native AOT, пример вызова из C++ и модульные тесты).

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. Экспортируемые методы делаем тонкими, а основное тело выносим отдельно
  6. Подходящие случаи
  7. Случаи, для которых это всё же не подходит
  8. Подводные камни
  9. Итог
  10. Список источников

1. Сначала вывод (в двух словах)

  • Если хочется вызывать обработку C# из C/C++ in-process, Native AOT + UnmanagedCallersOnly — весьма достойный кандидат.
  • Однако экспортируется строго точка входа C-функции. Это не мир, где можно напрямую выставлять string или List<T>.
  • На практике устойчивее свести всё к плоскому C API вроде create / destroy / operate, явно обозначив управление жизненным циклом и коды ошибок.
  • Если хочется естественно работать с классами и STL C++, лучше подходит C++/CLI; если нужна регистрация, автоматизация или пересечение процессов — лучше COM.

Иначе говоря: C# можно использовать как содержимое нативной DLL, но саму поверхность границы нужно проектировать как C ABI, а не как .NET. Если принять эту сделку, инструмент оказывается по-настоящему интересным.

2. Как выбирать способ моста

Что хочется сделать Сильный кандидат Почему
Вызвать набор C-функций из C# P/Invoke Направление естественное, самый органичный вариант
Естественно работать с библиотекой C++ из C# C++/CLI Типы C++, владение, исключения, std::wstring и подобное легко поглощаются на стороне C++
Пересечь границу 32-бит / 64-бит или границу процессов COM / IPC Одной in-process DLL этого не пересечь
Вызвать логику C# из C/C++ как нативную DLL Native AOT + UnmanagedCallersOnly Можно самостоятельно экспортировать точки входа C

Эта конфигурация раскрывается в ситуациях, когда нативная сторона играет главную роль, а C# вызывается как компонент. Это направление ровно противоположно P/Invoke и C++/CLI.

3. Схема архитектуры

вызовы функций cdeclПриложение C / C++DLL на C#, опубликованная через Native AOTЭкспорты с атрибутом UnmanagedCallersOnlyБизнес-логика на C#Таблица дескрипторов / управление состоянием

Картина выглядит просто. Важно свести поверхность границы к C-функциям. Внутренняя реализация на стороне C# может быть какой угодно — классы, коллекции, LINQ, — но поверхность, обращённая наружу, остаётся плоской.

4. Минимальная конфигурация

Здесь мы возьмём минимальный пример: сторона C++ создаёт «аккумулятор», складывает в него значения и в конце получает сумму. На практике это может быть механизм принятия решений, интерпретатор конфигурации или простой парсер. Считайте это паттерном, при котором нативная сторона держит дескриптор и по очереди вызывает функции операций.

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
  • Разрешить unsafe, поскольку используются указательные параметры

Примеры в этой статье рассчитаны на net8.0, но сам подход одинаков и для .NET 9 / 10.

4.2. Экспортируемый код C#

Методы с атрибутом UnmanagedCallersOnly становятся входными точками, видимыми со стороны нативного кода. Здесь мы выдаём дескрипторы как целые числа, а внутреннее состояние храним в словаре на стороне C#.

// 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
  • Само состояние хранится на стороне C#
  • create / add / get / destroy разбиты на плоские функции
  • Возвращаемое значение — код ошибки, выходные значения возвращаются через указательные параметры

При такой форме, даже если позже заменить внутреннюю реализацию на стороне C#, ABI на стороне C остаётся весьма стабильным.

4.3. Команда публикации

Публикуем как разделяемую библиотеку.

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. Собранное для win-x64 нельзя использовать как рассчитанное на win-arm64, а разрядность вызывающей стороны и DLL должны совпадать.

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 — вещь интересная, но на практике важнее то, чего вы не экспортируете.

5.1. Придерживаемся C ABI

Типы, выставляемые на границе, спокойнее с самого начала свести примерно к следующему.

  • Базовые типы вроде int32_t / int64_t / double
  • Структуры с фиксированным layout
  • Дескрипторы, эквивалентные intptr_t / void*
  • uint8_t* вместе с длиной

И наоборот, вот то, что с самого начала не стоит выпускать за границу.

  • string
  • object
  • List<T>
  • Task
  • Span<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-исключения напрямую просачивались к вызывающей стороне.

На практике удобно поступать так:

  • возвращаемое значение — код состояния;
  • фактические данные возвращаются через выходные буферы или указательные параметры;
  • при необходимости дополнительные сведения можно получить через функцию в стиле get_last_error.

Ничего эффектного в этом нет, но такое неброское проектирование окупается позже. Иначе говоря, не стоит внезапно устраивать борьбу прямо на границе.

5.4. Фиксируем соглашение о вызовах

В примере явно указан CallConvCdecl. Если его опустить, будет использовано соглашение о вызовах по умолчанию для платформы, но если вы хотите зафиксировать заголовки и типы указателей на функции, явно указать соглашение самому — меньше риска аварий.

Особенно если есть вероятность иметь дело с x86, оставлять этот момент неопределённым потом больно аукнется. Даже если на x64 это редко проявляется, правило лучше установить с самого начала.

5.5. Экспортируемые методы делаем тонкими, а основное тело выносим отдельно

Методы с атрибутом UnmanagedCallersOnly не рассчитаны на прямой вызов из обычного managed-кода. Поэтому если начать писать в них всю бизнес-логику целиком, тестировать становится тяжело.

В примере тоже управление реальным объектом вынесено в AccumulatorStore, а экспортируемый NativeExports — лишь тонкая входная точка. Это довольно важно.

  • Экспортируемые методы: приёмная стойка ABI
  • Внутренние классы: обычная логика на C#

При таком разделении труда границу с C++ и основной код C# можно обдумывать раздельно.

6. Подходящие случаи

Эта конфигурация действительно хорошо ложится в такие сценарии.

  • Хочется оставить существующее приложение на C/C++ как есть, перенеся в C# только часть бизнес-логики
  • Не хочется, чтобы предварительная установка среды выполнения .NET была обязательным условием распространения
  • Можно удержать небольшую поверхность экспортируемых функций
  • В перспективе может понадобиться вызывать тот же C API и из других языков, например Rust или Go

Особенно хорошо это сочетается со структурой, при которой нативное приложение остаётся неизменным, а на C# пишется только легко заменяемый слой логики: UI и управление оборудованием остаются на C++, а решения, вычисления и правила конфигурации переезжают на C#.

7. Случаи, для которых это всё же не подходит

Разумеется, это не панацея. Есть случаи, для которых это явно не подходит.

  • Хочется напрямую работать с классами C++, std::vector или исключениями
    • В этом случае естественнее C++/CLI или обёртка на нативной стороне.
  • Хочется войти в мир регистрации COM, автоматизации VBA / Office, расширений Explorer
    • Здесь лучше думать в терминах COM.
  • Хочется перебросить мост между 32-бит / 64-бит или пересечь границу процессов
    • Не in-process DLL, а COM / IPC / отдельный процесс — более логичный путь.
  • Хочется впоследствии выгружать плагин
    • Разделяемую библиотеку Native AOT не стоит использовать с расчётом на выгрузку.
  • Зависимые библиотеки сильно полагаются на reflection или динамическую генерацию кода
    • Если появляются предупреждения при публикации AOT, безопаснее не игнорировать их небрежно.

В конечном счёте водораздел — это укладываетесь ли вы в C ABI. Если не укладываетесь, чище будет выбрать другой мост.

8. Подводные камни

Напоследок соберём незаметные, но частые ловушки при экспорте через Native AOT.

  • Метод с атрибутом UnmanagedCallersOnly обязан быть static.
  • Его нельзя разместить в generic-методе или внутри generic-класса.
  • Если нужен именованный экспорт, добавьте EntryPoint.
  • Не используйте ref / in / out — лучше возвращать значения через указательные параметры.
  • Экспортируются только методы сборки, которая публикуется. Пометить атрибутом методы в подключаемой библиотеке-зависимости недостаточно, чтобы они сами по себе оказались на поверхности.
  • Разрядность вызывающей стороны и DLL должна совпадать.
  • Предупреждения при публикации очень важны. Если появляются предупреждения AOT / trimming, безопаснее сначала разобраться с ними.

Всё это — вещи, о которых, узнав однажды, думаешь «ну да, конечно». Но наступить на эти грабли не зная о них заранее — довольно неприятное занятие на некоторое время.

9. Итог

Когда хочется вызвать C# из C/C++, первым делом на ум приходят COM, C++/CLI или отдельный процесс. Все эти варианты правильные.

Но если хочется встроить обработку на C# как in-process нативную DLL, Native AOT + UnmanagedCallersOnly — по-настоящему интересный вариант.

Перечислим ключевые моменты ещё раз.

  • Не показывать C# как есть, а свести его к C ABI
  • Явно управлять жизненным циклом на основе дескрипторов
  • Пересекать границу через коды ошибок, а не через исключения
  • Зафиксировать соглашение о вызовах
  • Делать экспортируемые методы тонкими, отделяя их от внутренней логики

Ничего эффектного в этом нет. Но то, как проведена граница, сильно влияет на дальнейшую поддерживаемость. Когда хочется сохранить нативные активы, привнеся в слой логики продуктивность C#, эта конфигурация точно стоит того, чтобы её запомнить.

10. Список источников

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Если ваше Windows-приложение приняли за вирус — как реагировать на ложные срабатывания Microsoft Defender и жить с влиянием на производительность

Разбираем правильный порядок действий, если Microsoft Defender ложно определяет ваше Windows-приложение как вредоносное: как устроена сов...

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Можно ли вызвать код на C# из C++?
Да, можно. В .NET Native AOT позволяет опубликовать библиотеку классов C# в виде нативной разделяемой библиотеки, а методы с атрибутом UnmanagedCallersOnly можно раскрыть как точки входа C. То есть C# можно использовать из C/C++ in-process как «нативную DLL на вызываемой стороне».
В каких ситуациях подходит эта конфигурация?
В ситуациях, когда хочется оставить нативное приложение как есть, перенеся в C# только такие части, как логика принятия решений, обработка строк, интерпретация конфигурации или правила вычислений. Особенность направления — «нативная сторона играет главную роль, а C# вызывается как компонент». И наоборот: если нужно вызывать набор C-функций из C#, подходит P/Invoke; если хочется естественно работать с типами и владением C++ — C++/CLI; если нужно пересечь границу 32-бит/64-бит или границу процессов — COM/IPC.
На что обратить внимание при проектировании API?
Экспортируется, по сути, только точка входа C-функции, поэтому нельзя напрямую выставлять на границе string, List<T> или исключения. Нужно свести всё к плоскому C API вроде create / destroy / operate, явно обозначить управление жизненным циклом и коды ошибок, обрабатывать строки как указатель + длина + ёмкость буфера, не давать исключениям пересекать границу и зафиксировать соглашение о вызовах. Ключевая идея — проектировать поверхность границы как C ABI, а не как .NET.
Есть ли рабочий пример кода?
Да, есть. В репозитории komurasoft-blog-samples на GitHub опубликован полный набор сборно-запускаемых примеров, включающий библиотеку C#, публикуемую через Native AOT, пример вызова из C++ и модульные тесты. Примеры кода рассчитаны на DLL под Windows, но сам подход почти так же применим на Linux / macOS.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог