Работа с ЖК-дисплеем 128х64 через низкоуровневое программирование

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

Список компонентов

ЖК-матрица

Матрица выглядит следующим образом: Легко увидеть, что для подключения выведено аж 20 контактов. Чтобы разобраться с тем, что куда подключается, необходимо обратиться к официальному сайту за документацией. Из нее можно получить таблицу пинов:

Вывод Обозначение Назначение
1 Ucc Питание цифровой части модуля
2 GND Общий вывод (0 В, земля)
3 Uo Вход питания ЖК-панели
4-11 DB0—DB7 Шина данных
12 E1 Выбор кристалла 1
13 E2 Выбор кристалла 2
14 RES Сброс (начальная установка)
15 R/W Выбор: Чтение/Запись
16 A0 Выбор: Команда / Данные
17 E Стробирование данных
18 Uee Выход DC-DC преобразователя
19 A + питания подсветки
20 K - питания подсветки

Некоторые особенности распиновки

  1. Цифровая часть, ЖК-панель и подсветка запитываются отдельно друг от друга.
  2. Вход питания ЖК-панели (3) необходимо соединить с выходом DC-DC преобразователя (18) через потенциометр для возможности настройки контраста. Либо, если потенциометра под рукой нет, то через резистор ~4.7 КОм.
  3. Внутри ЖК-панели находятся два контроллера KS-0108. Поскольку размер матрицы 128х64, то каждый контроллер отвечает за свою половину экрана и активизируется соответствующим пином E1 и E2. Необходимо учитывать, что запись или чтение сразу в/из обоих контроллеров не допускается!

Память

Подробная картинка, содержащая в себе распределение ОЗУ находится на странице 4 даташита Модуль содержит ОЗУ для хранения данных, выводимых на ЖКИ, размером 64х64х2 бит (по 64х64 бит на каждый кристалл). Для выбора нужного кристалла используются выводы E1,E2. ОЗУ разбито на 8 страниц размером по 64х8 бит каждая. Каждой светящейся точке на ЖКИ соответствует логическая «1» в ячейке ОЗУ модуля.

Команды

Контроллер дисплея понимает 7 команд:

  1. Включение / выключение дисплея вне зависимости от данных в ОЗУ и внутреннего состояния
  2. Установка номера строки ОЗУ, которая будет отображаться в верхней строке дисплея
  3. Установка номера страницы ОЗУ
  4. Установка адреса ОЗУ для последующих обращений
  5. Чтение статуса состояния
  6. Запись данных
  7. Чтение данных

Программирование

Все низкоуровневое взаимодействие с контроллерами будет содержаться в трех функциях

  • void waitForLcdReady(byte state)
  • byte readByte(byte state)
  • void writeByte(short int data, byte state)

В каждая функция принимает аргумент state. Он содержит в себе информацию о том, с каким чипом необходимо работать и будут это команды или данные. Делается это за счет трех констант #define CHIP1 0x01, #define CHIP2 0x02, #define DATA 0x80. К примеру, нам необходимо записать байт в дисплей, тогда мы вызываем функцию: writeByte(data, CHIP1 + DATA);.

Теперь перейдем к реализации функций:

void LcdKs0108::waitForLcdReady(byte state)
{
	//Линия RW = 1 - мы читаем с индикатора
	digitalWrite(RW, 1); 
 
	//Читаем команду
	digitalWrite(CD, 0);
 
	//Включаем необходимый чип
	if (state && CHIP1) {
		digitalWrite(E1, 1);
	} else {
		digitalWrite(E2, 1);
	}
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Выставляем строб
	digitalWrite(E, 1);
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Седьмой пин данных делаем входом
	pinMode(dataPins[7], INPUT);
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Дожидаемся сброса сигнала занятости
	while (digitalRead(dataPins[7])) {
		;
	}
 
	//Сбрасываем строб
	digitalWrite(E, 0);
 
	//Вновь пин - на выход
	pinMode(dataPins[7], OUTPUT);
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Сбрасываем сигналы управления чипами
	digitalWrite(E1, 0);
	digitalWrite(E2, 0);
}
byte LcdKs0108::readByte(byte state)
{
	//Все пины - вход
	for (int i = 0; i < 8; ++i ) {
		pinMode(dataPins[i], INPUT);
	}
 
	//Ждем готовности
	waitForLcdReady(state);
 
	//Будем считывать
	digitalWrite(RW, 1);
	digitalWrite(CD, 1);
 
	//Выбираем чип
	(state & CHIP1)?digitalWrite(E1, 1):digitalWrite(E2, 1);
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Устанавливаем строб
	digitalWrite(E, 1);
 
	//Ждем
	delayMicroseconds(1);
 
	byte readedByte = 0;
 
	//Считываем данные с шины
	for (int i = 0; i < 8; ++i) {
		readedByte |= (digitalRead(dataPins[i]) << i);
	}
 
	//Убираем строб
	digitalWrite(E, 0);
 
	//Ждем
	delayMicroseconds(1);
 
	//Восстанавливаем направление шины
	for (int i = 0; i < 8; ++i ) {
		pinMode(dataPins[i], OUTPUT);
	}
	return readedByte;
}
void LcdKs0108::writeByte(short int data, byte state)
{
 
	//Ждем готовности
	waitForLcdReady(state);
 
	//На RW=0 - мы будем писАть в индикатор
	digitalWrite(RW, 0);
 
	//Команду или данный
	digitalWrite(CD, state & DATA);
 
	//Выбираем необходимый чип
	(state & CHIP1)?digitalWrite(E1, 1):digitalWrite(E2, 1);
 
	//Выставляем данные на шину
	for (int i = 0; i < 8; ++i ) {
		digitalWrite(dataPins[i], (data & (1 << i)));
	}
 
	//Необходимая задержка
	delayMicroseconds(1);
 
	//Устанавливаем строб
	digitalWrite(E, 1);
 
	//Ждем
	delayMicroseconds(1);
 
	//Сбрасываем строб
	digitalWrite(E, 0);
 
	//Снова ждем
	delayMicroseconds(1);
 
	//Сбрасываем сигналы управления чипами
	digitalWrite(E1, 0);
	digitalWrite(E2, 0);
}

Вполне резонный вопрос:"А что это за массив такой dataPins?" Данные участки кода — функции библиотеки, написанной специально для этого дисплея. Библиотеку я делал с учетом того, чтобы можно было использовать любые пины Arduino, необязательно расположенные последовательно. Заполнение этого массива происходит в функции Init.

void LcdKs0108::Init(int _DB0, int _DB1, int _DB2, int _DB3,
				int _DB4, int _DB5, int _DB6, int _DB7,
				int _E1, int _E2, int _RES, int _RW,
				int _CD, int _E)
{
 
	Serial.begin(115200);
	//Serial.println("init");
 
	//Заполняем массив пинами с линиями данных
	dataPins[0] = _DB0;
	dataPins[1] = _DB1;
	dataPins[2] = _DB2;
	dataPins[3] = _DB3;
	dataPins[4] = _DB4;
	dataPins[5] = _DB5;
	dataPins[6] = _DB6;
	dataPins[7] = _DB7;
 
	//Все пины - выход
	for (int i = 0; i < 8; ++i ) {
		//Serial.println(dataPins[i], DEC);
		pinMode(dataPins[i], OUTPUT);
	}
	pinMode(_CD, OUTPUT); CD = _CD;
	pinMode(_E, OUTPUT); E = _E;
 
	pinMode(_E1, OUTPUT); E1 = _E1;
	pinMode(_E2, OUTPUT); E2 = _E2;
	pinMode(_RES, OUTPUT); RES = _RES;
	pinMode(_RW, OUTPUT); RW = _RW;
 
	//Сбрасываем модуль
	digitalWrite(E, 0);
	digitalWrite(RES, 0);
	delay(2);
	digitalWrite(RES, 1);
	delay(2);
 
	//Верхняя строчка отображения - 0
	writeByte(0xC0, CHIP1);
	writeByte(0xC0, CHIP2);
 
	//Включить контроллер!
	writeByte(0x3F, CHIP1);
	writeByte(0x3F, CHIP2);
}

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

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

Принцип работы библиотеки

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

void putPixel(short int x, short int y, bool bit) 
{
	byte inByte = 0;
	byte outByte = 0;
	short int offset = 0;
	byte state = 0;
	if ((x > 127) || (y > 63)) {
		return;
	}
 
	if (x < 64) {
		state += CHIP1;
	} else {
		state += CHIP2;
		offset = 64;
	}
 
	writeByte(0xB8 | (y >> 3), state);
	writeByte(0x40 | (x - offset), state);
	inByte = readByte(state);
	inByte = readByte(state);
 
	outByte = 1 << (y & 0x07);
 
	if (bit == 1) {
		outByte = inByte | outByte;
	} else {
		outByte = inByte & (~outByte);
	}
	writeByte(0x40 | (x - offset), state);
	writeByte(outByte, state + DATA);
}

Легко видеть, что для зажигания одного пиксела необходимо выполнить 4 операции записи и 2 операции чтения. А это время. И отрисовка линии будет занимать его приличное количество.

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

Для этого в библиотеке имеется массив byte lcd_picture[8][128];, в котором находится содержимое экрана. Все манипуляции с экраном производятся как раз с этм массивом. И код для зажигания пиксела становится вот таким:

void LcdKs0108::putPixel(short int x, short int y, bool bit)
{
	if (bit) {
		lcd_picture[y / 8][x] |= 1 << (y % 8);
	} else {
		lcd_picture[y / 8][x] &= 0 << (y % 8);
	}
}

Массив lcd_picture выводится целиком и использует такую возможность контроллера дисплея, как автоинкремент адреса, что позволяет писать данные в страницу непрерывно, без явного указания адреса.

Пример работы

#include "lcd_ks0108.h"
 
LcdKs0108 lcd;
 
void setup()
{
        //Инициализация дисплея
        lcd.Init(2, 3, 4, 5, 6, 7, 8, 9, 10, A2, A3, A4, A0, A1);
 
        //Очистка
        lcd.clearScreen();
 
        //Добавляем линию
        lcd.addLine(0, 0, 127, 63);
        lcd.addLine(0, 63, 127, 0);
 
        //Добавляем окружность
        lcd.addCircle(63, 32, 30);
 
        //Выводим на экран
        lcd.draw();
}
void loop()
{
}

Результат:

Потенциометр

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

//Подключаем библиотек
#include "lcd_ks0108.h"
LcdKs0108 lcd;
 
//Используемый аналоговый вход
#define ANALOG_PIN	A5
 
//Массив, в котором содержится история значени с АЦП
short analogValues[127];
 
//Функция добавляет новое измерение в конец массива 
void appendValue(int value)
{
	for (int i = 1; i < 127; ++i) {
		analogValues[i-1] = analogValues[i];
	}
	analogValues[126] = value;
}
 
void setup() 
{
	//Инициализируем библиотеку
	lcd.Init(2, 3, 4, 5, 6, 7, 8, 9, 10, A2, A3, A4, A0, A1);
 
	//Обнуляем массив
	for (int i = 0; i < 127; ++i) {
		analogValues[i] = 0;
	}
 
	//Делаем пин входом
	pinMode(ANALOG_PIN, INPUT);
}
 
void loop()
{
	//Перед каждым обновлением очищаем массив
	lcd.clearScreen();
 
	//Добавляем новое считанное значение
	appendValue(analogRead(ANALOG_PIN));
 
	//Добавляем график-гистограмму. Не забыв, что диапазон значений
	//АЦП - 0..1024, а размер дисплея по оси Y - 0..63.
	//11 строк оставляем под строку со значением
	for (int i = 0; i < 127; ++i) {
		lcd.addLine(i, 54, i, 54 - int(float(analogValues[i]) / 1024.0 * 54));
	}
 
	//Добаляем посдледнее значение на экран
	lcd.addNumber(analogValues[126], 0, 7);
 
	//Рисуем
	lcd.draw();
}

Fin

Мы получили вот такой простой "осциллограф".

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