Привет, Хабр!
Наткнулись на задачу: нужен плагин на C#, который можно грузить как обычную C-библиотеку без CLR и JIT, а вызывать из Rust и Python. Без обвязок, без CoreCLR-хостинга и прочего. Чистый C ABI, нормальные строки, предсказуемые структуры, обработка ошибок и нулевой JIT-прогрев. Это как раз случай для NativeAOT: компилируем библиотеку в нативный .dll/.so/.dylib, экспортируем функции через [UnmanagedCallersOnly], а дальше живём как с любой C-библиотекой. Нюансов хватает: что экспортируется и как назвать символ, как договориться по ABI, что делать со строками UTF-8, как возвращать ошибку, как освобождать память снаружи, почему исключения нельзя проталкивать за границу, и в каком месте «cdecl» реально что-то значит.
Начну с каркаса проекта .NET. Нам нужна именно нативная библиотека, не приложение. В csproj это выражается так:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net8.0</TargetFramework> <PublishAot>true</PublishAot> <NativeLib>Shared</NativeLib> <SelfContained>true</SelfContained> <AllowUnsafeBlocks>true</AllowUnsafeBlocks> <DisableRuntimeMarshalling>true</DisableRuntimeMarshalling> <InvariantGlobalization>true</InvariantGlobalization> <Optimize>true</Optimize> </PropertyGroup> </Project>
<PublishAot>true</PublishAot> включает NativeAOT при публикации; <NativeLib>Shared</NativeLib> заставляет выпускать именно разделяемую библиотеку для C ABI; <SelfContained>true</SelfContained> собирает всё нужное внутрь, без внешнего .NET Runtime; отключённое runtime-маршалирование избавляет от неужного, оставляя только явные blittable-типы и кастомное маршалирование, что нам и нужно. Документация прямо говорит: NativeAOT в библиотечном сценарии экспортирует методы, помеченные UnmanagedCallersOnly, причём экспортируется только то, что находится в самом публикуемом проекте. Для экспорта нужно задать непустой EntryPoint, это имя C-символа.
Мини-контракт плагина calc
Не тянем в интерфейс ничего управляемого. Никаких string, bool, List<T>. Переходить границу будем через простые типы и указатели. Результат оформим как C-совместимую структуру с кодом статуса, значением и, при ошибке, указателем на сообщение в UTF-8.
Память под строку отдаём вызывающему через отдельный free.
// CalcExports.cs using System; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Text; [StructLayout(LayoutKind.Sequential)] public struct CalcResult { public int Code; // 0 - ok, иначе ошибка public double Value; // результат арифметики public nint ErrorUtf8; // char* с \0; владеет библиотека, освобождать через calc_free } public static unsafe class CalcExports { private const int Ok = 0; private const int ErrInvalid = 1; private const int ErrDivideByZero = 2; // Выделение UTF-8 буфера, \0-терминированного, для возврата наружу. private static nint Utf8Alloc(ReadOnlySpan<char> s) { // размер в байтах под UTF-8 + нуль-терминатор int byteCount = Encoding.UTF8.GetByteCount(s); byte* mem = (byte*)NativeMemory.Alloc((nuint)(byteCount + 1)); int written = Encoding.UTF8.GetBytes(s, new Span<byte>(mem, byteCount)); mem[written] = 0; return (nint)mem; } // Универсальный free для внешнего кода. [UnmanagedCallersOnly(EntryPoint = "calc_free", CallConvs = new[] { typeof(CallConvCdecl) })] public static void Free(void* p) => NativeMemory.Free(p); [UnmanagedCallersOnly(EntryPoint = "calc_add", CallConvs = new[] { typeof(CallConvCdecl) })] public static int Add(double a, double b, CalcResult* outRes) { try { *outRes = new CalcResult { Code = Ok, Value = a + b, ErrorUtf8 = 0 }; return Ok; } catch (Exception ex) { outRes->Code = ErrInvalid; outRes->Value = 0; outRes->ErrorUtf8 = Utf8Alloc(ex.Message); return ErrInvalid; } } [UnmanagedCallersOnly(EntryPoint = "calc_sub", CallConvs = new[] { typeof(CallConvCdecl) })] public static int Sub(double a, double b, CalcResult* outRes) { try { *outRes = new CalcResult { Code = Ok, Value = a - b, ErrorUtf8 = 0 }; return Ok; } catch (Exception ex) { outRes->Code = ErrInvalid; outRes->Value = 0; outRes->ErrorUtf8 = Utf8Alloc(ex.Message); return ErrInvalid; } } [UnmanagedCallersOnly(EntryPoint = "calc_mul", CallConvs = new[] { typeof(CallConvCdecl) })] public static int Mul(double a, double b, CalcResult* outRes) { try { *outRes = new CalcResult { Code = Ok, Value = a * b, ErrorUtf8 = 0 }; return Ok; } catch (Exception ex) { outRes->Code = ErrInvalid; outRes->Value = 0; outRes->ErrorUtf8 = Utf8Alloc(ex.Message); return ErrInvalid; } } [UnmanagedCallersOnly(EntryPoint = "calc_div", CallConvs = new[] { typeof(CallConvCdecl) })] public static int Div(double a, double b, CalcResult* outRes) { try { if (b == 0) { *outRes = new CalcResult { Code = ErrDivideByZero, Value = 0, ErrorUtf8 = Utf8Alloc("divide by zero") }; return ErrDivideByZero; } *outRes = new CalcResult { Code = Ok, Value = a / b, ErrorUtf8 = 0 }; return Ok; } catch (Exception ex) { outRes->Code = ErrInvalid; outRes->Value = 0; outRes->ErrorUtf8 = Utf8Alloc(ex.Message); return ErrInvalid; } } }
Экспорт виден только для методов с [UnmanagedCallersOnly] и непустым EntryPoint. Без него символ не попадёт в итоговый .dll/.so.
Исключения нельзя протолкнуть за границу в C. Их надо ловить внутри и конвертировать в код ошибки и строку.
Строки: наружу отдаём char* в UTF-8 и дополнительный экспорт calc_free. Это стандартный паттерн для ctypes и FFI, чтобы вызывающая сторона могла корректно освободить память тем же аллокатором, что её выделил.
В UnmanagedCallersOnly проставлен CallConvCdecl. На Unix-подобных платформах это соответствует обычному C ABI, а на Windows x64 компилятор всё равно применит единственный доступный calling convention.
Публикация библиотеки
Команды публикации под разные ОС:
# Windows x64 → calc.dll dotnet publish -f net8.0 -c Release -r win-x64 -p:PublishAot=true -p:NativeLib=Shared -p:SelfContained=true # Linux x64 → libcalc.so dotnet publish -f net8.0 -c Release -r linux-x64 -p:PublishAot=true -p:NativeLib=Shared -p:SelfContained=true # macOS x64/arm64 → libcalc.dylib dotnet publish -f net8.0 -c Release -r osx-arm64 -p:PublishAot=true -p:NativeLib=Shared -p:SelfContained=true
Для экспорта функций обязательно собирать shared-библиотеку, статические архивы .lib/.a не дают привычных экспортов символов и в общем случае требуют ручной линковки с зависимостями рантайма. Лучше начинать со Shared.
Про размер и дебаг-символы. В .NET 8 свойство StripSymbols на Linux по умолчанию включено, символы выносятся в отдельный .dbg-файл. Это уменьшает размер основного артефакта.
Про холодный старт. NativeAOT убирает JIT из пути выполнения. На реальных сценариях это сокращает время первого ответа, а в бессерверных окружениях это часто главная причина перехода на AOT.
Rust: аккуратный FFI поверх calc
Договоримся о структурах. В Rust описываем их с #[repr(C)], всё по байтовой раскладке:
// Cargo.toml: // [dependencies] // libloading = "0.8" use std::{ffi::{CStr}, os::raw::{c_char, c_int, c_double, c_void}}; use libloading::{Library, Symbol}; #[repr(C)] #[derive(Debug, Copy, Clone)] pub struct CalcResult { pub code: c_int, pub value: c_double, pub error_utf8: *const c_char, } type CalcFn = unsafe extern "C" fn(a: c_double, b: c_double, out_res: *mut CalcResult) -> c_int; type FreeFn = unsafe extern "C" fn(p: *mut c_void); pub struct Calc { lib: Library, add: Symbol<CalcFn>, sub: Symbol<CalcFn>, mul: Symbol<CalcFn>, div_: Symbol<CalcFn>, free_: Symbol<FreeFn>, } impl Calc { pub fn load() -> anyhow::Result<Self> { #[cfg(target_os = "windows")] let name = "calc.dll"; #[cfg(target_os = "linux")] let name = "libcalc.so"; #[cfg(target_os = "macos")] let name = "libcalc.dylib"; let lib = unsafe { Library::new(name)? }; unsafe { Ok(Self { add: lib.get(b"calc_add\0")?, sub: lib.get(b"calc_sub\0")?, mul: lib.get(b"calc_mul\0")?, div_: lib.get(b"calc_div\0")?, free_: lib.get(b"calc_free\0")?, lib, }) } } fn handle(&self, code: c_int, mut res: CalcResult) -> anyhow::Result<f64> { if code == 0 { return Ok(res.value); } let msg = if !res.error_utf8.is_null() { unsafe { let s = CStr::from_ptr(res.error_utf8).to_string_lossy().into_owned(); (self.free_)(res.error_utf8 as *mut c_void); s } } else { "calc error".to_string() }; anyhow::bail!(msg) } pub fn add(&self, a: f64, b: f64) -> anyhow::Result<f64> { let mut res = CalcResult { code: 0, value: 0.0, error_utf8: std::ptr::null() }; let code = unsafe { (self.add)(a, b, &mut res) }; self.handle(code, res) } pub fn div(&self, a: f64, b: f64) -> anyhow::Result<f64> { let mut res = CalcResult { code: 0, value: 0.0, error_utf8: std::ptr::null() }; let code = unsafe { (self.div_)(a, b, &mut res) }; self.handle(code, res) } } fn main() -> anyhow::Result<()> { let calc = Calc::load()?; println!("2 + 3 = {}", calc.add(2.0, 3.0)?); println!("4 / 0 = {:?}", calc.div(4.0, 0.0).err()); Ok(()) }
#[repr(C)] фиксирует ABI-совместимую раскладку структуры. Сигнатуры с extern "C" и простые типы, это базовый FFI в Rust. Нужен аккуратный unsafe, свободные строки чистим через экспортированный calc_free.
Python: ctypes-обёртка
ctypes — самый простой способ вызвать функции по C ABI. Не делайте restype = c_char_p для указателя, который нужно освобождать. Безопаснее хранить как c_void_p, вручную декодировать и освободить через calc_free.
# calc.py import ctypes from ctypes import c_int, c_double, c_void_p, c_char_p, POINTER, Structure import sys import os class CalcResult(Structure): _fields_ = [ ("code", c_int), ("value", c_double), ("error_utf8", c_void_p), ] def _lib_name(): if sys.platform.startswith("win"): return "calc.dll" elif sys.platform.startswith("darwin"): return "libcalc.dylib" else: return "libcalc.so" _lib = ctypes.CDLL(os.path.join(os.path.dirname(__file__), _lib_name())) _lib.calc_add.argtypes = [c_double, c_double, POINTER(CalcResult)] _lib.calc_add.restype = c_int _lib.calc_div.argtypes = [c_double, c_double, POINTER(CalcResult)] _lib.calc_div.restype = c_int _lib.calc_free.argtypes = [c_void_p] _lib.calc_free.restype = None def _handle(res: CalcResult, code: int): if code == 0: return res.value msg = "calc error" if res.error_utf8: cstr = ctypes.cast(res.error_utf8, c_char_p) msg = cstr.value.decode("utf-8", errors="replace") _lib.calc_free(res.error_utf8) # освобождаем буфер raise RuntimeError(msg) def calc_add(a: float, b: float) -> float: res = CalcResult() code = _lib.calc_add(a, b, ctypes.byref(res)) return _handle(res, code) def calc_div(a: float, b: float) -> float: res = CalcResult() code = _lib.calc_div(a, b, ctypes.byref(res)) return _handle(res, code)
Освобождать память надо тем же аллокатором, где она была выделена, поэтому отдельная функция calc_free не прихоть.
ABI, строки, структуры, исключения
ABI и calling convention. Для x64 на Windows действует один calling convention, называть его cdecl или stdcall бессмысленно, компилятор всё равно зафиксирует Microsoft x64 ABI. На Linux и macOS — System V AMD64 ABI. В коде C# мы указываем CallConvCdecl для унификации и понятности, а дальше полагаемся на соответствующий ABI платформы. Общая рекомендация: не «играть» с соглашением о вызовах, оставляйте предсказуемые C-сигнатуры.
Строки. В экспортируемых методах [UnmanagedCallersOnly] нельзя использовать управляемые строки в параметрах/результате. Возвращайте char* и давайте явный free. Для кодировок берём UTF-8. Это совместимо с Rust CStr и с Python ctypes.c_char_p.
Структуры. Только blittable-типы, фиксированная раскладка Sequential. Если нужен bool, лучше использовать int с 0/1 и задокументировать контракт. Не возвращайте большие структуры по значению, используйте указатель на выходной буфер, так проще и предсказуемее в разных компоновках.
Исключения. Их нельзя пропускать через границу. Внутри try/catch, наружу код ошибки и сообщение. Это общее правило interop.
Итог
Плагин на C# без рантайма вполне реализуемо, если идти через NativeAOT и UnmanagedCallersOnly. Экспортируем функции как C-символы, удерживаем интерфейс на уровне простых типов и указателей, для строк используем UTF-8 и явный free, исключения гасим внутри и отражаем кодом возврата. Сборка делается одной командой dotnet publish с -p:NativeLib=Shared -p:PublishAot=true -p:SelfContained=true. Из Rust и Python вызов ничем не отличается от обычной C-библиотеки: extern "C" и ctypes.
Если вы хотите начать изучение C#, курс C# Developer. Basic даст вам системное понимание языка и основных инструментов разработки. На занятиях вы познакомитесь с синтаксисом, типами данных, объектно‑ориентированным программированием и базовыми практиками написания кода.
Рост в IT быстрее с Подпиской — дает доступ к 3-м курсам в месяц по цене одного. Подробнее
