Разбираемся В Особенностях Графической Подсистемы Микроконтроллеров

Привет! В этой статье я хотел бы поговорить об особенностях реализации графического пользовательского интерфейса с виджетами на микроконтроллере и о том, как иметь одновременно привычный пользовательский интерфейс и достойный FPS. Хотелось бы акцентировать внимание не на какой-то конкретной графической библиотеке, а на общих вещах — памяти, кэше процессора, dma и так далее.

Потому что я командный разработчик Эмбокс , приведенные примеры и эксперименты будут на этой ОС RT. Ранее мы говорили о запуск библиотеки Qt на микроконтроллере .

В результате получилась довольно плавная анимация, но затраты памяти даже на хранение прошивки оказались значительными — код выполнялся с внешней флэш-памяти QSPI. Конечно, когда вам нужен сложный и многофункциональный интерфейс, умеющий еще и какую-то анимацию, то затраты аппаратных ресурсов могут быть полностью оправданы (особенно если у вас уже есть этот код, разработанный для Qt).

Но что, если вам не нужны все возможности Qt? Что делать, если у вас есть четыре кнопки, один регулятор громкости и пара всплывающих меню? При этом хочется, чтобы оно «выглядело красиво и работало быстро» :) Тогда целесообразно было бы использовать более легкие инструменты, например библиотеку лвгл или похожие.

Некоторое время назад в нашем проекте Embox у нас было портировано Nuklear — проект по созданию очень лёгкой библиотеки, состоящей из одного заголовка и позволяющей легко создать простой графический интерфейс.

Мы решили использовать его для создания небольшого приложения, которое будет содержать виджет с набором графических элементов и которым можно будет управлять через тачскрин.

В качестве платформы мы выбрали STM32F7-Discovery с Cortex-M7 и сенсорным экраном.



Первые оптимизации.

? Экономия памяти

Итак, графическая библиотека выбрана, и платформа тоже.

Теперь давайте разберемся, что значит иметь ресурсы.

Здесь стоит отметить, что основная память SRAM в разы быстрее внешней SDRAM, поэтому если размер вашего экрана позволяет, то, конечно, лучше поставить фреймбуфер в SRAM. Наш экран имеет разрешение 480х272. Если мы хотим цвет по 4 байта на пиксель, то получается около 512 КБ.

При этом размер внутренней оперативной памяти всего 320 и сразу понятно, что видеопамять будет внешней.

Другой вариант — уменьшить бит цвета до 16 (т.е.

2 байтов) и тем самым снизить потребление памяти до 256 КБ, которые уже могут поместиться в основную оперативную память.

Первое, что можно попробовать – это сэкономить на всем.

Давайте сделаем видеобуфер размером 256 КБ, разместим его в оперативной памяти и будем в него рисовать.

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

Nuklear перерисовывает всю сцену с нуля, поэтому каждый раз сначала заполняется весь экран, потом рисуется виджет, потом в него помещается кнопка с текстом и так далее.

В результате невооруженным глазом заметно, как перерисовывается вся сцена и «моргает» картинка.

То есть просто размещение во внутренней памяти не помогает.

Промежуточный буфер.

Оптимизация компилятора.

ФПУ

После того, как мы немного повозились с предыдущим методом (поместив его во внутреннюю память), на ум сразу стали приходить воспоминания о X Server и Wayland. Да, действительно, по сути оконные менеджеры занимаются обработкой запросов от клиентов (нашего кастомного приложения), а затем сбором элементов в финальную сцену.

Например, ядро Linux отправляет события от устройств ввода на сервер через драйвер evdev. Сервер, в свою очередь, определяет, какому клиенту адресовать событие.

Клиенты, получив событие (например, нажатие на сенсорный экран), выполняют свою внутреннюю логику — выделяют кнопку, отображают новое меню.

Далее (немного другим способом для X и Wayland) либо сам клиент, либо сервер рисует изменения в буфере.

А затем композитор собирает все части вместе для рендеринга на экране.

Довольно простое и схематичное объяснение здесь Здесь .

Стало понятно, что нам нужна подобная логика, но впихивать 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 pressed\n"); 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 много вычислений с плавающей запятой, Мы включили его поддержку изначально , без него фпс был бы еще ниже.

Первая и самая простая (бесплатная) оптимизация — это, конечно же, флаг компилятора -O2. Соберем и запустим тот же пример — получаем 20 FPS. Лучше, но все равно недостаточно, чтобы сделать хорошую работу.



Включение кэшей процессора.

Режим сквозной записи

Прежде чем перейти к дальнейшим оптимизациям, скажу, что мы используем плагин rawfb в составе Nuklear, который рисует напрямую в память.

Соответственно, оптимизация управления памятью выглядит весьма перспективной.

Первое, что приходит на ум – кэш.

В более старых версиях Cortex-M, таких как Cortex-M7 (наш случай), встроен дополнительный кеш процессора (кэш инструкций и кеш данных).

Он включается через регистр CCR блока управления системой.

Но с включением кэша возникают новые проблемы - несогласованность данных в кэше и памяти.

Есть несколько способов управления кэшем, но в этой статье я не буду на них останавливаться, поэтому перейду к одному из самых простых, на мой взгляд. Чтобы решить проблему несогласованности кеша и памяти, мы можем просто пометить всю доступную нам память как «некэшируемую».

Это означает, что все записи в эту память всегда будут поступать в память, а не в кеш.

Но если мы разметим таким образом всю память, то кэш потеряет смысл.

Есть еще один вариант. Это «сквозной» режим, в котором все операции записи в память, помеченные как сквозная запись, выполняются одновременно и в кэш, и в память.

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

Для Nuklear режим сквозной записи оказался очень хорош - производительность выросла с 20 FPS до 45 FPS, что само по себе уже неплохо и плавно.

Эффект конечно интересный, мы даже пытались отключить режим сквозной записи, не обращая внимания на несогласованность данных, но FPS поднялся всего лишь до 50 FPS, то есть существенного прироста по сравнению со сквозной записью не было.

Из этого мы пришли к выводу, что нашему приложению требуется много операций чтения, а не записи.

Вопрос конечно где? Возможно, из-за количества преобразований в коде rawfb, которые часто обращаются к памяти для чтения следующего коэффициента или чего-то в этом роде.



Двойная буферизация (пока с промежуточным буфером).

Включение прямого доступа к памяти

Нам не хотелось останавливаться на 45 FPS, поэтому мы решили экспериментировать дальше.

Следующей идеей была двойная буферизация.

Идея широко известна и, в целом, проста.

Мы рисуем сцену с помощью одного устройства в один буфер, и в то же время другое устройство отображает ее на экране из другого буфера.

Если посмотреть на предыдущий код, то хорошо видно цикл, в котором сцена сначала отрисовывается в буфер, а затем содержимое копируется в видеопамять с помощью memcpy. Понятно, что memcpy использует процессор, то есть рисование и копирование происходят последовательно.

Наша идея заключалась в том, что копирование можно было бы выполнять параллельно с использованием 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 failed\n"); } fb_buf_idx = (fb_buf_idx + 1) % 2;

Здесь вводится fb_buf_idx — индекс буфера.

fb_buf_idx = 0 — передний буфер, fb_buf_idx = 1 — задний буфер.

Функция dma_transfer() принимает пункт назначения, источник и количество 32-битных слов.

Далее DMA загружается необходимыми данными, и работа продолжается со следующим буфером.

Попробовав этот механизм, производительность выросла примерно до 48 FPS. Немного лучше, чем memcpy(), но незначительно.

Я не хочу сказать, что DMA был бесполезен, но в данном конкретном примере влияние кэша на общую картину было лучше.

После небольшого удивления, когда DMA работал хуже, чем ожидалось, мы пришли к, по нашему мнению, «отличной» идее — использовать несколько каналов DMA. В чем смысл? Объем данных, которые можно загрузить в DMA за один раз на stm32f7xx, составляет 256 КБ.

При этом мы помним, что у нас экран 480х272, а видеопамять около 512 КБ, а значит, казалось бы, мы можем поместить первую половину данных в один канал DMA, а вторую половину — во второй.

И вроде бы всё хорошо.

Но производительность падает с 48 FPS до 25-30 FPS. То есть мы возвращаемся к ситуации, когда у нас еще не был включен кэш.

С чем это может быть связано? Фактически, поскольку доступ к памяти SDRAM синхронизирован, даже эта память называется синхронной динамической оперативной памятью (SDRAM), поэтому этот вариант лишь добавляет дополнительную синхронизацию, не делая записи в память параллельной, как хотелось бы.

Немного подумав, мы поняли, что ничего удивительного здесь нет, ведь память всего одна, а циклы записи и чтения формируются на одном чипе (на одной шине), а поскольку добавлен еще один источник/приемник, то и арбитр, который разрешает запросы по шине, нужно смешивать циклы команд из разных каналов DMA.

Двойная буферизация.

Работаем с ЛТДК.

Копирование из промежуточного буфера — это, конечно, хорошо, но, как мы выяснили, этого недостаточно.

Давайте посмотрим на другое очевидное улучшение: двойную буферизацию.

В подавляющем большинстве современных контроллеров дисплея можно задать адрес используемой видеопамяти.

Таким образом можно вообще избежать копирования, а просто переставить адрес видеопамяти в подготовленный буфер, а контроллер экрана будет принимать данные оптимальным для него способом самостоятельно через DMA. Это настоящая двойная буферизация без промежуточного буфера, как было раньше.

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

LTDC (контроллер ЖК-TFT-дисплея), входящий в состав stm32f74xx, имеет два уровня аппаратного наложения — уровень 1 и уровень 2, где уровень 2 накладывается на уровень 1. Каждый уровень настраивается независимо и может быть включен или отключен отдельно.

Мы попытались включить только Layer 1 и изменить адрес его видеопамяти на передний или задний буфер.

То есть одно мы отдаем на дисплей, а в другом одновременно рисуем.

Но мы заметили заметное дрожание изображения при переключении наложений.

Мы попробовали вариант, где мы используем оба слоя и включаем/выключаем один из них, то есть когда каждый слой имеет свой адрес видеопамяти, который не меняется, а буфер меняется при одновременном включении одного из слоев.

выключив другой.

Этот вариант также привел к джиттеру.

И наконец, мы попробовали вариант, когда слой не отключался, а альфа-канал был установлен либо на ноль 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.

Период вертикального гашения (VBLANK)

Но даже при достигнутых 80 FPS оставалась заметная проблема — части виджета перемещались небольшими «промежутками» при перемещении по экрану.

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

Оказалось, что причина в некорректном обновлении видеопамяти.

Точнее, обновления через неверные промежутки времени.

Контроллер дисплея имеет свойство VBLANK, также известное как VBI или Период вертикального гашения .

Он обозначает временной интервал между соседними видеокадрами.

Или чуть точнее время между последней строкой предыдущего видеокадра и первой строкой следующего.

В течение этого интервала на дисплей не передается никаких новых данных, картинка статична.

По этой причине можно безопасно обновлять видеопамять внутри VBLANK. На практике контроллер LTDC имеет прерывание, которое настроено на срабатывание после обработки следующей строки кадрового буфера (регистр конфигурации положения прерывания линии LTDC (LTDC_LIPCR)).

Таким образом, если мы установим это прерывание на последний номер строки, то мы получим начало интервала VBLANK. На этом этапе мы выполняем необходимое переключение буферов.

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

Но при этом ФПС упал с 80 до 60. Давайте разберемся, в чем может быть причина такого поведения.

В документация вы можете найти следующую формулу:

LCD_CLK (MHz) = total_screen_size * refresh_rate,

где общий_размер_экрана = общая_ширина x общая_высота.

LCD_CLK — частота, с которой контроллер дисплея будет загружать пиксели из видеопамяти на экран (например, через последовательный интерфейс дисплея (DSI)).

А ведьrefresh_rate — это частота обновления самого экрана, его физическая характеристика.

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

Проверив регистры конфигурации, которую создает STM32Cube, мы выяснили, что он настраивает контроллер на экран с частотой 60 Гц.

Итак, все сошлось.



Немного об устройствах ввода в нашем примере

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

Здесь все довольно просто.

События от устройств ввода обрабатываются в основном цикле программы непосредственно перед рендерингом сцены:

/* 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);

Фактическая обработка событий сенсорного экрана происходит в функции handle_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; } } }

По сути, здесь события устройств ввода преобразуются в формат, понятный Nuklear. Собственно, это, пожалуй, и все.



Беги на другой доске

Получив вполне приличные результаты, мы решили воспроизвести их на другой плате.

У нас была еще одна похожая плата — STM32F769I-DISCO. Там тот же LTDC контроллер, но другой экран с разрешением 800х480. После запуска мы получили 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, конечно, более легкий, что снова приводит нас к мысли, что маленькие системы идут по пути больших.

UPD1 В результате метод изменения прозрачности оказался не нужен.

На обеих платах работал один и тот же код — с перестановкой адреса буфера через v-sync. При этом метод с прозрачностью тоже правильный, просто он не нужен.

УПД2 Я хочу сказать большое спасибо всем, кто предложил тройную буферизацию, мы до нее еще не дошли.

Но теперь мы видим, что это классический метод (особенно для FPS с высокой скоростью загрузки кадров на дисплей), который, помимо всего прочего, позволит нам избавиться от лагов из-за ожидания v-sync (т.е.

когда программное обеспечение заметно опережает картинку).

Мы с этим пока не сталкивались, но это лишь вопрос времени.

И хочу сказать отдельное спасибо за обсуждение тройной буферизации безицеруф И Белав ! Наши контакты: Гитхаб: https://github.com/embox/embox Информационная рассылка: embox-ru[at]googlegroups.com Телеграм-чат: t.me/embox_chat Теги: #Программирование микроконтроллеров #stm32 #с открытым исходным кодом #Системное программирование #mcu #embox #graphics #stm32f7discovery

Вместе с данным постом часто просматривают: