Дружим STM32 с LCD дисплеем 1604 по I2C шине (библиотека HAL)

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

image

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

image

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

image

image

В этом же окне можно посмотреть номера ножек, к которым подключается дисплей, в моем случае получилось так:
image
Для начала подключим всего один дисплей, я начну с 1602. Также я подключу известный бывалым ардуинщикам адаптер USB-UART CH340 для получения данных с платы.
image
Обратите внимание, адаптер подключается RX к TX и TX к RX, перемычка на адаптере стоит на 3.3В
image

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

Микросхема 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, необходимо установить такую же в термите. Вызываем функцию в основном теле программы, прошиваем плату и коннектимся в термите к нашему микроконтроллеру:
image
Точками отображаются все адреса, с которых ответ не был получен. Адрес у моего дисплея 0×26, так как я запаял перемычку А0. Теперь подключим второй дисплей параллельно первому, и посмотрим, что выдаст программа:
image
Имеем два адреса: 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");

image

Отлично, с дисплеем 1602 разобрались, теперь 2004. Разница между ними минимальная, даже этот код будет отлично работать. Все отличие сводится к организации адресов ячеек на дисплее. В обоих дисплеях память содержит 80 ячеек, в дисплее 1602 первые 16 ячеек отвечают за первую строчку, а за вторую строчку отвечают ячейки с 40 по 56. Остальные ячейки памяти на дисплей не выводятся, поэтому, если отправить на дисплей 17 символов, последний не перенесется на вторую строчку, а будет записан в ячейку памяти, не имеющую выхода на дисплей. Чуть более наглядно, память устроена так:
image
Для перевода строки я пользовался командой I2C_send (0b11000000,0);, она просто переходит к 40 ячейке. В дисплее 2004 все поинтереснее.
Первая строка — ячейки с 1 по 20
Вторая строка — ячейки с 40 по 60
Третья строка — ячейки с 21 по 40
Четвертая строка — ячейки с 60 по 80,
т.е. если отправить команду

LCD_SendString("___________________1___________________2___________________3___________________4");


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

  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");


Результат:
image
На этом пожалуй все с этими дисплеями, полезные ссылки, благодаря которым я смог во всем этом разобраться:
1) Код во многом посмотрел вот тут
2) Таблицы для конфигурации дисплея смотрел тут
3) Порядок действий смотрел тут

Программа и datasheet
P.S. не забывайте настроить яркость дисплея заранее.

© Habrahabr.ru