Перенос LittlevGL на монохромный дисплей

Использование возможностей LvGL в проекте с низким уровнем ресурсов

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

В этой области доступные ресурсы, мягко говоря, бесплодны. Большинство результатов, найденных в Интернете, относятся либо к проприетарным библиотекам, распространяемым поставщиками ЖК-дисплеев с несуществующей поддержкой, либо к сомнительным генераторам кода, которые не могут предоставить что-либо, кроме нескольких поддерживаемых драйверов, если вообще. Предыдущее решение моей компании представляло собой взломанный и модифицированный API, справочное руководство которого было давно потеряно во времени. Единственная надежная альтернатива запеканию собственного графического интерфейса - LittlevGL.

LittlevGL (или LvGL) - это графическая библиотека с открытым исходным кодом для встраиваемых систем. Он в основном ориентирован на цветные TFT-дисплеи, но в принципе может работать и с монохромными ЖК-дисплеями. Мой первый подход был обескуражен, поскольку его основной целью был цвет и размер скомпилированного результата (около 250 КБ двоичного кода для архитектуры PIC). Однако, немного поигравшись, я могу сказать, что очень доволен и хочу сделать небольшой урок о том, как его использовать.

Для справки: в моем проекте используется микроконтроллер PIC24, управляющий монохромным резистивным сенсорным ЖК-дисплеем с разрешением 240х128 пикселей; в принципе, однако, весь код абстрагирован от реального оборудования, и большинство объяснений в целом действительны. Все написанное здесь основано на обширной документации LittlevGL.

Монохромная конфигурация

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

/* Maximal horizontal and vertical resolution to support by the library.*/
#define LV_HOR_RES_MAX          (240)
#define LV_VER_RES_MAX          (128)
/* Color depth:
 * - 1:  1 byte per pixel
 * - 8:  RGB233
 * - 16: RGB565
 * - 32: ARGB8888
 */
#define LV_COLOR_DEPTH     1
/* Enable anti-aliasing (lines, and radiuses will be smoothed) */
#define LV_ANTIALIAS        0
/*1: Enable the Animations */
#define LV_USE_ANIMATION        0
/* Enable GPU optimization */
#define USE_LV_GPU              0

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

Самая важная настройка в этом случае - LV_COLOR_DEPTH. Он настраивает размер для каждого пикселя, который в данном случае составляет всего 1 бит, что позволяет сэкономить столь необходимую оперативную память.

Инициализация библиотеки

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

    static uint8_t gbuf[8*240];
    static lv_disp_buf_t disp_buf;
     
    lv_disp_buf_init(&disp_buf, gbuf, NULL, 8*240);
    
    lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);   /*Basic initialization*/
    disp_drv.buffer = &disp_buf;    /*Set an initialized buffer*/
    disp_drv.flush_cb = my_flush_cb; /*Callback*/
    disp_drv.set_px_cb = my_set_px_cb; /*Callback*/
    disp_drv.rounder_cb = my_rounder; /*Callback*/
    lv_disp_t * disp;
    disp = lv_disp_drv_register(&disp_drv); /*Register the driver and save the created display objects*/ 
    /*Input device*/
    lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);      /*Basic initialization*/
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb = my_input_read;
    /*Register the driver in LittlevGL and save the created input device object*/
    lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv);

Давайте развернем его построчно.

gbuf - это наш кадровый буфер, область памяти, в которой происходит рисование и графические операции. У меня экран 240х128 пикселей, с пиксельными линиями, сгруппированными по горизонтали в байты: итоговый общий размер будет 240 * 128/8 = 240 * 16.

Обратите внимание, что для буфера необязательно, чтобы он занимал весь экран одновременно: при необходимости я могу использовать меньшую область, и LvGL удобно разделит операции графического интерфейса, которые не помещаются там, на несколько меньших частей. Тем не менее, у меня есть достаточно свободной оперативной памяти.

lv_disp_buf_init инициализирует структуру disp_buf, зная, что мы используем единственный буфер (gbuf) шириной 8 * 240 байт.

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

Затем мы регистрируем устройство ввода: это LV_INDEV_TYPE_POINTER, сенсорный экран или мышь (последнее здесь). my_input_read просто возвращает текущие координаты касания, так что LvGL может знать, где пользователь щелкает:

bool my_input_read(lv_indev_drv_t * drv, lv_indev_data_t*data)
{
    data->point.x = Touch_Coord[0]; 
    data->point.y = Touch_Coord[1];
    data->state = f_touch_detected ? LV_INDEV_STATE_PR : LV_INDEV_STATE_REL;
    return false; /*No buffering now so no more data read*/
}

В нем я заполняю структуру координатами, которые я читаю.

Время выполнения

Помимо загрузки, LvGL также требует, чтобы для работы периодически вызывались две функции: lv_task_handler и lv_tick_inc.

lv_task_handler - вот как на самом деле работает LvGL. lv_tick_inc - это то, как он узнает, что время прошло (в частности, миллисекунды).

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

while (1) {
        lv_task_handler();
        lv_tick_inc(1);
        delay_ms(1);
    }

В качестве альтернативы они могут быть вызваны в подпрограммах прерывания, но в этом случае нужно позаботиться о том, чтобы lv_tick_inc находился в обработчике с более высоким приоритетом из соображений параллелизма. Фактически, увеличение числа тиков прерывания является хорошей практикой, поскольку оно более точное; в предыдущем примере время, затраченное lv_task_handler, могло бы сбить с пути миллисекундный период. Для целей этого примера этого достаточно.

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

Портирование LvGL

Один из самых привлекательных аспектов LvGL - это то, насколько хорошо он управляет интеграцией с драйвером дисплея. Нельзя ожидать найти общий API во встроенных системах, поэтому лучшим решением будет просто предоставить разработчику задачу по подключению оборудования и графического интерфейса: LVGL предоставляет несколько обратных вызовов, которые сигнализируют, когда и что показывать на экране; как это сделать, зависит от вас.

Некоторые из этих крючков имеют смысл только в цветной среде, поэтому для монохромного ЖК-дисплея нам нужно определить всего три функции:

  • flush_cb: функция, которая сигнализирует вашему приложению обновить часть экрана некоторыми данными.
  • rounder_cb: обратный вызов с просьбой «округлить» область, которую необходимо обновить. Подробнее об этом позже.
  • set_px_cb: просит отрисовать пиксель в буфер

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

flush_cb

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

Он получает 3 аргумента:

  • lv_disp_drv_t *drv: указатель на текущую сконфигурированную структуру драйвера дисплея
  • lv_area_t *area: набор координат, ограничивающих область экрана, которая должна быть обновлена
  • lv_color_t *color_p: указатель на буферную память, в которой отрисовываются графические объекты. Он содержит данные, которые должны быть выведены на дисплей.

Первый аргумент можно игнорировать; область сообщает вам, где вы должны обновить экран, а color_p содержит данные для очистки.

Это моя реализация обратного вызова:

void my_flush_cb(lv_disp_drv_t * disp_drv, const lv_area_t * area, lv_color_t * color_p)
{
    int row = area->y1, col;
    unsigned int address = (unsigned int)row * HW_COLUMNS + area->x1/8;
    uint8_t *buffer;
    int linelen = (area->x2 - area->x1)/8;
    
    buffer = (uint8_t*) color_p;
    
    for (row = area->y1; row <= area->y2; row++) {
        flush_one_row(address, buffer, linelen);
        buffer += linelen+1;
        address += HW_COLUMNS;
    }
    
    lv_disp_flush_ready(disp_drv);
}

Обратите внимание, что:

  • color_p - указатель на lv_color_t буфер, но когда LV_COLOR_DEPTH равно 1, эта структура сворачивается в однобайтовое объединение; таким образом, его можно безопасно преобразовать в uint8_t* для передачи
  • color_p будет указывать на область внутри буфера, который мы использовали для инициализации отображения LvGL (gbuf). Однако нет никакой гарантии, где это будет: виджеты не отображаются в gbuf в каком-либо конкретном порядке или позиции, это работает аналогично куче распределения.
  • area с другой стороны, сообщает мне, где именно промывка должна обновлять экран, поэтому я знаю адрес, по которому обновляется оперативная память ЖК-дисплея.
  • Когда функция завершена, должна быть вызвана очистка lv_disp_flush_ready. Здесь он используется в конце обратного вызова, но может сигнализировать о завершении более длительной процедуры (например, передача DMA или отложенное обновление).

rounder_cb

Это немного сложно. Я упоминал ранее, что в монохромных ЖК-дисплеях пиксели часто сгруппированы в байты; кроме этого, они могут принимать обновления в свою оперативную память только в специальных форматах (например, в определенных пакетах или путем записи на страницы памяти фиксированного размера). Из-за этого LvGL может использовать области, которые не имеют смысла в процессе обновления.

LvGL может захотеть, например, обновить прямоугольник, состоящий из координат x = (3,14) и y = (1,4). Если пиксели сгруппированы по байтам, невозможно отправить на экран нечетное число из 11 (14–3) бит. Решение состоит в том, чтобы округлить эту область до x = (0,16) и y = (1,4) (с учетом выравнивания пикселей по горизонтали).

rounder_cb имеет именно такую ​​функцию: LvGL уведомляет код следующего обновления экрана и просит округлить его до минимально возможного размера.

void my_rounder(struct _disp_drv_t * disp_drv, lv_area_t *a)
{
    a->x1 = a->x1 & ~(0x7);
    a->x2 = a->x2 |  (0x7);
}

Чтобы округлить горизонтальный байт, мне просто нужно переместить левую координату x на меньшее кратное 8 (логические и с 0b11111000), а правое - на большее кратное 8 (логическое или с 0b00000111). Эти значения также будут отправлены на flush_cb.

set_px_cb

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

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

void my_set_px_cb(struct _disp_drv_t * disp_drv, uint8_t * buf, lv_coord_t buf_w, lv_coord_t x, lv_coord_t y, lv_color_t color, lv_opa_t opa)
{
    buf += buf_w/8 * y;
    buf += x/8;
    if(lv_color_brightness(color) > 128) {(*buf) |= (1 << (7 - x % 8));}
    else {(*buf) &= ~(1 << (7 - x % 8));}
}
  • disp_drv - это обычная структура драйвера дисплея
  • buf - это диапазон памяти, в котором LvGL хочет, чтобы вы обновили пиксель.
  • buf_w - ширина указанного диапазона; координата y указывает его высоту
  • x и y - координаты пикселя для обновления.
  • color - в монохромной настройке - полный или чистый
  • opa - значение непрозрачности, здесь не используется по очевидным причинам.

Все эти арифметические и побитовые операции с указателями предназначены для установки одного бита в правильный горизонтальный сегмент / байт.

Небольшой пример

LvGL предоставляет множество практических руководств, примеров и демонстраций. К сожалению, многие из них выйдут из строя или будут работать неправильно в монохромной среде: для меня либо экран, либо доступная память MCU были слишком малы. Это небольшой пример с кнопкой и меняющейся меткой:

lv_obj_t *label2, *label1;
static void btn_event_cb(lv_obj_t * btn, lv_event_t event)
{
    if(event == LV_EVENT_RELEASED) {
        lv_label_set_text(label1, "RELEASED");
    } else if (event == LV_EVENT_PRESSED) {
        lv_label_set_text(label1, "CLICKED");
    }
}
int main (void)
{
    ConfigureOscillator();
    InitializeSystem();
    
    lv_init();
    
    static lv_disp_buf_t disp_buf;
    lv_disp_buf_init(&disp_buf, gbuf, NULL, 8*240);
    lv_disp_drv_t disp_drv;
    lv_disp_drv_init(&disp_drv);
    disp_drv.buffer = &disp_buf;
    disp_drv.flush_cb = my_flush_cb;
    disp_drv.set_px_cb = my_set_px_cb;
    disp_drv.rounder_cb = my_rounder;
  
    lv_disp_t * disp;
    disp = lv_disp_drv_register(&disp_drv);
    
    lv_indev_drv_t indev_drv;
    lv_indev_drv_init(&indev_drv);
    indev_drv.type = LV_INDEV_TYPE_POINTER;
    indev_drv.read_cb =my_input_read;
    lv_indev_t * my_indev = lv_indev_drv_register(&indev_drv);
    
    lv_obj_t * scr = lv_disp_get_scr_act(NULL);
    lv_theme_t * th = lv_theme_mono_init(0, NULL);
    /* Set the mono system theme */
    lv_theme_set_current(th);
    
    /*Create a Label on the currently active screen*/
    label1 =  lv_label_create(scr, NULL);
    lv_label_set_text(label1, "");
    lv_obj_set_pos(label1,30, 30);// position, position);
    
/*Create a button on the currently loaded screen*/    
    lv_obj_t * btn1 = lv_btn_create(scr, NULL);
    lv_obj_set_event_cb(btn1, btn_event_cb);                                  /*Set function to be called when the button is released*/
    //lv_obj_align(btn1, label2, LV_ALIGN_OUT_BOTTOM_LEFT, 0, 20);               /*Align below the label*/
    lv_obj_set_pos(btn1, 30, 50);
    
    /*Create a label on the button (the 'label' variable can be reused)*/
    label2 = lv_label_create(btn1, NULL);
    lv_label_set_text(label2, "Click me!");
    lv_obj_set_size(btn1, 100, 20);
    
while (1) {
        lv_task_handler();
        lv_tick_inc(1);
        delay_ms(1);
    }
}

После инициализации, как я уже объяснил, наиболее важными частями являются установка монотемы - единственной используемой в монохромной среде - и создание реальных виджетов. Событие кнопки работает из-за обратного вызова сенсорного ввода, который предоставляет координаты касания библиотеке, управляя всей системой ввода.

Дополнительные замечания

Как я уже упоминал во введении, сначала я не мог использовать LvGL из-за того, что он занимал память. В этом руководстве я использовал микроконтроллер PIC24EP512GP206 с 512 КБ флеш-памяти: только LvGL занимает половину этого объема.

С тех пор я поигрался с параметрами конфигурации и обнаружил, что многие неиспользуемые компоненты можно отключить для экономии места. Помимо всех тем и шрифтов, LvGL позволяет удалить каждый виджет с опциями макроса: если я не использую календарь на дисплее 128x64, я могу отключить его, установив LV_USE_CALENDAR на 0, и он не займет драгоценную вспышку. объем памяти. В файле шаблона конфигурации есть все значения по умолчанию.

Выбрав только то, что я планирую использовать (и оставив прилично открытыми мои варианты), я достиг разумного двоичного вывода 150 КБ, и я с нетерпением жду возможности использовать LvGL во всей моей будущей работе!