STM32F103C8 без HAL и SPL: Вывод текста на дисплей ST7735

разделы: STM32 , дата: 10 мая 2023г.

В данной статье рассматриваются способы вывода текста на дисплей ST7735 с помощью микроконтроллера STM32F103C8. В качестве шрифтов используются два шрифта с кириллицей 8х8 и 8х16, а также шрифт Cybercafe 8х16 без кириллицы.

Статья является продолжением предыдущей "Работа с SPI дисплеями Nokia_5110 и ST7735", где я рассматривал подключение дисплея к микроконтроллеру, его инициализацию, и затрагивал вопрос о максимально быстрой работе с дисплеем по SPI интерфейсу. В этот раз рассматривается вывод текста на дисплей ST7735, сжатие шрифтов, а так же отрисовка простых графических примитивов.

Содержание:

  1. Исследование инициализации дисплея ST7735 библиотекой Adafruit
  2. Функция заливки прямоугольной области дисплея
  3. Команда MADCTL - установка цветового режима и ориентации дисплея ST7735
  4. Шрифт CP866_8х8
  5. Печать строки на функции вывода одного символа
  6. Печать целой строки
  7. Ускоряемся до 100 fps с помощью DMA
  8. RLE-cжатие для шрифтов 8x16
  9. Печать текста шрифтом 8x16

Шрифт Terminus (добавлено 22 мая 2023г.)

  1. Шрифт Terminus 8x16
  2. Шрифт Terminus 12x24
  3. Шрифт Terminus 16x32

Конвертация векторных шрифтов

  1. Преобразование векторных шрифтов в растровые

Дизайнерские шрифты (добавлено 16 июня 2023г.)

  1. Словарное сжатие для больших шрифтов
  2. Жидко-кристальные шрифты DS_Digital и PixelLCD
  3. Футуристические шрифты: Nulshock, DuneRise, BladeRunner

Все примеры и готовые прошивки можно скачать с портала GitLab

1) Исследование инициализации дисплея ST7735 библиотекой Adafruit

Инициализация дисплея ST7735 уже рассматривалась в прошлогодней статье: "11) инициализацию дисплея ST7735", но тогда код заливки дисплея (не инициализации) содержал ошибку, из-за чего цвет отображался не всегда корректно. Я тогда ошибку видел, но моей заботой тогда было разобраться с максимально быстрой работой SPI интерфейса. Но впоследствии, из-за ошибки возникли вопросы к последовательности инициализации, а точнее к корректной установке цветового режима. Напомню, что последовательность инициализации я брал "из интернета", а именно, с сайта "MicroTechics: Подключение дисплея на базе ST7735 к микроконтроллеру STM32". Забегая вперед скажу, что она вполне рабочая. Под спойлером я привел код функции инициализации для напоминания:

void st7735_init() {
    // hardware reset
    // hardware reset
    gpio_reset(GPIOB,RST);
    delay_ms(10);
    gpio_set(GPIOB,RST);
    delay_ms(10);
    // init routine

    chip_select_enable();
    st7735_send(LCD_C,ST77XX_SWRESET);
    delay_ms(150);
    st7735_send(LCD_C,ST77XX_SLPOUT);
    delay_ms(150);

    st7735_send(LCD_C,ST7735_FRMCTR1);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x2C);
    st7735_send(LCD_D,0x2D);

    st7735_send(LCD_C,ST7735_FRMCTR2);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x2C);
    st7735_send(LCD_D,0x2D);

    st7735_send(LCD_C,ST7735_FRMCTR3);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x2C);
    st7735_send(LCD_D,0x2D);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x2C);
    st7735_send(LCD_D,0x2D);


    st7735_send(LCD_C,ST7735_INVCTR);
    st7735_send(LCD_D,0x07);

    st7735_send(LCD_C,ST7735_PWCTR1);
    st7735_send(LCD_D,0xA2);
    st7735_send(LCD_D,0x02);
    st7735_send(LCD_D,0x84);

    st7735_send(LCD_C,ST7735_PWCTR2);
    st7735_send(LCD_D,0xC5);

    st7735_send(LCD_C,ST7735_PWCTR3);
    st7735_send(LCD_D,0x0A);
    st7735_send(LCD_D,0x00);

    st7735_send(LCD_C,ST7735_PWCTR4);
    st7735_send(LCD_D,0x8A);
    st7735_send(LCD_D,0x2A);

    st7735_send(LCD_C,ST7735_PWCTR5);
    st7735_send(LCD_D,0x8A);
    st7735_send(LCD_D,0xEE);

    st7735_send(LCD_C,ST7735_VMCTR1);
    st7735_send(LCD_D,0x0E);

    st7735_send(LCD_C,ST77XX_INVOFF);

    st7735_send(LCD_C,ST77XX_MADCTL);
    st7735_send(LCD_D,0xC0);

    st7735_send(LCD_C,ST77XX_COLMOD);
    st7735_send(LCD_D,0x05);

    st7735_send(LCD_C,ST7735_GMCTRP1);
    st7735_send(LCD_D,0x02);
    st7735_send(LCD_D,0x1C);
    st7735_send(LCD_D,0x07);
    st7735_send(LCD_D,0x12);
    st7735_send(LCD_D,0x37);
    st7735_send(LCD_D,0x32);
    st7735_send(LCD_D,0x29);
    st7735_send(LCD_D,0x2D);
    st7735_send(LCD_D,0x29);
    st7735_send(LCD_D,0x25);
    st7735_send(LCD_D,0x2B);
    st7735_send(LCD_D,0x39);
    st7735_send(LCD_D,0x00);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x03);
    st7735_send(LCD_D,0x10);

    st7735_send(LCD_C,ST7735_GMCTRN1);
    st7735_send(LCD_D,0x03);
    st7735_send(LCD_D,0x1D);
    st7735_send(LCD_D,0x07);
    st7735_send(LCD_D,0x06);
    st7735_send(LCD_D,0x2E);
    st7735_send(LCD_D,0x2C);
    st7735_send(LCD_D,0x29);
    st7735_send(LCD_D,0x2D);
    st7735_send(LCD_D,0x2E);
    st7735_send(LCD_D,0x2E);
    st7735_send(LCD_D,0x37);
    st7735_send(LCD_D,0x3F);
    st7735_send(LCD_D,0x00);
    st7735_send(LCD_D,0x00);
    st7735_send(LCD_D,0x02);
    st7735_send(LCD_D,0x10);

    st7735_send(LCD_C,ST77XX_NORON);
    delay_ms(10);

    st7735_send(LCD_C,ST77XX_DISPON);
    delay_ms(100);

    chip_select_disable();
}

Ошибка была быстро найдена, но чтобы быть уверенным, я подключил дисплей к плате Arduino, в которую предварительно залил пример "graphicstest" из библиотеки Adafruit для работы с дисплеем ST7735, и логическим анализатором снял последовательность инициализации дисплея ST7735, чтобы сравнить ее со своей. И картинка получилась следующей.

Вначале идет команда сброса - "SWRESET":

Затем поступает команда выхода из спящего режима - "SLPOUT"

Промежутки между командами "SWREST", "SLPOUT" составляют что-то около 500 мс, хотя по документации достаточно выдерживать 120 мс.

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

Итак, после очередных 500 мс начинается главная последовательность инициализации:

В начале идут команды установки частоты работы дисплея FRMCTR1 и FRMCTR2. Первая устанавливает частоту дисплея в "нормальном" режиме, вторая - в спящем. Мы, кстати, спящий режим не используем.

Далее идет еще одна команда установки частоты кадров FRMCTR3 для работы в "Partial Mode". Затем идет команда настройки режима инверсии дисплея INVCTR. Параметр 0х07 устанавливает "Column Inversion" режим инверсии для Normal, Idle и Partial режимов работы дисплея.

Далее идут команды управления питанием: PWCTR1, PWCTR2 и PWCTR3, PWCTR4, PWCTR5, VMCTR1.

Затем командой 0x20 выключается режим инверсии дисплея:

До этого момента, у моя последовательность инициализации совпадала с логом библиотеки Adafruit, но дальше идут небольшие расхождения.

Команда MADCTL c параметром =0xC8 устанавливается цветовой режим BGR. В то время, как в моей последовательности инициализации, команда MADCTL передается параметр =0хС0.

Если посмотрим документацию, то увидим, установленный бит D3 задает цветовой режим BGR:

Далее командами CASET и RASET устанавливается рабочая область равная всему дисплею, т.е. 128х160 пикселей. В моей последовательности инициализации этих команд вообще нет.

Далее идут команды установки гамма-коррекции: GMCTRP1 (позитивная) и GMCTRN1 (негативная), с вереницей "мистических" аргументов, смысл которых мне лично малопонятен. Все аргументы совпадают с моей вариантом инициализации дисплея ST7735.

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

Итак, полученная последовательность инициализации дисплея ST7735, от моей отличается только BGR режимом цвета, и наличием команд CASET и RASET.

После инициализации дисплея ST7735, демо-программа Adafruit очищает дисплей заливкой черного цвета. Давайте взглянем на начало протокола заливки.

Здесь дважды передаются команды установки цветового режима RGB. Затем идут команды CASET и RASET:

После этого передается команда 0х2С для записи данных в графическую память дисплея, и собственно сами данные:

Команда 0х2С интересна в том плане, что для передачи данных у нас есть линия DC. Но данными могут быть не только данные графической памяти, но и параметры команд передаются как данные.

В результате анализа лога инициализации, я ничего сверхъестественного не нашел, кроме того, что полная заливка дисплея на Arduino занимает ~1200 мс, но не об этом речь. А вот команда MADCTL привлекла мое внимание.

2) Функция заливки прямоугольной области дисплея

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

Прототип этой функции уже был написан в прошлой статье, поэтому сейчас скачиваем проект для дисплея ST7735:

$ git clone git@gitlab.com:flank1er/stm32_bare_metal.git

Переходим в каталог проекта:

$ cd stm32_bare_metal/25_st7735_90fps/

Очищаем проект:

$ make clean

В модуле "src/st7735.c" нам нужна будет функция "void st7735_fill(uint16_t value)"

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

void st7735_fill(uint8_t x0, uint8_t x1, uint8_t y0, uint8_t y1, uint16_t color)

Далее, через команды CASET и RASET устанавливаем рабочую область дисплея:

   chip_select_enable();

   st7735_send(LCD_C,ST77XX_CASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,x0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,x1);

   st7735_send(LCD_C,ST77XX_RASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,y0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,y1);

И здесь нужно пояснить следующее. Границы устанавливаются ВКЛЮЧИТЕЛЬНО. Это означает, что если мы, например, захотим вывести точку c координатам {x0,y0}, то х1 должен быть равен х0, а y1 должен быть равен y0. Если мы хотим вывести квадрат размером два на два, то х1 должен быть равен х0+1, а y1 должен быть равен y0+1. Аналогично, если мы хотим вывести квадрат размером 50х50, то х1 должен быть равен х0+49, а y1 должен быть равен y0+49.

Далее следует подсчитать количество пикселей заданной области. Для квадрата 50х50, оно будет 50*50=2500. Однако,в параметрах функции, рабочая область задется не ее шириной и высотой, а начальными и конечными точками. Поэтому, количество пикселей будем посчитывать так:

 uint16_t len=(uint16_t)((uint16_t)(x1-x0+1)*(uint16_t)(y1-y0+1));

Цикл заливки будет следующим:

   gpio_set(GPIOB,DC);
   SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
   SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
   SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
   for (uint16_t i=0; i < len; i++)
   {
       while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
       SPI1->DR=color;
   }
   while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));

   chip_select_disable();

   SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
   SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_4);
   SPI1->CR1 |= CR1_SPE_Set;        // enable SPI

Здесь после цикла заливки добавлен цикл ожидания передачи последнего пикселя (выделено красным), и только после этого CS-линия отключает передачу. Без данного цикла ожидания, вывести простую точку из одного пикселя не получится, а, скажем, квадрат 2х2 будет состоять всего из трёх пикселей.

Используя функцию "st7735_fill()" мы можем для удобства ввести макросы для рисования прямоугольников, с заданием начальной точки, ширины и высоты:

#define st7735_rectangle(a,b,c,d,e) st7735_fill(a,a+b-1,c,c+d-1,e)

Макрос рисования точки:

#define st7735_point(x,y,c) st7735_fill(x,x,y,y,c)

Макрос заливки всего экрана:

#define st7734_clear(x) st7735_fill(0,0x7f,0,0x9f,x)

Макрос рисования вертикальной линии:

#define st7735_line_vertical(x,y,l,d,c)  st7735_fill(x,x+d-1,y,y+l-1,c)

Макрос рисования горизонтальной линии:

#define st7735_line_vertical(x,y,l,d,c)  st7735_fill(x,x+l-1,y,y+d-1,c)

Пока это все. Для тестирования данного кода, главный цикл в "main.c" пусть будет таким:

    st7735_init(ST7735_BLACK);

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_clear(ST77XX_BLACK);
        delay_ms(3000);
        println("begin");
        st7735_rectangle(0,50,5,50,ST77XX_RED);
        st7735_rectangle(30,50,55,50,ST77XX_GREEN);
        st7735_rectangle(70,50,105,50,ST77XX_BLUE);
        st7735_point(30,130,ST77XX_WHITE);
        st7735_line_horizont(5,154,118,2,ST7735_WHITE);
        st7735_line_horizont(5,5,118,2,ST7735_WHITE);
        st7735_line_vertical(5,5,150,1,ST7735_WHITE);
        st7735_line_vertical(123,5,150,1,ST7735_WHITE);
        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Результат выполнения программы выглядит так:

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

Время выполнения отрисовки составляет 4 мс:

11:11:27.183-> begin
11:11:27.187-> end
11:11:33.194-> begin
11:11:33.198-> end

3) Команда MADCTL - установка цветового режима и ориентации дисплея ST7735

Из всех команд, что использовались при инициализации дисплея ST7735, более всего мое внимание привлекла MADCTL, которая управляет ориентацией дисплея и переключает цветовой режим RGB/BGR.

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

Давайте еще раз взглянем на документацию:

Здесь, три бита MY, MX, MV управляют ориентацией дисплея. MY - это зеркальное отображение по оси Y, MX - это зеркальное отображение по оси X, MV - это смена X и Y местами, т.е. смена портретной ориентации на альбомную и обратно.

Для управления экраном командой MADCTL введем новую функцию:

void st7735_set_madctl(uint8_t value)
{
   chip_select_enable();

   st7735_send(LCD_C,ST77XX_MADCTL);
   st7735_send(LCD_D,value);

   chip_select_disable();
}

Для проверки функции, составим такой главный цикл:

    for(;;) {
        gpio_set(GPIOC,LED);
        delay_ms(3000);
        st7735_clear(ST77XX_BLACK);
        println("begin");
        st7735_set_madctl(0xC0);
        st7735_rectangle(0,50,5,50,ST77XX_RED);

        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
        st7735_set_madctl(0xA8);
        st7735_rectangle(0,50,5,50,ST77XX_RED);
    }

Здесь, дважды выводится один и тот же графический примитив - квадрат красного цвета, но в первый раз он выводится в "нормальных" настройках, а второй выводится в альбомной ориентации экрана и в BGR режиме. В итоге, на дисплее получается два квадрата, причем при выводе второго, первый меняет цвет с красного на синий. Смена цвета происходит "по щелчку", т.е. мгновенно. Полезная функция. Также, "по щелчку" меняется цветовая гамма при инвертировании дисплея: команды 0x20 и 0x21.

4) Шрифт CP866_8х8

Про конвертацию шрифта восемь на восемь пикселей я рассказывал в предыдущей статье: "Шрифты для дисплея Nokia 1202", однако, в случае использования данного шрифта на дисплее ST7735, мне пришлось и шрифт переконвертировать, и драйвер вывода текста полностью переписывать с нуля. Но, опять же, давайте по порядку.

  1. Давайте договоримся, что выводить на дисплей будем строку. Для этой строки выделим в ОЗУ буфер размером =8*M*8, где M - число максимальное число символов в строке, 8*8 это размер одного символа в пикселах
  2. Я буду использовать альбомную (горизонтальную) ориентация дисплея, при которой длина сроки равна 160 пикселам. В данную строку вмещается 20 символов. Следовательно, размер буфера будет равен 8*20*20=1280 пикселам.
  3. Т.к. на каждый пиксел приходится два байта цвета, то тип буфера будет uint16_t, а размер буфера в байтах будет равен: 1280 * 2 = 2560 байт.
  4. Для вывода текста на максимальной скорости, с использованием DMA, понадобится ДВА буфера. Итого размер требуемой ОЗУ 2560 * 2 = 5120 байт.
  5. Символьная емкость дисплея для шрифта восемь на восемь составляет 20*16 = 320 символов.
  6. Дисплей ST7735 "плотнее" чем дисплей Nokia 1202 и уж тем более Nokia 5110. Сравните сами:

    В частности, это приводит к тому, что шрифт 8х8 который на дисплее Nokia 1202 смотрелся как "жирный", на дисплее ST7735 смотрится как "мелкий". Хотя на фотографии масштаб трудно передать, но все же:

  1. Каждый символ шрифта 8х8 у нас хранится в виде последовательности восьми байт, в то время как в видеопамяти дисплея символ занимает 8*8=64 пиксела. Нам потребуется функция которая бы преобразовывала закодированный символ в массив "uint16_t buffer[64]" для передачи на дисплей. Теоретически, имея такую функцию, мы бы весь текст могли печатать с ее помощью.
  2. Следует обратить внимание на то, в какой последовательности заполняется область дисплея, которую мы задаем командами CASET и RASET:

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

    Обратите внимание, что пиксели слева кодируются старшими битами. Т.к. формат шрифтов PSF и FNT кодируется как-раз в такой последовательности, перекодировать шрифт CP866_8x8 не составило труда. В частности, функция преобразования для шрифта PSF получилась совсем элементарной:
       if (f.is_open()) {
            f.seekg (0, f.end);
            uint32_t length_file = (uint32_t)f.tellg();
            f.seekg (0, f.beg);
    
            std::cout << "file size: " << length_file << std::endl;
    
            f.seekg(32,f.cur);  // skip 32 bytes
    
            for (int z=0; z<256; z++) {
                std::cout << std::hex;
                uint8_t data;
                for(int i=0; i<8;i++) {
                    f.read((char*)&data,sizeof(data));
                    std::cout << "0x" << (int)data << ", ";
                }
                std::cout << std::endl;
    
            }
    
            f.close();
        }

5) Печать строки на функции вывода одного символа

Самый простой способ вывода текста - это вывод по одной букве. Для этого нам нужен небольшой буфер в ОЗУ и при этом не требуется никаких DMA. Такой код вполне можно использовать на ATmega8/Arduino и прочих 8-битниках с небогатой периферией.

Первое что нам потребуется - это небольшой буфер:

#define CHAR_LEN 64                 // 64=(8 x 8 )  i.e. width * height
#define MAX_CHAR 1                  //
#define BUF_LEN MAX_CHAR*CHAR_LEN   // =64

volatile uint16_t buf[BUF_LEN];

В данном случае буфер выделен всего под один символ. Спецификатор "volatile" объявлен для того, что бы оптимизация GCC на "-Os" и "-O2" не "убивала" наш буфер.

Далее нам потребуется функция, которая будет декодировать символ наш буфер:

void cp866_char_to_buf(uint8_t x_pos, uint8_t ch, uint16_t fg_color, uint16_t bg_color) {

    uint16_t c = (((uint16_t)ch) * FONT_CP866_8_CHAR_WIDTH);    // FONT_CP866_8_CHAR_WIDTH=8
    for (uint8_t i=0; i < FONT_CP866_8_CHAR_WIDTH; i++) {
        uint8_t data=CP866_8x8[c+i];

        uint16_t index= (i*FONT_CP866_8_CHAR_WIDTH);            // FONT_CP866_8_CHAR_WIDTH=8
        for (uint8_t j=0; j<FONT_CP866_8_CHAR_WIDTH;j++) {

            buf[index++]=(data & 0x80) ? fg_color : bg_color;
            data = data << 1;
        }
    }
}

В принципе, ничего сложного.

Теперь нам потребуется функция, которая будет выводить полученный буфер на дисплей по заданным координатам:

void st7735_send_char(uint8_t x, uint8_t y, uint8_t ch, uint16_t fg_color, uint16_t bg_color) {

    cp866_char_to_buf(x,ch, fg_color,bg_color);

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x+7);

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y+7);

    st7735_send(LCD_C,ST77XX_RAMWR);
#ifdef HW_SPI

    gpio_set(GPIOB,DC);

    // switch SPI to 16-bit mode
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
    for (uint8_t i=0;i<BUF_LEN;i++) {

           uint16_t pixel=buf[i];

           while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
           SPI1->DR=pixel;
    }

    while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
    chip_select_disable();

    // switch SPI to 8-bit mode
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
#else
   chip_select_disable();
#endif

}

Фактически - это слегка измененная функция заливки, которая заливает квадрат 8х8 содержимым буфера. В самом начале происходит вызов функции "cp866_char_to_buf()". Возможно ее следовало бы объявить как "inline", т.к. она нигде больше не вызывается, но впоследствии, она будет вызываться также из функции печати строки одним массивом разом. Вариант с программным SPI я вырезал для простоты восприятия.

Имея функцию печати символа, мы всегда можем получить функцию печати строки:

void cp866_print_str(char *str, uint8_t x, uint8_t y, uint16_t fg_color, uint16_t bg_color) {
    uint8_t i=0;
    while (*str) {
        st7735_send_char(x + i*8,y,*str++,fg_color,bg_color);
        ++i;
    }
}

Соответственно, имея функцию печати строки, мы можем получить функцию печати целого числа:

void cp866_print_num(uint32_t num, uint8_t x, uint8_t y, uint16_t fg, uint16_t bg){
    uint8_t n[numlen];
    uint8_t *s=n+(numlen-1);
    *s=0;           // EOL
    do {
        *(--s)=('0' + num%10);
        num = num/10;
    } while (num>0);
    cp866_print_str((char*)s,x,y,fg,bg);
}

Для проверки работы написанного кода, в главном цикле напишем следующее:

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_clear(ST77XX_BLACK);
        delay_ms(3000);
        println("begin");
        for (uint8_t i=0; i<16;i++) {
            cp866_print_str("\xe2\xa5\xaa\xe3\xe9\xa8\xa9\x20\xe1\xe7\xa5\xe2\x3a\x20",10,(i*8),ST7735_WHITE, ST7735_BLACK);
            cp866_print_num(count++,120,(i*8),ST7735_WHITE, ST7735_BLACK);
        }
        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Результат работы:

Давайте откроем монитор последовательного порта, и оценим время вывода текста на экран:

15:04:32.288-> begi15:04:32.290-> n
15:04:32.311-> end
15:04:37.464-> begin
15:04:37.487-> end
15:04:42.639-> begin
15:04:42.661-> end15:04:42.662-> 

Итого имеем где-то 23 мс для заполнения экрана. В данном случае не весь экран заполняется текстом, а лишь только 17 символов. Если заполнять дисплей текстом полностью, по 20 символов, то время вывода страницы на дисплей составит около 28 мс:

14:14:46.342-> b14:14:46.343-> egin
14:14:46.370-> end
14:14:52.377-> begin
14:14:52.404-> end14:14:52.405-> 
14:14:58.412-> begin
14:14:58.440-> end
14:15:04.446-> be14:15:04.447-> gin
14:15:04.474-> end

Это дает нам где-то 36 FPS. В принципе, неплохая отдача при минимуме вложений.

6) Печать целой строки

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

В частности, для 20-символьной строки размер буфера составит 2560 байт:

#define CHAR_LEN 64                 // 64=(8 x 8 )  i.e. width * height
#define MAX_CHAR 20                 // =160/8
#define BUF_LEN MAX_CHAR*CHAR_LEN   // =1280

volatile uint16_t buf[BUF_LEN];

Главная сложность при таком подходе - правильно сформировать буфер строки так, что бы буквы следовали рядами друг за другом, а не "сначала одна буква, потом другая, потом следующая и т.д.". Т.е. как-то так:

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

void cp866_char_to_buf(uint8_t x_pos, uint8_t ch, uint8_t len, uint16_t fg_color, uint16_t bg_color) {
    if ((x_pos + FONT_CP866_8_CHAR_WIDTH) > LCD_X)
        return;

    uint16_t c = ((uint16_t)ch - FONT_CP866_8_START_CHAR) * (FONT_CP866_8_CHAR_WIDTH * FONT_CP866_8_CHAR_HEIGHT);
    for (uint8_t i=0; i < FONT_CP866_8_CHAR_WIDTH; i++) {
        uint8_t data=CP866_8x8[c+i];

        uint16_t index= x_pos + (i*len*8);
        for (uint8_t j=0; j<8;j++) {

            buf[index++]=(data & 0x80) ? fg_color : bg_color;
            data = data << 1;
        }
    }
}

Функция печати строки, или печати полученного буфера, не сильно отличается от предыдущего примера:

void st7735_print_str(char* str, uint8_t len, uint8_t x, uint8_t y, uint16_t fg_color, uint16_t bg_color) {

    for(uint8_t i=0; i<len;i++) {
        cp866_char_to_buf((i*8),str[i], len,fg_color,bg_color);
    }

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(x+len*8-1));

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(y+len*8-1));

    st7735_send(LCD_C,ST77XX_RAMWR);

#ifdef HW_SPI
    gpio_set(GPIOB,DC);
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
    for (uint16_t i=0; i<(CHAR_LEN * len); i++) {
        while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
        SPI1->DR=buf[i];
    }

    while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
    chip_select_disable();

    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
#else
   chip_select_disable();
#endif

}

И перейдем сразу к результату, время выполнения предыдущего примера уменьшилось на 4 мс:

11:20:19.178-> beg11:20:19.178-> in
11:20:19.198-> en11:20:19.198-> d
11:20:24.350-> beg11:20:24.351-> in
11:20:24.370-> en11:20:24.371-> d
11:20:29.522-> begi11:20:29.523-> n
11:20:29.542-> end11:20:29.543-> 
11:20:34.695-> begi11:20:34.696-> n
11:20:34.715-> end11:20:34.716-> 
11:20:39.867-> begin11:20:39.868-> 
11:20:39.887-> end11:20:39.888-> 
11:20:45.039-> begin11:20:45.040->
11:20:45.059-> end

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

09:55:59.218-> begin
09:55:59.242-> end
09:56:05.249-> begin
09:56:05.273-> end
09:56:11.281-> begin
09:56:11.305-> end

Т.о. имеем быстродействие в 42 fps. Чтобы его еще ускорить, следует использовать DMA.

7) Ускоряемся до 100 fps с помощью DMA

Для того, чтобы кардинально увеличить скорость вывода текста на дисплей, следует использовать DMA.

Работает это так. Процесс разделяется на две стадии: 1) преобразование строки в буфер дисплея (функция "cp866_char_to_buf()"); 2) вывод буфера на дисплей при помощи DMA как бы "в фоне".

Данный способ не избавляет от задержек (блокировок) в выполнении программы. Если функция "cp866_char_to_buf()" будет работать медленно, то SPI будет простаивать. Если же она будет работать быстро, то придется ждать, когда SPI освободится.

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

#define CHAR_LEN 64                 // 64=(8 x 8 )  i.e. width * height
#define MAX_CHAR 20                 // =160/8
#define BUF_LEN MAX_CHAR*CHAR_LEN   // =1280

uint16_t buf[BUF_LEN];
uint16_t buf2[BUF_LEN];

Настройка DMA для работы с SPI будет выглядеть следующим образом.

Для начала следует включить тактирование DMA модуля:

#ifdef HW_SPI
    RCC->APB2ENR |= RCC_APB2Periph_SPI1;            // enable SPI1
    RCC->AHBENR  |= RCC_AHBENR_DMA1EN;              // enable DMA1
#endif

Далее идет конфигурация SPI и DMA:

#ifdef HW_SPI
    // --- SPI1 setup ----
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    //SPI1->CR2 |= SPI_CR2_TXDMAEN;                     // enable DMA request
    SPI1->CR1 |= SPI_CR1_SPE;
    // --- DMA1 Setup --------------------
    DMA1_Channel3->CCR |= DMA_CCR1_MSIZE_0;             // =0x0400 (Memory Size is 16-Bit)
    DMA1_Channel3->CCR |= DMA_CCR1_PSIZE_0;             // =0x0100 (Peripheral Size is 16-Bit)
    DMA1_Channel3->CCR |= DMA_CCR1_DIR;                 // =0x0010 (Read from Memory)
    DMA1_Channel3->CCR |= DMA_CCR1_MINC;                // =0x0080 (Memory Increment Enable)
    DMA1_Channel3->CCR |= DMA_CCR1_PL;                  // =0x3000 (Priority Level is Very High)
#endif

Здесь SPI пока настраивается на работу в 8-битном режиме без DMA, т.к. в этом режиме посылаются команды на дисплей ST7735. DMA настраивается на работу в 16-битном режиме, но сам DMA пока не включается.

Разрешаем прерывание DMA:

    NVIC_SetPriority(DMA1_Channel3_IRQn,1);             // set priority for DMA1_Channel3 IRQ
    NVIC_EnableIRQ(DMA1_Channel3_IRQn);                 // enable DMA1_Channel3 IRQ

Далее работает функция вывода текста посредством DMA. Ее можно разбить на несколько блоков. В первом блоке текст преобразуется в буфер, для вывода на дисплей:

void st7735_print_str_DMA(char* str, uint8_t len, uint8_t x, uint8_t y, uint16_t fg_color, uint16_t bg_color) {

    bool isDone=is_done_DMA;

    if (isDone || (buf_num != 0)) {
        buf_num=0;
        for(uint8_t i=0; i<len;i++)
            cp866_char_to_buf((i*8),str[i], len,fg_color,bg_color, buf);
    } else {
        buf_num=1;
        for(uint8_t i=0; i<len;i++) {
            cp866_char_to_buf((i*8),str[i], len,fg_color,bg_color, buf2);
        }
    }

Глобальная переменная "buf_num" хранит номер буфера который использовался в последний раз. Он считается пока "занятым", поэтому вызывается функция преобразования текста cp866_char_to_buf() в "свободный" буфер.

Далее ждем пока SPI шина освободится. После завершения работы DMA срабатывает прерывание, в обработчике которого сбрасывается значение глобальной переменной "is_done_DMA":

    while (!is_done_DMA) {};

После того как SPI шина освободилась, посылаем команды CASET и RASET, настраиваем работу SPI и DMA, и в конце запускаем DMA:

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(x+len*8-1));

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(y+len*8-1));

    st7735_send(LCD_C,ST77XX_RAMWR);

#ifdef HW_SPI
    gpio_set(GPIOB,DC);
    is_done_DMA=false;
    // DMA setup
    DMA1_Channel3->CCR&=~(DMA_CCR1_EN);             //Disable DMA
    DMA1_Channel3->CPAR=(uint32_t)(&SPI1->DR);      //Address of uses peripheral interface
    if (buf_num == 0)
        DMA1_Channel3->CMAR=(uint32_t)&buf;         //Address of buffer with data
    else
        DMA1_Channel3->CMAR=(uint32_t)&buf2;        //Address of buffer with data
    DMA1_Channel3->CNDTR=(len * CHAR_LEN);          // Size of data
    DMA1_Channel3->CCR|=DMA_CCR1_TCIE;              // Enable of IRQ
    DMA1->IFCR |= DMA_IFCR_CGIF3;                   // Clear all interrupt flags

    // SPI setup
    SPI1->CR1 &= CR1_SPE_Reset;                     // disable SPI for setup
    SPI1->CR1  |= (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR2 |= SPI_CR2_TXDMAEN;                   // enable DMA request
    SPI1->CR1 &= ~SPI_CR1_SPE;                      // clear

    // Enable SPI+DMA
    SPI1->CR1 |= CR1_SPE_Set;                   // enable SPI
    DMA1_Channel3->CCR|=DMA_CCR1_EN;            //Включить DMA
#else
   chip_select_disable();
#endif
}

На этом работа функции отправки данных заканчивается.

После завершения работы DMA вызывается обработчик прерывания DMA, который отключает DMA и настраивает SPI на обычный 8-битный режим работы без DMA:

void DMA1_Channel3_IRQHandler(void) {

    DMA1->IFCR |= DMA_IFCR_CGIF3;               // Clear all interrupt flags
    DMA1_Channel3->CCR &= ~(DMA_CCR1_EN);       // Disable DMA1

    chip_select_disable();

    SPI1->CR1 &= CR1_SPE_Reset;                 // Disable SPI for setup
    SPI1->CR2 &= ~SPI_CR2_TXDMAEN;              // Disable DMA request
    SPI1->CR1 = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2); // toggel to 8-bit SPI mode
    SPI1->CR1 |= SPI_CR1_SPE;
    SPI1->CR1 |= CR1_SPE_Set;                   // Enable SPI after setup

    is_done_DMA=true;
}

Давайте посмотрим на скорость вывода текста:

17:39:33.537-> begin
17:39:33.548-> end
17:39:39.554-> b17:39:39.555-> egin
17:39:39.566-> end
17:39:45.573-> be17:39:45.574-> gin
17:39:45.585-> end
17:39:51.591-> beg17:39:51.592-> in
17:39:51.603-> end

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

8) RLE-cжатие для шрифтов 8x16

Честно говоря, шрифт 8х8 на дисплее ST7735 выглядит мелковатым и трудно читаемым. Поковырявшись еще в консольных шрифтах, я также обнаружил шрифты размером 8х14 и 8х16. Наиболее интересным мне показался последний шрифт, который в ширину имеет 8 пикселов, а в высоту 16. Его глифы выглядят примерно так:

На экране обычного монитора, шрифты 8x8 и 8x16 визуально смотрятся так:

Если смотреть издалека, то шрифт 8х16 выглядит более читаемым, и я подумал, что для дисплея ST7735 он более подойдет.

Сконвертировать еще один шрифт несложно, но полный набор в 256 глифов будет занимать 4096 байт флеш-памяти. Два таких шрифта будут занимать уже 8192 байт. А если нам понадобятся более крупные шрифты, то счет уже пойдет на десятки килобайт. И тут самое время задуматься о сжатии данных шрифтов, т.к. если посмотреть на изображения глифов, то в глаза бросаются "пустые" строки, последовательность которых можно попытаться как-то закодировать.

Кодирование последовательностей одинаковых данных называется RLE-сжатем, при котором последовательность одинаковых байт заменяется парой: "число повторов + значащий байт".

    RLE - сжатие не является каким-то стандартом, это общее название метода сжатия, и для сжатия шрифтов я разработал собственный RLE-алгоритм, суть которого в следующем:
  1. Т.к. нужен будет произвольный доступ к любому символу шрифта, сжиматься будет не массив шрифта, а 256 последовательностей по 16 байт каждая, т.е. каждый символ отдельно. Это нужно для того, чтобы в последствие можно было бы удалить неиспользуемые символы из шрифта без полного перекодирования.
  2. Т.к. на выходе для каждого символа получаются последовательности различной длины, для произвольного доступа к символам потребуется таблица смещений, в которой для каждого символа будет указано смещение от начала массива шрифта. Т.о., шрифт будет состоять из ДВУХ массивов: сжатого шрифта и таблицы смещений.
  3. Таблица смещений будет иметь тип "uint16_t table[256]" (для полного набора). Итого 512 байт. Т.о. шрифт должен сжиматься больше,чем на 512 байт, иначе сжатие не будет иметь смысла.
  4. Число повторов называется индексом. Т.к. на входе всего 16 байт, индекс может принимать значение от 1 до 16.
  5. Число большее или равное 0xF0 будет являться индексом, где младшие четыре бита выражают значение индекса. При этом, ноль означает число 16, т.к. ноль повторов быть не может.
  6. Числа со значениями 0xF0 и больше кодируются последовательностю "индекс:число". Например: "0xF0" как "0xF1, 0xF0". Или "0xF2, 0xF2" как "0xF2, 0xF2", и т.д.
  7. Если число неповторяющиеся, и его значение меньше чем 0xF0, то индекс пропускается, т.е. он считается равным единице, и число записывается как есть, без индекса.

Данный тип сжатия не должен сказаться на производительности вывода текста. Декодирование осуществляется по довольно простому алгоритму и требует только 16 байт в ОЗУ под раскодированный символ.

В целом, для полного набора шрифта CP866_8x16 получилось 2608 байт + 512 байт таблица смещений. Т.е. сжатие дало экономию около одного килобайта, или 25 процентов.

Лучше всего сжимаются сжимаются символы псевдографики, там, где имеются одинаковые вертикальные последовательности. Например, следующие глифы:

сжались следующим образом:

0xf7, 0x18, 0xf1, 0xf8, 0xf8, 0x18,                     // Символ 180 0xB4  ┤ lenght: 6 offset: 1994
0xf5, 0x18, 0xf1, 0xf8, 0x18, 0xf1, 0xf8, 0xf8, 0x18,   // Символ 181 0xB5  ╡ lenght: 9 offset: 2000
0xf7, 0x36, 0xf1, 0xf6, 0xf8, 0x36,                     // Символ 182 0xB6  ╢ lenght: 6 offset: 2009
0xf7, 0x0, 0xf1, 0xfe, 0xf8, 0x36,                      // Символ 183 0xB7  ╖ lenght: 6 offset: 2015
0xf5, 0x0, 0xf1, 0xf8, 0x18, 0xf1, 0xf8, 0xf8, 0x18,    // Символ 184 0xB8  ╕ lenght: 9 offset: 2021
0xf5, 0x36, 0xf1, 0xf6, 0x6, 0xf1, 0xf6, 0xf8, 0x36,    // Символ 185 0xB9  ╣ lenght: 9 offset: 2030
0xf0, 0x36,                                             // Символ 186 0xBA  ║ lenght: 2 offset: 2039
0xf5, 0x0, 0xf1, 0xfe, 0x6, 0xf1, 0xf6, 0xf8, 0x36,     // Символ 187 0xBB  ╗ lenght: 9 offset: 2041

Единственный символ, который дал избыточность кодирования - это:

Он "сжался" в последовательность из 19 байт:

0x0, 0x80, 0xc0, 0xe0, 0xf1, 0xf0, 0xf1, 0xf8, 0xf1, 0xfc, 0xf1, 0xf8, 0xf1, 0xf0, 0xe0, 0xc0, 0x80, 0xf4, 0x0, // Символ 16 0x10 ► lenght: 19 offset: 177

Все остальные глифы сжались либо в 16 байт, либо меньше.

9) Печать текста шрифтом 8x16

Для начала приведу значения именованных констант для шрифта 8х16:

#define FONT_CP866_16_LENGTH           256      // full cp866 code page
#define FONT_CP866_16_START_CHAR       0
#define FONT_CP866_16_CHAR_WIDTH       8        
#define FONT_CP866_16_CHAR_HEIGHT      2        
#define FONT_CP866_16_ARRAY_LENGTH   2608       // compressed

Для декомпрессии символа нужен будет буфер на 16 байт, для полученного результата:

volatile uint8_t sym_buf[(FONT_CP866_16_CHAR_WIDTH*FONT_CP866_16_CHAR_HEIGHT)];

Функция декомпрессии символа в буфер у меня получилась такой:

void cp866x16_decompress_sym(uint8_t sym) {
    uint16_t idx=CP866_8x16_ID[sym];
    uint8_t i=0;
    while (i<(FONT_CP866_16_CHAR_WIDTH*FONT_CP866_16_CHAR_HEIGHT)) {
        uint8_t s=CP866_8x16[idx];
        if (s >= 0xf0) {
            s &= 0x0f;
            if (s==0) s=16;

            for (uint8_t j=0; j<s;j++) {
                uint8_t value=CP866_8x16[idx+1];
                sym_buf[i+j]=value;
            }
            i += s;
            idx +=2;
        } else {
            sym_buf[i]=s;
            ++idx; ++i;
        }
    }
}

По идее, данная функция должна выполняться "на лету" и не должна сказываться на скорости вывода текста.

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

void cp866_16_char_to_buf(uint8_t x_pos, uint8_t ch, uint8_t len, uint16_t fg_color, uint16_t bg_color, uint16_t* buffer) {
    if ((x_pos + FONT_CP866_16_CHAR_WIDTH) > LCD_X)
        return;
    cp866x16_decompress_sym(ch);

    for (uint8_t i=0; i < (FONT_CP866_16_CHAR_WIDTH * FONT_CP866_16_CHAR_HEIGHT); i++) {
        uint8_t data=sym_buf[i];

        uint16_t index= x_pos + (i*len*8);
        for (uint8_t j=0; j<8;j++) {

            buffer[index++]=(data & 0x80) ? fg_color : bg_color;
            data = data << 1;
        }
    }
}

Здесь красным выделены отличия от варианта для шрифта 8х8.

Функция печати символа:

void cp866x16_send_char(uint8_t x, uint8_t y, uint8_t ch, uint16_t fg_color, uint16_t bg_color) {

    cp866_16_char_to_buf(x,ch, 1, fg_color,bg_color,buf);

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x+7);

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y+15);

    st7735_send(LCD_C,ST77XX_RAMWR);
#ifdef HW_SPI

    gpio_set(GPIOB,DC);
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
    for (uint8_t j=0;j<128;j++) {
        uint16_t color;

        color=buf[j + x ];
        while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
        SPI1->DR=color;
    }

    while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
    chip_select_disable();

    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
#else
   chip_select_disable();
#endif

}

Здесь отличия лишь в нескольких константах.

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

В главный цикл поставим следующее:

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_clear(ST77XX_BLACK);
        delay_ms(3000);
        println("begin");

        for (uint8_t i=0; i<8;i++) {
            cp866x16_print_str_from_char("\xe2\xa5\xaa\xe3\xe9\xa8\xa9\x20\xe1\xe7\xa5\xe2\x3a\x20", 12, (i*16), ST7735_GREEN, ST7735_BLACK);
            cp866x16_print_num_from_char(count++,120,(i*16),ST7735_GREEN, ST7735_BLACK);
        }
        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Результат работы прошивки выглядит так:

Это уже вполне читаемый шрифт.

Если замерить скорость вывода текста:

09:16:32.123-> begin09:16:32.124-> 
09:16:32.138-> end
09:16:38.145-> begin
09:16:38.160-> end
09:16:44.167-> begin
09:16:44.182-> end

Получается 15 мс. Это 66 fps. Скорость работы не уменьшилась, а даже увеличилась. Парадокс))

Если текст печатать не по буквам, а единой строкой, то придется размер буфера строки увеличить в два раза:

uint16_t buf[2560];              // 5120 bytes

В функции печати строки будет пара изменений:

void cp866x16_print_str(char* str, uint8_t len, uint8_t x, uint8_t y, uint16_t fg_color, uint16_t bg_color) {
    for(uint8_t i=0; i<len;i++) {
        cp866_16_char_to_buf((i*8),str[i], len,fg_color,bg_color, buf);
    }

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(x+len*8-1));

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,(y+len*16-1));

    st7735_send(LCD_C,ST77XX_RAMWR);

#ifdef HW_SPI
    gpio_set(GPIOB,DC);
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
    for (uint16_t i=0; i<(CHAR_LEN * len); i++) {
        while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
        SPI1->DR=buf[i];
    }

    while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
    chip_select_disable();

    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
#else
   chip_select_disable();
#endif

}

Здесь вдвое увеличивается область печати, и константа CHAR_LEN имеет значение уже не 64, а 128.

Если посмотреть на скорость работы:

10:24:33.554-> begin
10:24:33.567-> end10:24:33.568-> 
10:24:39.574-> begin
10:24:39.587-> e10:24:39.588-> nd
10:24:45.594-> begin
10:24:45.608-> end

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

Если посмотреть на быстродействие с использованием DMA, то на отрисовку дисплея затрачивается около 13 мс:

11:50:51.992-> begin
11:50:52.005-> end
11:50:58.012-> begin
11:50:58.025-> end
11:51:04.031-> begin
11:51:04.044-> end

Т.е. какого-то серьезного выигрыша по скорости, в данном случае нет.

Драйвер шрифта 8х16 позволяет выводить и другие шрифты данного размера. Я на скорую руку преобразовал шрифт "Cybercafe", и добавил его в прошивку. Шрифт не содержит кириллицу, только латинские глифы, псевдографику и какие-то непонятные символы. Выглядит так:

На этом хотелось бы поставить не точку, но запятую. В целом, мне понравился вывод текста на функции одного символа. Минимум ресурсов при максимуме результата. Вывод текста все-таки не такая сложная задача, чтобы для этого требовалось DMA. Но работу с DMA надо было рассмотреть, т.к. в будущем это обязательно пригодится. Все исходники и готовые прошивки можно скачать с портала GitLab по ссылке https://gitlab.com/flank1er/stm32_bare_metal. Для данной статьи это проекты:

  • 33_st7735_fill
  • 34_st7735_cp866_char
  • 35_st7735_cp866_string
  • 36_st7735_cp866_DMA
  • 37_st7735_cp866_8x16_char
  • 38_st7735_cp866_8x16_string
  • 39_st7735_8x16_DMA
  • 40_st7735_cybercafe_DMA

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

Продолжение (добавлено 22 мая 2023г.)

10) Шрифт Terminus 8x16

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

В шрифт включены наборы символов для размеров 6×12, 8×14, 8×16, 10×18, 10×20, 11×22, 12×24, 14×28 и 16×32 пикселей[2]. Доступно нормальное и полужирное (за исключением 6×12) начертание.

Шрифт моноширинный, без засечек. Лицензия OFL - свободная. Доступны кодировки KOI8-R и CP1251, кодировка CP866 не поддерживается, поэтому пришлось выбрать KOI8-R. Для нее доступны следующие шрифты:

PSF_KOI8_RV = ter-k14n.psf ter-k14b.psf ter-k16n.psf ter-k16b.psf
PSF_KOI8_R  = ter-k12n.psf ter-k18n.psf ter-k18b.psf ter-k20n.psf ter-k20b.psf ter-k22n.psf ter-k22b.psf ter-k24n.psf ter-k24b.psf ter-k28n.psf ter-k28b.psf ter-k32n.psf ter-k32b.psf

Здесь цифра - это размер шрифта по вертикали. Каждый шрифт доступен в обычном начертании (суффикс n) и в полужирном (суффикс b).

Для примеров, я возьму шрифты на 16пх, 24пх и 32пх. Всего шесть шрифтов, т.к. каждый шрифт в двух вариантах: "нормальный" и "полужирный".

Если говорить про "нормальный" шрифт на 16пх, то его начертание выглядит так:

Я не уверен, в чем причина, но строчные буквы "съехали" вверх со смещением 0x20. Я подумал, что с этим делать, и решил, что проще будет из кода строчных букв вычитать число 32.

На экране дисплея шрифт выглядит следующим образом:

Если взять полужирный шрифт, то в сравнении с нормальным шрифтом, он выглядит так:

На экране дисплея ST7735 полужирный шрифт Terminus 8x16 выглядит так:

11) Шрифт Terminus 12x24

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

Согласно стандарту PSF, если число пикселей по горизонтали больше восьми, но меньше шестнадцати, то данные кодируются парой байт. При этом "лишние", младшие(LSB), биты забиваются нулями. Т.о., один символ шрифта 12*24 будет занимать: 2*24 (горизонталь * вертикаль) =48 байт. Полный набор глифов из 256 символов, будет занимать (48*256)= 12288 байт.

Например, нулевой глиф, который в шрифте Terminus выглядит следующим образом:

Будет кодироваться последовательностью из 48 байт выделенных красным:

00000000  72 b5 4a 86 00 00 00 00  20 00 00 00 01 00 00 00  |r.J..... .......|
00000010  00 01 00 00 30 00 00 00  18 00 00 00 0c 00 00 00  |....0...........|
00000020  00 00 00 00 00 00 00 00  7f c0 40 40 40 40 40 40  |..........@@@@@@|
00000030  40 40 40 40 40 40 40 40  40 40 40 40 40 40 40 40  |@@@@@@@@@@@@@@@@|
00000040  40 40 40 40 7f c0 00 00  00 00 00 00 00 00 00 00  |@@@@............|

В этой последовательности, младшие 4 бита всегда нули:

00000000  72 b5 4a 86 00 00 00 00  20 00 00 00 01 00 00 00  |r.J..... .......|
00000010  00 01 00 00 30 00 00 00  18 00 00 00 0c 00 00 00  |....0...........|
00000020  00 00 00 00 00 00 00 00  7f c0 40 40 40 40 40 40  |..........@@@@@@|
00000030  40 40 40 40 40 40 40 40  40 40 40 40 40 40 40 40  |@@@@@@@@@@@@@@@@|
00000040  40 40 40 40 7f c0 00 00  00 00 00 00 00 00 00 00  |@@@@............|

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

  1. Кодируются двух-байтные данные, последовательностями по 24. Т.е. также, каждый символ сжимается отдельно.
  2. Производится выравнивание данных по правому краю, путем сдвига вправо на 4 бита. В освободившиеся 4 бита слева записывается индекс. Если индекс имеет значение ноль, то он равняется 16.
  3. Т.к. индекс может принимать максимальное значение 16 то, чтобы закодировать повторяющуюся последовательность большей длины, следует использовать двойное кодирование. Например, последовательность из двадцати четырех нулей будет кодироваться парой: "0x0, 0x8000".

Для примера, приведенная выше последовательность для нулевого глифа: "0x0, 0x0, 0x0, 0x0, 0x7fc0, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x4040, 0x7fc0, 0x0, 0x0, 0x0, 0x0, 0x0"
будет кодироваться следующим образом: "0x4000, 0x17fc, 0xd404, 0x17fc, 0x5000"

В целом, полный набор из 256 глифов сжимается с 12кБ до 4.5кБ, вместе с таблицей смещений. Т.е. коэффициент сжатия достигает 2.5 раза, а не на 25%, как в случае с 16-пиксельным шрифтом.

Набор глифов шрифта Terminus-k24 с нормальным и полужирным начертанием выглядит так:

В данном случае кодировка KOI8-R корректная, без смещений.

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

static volatile uint16_t sym16_buf[(TERMINUS_K12x24N_CHAR_WIDTH*TERMINUS_K12x24N_CHAR_HEIGHT)];

Здесь именованные константы "TERMINUS_K12x24N_CHAR_WIDTH" равна 12, а "TERMINUS_K12x24N_CHAR_HEIGHT" равна 2.

Функция декомпрессии глифа у меня вышла такой:

void KOI8Rx24_decompress_sym(uint8_t sym, uint8_t font) {
    uint16_t idx;

    idx=(!font) ? TERMINUS_K12x24N_ID[sym] : TERMINUS_K12x24B_ID[sym];
    uint8_t i=0;
    while (i<(TERMINUS_K12x24N_CHAR_WIDTH*TERMINUS_K12x24N_CHAR_HEIGHT)) {
        uint16_t s,j;
        s=(!font) ? TERMINUS_K12x24N[idx] : TERMINUS_K12x24B[idx];
        j=(s >> 12);
        s &= (uint16_t)0x0fff;

        if (j == 0) j=16;

        for (uint8_t k=0;k<j;k++)
            sym16_buf[i++]=s;

        ++idx;

    }
}

Через параметр функции "font" можно задавать шрифт: нормальный или полужирный:

#define REGULAR 0x0
#define BOLD    0x1

Остальные функции типовые. Преобразование глифа в дисплейный буфер:

void KOI8Rx24_char_to_buf(uint8_t x_pos, uint8_t ch, uint8_t len, uint8_t font, uint16_t fg_color, uint16_t bg_color, uint16_t* buffer) {
    if ((x_pos + TERMINUS_K12x24N_CHAR_WIDTH) > LCD_X)
        return;

    KOI8Rx24_decompress_sym(ch, font);

    for (uint8_t i=0; i < (TERMINUS_K12x24N_CHAR_WIDTH*TERMINUS_K12x24N_CHAR_HEIGHT); i++) {
        uint16_t data=sym16_buf[i];
        data = data <<4;

        uint16_t index= (i*len*TERMINUS_K12x24N_CHAR_WIDTH);
        for (uint8_t j=0; j<TERMINUS_K12x24N_CHAR_WIDTH;j++) {

            buffer[index++]=(data & (uint16_t)0x8000) ? fg_color : bg_color;
            data = data << 1;
        }
    }
}

Функция вывода буфера на дисплей ST7735 в данном случае примет следующий вид:

void KOI8Rx24_send_char(uint8_t x, uint8_t y, uint8_t ch, uint8_t font, uint16_t fg_color, uint16_t bg_color) {

    KOI8Rx24_char_to_buf(x,ch, 1, font, fg_color,bg_color,buf);

    chip_select_enable();
    // 8x8 area
    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,x+11);

    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,y+23);

    st7735_send(LCD_C,ST77XX_RAMWR);
#ifdef HW_SPI

    gpio_set(GPIOB,DC);
    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
    for (uint16_t j=0;j<CHAR_LEN;j++) {
        uint16_t color;

        color=buf[j];
        while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
        SPI1->DR=color;
    }

    while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
    chip_select_disable();

    SPI1->CR1 &= CR1_SPE_Reset;      // disable SPI for setup
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);
    SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
#else
   chip_select_disable();
#endif

}

Здесь именованные константа "CHAR_LEN" равняется 24*12=288.

Для демонстрации, в главном цикле модуля "main.c" я задал печать на дисплей ST7735 обоими шрифтами.

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_clear(ST77XX_BLACK);
        delay_ms(3000);
        println("begin");
        for (uint8_t i=0; i<5;i++) {
              if (i%2) {
                KOI8Rx24_print_str_from_char("\xd3\xde\xc5\xd4\x3a", 10,(i*24),ST7735_GREEN,ST7735_BLACK, REGULAR);
                KOI8Rx24_print_num(count,80, (i*24),ST7735_GREEN,ST7735_BLACK, REGULAR);
              } else {
                KOI8Rx24_print_str_from_char("\xd3\xde\xc5\xd4\x3a", 10,(i*24),ST7735_GREEN,ST7735_BLACK, BOLD);
                KOI8Rx24_print_num(count,80, (i*24),ST7735_GREEN,ST7735_BLACK,BOLD);
              }
              ++count;
        }
        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Результат выглядит так:

Здесь первая, третья и пятая строка выведены полужирным шрифтом, а вторая и четвертая - нормальным. Время обновления дисплея - около 9 мс:

13:39:59.088-> begin
13:39:59.097-> end
13:40:05.105-> begin
13:40:05.114-> end

Функции вывода полной строки и с использованием DMA я приводить не буду, т.к. они типовые, готовый код можно посмотреть в репозитории. Время обновления дисплея с использованием DMA зависит от оптимизации компилятора. При "-O2" оно составляет 7 мс, но в этом случае не всегда корректно работает функция печати числа. При "-Os" она не работает совсем, а при отключенной оптимизации, т.е. "-O0", все работает корректно, но время обновления дисплея ST7735 составляет те же 9 мс. В данном случае следовало бы переписать функцию вручную на ассемблере.

12) Шрифт Terminus 16x32

Шрифт размером 16х32 опять заставляет менять алгоритм сжатия, т.к. в этом варианте нет свободных 4 бита, куда можно было бы записать индекс, а кодировать двухбайтные последовательности таким же двухбайтным индексом, при том, что необходимо лишь 5 бит под индекс для последовательности из 32 элементов, ну как-то совсем накладно.

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

Общий принцип используемого варианта RLE-сжатия описан в википедии: Кодирование длин серий

Возьмём строку, состоящую из большого количества неповторяющихся символов:
ABCABCABCDDDFFFFFF алфавит целых чисел можно разделить на две части: положительные и отрицательные числа. Положительные числа используют для записи количества повторов одного символа, а отрицательные — для записи количества неодинаковых символов, следующих друг за другом. Посчитаем символы с учётом вышесказанного: * сначала друг за другом следуют 9 не одинаковых символов: «ABCABCABC»; * затем записаны 3 символа «D»; * наконец записаны 6 символов «F». Сжатая строка запишется в виде: -9ABCABCABC3D6F Исходная строка состоит из 18 символов, а сжатая — из 15. Размер данных уменьшился в 18/15=1.2 раза.

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

    В целом, сжатие осуществляется по следующим принципам:
  • Индекс имеет размер один байт, в котором установленный старший бит указывает на последовательность неповторяющихся данных, а сброшенный старший бит указывает на последовательность повторяющихся данных.
  • Как и в предыдущем случае, кодируются 16-битные последовательности, но они разбиваются на 8-битные пары, т.к. индекс 8-битный. Таблица со шрифтом имеет соответственно тип "uint8_t".
  • Общепринятый формат отрицательных числ не используются, для "отрицательного" числа просто выставлется старший бит. Т.е. -1 это 0x81, а не 0xfe.

Про индекс.

Для сжатия требуется 5-битный индекс, который позволит индексировать последовательность из 32 элементов. При этом 0 равняется 32, т.к. ноль повторов быть не может.

Еще один бит явлется флагом отрицательного числа.

Остается два бита, которые можно использовать по своему усмотрению. Например, для комбинации со "словарным" методом сжатия.

Т.к. имеется два шрифта Regular и Bold, которые требуется сжать, можно определить самые часто встречающиеся значения и закодировать их повторы с помощью лишних двух бит индекса.

Я сделал частотный анализ для шрифта Regular, и самым часто встречающимся значеним является ноль:

$ grep -Eo "0x0," ./regular.txt |wc -l
450

Далее идут "0x300c" и "0x180":

$ grep -Eo "0x300c," ./regular.txt |wc -l
114
$ grep -Eo "0x180," ./regular.txt |wc -l
98

Для полужирного шрифта на перовом месте также ноль:

$ grep -Eo "0x0," ./bold.txt |wc -l
450

А вот а втором и третьем месте идут значения "0x701c" и "0х380":

$ grep -Eo "0x701c," ./bold.txt |wc -l
117
$ grep -Eo "0x380," ./bold.txt |wc -l
117

Т.о., в обоих шрифтах самым часто встречающимся значеним является ноль. Если закодировать последовательность нолей специальным индексом, то получится экономия на 450*2=900 байт.

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

Т.о. структура выглядит так:

1 11111 11

где старший бит обозначенный синим определяет, является ли последовательность повторяющийся или нет, далее идут пять битов индекса обозначенные зеленым. Два младших бита могут принимать значение 00 или 11 (3). Если младшие биты сброшены в ноль, то за индексом идут данные, если же они установлены в единицы, то индекс кодирует количество нулей.

Индекс декодируется следующей структурой:

       if ((index & 0x03) == 0x03) {
            ...
        } else if (index & 0x80) {
            ...
        } else {
            ...

        }

Т.е. сначала проверяются младшие биты, затем проверяется старший бит, после чего отрабатывается оставшийся вариант.

Приведу пару примеров кодирования символов. Сперва простой случай, символ "0х0":

В оригинальном файле шрифта Он кодируется следующей последовательностью:

$ hexdump -C ter-k32n.psf |head
00000000  72 b5 4a 86 00 00 00 00  20 00 00 00 01 00 00 00  |r.J..... .......|
00000010  00 01 00 00 40 00 00 00  20 00 00 00 10 00 00 00  |....@... .......|
00000020  00 00 00 00 00 00 00 00  00 00 00 00 3f fc 3f fc  |............?.?.|
00000030  30 0c 30 0c 30 0c 30 0c  30 0c 30 0c 30 0c 30 0c  |0.0.0.0.0.0.0.0.|
*
00000050  3f fc 3f fc 00 00 00 00  00 00 00 00 00 00 00 00  |?.?.............|

В данном случае имеются только повторяющиеся последовательности:

0x6: 0x0, 0x2: 0x3ffc, 0x10: 0x300c, 0x2: 0x3ffc, 6: 0x0,

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

0x1b, 0x8, 0xfc, 0x3f, 0x40, 0xc, 0x30, 0x8, 0xfc, 0x3f, 0x1b,

Напомню, что индекс записывается умноженный на 4. Т.е. восемь означает два.

Следующий пример будет посложнее:

Он кодируется следующей последовательностью:

$ hexdump -C ter-k32n.psf |head
00000060  00 00 00 00 00 00 00 00  00 00 00 00 1f f8 3f fc  |..............?.|
00000070  70 0e 60 06 60 06 60 06  6e 76 6e 76 60 06 60 06  |p.`.`.`.nvnv`.`.|
00000080  60 06 60 06 6f f6 67 e6  60 06 60 06 60 06 70 0e  |`.`.o.g.`.`.`.p.|
00000090  3f fc 1f f8 00 00 00 00  00 00 00 00 00 00 00 00  |?...............|    

В упрощенной сжатой записи он будет выглядеть так:

0x6: 0x0, !0x3: 0x1ff8, 0x3ffc, 0x700e, 0x3: 0x6006, 0x2: 0x6e76, 0x4: 0x6006, !0x2: 0x6ff6, 0x67e6, 0x3: 0x6006, !0x3: 0x700e, 0x3ffc, 0x1ff8, 6: 0x0,

Где индекс с двоеточием - это повторяющиеся данные, а индекс записаный со знаком восклицания - НЕповторяющиеся данные.

Путем сжатия получится последовательность из 33 байт:

0x1b, 0x8c, 0xf8, 0x1f, 0xfc, 0x3f, 0xe, 0x70, 0xc, 0x6, 0x60, 0x8, 0x76, 0x6e, 0x10, 0x6, 0x60, 0x88, 0xf6, 0x6f, 0xe6, 0x67, 0xc, 0x6, 0x60, 0x8c, 0xe, 0x70, 0xfc, 0x3f, 0xf8, 0x1f, 0x1b,

В целом исходный шрифт весит 64*256=16384 байт. Путем сжатия он уменьшается до 5.5 килобайт. Т.е. удалось добиться сжатия в три раза.

Готовая функция декомпрессии символа у меня вышла такой:

void KOI8Rx32_decompress_sym(uint8_t sym, uint8_t font) {
    uint16_t idx;

    idx=(!font) ? TERMINUS_K16x32N_ID[sym] : TERMINUS_K16x32B_ID[sym];
    uint8_t i=0;
    while (i<32) {
        uint8_t index = (!font) ? TERMINUS_K16x32N[idx++] : TERMINUS_K16x32B[idx++];
        if ((index & 0x03) == 0x03) {
            index=(index >> 2);
            if (index == 0) index=0x20;
            for (uint8_t j=0; j<index;j++) {
                sym32_buf[i++]=0x0;
            }
        } else if (index & 0x80) {
            index &=0x7f;
            index = index>>2;
            if (index == 0) index=0x20;
            for(uint8_t j=0;j<index;j++){
                uint16_t a1=(!font) ? TERMINUS_K16x32N[idx++] : TERMINUS_K16x32B[idx++];
                uint16_t a2=(!font) ? TERMINUS_K16x32N[idx++] : TERMINUS_K16x32B[idx++];
                a1=((a2<<8) + a1);

                sym32_buf[i++]= a1;
            }
        } else {
            index=(index >> 2);
            if (index == 0) index=0x20;
            uint16_t a1=(!font) ? TERMINUS_K16x32N[idx++] : TERMINUS_K16x32B[idx++];
            uint16_t a2=(!font) ? TERMINUS_K16x32N[idx++] : TERMINUS_K16x32B[idx++];
            a1=((a2<<8) + a1);

            for(uint8_t j=0;j<index;j++){
                sym32_buf[i++]= a1;
            }

        }
    }
}

Остальные функции типовые, их можно посмотреть в репозитории. Вывод с помощью буфера целой строки и DMA я в данном случае не реализовывал, т.к. они дают незначительный прирост производительности, а для двух буферов в STM32F103C8 уже не хватает ОЗУ.

В главном цикле модуля "main.c" пусть будет следующий код:

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_clear(ST77XX_BLACK);
        delay_ms(3000);
        println("begin");

        KOI8Rx32_print_str_from_char("\xd3\xde\xc5\xd4\x3a",25,0,ST7735_GREEN,ST7735_BLACK,BOLD);
        KOI8Rx32_print_num(count++,110,0,ST7735_GREEN,ST7735_BLACK,BOLD);
        KOI8Rx32_print_str_from_char("count:",10,32,ST7735_RED,ST7735_BLACK,BOLD);
        KOI8Rx32_print_num(count++,110,32,ST7735_RED,ST7735_BLACK,BOLD);
        KOI8Rx32_print_str_from_char("\xfe\xe9\xf3\xec\xef\x3a",10,64,ST7735_BLUE,ST7735_BLACK,BOLD);
        KOI8Rx32_print_num(count++,110,64,ST7735_BLUE,ST7735_BLACK,BOLD);
        KOI8Rx32_print_str_from_char("NUMBER:",0,96,ST7735_YELLOW,ST7735_BLACK,BOLD);
        KOI8Rx32_print_num(count++,110,96,ST7735_YELLOW,ST7735_BLACK,BOLD);
        if (count >996)
            count=0;

        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Результат работы программы выглядит следующим образом. Полужирный шрифт:

Нормальный шрифт:

На данном этапе уже можно создать какой-нибудь простенький экран для вывода данных. Например:

Также полезным будет масштабирование текста. В частности масштабирование в два раза даст шрифт 32х64, т.е. по высоте строка будет занимать половину дисплея, что смотрится достаточно крупно.

Масштабирования для ST7735 намного проше, чем аналогичная операция для Nokia 5110, в результате чего преобразование будет выполняется "на лету". У меня функция получилась следующего вида:

void KOI8Rx32_char_to_buf_2X(uint8_t x_pos, uint8_t ch, uint8_t len, uint8_t font, uint16_t fg_color, uint16_t bg_color, uint16_t* buffer) {
    if ((x_pos + (TERMINUS_K16x32N_CHAR_WIDTH*2)) > LCD_X)
        return;

    KOI8Rx32_decompress_sym(ch, font);

    for (uint8_t i=0; i < (TERMINUS_K16x32N_CHAR_WIDTH*TERMINUS_K16x32N_CHAR_HEIGHT); i++) {
        uint16_t data=sym32_buf[i];

        uint16_t index= x_pos + (i*len*TERMINUS_K16x32N_CHAR_WIDTH*4);
        for (uint8_t j=0; j<TERMINUS_K16x32N_CHAR_WIDTH;j++) {

            uint16_t color=(data & (uint16_t)0x8000) ? fg_color : bg_color;
            buffer[index+(TERMINUS_K16x32N_CHAR_WIDTH*2)]=color;
            buffer[index+(TERMINUS_K16x32N_CHAR_WIDTH*2+1)]=color;
            buffer[index++]=color;
            buffer[index++]=color;
            data = data << 1;
        }
    }
}

Аналогично можно сделать масштабирование в три и четыре раза. Генерируемый шрифт выглядит так:

В принципе, пикселизации практически не заметно.

13) Преобразование векторных шрифтов в растровые

В настоящее время практически все шрифты рисуются в векторе, и поставляются они в соответствующих форматах: TTF, OTF и пр. В операционных системах, эти шрифты "на лету" преобразовываются в растровые, после чего отрисовываются на дисплее. Это довольно "дорогая" операция в плане вычислительных ресурсов и поэтому во встраиваемых системах конвертацию выполняют предварительно с помощью какой-либо компьютерной программы, а в прошивку включают уже готовый растровый шрифт.

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

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

На картинке один и тот же шрифт, но слева буква со сглаживанием, а справа без:

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

В частности, для первого эксперимента я выбрал "жидко-кристаллический" шрифт "Digital-7":

У шрифта есть сайт: www.styleseven.com, где он заявлен как "freeware". Хотя, строго говоря, freeware может быть только ПО.

Данный шрифт достаточно узкий, благодаря чему мне удалось сконвертировать его в растровый шрифт размером 32х15 пикселей. Это означает, что его можно кодировать и печатать на функциях для шрифта "Terminus 32х16". Остальные шрифты, которые привлекли мое внимание, выглядели более квадратными, и требовали опять менять алгоритм сжатия.

Первым делом я пытался сконвертировать шрифты программой matrixFont, про которую упоминал в предыдущей статье.

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

При импорте нужно задать размер шрифта (подчеркнуто красным), и в результате получается шрифт какой-то непонятной величины (размер холста):

Методом перебора значений, я сумел добиться на выходе шрифта высотой 32 пикселя и шириной 17:

Но позвольте спросить, какая взаимосвязь между шрифтом размера 28 и шрифтом 32х17 ?? Кроме того, мне нужен было размер 32х16 пикселей.

Также я подозревал, что с начертанием шрифтов тоже будут косяки. Если взять например букву "Х", то она сконвертировалась так:

В графическом редакторе глиф сопоставимого размера выглядел по другому:

Вроде мелочь, но неприятно. Несмотря на эти недочеты, программа matrixFont дает вполне приемлемый результат, и если бы я пользовался Windows, я бы остановил свой выбор на ней.

В поисках альтернативы, я покопался на гитхабе, и нашел проект: STM32-LCD_Font_Generator

Он написан на питоне, и имеет только текстовый интерфейс. Тем не менее, мой выбор пал именно на него.

Его использование очень простое:

$ python3 ./stm32-font.py --font ../Fonts/digital7.ttf  --size 32

В результате чего появляется новый файл с результатом "FontDigital7Mono32.h", в котором содержится таблица сгенерированных символов. Причем есть визуальное отображение результата. Например та же буква "Х" получилась так:

    // @5632 'x' (15 pixels wide)
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x30, 0x18, /* |  ##       ##  | */
    0x70, 0x38, /* | ###      ###  | */
    0x38, 0x38, /* |  ###     ###  | */
    0x38, 0x70, /* |  ###    ###   | */
    0x1C, 0x70, /* |   ###   ###   | */
    0x1C, 0xE0, /* |   ###  ###    | */
    0x0E, 0xE0, /* |    ### ###    | */
    0x0E, 0xC0, /* |    ### ##     | */
    0x06, 0xC0, /* |     ## ##     | */
    0x06, 0x80, /* |     ## #      | */
    0x00, 0x00, /* |               | */
    0x06, 0x80, /* |     ## #      | */
    0x06, 0xC0, /* |     ## ##     | */
    0x0E, 0xC0, /* |    ### ##     | */
    0x0E, 0xE0, /* |    ### ###    | */
    0x1C, 0xE0, /* |   ###  ###    | */
    0x1C, 0x70, /* |   ###   ###   | */
    0x38, 0x70, /* |  ###    ###   | */
    0x38, 0x38, /* |  ###     ###  | */
    0x70, 0x38, /* | ###      ###  | */
    0x30, 0x18, /* |  ##       ##  | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */
    0x00, 0x00, /* |               | */

Во-первых, здесь имеется горизонтальная прорезь в середине буквы, которую matrixFont "потерял". Во-вторых все шрифты, которые я прогонял через данную программу преобразуются именно в заданное число пикселей по высоте. Ну и еще, в результате получился шрифт в 15 пикселей по ширине, что позволило использовать функции написанные для Terminus 32х16.

Шрифт имеет только заглавные латинские буквы. Визуально он мне не очень нравится, но, имхо, имеет право на существование. На дисплее ST7735 шрифт смотрится как-то так:

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

Размер массива шрифта - немного меньше двух килобайт. Количество символов - 69 (латинская раскладка без строчных букв).

14) Словарное сжатие для больших шрифтов

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

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

И я подумал, что было бы неплохо попытаться сконвертировать в матричный формат и посмотреть как они будут выглядеть на дисплее ST7735. В итоге, я отобрал пять шрифтов (два жидко-кристальных и три футористичных), и приступил к работе.

Первое, что нужно заметить, набор символов у всех пяти шрифтов одинаковый - это латинский шрифт содержащий только заглавные буквы. Всего 69 символов:

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

Следующий момент на который я обратил внимание - шрифты достаточно квадратные. При высоте в 32 пикселя, для примера, шрифт BladeRunner занимает 40 пикселей в ширину.

Если подсчитать, то 40 пикселей это 5 байт. Один символ высотой 32 пикселя - это 32*5 = 160 байт. Весь набор - это 160*69 = 11040 байт. Итого 11 килобайт.

Для сжатия шрифта я решил использовать словарное сжатие. Если посмотреть кодирование символа "!", то тут сплошные повторяющиеся строки:

    // @96 '!' (23 pixels wide)
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x60, 0x00, /* |         ##            | */
    0x00, 0x60, 0x00, /* |         ##            | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0x00, 0x00, /* |                       | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */
    0x00, 0xF8, 0x00, /* |        #####          | */

Здесь всего три варианта строк, которые следует поместить в словарь, а сами строки заменить индексами словаря. Тогда символ будет закодирован 32 цифрами которые можно будет сжимать по RLE-алгоритму.

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

Напомню, что предыдущий вариант RLE - сжатия был самым "замороченным" и я решил его еще более "заморочить". Смысл в следующем.

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

0 0 0 0 0 0 0 0 4 104 67 55 9 26 32 6 6 20 0 98 11 34 42 59 3 37 37 75 75 0 0 0
0 0 0 0 0 0 0 0 0 10 7 41 5 1 1 1 5 18 0 18 5 1 1 1 1 5 48 12 10 0 0 0
0 0 0 0 0 0 0 0 0 20 13 2 2 2 2 2 13 19 0 19 2 2 2 2 2 2 13 20 0 0 0 0
0 0 0 0 0 0 0 0 0 10 7 64 4 4 4 4 4 184 30 12 8 3 3 3 3 8 169 12 10 0 0 0
0 0 0 0 0 0 0 0 0 10 7 64 4 4 4 4 4 31 7 31 4 4 4 4 4 4 65 12 10 0 0 0
0 0 0 0 0 0 0 0 0 0 87 5 1 1 1 1 5 14 7 31 4 4 4 4 4 4 23 57 0 0 0 0
0 0 0 0 0 0 0 0 0 10 7 25 8 3 3 3 3 27 88 31 4 4 4 4 4 4 65 12 10 0 0 0
0 0 0 0 0 0 0 0 0 10 7 25 8 3 3 3 8 24 7 14 5 1 1 1 1 5 48 12 10 0 0 0
0 0 0 0 0 0 0 0 0 10 7 64 4 4 4 4 4 21 0 21 4 4 4 4 4 4 23 57 0 0 0 0
0 0 0 0 0 0 0 0 0 10 7 41 5 1 1 1 5 14 7 14 5 1 1 1 1 5 48 12 10 0 0 0
0 0 0 0 0 0 0 0 0 10 7 41 5 1 1 1 5 14 7 31 4 4 4 4 4 4 65 12 10 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 2 2 2 0 0 0 0 0 0 0 0 0 0 0 2 2 2 0 0 0
0 0 0 0 0 0 0 0 0 0 0 2 2 2 0 0 0 0 0 0 0 0 0 0 0 2 2 2 2 2 0 0

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

    Таким образом структура индекса имеет следующий вид:
  1. 11111111
  2. Индекс состоит из старшего полубайта и младшего полубайта.
  3. Если индекс имеет вид Fx, где х-число не равное нулю, то такой индекс кодирует последовательность неповторяющихся одно-байтных символов. X задает длину последовательности.
  4. Если индекс имеет вид F0, то такой индекс кодирует двухбайтное меньшее 512. Число равно: 0х100 + следующий байт.
  5. Если индекс имеет вид 0x, то такой индекс кодирует повторяющуюся последовательность длинной х байт. Следующий за индексом байт задает повторяющееся значение.
  6. Если индекс имеет вид 0xYX, где 0 < Y < 16, то такой индекс кодирует повторяющуюся последовательность, длина которой задается старшим полубайтом, а значение младшим полубайтом.

В общем виде парсер индекса работает по следующему алгоритму:

        // если двухбайтное число
        if (index == 0xf0) {
            ...
        } else {
            uint8_t k = (index >> 4);
            // если неповторяющаяся последовательность
            if (k == (uint8_t)0x0f) {
                ...
            // повторяющаяся последовательность со значением более или равному 16
            } else if (k == 0x0 ) {
                ...
            } else {
                // повторяющаяся последовательность со значением менее  16
                ...
            }

Для пример, следующая строка:

0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x1, 0x0, 0x1, 0x3, 0x3, 0x3, 0x3, 0x3, 0x0,

сжимается в следующую последовательность:

0x90, 0xe3, 0xf3, 0x1, 0x0, 0x1, 0x53, 0x10,

Пример посложнее. Строка:

0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x16f, 0x5d, 0x5d, 0x5d, 0x9e, 0x9e, 0x3b, 0x3b, 0x11, 0x164, 0x9c, 0x9c, 0x58, 0x2e, 0x2e, 0x2e, 0x15c, 0x154, 0x93, 0x93, 0x95, 0x95, 0x0,

сжимается в следующую в следующую последовательность:

0x90, 0xf0, 0x6f, 0x3, 0x5d, 0x2, 0x9e, 0x2, 0x3b, 0xf1, 0x11, 0xf0, 0x64, 0x2, 0x9c, 0xf1, 0x58, 0x3, 0x2e, 0xf0, 0x5c, 0xf0, 0x54, 0x2, 0x93, 0x2, 0x95, 0x10,

Здесь исходная строка состоит из 32 значений, четыре из которых двухбайтные числа. Вторая строка состоит из 28-и однобайтных чисел.

15) Жидко-кристальные шрифты DS_Digital и PixelLCD

Предыдущий жидко-кристальный шрифт меня не очень впечатлил и я решил попробовать сконвертировать еще пару шрифтов. Выбор пал на DS_Digital и PixelLCD. Выглядят они примерно так. Шрифт DS_Digital:

Шрифт PixelLCD:

В сравнении со шрифтом Digital_7, DS_Digital выглядит как жирный, а PixelLCD еще жирнее)

Оба шрифта трех-байтные по ширине, первый 22 пикселя, второй 23 пикселя. По высоте оба шрифта имеют 32 пикселя. Словарь шрифта DS_Digital составил 213 слов, а PixelLCD - 205. Т.е. в обоих случаях индекс словаря не превышает одного байта.

Размер шрифта DS_Digital составил 1136+69*2+213*3=1913 байт. Размер шрифта PixelLCD составил 1127+69*2+205*3=1880 байт.

На дисплее ST7735 шрифт DS_Digital выглядит так:

Шрифт увеличенный в два раза получился таким:

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

Тоже самое для шрифта PixelLCD. Нормальный шрифт:

Увеличенный в два раза шрифт:

Т.о. получился набор из трех жидко-кристальных шрифтов: тонкого(Digital_7), нормального (DS_Digital) и жирного (PixelLCD).

16) Футуристические шрифты: Nulshock, DuneRise, BladeRunner

Последние три шрифта, это дизайнерские шрифты: Nulshock, DuneRise, BladeRunner. Выглядят они так:

Шрифт "Nulshock":

Шрифт "DuneRise":

Шрифт "BladeRunner"

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

Не следует обольщаться красивыми изогнутыми линиями шрифтов, для конвертации следует выбирать скорее "прямоугольные" шрифты, а не такие. Мне просто было интересно как эта красота будет смотреться на TFT дисплее ST7735.

Все три шрифта пятибайтные, по ширине имеют 36, 38 и 40 пикселов, при высоте в 32 пиксела. Т.е.

Словарь шрифтов занимает 383, 300 и 467 пятибайтных слов. Сжатые шрифты занимают: Nulshock - 3390 байт, RuneRise - 2926 байт, BladeRunner - 4210 байт.

Шрифт Nulshock, на мой взгляд, получился неплохо:

На увеличенном шрифте, на скруглениях, вылазит пикселизация:

При этом глифы без скруглений, например "H" или "F" выглядят нормально.

Шрифт "DuneRaise" выглядит оригинально и тоже довольно неплохо:

Хуже всего получился шрифт BladeRunner, из-за того, что его глифы имеют наклон:

Также неказисто будут выглядеть любые наклонные шрифты.

Все примеры и готовые прошивки можно скачать с портала GitLab