- PVSM.RU - https://www.pvsm.ru -
Для меня .NET в каждой бочке затычка, поэтому меня бобмануло от использования Mono в 2024 году. В этой статье я покажу своё видение того, как максимально канонично и современно писать на .NET для GNU/Linux и SBC (Single-board computer, aka одноплатник).
На данный момент довольно широко распространены и доступны для покупки любителям одноплатники на 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 в том числе).
Если у вас Raspberry PI или плата на RK3588, то вы можете попробовать ARM версию Windows. Но во всех остальных случаях GNU/Linux практически безальтернативен.
Если у вас Raspberry — вариант по-умолчанию — RaspberryPI OS.
В других случаях, как правило, есть два варианта — дистрибутив от производителя платы и сторонний дистрибутив.
Эти образы чаще всего основаны на BSP (Board Support Package) от производителя SoC. Как я понимаю, производитель SoC в первую очередь обеспечивает поддержку Android. Поэтому номер версии ядра скорее всего будет тем, который был актуален для Android на момент выпуска SoC. В этом есть как плюсы, так и минусы.
Плюсы:
скорее всего будет максимальная поддержка всех аппаратных блоков SoC;
скорее всего будет поддержка «из коробки» для фирменных аксессуаров типа камер, дисплеев, etc.
Минусы:
вероятно будет старое или очень старое ядро. Если версия ядра 6.*, то это несказанное счастье;
много блобов. Закрытые бинари от производителя SoC для GPU и разных HW блоков;
«мутные» китайские репозитории пакетов. Для кого-то это может быть проблемой;
очень часто это «старый» дистрибутив, типа Ubuntu 18.
Говорим «Сторонний дистрибутив» — подразумеваем Armbian. Конечно, это не всегда так. Есть множество различных дистрибутивов для SBC, например DietPi. Некоторые производители SBC ведут базы сторонних дистрибутивов. OrangePi, как пример [1].
Плюсы:
часто можно выбрать между ядром из BSP и mainline;
доступны сборки со свежим mainline ядром;
репозитории пакетов можно считать более надёжными, ибо сообщество больше;
если нет поддержки конкретно вашего SBC, то можно относительно легко добавить;
самые последние версии ОС (а с ними и софта, библиотек).
Минусы:
могут быть проблемы с GPU или аппаратными декодерами;
на mainline ядрах может не быть поддержки всего функционала;
утилиты конфигурации скорее всего максимально универсальные и не «заточены» на конкретную плату.
«Сердце» сторонних дистрибутивов — это системы сборки образов. Например, в Armbian новая плата добавляется с помощью добавления текстового конфига. Таким образом, если Armbian не поддерживает ваш SBC, но поддерживает похожие, то высок шанс того, что вы сами сможете реализовать эту поддержку.
В данном разделе довольно сумбурно перечислены различные темы и советы, которые полезны, если вы пишете приложение для SBC.
Воспринимайте это как каталог ключевых слов, которые можно при желании погуглить.
Если у вас что-то на RK3588, то можно просто установить дистрибутив с desktop окружением, запустить VS Code и не париться.
В статье же будем исходить из того, что используется low-end одноплатник на каком-нибудь древнем, но супердешёвом Allwinner H3. На таком SoC даже просто сборка HelloWorld будет небыстрым процессом. Поэтому разработку и сборку крайне желательно производить на отдельной машине для разработки, aka ББ (Big Brother, если вы олд). Это в свою очередь приводит нас к необходимости использования удалённого отладчика и автоматизированного копирования бинарей на SBC.
Известные мне варианты удобной разработки:
Мой старый вариант [2].
Расширение для VS VS .NET Linux Debugger [3].
Так или иначе всё сводится к тому, чтобы собрать приложение на мощной машине, скопировать на SBC и подключиться удалённым отладчиком.
Если задача — создать какую-то простую утилиту, то приложение ничем не будет отличаться от обычного HelloWorld — парсим (желательно готовой либой) аргументы запуска, а в качестве лога используем Console.WriteLine().
Однако, раз мы говорим о приложении для SBC, то вероятнее всего приложение будет работать в фоне как демон.
Конечно, можно написать
while(true)
{
// Делаем всю работу
}
но это не путь самурая от .NET.
Правильно будет использовать .NET Generic Host [4]. Эта штука предоставляет базовые сервисы, такие как DI, логирование, конфигурация, работа с жизненным циклом приложения (отслеживание останова приложения), интеграция с системой (например, с systemd). Подробнее рассмотрим использование в практической части статьи.
Штука, известная для многих разработчиков на .NET. Но иногда кажется, что весь остальной мир застыл во временах Framework 3.5 и до сих пор не знает, что рантайм можно поставлять вместе с приложением и это стандартная фича.
Приложение можно тримить (с большими ограничениями). Система сборки может выкинуть неиспользуемый код из приложения и, вроде бы, рантайма. Таким образом, ваше приложение станет легче.
Проблемы с производительностью на слабом железе проявляю себя куда ярче, чем обычно.
Следует помнить, что у нас не только слабый CPU, но и очень медленная память. То есть, проблема будет не с выделением памяти - это .NET делает быстрее, чем С++, а с копированием памяти и GC.
Это приводит к следующим очевидным рекомендациям:
Меньше копирований.
Высока вероятность, что вы будете работать с байтовыми массивами. Старайтесь использовать Span<T> и Memory<T>.
Меньше созданий.
Используем пакеты System.Buffers( System.Buffers.ArrayPool<T>) и Microsoft.Extensions.ObjectPool( ObjectPool<T>), если ваше приложение в реалтайме прожёвывает какие-то данные, например RTP пакеты или фреймы видео, а вам позарез нужны объекты.
Попробуйте векторные расширения.System.Runtime.Intrinsics предоставляет доступ к расширениям и для ARM.
Используйте Pinned Object Heap (статья на Habr [5]) для данных, которые ходят в unmanaged - буферы для сетевых операций, аудиофреймы, которые передаются в OS и тп.
Медленнее памяти на SBC может быть только дисковая подсистема. С 90% вероятностью в хоббийном сегменте вы будете работать с SD картой.
В первую очередь читаем вот эту страницу документации [6].
Как обычно, главное выбрать тип GC — Server или Workstation. Если вы пишете демона, то это не означает автоматом, что вам нужен серверный GC. Всё зависит от профиля нагрузки.
Интересными могут оказаться следующие дополнительные опции:
Retain VM - удерживать ли освобождённые куски кучи обратно OS. По‑умолчанию — false;
Dynamic Adaptation To Application Sizes (DATAS) - адаптация размера хипа для серверного GC.
С одной стороны, вы можете получить нативный бинарь, который будет быстро запускаться. С другой, в итоге приложение может работать медленнее, ведь вы потеряете Tiering и Dynamic PGO. LINQ тоже станет медленнее.
То есть — отталкивайтесь от ваших нужд.
Для взаимодействия с системой вам необходимо будет или найти готовый nuget пакет или же самим писать биндинги к системным библиотекам. Долгое время для этого использовался атрибут DllImport. Однако теперь у нас больше вариантов:
LibraryImport [7] — новый атрибут, который завезли в.NET7. Используется генерация кода, обещается лучшая совместимость с AOT и в целом лучшая производительность.
AdvancedDLSupport [8] — суперская библиотека, которая, к моему сожалению, не обновлялась уже два года.
Репозиторий dotnet/iot [9] содержит готовые классы для работы с различными периферийными устройствами, которые можно подключать по I2C, SPI и т.п..
Одна из встречающихся для SBC задач — реализация Human-Machine Interface (HMI).
В таких ситуациях на SBC запущено одно единственное полноэкранное приложение. Это позволяет отказаться от X11/Wayland/оконных менеджеров и рисовать интерфейс напрямую используя Direct Rendering Manager (DRM).
Возрадуйтесь, олдскульные WPF разработчики, ибо Avalonia так умеет через пакет Avalonia.LinuxFrameBuffer. Подробности в документации [10].
Однако, если вам не хватает производительности или нужен больший контроль над происходящим, то всегда можно использовать более низкоуровневый Dear ImGui [11] для которого есть .NET биндинги.
Перед тем, как читать дальше
Если вам интересно .NET приложение, которое «в продакшене» работает на SBC, обратите взор на репозиторий OpenHD-WebUI [12]. Это 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).
Приступим к написанию/разбору кода.
Мы используем 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<Worker>()
.AddSystemd();
builder.Logging
.AddSystemdConsole();
var host = builder.Build();
host.Run();
}
}
Спасибо DI, мы можем получать реализации интерфейсов через конструктор.
В примере используются логер (для логирования) и IHostApplicationLifetime (для того, чтобы могли подписаться на события жизненного цикла приложения).
Метод ExecuteAsync порождает Task, которая будет крутиться в фоне.CancellationToken, который передаётся в ExecuteAsync, позволяет отследить, когда бесконечную таску пора завершать.
internal class Worker : BackgroundService
{
private readonly ILogger<Worker> _logger;
public Worker(
ILogger<Worker> 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");
}
}
Теперь посмотрим, как выглядит csproj, который позволяет собрать приложение с кодом, приведённым выше
Это «новый» формат проектов msbuild. Он выгодно отличается от того, что используется в старом Net Framework и Mono своей лаконичностью. Если раньше править csproj вручную было больно, то теперь в некоторых случаях это проще, чем использовать GUI.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Systemd" Version="8.0.0" />
</ItemGroup>
</Project>
dotnet/iot состоит из двух частей:
System.Device.Gpio — абстракции для работы с GPIO/I2C/SPI/PWM.
Iot.Device.Bindings — userspace драйверы конкретных устройств. Работает поверх System.Device.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 и увидел вот эту строку [13].
Давайте попробуем помигать светодиодом. Расширим класс Worker, приведённый выше:
internal class Worker : BackgroundService
{
private const int PinNum = 20; // PA20
private readonly ILogger<Worker> _logger;
private readonly GpioController _pinController;
private readonly GpioPin _pin;
public Worker(
ILogger<Worker> 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");
}
}
}
Выводных светодиодов в закромах не оказалось, поэтому пруф вот такой:
Для 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: -- -- -- -- -- -- -- --
из которого видно, что дисплей отвечает по адресу 0x3C, что соответствует документации на контроллер дисплея.
Драйвер дисплея, предоставляемый Iot.Device.Bindings, работает с битмапами. Оригинальный пример полагается на SkiaSharp. Пойдём по пути примера и доустановим пакет Iot.Device.Bindings.SkiaSharpAdapter.
Зарегистрируем адаптер, добавив следующий код в Main():
SkiaSharpAdapter.Register();
Также, установим пакеты в ОС:
sudo apt install libfontconfig1
Теперь Worker выглядит так:
internal class Worker : BackgroundService
{
private const int PinNum = 20; // PA20
private readonly ILogger<Worker> _logger;
private readonly GpioController _pinController;
private readonly GpioPin _pin;
private readonly Ssd1306 _display;
public Worker(
ILogger<Worker> 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);
}
}
}
Результат работы:
В 2024 году нет особого смысла использовать Mono, а .NET8 вполне себе отлично работает на ARM.
Пример кода из статьи в этом репозитории [14].
Пример относительно качественного деплоя в другом репозитории [12].
UPD: @NoobLoser [15] указал на неправильное понимание опции Retain VM
Автор: Роман Булдыгин
Источник [16]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-2/392759
Ссылки в тексте:
[1] OrangePi, как пример: http://www.orangepi.org/orangepiwiki/index.php/Third_Party_Images
[2] Мой старый вариант: https://habr.com/ru/articles/422141/
[3] VS .NET Linux Debugger: https://github.com/SuessLabs/VsLinuxDebug
[4] .NET Generic Host: https://learn.microsoft.com/en-us/dotnet/core/extensions/generic-host?tabs=appbuilder
[5] статья на Habr: https://habr.com/ru/articles/593441/
[6] вот эту страницу документации: https://learn.microsoft.com/en-us/dotnet/core/runtime-config/garbage-collector
[7] LibraryImport: https://learn.microsoft.com/en-us/dotnet/standard/native-interop/pinvoke-source-generation
[8] AdvancedDLSupport: https://github.com/Nihlus/AdvancedDLSupport
[9] dotnet/iot: https://github.com/dotnet/iot
[10] документации: https://docs.avaloniaui.net/docs/guides/platforms/rpi/running-on-raspbian-lite-via-drm
[11] Dear ImGui: https://github.com/ocornut/imgui
[12] OpenHD-WebUI: https://github.com/OpenHD/OpenHD-WebUI
[13] вот эту строку: https://elixir.bootlin.com/linux/v6.6.36/source/drivers/pinctrl/sunxi/pinctrl-sunxi.h#L19
[14] этом репозитории: https://github.com/buldo/dotnet-iot-example
[15] @NoobLoser: https://habr.com/ru/users/NoobLoser/
[16] Источник: https://habr.com/ru/articles/829086/?utm_source=habrahabr&utm_medium=rss&utm_campaign=829086
Нажмите здесь для печати.