Привет, Хабр!
В этой статье я хотел бы рассказать о своем опыте подключения LCD дисплеев к микроконтроллеру STM32 с использованием библиотеки HAL по I2C шине.

Подключать буду дисплей 1602 и 2004. Они оба имеют припаянный I2C адаптер на основе чипа PCF8574T. Отладочной платой выступит Nucleo767ZI, а средой разработки – STM32CubeIDE 1.3.0.
Про принцип работы I2C шины подробно рассказывать не буду, советую заглянуть сюда и сюда.
Создаем проект, выбираем отладочную плату:

Указываем, что будем использовать I2C1. Также я подключу UART5 для общения с платой, это нужно для получения информации от платы об адресе дисплея.


В этом же окне можно посмотреть номера ножек, к которым подключается дисплей, в моем случае получилось так:

Для начала подключим всего один дисплей, я начну с 1602. Также я подключу известный бывалым ардуинщикам адаптер USB-UART CH340 для получения данных с платы.

Обратите внимание, адаптер подключается RX к TX и TX к RX, перемычка на адаптере стоит на 3.3В

Рассмотрим подробнее работу с микросхемой PCF8574T и дисплеем. Ниже приведена принципиальная схема модуля с дисплеем:

Микросхема PCF8574T по функционалу схожа с регистром сдвига 74hc595 – она получает по I2C интерфейсу байт и присваивает своим выводам (P0-P7) значения соответствующего бита.
Рассмотрим какие выводы микросхемы соединены с дисплеем и за что отвечают:
К одной I2C шине может быть подключено несколько устройств одновременно. Для того, чтобы можно было обращаться к конкретному устройству, каждое из них имеет свой адрес, для начала выясним его. Если контакты А1, А2 и А3 на плате адаптера не запаяны, то адрес будет скорее всего 0х27, но лучше проверить. Для этого напишем небольшую функцию, которая покажет адреса всех устройств, которые подключены к I2C шине:
Данная функция опрашивает все адреса от 0 до 127 и если с этого адреса поступил ответ, она отправляет номер этого адреса в 16-тиричной форме в UART.
Для общения с платой я использую программу Termite. По умолчанию скорость UART у микроконтроллера устанавливается в значении 115200, необходимо установить такую же в термите. Вызываем функцию в основном теле программы, прошиваем плату и коннектимся в термите к нашему микроконтроллеру:

Точками отображаются все адреса, с которых ответ не был получен. Адрес у моего дисплея 0х26, так как я запаял перемычку А0. Теперь подключим второй дисплей параллельно первому, и посмотрим, что выдаст программа:

Имеем два адреса: 0х26 (дисплей 1602) и 0х27 (дисплей 2004). Теперь о том, как работать с дисплеем. Микроконтроллер посылает байт адреса, а все устройства, подключенные к шине, сверяют его со своим. Если он совпадает, то модуль начинает общение с микроконтроллером. В первую очередь нужно настроить дисплей: откуда будет идти отсчет символов и в какую сторону, как будет вести себя курсор и т.п. После этого уже можно будет передавать дисплею информацию для вывода. Особенность в том, что мы можем использовать только 4 бита для передачи информации, т.е. данные необходимо разбивать на две части. Данные хранятся в старших битах (4-7), а младшие биты используются для указания того, будет ли включена подсветка (3 бит), приходят ли данные для вывода или же настройки работы дисплея (вывод RS, 0 бит), и 2 бит, по изменению ��оторого происходит считывание, т.е чтобы отправить 1 байт данных необходимо отправить 4 байта – 1й байт будет содержать 4 бита информации, 2й бит в состояние 1, 2й байт это повторение 1-го, только уже 2й бит в состояние 0. 3й и 4й байт аналогично, только там содержится вторая половина данных. Звучит немного непонятно, покажу на примере:
Разберем все по порядку. В начале идут переменные, хранящие в себе адрес дисплея, и биты настроек, которые необходимо отправлять каждый раз вместе с данными. В функции отправки мы в первую очередь проверяем, есть ли по записанному адресу модуль. В случае получения сообщения HAL_OK начинаем формировать байты для отправки. В начале байт, который мы будем отправлять, необходимо разделить на две части, оба из них записать в старшие биты. Допустим, мы хотим, чтобы дисплей отобразил символ ‘s’, в двоичной системе это 1110011 (калькулятор). С помощью логической операции & мы записываем в переменную up = 01110000, т.е. записываем только старшие биты. Младшие биты в начале сдвигаются влево на 4 символа, а потом записываются в переменную lo = 00110000. Дальше мы формируем массив из 4 байт, которые содержат информацию о символе, который необходимо вывести. Теперь к существующим байтам приписываем биты конфигурации (0-3 биты). После этого отправляем байт адреса и 4 байта информации на дисплей с помощью функции HAL_I2C_Master_Transmit();
Но не спешите загружать программу, ведь в начале необходимо задать настройки дисплею. На сайте есть прекрасная переведенная таблица с командами для настройки дисплея. Сверив ее с документацией, я пришел к следующим оптимальным для себя настройкам:
Эти команды поместим перед началом бесконечного цикла, чтобы настройки отправлялись единожды перед началом работы (как void setup у ардуинки). Функция I2C_send помимо байта требует указать, будут отправляться настройки дисплея или же данные. Если второй аргумент функции 0, то настройки, а если 1, то данные.
И последний штрих – нужна функция, которая будет отправлять сроку посимвольно. Тут все довольно просто:
Собрав все эти функции воедино можно написать:

Отлично, с дисплеем 1602 разобрались, теперь 2004. Разница между ними минимальная, даже этот код будет отлично работать. Все отличие сводится к организации адресов ячеек на дисплее. В обоих дисплеях память содержит 80 ячеек, в дисплее 1602 первые 16 ячеек отвечают за первую строчку, а за вторую строчку отвечают ячейки с 40 по 56. Остальные ячейки памяти на дисплей не выводятся, поэтому, если отправить на дисплей 17 символов, последний не перенесется на вторую строчку, а будет записан в ячейку памяти, не имеющую выхода на дисплей. Чуть более наглядно, память устроена так:

Для перевода строки я пользовался командой I2C_send(0b11000000,0);, она просто переходит к 40 ячейке. В дисплее 2004 все поинтереснее.
Первая строка — ячейки с 1 по 20
Вторая строка — ячейки с 40 по 60
Третья строка — ячейки с 21 по 40
Четвертая строка — ячейки с 60 по 80,
т.е. если отправить команду
Получим следующее:

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

На этом пожалуй все с этими дисплеями, полезные ссылки, благодаря которым я смог во всем этом разобраться:
Программа и datasheet
P.S.: не забывайте настроить яркость дисплея заранее.
В этой статье я хотел бы рассказать о своем опыте подключения LCD дисплеев к микроконтроллеру STM32 с использованием библиотеки HAL по I2C шине.

Подключать буду дисплей 1602 и 2004. Они оба имеют припаянный I2C адаптер на основе чипа PCF8574T. Отладочной платой выступит Nucleo767ZI, а средой разработки – STM32CubeIDE 1.3.0.
Про принцип работы I2C шины подробно рассказывать не буду, советую заглянуть сюда и сюда.
Создаем проект, выбираем отладочную плату:

Указываем, что будем использовать I2C1. Также я подключу UART5 для общения с платой, это нужно для получения информации от платы об адресе дисплея.


В этом же окне можно посмотреть номера ножек, к которым подключается дисплей, в моем случае получилось так:

Для начала подключим всего один дисплей, я начну с 1602. Также я подключу известный бывалым ардуинщикам адаптер USB-UART CH340 для получения данных с платы.

Обратите внимание, адаптер подключается RX к TX и TX к RX, перемычка на адаптере стоит на 3.3В

Рассмотрим подробнее работу с микросхемой PCF8574T и дисплеем. Ниже приведена принципиальная схема модуля с дисплеем:

Микросхема PCF8574T по функционалу схожа с регистром сдвига 74hc595 – она получает по I2C интерфейсу байт и присваивает своим выводам (P0-P7) значения соответствующего бита.
Рассмотрим какие выводы микросхемы соединены с дисплеем и за что отвечают:
- Вывод Р0 микросхемы соединен с выводом RS дисплея, отвечающего за то, принимает дисплей данные (1) или инструкции по работе дисплея (0);
- Вывод Р1 соединен с R\W, если 1 – запись данных в дисплей, 0 – считывание;
- Вывод Р2 соединен с CS – вывод, по изменению состояния которого идет считывание;
- Вывод Р3 – управление подсветкой;
- Выводы Р4 — Р7 служат для передачи данных дисплею.
К одной I2C шине может быть подключено несколько устройств одновременно. Для того, чтобы можно было обращаться к конкретному устройству, каждое из них имеет свой адрес, для начала выясним его. Если контакты А1, А2 и А3 на плате адаптера не запаяны, то адрес будет скорее всего 0х27, но лучше проверить. Для этого напишем небольшую функцию, которая покажет адреса всех устройств, которые подключены к I2C шине:
void I2C_Scan () { // создание переменной, содержащей статус HAL_StatusTypeDef res; // сообщение о начале процедуры char info[] = "Scanning I2C bus...\r\n"; // отправка сообщения по UART HAL_UART_Transmit(&huart5, (uint8_t*)info, strlen(info), HAL_MAX_DELAY); /* &huart5 - адрес используемого UART * (uint8_t*)info - указатель на значение для отправки * strlen(info) - длина отправляемого сообщения * HAL_MAX_DELAY - задержка */ // перебор всех возможных адресов for(uint16_t i = 0; i < 128; i++) { // проверяем, готово ли устройство по адресу i для связи res = HAL_I2C_IsDeviceReady(&hi2c1, i << 1, 1, HAL_MAX_DELAY); // если да, то if(res == HAL_OK) { char msg[64]; // запись адреса i, на который откликнулись, в строку в виде // 16тиричного значения: snprintf(msg, sizeof(msg), "0x%02X", i); // отправка номера откликнувшегося адреса HAL_UART_Transmit(&huart5, (uint8_t*)msg, strlen(msg), HAL_MAX_DELAY); // переход на новую строчку HAL_UART_Transmit(&huart5, (uint8_t*)"\r\n", 2, HAL_MAX_DELAY); } else HAL_UART_Transmit(&huart5, (uint8_t*)".", 1, HAL_MAX_DELAY); } HAL_UART_Transmit(&huart5, (uint8_t*)"\r\n", 2, HAL_MAX_DELAY); }
Данная функция опрашивает все адреса от 0 до 127 и если с этого адреса поступил ответ, она отправляет номер этого адреса в 16-тиричной форме в UART.
Для общения с платой я использую программу Termite. По умолчанию скорость UART у микроконтроллера устанавливается в значении 115200, необходимо установить такую же в термите. Вызываем функцию в основном теле программы, прошиваем плату и коннектимся в термите к нашему микроконтроллеру:

Точками отображаются все адреса, с которых ответ не был получен. Адрес у моего дисплея 0х26, так как я запаял перемычку А0. Теперь подключим второй дисплей параллельно первому, и посмотрим, что выдаст программа:

Имеем два адреса: 0х26 (дисплей 1602) и 0х27 (дисплей 2004). Теперь о том, как работать с дисплеем. Микроконтроллер посылает байт адреса, а все устройства, подключенные к шине, сверяют его со своим. Если он совпадает, то модуль начинает общение с микроконтроллером. В первую очередь нужно настроить дисплей: откуда будет идти отсчет символов и в какую сторону, как будет вести себя курсор и т.п. После этого уже можно будет передавать дисплею информацию для вывода. Особенность в том, что мы можем использовать только 4 бита для передачи информации, т.е. данные необходимо разбивать на две части. Данные хранятся в старших битах (4-7), а младшие биты используются для указания того, будет ли включена подсветка (3 бит), приходят ли данные для вывода или же настройки работы дисплея (вывод RS, 0 бит), и 2 бит, по изменению ��оторого происходит считывание, т.е чтобы отправить 1 байт данных необходимо отправить 4 байта – 1й байт будет содержать 4 бита информации, 2й бит в состояние 1, 2й байт это повторение 1-го, только уже 2й бит в состояние 0. 3й и 4й байт аналогично, только там содержится вторая половина данных. Звучит немного непонятно, покажу на примере:
void I2C_send(uint8_t data, uint8_t flags) { HAL_StatusTypeDef res; // бесконечный цикл for(;;) { // проверяем, готово ли устройство по адресу lcd_addr для связи res = HAL_I2C_IsDeviceReady(&hi2c1, LCD_ADDR, 1, HAL_MAX_DELAY); // если да, то выходим из бесконечного цикла if(res == HAL_OK) break; } // операция И с 1111 0000 приводит к обнулению бит с 0 по 3, остаются биты с 4 по 7 uint8_t up = data & 0xF0; // то же самое, но data сдвигается на 4 бита влево uint8_t lo = (data << 4) & 0xF0; uint8_t data_arr[4]; // 4-7 биты содержат информацию, биты 0-3 настраивают работу дисплея data_arr[0] = up|flags|BACKLIGHT|PIN_EN; // дублирование сигнала, на выводе Е в этот раз 0 data_arr[1] = up|flags|BACKLIGHT; data_arr[2] = lo|flags|BACKLIGHT|PIN_EN; data_arr[3] = lo|flags|BACKLIGHT; HAL_I2C_Master_Transmit(&hi2c1, LCD_ADDR, data_arr, sizeof(data_arr), HAL_MAX_DELAY); HAL_Delay(LCD_DELAY_MS); }
Разберем все по порядку. В начале идут переменные, хранящие в себе адрес дисплея, и биты настроек, которые необходимо отправлять каждый раз вместе с данными. В функции отправки мы в первую очередь проверяем, есть ли по записанному адресу модуль. В случае получения сообщения HAL_OK начинаем формировать байты для отправки. В начале байт, который мы будем отправлять, необходимо разделить на две части, оба из них записать в старшие биты. Допустим, мы хотим, чтобы дисплей отобразил символ ‘s’, в двоичной системе это 1110011 (калькулятор). С помощью логической операции & мы записываем в переменную up = 01110000, т.е. записываем только старшие биты. Младшие биты в начале сдвигаются влево на 4 символа, а потом записываются в переменную lo = 00110000. Дальше мы формируем массив из 4 байт, которые содержат информацию о символе, который необходимо вывести. Теперь к существующим байтам приписываем биты конфигурации (0-3 биты). После этого отправляем байт адреса и 4 байта информации на дисплей с помощью функции HAL_I2C_Master_Transmit();
Но не спешите загружать программу, ведь в начале необходимо задать настройки дисплею. На сайте есть прекрасная переведенная таблица с командами для настройки дисплея. Сверив ее с документацией, я пришел к следующим оптимальным для себя настройкам:
I2C_send(0b00110000,0); // 8ми битный интерфейс I2C_send(0b00000010,0); // установка курсора в начале строки I2C_send(0b00001100,0); // нормальный режим работы, выкл курсор I2C_send(0b00000001,0); // очистка дисплея
Эти команды поместим перед началом бесконечного цикла, чтобы настройки отправлялись единожды перед началом работы (как void setup у ардуинки). Функция I2C_send помимо байта требует указать, будут отправляться настройки дисплея или же данные. Если второй аргумент функции 0, то настройки, а если 1, то данные.
И последний штрих – нужна функция, которая будет отправлять сроку посимвольно. Тут все довольно просто:
void LCD_SendString(char *str) { // *char по сути является строкой // пока строчка не закончится while(*str) { // передача первого символа строки I2C_send((uint8_t)(*str), 1); // сдвиг строки налево на 1 символ str++; } }
Собрав все эти функции воедино можно написать:
LCD_SendString(" Hello"); I2C_send(0b11000000,0); // перевод строки LCD_SendString(" Habr");

Отлично, с дисплеем 1602 разобрались, теперь 2004. Разница между ними минимальная, даже этот код будет отлично работать. Все отличие сводится к организации адресов ячеек на дисплее. В обоих дисплеях память содержит 80 ячеек, в дисплее 1602 первые 16 ячеек отвечают за первую строчку, а за вторую строчку отвечают ячейки с 40 по 56. Остальные ячейки памяти на дисплей не выводятся, поэтому, если отправить на дисплей 17 символов, последний не перенесется на вторую строчку, а будет записан в ячейку памяти, не имеющую выхода на дисплей. Чуть более наглядно, память устроена так:

Для перевода строки я пользовался командой I2C_send(0b11000000,0);, она просто переходит к 40 ячейке. В дисплее 2004 все поинтереснее.
Первая строка — ячейки с 1 по 20
Вторая строка — ячейки с 40 по 60
Третья строка — ячейки с 21 по 40
Четвертая строка — ячейки с 60 по 80,
т.е. если отправить команду
LCD_SendString("___________________1___________________2___________________3___________________4");
Получим следующее:

Для организации переходов между строками необходимо переводить на нужную ячейку памяти курсор вручную, либо можно программно дополнить функцию. Я пока остановился на ручном варианте:
I2C_send(0b10000000,0); // переход на 1 строку LCD_SendString(" Hello Habr"); I2C_send(0b11000000,0); // переход на 2 строку LCD_SendString(" STM32 + LCD 1602"); I2C_send(0b10010100,0); // переход на 3 строку LCD_SendString(" +LCD 2004A"); I2C_send(0b11010100,0); // переход на 4 строку LCD_SendString(" library HAL");
Результат:

На этом пожалуй все с этими дисплеями, полезные ссылки, благодаря которым я смог во всем этом разобраться:
- Код во многом посмотрел вот тут
- Таблицы для конфигурации дисплея смотрел тут
- Порядок действий смотрел тут
Программа и datasheet
P.S.: не забывайте настроить яркость дисплея заранее.
