Привет, Хабр!
Наткнулись на задачу: нужен плагин на 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-м курсам в месяц по цене одного. Подробнее
Kerman
SelfContained не нужен в сборке Native AOT. Он просто ничего не делает там. При Native AOT всё и так собирается в один бинарь, никакого рантайма не требуется.