Как вызвать 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.
flowchart TB
accTitle: Чем это направление отличается от предыдущей статьи
accDescr: Ранее речь шла о границе, когда C# вызывает нативную DLL; сейчас направление обратное — приложение C/C++ in-process вызывает нативную DLL на C#, опубликованную через Native AOT.
prev["Ранее: C# вызывает C++"] --> wrap["Речь об обёртке C++/CLI"]
now["Сейчас: C/C++ вызывает C#"] --> aot["C# как DLL через Native AOT"]
aot --> entry["Вход — 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)
Содержание
- Сначала вывод (коротко)
- Как выбирать способ
- Схема
- Минимальная сборка
- 4.1. Проект C#
- 4.2. Экспортируемый код C#
- 4.3. Команда публикации
- 4.4. Пример вызова со стороны C++
- 4.5. Как проверить, что символы экспортированы
- 4.6. Если нужна статическая линковка через import lib
- Форма API, которая меньше ломается
- 5.1. Свести границу к C ABI
- 5.2. Строки — указатель + длина + ёмкость буфера
- 5.3. Не выпускать исключения за границу
- 5.4. Зафиксировать calling convention
- 5.5. Экспортируемые методы держать тонкими, логику вынести отдельно
- Где схема уместна
- Где она всё же не подходит
- Типичные ловушки
- Итог
- Источники
На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 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. Если это ограничение приемлемо, инструмент получается по-настоящему полезным.
flowchart TB
accTitle: Границу проектируют как C ABI
accDescr: Внутри может остаться обычный C# с классами и коллекциями, но наружу выставляют не string и List<T>, а плоский C API вроде create / destroy / operate, явно обозначая время жизни и коды ошибок.
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++, владение, исключения, 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.
flowchart TB
accTitle: Кто главная сторона
accDescr: P/Invoke и C++/CLI — это направление, где главная сторона C#, и она подтягивает нативный код; схема Native AOT в этой статье наоборот: главная сторона нативная, а логика C# вызывается как компонент.
cs["Главная сторона — C#"] -->|"вызывает нативный код"| n1["P/Invoke или C++/CLI"]
nat["Главная сторона — нативный код"] -->|"вызывает C# как компонент"| n2["export через Native AOT"]
Рис. 3: Мост выбирают по направлению. В этой статье главная сторона нативная, C# — компонент.
3. Схема
flowchart LR
accTitle: Схема вызова Native AOT DLL из C/C++
accDescr: Приложение C/C++ вызывает функции cdecl, которые UnmanagedCallersOnly экспортирует из DLL на C#, опубликованной через Native AOT; за экспортами стоят бизнес-логика и таблица handle.
Cpp["Приложение C / C++"] -->|вызов функции cdecl| Dll["DLL на C#, опубликованная Native AOT"]
Dll --> Exports["export с UnmanagedCallersOnly"]
Exports --> Core["Бизнес-логика на C#"]
Exports --> Store["Таблица 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 останется довольно стабильным.
flowchart TB
accTitle: Плоский API на handle
accDescr: create выдаёт handle, операции вроде add и get вызывают с этим handle, 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;как признак «ещё ничего не получили». - На 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.
flowchart TB
accTitle: Как ломается нумерация handle при обороте и чем это закрывают
accDescr: На 32-бит при обороте нумерации Add для уже уничтоженного и убранного из словаря значения проходит успешно, и старый handle на стороне C начинает указывать на посторонний новый экземпляр. Закрывают это номером поколения в handle либо постоянным отказом после исчерпания диапазона.
wrapd["На 32-бит нумерация завернулась"] --> add["Add с тем же значением проходит"]
add --> alias["Старый handle указывает на новый экземпляр"]
alias --> silent["Ломается без исключения и без кода ошибки"]
silent --> g1["Мера: вшить номер поколения"]
silent --> g2["Мера: исчерпав диапазон, отказывать"]
Рис. 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 тоже должна совпадать.
flowchart TB
accTitle: Первый барьер при publish
accDescr: Для publish Native AOT отдельно нужен нативный toolchain; без него dotnet publish падает не на компиляции C#, а на последнем этапе нативной линковки. Публикуют по RID, разрядность тоже выравнивают.
pub["Запускают dotnet publish"] --> q{"Есть ли нативный toolchain"}
q -->|"нет"| fail["Падает на последней нативной линковке"]
q -->|"есть"| out["Для каждого RID выходит нативная DLL"]
out -.-> match["Выровнять разрядность с вызывающей стороной"]
Рис. 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 не найден. Так локализуют проблему.
flowchart TB
accTitle: Как локализовать, почему вызвать не получается
accDescr: Если падает LoadLibraryW, DLL не загрузилась — смотрят путь, разрядность и недостающие зависимые DLL; если падает GetProcAddress, DLL читается, но export не найден; если оба шага прошли, вызывать можно через указатель на функцию.
s1{"LoadLibraryW успешен?"}
s1 -->|"нет"| f1["DLL не загрузилась"]
f1 -.-> f1a["Смотреть путь, разрядность, зависимые DLL"]
s1 -->|"да"| s2{"GetProcAddress успешен?"}
s2 -->|"нет"| f2["export не найден"]
s2 -->|"да"| ok["Можно вызывать как 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, обычно смотрят в таком порядке.
- Есть ли имя в
dumpbin /exports(если нет — это сторона C#) - Совпадает ли строка в
EntryPointсо строкой, переданной вGetProcAddress, байт в байт (регистр тоже различается) - Совпадает ли разрядность вызывающего EXE и DLL
- Метод с
UnmanagedCallersOnly—static, и он не сидит внутри generic - Атрибут написан на сборке, которую публикуют (написать его в подключаемой библиотеке недостаточно — на поверхность он не выйдет)
Если падает сам LoadLibraryW, это уже не про export. Сначала смотрят путь DLL, разрядность и недостающие зависимые DLL.
flowchart TB
accTitle: Порядок проверки, когда GetProcAddress вернул NULL
accDescr: Сначала смотрят, есть ли имя в списке exports dumpbin, затем полное совпадение строки EntryPoint, совпадение разрядности, что метод static и вне generic, и что атрибут написан на публикуемой сборке.
c1["Есть ли имя в dumpbin"] --> c2["Строки совпадают полностью?"]
c2 --> c3["Совпадает ли разрядность?"]
c3 --> c4["static и вне generic?"]
c4 --> c5["Атрибут на публикуемой сборке?"]
c1 -.->|"нет"| cs["Разбирать как проблему стороны C#"]
Рис. 9: Если сначала проверить, есть ли имя в export, сразу видно, это сторона C# или сторона вызова.
4.6. Если нужна статическая линковка через import lib
До сих пор мы писали через LoadLibrary / GetProcAddress. Так проще увидеть, что экспортируется и с какой сигнатурой это принимать.
На практике часто хочется «подключить заголовок и вызывать функции напрямую». Тогда это статическая загрузка через import library. По шагам:
- Если в выходе publish уже есть import library (
.lib) — линкуйте её - Если нет — составьте
.defсо списком имён export и соберите import library командойlib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 - В заголовке объявляйте обычные функции, а не типы указателей на функции
/* 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 позволяет подключить заголовок и вызывать напрямую, но без DLL процесс падает уже при запуске.
q{"Как встраивать?"}
q -->|"подключать как плагин"| dyn["Способ LoadLibrary"]
q -->|"вызывать из заголовка напрямую"| stat["Статическая линковка через import lib"]
stat -.-> risk["Без 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» как раз значит опустить границу до этого простого набора правил.
flowchart TB
accTitle: Из чего состоит ABI
accDescr: ABI — договорённость о том, как скомпилированные двоичные модули стыкуются во время выполнения; в неё входят calling convention (как передают аргументы и возвращаемое значение), раскладка типов, а также имена export и декорация.
abi["ABI〔договорённость на уровне машинного кода〕"] --> a1["Calling convention"]
abi --> a2["Раскладка типов"]
abi --> a3["Имена и линковка"]
a1 -.-> ex["cdecl и stdcall — имена этого пункта"]
Рис. 11: «Свести к C ABI» значит опустить границу до диапазона, где эти три договорённости простые.
Типы на границе с самого начала спокойнее держать примерно такими.
- Базовые типы вроде
int32_t/int64_t/double - struct с фиксированной раскладкой
- handle, эквивалентный
intptr_t/void* 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: На практике важнее «чего не экспортировать». Привычки обеих сторон на границу не выпускают.
Чтобы меньше ошибаться при переносе сигнатур, вот таблица соответствия. В сигнатуре метода с 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
Ничего эффектного в этом нет, но такая спокойная схема потом окупается. На границе не устраивают трюки.
flowchart TB
accTitle: Ошибки без пересечения границы исключениями
accDescr: Managed-исключение не выпускают к вызывающему как есть: перехватывают внутри границы, возвращают status code, фактические данные отдают указательным параметром, подробности при необходимости — в стиле get_last_error.
exc["Исключение внутри C#"] --> stop["Перехватить внутри границы"]
stop --> code["Возвращаемое значение — status code"]
stop --> outp["Фактические данные — указательным параметром"]
stop -.-> last["Подробности — в стиле get_last_error"]
Рис. 13: Исключения границу не пересекают: их переводят в мир status code и указательных параметров.
5.4. Зафиксировать calling convention
В примере явно указан CallConvCdecl. Если его опустить, возьмется соглашение платформы по умолчанию. Но если заголовки и типы указателей на функции вы хотите зафиксировать, явное указание с вашей стороны меньше ведёт к сюрпризам.
Особенно если в поле зрения может оказаться x86: оставлять это размытым потом дорого. На x64 это реже всплывает, но правило лучше зафиксировать сразу.
5.5. Экспортируемые методы держать тонкими, логику вынести отдельно
Методы с UnmanagedCallersOnly не рассчитаны на прямой вызов из обычного managed-кода. Если начать писать в них всю бизнес-логику, тестировать становится тяжело.
В примере управление объектом тоже вынесено в AccumulatorStore, а экспортируемый NativeExports — только тонкий вход. Это довольно важно.
- Экспортируемые методы: окно ABI
- Внутренние классы: обычная логика на C#
При таком разделении границу с C++ и основной код C# можно обдумывать по отдельности.
flowchart TB
accTitle: Export тонкий, основное тело отдельно
accDescr: Методы export с UnmanagedCallersOnly оставляют тонким окном ABI, а управление объектами и бизнес-логику кладут во внутренние классы — так границу и основное тело можно обдумывать отдельно и проще тестировать.
exp["Метод export〔тонкое окно〕"] --> core["Внутренний класс〔обычный C#〕"]
exp -.-> abi["Только проверка ABI и преобразование"]
core -.-> test["Можно тестировать как обычный C#"]
Рис. 14: Не начинайте писать бизнес-логику в export. Разделение окна и основного тела упрощает сопровождение и тесты.
6. Где схема уместна
Схема хорошо ложится в такие ситуации.
- Существующее приложение на C/C++ оставляют как есть, а в C# переносят только часть бизнес-логики
- Не хочется делать предварительную установку среды выполнения .NET условием поставки
- Поверхность экспортируемых функций можно удержать маленькой
- Позже тот же C API, возможно, захотят вызывать и из других языков — Rust, Go и т. п.
Особенно хорошо это стыкуется со схемой, где нативное приложение остаётся на месте, а на C# пишут только легко заменяемый слой логики. UI и управление оборудованием остаются на C++, решения, расчёты и правила настроек — на C#. Связка — через небольшую поверхность C API.
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.
- Нужно перекинуть мост между 32-бит / 64-бит или пересечь границу процессов
- Не in-process DLL, а COM / IPC / отдельный процесс — более прямой путь.
- Плагин потом захочется выгрузить
- Shared library Native AOT лучше не использовать с расчётом на выгрузку.
- Зависимые библиотеки сильно опираются на reflection или динамическую генерацию кода
- Если при AOT publish появляются warning, безопаснее не отмахиваться от них.
Водораздел в итоге один: готовы ли вы ограничиться C ABI. Если нет, другой мост будет чище.
flowchart TB
accTitle: Где схема не подходит и какой мост взять вместо неё
accDescr: Если нужны типы и исключения C++ как есть — C++/CLI; если мир регистрации и автоматизации — COM; если нужно пересечь разрядность или границу процессов — COM или IPC; для плагина с выгрузкой эта схема сама по себе не подходит.
q{"Что требуется?"}
q -->|"типы и исключения C++ как есть"| cli["К C++/CLI или обёртке"]
q -->|"мир регистрации и автоматизации"| com["К контексту COM"]
q -->|"разрядность или переход через процесс"| ipc["К COM, IPC или отдельному процессу"]
q -->|"потом выгрузить"| ng["Эта схема не подходит по предпосылкам"]
Рис. 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# только в слой логики, эту схему стоит помнить.
flowchart TB
accTitle: Пять пунктов устойчивой границы
accDescr: Сплющивание до C ABI, явное время жизни через handle, переход через границу кодом ошибки, фиксация calling convention и тонкий export, отделённый от внутренней логики, вместе дают устойчивую границу.
goal["Устойчивая нативная DLL на C#"] --> p1["Сплющить до C ABI"]
goal --> p2["Время жизни явно через handle"]
goal --> p3["Через границу — error code"]
goal --> p4["Зафиксировать calling convention"]
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
- Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke - KomuraSoft Blog
- Пример COM-моста: вызов 64-битной DLL из 32-битного приложения - KomuraSoft Blog
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Time Travel Debugging — записывать и перематывать ошибки, которые не воспроизводятся в долгоживущих приложениях
Ошибка раз в месяц оставляет в дампе только результат. Записывайте и перематывайте исполнение через WinDbg Time Travel Debugging (TTD): T...
Почему ломаются аргументы ── правила аргументов командной строки Windows
В Windows массива аргументов нет: в CreateProcess уходит одна строка, делит её принимающая сторона. Правила деления CommandLineToArgvW, C...
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Обратная совместимость интерфейсов DLL и COM — таблица: какие изменения ломают вызывающий код
Какие изменения DLL или COM-компонента ломают вызывающий код. Разбираем три слоя совместимости — бинарную, исходного кода и поведенческую...
Версионирование схемы БД бизнес-приложения — практика миграций, чтобы у клиентов не оказалось «у каждого своя база»
Практическое руководство по версионированию схемы БД бизнес-приложения, которое стоит у множества клиентов. Разбираем PRAGMA user_version...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Совместимость 32 и 64 бит
Совместимость 32/64 бит, нативные границы и решения по проектированию Windows.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Тема — реализация границы между C# и C/C++, поэтому она напрямую связана с консультациями по проектированию и реализации Windows-приложений.
Использование и перенос существующих активов
Как навести мост между существующим нативным кодом и .NET — тема хорошо стыкуется с повторным использованием существующих наработок и поддержкой миграции.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Можно ли вызвать код на 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.