Данный сайт является зеркалом сайта www.count-zero.ru

STM32F103C8 без HAL и SPL: Работа с SPI дисплеями Nokia_5110 и ST7735

разделы: PCD8544 , STM32 , дата: 27 сентября 2022г.

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

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

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

В качестве дисплея я буду использовать т.н. Nokia 5110 дисплей на контроллере PCD8544. И хотя я уже писал очень длинную статью по работе с ним на ATmega8, с тех пор я многое переосмыслил. Главное же конечно то, что дисплей все еще продается на али, он доступный в плане цены, и скорее всего, он уже имеется у всех, кто читает эти строки. Впоследствии, алгоритмы написанные для Nokia 5110 мы будем переносить на другие дисплеи.

И чтобы статья совсем не казалось скучной, в завершении рассмотрим работу c цветным дисплеем на контроллере ST7735 с разрешением 128 на 160 пикселей и диагональю 1.8 дюйма (4.5 сантиметра). Будем пытаться добится от него работы на 90 fps (я серьёзно).

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

Содержание:

I. Часть первая, вводная

  1. Базовый проект на CMSIS для stm32f103c8
  2. Добавляем управление через UART
  3. Подключение энкодера (таймер TIM2)
  4. Подключение энкодера (таймер TIM4)

II. Часть вторая, дисплей Nokia 5110

  1. Подключение дисплея Nokia 5110
  2. Работа с текстом
  3. Интерфейс FM-приемника
  4. Меню со скролом
  5. Бегущая строка

III. Часть третья, дисплей ST7735

  1. Пара слов про дисплей ST7735
  2. Инициализация дисплея ST7735
  3. Функция заливки
  4. Выжимаем 90 fps из дисплея ST7735
  5. Вместо заключения: баги и все такое

Бонус. IPS дисплеи ST7789 320х240 и 240х240 (добавленно позже)

  1. Обзор IPS дисплеев на контроллере ST7789
  2. Проверка дисплеев на контроллере ST7789 в Arduino
  3. Исправление функций модуля "st7735.c" для совместимости с контроллером ST7789
  4. Инициализация дисплея ST7789
    Содержание цикла STM32F103C8 без HAL и SPL
  1. Система тактирования RCC, таймер SysTick, UART передатчик, планировщик задач, SPI и I2C модули в режиме мастера
  2. Работа с SPI дисплеями Nokia_5110 и ST7735

Все примеры с скомпилированными прошивками можно скачать с портала GitLab по ссылке: https://gitlab.com/flank1er/stm32_bare_metal

1) Базовый проект на CMSIS для stm32f103c8

Создание базового проекта на CMSIS для микроконтроллера stm32f103c8 (Bluepill) неоднократно мною рассматривалось, поэтому не хотелось бы повторять уже написанное. На скорую руку я составил стартовый проект на CMSIS для stm32f103c8. Он имеет следующую структуру:

$ tree . 
.
├── CMSIS
│   ├── core
│   │   ├── core_cm3.c
│   │   └── core_cm3.h
│   └── device
│       ├── stm32f10x.h
│       └── system_stm32f10x.h
├── Makefile
├── SPL
│   └── inc
│       ├── stm32f10x_gpio.h
│       ├── stm32f10x_rcc.h
│       └── stm32f10x_usart.h
├── asm
│   ├── assembly.s
│   └── init.s
├── build
├── inc
│   ├── main.h
│   └── uart.h
├── main.c
├── script.ld
├── src
│   ├── my_misc.c
│   ├── startup.c
│   └── uart.c
└── stm32f103c8.qbs

9 directories, 18 files

Данный проект стартует микроконтроллер на частоте 72 МГц, мигает светодиодом на PC13, и печатает на UART. Для сборки имеется Makefile и Qbs скрипт.

Модуль "main.c" выглядит следующим образом:

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_usart.h"
#include "stm32f10x_rcc.h"
#include "uart.h"

int main()
{
    // enable GPIOC port
    RCC->APB2ENR |= RCC_APB2Periph_GPIOC;           // enable PORT_C
    RCC->APB2ENR |= RCC_APB2Periph_GPIOA;           // enable PORT_A
    RCC->APB2ENR |= RCC_APB2Periph_USART1;          // enable UART1
    // --- GPIO setup ----
    GPIOC->CRH &= ~(uint32_t)(0xf<<20);             // Reset for PC13 (LED)
    GPIOC->CRH |=  (uint32_t)(0x2<<20);             // Push-Pull 2-MHz fo PC13 (LED)
    gpio_set(GPIOC,LED);
    GPIOA->CRH &= ~(uint32_t)(0xf<<4);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<4);              // for PA9 = USART1_TX
    // ------- SysTick CONFIG --------------
    if (SysTick_Config(72000)) // set 1ms
    {
        while(1); // error
    }
    // --- UART setup ----
    //USART1->BRR  = 0x1d4c;                                // 9600 Baud, when APB2=72MHz
    USART1->BRR = 0x271;                                // 115200 Baud, when APB2=72MHz
    USART1->CR1 |= (USART_CR1_UE_Set | USART_Mode_Tx);  // enable USART1, enable  TX mode
    //////////////////////////////////////
    /// let's go.....
    /////////////////////////////////////
    uint32_t tick=0;
    println("ready...");
    for(;;){
        gpio_set(GPIOC,LED);
        delay_ms(1000);
        gpio_reset(GPIOC,LED);
        delay_ms(1000);
        print("tick: ");
        println_num(tick++);
    }
}

Здесь "gpio_set()" и "gpio_reset()" макросы:

#define gpio_set(PORT,pin)  PORT->BSRR=pin
#define gpio_reset(PORT,pin)  PORT->BRR=pin

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/13_start_project

2) Добавляем управление через UART

Первым делом мы добавим управление через UART. Это даст нам простой командный интерфейс, через который мы сможем запускать тесты работы дисплея. Если в предыдущей статье "ATmega8 + PCD8544: работа с графическим дисплеем от телефонов Nokia 5110/3310" я писал серию отдельных прошивок для демонстрации того или иного эффекта, то с командным интерфейсом мы сможем разместить все в одной прошивке, и через командный интерфейс переключать их.

Работу с UART в режиме приемника на STM32F1xx я еще не разбирал, зато разбирал это на примере STM8S "Базовый проект, реализация программы эхо/echo для UART интерфейса", в STM32 все тоже самое.

Нам нужно будет прерывание, которое будет будет обрабатывать флаги ORE и RXNE регистра USARTx->SR, принимать данные и сигналить о готовности, при приеме символа конца строки NL или CR. Если все это писать на Си, то код будет практически одинаков и для STM8 и для STM32 и даже для AVR.

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

uint8_t uart_ready;
uint8_t uart_index;
char uart_buf[UART_BUFFER_LEN];


void USART1_IRQHandler(void)
{
    if ((USART1->SR & USART_SR_ORE) || uart_ready) {
        USART1->DR;
        uart_index=0;
        return;
    }

    uint8_t ch=USART1->DR;
    if (ch == 0xa || ch == 0xd) {
        uart_ready=1;
        uart_buf[uart_index]=0;
    } else {
        if (uart_index < (UART_BUFFER_LEN-1))
            uart_buf[uart_index++]=ch;
        else{
            uart_ready=1;
            uart_buf[UART_BUFFER_LEN-1]=0;
        }
    }
}

В модуле "main.c", в блоке конфигурации периферии:

1) Активируем ножку PA10:

    GPIOA->CRH &= ~(uint32_t)(0xf<<8);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<8);              // for PA10 = USART1_RX

2) Конфигурируем UART:

 USART1->CR1 |= (USART_CR1_UE_Set | USART_Mode_Tx | USART_Mode_Rx | USART_CR1_RXNEIE);  // enable USART1, enable  TX/RX mode, enable RX interrupt

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

    NVIC_SetPriority(USART1_IRQn,1);                // set priority for USART1 IRQ
    NVIC_EnableIRQ(USART1_IRQn);                    // enable USART1 IRQ via NVIC

Для обработки команд нам следует выставить частоту исполнения главного цикла в районе 20 раз в секунду(Гц). Для этого главный цикл нужно будет переработать. Например, так:

    uint8_t led_toggle=0;
    bool tick_enable=1;
    uint8_t count=0;
    println("ready...");
    for(;;){
        if (uart_ready) {
            if (uart_buf[0] == '?' && uart_buf[1] == 0x0) {
                print("help:\n");
                print("t-  disable print tick\n");
            } else if (uart_buf[0] == 't' && uart_buf[1] == '-') {
                print("turn off tick\n");
                tick_enable=false;
            } else {
                print("invalid command\n");
            }
            clear_uart_buf();
            uart_ready=0;
            uart_index=0;
        }

        if ((++count % 20)  == 0) {
            if (led_toggle)
                gpio_set(GPIOC,LED);
            else
                gpio_reset(GPIOC,LED);
            led_toggle=!led_toggle;
            if (tick_enable)
                print("tick..\n");
            count=0;
        }
        delay_ms(50);
    }

Как видно, здесь у нас уже появились пара команд "?" и "t-" для управления микроконтроллером.

И в принципе, это все.

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/14_uart_receiver

3) Подключение энкодера (таймер TIM2)

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

На easyelectronics.ru есть хорошая статья про энкодеры и как с ними работать, - "AVR. Учебный Курс. Инкрементальный энкодер.", с картинками и графиками. В этой статье предлагается использовать таймер для периодического опроса состояния энкодера. В AVR я, в принципе, так и делал. Проблема этого метода в том, что таймер должен работать на высокой частоте (в статье предлагается 1кГц, я использовал 10кГц), и это довольно ощутимо нагружает процессор микроконтроллера. Если все силы вашего микроконтроллера уходят на то, что бы отрисовывать картинку на дисплее с 50-60 fps, то такой таймер обеспечит вам приличные фризы, когда вы будете крутить энкодер.

К счастью, таймеры в STM32 могут работать в режиме счетчика энкодера. Таким образом энкодер будет работать на стороне периферии, без прерываний. Ну, почти. На самом деле нам потребуется два таймера. Один будет счетчиком, а другой будет замерять "закончили вы уже крутить энкодер или еще нет". Если вы например на паяльной станции задаете температурный режим с помощью энкодера, то при повороте ручки цифры меняются постоянно. Микроконтроллер должен подождать, пока вы выставите нужную температуру, и только после этого задает режим работы нагревателя.

Давайте для начала разберемся с таймером счетчиком энкодера. Как настроить таймер в режиме энкодера описано здесь: "STM32 – Подключаем энкодер". В принципе там все написано, нам остается только скопировать готовый код в свой проект.

Включаем тактирование таймера:

    RCC->APB1ENR |= RCC_APB1Periph_TIM2;            // enable TIM2

Переводим ножки PA0 и PA1 в "Input" режим и включаем подтяжку к линии питания:

    // encoder PA0, PA1
    GPIOA->CRL &= ~(uint32_t)(0xff);                // clear
    GPIOA->CRL |=  (uint32_t)(0x88);                // pull-down input mode for PA0, PA1
    GPIOA->ODR |= (uint16_t)(0x3);                  // switch to pull-up mode for PA0,PA1

Настраиваем таймер в режиме энкодера:

    // ------ TIM2 Setup -------------------
    TIM2->CCMR1 = TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0;
    TIM2->CCER  = TIM_CCER_CC1P | TIM_CCER_CC2P;
    TIM2->SMCR  = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;
    TIM2->ARR   = 1000;
    TIM2->CR1   = TIM_CR1_CEN;

Здесь в регистр TIM2->ARR заносится диапазон значений счетчика. Каждый щелчок на одно деление уменьшает или увеличивает его значение на 4. Следовательно, если у нас в регистре TIM2->ARR записана тысяча, то без переполнения в одну сторону можно крутануть на 1000/(4*2) = 125 делений.

В главный цикл добавляем чтение счетчика:

        uint16_t enc_value=TIM2->CNT;
        if (enc_value != enc_prev_value) {
            print("encoder: ");
            println_num(enc_value);
            enc_prev_value=enc_value;
        }

Подключаем энкодер к ножкам PA0 и PA1, крутим энкодер, и в мониторе последовательного порта должно появится что-то подобное:

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

void uart_print_int(int value) {
    if (value >= 0)
        uart_print_num((uint16_t)value);
    else {
    uart_send_char('-');
        value=get_abs(value);
        uart_print_num((uint16_t)value);
    }
}

Преобразуем значение счетчика в знаковое целое число:

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            if (enc_value >= 125) {
                enc_value = (enc_value - 250);
            }
            print("encoder: ");
            uart_print_int(enc_value);
            uart_send_char('\n');
        }

Теперь работа энкодера выглядит так:

Уже как-то более осмыслено.

Полный исходник "main.c" можно посмотреть под спойлером

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_usart.h"
#include "stm32f10x_rcc.h"
#include "uart.h"

extern uint8_t uart_ready;
extern uint8_t uart_index;
extern char uart_buf[UART_BUFFER_LEN];              // UART_BUFFER_LEN=10
void clear_uart_buf();

int main()
{
    // enable GPIOC port
    RCC->APB2ENR |= RCC_APB2Periph_GPIOC;           // enable PORT_C
    RCC->APB2ENR |= RCC_APB2Periph_GPIOA;           // enable PORT_A
    RCC->APB2ENR |= RCC_APB2Periph_USART1;          // enable UART1
    RCC->APB1ENR |= RCC_APB1Periph_TIM2;            // enable TIM2
    // --- GPIO setup ----
    GPIOC->CRH &= ~(uint32_t)(0xf<<20);             // Reset for PC13 (LED)
    GPIOC->CRH |=  (uint32_t)(0x2<<20);             // Push-Pull 2-MHz fo PC13 (LED)
    gpio_set(GPIOC,LED);
    GPIOA->CRH &= ~(uint32_t)(0xf<<4);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<4);              // for PA9 = USART1_TX
    GPIOA->CRH &= ~(uint32_t)(0xf<<8);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<8);              // for PA10 = USART1_RX
    // encoder PA0, PA1
    GPIOA->CRL &= ~(uint32_t)(0xff);                // clear
    GPIOA->CRL |=  (uint32_t)(0x88);                // pull-down input mode for PA0, PA1
    GPIOA->ODR |= (uint16_t)(0x3);                  // switch to pull-up mode for PA0,PA1
    // ------- SysTick CONFIG --------------
    if (SysTick_Config(72000)) // set 1ms
    {
        while(1); // error
    }
    // ------ TIM2 Setup -------------------
    TIM2->CCMR1 = TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0;
    TIM2->CCER  = TIM_CCER_CC1P | TIM_CCER_CC2P;
    TIM2->SMCR  = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;
    TIM2->ARR   = 1000;
    TIM2->CR1   = TIM_CR1_CEN;
    // --- UART setup ----
    //USART1->BRR  = 0x1d4c;                        // 9600 Baud, when APB2=72MHz
    USART1->BRR = 0x271;                            // 115200 Baud, when APB2=72MHz
    USART1->CR1 |= (USART_CR1_UE_Set | USART_Mode_Tx | USART_Mode_Rx | USART_CR1_RXNEIE);  // enable USART1, enable  TX/RX mode, enable RX interrupt
    NVIC_SetPriority(USART1_IRQn,1);                // set priority for USART1 IRQ
    NVIC_EnableIRQ(USART1_IRQn);                    // enable USART1 IRQ via NVIC
    //////////////////////////////////////
    /// let's go.....
    /////////////////////////////////////
    int enc_prev_value=0;
    uint8_t led_toggle=0;
    bool tick_enable=1;
    uint8_t count=0;
    clear_uart_buf();
    println("ready...");
    for(;;){
        if (uart_ready) {
            if (uart_buf[0] == '?' && uart_buf[1] == 0x0) {
                print("help:\n");
                print("t-  disable print tick\n");
            } else if (uart_buf[0] == 't' && uart_buf[1] == '-') {
                print("turn off tick\n");
                tick_enable=false;
            } else {
                print("invalid command\n");
            }
            clear_uart_buf();
            uart_ready=0;
            uart_index=0;
        }

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            if (enc_value >= 125) {
                enc_value = (enc_value - 250);
            }
            print("encoder: ");
            uart_print_int(enc_value);
            uart_send_char('\n');
        }

        if ((++count % 20)  == 0) {
            if (led_toggle)
                gpio_set(GPIOC,LED);
            else
                gpio_reset(GPIOC,LED);
            led_toggle=!led_toggle;
            if (tick_enable)
                print("tick..\n");
            count=0;
        }
        delay_ms(50);
    }
}

void clear_uart_buf() {
    for (uint8_t i=0; i<UART_BUFFER_LEN;i++)
        uart_buf[i]=0;
}

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/15_encoder_tim2

4) Подключение энкодера (таймер TIM4)

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

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

Включаем тактирование таймера TIM4:

  RCC->APB1ENR |= RCC_APB1Periph_TIM4;    // enable TIM4

Настраиваем таймер:

    // ----- TIM4 Setup -------
    TIM4->CR1 = (0x80);                                 // set ARPE flag
    TIM4->PSC = 36000 - 1;                              // 1000 tick/sec
    TIM4->ARR = 100;                                    // 10 Interrupt/sec (1000/100)
    TIM4->DIER |=  TIM_DIER_UIE;                        // enable interrupt per overflow
    TIM4->SR = 0;
    TIM4->EGR |= (0x01);
    TIM4->CR1 |= TIM_CR1_CEN;                           // enable timer

Здесь устанавливаем частоту срабатывания прерывания таймера TIM4 10 раз в секунду. Таймер тактируется от шины APB2 которая работает с делителем =2, т.е. на частоте 36 МГц.

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

    NVIC_SetPriority(TIM4_IRQn, 21);                // set priority for TIM4_IRQ
    NVIC_EnableIRQ(TIM4_IRQn);                      // enable TIM4 IRQ

Добавляем обработчик прерывания:

void  TIM4_IRQHandler(void){
    TIM4->SR = 0;
    if (tim4_counter)
        --tim4_counter;
}

И теперь, в главном цикле, вместо:

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            if (enc_value >= 125) {
                enc_value = (enc_value - 250);
            }
            print("encoder: ");
            uart_print_int(enc_value);
            uart_send_char('\n');
        }

Пишем следующее:

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            isEncoder=true;
            print_encoder("encoder: ",enc_value);
            tim4_counter=TIM4_INIT_VALUE;
        } else if (isEncoder && !tim4_counter) {
            __disable_irq();
            isEncoder=false;
            TIM2->CNT=0;
            __enable_irq();
            enc_prev_value=0;
            print_encoder("total: ",enc_value);
        }

где функция "print_encoder()" это:

void print_encoder(char* str, int value) {
    if (value >= 125) {
        value -= 250;
    }
    print(str);
    uart_print_int(value);
    uart_send_char('\n');
}

Работа программы теперь будет следующим образом:

С этим уже как-то можно работать. В принципе, вместо таймера TIM4 можно было использовать Systick или даже главный цикл, который в данный момент работает с интервалом 50 мс. Но это только в таком простом примере он так работает. В реальных проектах задержки главного цикла формируются, как правило, более сложным способом.

Полный текст модуля "main.c" под спойлером.

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_usart.h"
#include "stm32f10x_rcc.h"
#include "uart.h"
extern uint8_t tim4_counter;
extern uint8_t uart_ready;
extern uint8_t uart_index;
extern char uart_buf[UART_BUFFER_LEN];              // UART_BUFFER_LEN=10
void clear_uart_buf();
void print_encoder(char* str, int value);

int main()
{
    // enable GPIOC port
    RCC->APB2ENR |= RCC_APB2Periph_GPIOC;           // enable PORT_C
    RCC->APB2ENR |= RCC_APB2Periph_GPIOA;           // enable PORT_A
    RCC->APB2ENR |= RCC_APB2Periph_USART1;          // enable UART1
    RCC->APB1ENR |= RCC_APB1Periph_TIM2;            // enable TIM2
    RCC->APB1ENR |= RCC_APB1Periph_TIM4;    // enable TIM4
    // --- GPIO setup ----
    GPIOC->CRH &= ~(uint32_t)(0xf<<20);             // Reset for PC13 (LED)
    GPIOC->CRH |=  (uint32_t)(0x2<<20);             // Push-Pull 2-MHz fo PC13 (LED)
    gpio_set(GPIOC,LED);
    GPIOA->CRH &= ~(uint32_t)(0xf<<4);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<4);              // for PA9 = USART1_TX
    GPIOA->CRH &= ~(uint32_t)(0xf<<8);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<8);              // for PA10 = USART1_RX
    // encoder PA0, PA1
    GPIOA->CRL &= ~(uint32_t)(0xff);                // clear
    GPIOA->CRL |=  (uint32_t)(0x88);                // pull-down input mode for PA0, PA1
    GPIOA->ODR |= (uint16_t)(0x3);                  // switch to pull-up mode for PA0,PA1
    // ------- SysTick CONFIG --------------
    if (SysTick_Config(72000)) // set 1ms
    {
        while(1); // error
    }
    // ------ TIM2 Setup -------------------
    TIM2->CCMR1 = TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0;
    TIM2->CCER  = TIM_CCER_CC1P | TIM_CCER_CC2P;
    TIM2->SMCR  = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;
    TIM2->ARR   = 1000;
    TIM2->CR1   = TIM_CR1_CEN;
    // ----- TIM4 Setup -------
    TIM4->CR1 = (0x80);                                 // set ARPE flag
    TIM4->PSC = 36000 - 1;                              // 1000 tick/sec
    TIM4->ARR = 100;                                    // 10 Interrupt/sec (1000/100)
    TIM4->DIER |=  TIM_DIER_UIE;                        // enable interrupt per overflow
    TIM4->SR = 0;
    TIM4->EGR |= (0x01);
    TIM4->CR1 |= TIM_CR1_CEN;                           // enable timer
    // --- UART setup ----
    //USART1->BRR  = 0x1d4c;                        // 9600 Baud, when APB2=72MHz
    USART1->BRR = 0x271;                            // 115200 Baud, when APB2=72MHz
    USART1->CR1 |= (USART_CR1_UE_Set | USART_Mode_Tx | USART_Mode_Rx | USART_CR1_RXNEIE);  // enable USART1, enable  TX/RX mode, enable RX interrupt
    NVIC_SetPriority(USART1_IRQn,1);                // set priority for USART1 IRQ
    NVIC_EnableIRQ(USART1_IRQn);                    // enable USART1 IRQ via NVIC
    NVIC_SetPriority(TIM4_IRQn, 21);                // set priority for TIM4_IRQ
    NVIC_EnableIRQ(TIM4_IRQn);                      // enable TIM4 IRQ
    //////////////////////////////////////
    /// let's go.....
    /////////////////////////////////////
    bool isEncoder=false;
    int enc_prev_value=0;
    uint8_t led_toggle=0;
    bool tick_enable=1;
    uint8_t count=0;
    clear_uart_buf();
    println("ready...");
    for(;;){
        if (uart_ready) {
            if (uart_buf[0] == '?' && uart_buf[1] == 0x0) {
                print("help:\n");
                print("t-  disable print tick\n");
            } else if (uart_buf[0] == 't' && uart_buf[1] == '-') {
                print("turn off tick\n");
                tick_enable=false;
            } else {
                print("invalid command\n");
            }
            clear_uart_buf();
            uart_ready=0;
            uart_index=0;
        }

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            isEncoder=true;
            print_encoder("encoder: ",enc_value);
            tim4_counter=TIM4_INIT_VALUE;
        } else if (isEncoder && !tim4_counter) {
            __disable_irq();
            isEncoder=false;
            TIM2->CNT=0;
            __enable_irq();
            enc_prev_value=0;
            print_encoder("total: ",enc_value);
        }

        if ((++count % 20)  == 0) {
            if (led_toggle)
                gpio_set(GPIOC,LED);
            else
                gpio_reset(GPIOC,LED);
            led_toggle=!led_toggle;
            if (tick_enable)
                print("tick..\n");
            count=0;
        }
        delay_ms(50);
    }
}

void clear_uart_buf() {
    for (uint8_t i=0; i<UART_BUFFER_LEN;i++)
        uart_buf[i]=0;
}

void print_encoder(char* str, int value) {
    if (value >= 125) {
        value -= 250;
    }
    print(str);
    uart_print_int(value);
    uart_send_char('\n');
}

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/16_encoder_tim4

5) Подключение дисплея Nokia 5110

Старый-добрый дисплей Nokia 5110 про который не писал только ленивый и я в том числе, снова у нас на рассмотрении. На этот раз мы его будем запускать на STM32.

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

В тоже время дисплей Nokia 5110 является чем-то вроде устоявшегося стандарта. Если посмотреть на дисплейные модули Nokia 5110 и ST7735:

То видно, что разъем подключения здесь один к одному. Ну и как не трудно догадаться, протокол работы у них тоже один. Есть правда небольшая разница. В nokia 5110 подсветка включается землей, а в st7735 требуется 3.3В. В принципе, у разных модулей может быть по разному.

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

Итак. для подключения дисплея нам нужно будет выделить шесть GPIO. Условно разделим их на две группы: 1) CS,CLK и DIN; 2) RST, DC и BL. Для первой группы выделим порт A, для второй - порт B. Например, так:

PA4 - SE (chip select)
PA5 - CLK 
PA7 - DIN
/-----/
PB4 - RST
PB5 - BL
PB6 - D/C

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

Подсветка дисплея состоит из 4-х светодиодов. По моим замерам она потребляет около 6 или 7 мА. Мы подключим её к ножке микроконтроллера PB5, которую переведем в Open Drain режим. В этом режиме одна ножка может пропускать ток до 20 мА. Нам этого вполне хватит.

Далее, в коде программы включаем тактирование порта GPIOB:

    RCC->APB2ENR |= RCC_APB2Periph_GPIOB;           // enable PORT_B

Конфигурируем GPIO:

    // Configure Port B. PB6_DC, PB4_RST, PB5_BackLight
    GPIOB->CRL &= ~(uint32_t)(0xf<<24);    // clear mode for PB6   (DC)
    GPIOB->CRL &= ~(uint32_t)(0xf<<16);    // clear mode for PB4   (RST)
    GPIOB->CRL &= ~(uint32_t)(0xf<<20);    // clear mode for PB5   (BackLight)
    GPIOB->CRL |= (uint32_t)(0x3<<24);     // for PB6 set  PushPull mode 50MHz (DC)
    GPIOB->CRL |= (uint32_t)(0x3<<16);     // for PB4 set  PushPull mode 50MHz (RST)
    GPIOB->CRL |= (uint32_t)(0x7<<20);     // for PB5 set  OpenDrain mode 50MHz (BL)
    // SOFT SPI Setup (PA4_SE, PA5_CLK,  PA7_DIO)
    GPIOA->CRL &= ~(uint32_t)(0xf<<16);    // clear mode for PA4   (SE)
    GPIOA->CRL &= ~(uint32_t)(0xf<<20);    // clear mode for PA5   (SPI_Clock)
    GPIOA->CRL &= ~(uint32_t)(0xf<<28);    // clear mode for PA7   (SPI_DIO)
    GPIOA->CRL |= (uint32_t)(0x3<<16);     // for PA4 set  PushPull mode 50MHz (SE)
#ifdef HW_SPI
    GPIOA->CRL |= (uint32_t)(0xb<<20);              // for PA5 set  Alternative mode, 50MHz
    GPIOA->CRL |= (uint32_t)(0xb<<28);              // for PA7 set  Alternative mode, 50MHz
#else
    GPIOA->CRL |= (uint32_t)(0x3<<20);     // for PA5 set  PushPull mode 50MHz (SPI_Clock)
    GPIOA->CRL |= (uint32_t)(0x3<<28);     // for PA7 set  PushPull mode 50MHz (SPI_DIO)
#endif

Нам нужно будет добавить в проект модуль "pcd8544.c", который будет содержать функции для работы с дисплеем. Сейчас нам нужны функции инициализации дисплея, заливки, и выключения дисплея. Пока так:

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_spi.h"
#include "pcd8544.h"
#include "uart.h"

// Port B
#define RST  GPIO_Pin_4
#define BL   GPIO_Pin_5
#define DC   GPIO_Pin_6
// Port A
#define CS   GPIO_Pin_4
#define CLK  GPIO_Pin_5
#define DIN  GPIO_Pin_7

#define LCD_C     0x00
#define LCD_D     0x01

#define LCD_X     84
#define LCD_Y     48
#define LCD_LEN   (uint16_t)((LCD_X * LCD_Y) / 8)

#define chip_select_enable() gpio_reset(GPIOA,CS)
#define chip_select_disable() gpio_set(GPIOA,CS)

#define FONT_SIZE_1 (uint8_t)0x01
#define FONT_SIZE_2 (uint8_t)0x02
#define FONT_SIZE_3 (uint8_t)0x03

static uint8_t fb[LCD_LEN];          // screen buffer

bool isPower=false;

void pcd8544_send(uint8_t dc, uint8_t data);

void pcd8544_init() {
    // hardware reset
    gpio_reset(GPIOB,RST);
    gpio_set(GPIOB,RST);
    // init routine
    chip_select_enable();
    pcd8544_send(LCD_C, 0x21);  // LCD Extended Commands.
    pcd8544_send(LCD_C, 0x14);  // LCD bias mode 1:48. //0x13
    pcd8544_send(LCD_C, 0xB6);  // Set LCD Vop (Contrast).
    pcd8544_send(LCD_C, 0x04);  // Set Temp coefficent. //0x04
    pcd8544_send(LCD_C, 0x20);  // LCD Basic Commands
    pcd8544_send(LCD_C, 0x0C);  // LCD in normal mode.
    //pcd8544_send(LCD_C, 0x0d);  // inverse mode
    chip_select_disable();
    gpio_reset(GPIOB,BL);           // enable blacklight
    isPower=true;
    pcd8544_fill_fb(0x0);
    pcd8544_display_fb();
}

void pcd8544_send(uint8_t dc, uint8_t data)
{

   if (dc == LCD_D)
      gpio_set(GPIOB,DC);
   else
      gpio_reset(GPIOB,DC);

#ifdef HW_SPI
   SPI1->DR=data;
   while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
#else

   for (uint8_t i=0; i<8; i++)
   {
      if (data & 0x80)
          gpio_set(GPIOA,DIN);
      else
          gpio_reset(GPIOA,DIN);

      data=(data<<1);
      // Set Clock Signal
      gpio_set(GPIOA,CLK);
      gpio_reset(GPIOA,CLK);
   }
#endif
}

//  work with buffer
void pcd8544_fill_fb(uint8_t value)
{
    for(int i=0;i<LCD_LEN; i++)
        fb[i]=value;
}

void pcd8544_display_fb() {
    chip_select_enable();
    for(uint16_t i=0;i<LCD_LEN; i++)
        pcd8544_send(LCD_D,fb[i]);
    chip_select_disable();
}


void pcd8544_fill(uint8_t value)
{
   chip_select_enable();
   for (int i=0; i < LCD_LEN; i++)
   {
      pcd8544_send(LCD_D, value);
   }
   chip_select_disable();
}

void pcd8544_off() {
    pcd8544_fill(0);
    // hardware reset
    gpio_reset(GPIOB,RST);
    delay_ms(10);
    gpio_set(GPIOB,RST);
    delay_ms(10);
    // blacklight off
    gpio_set(GPIOB,BL);
    isPower=false;
}

Все функции были взяты из предыдущей статьи: "ATmega8 + PCD8544: работа с графическим дисплеем от телефонов Nokia 5110/3310".

Перед началом главного цикла в "main.c" вызываем инициализацию дисплея:

    pcd8544_init();
    pcd8544_fill_fb(0x01);
    pcd8544_display_fb();

Теперь мы можем добавить в uart-интерфейс пару команд включения и выключения дисплея. Блок обработки UART-команд будет следующим:

        if (uart_ready) {
            if (uart_buf[0] == '?' && uart_buf[1] == 0x0) {
                print("help:\n");
                print("t-  disable print tick\n");
                print("on - turn on display\n");
                print("t-  turn off display\n");
            } else if (uart_buf[0] == 't' && uart_buf[1] == '-') {
                print("turn off tick\n");
                tick_enable=false;
            } else if (comp_str(uart_buf,"off") && isPower) {
                pcd8544_off();
            } else if (uart_buf[0] == 'o' && uart_buf[1] == 'n' && !isPower) {
                pcd8544_init();
            } else {
                print("invalid command\n");
            }
            clear_uart_buf();
            uart_ready=0;
            uart_index=0;
        }

Теперь, после прошивки дисплей должен включиться, и на нем должно появиться шесть горизонтальных линий. Через UART командами "off" и "on" можно включать и выключать дисплей вместе с подсветкой.

Если все заработало, то можно переключиться на аппаратный SPI. Для этого включаем тактирование модуля SPI1:

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

Настраиваем модуль:

#ifdef HW_SPI
    // --- SPI1 setup ----
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_4);
#ifdef USE_DMA
    SPI1->CR2 |= SPI_CR2_TXDMAEN;           // enable DMA request
#endif
    SPI1->CR1 |= SPI_CR1_SPE;
#endif

Осталось добавить макроопределение "HW_SPI" при сборке проекта, и дисплей должен заработать от аппаратного SPI.

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/17_pcd8544_init

6) Работа текстом

Дисплей Nokia 5110 единственный, к которому у меня нет претензий по шрифтам. Единственная загвоздка - он один, но зато хороший. Шрифт для данного дисплея, который я приводил в: "ATmega8 + PCD8544: работа с графическим дисплеем от телефонов Nokia 5110/3310" широко распространенн в сети, и используется на всевозможных дисплеях. Он качественный, легкий и полностью использует графическое пространство дисплея nokia 5110.

Сейчас нам нужно добавить заголовочный файл со шрифтом в проект. Шрифт включает в себя кириллицу. Но я не буду ее использовать в примерах, т.к. для этого придется исходники конвертировать в кодировку CP866, я достаточно подробно рассказывал об этом в прошлый раз. Сейчас я использую Qt Creator, который без проблем работает с кодировками отличными от юникода, но вот как это сделать, допустим, в vim, для меня загадка.

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

Полный исходник модуля "pcd8544.c" содержащий данные функции, я спрятал под спойлер.

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_spi.h"
#include "pcd8544.h"
#include "uart.h"
#include "fonts.h"

// Port B
#define RST  GPIO_Pin_4
#define BL   GPIO_Pin_5
#define DC   GPIO_Pin_6
// Port A
#define CS   GPIO_Pin_4
#define CLK  GPIO_Pin_5
#define DIN  GPIO_Pin_7

#define LCD_C     0x00
#define LCD_D     0x01

#define LCD_X     84
#define LCD_Y     48
#define LCD_LEN   (uint16_t)((LCD_X * LCD_Y) / 8)

#define chip_select_enable() gpio_reset(GPIOA,CS)
#define chip_select_disable() gpio_set(GPIOA,CS)

#define FONT_SIZE_1 (uint8_t)0x01
#define FONT_SIZE_2 (uint8_t)0x02
#define FONT_SIZE_3 (uint8_t)0x03

static const uint8_t lookup[16] = {
0x0, 0x8, 0x4, 0xc, 0x2, 0xa, 0x6, 0xe,
0x1, 0x9, 0x5, 0xd, 0x3, 0xb, 0x7, 0xf, };

static uint8_t fb[LCD_LEN];          // screen buffer
uint16_t pos;

bool isPower=false;

void pcd8544_send(uint8_t dc, uint8_t data);

void pcd8544_init() {
    // hardware reset
    gpio_reset(GPIOB,RST);
    gpio_set(GPIOB,RST);
    // init routine
    chip_select_enable();
    pcd8544_send(LCD_C, 0x21);  // LCD Extended Commands.
    pcd8544_send(LCD_C, 0x14);  // LCD bias mode 1:48. //0x13
    pcd8544_send(LCD_C, 0xB6);  // Set LCD Vop (Contrast).
    pcd8544_send(LCD_C, 0x04);  // Set Temp coefficent. //0x04
    pcd8544_send(LCD_C, 0x20);  // LCD Basic Commands
    pcd8544_send(LCD_C, 0x0C);  // LCD in normal mode.
    //pcd8544_send(LCD_C, 0x0d);  // inverse mode
    chip_select_disable();
    gpio_reset(GPIOB,BL);           // enable blacklight
    isPower=true;
    pcd8544_fill_fb(0x0);
    pcd8544_display_fb();
}

void pcd8544_send(uint8_t dc, uint8_t data)
{

   if (dc == LCD_D)
      gpio_set(GPIOB,DC);
   else
      gpio_reset(GPIOB,DC);

#ifdef HW_SPI
   SPI1->DR=data;
   while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
#else

   for (uint8_t i=0; i<8; i++)
   {
      if (data & 0x80)
          gpio_set(GPIOA,DIN);
      else
          gpio_reset(GPIOA,DIN);

      data=(data<<1);
      // Set Clock Signal
      gpio_set(GPIOA,CLK);
      gpio_reset(GPIOA,CLK);
   }
#endif
}

//  work with buffer
void pcd8544_fill_fb(uint8_t value)
{
    for(int i=0;i<LCD_LEN; i++)
        fb[i]=value;
}

void pcd8544_display_fb() {
    chip_select_enable();
    for(uint16_t i=0;i<LCD_LEN; i++)
        pcd8544_send(LCD_D,fb[i]);
    chip_select_disable();
}


void pcd8544_fill(uint8_t value)
{
   chip_select_enable();
   for (int i=0; i < LCD_LEN; i++)
   {
      pcd8544_send(LCD_D, value);
   }
   chip_select_disable();
}

void pcd8544_off() {
    pcd8544_fill(0);
    // hardware reset
    gpio_reset(GPIOB,RST);
    delay_ms(10);
    gpio_set(GPIOB,RST);
    delay_ms(10);
    // blacklight off
    gpio_set(GPIOB,BL);
    isPower=false;
}


void pcd8544_mirror() {
    for (uint16_t i=0;i<LCD_LEN;i++) {
        uint8_t b=fb[i];
        fb[i]=(lookup[b&0b1111] << 4) | lookup[b>>4];
    }
    for (uint16_t i=0,j=(LCD_LEN-1);i<(LCD_LEN>>1);i++,j--) {
        uint8_t b=fb[i];
        fb[i]=fb[LCD_LEN-1-i];
        fb[j]=b;
    }
}

void pcd8544_print_string_invert_fb(char *str, uint8_t x, uint8_t y)
{
    pos=y*LCD_X + x;
    while (*str)
    {
        pcd8544_send_char(*str++,true);
    }
}

void pcd8544_print_string(char *str, uint8_t x, uint8_t y)
{
    pos=y*LCD_X + x;
    while (*str)
    {
      pcd8544_send_char(*str++,false);
    }
}

void pcd8544_send_char(uint8_t ch,bool inverse)
{
    if (ch >= 0x20 && ch <= 0xf0 && pos <= (LCD_LEN-7))
    {
        for (uint8_t i=0; i < 5; i++)
        {
            uint8_t c=(ch<0xe0) ? ch - 0x20 : ch - 0x50;
            c=ASCII_5x7[c][i];
            if (inverse)
                fb[pos+i]=~c;
            else
                fb[pos+i]=c;

        }
        if (inverse) {
            fb[pos+6]=0xff;
            fb[pos+5]=0xff;
        } else {
            fb[pos+6]=0x0;
            fb[pos+5]=0x0;
        }
        pos+=7;
    }
}

void pcd8544_send_char_size2_fb(uint8_t ch, uint8_t x, uint8_t y) {
    uint8_t s[5]; // source
    uint8_t r[20]; // result
    uint8_t i,j;
   // get littera
    if (ch >= 0x20 && ch <= 0xf0)
    {
        for (i=0; i < 5; i++)
        {
              uint8_t c=(ch<0xe0) ? ch - 0x20 : ch - 0x50;
              s[i]=ASCII_5x7[c][i];
        }
    }
    // scale
    for(i=0;i<5;i++)
    {
        uint8_t a=0;
        for(j=0;j<4;j++)
        {
            uint8_t b=(s[i]>>j) & 0x01;
            a|=(b<<(j<<1)) | (b<<((j<<1)+1));
        }
        r[(i<<1)]=a;
        r[(i<<1)+1]=a;
    }

    for(i=0;i<5;i++)
    {
        uint8_t a=0;
        for(j=0;j<4;j++)
        {
            uint8_t b=(s[i]>>(j+4)) & 0x01;
            a|=(b<<(j<<1)) | (b<<((j<<1)+1));
        }
        r[(i<<1)+10]=a;
        r[(i<<1)+11]=a;
    }
    // print
    pos=y*LCD_X+x;
    if (pos<(LCD_LEN-14))
    {
        fb[pos++]=0x00; fb[pos++]=0x00;
        for(i=0;i<10;i++)
            fb[pos++]=r[i];

        fb[pos++]=0x00; fb[pos++]=0x00;
    };

    pos=(y+1)*LCD_X+x;
    if(pos<(LCD_LEN-14))
    {
        fb[pos++]=0x00; fb[pos++]=0x00;
        for(i=10;i<20;i++)
            fb[pos++]=r[i];
        fb[pos++]=0x00; fb[pos++]=0x00;
    }
}

void pcd8544_send_char_size3_fb(uint8_t ch, uint8_t x, uint8_t y) {
    uint8_t s[5]; // source
    uint8_t r[45]; // result
    uint8_t i;
    // get littera
    if (ch >= 0x20 && ch <= 0xf0)
    {
        for (i=0; i < 5; i++)
        {
              uint8_t c=(ch<0xe0) ? ch - 0x20 : ch - 0x50;
              s[i]=ASCII_5x7[c][i];
        }
    }
    // scale
    for(i=0;i<5;i++)
    {
        uint8_t b,a;
        b=(s[i] & 0x01);
        a=(b) ? 0x7 : 0;
        b=(s[i]>>1) & 0x01;
        if (b) a|=0x38;
        b=(s[i]>>2) & 0x01;
        a|=(b<<6)|(b<<7);

        r[i*3]=a;
        r[i*3+1]=a;
        r[i*3+2]=a;

        r[i*3+15]=b;
        r[i*3+16]=b;
        r[i*3+17]=b;
    }

    for(i=0;i<5;i++)
    {
        uint8_t b,a;
        b=(s[i]>>3) & 0x01;
        a=(b) ? 0x0e : 0;
        b=(s[i]>>4) & 0x01;
        if (b) a|=0x70;
        b=(s[i]>>5) & 0x01;
        a|=(b<<7);

        r[i*3+15]|=a;
        r[i*3+16]|=a;
        r[i*3+17]|=a;
     }

    for(i=0;i<5;i++)
    {
        uint8_t b,a;
        b=(s[i]>>5) & 0x01;
        a=(b) ? 0x3 : 0;
        b=(s[i]>>6) & 0x01;
        if (b) a|=0x1c;
        b=(s[i]>>7) & 0x01;
        if (b) a|=0xe0;

        r[i*3+30]=a;
        r[i*3+31]=a;
        r[i*3+32]=a;
     }

    // print
    pos=y*LCD_X+x;
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;
    for(i=0;i<15;i++) fb[pos++]=r[i];
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;

    pos=(y+1)*LCD_X+x;
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;
    for(i=15;i<30;i++) fb[pos++]=r[i];
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;

    pos=(y+2)*LCD_X+x;
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;
    for(i=30;i<45;i++) fb[pos++]=r[i];
    fb[pos++]=0;fb[pos++]=0; fb[pos++]=0;
}

void pcd8544_print_uint8_at(uint8_t num, uint8_t size, uint8_t x, uint8_t y){
    uint8_t sym[3];
    int8_t i=2;
    do  {
      if (num == 0 && i<2)
        sym[i]=0x20; // space
      else
        sym[i]=0x30+num%10;

      num=num/10;
      i--;

    } while (i>=0);

    uint8_t j=0;
    for (i=0;i<3;i++)
    {
        if (!(i<2 && sym[i] == 0x20))
        {
            switch(size) {
            case 3:
                pcd8544_send_char_size3_fb(sym[i],x+j*6*size,y);
                break;
            case 2:
                pcd8544_send_char_size2_fb(sym[i],x+j*6*size,y);
                break;
            default:
                pcd8544_send_char(sym[i],false);
                break;
            }
            j++;
        }
    }
}

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

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

void pcd8544_send_char(uint8_t ch,bool inverse)
{
    if (ch >= 0x20 && ch <= 0xf0 && pos <= (LCD_LEN-7))
    {
        for (uint8_t i=0; i < 5; i++)
        {
            uint8_t c=(ch<0xe0) ? ch - 0x20 : ch - 0x50;
            c=ASCII_5x7[c][i];
            if (inverse)
                fb[pos+i]=~c;
            else
                fb[pos+i]=c;

        }
        if (inverse) {
            fb[pos+6]=0xff;
            fb[pos+5]=0xff;
        } else {
            fb[pos+6]=0x0;
            fb[pos+5]=0x0;
        }
        pos+=7;
    }
}

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

void pcd8544_mirror() {
    for (uint16_t i=0;i<LCD_LEN;i++) {
        uint8_t b=fb[i];
        fb[i]=(lookup[b&0b1111] << 4) | lookup[b>>4];
    }
    for (uint16_t i=0,j=(LCD_LEN-1);i<(LCD_LEN>>1);i++,j--) {
        uint8_t b=fb[i];
        fb[i]=fb[LCD_LEN-1-i];
        fb[j]=b;
    }
}

С помощью новых функций мы можем добавить в программу несколько новых тестов.

Первый тест, это команда "fill". Она не текстовая, но на мой взгляд полезная, заливает экран паттерном 0хАА:

            }  else if (comp_str(uart_buf,"fill") && isPower) {
                pcd8544_clear();
                pcd8544_fill_fb(0xaa);
                pcd8544_display_fb();

Выглядит это следующим образом:

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

Команда "test" - заполняет дисплей текстом одинарной величины:

            } else if(comp_str(uart_buf,"test") && isPower) {
                pcd8544_clear();
                lcd_print("Hello, World",0,1);
                lcd_print("++++++++++++",0,0);
                lcd_print("!!!!!!!!!!!!",0,2);
                lcd_print_invert("testtesttest",0,3);
                lcd_print("------------",0,4);
                lcd_print("Privet, MiR!",0,5);
                if (isMirror)
                    pcd8544_mirror();
                pcd8544_display_fb();

Здесь четвертая строка выводится в инверсном режиме.

Команда "mirror" переключает режим зеркалирования изображения по вертикали:

            } else if (comp_str(uart_buf,"mirror") && isPower) {
                isMirror=!isMirror;
                if (isMirror)
                    print("mirror mode is ON\n");
                else
                    print("mirror mode is OFF\n");
                pcd8544_mirror();
                pcd8544_display_fb();

Команда "сount" включает счетчик большим шрифтом. Именно она стартует первой при включении дисплея:

            if (count_enable && !isEncoder) {
                pcd8544_clear();
                lcd_print_hugo('>',0,2);
                pcd8544_print_uint8_at(tick,3,18,2);
                if (isMirror)
                    pcd8544_mirror();
#ifndef         USE_DMA
                pcd8544_display_fb();
#else
                pcd8544_DMA_UPD();
#endif
                tick++;

И также имеется режим работы с энкодером:

Если начать крутить энкодер во время выполнения команды "count", то на дисплее станет отображаться значение энкодера. После того как энкодер остановили, программа ждет 3 секунды и снова запускает команду "count". Во всех иных случаях, дисплей продолжит казать последнее значение энкодера, пока не поступит новая команда или энкодер снова не начнет вращаться.

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/18_pcd8544_text

7) Интерфейс FM-приемника

В качестве примера также можно вспомнить про интерфейс FM-приемника, который был описан в "ATmega8 + PCD8544: работа с графическим дисплеем от телефонов Nokia 5110/3310 - Интерфейс для FM-радиоприемника". На этот раз у нас есть энкодер, и мы можем добавить эффект смены частоты приемника.

Для этого, в модуль "pcd8544.c" добавляем следующие функции:

const uint8_t pointer[7]={0x2,0x6,0xc,0x18,0xc,0x6,0x2};
const uint8_t stereo[7]={0x3c,0x66,0xc3,0x81,0xc3,0x66,0x3c};
const uint8_t battery[14]={28,34,65,93,93,65,93, 93,65,93,93,65,127,0};


extern uint16_t freq;

void pcd8544_set_point(uint8_t x, uint8_t y) {
    if (x < LCD_X && y < LCD_Y)
    {
        uint16_t index = ((y>>3)*LCD_X)+x;
        fb[index]|=(1<<(y&0x07));
    }
}

//https://ru.wikibooks.org/wiki/%D0%A0%D0%B5%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D0%B8_%D0%B0%D0%BB%D0%B3%D0%BE%D1%80%D0%B8%D1%82%D0%BC%D0%BE%D0%B2/%D0%90%D0%BB%D0%B3%D0%BE%D1%80%D0%B8$
void pcd8544_draw_line(uint8_t x1, uint8_t y1, uint8_t x2, uint8_t y2) {
    const int deltaX = myabs(x2 - x1);
    const int deltaY = myabs(y2 - y1);
    const int signX = x1 < x2 ? 1 : -1;
    const int signY = y1 < y2 ? 1 : -1;

    int error = deltaX - deltaY;

    pcd8544_set_point(x2,y2);
    while(x1 != x2 || y1 != y2)
    {
        pcd8544_set_point(x1,y1);
        const int error2 = error * 2;

        if(error2 > -deltaY)
        {
            error -= deltaY;
            x1 += signX;
        }
        if(error2 < deltaX)
        {
            error += deltaX;
            y1 += signY;
        }
    }
}
void pcd8544_draw_icon_fb(const char * img, uint8_t x,uint8_t y, uint8_t num) {
    pos=x+y*84;
    uint8_t i;
    for(i=0;i<(num*7);i++)
    {
        uint8_t c=img[i];
        if ((pos+i)<504) fb[pos+i]|=c;
    }
    pos+=num;
}


void fm_radio_interface(uint16_t freq) {
    //fr=freq;
    pcd8544_clear();

    uint8_t f1=(uint8_t)(freq/10);
    uint8_t f2=(uint8_t)(freq%10);
    if (f1>=100)
        pcd8544_print_uint8_at(f1,FONT_SIZE_3,7,1);
    else
        pcd8544_print_uint8_at(f1,FONT_SIZE_3,22,1);

    pcd8544_print_uint8_at(f2,FONT_SIZE_2,66,2);

    pos=312;
    pcd8544_send_char(0x27,false);
    pcd8544_print_at_fb("MHz",FONT_SIZE_1,63,1);
    pcd8544_print_at_fb("MM", FONT_SIZE_1,18,0);
    pcd8544_print_at_fb("60dB", FONT_SIZE_1,36,0);
    pcd8544_print_at_fb("7th-STATION", FONT_SIZE_1,3,4);
    //pcd8544_print_at_fb("vol-", FONT_SIZE_1,0,5);
    //pcd8544_print_uint8_at(11, FONT_SIZE_1,18,5);

    pcd8544_draw_line(0,46,83,46);
    for(uint8_t j=0;j<14;j++)
        pcd8544_draw_line(j*6,44,j*6,47);
    pcd8544_draw_line(83,44,83,47);

    char * ptr= (char *)(&pointer);
    uint8_t p=(freq-880)/2-(freq-880)/10;
    pcd8544_draw_icon_fb(ptr,p,5,1);

    ptr= (char *)(&stereo);
    pcd8544_draw_icon_fb(ptr,0,0,1);
    pcd8544_draw_icon_fb(ptr,7,0,1);

    ptr= (char *)(&battery);
    pcd8544_draw_icon_fb(ptr,70,0,2);

    if (isMirror)
        pcd8544_mirror();

#ifndef USE_DMA
    pcd8544_display_fb();
#else
    pcd8544_DMA_UPD();
#endif
}

void radio_encoder_interface(int offset) {
    pcd8544_clear();
    /// print frequency
    uint16_t f=(uint16_t)(freq + offset);
    uint8_t f1=(uint8_t)(f/10);
    uint8_t f2=(uint8_t)(f%10);

    if (f1>=100)
        pcd8544_print_uint8_at(f1,FONT_SIZE_3,7,1);
    else
        pcd8544_print_uint8_at(f1,FONT_SIZE_3,22,1);

    pcd8544_print_uint8_at(f2,FONT_SIZE_2,66,2);

    pos=312;
    pcd8544_send_char(0x27,false);

    if (isMirror)
        pcd8544_mirror();

#ifndef USE_DMA
    pcd8544_display_fb();
#else
    pcd8544_DMA_UPD();
#endif
}

Алгоритм простой. По команде "radio" мы будем вызывать функцию fm_radio_interface(), которая будет рисовать интерфейс FM-приемника:

А когда начнем крутить ручку энкодера будем вызывать функцию radio_encoder_interface(), которая будет отображать настройку частоты:

После того, как ручку экодера перестанут крутить, снова вызовется функция fm_radio_interface(), но уже с новой частотой на дисплее.

Исходник "main.c" на данном этапе имеет следующий вид:

#include "main.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_usart.h"
#include "stm32f10x_spi.h"
#include "stm32f10x_rcc.h"
#include "uart.h"
#include "pcd8544.h"
extern bool isPower;
extern uint8_t tim4_counter;
extern uint8_t uart_ready;
extern uint8_t uart_index;
extern char uart_buf[UART_BUFFER_LEN];              // UART_BUFFER_LEN=10
uint16_t freq=967;
bool isMirror=false;
bool isRadio=false;
void clear_uart_buf();
void print_encoder(char* str, int value);

int main()
{
    // enable GPIOC port
    RCC->APB2ENR |= RCC_APB2Periph_GPIOC;           // enable PORT_C
    RCC->APB2ENR |= RCC_APB2Periph_GPIOA;           // enable PORT_A
    RCC->APB2ENR |= RCC_APB2Periph_GPIOB;           // enable PORT_B
    RCC->APB2ENR |= RCC_APB2Periph_USART1;          // enable UART1
    RCC->APB1ENR |= RCC_APB1Periph_TIM2;            // enable TIM2
    RCC->APB1ENR |= RCC_APB1Periph_TIM4;            // enable TIM4
#ifdef HW_SPI
    RCC->APB2ENR |= RCC_APB2Periph_SPI1;            // enable SPI1
#endif
#ifdef USE_DMA
    RCC->AHBENR  |= RCC_AHBENR_DMA1EN;              // enable DMA1
#endif
    // --- GPIO setup ----
    GPIOC->CRH &= ~(uint32_t)(0xf<<20);             // Reset for PC13 (LED)
    GPIOC->CRH |=  (uint32_t)(0x2<<20);             // Push-Pull 2-MHz fo PC13 (LED)
    gpio_set(GPIOC,LED);
    GPIOA->CRH &= ~(uint32_t)(0xf<<4);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<4);              // for PA9 = USART1_TX
    GPIOA->CRH &= ~(uint32_t)(0xf<<8);              // enable Alterentive mode
    GPIOA->CRH |=  (uint32_t)(0xa<<8);              // for PA10 = USART1_RX
    // encoder PA0, PA1
    GPIOA->CRL &= ~(uint32_t)(0xff);                // clear
    GPIOA->CRL |=  (uint32_t)(0x88);                // pull-down input mode for PA0, PA1
    GPIOA->ODR |= (uint16_t)(0x3);                  // switch to pull-up mode for PA0,PA1
    // Configure Port B. PB6_DC, PB4_RST, PB5_BackLight
    GPIOB->CRL &= ~(uint32_t)(0xf<<24);    // clear mode for PB6   (DC)
    GPIOB->CRL &= ~(uint32_t)(0xf<<16);    // clear mode for PB4   (RST)
    GPIOB->CRL &= ~(uint32_t)(0xf<<20);    // clear mode for PB5   (BackLight)
    GPIOB->CRL |= (uint32_t)(0x3<<24);     // for PB6 set  PushPull mode 50MHz (DC)
    GPIOB->CRL |= (uint32_t)(0x3<<16);     // for PB4 set  PushPull mode 50MHz (RST)
    GPIOB->CRL |= (uint32_t)(0x7<<20);     // for PB5 set  OpenDrain mode 50MHz (BL)
    // SOFT SPI Setup (PA4_SE, PA5_CLK,  PA7_DIO)
    GPIOA->CRL &= ~(uint32_t)(0xf<<16);    // clear mode for PA4   (SE)
    GPIOA->CRL &= ~(uint32_t)(0xf<<20);    // clear mode for PA5   (SPI_Clock)
    GPIOA->CRL &= ~(uint32_t)(0xf<<28);    // clear mode for PA7   (SPI_DIO)
    GPIOA->CRL |= (uint32_t)(0x3<<16);     // for PA4 set  PushPull mode 50MHz (SE)
#ifdef HW_SPI
    GPIOA->CRL |= (uint32_t)(0xb<<20);              // for PA5 set  Alternative mode, 50MHz
    GPIOA->CRL |= (uint32_t)(0xb<<28);              // for PA7 set  Alternative mode, 50MHz
#else
    GPIOA->CRL |= (uint32_t)(0x3<<20);     // for PA5 set  PushPull mode 50MHz (SPI_Clock)
    GPIOA->CRL |= (uint32_t)(0x3<<28);     // for PA7 set  PushPull mode 50MHz (SPI_DIO)
#endif
    // ------- SysTick CONFIG --------------
    if (SysTick_Config(72000)) // set 1ms
    {
        while(1); // error
    }
    // ------ TIM2 Setup -------------------
    TIM2->CCMR1 = TIM_CCMR1_CC1S_0 | TIM_CCMR1_CC2S_0;
    TIM2->CCER  = TIM_CCER_CC1P | TIM_CCER_CC2P;
    TIM2->SMCR  = TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;
    TIM2->ARR   = 1000;
    TIM2->CR1   = TIM_CR1_CEN;
    // ----- TIM4 Setup -------
    TIM4->CR1 = (0x80);                                 // set ARPE flag
    TIM4->PSC = 36000 - 1;                              // 1000 tick/sec
    TIM4->ARR = 100;                                    // 10 Interrupt/sec (1000/100)
    TIM4->DIER |=  TIM_DIER_UIE;                        // enable interrupt per overflow
    TIM4->SR = 0;
    TIM4->EGR |= (0x01);
    TIM4->CR1 |= TIM_CR1_CEN;                           // enable timer
    // --- UART setup ----
    //USART1->BRR  = 0x1d4c;                        // 9600 Baud, when APB2=72MHz
    USART1->BRR = 0x271;                            // 115200 Baud, when APB2=72MHz
    USART1->CR1 |= (USART_CR1_UE_Set | USART_Mode_Tx | USART_Mode_Rx | USART_CR1_RXNEIE);  // enable USART1, enable  TX/RX mode, enable RX interrupt
#ifdef HW_SPI
    // --- SPI1 setup ----
    SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_8b|SPI_NSS_Soft|SPI_BaudRatePrescaler_4);
#ifdef USE_DMA
    SPI1->CR2 |= SPI_CR2_TXDMAEN;           // enable DMA request
#endif
    SPI1->CR1 |= SPI_CR1_SPE;
#endif

    //---------- NVIC ----------------------
    NVIC_SetPriority(USART1_IRQn,1);                // set priority for USART1 IRQ
    NVIC_EnableIRQ(USART1_IRQn);                    // enable USART1 IRQ via NVIC
    NVIC_SetPriority(TIM4_IRQn, 21);                // set priority for TIM4_IRQ
    NVIC_EnableIRQ(TIM4_IRQn);                      // enable TIM4 IRQ
    //////////////////////////////////////
    /// let's go.....
    /////////////////////////////////////
    bool isEncoder=false;
    int enc_prev_value=0;
    uint8_t led_toggle=0;
    bool tick_enable=1;
    bool count_enable=true;
    uint8_t tick=0;
    uint8_t count=0;
    clear_uart_buf();
    println("ready...");
    pcd8544_init();
    for(;;){
        if (uart_ready) {
            if (uart_buf[0] == '?' && uart_buf[1] == 0x0) {
                print("help:\n");
                print("t-  disable print tick\n");
                print("on - turn on display\n");
                print("t-  turn off display\n");
                print("test -  print on LCD text\n");
                print("fill - to fill LCD of pattern 0xAA\n");
                print("count - enable increment count on LCD\n");
                print("mirror - rotate image on 180 deg\n");
            } else if (uart_buf[0] == 't' && uart_buf[1] == '-') {
                print("turn off tick\n");
                tick_enable=false;
            } else if (comp_str(uart_buf,"off") && isPower) {
                pcd8544_off();
                count_enable=false;
                isRadio=false;
            } else if (comp_str(uart_buf,"mirror") && isPower) {
                isMirror=!isMirror;
                if (isMirror)
                    print("mirror mode is ON\n");
                else
                    print("mirror mode is OFF\n");
                pcd8544_mirror();
                pcd8544_display_fb();
            }  else if (comp_str(uart_buf,"fill") && isPower) {
                pcd8544_clear();
                pcd8544_fill_fb(0xaa);
                pcd8544_display_fb();
                count_enable=false;
                isRadio=false;
            }  else if (comp_str(uart_buf,"radio") && isPower) {
                fm_radio_interface(freq);
                count_enable=false;
                isRadio=true;
            } else if (uart_buf[0] == 'o' && uart_buf[1] == 'n' && !isPower) {
                pcd8544_init();
                isRadio=false;
            } else if (comp_str(uart_buf,"count") && isPower) {
                count_enable=true;
                 isRadio=false;
                tick=0;
            } else if(comp_str(uart_buf,"test") && isPower) {
                pcd8544_clear();
                lcd_print("Hello, World",0,1);
                lcd_print("++++++++++++",0,0);
                lcd_print("!!!!!!!!!!!!",0,2);
                lcd_print_invert("testtesttest",0,3);
                lcd_print("------------",0,4);
                lcd_print("Privet, MiR!",0,5);
                if (isMirror)
                    pcd8544_mirror();
                pcd8544_display_fb();
                count_enable=false;
                isRadio=false;
            } else {
                print("invalid command\n");
            }
            clear_uart_buf();
            uart_ready=0;
            uart_index=0;
        }

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            isEncoder=true;
            print_encoder("encoder: ",enc_value);
            tim4_counter=TIM4_INIT_VALUE;
            //-------- LCD ---------------
            if (enc_value >= 125) {
                enc_value -= 250;
            }
            if (!isRadio) {
                pcd8544_clear();
                if (enc_value < 0)
                    lcd_print_hugo('-',0,2);
                pcd8544_print_uint8_at(get_abs(enc_value),3,18,2);
                if (isMirror)
                    pcd8544_mirror();
#ifndef         USE_DMA
                pcd8544_display_fb();
#else
                pcd8544_DMA_UPD();
#endif
            } else
                radio_encoder_interface(enc_value);

        } else if (isEncoder && !tim4_counter) {
            __disable_irq();
            isEncoder=false;
            if (isRadio) {
                int enc_value=TIM2->CNT;
                enc_value >>=2;
                if (enc_value >= 125) {
                    enc_value -= 250;
                }
                freq +=enc_value;
                fm_radio_interface(freq);
            }
            TIM2->CNT=0;
            __enable_irq();
            enc_prev_value=0;
            print_encoder("total: ",enc_value);
        }

        if ((++count % 20)  == 0) {
            if (led_toggle)
                gpio_set(GPIOC,LED);
            else
                gpio_reset(GPIOC,LED);
            led_toggle=!led_toggle;

            if (count_enable && !isEncoder) {
                pcd8544_clear();
                lcd_print_hugo('>',0,2);
                pcd8544_print_uint8_at(tick,3,18,2);
                if (isMirror)
                    pcd8544_mirror();
#ifndef         USE_DMA
                pcd8544_display_fb();
#else
                pcd8544_DMA_UPD();
#endif
                tick++;
            }

            if (tick_enable)
                print("tick..\n");
            count=0;
        }
        delay_ms(50);
    }
}

void clear_uart_buf() {
    for (uint8_t i=0; i<UART_BUFFER_LEN;i++)
        uart_buf[i]=0;
}

void print_encoder(char* str, int value) {
    if (value >= 125) {
        value -= 250;
    }
    print(str);
    uart_print_int(value);
    uart_send_char('\n');
}

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/19_pcd8544_radio

8) Меню со скролом

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

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

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

Показывать анимацию на фото дело неблагодарное, но выглядит это так:

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

Всю механику работы меню я реализовал в отдельном модуле "menu.c":

#include "pcd8544.h"
#include "menu.h"
#include "uart.h"

#define LCD_X 12
#define LCD_Y 6
#define LCD_LEN (uint8_t)(LCD_X * LCD_Y)        // =72
#define MAIN_MENU_MAX_DEPTH 8
#define MAIN_MENU_LEN   (uint8_t)(MAIN_MENU_MAX_DEPTH * LCD_X)
const uint8_t  menu[MAIN_MENU_LEN] = {"  LINE 01     LINE 02     LINE 03     LINE 04     LINE 05     LINE 06     LINE 07     LINE 08   "};

extern bool isMirror;
static uint8_t select_line=0;

void main_menu(uint8_t select) {
    if (select >= MAIN_MENU_MAX_DEPTH)
        return;

    uint8_t start;
    if (select < LCD_Y)
        start=0;
    else
        start=(select-LCD_Y+1);

    pcd8544_clear();
#if MAIN_MENU_MAX_DEPTH > LCD_Y
    for (uint8_t i=(start*LCD_X); i<((start*LCD_X)+LCD_LEN); i++) {
        if (i<(MAIN_MENU_MAX_DEPTH*LCD_X))
            pcd8544_send_char(menu[i],false);
    }
#else
    for (uint8_t i=0; i<MAIN_MENU_LEN; i++) {
        pcd8544_send_char(menu[i],false);
    }
#endif

    menu_inverse_line(select);

    if (isMirror)
        pcd8544_mirror();
    pcd8544_display_fb();

    select_line=select;
}

void main_menu_enc(int offset) {
    int nl=(int)(select_line + offset);
    if (nl < 0) {
        nl=0;
    } else if (nl >= MAIN_MENU_MAX_DEPTH) {
        nl = (MAIN_MENU_MAX_DEPTH -1);
    }

    main_menu((uint8_t)nl);
    return;
}

void menu_inverse_line(uint8_t line) {
    if (line < LCD_Y) {
        pcd8544_set_pos(0,line);
    } else {
        pcd8544_set_pos(0,LCD_Y-1);
    }


    line = line *LCD_X;
    for (uint8_t i=line; i<(line+LCD_X); i++) {
        uint8_t c=menu[i];
        pcd8544_send_char(c,true);
    }

}

Здесь функция "void main_menu_enc(int offset)" получает значения с энкодера, вычисляет положение положение ползунка и вызывает отрисовку меню через функцию "void main_menu(uint8_t select)". Функция "void menu_inverse_line(uint8_t line)" инвертирует строку с ползунком.

В главный цикл потребовалось добавить лишь одно ветвление для работы энкодера с меню (выделено красным):

        int enc_value=TIM2->CNT;
        enc_value >>=2;
        if (enc_value != enc_prev_value) {
            enc_prev_value=enc_value;
            isEncoder=true;
            print_encoder("encoder: ",enc_value);
            tim4_counter=TIM4_INIT_VALUE;
            //-------- LCD ---------------
            if (enc_value >= 125) {
                enc_value -= 250;
            }
            if (!isRadio && !isMenu) {
                pcd8544_clear();
                if (enc_value < 0)
                    lcd_print_hugo('-',0,2);
                pcd8544_print_uint8_at(get_abs(enc_value),3,18,2);
                if (isMirror)
                    pcd8544_mirror();
#ifndef         USE_DMA
                pcd8544_display_fb();
#else
                pcd8544_DMA_UPD();
#endif
            } else if (isRadio) {
                radio_encoder_interface(enc_value);
            } else if (isMenu) {
                TIM2->CNT=0;
                if (enc_value)
                    main_menu_enc(enc_value);
            }

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

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/20_pcd8544_menu

9) Бегущая строка

Одним из самых востребованных эффектов на мой взгляд - это бегущая строка. Самый простой пример - радиотекст, который может быть длиною до 64 символов. И этот текст нужно вывести строкою в 12 символов.

Бегущая строка тоже бывает разная: посимвольная, побитовая, со скролом вправо или влево, кольцевая и т.д.

Я реализовал бегущую строку которая "бежит" в одну сторону, а по достижении конца строки - "бежит" в другую. Т.к. у нас графический дисплей, то строка побитовая.

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

Реализация у меня вышла несколько корявой. При высокой скорости скрола строка становится нечитаемой. Я остановился на скорости 4 пикселя в секунду, которая дает достаточно четкую картинку.

Если посмотреть на фото:

То пятая строка все-таки нечеткая. В программе у меня при выводе бегущей строки перерисовывается весь экран. Я полагаю, что следует печатать только данную строку, дабы нивелировать этот эффект. Сделать это не сложно, но я не стал усложнять программу.

Функция печати бегущей строки:

void running_text() {

    for (uint16_t i=text_offset,j=0;i<(text_offset +LCD_X);i++,j++) {
        uint8_t c=buf2A[i];
        fb[336+j]=c;
    }
    text_offset =(direct_text) ? text_offset +1: text_offset -1;
    if (text_offset >= (buff2A_LEN-LCD_X) || text_offset == 0) {
        direct_text=!direct_text;
    }

    pcd8544_display_fb();
}

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/21_pcd8544_running_text

10) Пара слов про дисплей ST7735

Я решил начать обзор цветных дисплеев с ST7735, который на мой взгляд является такой же "классикой" как и дисплей Nokia 5110. Это дисплей начального уровня, с доступной ценой. Он широко представлен на али. У меня имеется вариант с разрешением 128х160 пикселей, его я и буду рассматривать.

Дисплеи подобного разрешения ставились на телефоны в середине нулевых, сейчас их тоже используют в бюджетных кнопочных телефонах. На али их продают в виде модулей для Arduino. Так же часто их совмещают со слотом SD/microSD карты.

К сожалению, с цветными дисплеями не все так гладко. Они требуют значительной производительности от микроконтроллера и большого объема памяти под видеобуфер. STM32F103xx такой памятью не обладает, поэтому часто используют отрисовку картинки непосредственно на дисплей. Получается такая не радостная картинка, что возможности STM32F103 для монохромных дисплеев избыточны, а для цветных недостаточны. Особенно печальной выглядит ситуация, если использовать библиотеки Arduino, HAL, SPL и прочее. Видео с тестами скорости отрисовки для ST7735 можно найти на ютьюбе. На мой взгляд, для комфортной работы с цветными дисплеями требуются микроконтроллеры серии F4xx. Там уже достаточно памяти и SPI пошустрее.

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

Итак, мы имеем дисплей 160 на 128 пикселей, каждый пиксель кодируется двумя байтами цвета - R5G6B5.

Как я уже упоминал, дисплей имеет совместимый с PCD8544 разъем подключения, за тем исключением, что подсветка (BL) подключается не к земле, а к шине питания 3.3V

Мой модуль имел преобразователь питания с 5В на 3.3В и надпись, что для использования с питанием 3.3 следует запаять перемычку J1, что я и сделал:

В принципе, преобразователь можно выпаять и использовать где-то в более нужном месте.

Дисплей имеет защитную пленку с непонятной надписью (я сначала подумал, что это битые пиксели):

Ну, давайте попробуем его подключить к микроконтроллеру.

11) Инициализация дисплея ST7735

Дислей подключается также как Nokia 5110, на те же самые выводы микроконтроллера. Единственное исключение - подсветка (BL) подключается к шине питания 3.3 Вольт. Без подсветки вы ничего увидете на дисплее, это не монохромник, который может работать на отраженном свете.

Нам нужно вернуться в пункт "Подключение дисплея Nokia 5110". Пока отключим аппаратный SPI, также я переименовал модуль "pcd8544.c" в "st7735.c".

Процедура инициализации подобных дисплеев, это длинная портянка из команд и их параметров. Рабочий код процедуры инициализации дисплеев обычно берется из библиотек Adafruit, если он там есть, и дальше этот код в различных вариациях гуляет по интернету. Я процедуру инициализации брал из этой статьи: "Подключение дисплея на базе ST7735 к микроконтроллеру STM32", которая также ссылается на библиотеку Adafruit. Получилось в итоге следующее:

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();
}

Где функция st7735_send() - это переименованная функция pcd8544_send() без каких либо изменений:

void st7735_send(uint8_t dc, uint8_t data)
{

   if (dc == LCD_D)
      gpio_set(GPIOB,DC);
   else
      gpio_reset(GPIOB,DC);

#ifdef HW_SPI
   SPI1->DR=data;
   while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
#else

   for (uint8_t i=0; i<8; i++)
   {
      if (data & 0x80)
          gpio_set(GPIOA,DIN);
      else
          gpio_reset(GPIOA,DIN);

      data=(data<<1);
      // Set Clock Signal
      gpio_set(GPIOA,CLK);
      gpio_reset(GPIOA,CLK);
   }
#endif
}

Также нам нужны будут следующие именованные константы:

// some flags for initR() :(
#define INITR_GREENTAB 0x00
#define INITR_REDTAB 0x01
#define INITR_BLACKTAB 0x02
#define INITR_18GREENTAB INITR_GREENTAB
#define INITR_18REDTAB INITR_REDTAB
#define INITR_18BLACKTAB INITR_BLACKTAB
#define INITR_144GREENTAB 0x01
#define INITR_MINI160x80 0x04
#define INITR_HALLOWING 0x05

// Some register settings
#define ST7735_MADCTL_BGR 0x08
#define ST7735_MADCTL_MH 0x04

#define ST7735_FRMCTR1 0xB1
#define ST7735_FRMCTR2 0xB2
#define ST7735_FRMCTR3 0xB3
#define ST7735_INVCTR 0xB4
#define ST7735_DISSET5 0xB6

#define ST7735_PWCTR1 0xC0
#define ST7735_PWCTR2 0xC1
#define ST7735_PWCTR3 0xC2
#define ST7735_PWCTR4 0xC3
#define ST7735_PWCTR5 0xC4
#define ST7735_VMCTR1 0xC5

#define ST7735_PWCTR6 0xFC

#define ST7735_GMCTRP1 0xE0
#define ST7735_GMCTRN1 0xE1

// Some ready-made 16-bit ('565') color settings:
#define ST7735_BLACK ST77XX_BLACK
#define ST7735_WHITE ST77XX_WHITE
#define ST7735_RED ST77XX_RED
#define ST7735_GREEN ST77XX_GREEN
#define ST7735_BLUE ST77XX_BLUE
#define ST7735_CYAN ST77XX_CYAN
#define ST7735_MAGENTA ST77XX_MAGENTA
#define ST7735_YELLOW ST77XX_YELLOW
#define ST7735_ORANGE ST77XX_ORANGE


#define ST_CMD_DELAY 0x80 // special signifier for command lists

#define ST77XX_NOP 0x00
#define ST77XX_SWRESET 0x01
#define ST77XX_RDDID 0x04
#define ST77XX_RDDST 0x09

#define ST77XX_SLPIN 0x10
#define ST77XX_SLPOUT 0x11
#define ST77XX_PTLON 0x12
#define ST77XX_NORON 0x13

#define ST77XX_INVOFF 0x20
#define ST77XX_INVON 0x21
#define ST77XX_DISPOFF 0x28
#define ST77XX_DISPON 0x29
#define ST77XX_CASET 0x2A
#define ST77XX_RASET 0x2B
#define ST77XX_RAMWR 0x2C
#define ST77XX_RAMRD 0x2E

#define ST77XX_PTLAR 0x30
#define ST77XX_TEOFF 0x34
#define ST77XX_TEON 0x35
#define ST77XX_MADCTL 0x36
#define ST77XX_COLMOD 0x3A

#define ST77XX_MADCTL_MY 0x80
#define ST77XX_MADCTL_MX 0x40
#define ST77XX_MADCTL_MV 0x20
#define ST77XX_MADCTL_ML 0x10
#define ST77XX_MADCTL_RGB 0x00

#define ST77XX_RDID1 0xDA
#define ST77XX_RDID2 0xDB
#define ST77XX_RDID3 0xDC
#define ST77XX_RDID4 0xDD

// Some ready-made 16-bit ('565') color settings:
#define ST77XX_BLACK 0x0000
#define ST77XX_WHITE 0xFFFF
#define ST77XX_RED 0xF800
#define ST77XX_GREEN 0x07E0
#define ST77XX_BLUE 0x001F
#define ST77XX_CYAN 0x07FF
#define ST77XX_MAGENTA 0xF81F
#define ST77XX_YELLOW 0xFFE0
#define ST77XX_ORANGE 0xFC00

Теперь ставим вызов "st7735_init()" перед главным циклом, собираем проект и прошиваем микроконтроллер. Если все сделанно правильно, то на дисплее должна появится такая картинка:

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

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/23_st7735_init

12) Функция заливки

У st7735 есть интересная особенность. Для того чтобы залить его какой-нибудь картинкой, сперва нужно определить рабочую область. Это означает, что если мы хотим допустим прочертить прямую вертикальную линию, нам не требуется перерисовывать весь экран, нужно лишь выделить область 160 х 1 и залить ее нужным цветом. Т.е. не смотря на то, что у нас в stm32f103 мало памяти, мы все-равно можем использовать буфер для работы с частью дисплея. Если вспомнить интерфейс FM-приемника, то там рабочая область дисплея как раз разбита на несколько сегментов/тайлов.

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

void st7735_fill(uint16_t value)
{
   chip_select_enable();

   st7735_send(LCD_C,ST77XX_CASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0x7F);         // 127 horizontal

   st7735_send(LCD_C,ST77XX_RASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0x9f);         // 159  vertical

   st7735_send(LCD_C,ST77XX_RAMWR);

   uint8_t c1=value>>8;
   uint8_t c2=(uint8_t)value;

   gpio_set(GPIOB,DC);
   for (uint16_t i=0; i < (uint16_t)20480; i++)
   {
      st7735_send(LCD_D,c1);
      st7735_send(LCD_D,c2);
   }

   chip_select_disable();
}

Главный цикл пока у нас будет такой:

    for(;;) {
        gpio_set(GPIOC,LED);
        st7735_fill(ST77XX_GREEN);
        delay_ms(3000);
        println("begin");
        st7735_fill(ST77XX_BLUE);
        println("end");
        gpio_reset(GPIOC,LED);
        delay_ms(3000);
    }

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/24_st7735_fill

Запускаем прошивку:

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

Давайте посмотрим сколько у нас занимает заливка всего экрана. Запускаем монитор последовательного порта с печатью времени:

15:30:47.310-> be15:30:47.311-> gin
15:30:47.578-> end
15:30:53.843-> begin
15:30:54.111-> end

Получаем (578-310) = 268 мс на кадр, итого три кадра в секунду! Что делать?

13) Выжимаем 90 fps из дисплея ST7735

Ну, давайте включим для начала аппаратный SPI:

20:06:18.123-> be20:06:18.124-> gin
20:06:18.187-> end
20:06:24.250-> begin
20:06:24.314-> end

На вид, да и на глаз, заработало гораздо шустрее. (314-250) = 64 мс на один кадр. Это 15 fps. Хорошо, но недостаточно.

Самый простой способ увеличить скорость SPI в два раза - это использовать 16-битный режим передачи вместо 8-битного. Цвет у нас все-равно передается в 16-битном виде.

Для этого приводим функцию st7735_fill() к следующему виду:

void st7735_fill(uint16_t value)
{
   chip_select_enable();

   st7735_send(LCD_C,ST77XX_CASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0x7F);         // 127 horizontal

   st7735_send(LCD_C,ST77XX_RASET);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0);
   st7735_send(LCD_D,0x9f);         // 159  vertical

   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_4);
   SPI1->CR1 |= CR1_SPE_Set;        // enable SPI
   for (uint16_t i=0; i < (uint16_t)20480; i++)
   {
       SPI1->DR=value;
       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
#else
   uint8_t c1=value>>8;
   uint8_t c2=(uint8_t)value;

   gpio_set(GPIOB,DC);
   for (uint16_t i=0; i < (uint16_t)20480; i++)
   {
      st7735_send(LCD_D,c1);
      st7735_send(LCD_D,c2);
   }

   chip_select_disable();
#endif

}

И в этот раз экран меняет цвет уже как по щелчку:

20:20:05.636-> begin
20:20:05.665-> end
20:20:11.694-> begin
20:20:11.723-> en2d
20:20:17.751-> begin
20:20:17.781-> end

Получаем около 30 мс на отрисовку одного кадра. Или 1000/30 = 33 fps другими словами.

Теперь давайте предделитель SPI с 4-х поменяем на два. Т.е. в функции st7735_fill() строку, что выделена красным:

 SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_4);

поменяем на

 SPI1->CR1  = (SPI_Mode_Master|SPI_DataSize_16b|SPI_NSS_Soft|SPI_BaudRatePrescaler_2);

Снова смотрим в монитор последовательного порта:

20:40:10.136-> begin
20:40:10.157-> end
20:40:16.176-> begin
20:40:16.195-> end
20:40:22.214-> begin
20:40:22.234-> end
20:40:28.253-> begin
20:40:28.273-> end

73-53= 20 мс, прирост в полтора раза. Т.о. мы имеем уже 50 fps... Как бы нам еще увеличить скорость работы дисплея?

В поисках ответа на этот вопрос я набрел на статью на хабре: "STM32: SPI: LCD — Вы всё делаете не так [восклицательный знак]". Там предлагается в цикле передачи данных по SPI вместо:

   for (uint16_t i=0; i < (uint16_t)20480; i++)
   {
       SPI1->DR=value;
       while (!(SPI1->SR & SPI_I2S_FLAG_TXE) || (SPI1->SR & SPI_I2S_FLAG_BSY));
   }

использовать следующее:

   for (uint16_t i=0; i < (uint16_t)20480; i++)
   {
       while (!(SPI1->SR & SPI_I2S_FLAG_TXE));
       SPI1->DR=value;
   }

Давайте глянем в лог монитора:

20:52:18.474-> begin
20:52:18.486-> end
20:52:24.496-> begin
20:52:24.507-> end
20:52:30.516-> begin
20:52:30.527-> end

Ну, это уже неплохо, 11 мс на один фрейм, это целых 90 fps. Я записал на телефон короткое демо работы программы, что бы можно было на глаз оценить скорость заливки:

Полные исходники примера здесь: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/25_st7735_90fps

14) Вместо заключения: баги и все такое

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

Первый вопрос, который вероятно захочется задать: "A можно ли заставить дисплей ST7735 работать еще быстрее?". К сожалению нет. У меня имеется чип AT32F403RC, о котором я уже упоминал в: "Прошивка и отладка китайского ARM микроконтроллера AT32F403RC". Чип очень скоростной, 200 МГц частота процессора, но вот шины APB1 и APB2, они 100 МГц максимум. Ну и в целом, в плане периферии чип где-то на уровне STM32F411xx. Тем не менее, я сумел выжать 140 fps, но на этой частоте на дисплее начали появляться такие вот артефакты:

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

2. Все исходники с примерами к данной статье я выложил на гитлаб https://gitlab.com/flank1er/stm32_bare_metal, где они дополнили предыдущие примеры для stm32f103c8, т.к. создавать отдельный проект для каждой статьи как-бы не принято.

Один пример идет как-бы бонусом, это пример работы c DMA https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/22_pcd8544_dma. Там нет ничего особенного, код я брал из статьи на хабре: "Подключение OLED дисплея ssd1306 к STM32 (SPI+DMA)". В случае использования монохромного дисплея: pcd8544 или ssd1306, DMA нам ничего не дает. Этот режим обретает смысл только при использовании цветных дисплеев. Т.к. отрисовка дисплея с частотой 90 или 60 fps полностью на 100% загрузит процессор микроконтроллера. DMA возьмет эту на работу на себя, процессору останется только нарисовать картинку в буфер и "скормить" его DMA для вывода на дисплей. Звучит красиво, но всякий раз когда мы говорим про буфер, а их надо ДВА, мы смотрим в сторону от stm32f103xx где памяти для этого нет. Так что в будущем, скорее всего будет дрифт в сторону stm32f4хх, где памяти побольше.

Все примеры с 13-го до 25-й, содержат скомпилированные прошивки, которые были проверены. НО. Есть некоторые ошибки и допущения.

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

Все примеры имеют сборочные файлы Makefile и Qbs. В случае Qbs оптимизация выключается (используется флаг оптимизации "-O0"). В случае Makefile используется флаг оптимизации "-Og". А вот если собрать с флагом "-O2", то примеры работать не будут. Чтобы они работали, следует:

a) Массивы: "static uint8_t fb[LCD_LEN]" и "static uint8_t buf2A[buff2A_LEN]" объявить как volatile.

б) Обработчик прерывания таймера TIM4:

void  TIM4_IRQHandler(void){
    if (tim4_counter)
        --tim4_counter;

    TIM4->SR &= ~(0x01);
}

Следует заменить на:

void  TIM4_IRQHandler(void){

    TIM4->SR = 0;

    if (tim4_counter)
        --tim4_counter;
}

Иначе прерывание будет отрабатывать по два раза.

Других (известных) багов (пока) нет))

15) Обзор IPS дисплеев на контроллере ST7789

Спустя неделю после опубликования статьи, ко мне приехал IPS дисплей на контроллере ST7789 c разрешением 320х240 пикселей. Т.к. данный контроллер является вариантом контроллера ST7735, я решил дополнить статью этим дисплеем. По размерам дисплеи практически не отличаются, 1.8 дюйма дисплей на st7735 и 2.0 дюйма дисплей на st7789:

Но вот по качеству изображения, это уже что-то совершенно другое. Я бы сказал, что IPS дисплей - это что-то между TFT дисплеем и OLED дисплеем. Картинка очень четкая, цвета сочные, и все видно под любым углом. Такой дисплей - это гораздо лучше тех дисплеев, что ставят сейчас в бюджетные кнопочные телефоны. Устройство на таком дисплее будет не стыдно показать любому человеку, избалованному IPS дисплеями на смартфонах (при условии, что все остальное вы тоже выполните на уровне смартфона).

При этом дисплей достаточно недорогой, в пределах 300 руб, с доставкой. На али продают различные варианты дисплеев на ST7789, с разной диагональю. У меня так же валялся на полке дисплей с разрешением 240х240 на этом же контроллере. У него диагональ 1.3 дюйма:

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

Самым главным недостатком IPS дисплеев является их разрешение. Дисплей с разрешением 320х240 имеет графическую емкость в четыре (!) раза выше, чем дисплей на ST7735. Это вынуждает нас переходить на какие-то более производительные микроконтроллеры, с бОльшим объемом оперативной памяти и более производительной SPI шиной.

Но давайте вернемся к дисплеям. Если посмотреть на оборотную сторону двух-дюймового модуля, то увидим следующее:

Во-первых, здесь нет вывода BL, т.е. подсветки дисплея. Во-вторых, здесь так же присутствует преробразователь напряжения на 3.3 Вольта (обведено красным), из-за чего я поначалу подумал, что модуль рассчитан на 5 Вольта, т.е. под ардуино. Но, опытным путем выяснилось, что модуль нормально работает от 3.3 Вольт, без каких-либо доделок. У другого модуля, в свою очередь, нет вывода CS.

16) Проверка дисплеев на контроллере ST7789 в Arduino

Теоретически, ST7735 и ST7789 совместимы между собой по протоколу и по командам, т.е. теоретически, можно подключить IPS дисплей вместо ST7735 и он сразу будет работать на четверти своего экрана.

Выглядит это как-то так:

Но в моем случае это не сработало. Разница между IPS и TFT дисплеем на ST7735 еще в том, что подключая дисплей на ST7735 к питанию, вы увидите белый экран, если не забыли включить подсветку. Без подсветки вы увидите ничего. IPS дисплей с включенной подсветкой остается черным, и вам остается гадать, работает там подсветка или нет.

Поэтому пришлось поискать на полке запылившуюся плату Arduino для проверки работоспособности дисплея. Через менеджер библиотек устанавливаем библиотеку Adafruit:

Из примеров загружаем скетч "graphicstest_st7789" и в блоке "setup" раскомментируем строку "tft.init(240, 320);":

Подключаем дисплей к следующим контактам:

  #define TFT_CS        10
  #define TFT_RST        9 // Or set to -1 and connect to Arduino RESET pin
  #define TFT_DC         8
//#define TFT_MOSI 11  // Data out
//#define TFT_SCLK 13  // Clock out

Я питал Arduino через USBasp с выставленным напряжением 3.3 Вольт. После включения должен заработать тест дисплея:

Со вторым дисплеем 240x240 было сложнее. От данной библиотеки он не работал, и поначалу я даже подумал, что он нерабочий. Но, методом перебора я нашел подходящую библиотеку.

Рабочий скетч находится на гитхабе https://github.com/cbm80amiga/ST7789_BMPviaSerial

Для его работы потребуется вручную установить библиотеку: https://github.com/cbm80amiga/Arduino_ST7789_Fast

Здесь несколько иное подключение пинов:

 #01 GND -> GND
 #02 VCC -> VCC (3.3V only!)
 #03 SCL -> D13/SCK
 #04 SDA -> D11/MOSI
 #05 RES -> D8 or any digital
 #06 DC  -> D7 or any digital
 #07 BLK -> NC

Пин BL никуда не подключается. При включении дисплей показал следующее:

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

17) Исправление функций модуля "st7735.c" для совместимости с контроллером ST7789

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

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

Большой дисплей выводит прямоугольник, который целиком занимает экран ST7735. Можно заметить, что: 1) прямоугольник повернут на 90 градусов; 2) цвета не совпадают.

Что бы предыдущий код от st7735 работал с st7789, в функцию "st7735_send()" следует вернуть управление ножкой "CS" , т.е. функция должна выглядеть так:

void st7735_send(uint8_t dc, uint8_t data)
{
    chip_select_enable();
    ..
    ..
    ..
    chip_select_disable();
}

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

Остальное, в принципе, дело техники.

Полный код примера можно посмотреть по ссылке: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/26_st7735_fix

18) Инициализация дисплея ST7789

Последовательность инициализации дисплея ST7789 я брал из библиотеки Adafruit. Текст функции получился следующим:

void st7789_init() {
    // hardware reset
    gpio_reset(GPIOC,RST);
    delay_ms(10);
    gpio_set(GPIOC,RST);
    delay_ms(100);
    // init routine
    gpio_set(GPIOB,DC);
#ifndef HW_SPI
    gpio_set(GPIOA,CLK);
#endif
    // init routine

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

    st7735_send(LCD_C,ST77XX_COLMOD);
    st7735_send(LCD_D,0x55);            // 16-bit color mode
    delay_ms(10);

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

    st7735_send(LCD_C,ST77XX_CASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,0xf0);


    st7735_send(LCD_C,ST77XX_RASET);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,0);
    st7735_send(LCD_D,0x01);
    st7735_send(LCD_D,0x40);

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

    isPower=true;
}

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

В принципе, осталось поправить размеры области заливки:

Что касается времени заливки, данные несколько противоречивые:

15:43:17.180-> begin
15:43:17.212-> end
15:43:23.256-> begin
15:43:23.304-> end

Одна заливка идет за 32 мс, другая за 60 мс, но на взгляд процесс заливки заметен. В STM32F103xx скорость SPI равна 18 МBit. Но интерфейс SPI1 тактируется от шины APB2, которая работает на частоте 72 МГц. Я выставил предделитель SPI интерфейса =2 , т.е. 36 МГц, странно, но это работает.

Полный код можно посмотреть по ссылке: https://gitlab.com/flank1er/stm32_bare_metal/-/tree/master/27_st7789_fill

Опять же, я проверял скорость заливки на китайском at32f403rc, в этот раз мне удалось разогнать его SPI на максимальную скорость. В итоге, мне удалось добиться 40 fps. Если подсчитать. то 40 * 320 * 240 * 2 * 8 = 49152000 т.е. ~49 Mbit, это максимум скорости SPI интерфеса равной 50 Mbit. Видео можно посмотреть здесь: