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

· Обновлено: · · C#, .NET, Native AOT, C++, Разработка под Windows, Нативное взаимодействие

История изменений (2 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Карта знаний: метка COM Interop приведена к каноническому узлу. Утверждения статьи не менялись.
Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.21619669)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). Как вызвать Native AOT DLL на C# из C/C++. KomuraSoft LLC. https://comcomponent.com/ru/blog/2026/03/12/003-csharp-native-aot-native-dll-from-c-cpp/

DOI (зарегистрированный архив)
10.5281/zenodo.21619669
DOI (последняя зарегистрированная версия)
10.5281/zenodo.21619670

В предыдущей статье Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke мы разбирали границу для случая, когда C# вызывает C++. Здесь направление обратное: C/C++ вызывает C#.

Бывает, что логику на C# хочется вызвать из уже существующего приложения на C/C++, но P/Invoke смотрит в другую сторону, а тащить ради этого C++/CLI или COM кажется избыточным. Особенно это касается случая, когда нативное приложение оставляют как есть, а в C# переносят только куски вроде правил принятия решений, обработки строк, разбора настроек или расчётных правил.

Мост можно навести и через COM, но здесь речь о более in-process подходе — ближе к обычной DLL. В Native AOT у .NET библиотеку классов можно опубликовать как нативную shared library, а методы с UnmanagedCallersOnly — открыть как C entry point. То есть C# можно использовать как «нативную DLL на стороне вызываемого».

Но через границу нельзя тащить всё подряд. Стоит выпустить на границу string, List<T>, исключения или владение — и схема быстро становится хрупкой. На минимальном примере Windows + C++ разберём, когда эта схема действительно подходит и какой формой API она меньше ломается. На Linux / macOS идея почти та же, но примеры кода рассчитаны на DLL под Windows.

Чем это направление отличается от предыдущей статьиРанее речь шла о границе, когда C# вызывает нативную DLL; сейчас направление обратное — приложение C/C++ in-process вызывает нативную DLL на C#, опубликованную через Native AOT.Ранее: C# вызывает C++Речь об обёртке C++/CLIСейчас: C/C++ вызывает C#C# как DLL через Native AOTВход — UnmanagedCallersOnly

Рис. 1: Здесь направление противоположно P/Invoke и C++/CLI: C# становится нативной DLL на стороне вызываемого.

Код из статьи опубликован на 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++
    • 4.5. Как проверить, что символы экспортированы
    • 4.6. Если нужна статическая линковка через import lib
  5. Форма API, которая меньше ломается
    • 5.1. Свести границу к C ABI
    • 5.2. Строки — указатель + длина + ёмкость буфера
    • 5.3. Не выпускать исключения за границу
    • 5.4. Зафиксировать calling convention
    • 5.5. Экспортируемые методы держать тонкими, логику вынести отдельно
  6. Где схема уместна
  7. Где она всё же не подходит
  8. Типичные ловушки
  9. Итог
  10. Источники

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 22, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

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. Если это ограничение приемлемо, инструмент получается по-настоящему полезным.

Границу проектируют как C ABIВнутри может остаться обычный C# с классами и коллекциями, но наружу выставляют не string и List, а плоский C API вроде create / destroy / operate, явно обозначая время жизни и коды ошибок.Внутри — обычный C#Наружу — плоский C APIВремя жизни явно через handleОшибки возвращают кодомstring и List〔T〕 наружу не выставляют

Рис. 2: Ограничение одно. Не «показать .NET как есть», а свести границу к C ABI.

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 entry point

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

Кто главная сторонаP/Invoke и C++/CLI — это направление, где главная сторона C#, и она подтягивает нативный код; схема Native AOT в этой статье наоборот: главная сторона нативная, а логика C# вызывается как компонент.вызывает нативный кодвызывает C# как компонентГлавная сторона — C#P/Invoke или C++/CLIГлавная сторона — нативный кодexport через Native AOT

Рис. 3: Мост выбирают по направлению. В этой статье главная сторона нативная, C# — компонент.

3. Схема

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

Рис. 4: Со стороны C/C++ как C-функции видны только export с UnmanagedCallersOnly.

Картина простая. Важно выровнять границу по 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 выдаём как целое, а внутреннее состояние держим в dictionary на стороне 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);
    }
}

Здесь всё довольно прямолинейно.

  • Нативной стороне показывают только handle типа intptr_t
  • Само состояние живёт на стороне C#
  • create / add / get / destroy разложены на плоские функции
  • Возвращаемое значение — код ошибки, выходные данные уходят через указательные параметры

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

Плоский API на handlecreate выдаёт handle, операции вроде add и get вызывают с этим handle, destroy освобождает. Состояние держит сторона C#, возвращаемое значение — код ошибки, выходные данные — через указательные параметры.create: выдать handleadd и get: операции по handledestroy: освободитьСостояние держит сторона C#Возвращаемое значение — код ошибки

Рис. 5: Наружу видны только handle и функции операций. Внутреннюю реализацию можно заменить, ABI останется стабильным.

Про нумерацию handle — одно уточнение. В примере счётчик держат как long и результат Interlocked.Increment приводят к nint. У этой схемы есть два свойства, о которых лучше знать заранее.

  • 0 не выдаётся. Счётчик начинается с 0, Increment возвращает значение после сложения, поэтому первый handle — 1. Именно поэтому сторона C++ может использовать intptr_t handle = 0; как признак «ещё ничего не получили».
  • На 32-бит происходит усечение. nint имеет ширину указателя: на 64-бит это 64 бита, на 32-битном билде — 32. Приведение long к nint молча отбрасывает старшие биты, и когда нумерация превышает 2^32, значение заворачивается. При круглосуточном create / destroy это, в теории, достижимо.

Что происходит при обороте, нужно понимать точно. Исключение о дублирующемся ключе в s_instances.Add(handle, instance) помогает только если handle с тем же значением сейчас жив. Обычное использование этого API — цикл create и destroy, и уничтоженный handle уже убран из словаря. Когда нумерация вернулась к тому же значению, ключа в словаре нет, и Add проходит успешно. В результате старый handle, который сторона C всё ещё держит, начинает указывать на посторонний новый экземпляр. Ни исключения, ни кода ошибки нет — тихо ломаются только данные.

Ещё одно: у значения ровно 2^32 младшие 32 бита все нули, поэтому выдаётся 0, который вы как раз держали как признак «ещё ничего не получили».

Поэтому не рассчитывайте на проверку дублирующихся ключей как на предохранитель. Если в поле зрения есть 32-бит, выбирайте одно из двух.

  • Вшить в handle номер поколения. Младшие биты — порядковый номер, старшие — поколение; при каждом destroy поколение двигают. Даже если тот же порядковый номер вернётся, значения не совпадут
  • Исчерпав диапазон, дальше всегда отказывать. Когда нумерация дошла до потолка, последующие create возвращают ошибку. На постоянно работающей установке понадобится перезапуск, но это всё же лучше, чем тихая порча данных

В обоих случаях сам счётчик держат как nint, чтобы не выйти за его ширину, и по-прежнему не выдают 0.

Как ломается нумерация handle при обороте и чем это закрываютНа 32-бит при обороте нумерации Add для уже уничтоженного и убранного из словаря значения проходит успешно, и старый handle на стороне C начинает указывать на посторонний новый экземпляр. Закрывают это номером поколения в handle либо постоянным отказом после исчерпания диапазона.На 32-бит нумерация завернуласьAdd с тем же значением проходитСтарый handle указывает на новый экземплярЛомается без исключения и без кода ошибкиМера: вшить номер поколенияМера: исчерпав диапазон, отказывать

Рис. 6: Исключение о дублирующемся ключе не является предохранителем. Поломка при обороте тихая, защиту закладывают в дизайн.

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

Сначала одна предпосылка. Для publish Native AOT отдельно нужен нативный toolchain. Если просто выставить 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 почти всегда отсюда.

После этого публикуют как shared library.

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 тоже должна совпадать.

Первый барьер при publishДля publish Native AOT отдельно нужен нативный toolchain; без него dotnet publish падает не на компиляции C#, а на последнем этапе нативной линковки. Публикуют по RID, разрядность тоже выравнивают.нетестьЗапускают dotnet publishЕсть ли нативный toolchainПадает на последней нативной линковкеДля каждого RID выходит нативная DLLВыровнять разрядность с вызывающей стороной

Рис. 7: Падает не компиляция C#, а этап линковки. Первый барьер — есть ли toolchain.

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;

    // Shared library Native AOT не рассчитана на выгрузку.
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

В этом примере со стороны C++ видно только «C API, который вызывают через указатели на функции». То, что внутри написано на C#, почти не нужно держать в голове.

Положите опубликованную DLL в ту же папку, что и main.exe, и запустите. Складываются 10 и 20, поэтому в стандартный вывод уйдёт только это.

total = 30

Если где-то по пути произошёл сбой, в std::cerr будет видно, на каком шаге упало. LoadLibraryW failed — DLL вообще не найдена; GetProcAddress failed: km_accumulator_add — DLL читается, но export не найден. Так локализуют проблему.

Как локализовать, почему вызвать не получаетсяЕсли падает LoadLibraryW, DLL не загрузилась — смотрят путь, разрядность и недостающие зависимые DLL; если падает GetProcAddress, DLL читается, но export не найден; если оба шага прошли, вызывать можно через указатель на функцию.нетданетдаLoadLibraryW успешен?DLL не загрузиласьСмотреть путь, разрядность, зависимые DLLGetProcAddress успешен?export не найденМожно вызывать как C API

Рис. 8: По шагу, на котором упало, отличают проблему загрузки DLL от проблемы export.

4.5. Как проверить, что символы экспортированы

Когда «вызвать не получается», сначала смотрят, действительно ли имя есть на стороне DLL. Быстрее всего — dumpbin в Developer Command Prompt Visual Studio.

dumpbin /exports NativeAotSample.dll

Если в списке name стоят все четыре — km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy — публикация на стороне C# прошла. Чтобы отфильтровать по имени:

dumpbin /exports NativeAotSample.dll | findstr km_

Имени нет — проблема на стороне C#; имя есть, а GetProcAddress падает — проблема на стороне вызова.

Когда GetProcAddress возвращает NULL, обычно смотрят в таком порядке.

  1. Есть ли имя в dumpbin /exports (если нет — это сторона C#)
  2. Совпадает ли строка в EntryPoint со строкой, переданной в GetProcAddress, байт в байт (регистр тоже различается)
  3. Совпадает ли разрядность вызывающего EXE и DLL
  4. Метод с UnmanagedCallersOnlystatic, и он не сидит внутри generic
  5. Атрибут написан на сборке, которую публикуют (написать его в подключаемой библиотеке недостаточно — на поверхность он не выйдет)

Если падает сам LoadLibraryW, это уже не про export. Сначала смотрят путь DLL, разрядность и недостающие зависимые DLL.

Порядок проверки, когда GetProcAddress вернул NULLСначала смотрят, есть ли имя в списке exports dumpbin, затем полное совпадение строки EntryPoint, совпадение разрядности, что метод static и вне generic, и что атрибут написан на публикуемой сборке.нетЕсть ли имя в dumpbinСтроки совпадают полностью?Совпадает ли разрядность?static и вне generic?Атрибут на публикуемой сборке?Разбирать как проблему стороны C#

Рис. 9: Если сначала проверить, есть ли имя в export, сразу видно, это сторона C# или сторона вызова.

4.6. Если нужна статическая линковка через import lib

До сих пор мы писали через LoadLibrary / GetProcAddress. Так проще увидеть, что экспортируется и с какой сигнатурой это принимать.

На практике часто хочется «подключить заголовок и вызывать функции напрямую». Тогда это статическая загрузка через import library. По шагам:

  1. Если в выходе publish уже есть import library (.lib) — линкуйте её
  2. Если нет — составьте .def со списком имён export и соберите import library командой lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. В заголовке объявляйте обычные функции, а не типы указателей на функции
/* 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.

Когда динамическая загрузка, когда статическая линковкаСпособ LoadLibrary загружает во время выполнения и удобен для подключения как плагин; статическая линковка через import library позволяет подключить заголовок и вызывать напрямую, но без DLL процесс падает уже при запуске.подключать как плагинвызывать из заголовка напрямуюКак встраивать?Способ LoadLibraryСтатическая линковка через import libБез DLL падает уже при запуске

Рис. 10: Писать проще со статической линковкой, но «без этой функции приложение всё же живёт» даёт только динамическая загрузка.

Публикация как статической библиотеки (NativeLib=Static) официально не поддерживается, так что на это лучше не рассчитывать.

5. Форма API, которая меньше ломается

Экспорт через Native AOT — штука занятная, но на практике важнее чего не экспортировать.

5.1. Свести границу к C ABI

Сначала про термины. Ядро статьи — «проектировать границу как C ABI, а не как .NET». ABI здесь — Application Binary Interface, то есть договорённость о том, как уже скомпилированные двоичные модули стыкуются во время выполнения. Это не уровень исходников, а уровень машинного кода. По сути там три вещи.

Договорённость Что фиксирует Что будет, если её не соблюсти
Calling convention (соглашение о вызовах) Как аргументы кладут в регистры и на стек, куда кладут возвращаемое значение, кто после вызова восстанавливает стек — вызывающий или вызываемый Аргументы съезжают, сразу после возврата ломается стек
Раскладка типов Сколько байт у каждого типа, по каким смещениям стоят поля struct (padding и alignment) Значения начинают читаться с середины структуры
Имена и линковка Написание экспортируемого имени функции, есть ли декорация (name decoration) GetProcAddress не находит имя

cdecl и stdcall — это имена как раз первого пункта, calling convention. У классов и исключений C++ эти три договорённости различаются от компилятора к компилятору, поэтому на границу их как есть не выставляют. Обратная сторона: если ограничиться C-функциями и базовыми типами, договорённости простые и стыковать легче. «Свести к C ABI» как раз значит опустить границу до этого простого набора правил.

Из чего состоит ABIABI — договорённость о том, как скомпилированные двоичные модули стыкуются во время выполнения; в неё входят calling convention (как передают аргументы и возвращаемое значение), раскладка типов, а также имена export и декорация.ABI〔договорённость на уровне машинного кода〕Calling conventionРаскладка типовИмена и линковкаcdecl и stdcall — имена этого пункта

Рис. 11: «Свести к C ABI» значит опустить границу до диапазона, где эти три договорённости простые.

Типы на границе с самого начала спокойнее держать примерно такими.

  • Базовые типы вроде int32_t / int64_t / double
  • struct с фиксированной раскладкой
  • handle, эквивалентный intptr_t / void*
  • uint8_t* плюс длина

И наоборот, с самого начала не стоит выпускать наружу следующее.

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • классы C++, std::vector, std::wstring

Попытка протащить это через границу как есть быстро мутит поверхность. Важно не выпускать на сторону C++ привычки C# и не тащить слишком много привычек C++ на сторону C#.

Какие типы выставлять на границу, какие не выпускатьНа границу выставляют базовые типы, struct с фиксированной раскладкой, handle, указатель и длину; string, object, List, Task, классы C++ и STL наружу не выпускают.Типы на границеБазовые типы, фиксированный struct, handleУказатель и длинаТипы, которые не выпускаютstring, List〔T〕, TaskКлассы C++ и STL

Рис. 12: На практике важнее «чего не экспортировать». Привычки обеих сторон на границу не выпускают.

Чтобы меньше ошибаться при переносе сигнатур, вот таблица соответствия. В сигнатуре метода с 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 long в C++ на Windows — 32 бита, в LP64 на Linux — 64. Безопаснее не писать long, а использовать int64_t
nint / nuint intptr_t / uintptr_t Ширина указателя. На 32-битном билде это 32 бита
float / double float / double  
bool не использовать Не blittable. Передают int32_t как 0 / 1
char / string не использовать Строки, как в 5.2, передают указателем + длиной
T* (unsafe-указатель) T* Через это возвращают выходные значения
struct с фиксированной раскладкой struct с той же раскладкой Порядок полей, типы и padding с обеих сторон должны совпадать

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

Ничего эффектного в этом нет, но такая спокойная схема потом окупается. На границе не устраивают трюки.

Ошибки без пересечения границы исключениямиManaged-исключение не выпускают к вызывающему как есть: перехватывают внутри границы, возвращают status code, фактические данные отдают указательным параметром, подробности при необходимости — в стиле get_last_error.Исключение внутри C#Перехватить внутри границыВозвращаемое значение — status codeФактические данные — указательным параметромПодробности — в стиле get_last_error

Рис. 13: Исключения границу не пересекают: их переводят в мир status code и указательных параметров.

5.4. Зафиксировать calling convention

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

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

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

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

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

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

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

Export тонкий, основное тело отдельноМетоды export с UnmanagedCallersOnly оставляют тонким окном ABI, а управление объектами и бизнес-логику кладут во внутренние классы — так границу и основное тело можно обдумывать отдельно и проще тестировать.Метод export〔тонкое окно〕Внутренний класс〔обычный C#〕Только проверка ABI и преобразованиеМожно тестировать как обычный C#

Рис. 14: Не начинайте писать бизнес-логику в export. Разделение окна и основного тела упрощает сопровождение и тесты.

6. Где схема уместна

Схема хорошо ложится в такие ситуации.

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

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

Удачное разделениеUI и управление оборудованием оставляют на C++, легко заменяемый слой логики — решения, расчёты, правила настроек — пишут на C# и связывают небольшой поверхностью C API.Существующее приложение C/C++UI и управление оборудованием остаются на C++Решения, расчёты, правила настроек — на C#Связка через небольшую поверхность C APIС той же поверхности можно вызывать и из других языков

Рис. 15: Нативная сторона остаётся главной, а в легко заменяемый слой логики приносят продуктивность C#.

7. Где она всё же не подходит

Это не универсальный инструмент. Есть ситуации, где схема явно не подходит.

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

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

Где схема не подходит и какой мост взять вместо неёЕсли нужны типы и исключения C++ как есть — C++/CLI; если мир регистрации и автоматизации — COM; если нужно пересечь разрядность или границу процессов — COM или IPC; для плагина с выгрузкой эта схема сама по себе не подходит.типы и исключения C++ как естьмир регистрации и автоматизацииразрядность или переход через процесспотом выгрузитьЧто требуется?К C++/CLI или обёрткеК контексту COMК COM, IPC или отдельному процессуЭта схема не подходит по предпосылкам

Рис. 16: Водораздел — «готовы ли ограничиться C ABI». Если нет, другой мост будет чище.

8. Типичные ловушки

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

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

Всё это из разряда «узнал — и да, логично». Но наступить на это вслепую — довольно неприятная потеря времени.

9. Итог

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

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

Ещё раз коротко.

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

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

Пять пунктов устойчивой границыСплющивание до C ABI, явное время жизни через handle, переход через границу кодом ошибки, фиксация calling convention и тонкий export, отделённый от внутренней логики, вместе дают устойчивую границу.Устойчивая нативная DLL на C#Сплющить до C ABIВремя жизни явно через handleЧерез границу — error codeЗафиксировать calling conventionExport тонкий, логика отдельно

Рис. 17: Пять пунктов итога. Спокойный дизайн границы сильнее всего влияет на дальнейшее сопровождение.

10. Источники

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

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

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

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

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

Можно ли вызвать код на C# из C++?
Да. Native AOT в .NET позволяет опубликовать библиотеку классов C# как нативную shared library, а методы с 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, явно обозначьте время жизни и коды ошибок, передавайте строки как указатель + длина + ёмкость буфера, не выпускайте исключения за границу и зафиксируйте calling convention. Главное: проектировать границу как C ABI, а не как .NET.
Есть ли рабочий пример?
Да. В репозитории komurasoft-blog-samples на GitHub опубликован полный набор, который собирается и запускается: библиотека C# под Native AOT, пример вызова из C++ и модульные тесты. Примеры рассчитаны на DLL под Windows, но сама идея почти так же применима на Linux / macOS.

Об авторе

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

Го Комура

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

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

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

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