В своей жизни я обожаю как минимум три вещи: это C# (как и .NET в целом), интересное железо и одноплатные компьютеры. В Embedded-системах на Linux обычно принято писать код на C/C++ для решения чувствительных к производительности задач и интерпретируемых Lua/Python для быстрого прототипирования, которые стали популярны в встраиваемых устройствах сравнительно недавно. Однако о нативной разработке под одноплатники на C# практически ничего не слышно и я решил исправить это недоразумение! В сегодняшнем материале: рассмотрим, какие платформы .NET нам доступны на одноплатниках, научимся работать с GPIO и SPI в юзерспейсе, а также напишем практическое приложение, которое реализовывает драйвер дисплея и выводит на экран определенное изображение.
❯ Предисловие
Одноплатники уже давно вошли в повседневную жизнь многих DIY-щиков, сисадминов и людей, которые интересуются мини-компьютерами. Казалось бы, одну и ту же задачу можно решить несколькими методами на самых разных языках: кто-то предпочитает писать нативный код на тех же плюсах, а особо прожженные — на Plain-C и ассемблере, стараясь получить максимальную производительность, а кто-то хочет сразу перейти к реализации своего устройства не заморачиваясь с подробным изучением того, как чип работает «под капотом» и какие шины существуют, ограничиваясь использованием готовых библиотек.
Но я лично очень люблю C# за его максимальную гибкость, позволяющую оптимизировать некоторые обращения к памяти путем получения прямых указателей на данные, умеет в удобные темплейты, а также имеет механизм для маршаллинга (прямой импорт функций из библиотек, возможность создать нативный трамплин на управляемый делегат, возможность быстрого копирования из unmanaged в managed окружение и т. п.). Потому всегда думал: почему бы его не использовать в своих embedded-проектах на базе одноплатников?
Сейчас .NET можно накатить на большинство современных одноплатников, за исключением самых слабых с 64Мб ОЗУ «бутербродом» на чипе (AllWinner F1C100s, AllWinner V3s, некоторые MStar и т. п.). Доступно два рантайма, которые предлагают разные профили и соответственно, разный функционал.
- dotnet — официальный рантайм, который реализует профиль .NET Core (ой, простите, так уже не модно, теперь это просто .NET). Предоставляет весь современный базовый функционал дотнета вкупе с современными версиями самого C#, но в нём нет, например, Windows Forms для UI (если вы используете полноценные «иксы» и GTK), и System.Drawing для обработки графики и отрисовки текста. Это эталонная реализация дотнета и его можно без проблем накатить на любой одноплатник, для которого есть достаточно свежий Linux.
- Mono — альтернативная реализация .NET Framework для Linux, ранее активно использовалась в Unity. В отличии от .NET Core, может работать и на более старых одноплатниках на прошлых версиях дистрибутивов Linux, в том числе и самой первой Raspberry Pi. Считается более медленной, чем dotnet, зато имеет значительно большую функциональность, почти идентичную фреймворку на Windows.
В сегодняшней статье мы будем писать программу на C# для OrangePi One, которая должна инициализировать дисплей из юзерспейса и выводить на него определенные данные. В качестве профиля используем .NET Framework 4 (да, я порой старомоден), а одноплатником выступит OrangePi One в стоковой конфигурации ядра, без правок devicetree, где по умолчанию у нас доступен spidev без аппаратных чипселектов, доступ к GPIO из /sys/ и i2cdev.
❯ Настраиваем окружение
Для начала нам нужен образ системы для нашего одноплатника. Какой — выбирать вам. Для большинства устройств на чипсетах AllWinner доступны образы с ядром 3.x, которые более стабильны, но не используют devicetree и не входят в мейнлайн и 5.x, так называемый мейнлайн, но там всё ещё есть некоторые нюансы. Я выбрал Ubuntu Xenial с ядром 5.3.5.
Теперь самое время накатить рантайм, что мы и делаем командой:
apt-get install mono-all
Обратите внимание, Mono громоздкий и с учетом всех зависимостей может устанавливаться минут 30, если у вас достаточно медленная флэшка. Всё, теперь устройство готово к запуску программ на дотнете, нашу программу можно запустить следующей командой:
mono assembly.exe
Давайте же перейдём к фактической реализации нашей программы и узнаем как работать с периферией устройства!
❯ GPIO
Начинаем с GPIO или «ногодрыга». В Linux есть удобный интерфейс, позволяющий экспортировать пины общего назначения в юзерспейс и рулить ими прямо из sysfs, в том числе и из терминала! Для реализации софтварного SPI или быстрого опроса цифровых пинов такой способ не подойдет — слишком большой оверхед, но для моргания светодиодами, обработки кнопок или… программного ногодрыга чипселектом — вполне подойдет 🙂
Как я и говорил выше, GPIO сначала нужно сделать видимым в sysfs — т. е. экспортировать, путём записи номера нужного пина в «файл» /sys/class/gpio/export. Посчитать ID нужного пина можно с помощью простой формулы: (позиция буквы в алфавите — 1) * 32 + номер пина. То есть, для PA10 ID будет 10. При ошибке, системный вызов close выбросит ошибку, а поток в C# — IOException.
public static bool Export(int id)
{
try
{
File.WriteAllText("/sys/class/gpio/export", id.ToString());
Log.WriteLine("Exported GPIO {0}", id);
return true;
}
catch (IOException e)
{
Log.WriteLine("Failed to export GPIO, assuming they are already exported");
return false;
}
}
После этого, по пути /sys/class/gpio/gpio10/ появится директория с файлами direction, куда нужно записать направление нашего пина («in» — ввод, «out» — вывод) и value, куда мы будем записывать или читать значение пина. Реализовать управление пином можно так:
public GPIO(int id, bool isOutput)
{
Mode = isOutput;
basePath = string.Format("/sys/class/gpio/gpio{0}/", id);
if (!Directory.Exists(basePath))
throw new ArgumentException("GPIO not available");
File.WriteAllText(basePath + "direction", Mode ? "out" : "in");
}
public bool ReadValue()
{
return File.ReadAllText(basePath + "value") == "1";
}
public void SetValue(bool val)
{
state = val;
File.WriteAllText(basePath + "value", val ? "1" : "0");
}
Да, всё так просто! Мигалка светодиодом в нашем случае будет выглядеть так:
GPIO.Export(10);
GPIO gpio = new GPIO(10, true);
while(true)
{
gpio.SetValue(true);
Thread.Sleep(1000);
gpio.SetValue(false);
Thread.Sleep(1000);
}
Переходим к чему посложнее, а именно к SPI из всё того-же юзерспейса!
❯ SPI
Для управления SPI нам потребуется вызов ioctl, который позволяет отправлять устройству различные пакеты с описанием команд. Для этого нам пригодится PInvoke:
internal sealed class NativeCalls
{
public struct SPIDevIOCTransfer
{
public ulong txBufPointer;
public ulong rxBufPointer;
public uint length;
public uint speedHz;
public ushort delayUsecs;
public byte bitsPerWord;
public byte csChange;
public uint pad;
}
[DllImport("libc", EntryPoint = "ioctl", CallingConvention = CallingConvention.Cdecl, SetLastError = true)]
public static extern int ioctl(IntPtr handle, uint request, IntPtr data);
}
Для каждой аппаратной шины SPI создаётся одно устройство spidev. В случае OrangePi One, по умолчанию экспортирована только одна шина (поскольку и SPI-контроллер на гребенке лишь один) — spidev0.0. Для начала открываем наше устройство для записи:
public SPIBus(string debugName)
{
DebugName = debugName;
device = File.OpenWrite("/dev/spidev0.0");
}
Драйвер spidev работает по принципу транзакций — вы посылаете IOCTL с запросом SPI_IOC_MESSAGE (в оригинале это макрос с возможностью послать сразу несколько транзакций в драйвер) и указателем на структуру spi_ioc_transfer с описанием отправляемых или получаемых данных, а драйвер уже сам решает что и когда отправить, при этом вызов ioctl — блокирующий, то есть управление в поток вернется только когда драйвер завершит работу. Но есть нюанс — драйвер SPI у чипсетов AllWinner не может отправлять более 128-байт (на AllWinner A10/A13 — 64-байт) данных за транзакцию, поэтому большой массив данных придётся разбивать на несколько мелких:
public unsafe bool Transfer(byte[] data)
{
NativeCalls.SPIDevIOCTransfer transferDesc = new NativeCalls.SPIDevIOCTransfer();
fixed (void* ptr = &data[0]) // Запрещаем GC перемещать массив data и получаем указатель на первый элемент
{
int numTrans = data.Length / 128;
bool isSucceded = true;
// Разбиваем данные на несколько транзакций
for(int i = 0; i < numTrans; i++)
{
transferDesc.txBufPointer = ((ulong)ptr) + ((ulong)(i * 128)); // Считаем смещение в нашем массиве байтов в буфере для отправки
transferDesc.bitsPerWord = 8; // Указываем число битов в одном машинном слове (8 для AllWinner)
transferDesc.length = 128; // Указываем длину транзакции
transferDesc.speedHz = DesiredFrequency; // Указываем желаемую максимальную скорость транзакции. Это не значит, что SPI-контроллер именно с такой скоростью будет отправлять и получать данные.
if (i == numTrans - 1)
transferDesc.length = (uint)(data.Length - (i * 128)); // Иногда размер последней транзакции не кратен всему массиву данных и длину нужно обрезать - иначе ioctl вылезет за границы массива и это приведет к SEGFAULT.
isSucceded = NativeCalls.ioctl(device.SafeFileHandle.DangerousGetHandle(), IOCTLMessage, new IntPtr(&transferDesc)) >= 0; // Наконец-то, отправляем транзакцию в драйвер. DangerousGetHandle получает десктриптор файла в Linux.
if (!isSucceded)
break;
}
return isSucceded;
}
}
Уже в шоке от обилия указателей в коде на шарпе? 🙂 Надеюсь, комментарии помогут вам разобраться.
Тоже самое и для чтения данных с шины, только вместо txBufPointer — rxBufPointer.
public unsafe void Receive(byte[] data, int length)
{
NativeCalls.SPIDevIOCTransfer transferDesc = new NativeCalls.SPIDevIOCTransfer();
fixed(byte* ptr = &data[0])
{
transferDesc.rxBufPointer = (ulong)ptr;
transferDesc.bitsPerWord = 8;
transferDesc.length = (uint)length;
transferDesc.speedHz = DesiredFrequency;
if (NativeCalls.ioctl(device.SafeFileHandle.DangerousGetHandle(), IOCTLMessage, new IntPtr(&transferDesc)) < 0)
throw new ArgumentException("Receive failed");
}
}
Пример работы прост до безобразия:
SPI spi = new SPI("test");
spi.Transfer(0x04);
spi.Receive(id, id.Length);
Console.WriteLine("{0} {1} {2} {3}", id[0], id[1], id[2], id[3]);
Имея GPIO и SPI уже можно переходить к реализации чего-то более конкретного!
❯ Дисплей
В качестве дисплея я буду использовать стандартную дешёвую 2.4" матрицу с разрешением 240x320 и контроллером ST7789 с интерфейсом SPI. Для использования дисплея с питанием 3.3В нужно поставить перемычку на позиции J1.
Для подключения такого дисплея, достаточно всего лишь 4 (5, если нужен чипселект) сигнальные линии на 40-пиновой гребенке RPi One, плюс один для ШИМ (если нужно регулировать подсветку) и два на питание. Обратите внимание, что лучше сдуть гребенку и паяться к одноплатнику напрямую — у меня из-за китайских дюпонтов постоянно помехи на дисплее и мусор на шине.
Схема подключения:
VCC -> 3.3V
GND -> Масса
CS -> PA9
RESET — PA10
D/C — PA20
MOSI — PC0
SCK — PC2
LED -> 3.3V
Начинаем с подготовки необходимых GPIO. Для управления дисплеем всегда нужен аппаратный RESET и D/C (бит команда/данные). Чипселект необязателен (его можно кинуть на массу), если это будет единственное устройство на шине, однако в случае ST7789 почему-то в таком случае нужно использовать SPI MODE 3.
private void PrepareGPIO()
{
const int DC = 10, Reset = 20, CS = 9;
GPIO.Export(DC);
GPIO.Export(Reset);
GPIO.Export(CS);
gpioChipSelect = new GPIO(CS, true);
gpioDataCommand = new GPIO(DC, true);
gpioReset = new GPIO(Reset, true);
gpioChipSelect.SetValue(true);
}
Переходим к реализации коммуникации с дисплеем. Здесь всё просто — ставим CS в низкий уровень, начиная транзакцию, устанавливаем D/C в низкий уровень в случае команды, либо высокий в случае данных и отправляем байт контроллеру, после чего устанавливаем чипселект обратно в высокий уровень.
private void SendCommand(byte cmd)
{
// Acquire bus for transaction
gpioChipSelect.SetValue(false);
gpioDataCommand.SetValue(false);
Board.SPI.Transfer(cmd);
// Release bus
gpioChipSelect.SetValue(true);
}
private void SendData(byte data)
{
gpioChipSelect.SetValue(false);
gpioDataCommand.SetValue(true);
Board.SPI.Transfer(data);
gpioChipSelect.SetValue(true);
}
Теперь дисплей нужно инициализировать. Здесь нужно сконфигурировать регистры контроллера дисплея для установки режима адресации, цветности и порядка байт в пикселях (BGR или RGB).
// Reset LCM
gpioReset.SetValue(false);
Thread.Sleep(10);
gpioReset.SetValue(true);
SendCommand(0x01); //SWRESET
SendCommand(0x11); //SLPOUT
SendCommand(0x3A); //COLMOD RGB444(12bit) 0x03, RGB565(16bit) 0x05,
SendData(0x05); //RGB666(18bit) 0x06
SendCommand(0x36); //MADCTL
SendData(0x14); //0x08 B-G-R, 0x14 R-G-B
SendCommand(0x20); //INVON
SendCommand(0x13); //NORON
SendCommand(0x29); //DISPON
Если всё сделано правильно — то после этого вы должны увидеть «мусор» на дисплее, поскольку состояние ОЗУ не определено после подачи питания на контроллер (но при сбросе содержимое DRAM останется на месте).
Теперь нам надо установить границы нашего изображения, в пределах которых работает автоинкермент контроллера дисплея. Нужно это для того, чтобы мы могли, например, пнуть уже готовую картинку в DMA-контроллер и уйти заниматься своими делами, а когда картинка отправилась — установить новые границы и нарисовать что-то ещё. В моём случае, всё рисование производится во второй буфер, который затем рисуется на дисплей — поэтому мне нужны размеры всего дисплея сразу:
SendCommand(0x2A); // Set X address
SendData(0);
SendData(0);
SendData((byte)(w >> 8));
SendData((byte)w);
SendCommand(0x2B); // Set Y address
SendData(0);
SendData(0);
SendData((byte)(h >> 8));
SendData((byte)h);
SendCommand(0x2C);// Start display
После этого, достаточно лишь непрерывно слать изображение на контроллер дисплея и всё будет работать!
public unsafe void CopyFrom(Bitmap bitmap)
{
gpioChipSelect.SetValue(false);
gpioDataCommand.SetValue(true);
if (!Board.SPI.Transfer(bitmap.Data))
throw new ArgumentException("Failed to copy bitmap on to screen");
gpioChipSelect.SetValue(true);
}
Поскольку ни один формат изображений не соответствовал моим требованиям (RGB565, без выравнивания), я быстренько накостылил конвертер в самопальный:
private static unsafe void WriteBitmap(Bitmap bmp, Stream strm)
{
BinaryWriter binWriter = new BinaryWriter(strm);
// Write header
binWriter.Write((short)0x1337);
binWriter.Write((short)bmp.Width);
binWriter.Write((short)bmp.Height);
binWriter.Write((short)0); // 0 - RGB565, fixed at this moment
var data = bmp.LockBits(new Rectangle(0, 0, bmp.Width, bmp.Height), System.Drawing.Imaging.ImageLockMode.ReadOnly, System.Drawing.Imaging.PixelFormat.Format16bppRgb565);
byte[] pixels = new byte[bmp.Width * bmp.Height * 2];
byte* ptr = (byte*)data.Scan0.ToPointer();
// Quick endianness swap
for(int i = 0; i < bmp.Width * bmp.Height; i++)
{
pixels[i * 2] = ptr[i * 2 + 1];
pixels[i * 2 + 1] = ptr[i * 2];
}
bmp.UnlockBits(data);
binWriter.Write(pixels, 0, pixels.Length);
}
static void Main(string[] args)
{
if(args.Length < 1)
{
Console.WriteLine("Usage: Image2Bmp filename");
return;
}
Bitmap bmp = (Bitmap)Image.FromFile(args[0]);
FileStream strm = File.Create(Path.GetFileNameWithoutExtension(args[0]) + ".bitmap");
Console.WriteLine("Converting bitmap {0}", args[0]);
WriteBitmap(bmp, strm);
strm.Close();
}
Загрузчик такого формата выглядит так:
public static unsafe Bitmap Load(string fileName)
{
// Read header
Stream strm = File.OpenRead(fileName);
BinaryReader binReader = new BinaryReader(strm);
BmpHeader hdr = new BmpHeader()
{
Header = binReader.ReadInt16(),
Width = binReader.ReadInt16(),
Height = binReader.ReadInt16(),
Format = binReader.ReadInt16()
};
Bitmap bitmap = new Bitmap(hdr.Width, hdr.Height);
binReader.Read(bitmap.Data, 0, bitmap.Data.Length);
return bitmap;
}
А фактическое использование — так:
Bitmap bg = BmpLoader.Load("test.bitmap");
LCD lcd = new LCD();
while(true)
{
lcd.CopyFrom(bg);
}
❯ Заключение
Как мы видим, писать программы для одноплатников на C# отнюдь не сложно и можно пользоваться всеми приятными фишками языка. Часть кода из этой статьи выдрана из моего сайд-проекта, о котором хочу рассказать вам в ближайшее время — поэтому местами код совсем не причесан, но надеюсь — всё было понятно 🙂
Исходный код для сегодняшнего материала можно найти здесь.
Также у меня есть канал в Telegram, куда я выкладываю посты с тематикой DIY, ремонта и моддинга, а также программирования под гаджеты прошлых лет и вовремя ссылки на новые статьи.
Читайте также:
А ещё я держу все свои мобилы в одной корзине при себе (в смысле, все проекты у одного облачного провайдера) — Timeweb. Потому нагло рекомендую то, чем пользуюсь сам — вэлкам: