Привет!
В этой статье я бы хотел рассказать об особенностях реализации графического пользовательского интерфейса с виджетами на микроконтроллере и как при этом иметь и привычный пользовательский интерфейс и приличный FPS. Внимание я хотел бы акцентировать не на какой-то конкретной графической библиотеке, а на общих вещах — память, кэш процессора, dma и так далее. Поскольку я являюсь разработчиком команды Embox, приведенные примеры и эксперименты будут на данной ОС РВ.
Ранее мы уже рассказывали про запуск библиотеки Qt на микроконтроллере. Получилась достаточно плавная анимация, но при этом затраты по памяти даже на хранение прошивки были существенными — код исполнялся из внешней флэш памяти QSPI. Конечно, когда требуется сложный и многофункциональный интерфейс, который еще и анимацию какую-то умеет делать, то затраты по аппаратным ресурсам могут быть вполне оправданы (особенно если у вас уже есть этот код, разработанный под Qt).
Но если вам не нужна вся функциональность Qt? Что если у вас четыре кнопки, один регулятор громкости и пара popup меню? При этом хочется, чтобы “выглядело красиво и работало быстро” :) Тогда будет целесообразным использовать более легковесные средства, например библиотеку lvgl или аналогичную.
У нас в проекте Embox некоторое время назад был портирован Nuklear — проект по созданию очень легковесной библиотека, состоящая из одного хедера и позволяющий легко создавать несложный GUI. Его мы и решили использовать для создания небольшого приложения в котором будет виджет с набором графических элементов и которым можно было бы управлять через touchscreen.
В качестве платформы выбрали STM32F7-Discovery c Cortex-M7 и сенсорным экраном.
Первые оптимизации. Экономия памяти
Итак, графическая библиотека выбрана, платформа тоже. Теперь поймем что по ресурсам. Тут стоит отметить, что основная память SRAM в разы быстрей внешней SDRAM, поэтому если вам позволяют размеры экраны, то конечно лучше положить фреймбуфер в SRAM. Наш экран имеет разрешение 480x272. Если мы хотим цвет в 4 байта на пиксель, то получается порядка 512 Кб. При этом размер внутреннего RAM всего 320 и сразу понятно, что видеопамять будет внешней. Другой вариант — уменьшить битность цвета до 16 (т.е. 2 байта), и таким образом сократить расход памяти до 256 Кб, что уже может влезть в основную RAM.
Первое что можно попробовать — сэкономить на всем. Сделаем видео буфер на 256 Кб, разместим его в RAM и будем в него же и рисовать. Проблема, с которой сразу же столкнулись — это “мерцание” сцены возникающее если рисовать напрямую в видеопамять. Nuklear перерисовывает всю сцену “с нуля”, поэтому каждый раз сначала выполняется заливка всего экрана, далее рисуется виджет, потом в него кладется кнопка, в которую помещается текст и так далее. Как следствие, невооруженным взглядом заметно, как вся сцена перерисовывается и картинка “мигает”. То есть простое помещение во внутреннюю память не спасает.
Промежуточный буфер. Компиляторные оптимизации. FPU
После того как мы немного повозились с предыдущим способом (размещением во внутренней памяти), в голову сразу стали приходить воспоминания об X Server и Wayland. Да, действительно, по сути оконные менеджеры и занимаются тем, что обрабатывают запросы от клиентов (как раз наше пользовательское приложение), и далее собирают элементы в итоговую сцену. К примеру, ядро Линукса посылает серверу события от input устройств через драйвер evdev. Сервер, в свою очередь, определяет кому из клиентов адресовать событие. Клиенты, получив событие (например, нажатие на сенсорном экране) выполняют свою внутреннюю логику — подсвечивают кнопку, отображают новое меню. Далее (немного по-разному для X и Wayland) либо сам клиент, либо сервер производит отрисовку изменений в буфер. И затем компоновщик (compositor) уже соединяет все кусочки воедино для отрисовки на экран. Достаточно просто и схематичное объяснение вот здесь.
Стало ясно, что нам нужна похожая логика, вот только запихивать X Server в stm32 ради маленького приложения очень уж не хочется. Поэтому попробуем просто рисовать не в видео память, а в обычную память. После отрисовки всей сцены будет копировать буфер в видео память.
if (nk_begin(&rawfb->ctx, "Demo", nk_rect(50, 50, 200, 200),
NK_WINDOW_BORDER|NK_WINDOW_MOVABLE|
NK_WINDOW_CLOSABLE|NK_WINDOW_MINIMIZABLE|NK_WINDOW_TITLE)) {
enum {EASY, HARD};
static int op = EASY;
static int property = 20;
static float value = 0.6f;
if (mouse->type == INPUT_DEV_TOUCHSCREEN) {
/* Do not show cursor when using touchscreen */
nk_style_hide_cursor(&rawfb->ctx);
}
nk_layout_row_static(&rawfb->ctx, 30, 80, 1);
if (nk_button_label(&rawfb->ctx, "button"))
fprintf(stdout, "button pressedn");
nk_layout_row_dynamic(&rawfb->ctx, 30, 2);
if (nk_option_label(&rawfb->ctx, "easy", op == EASY)) op = EASY;
if (nk_option_label(&rawfb->ctx, "hard", op == HARD)) op = HARD;
nk_layout_row_dynamic(&rawfb->ctx, 25, 1);
nk_property_int(&rawfb->ctx, "Compression:", 0, &property, 100, 10, 1);
nk_layout_row_begin(&rawfb->ctx, NK_STATIC, 30, 2);
{
nk_layout_row_push(&rawfb->ctx, 50);
nk_label(&rawfb->ctx, "Volume:", NK_TEXT_LEFT);
nk_layout_row_push(&rawfb->ctx, 110);
nk_slider_float(&rawfb->ctx, 0, &value, 1.0f, 0.1f);
}
nk_layout_row_end(&rawfb->ctx);
}
nk_end(&rawfb->ctx);
if (nk_window_is_closed(&rawfb->ctx, "Demo")) break;
/* Draw framebuffer */
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);
memcpy(fb_info->screen_base, fb_buf, width * height * bpp);
В этом примере создается окно размером 200 x 200 пикселей, в него отрисовываются графические элементы. Сама итоговая сцена рисуется в буффер fb_buf, который мы выделили SDRAM. А далее в последней строчке просто вызывается memcpy. И все повторяется в бесконечном цикле.
Если просто собрать и запустить этот пример, получим порядка 10-15 FPS. Что конечно не очень хорошо, ведь заметно даже глазом. Причем поскольку в коде рендера Nuklear много вычислений с плавающей точкой, ее поддержку мы включили изначально, без нее FPS был бы еще ниже. Первая и самая простая (бесплатная) оптимизация, конечно, флаг компилятора -O2.
Соберем и запустим тот же самый пример — получим 20 FPS. Уже лучше, но все равно недостаточно для хорошей работы.
Включение кэшей процессора. Режим Write-Through
Перед тем как перейти к дальнейшим оптимизациям, скажу что мы используем плагин rawfb в составе Nuklear, который как раз и рисует напрямую в память. Соответственно, оптимизация работы с памятью выглядит очень перспективно. Первое что приходит на ум — это cache.
В старших версиях Cortex-M, таких как Cortex-M7 (наш случай), встроен дополнительный кэш процессора (кэш инструкций и кэш данных). Он включается через регистр CCR блока System Control Block. Но с включением кэша приходят новые проблемы — несогласованность данных в кэше и памяти. Есть несколько способов управления кэшем, но в этой статье я не буду на них останавливаться, поэтому перейду к одному из самых простых, на мой взгляд. Чтобы решить проблему несогласованности кэша и памяти можно просто пометить всю доступную нам память как “некэшируемую”. Это означает, что все записи в эту память будут всегда проходить в память, а не в кэш. Но если мы таким способом пометим всю память, то и от кэша смысла не будет. Есть еще один вариант. Это “сквозной” режим, при котором все записи в память помеченную как write through попадают одновременно как в кэш, так и в память. Это создает накладные расходы на запись, но с другой стороны, сильно ускоряет чтение, поэтому результат будет зависеть от конкретного приложения.
Для Nuklear’а write-through режим оказался очень хорош — производительность поднялась с 20 FPS до 45 FPS, что само по себе уже достаточно хорошо и плавно. Эффект конечно интересный, мы даже пробовали отключать write through режим, не обращая внимания на несогласованность данных, но FPS поднимался лишь до 50 FPS, то есть значительного прироста по сравнению с write through не наблюдалось. Отсюда мы сделали вывод, что для нашего приложения требуются много именно операций чтения, а не записи. Вопрос конечно откуда? Возможно, из-за количества преобразований в коде rawfb, которые часто обращаются в память за чтением очередного коэффициента или что-то в этом роде.
Двойная буферизация (пока с промежуточным буфером). Включение DMA
Останавливаться на 45 FPS не хотелось, поэтому решили поэкспериментировать дальше. Следующей идей была двойная буферизация. Идея широко известная, и в общем-то нехитрая. Отрисовываем сцену с помощью одного устройства в один буфер, а другое устройство в это время выводит на экран из другого буфера. Если посмотреть на предыдущий код, то хорошо виден цикл, в котором сначала в буфер рисуется сцена, а затем с помощью memcpy содержимое копируется в видео память. Понятно, что memcpy использует CPU, то есть отрисовка и копирование происходят последовательно. Наша идея была в том, что копирование можно делать параллельно с помощью DMA. Другими словами, пока процессор рисует новую сцену, DMA копирует предыдущую сцену в видеопамять.
Memcpy заменяется следующим кодом:
while (dma_in_progress()) {
}
ret = dma_transfer((uint32_t) fb_info->screen_base,
(uint32_t) fb_buf[fb_buf_idx], (width * height * bpp) / 4);
if (ret < 0) {
printf("DMA transfer failedn");
}
fb_buf_idx = (fb_buf_idx + 1) % 2;
Здесь вводится fb_buf_idx — индекс буфера. fb_buf_idx = 0 — это front buffer, fb_buf_idx = 1 — это back buffer. Функция dma_transfer() принимает destination, source и кол-во 32 битных слов. Далее DMA заряжается требуемыми данными, а работа продолжается со следующим буфером.
Попробовав такой механизм производительность выросла примерно до 48 FPS. Чуть лучше чем с memcpy(), но незначительно. Я не хочу сказать, что DMA оказался бесполезен, просто в этом конкретном примере влияние кэша на общую картину показало себя лучше.
После небольшого удивления, что DMA показал себя хуже чем ожидалось, пришла “отличная”, как нам тогда казалось, мысль использовать несколько DMA каналов. В чем суть? Число данных, которые можно зарядить в DMA за один раз на stm32f7xx составляет 256 Кб. При этом помним, что экран у нас 480x272 и видеопамять порядка 512 Кб, а значит, казалось бы, что можно первую половину данных положить в один канал DMA, а вторую половину — во второй. И все вроде бы хорошо… Вот только производительность падает с 48 FPS до 25-30 FPS. То есть возвращаемся к той ситуации, когда еще не включили кэш. С чем это может быть связано? На самом деле с тем, что доступ к памяти SDRAM синхронизируется, даже память так и называется Synchronous Dynamic Random Access Memory (SDRAM), поэтому такой вариант лишь добавляет дополнительную синхронизацию, не делая при этом запись в память параллельной, как хочется. Немного поразмыслив, мы поняли, что ничего удивительного тут нет, ведь память то одна, и циклы записи и чтения генерируются к одной микросхеме (по одной шине), а поскольку добавляется еще один источник/приемник, то арбитру, который и разруливает обращения по шине, нужно смешивать циклы команд от разных DMA каналов.
Двойная буферизация. Работа с LTDC
Копирование из промежуточного буфера конечно хорошо, но как мы выяснили, этого недостаточно. Рассмотрим еще одно очевидное улучшение — двойную буферизацию. В подавляющем большинстве современных контроллеров дисплея можно задавать адрес на используемую видеопамять. Таким образом можно вообще избежать копирования, и просто переставлять адрес видеопамяти на подготовленный буфер, а контроллер экрана заберет данные оптимальным для него способом самостоятельно по DMA. Это и есть настоящая двойная буферизация, без промежуточного буфера как было до этого. Еще есть вариант когда контроллер дисплея может иметь два и более буферов, что по сути дела тоже самое — пишем в один буфер, а другой используется контроллером, при этом копирование не требуется.
У LTDC (LCD-TFT display controller) в составе stm32f74xx есть два аппаратных уровня наложения — Layer 1 и Layer 2, где Layer 2 накладывается на Layer 1. Каждый из уровней конфигурируется независимо и может быть включен или отключен отдельно. Мы попробовали включить только Layer 1 и у него переставлять адрес видеопамяти на front buffer или back buffer. То есть один отдаем дисплею, а в другой в это время рисуем. Но получили заметное дрожание картинки при переключении наложений.
Попробовали вариант когда используем оба слоя с включением/отключением одного из них, то есть когда каждый слой имеет свой адрес видеопамяти, который не меняется, а смена буфера осуществляется включением одного из слоев с одновременным выключением другого. Вариант также приводил к дрожанию. И наконец, попробовали вариант, когда слой не отключался, а выставлялся альфа канал либо в ноль 0 либо в максимум (255), то есть мы управляли прозрачностью, делая один из слоев невидимым. Но и этот вариант не оправдал ожидания, дрожание все еще присутствовало.
Причина была не ясна — в документации сказано, что обновление конфигурации слоев можно выполнять “на лету”. Сделали простой тест — отключили кэши, плавающую точку, нарисовали статическую картинку с зеленым квадратом в центре экрана, одинаковую для обоих Layer 1 и Layer 2, и стали переключать уровни в цикле, надеясь получить статическую картину. Но снова получили то же самое дрожание.
Стало понятно, что дело в чем-то другом. И тут вспомнили про выравнивание адреса фреймбуфера в памяти. Так как буферы выделялись из кучи и их адреса были не выровнены, мы выровняли их адреса на 1 Кб — получили ожидаемую картинку без дрожания. Потом нашли в документации, что LTDC вычитывает данные пачками по 64 байта, и что невыравненность данных дает значительную потерю в производительности. При этом выровнены должны быть как адрес начала фреймбуфера, так и его ширина. Для проверки мы изменили ширину 480x4 на 470x4, которая не делится на 64 байта, и получил то же самое дрожание картинки.
В итоге, выровняли оба буфера на 64 байта, убедились что ширина выровнена тоже на 64 байта и запустили nuklear — дрожание исчезло. Решение, которое сработало, выглядит так. Вместо переключения между уровнями при помощи полного отключения либо Layer 1 либо Layer используем прозрачность. То есть, чтобы отключить уровень, установим его прозрачность в 0, а чтобы включить — в 255.
BSP_LCD_SetTransparency_NoReload(fb_buf_idx, 0xff);
fb_buf_idx = (fb_buf_idx + 1) % 2;
BSP_LCD_SetTransparency(fb_buf_idx, 0x00);
Получили 70-75 FPS! Значительно лучше, чем изначальные 15.
Стоит отметить, что решение работает через управление прозрачностью, а варианты с отключением одного из уровней и вариант с переставлением адреса уровня дают дрожание картинки при FPS больших 40-50, причина нам на данный момент неизвестна. Также забегая вперед скажу, что это решение для данной платы.
Аппаратная заливка сцены через DMA2D
Но и это еще не предел, последней на текущий момент нашей оптимизацией для увеличения FPS, стала аппаратная заливка сцены. До этого мы делали заливку программно:
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 1);
Давайте теперь скажем плагину rawfb, что заливать сцену не нужно, а только рисовать поверх:
nk_rawfb_render(rawfb, nk_rgb(30,30,30), 0);
Сцену будем заливать тем же цветом 0xff303030, только аппаратно через контроллер DMA2D. Одна из основных функций DMA2D это копирование или заливка цветом прямоугольника в оперативной памяти. Основное удобство здесь в том, что это не непрерывный отрезок памяти, а именно прямоугольная область, которая в памяти располагается с разрывами, а значит обычным DMA сходу не обойтись. В Embox мы еще не работали с этим устройством, поэтому давайте просто воспользовались средствами STM32Cube — функция BSP_LCD_Clear(uint32_t Color). Она программирует в DMA2D цвет заливки и размеры всего экрана.
Vertical Blanking Period (VBLANK)
Но даже при достигнутых 80 FPS осталась заметная проблема — части виджета двигались небольшими “разрывами” при перемещении по экрану. То есть, виджет будто бы делился на 3 (или больше) части, которые двигались рядом, но с небольшой задержкой. Оказалось что причина в неправильном обновления видеопамяти. А точнее, обновления в неправильные интервалы времени.
У контроллера дисплея есть такое свойство как VBLANK, оно же VBI или Vertical Blanking Period. Оно обозначает временной интервал между соседними видео кадрами. Или чуть точнее, время между последней строкой предыдущего видеокадра и первой строкой следующего. В этом промежутке никакие новые данные не передаются на дисплей, картинка статическая. По этой причине обновлять видеопамять безопасно именно внутри VBLANK’а.
На практике, у контроллера LTDC есть прерывание, которое настраивается на срабатывание после обработки очередной строки фреймбуфера (LTDC line interrupt position configuration register (LTDC_LIPCR)). Таким образом, если настроить это прерывание на номер последней строки, то мы как раз и получим начало интервала VBLANK. В этом месте и производим необходимое переключение буферов.
В результате таких действий картинка нормализовалась, разрывы ушли. Но при этом FPS упал с 80 до 60. Давайте поймем в чем может быть причина подобного поведения.
В документации можно найти следующую формулу:
LCD_CLK (MHz) = total_screen_size * refresh_rate,
где total_screen_size = total_width x total_height. LCD_CLK это частота, на которой контроллер дисплея будет загружать пиксели из видеопамяти в экран (к примеру, через Display Serial Interface (DSI)). А вот refresh_rate это уже частота обновления самого экрана, его физическая характеристика. Выходит, зная refresh rate экрана и его размеры, можно сконфигурировать частоту для контроллера дисплея. Проверив по регистрам ту конфигурацию, которую создает STM32Cube, выяснили, что он настраивает контроллер на экран 60 Hz. Таким образом, все сошлось.
Немного об input устройствах в нашем примере
Вернемся к нашему приложению и рассмотрим как происходит работа с touchscreen, ведь как вы понимаете, современный интерфейс подразумевает интерактивность, то есть взаимодействие с пользователем.
У нас все устроено достаточно просто. События от input устройств обрабатываются в основном цикле программы непосредственно перед отрисовкой сцены:
/* Input */
nk_input_begin(&rawfb->ctx);
{
switch (mouse->type) {
case INPUT_DEV_MOUSE:
handle_mouse(mouse, fb_info, rawfb);
break;
case INPUT_DEV_TOUCHSCREEN:
handle_touchscreen(mouse, fb_info, rawfb);
break;
default:
/* Unreachable */
break;
}
}
nk_input_end(&rawfb->ctx);
Сама же обработка событий от touchscreen происходит в функции handle_touchscreen():
static void handle_touchscreen(struct input_dev *ts, struct fb_info *fb_info,
struct rawfb_context *rawfb) {
struct input_event ev;
int type;
static int x = 0, y = 0;
while (0 <= input_dev_event(ts, &ev)) {
type = ev.type & ~TS_EVENT_NEXT;
switch (type) {
case TS_TOUCH_1:
x = normalize_coord((ev.value >> 16) & 0xffff, 0, fb_info->var.xres);
y = normalize_coord(ev.value & 0xffff, 0, fb_info->var.yres);
nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 1);
nk_input_motion(&rawfb->ctx, x, y);
break;
case TS_TOUCH_1_RELEASED:
nk_input_button(&rawfb->ctx, NK_BUTTON_LEFT, x, y, 0);
break;
default:
break;
}
}
}
По сути, здесь происходит конвертация событий input устройств в формат понятный Nuklear’у. Собственно, наверное и все.
Запускаем на другой плате
Получив вполне приличные результаты, мы решили воспроизвести их на другой плате. У нас была другая похожая плата — STM32F769I-DISCO. Там такой же LTDC контроллер, но уже другой экран с разрешением 800x480. После запуска на ней получили 25 FPS. То есть заметное падение производительности. Это легко объясняется размером фреймбуфера — он почти в 3 раза больше. Но основная проблема оказалась в другом — изображение очень сильно искажалось, статической картинки в момент когда виджет должен быть на одном месте не было.
Причина была не ясна, поэтому мы пошли смотреть стандартные примеры из STM32Cube. Там оказался пример с двойной буферизаций именно для данной платы. В этом примере разработчики в отличие от метода с изменением прозрачности просто переставляют указатель на фреймбуфер по прерыванию VBLANK Этот способ мы уже пробовали ранее для первой платы, но для нее он не сработал. Но применив этот метод для STM32F769I-DISCO, мы получили достаточно плавное изменение картинки с 25 FPS.
Обрадовавшись, мы еще раз проверили данный метод (с переставлением указателей) на первой плате, но он все так же не работал при больших FPS. В итоге, на одной плате работает метод с прозрачностями слоев (60 FPS), а на другой метод с переставлением указателей (25 FPS). Обсудив ситуацию, мы решили отложить унификацию до более глубокой проработки графического стека.
Итоги
Итак, подведем итоги. Показанный пример представляет простой, но в то же время распространенный паттерн GUI для микроконтроллеров — несколько кнопок, регулятор громкости, может что-то еще. В примере отсутствует какая либо логика привязанная к событиям, так как упор был сделан именно на графику. По производительности получилось вполне приличное значение FPS.
Накопленные нюансы для оптимизации производительности подводят к выводу, что в современных микроконтроллерах графика усложняется. Теперь нужно, как и на больших платформах, следить за кэшем процессора, что-то размещать во внешней памяти, а что-то в более быстрой, задействовать DMA, использовать DMA2D, следить за VBLANK и так далее. Все это стало похожим на большие платформы, и быть может поэтому я уже несколько раз сослался на X Server и Wayland.
Пожалуй, одной из самых неоптимизированных частей можно считать сам рендеринг, мы перерисовываем всю сцену с нуля, целиком. Я не могу сказать как сделано в других библиотеках для микроконтроллеров, возможно где-то эта стадия встроена в саму библиотеку. Но по итогам работы с Nuklear кажется что в этом месте нужен аналог X Server или Wayland, конечно, более легковесный, что опять таки уводит нас к мысли, что маленькие системы повторяют путь больших.
Наши контакты:
Github: https://github.com/embox/embox
Рассылка: embox-ru[at]googlegroups.com
Телеграмм чат: t.me/embox_chat
Автор: alexkalmuk