Привет, Хабр!

Наткнулись на задачу: нужен плагин на 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-м курсам в месяц по цене одного. Подробнее

Комментарии (1)


  1. Kerman
    08.10.2025 04:05

    <PublishAot>true</PublishAot> включает NativeAOT при публикации; <NativeLib>Shared</NativeLib> заставляет выпускать именно разделяемую библиотеку для C ABI; <SelfContained>true</SelfContained> собирает всё нужное внутрь, без внешнего .NET Runtime;

    SelfContained не нужен в сборке Native AOT. Он просто ничего не делает там. При Native AOT всё и так собирается в один бинарь, никакого рантайма не требуется.