.NET на SBC максимально канонично

Введение

В интернетах всегда кто-то не прав. В этот раз, по моему максимально предвзятому мнению, оказался не прав @bodyawm.

Для меня .NET в каждой бочке затычка, поэтому меня бобмануло от использования Mono в 2024 году. В этой статье я покажу своё видение того, как максимально канонично и современно писать на .NET для GNU/Linux и SBC (Single-board computer, aka одноплатник).

1. К вопросу о выборе платформы

На данный момент довольно широко распространены и доступны для покупки любителям одноплатники на X86, ARM и RISC-V (MIPS как бы можно найти, но сложно и не мейнстрим).

Если в вашем проекте важна работа с мультимедиа и не нужны GPIO — выбирайте X86 и избавьте себя от страданий с драйверами аппаратных видеокодеков и GPU. Тем более, что на данный момент существует большое количество как одноплатников, так и готовых мини PC с X86.

Если вам нужен ARM и высокая производительность, обратите внимание на платформу Rockchip RK3588. В продаже доступны одноплатники даже с 32GB памяти и загрузкой с NVMe.

Если вам нужен ARM и не хотите головной боли — выбирайте RaspberryPI.

Использование .NET на RISC-V для меня всё ещё Terra incognita.

Так что в данной статье будет про использование .NET на ARM (и ARM64 в том числе).

1.1 Выбор дистрибутива

Если у вас Raspberry PI или плата на RK3588, то вы можете попробовать ARM версию Windows. Но во всех остальных случаях GNU/Linux практически безальтернативен.

Если у вас Raspberry — вариант по-умолчанию — RaspberryPI OS.

В других случаях, как правило, есть два варианта — дистрибутив от производителя платы и сторонний дистрибутив.

1.1.1 Система от производителя

Эти образы чаще всего основаны на BSP (Board Supprt Package) от производителя SoC. Как я понимаю, производитель SoC в первую очередь обеспечивает поддержку Android. Поэтому номер версии ядра скорее всего будет тем, который был актуален для Android на момент выпуска SoC. В этом есть как плюсы, так и минусы.

Плюсы:

  • скорее всего будет максимальная поддержка всех аппаратных блоков SoC;

  • скорее всего будет поддержка «из коробки» для фирменных аксессуаров типа камер, дисплеев, etc.

Минусы:

  • вероятно будет старое или очень старое ядро. Если версия ядра 6.*, то это несказанное счастье;

  • много блобов. Закрытые бинари от производителя SoC для GPU и разных HW блоков;

  • «мутные» китайские репозитории пакетов. Для кого-то эти может быть проблемой;

  • очень часто это «старый» дистрибутив, типа Ubuntu 18.

1.1.2 Сторонний дистрибутив

Говорим «Сторонний дистрибутив» — подразумеваем Armbian. Конечно, это не всегда так. Есть множество различных дистрибутивов для SBC, например DietPi. Некоторые производители SBC ведут базы сторонних дистрибутивов. OrangePi, как пример.

Плюсы:

  • часто можно выбрать между ядром из BSP и mainline;

  • доступны сборки со свежим mainline ядром;

  • репозитории пакетов можно считать более надёжными, ибо сообщество больше;

  • если нет поддержки конкретно вашего SBC, то можно относительно легко добавить;

  • самые последние версии ОС (а с ними и софта, библиотек).

Минусы:

  • могут быть проблемы с GPU или аппаратными декодерами;

  • на mainline ядрах может не быть поддержки всего функционала;

  • утилиты конфигурации скорее всего максимально универсальные и не «заточены» на конкретную плату.

1.2 Заметки на полях

«Сердце» сторонних дистрибутивов — это системы сборки образов. Например, в Armbian новая плата добавляется с помощью добавления текстового конфига. Таким образом, если Armbian не поддерживает ваш SBC, но поддерживает похожие, то высок шанс того, что вы сами сможете реализовать эту поддержку.

2. Теория по .NET

В данном разделе довольно сумбурно перечислены различные темы и советы, которые полезны, если вы пишете приложение для SBC.

Воспринимайте это как каталог ключевых слов, которые можно при желании погуглить.

2.1 Среда разработки .NET приложений для SBC

Если у вас что-то на RK3588, то можно просто установить дистрибутив с desktop окружением, запустить VS Code и не париться.

В статье же будем исходить из того, что используется low-end одноплатник на каком-нибудь древнем, но супердешёвом Allwinner H3. На таком SoC даже просто сборка HelloWorld будет небыстрым процессом. Поэтому разработку и сборку крайне желательно производить на отдельной машине для разработки, aka ББ (Big Brother, если вы олд). Это в свою очередь приводит нас к необходимости использования удалённого отладчика и автоматизированного копирования бинарей на SBC.

Известные мне варианты удобной разработки:

  1. Расширение для VS Code .NET FastIoT. Статья на Habr.

  2. Мой старый вариант.

  3. Расширение для VS VS .NET Linux Debugger.

Так или иначе всё сводится к тому, чтобы собрать приложение на мощной машине, скопировать на SBC и подключиться удалённым отладчиком.

2.2 Модель приложения и GenericHost

Если задача — создать какую-то простую утилиту, то приложение ничем не будет отличаться от обычного HelloWorld — парсим (желательно готовой либой) аргументы запуска, а в качестве лога используем Console.WriteLine().  

Однако, раз мы говорим о приложении для SBC, то вероятнее всего приложение будет работать в фоне как демон.  

Конечно, можно написать

while(true)
{
    // Делаем всю работу
}

но это не путь самурая от .NET.

Правильно будет использовать .NET Generic Host. Эта штука предоставляет базовые сервисы, такие как DI, логирование, конфигурация, работа с жизненным циклом приложения (отслеживание останова приложения), интеграция с системой (например, с systemd). Подробнее рассмотрим использование в практической части статьи.

2.3 Self-hosted

Штука, известная для многих разработчиков на .NET. Но иногда кажется, что весь остальной мир застыл во временах Framework 3.5 и до сих пор не знает, что рантайм можно поставлять вместе с приложением и это стандартная фича.

2.4 Trimming

Приложение можно тримить (с большими ограничениями). Система сборки может выкинуть неиспользуемый код из приложения и, вроде бы, рантайма. Таким образом, ваше приложение станет легче.

2.5 Пишем эффективно

Проблемы с производительностью на слабом железе проявляю себя куда ярче, чем обычно.

Следует помнить, что у нас не только слабый CPU, но и очень медленная память. То есть, проблема будет не с выделением памяти — это .NET делает быстрее, чем С++, а с копированием памяти и GC.

Это приводит к следующим очевидным рекомендациям:

  1. Меньше копирований.
    Высока вероятность, что вы будете работать с байтовыми массивами. Старайтесь использовать Span и Memory.

  2. Меньше созданий.
    Используем пакеты System.Buffers(System.Buffers.ArrayPool) и Microsoft.Extensions.ObjectPool(ObjectPool), если ваше приложение в реалтайме прожёвывает какие-то данные, например RTP пакеты или фреймы видео, а вам позарез нужны объекты.

  3. Попробуйте векторные расширения.
    System.Runtime.Intrinsics предоставляет доступ к расширениям и для ARM.

  4. Используйте Pinned Object Heap (статья на Habr) для данных, которые ходят в unmanaged — буферы для сетевых операций, аудиофреймы, которые передаются в OS и тп.

Медленнее памяти на SBC может быть только дисковая подсистема. С 90% вероятностью в хоббийном сегменте вы будете работать с SD картой.

2.6 Тюним GC

В первую очередь читаем вот эту страницу документации.
Как обычно, главное выбрать тип GC — Server или Workstation. Если вы пишете демона, то это не означает автоматом, что вам нужен серверный GC. Всё зависит от профиля нагрузки.

Интересными могут оказаться следующие дополнительные опции:

  • Retain VM — возвращать ли освобождённые куски кучи обратно OS. По‑умолчанию — false;

  • Dynamic Adaptation To Application Sizes (DATAS) — адаптация размера хипа для серверного GC.

2.7 С сомнением смотрим на Native AOT

С одной стороны, вы можете получить нативный бинарь, который будет быстро запускаться. С другой, в итоге приложение может работать медленнее, ведь вы потеряете Tiering и Dynamic PGO. LINQ тоже станет медленнее.  

То есть — отталкивайтесь от ваших нужд.

2.8 Современный P/Invoke

Для взаимодействия с системой вам необходимо будет или найти готовый nuget пакет или же самим писать биндинги к системным библиотекам. Долгое время для этого использовался атрибут DllImport. Однако теперь у нас больше вариантов:

  • LibraryImport — новый атрибут, который завезли в .NET7. Используется генерация кода, обещается лучшая совместимость с AOT и в целом лучшая производительность.

  • AdvancedDLSupport — суперская библиотека, которая, к моему сожалению, не обновлялась уже два года.

2.9 Используем System.Device.Gpio и Iot.Device.Bindings

Репозиторий dotnet/iot содержит готовые классы для работы с различными периферийными устройствами, которые можно подключать по I2C, SPI и т.п…

2.10 GUI

Одна из встречающихся для SBC задач — реализация Human-Machine Interface (HMI).  

В таких ситуациях на SBC запущено одно единственное полноэкранное приложение. Это позволяет отказаться от X11/Wayland/оконных менеджеров и рисовать интерфейс напрямую используя Direct Rendering Manager (DRM).  

Возрадуйтесь, олдскульные WPF разработчики, ибо Avalonia так умеет через пакет Avalonia.LinuxFrameBuffer. Подробности в документации.  

Однако, если вам не хватает производительности или нужен больший контроль над происходящим, то всегда можно использовать более низкоуровневый Dear ImGui для которого есть .NET биндинги.

3. Практический пример

Перед тем, как читать дальше
Если вам интересно .NET приложение, которое «в продакшене» работает на SBC, обратите взор на репозиторий OpenHD-WebUI. Это WebUI, который крутится на различных SBC, которые используются для DIY FPV систем. В репозитории настроен CI (сборка и публикация deb пакетов), есть пример unit файла для systemd.

Вернёмся к практике

Возьму для примера мой самый ужасный SBC на ARM — OrangePi Lite 1G.
SoC: Allwinner H3 — 4 ядра Cortex‑A7(ARMv7, NEON, VFP4), Mali400 MP2
RAM: 1GB DDR3

Armbian предлагает сборку Debian 12 (Bookworm) с Linux 6.6. Скачал минимальный образ, прошил его на microSD с помощью Balena Etcher и готово.
У этого SBC нет Ethernet. Чтобы не искать монитор и клавиатуру для настройки WiFi, просто подключил USB‑Ethernet адаптер.

На всякий случай установим утилиту конфигурации:

sudo apt update && sudo apt install armbian-config

Удалённую отладку на практике раскрывать не буду, так как уже делал это (см. п. 2.1).

Приступим к написанию/разбору кода.

3.1 Пример минимального Main

Мы используем GenericHost для того, чтобы наше приложение нормально запускалось и как обычное консольное, и как systemd сервис. На сколько я понимаю, systemd умеет общаться с приложениями по D-Bus, для того, чтобы узнавать, что приложение готово к работе и т.п. Пакет Microsoft.Extensions.Hosting.Systemd как раз реализует этот функционал интеграции с systemd.

internal class Program
{
    static void Main(string[] args)
    {
        var builder = Host.CreateApplicationBuilder(args);

        builder.Services
            .AddHostedService()
            .AddSystemd();

        builder.Logging
            .AddSystemdConsole();

        var host = builder.Build();
        host.Run();
    }
}

3.2 Пример минимального BackgroundService

Спасибо DI, мы можем получать реализации интерфейсов через конструктор. 

В примере используются логер (для логирования) и IHostApplicationLifetime (для того, чтобы могли подписаться на события жизненного цикла приложения).
Метод ExecuteAsync порождает Task, которая будет крутиться в фоне.
CancellationToken, который передаётся в ExecuteAsync, позволяет отследить, когда бесконечную таску пора завершать.

internal class Worker : BackgroundService
{
    private readonly ILogger _logger;

    public Worker(
        ILogger logger,
        IHostApplicationLifetime appLifetime)
    {
        _logger = logger;
        appLifetime.ApplicationStopping.Register(OnStopping);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var timer = new PeriodicTimer(TimeSpan.FromSeconds(10));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            _logger.LogWarning("Timer tick");
        }
    }

    private void OnStopping()
    {
        _logger.LogWarning("Stopping");
    }
}

3.3 Пример минимального csproj

Теперь посмотрим, как выглядит csproj, который позволяет собрать приложение с кодом, приведённым выше

Это «новый» формат проектов msbuild. Он выгодно отличается от того, что используется в старом Net Framework и Mono своей лаконичностью. Если раньше править csproj вручную было больно, то теперь в некоторых случаях это проще, чем использовать GUI.



  
    Exe
    net8.0
    enable
    enable
  

  
    
    
  

3.4 Прикручиваем dotnet/iot

dotnet/iot состоит из двух частей:

  • System.Device.Gpio — абстракции для работы с GPIO/I2C/SPI/PWM.

  • Iot.Device.Bindings — userspace драйверы конкретных устройств. Работает поверх System.Device.Gpio.

3.4.1 Работаем с GPIO

Входной точкой для работы с gpio и шинами в System.Device.Gpio является класс Board и его наследники. Готовых реализаций у этого класса две — RaspberryPiBoard и GenericBoard. Мы будем использовать GenericBoard — эта реализация будет пытаться найти подходящий способ для работы с GPIO — трушный libgpiod или устаревший способ через sysfs. Начиная с linux 4.8 sysfs интерфейс для работы с GPIO признан устаревшим. Так что высока вероятность, что всё будет работать через libgpiod.

Установим libgpiod:

sudo apt install libgpiod-dev gpiod

Посмотрим, какие GPIO доступны для управления:

sudo gpioinfo

В моём случае доступны два gpiochip — на 224 и 32 линии. Чип на 224 линии — как раз GPIO нашего SoC. Все порты идут по порядку со смещением в 32 линии. Почему я так решил? Заглянул в исходники linux и увидел вот эту строку.

Давайте попробуем помигать светодиодом. Расширим класс Worker, приведённый выше:

internal class Worker : BackgroundService
{
    private const int PinNum = 20; // PA20
    private readonly ILogger _logger;
    private readonly GpioController _pinController;
    private readonly GpioPin _pin;

    public Worker(
        ILogger logger,
        Board board)
    {
        _logger = logger;
        _pinController = board.CreateGpioController();
        _pin = _pinController.OpenPin(PinNum, PinMode.Output);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            _pin.Toggle();
            _logger.LogWarning("Timer tick");
        }
    }
}

Выводных светодиодов в закромах не оказалось, поэтому пруф вот такой:

Когда хватило денег на Fluke, но не хватило на LED

Когда хватило денег на Fluke, но не хватило на LED

3.4.2 Работаем с I2C

Для I2C и SPI шин System.Device.Gpio также уже имеет в своём составе все необходимые методы для работы.
Воспользуемся armbian-config чтобы активировать шину i2c0.

Также стоит установить i2c-tools чтобы упростить диагностику:

sudo apt install i2c-tools

После подключения дисплея, перепроверим его адрес, выполнив команду:

sudo i2cdetect 0

Получаем вот такой вывод:

buldo@orangepilite:~$ sudo i2cdetect 0
WARNING! This program can confuse your I2C bus, cause data loss and worse!
I will probe file /dev/i2c-0.
I will probe address range 0x08-0x77.
Continue? [Y/n]
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- --
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
20: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
30: -- -- -- -- -- -- -- -- -- -- -- -- 3c -- -- --
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- --
70: -- -- -- -- -- -- -- --

из которого видно, что дисплей отвечает по адресу 0×3C, что соответствует документации на контроллер дисплея.

Драйвер дисплея, предоставляемый Iot.Device.Bindings, работает с битмапами. Оригинальный пример полагается на SkiaSharp. Пойдём по пути примера и доустановим пакет Iot.Device.Bindings.SkiaSharpAdapter.

Зарегистрируем адаптер, добавив следующий код в Main():

SkiaSharpAdapter.Register();

Также, установим пакеты в ОС:

sudo apt install libfontconfig1

Теперь Worker выглядит так:

Уже достаточно длинный Worker, чтобы прятать под спойлер

internal class Worker : BackgroundService
{
    private const int PinNum = 20; // PA20
    private readonly ILogger _logger;
    private readonly GpioController _pinController;
    private readonly GpioPin _pin;
    private readonly Ssd1306 _display;

    public Worker(
        ILogger logger,
        Board board)
    {
        _logger = logger;
        _pinController = board.CreateGpioController();
        _pin = _pinController.OpenPin(PinNum, PinMode.Output);
        var i2cBus = board.CreateOrGetI2cBus(0, [11, 12]);
        var device = i2cBus.CreateDevice(0x3c);
        _display = new Ssd1306(device, 128, 32);
        _display.EnableDisplay(true);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        var fontSize = 25;
        var font = "DejaVu Sans";
        var drawPoint = new Point(0, 0);

        var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(100));
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            _pin.Toggle();
            _logger.LogWarning("Timer tick");

            using var image = BitmapImage.CreateBitmap(128, 32, PixelFormat.Format32bppArgb);
            image.Clear(Color.Black);
            var drawingApi = image.GetDrawingApi();
            drawingApi.DrawText(DateTime.Now.ToString("HH:mm:ss"), font, fontSize, Color.White, drawPoint);
            _display.DrawBitmap(image);
        }
    }
}

Результат работы:

Что ни собирай, получаются или часы, или погодная станция

Что ни собирай, получаются или часы, или погодная станция

4. Итоги

В 2024 году нет особого смысла использовать Mono, а .NET8 вполне себе отлично работает на ARM.

Пример кода из статьи в этом репозитории.

Пример относительно качественного деплоя в другом репозитории.

© Habrahabr.ru