.NET on SBC is as canonical as possible
Introduction
There's always someone wrong on the internet. This time, in my most biased opinion, @bodyawm was wrong.
For me, .NET is a jack of all trades, so I was fooled into using Mono in 2024. In this article, I will show my vision of how to write in .NET for GNU/Linux and SBC (Single-board computer, aka single-board computer) in the most canonical and modern way.
1. On the issue of choosing a platform
At the moment, single-board computers on X86, ARM and RISC-V are quite widespread and available for purchase to hobbyists (MIPS can be found, but it is difficult and not mainstream).
If your project requires multimedia and you don't need GPIO, choose X86 and save yourself the hassle of dealing with hardware video codec drivers and GPUs. Especially since there are currently a large number of both single-board and ready-made mini PCs with X86.
If you need ARM and high performance, pay attention to the Rockchip RK3588 platform. Single-board systems with even 32GB of memory and NVMe boot are available for sale.
If you need ARM and don't want headaches, choose RaspberryPI.
Using .NET on RISC-V is still Terra incognita for me.
So this article will be about using .NET on ARM (including ARM64).
1.1 Selecting a distribution
If you have a Raspberry PI or a board on RK3588, you can try the ARM version of Windows. But in all other cases, GNU/Linux is almost the only alternative.
If you have a Raspberry, the default option is RaspberryPI OS.
In other cases, there are usually two options – a distribution from the board manufacturer and a third-party distribution.
1.1.1 System from the manufacturer
These images are most often based on the BSP (Board Support Package) from the SoC manufacturer. As I understand it, the SoC manufacturer primarily provides Android support. Therefore, the kernel version number will most likely be the one that was relevant for Android at the time the SoC was released. This has both pros and cons.
Pros:
most likely there will be maximum support for all SoC hardware blocks;
Most likely there will be “out of the box” support for branded accessories such as cameras, displays, etc.
Minuses:
probably will be an old or very old kernel. If the kernel version is 6.*, then it is unspeakable happiness;
many blobs. Closed binaries from SoC manufacturer for GPU and various HW blocks;
“murky” Chinese package repositories. For some, this may be a problem;
very often it is an “old” distribution, like Ubuntu 18.
1.1.2 Third-party distribution
When we say “Third-party distribution”, we mean Armbian. Of course, this is not always the case. There are many different distributions for SBC, such as DietPi. Some SBC manufacturers maintain databases of third-party distributions. OrangePi as an example.
Pros:
often you can choose between the kernel from the BSP and the mainline;
builds with fresh mainline kernel are available;
package repositories can be considered more reliable because the community is larger;
if there is no support for your specific SBC, then it is relatively easy to add;
the latest versions of the OS (and with them software and libraries).
Minuses:
there may be problems with GPU or hardware decoders;
mainline kernels may not support all functionality;
The configuration utilities are most likely as universal as possible and are not “tailored” to a specific board.
1.2 Notes in the Margins
The “heart” of third-party distributions is the image build system. For example, in Armbian, a new board is added by adding a text config. So, if Armbian does not support your SBC, but supports similar ones, there is a high chance that you can implement this support yourself.
2. Theory on .NET
This section lists, in a rather haphazard manner, various topics and tips that are useful if you are writing an application for SBC.
Think of it as a directory of keywords you can Google if you want.
2.1 .NET Application Development Environment for SBC
If you have something on RK3588, you can simply install the distribution with the desktop environment, launch VS Code and not worry about it.
In this article, we will proceed from the fact that a low-end single-board computer is used on some ancient, but super-cheap Allwinner H3. On such a SoC, even just assembling HelloWorld will not be a fast process. Therefore, it is highly desirable to develop and assemble on a separate machine for development, aka BB (Big Brother, if you are an old-timer). This in turn leads us to the need to use a remote debugger and automated copying of binaries to the SBC.
The options for convenient development that I know of are:
Extension for VS Code .NET FastIoT. Article on Habr.
My old version.
Extension for VS VS .NET Linux Debugger.
One way or another, it all comes down to building the application on a powerful machine, copying it to the SBC and connecting with a remote debugger.
2.2 Application Model and GenericHost
If the task is to create some simple utility, then the application will not differ in any way from the usual HelloWorld – we parse (preferably with a ready-made one) the launch arguments, and use as a log Console.WriteLine()
.
However, since we are talking about an application for SBC, then most likely the application will run in the background as a daemon.
Of course, you can write
while(true)
{
// Делаем всю работу
}
but this is not the way of the samurai from .NET.
It would be correct to use .NET Generic Host. This thing provides basic services such as DI, logging, configuration, working with the application life cycle (tracking application shutdown), integration with the system (for example, with systemd). We will consider the use in more detail in the practical part of the article.
2.3 Self-hosted
A thing known to many .NET developers. But sometimes it seems like the rest of the world is frozen in the times of Framework 3.5 and still doesn't know that runtime can be shipped with the application and that it is a standard feature.
2.4 Trimming
The application can be trimmed (with great restrictions). The build system can throw out unused code from the application and, apparently, the runtime. Thus, your application will become lighter.
2.5 Writing effectively
Performance issues on weak hardware are much more pronounced than usual.
It should be remembered that we not only have a weak CPU, but also very slow memory. That is, the problem will not be with memory allocation – .NET does this faster than C++, but with memory copying and GC.
This leads to the following obvious recommendations:
Less copying.
Chances are high that you will be working with byte arrays. Try to useSpan<T>
AndMemory<T>
.Fewer creatures.
We use packagesSystem.Buffers
(System.Buffers.ArrayPool<T>
) AndMicrosoft.Extensions.ObjectPool
(ObjectPool<T>
), if your application chews some data in real time, for example RTP packets or video frames, and you desperately need objects.Try vector extensions.
System.Runtime.Intrinsics
provides access to extensions for ARM as well.Use Pinned Object Heap (article on Habr) for data that goes to unmanaged – buffers for network operations, audio frames that are transferred to the OS, etc.
The only thing slower than the memory on an SBC is the disk subsystem. In the hobby segment, you will probably work with an SD card with 90% probability.
2.6 Tuning GC
First of all, we read here is this page of the documentation.
As usual, the main thing is to choose the GC type – Server or Workstation. If you write a daemon, this does not automatically mean that you need a server GC. Everything depends on the load profile.
The following additional options may be of interest:
Retain VM – whether to return freed heap chunks back to the OS. Default is false;
Dynamic Adaptation To Application Sizes (DATAS) – heap size adaptation for server GC.
2.7 We look at Native AOT with doubt
On the one hand, you can get a native binary that will start up quickly. On the other hand, the application may end up running slower, because you will lose Tiering and Dynamic PGO. LINQ will also become slower.
That is, start from your needs.
2.8 Modern P/Invoke
To interact with the system, you will need to either find a ready-made nuget package or write bindings to system libraries yourself. For a long time, the attribute was used for this DllImport
. However, now we have more options:
LibraryImport – a new attribute that was brought in .NET7. It uses code generation, promises better AOT compatibility and overall better performance.
AdvancedDLSupport – a super library, which, to my regret, has not been updated for two years.
2.9 Using System.Device.Gpio and Iot.Device.Bindings
Repository dotnet/iot contains ready-made classes for working with various peripheral devices that can be connected via I2C, SPI, etc.
2.10 GUI
One of the tasks encountered in SBC is the implementation of the Human-Machine Interface (HMI).
In such situations, the SBC runs a single full-screen application. This allows you to do away with X11/Wayland/window managers and draw the interface directly using the Direct Rendering Manager (DRM).
Rejoice, old-school WPF developers, because Avalonia can do this via a package Avalonia.LinuxFrameBuffer
. Details in documentation.
However, if you lack performance or need more control over what's going on, you can always use a lower-level one. Dear ImGui for which there are .NET bindings.
3. Practical example
Before you read further
If you are interested in a .NET application that runs in production on SBC, take a look at the repository OpenHD-WebUI. This is a WebUI that runs on various SBCs used for DIY FPV systems. The repository has CI (build and publish deb packages) configured, and there is an example of a unit file for systemd.
Let's get back to practice
Let me take my worst ARM SBC, the OrangePi Lite 1G, as an example.
SoC: Allwinner H3 — 4 cores Cortex‑A7(ARMv7, NEON, VFP4), Mali400 MP2
RAM: 1GB DDR3
Armbian offers a build of Debian 12 (Bookworm) with Linux 6.6. Download the minimal image, flash it to microSD using Balena Etcher and you're done.
This SBC does not have Ethernet. To avoid searching for a monitor and keyboard to configure WiFi, I simply connected a USB-Ethernet adapter.
Just in case, let's install the configuration utility:
sudo apt update && sudo apt install armbian-config
I will not disclose remote debugging in practice, since I have already done this (see p. 2.1).
Let's start writing/analyzing the code.
3.1 Example of a minimal Main
We use GenericHost
so that our application runs normally both as a regular console application and as a systemd service. As far as I understand, systemd can communicate with applications via D-Bus, in order to find out that the application is ready for work, etc. Package Microsoft.Extensions.Hosting.Systemd
just implements this functionality of integration with 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();
}
}
3.2 Example of a minimal BackgroundService
Thanks to DI, we can get interface implementations through the constructor.
The example uses a logger (for logging) and IHostApplicationLifetime
(so that they can subscribe to application lifecycle events).
Method ExecuteAsync
generates Task
which will spin in the background.CancellationToken
which is transmitted to ExecuteAsync
allows you to track when it's time to finish an endless task.
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");
}
}
3.3 Example of a minimal csproj
Now let's see what csproj looks like, which allows you to build an application with the code given above
This is the “new” msbuild project format. It compares favorably with what is used in the old Net Framework and Mono in its brevity. If you edited earlier csproj
It used to be painful to do it manually, but now in some cases it's easier than using the 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>
3.4 Attaching dotnet/iot
dotnet/iot
consists of two parts:
System.Device.Gpio
– abstractions for working with GPIO/I2C/SPI/PWM.Iot.Device.Bindings
– userspace drivers for specific devices. Works on top ofSystem.Device.Gpio
.
3.4.1 Working with GPIO
The entry point for working with gpio and buses in System.Device.Gpio
is a class Board
and its heirs. There are two ready-made implementations of this class – RaspberryPiBoard
And GenericBoard
. We will use GenericBoard
– this implementation will try to find a suitable way to work with GPIO – true libgpiod
or the outdated method via sysfs
. Since Linux 4.8 sysfs
interface for working with GPIO is considered obsolete. So it is highly likely that everything will work through libgpiod
.
Let's install it libgpiod
:
sudo apt install libgpiod-dev gpiod
Let's see which GPIOs are available for control:
sudo gpioinfo
In my case, two gpiochips are available – on 224 and 32 lines. The chip on 224 lines is exactly the GPIO of our SoC. All ports are in order with an offset of 32 lines. Why did I decide so? I looked into the Linux sources and saw this line.
Let's try blinking an LED. Let's expand the class Worker
given above:
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");
}
}
}
There were no output LEDs in stock, so here is the proof:
3.4.2 Working with I2C
For I2C and SPI buses System.Device.Gpio
also already has in its composition all the necessary methods for work.
Let's take advantage armbian-config
to activate the bus i2c0
.
It is also worth installing i2c-tools
to simplify diagnostics:
sudo apt install i2c-tools
After connecting the display, we will double-check its address by running the command:
sudo i2cdetect 0
We get the following conclusion:
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: -- -- -- -- -- -- -- --
from which it can be seen that the display responds to address 0x3C, which corresponds to the documentation for the display controller.
Display driver provided by Iot.Device.Bindings
works with bitmaps. The original example relies on SkiaSharp
. Let's follow the example path and install the package. Iot.Device.Bindings.SkiaSharpAdapter
.
Let's register the adapter by adding the following code to Main()
:
SkiaSharpAdapter.Register();
Also, let's install packages in the OS:
sudo apt install libfontconfig1
Now the Worker looks like this:
Worker is already long enough to hide under a spoiler
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);
}
}
}
Result of work:
4. Results
In 2024, there's not much point in using Mono, and .NET8 runs just fine on ARM.
Example code from the article in in this repository.
An example of a relatively high-quality deployment in other repositories.