После LCD-часов из прошлого урока я собрал второй прибор — на OLED 0.96, и с ходу напоролся на то, с чего надо было начинать: шрифт по умолчанию высотой 8 пикселов. С полуметра от экрана — читается, с трёх метров от дивана — серая полоска, в которой угадываются только двоеточия. Крупный шрифт включается одним вызовом setTextSize(2), но вокруг него куча мелочей, каждая из которых способна съесть вечер: символы растут вдвое, в строке помещается вдвое меньше знаков, setCursor после LCD-привычки ставит курсор мимо, а видеобуфер внезапно оказывается половиной всей оперативной памяти Arduino.
План такой: посчитаем геометрию пиксельного экрана, соберём крупный циферблат с мигающим двоеточием, оценим цену одного кадра по шине I2C и проверим, что 1024 байта буфера — честная цена за красивый экран. Экран я беру классический SSD1306 128x64 на адресе 0x3C — схема и первый текст на нём были в уроке про первый запуск OLED, базовые print и setCursor — в уроке про текст и курсор. Если ваш модуль вместо 0x3C отвечает на 0x3D — это тот же чип с другим выбором адреса, скетч ниже меняется одним аргументом.
Геометрия: пиксели вместо клеток
Библиотека Adafruit_GFX рисует текст из матрицы 5×7 пикселов плюс колонка-зазор — итого 6×8 на символ. На 128 пикселов по горизонтали помещается 21 знак (128 / 6 = 21,3), по вертикали — 8 строк (64 / 8). Вызов setTextSize(2) масштабирует всё вдвое: символ 12×16, в строке 10 знаков, строк всего 4. Это не таблица ради таблицы — под каждый элемент циферблата сначала считаются пиксели, и только потом ставится курсор: строка, которая влезала с запасом, после удвоения шрифта упирается в край и обрезается молча.
Главная ловушка переезда с LCD: setCursor у OLED принимает пиксельные координаты (x, y), а не номера клеток. lcd.setCursor(4, 0) из прошлого урока и oled.setCursor(4, 0) — это разные места экрана: четыре пиксела слева — это почти самый край, а не четвёртая клетка. Правило, к которому я пришёл: для OLED каждая координата считается в пикселях от размеров шрифта, и только потом пишется в код. Мелкий шрифт при этом остаётся незаменимым для подписей — 21 знак в строке против 10 у крупного.
Если у вас модуль 128x32 — половина высоты — арифметика остаётся той же, но строк крупного шрифта всего две: циферблат занимает весь экран, и дата с температурой едут на одну строку мелким шрифтом. Такой модуль дешевле и тоньше, но привычка считать пиксели до печати становится не дисциплиной, а необходимостью: одна лишняя строка — и показания наезжают друг на друга. Я держал оба модуля рядом месяц и в итоге оставил 128x64: запас по вертикали окупается каждый раз, когда захочется добавить третью зону — статус будильника, погоду, счётчик.
Арифметика циферблата. Строка времени HH:MM:SS — восемь символов; при setTextSize(2) каждый шириной 12 пикселов, вся строка — 96. Центр: (128 − 96) / 2 = 16 — ровно столько отступаем слева, oled.setCursor(16, 20). Вертикаль: символ крупного шрифта высотой 16 пикселов, ставлю y = 20 — от верхнего края остаётся полоса в 20 пикселов под шапку, снизу — 28 под дату и температуру мелким шрифтом (8 пикселов на строку плюс запас). Все три зоны считаются до первой строчки кода — это дешевле, чем двигать их по экрану на живом приборе.
Двоеточие мигает по той же схеме, что и на LCD: секунда нечётная — вместо «:» подставляю пробел. Только реализация другая: у OLED строку времени я собираю целиком в String — z2(now.hour()) + разделитель + z2(now.minute()) + разделитель + z2(now.second()) — и печатаю готовую одним вызовом print. Один print на строку — не только аккуратность: каждая команда дисплею — это пакет по шине I2C, а чем меньше пакетов, тем короче паузы в loop. Функция z2 при этом возвращает String с ведущим нулём — 5 превращается в 05, и ширина строки не прыгает.
Две зоны на одной строке мелкого шрифта — рабочий приём: дату я прижал к левому краю с x = 0, температуру поставил в правый нижний угол setCursor(96, 56) — это шестнадцатая колонка мелкого шрифта (96 / 6). Лишь бы зоны не пересекались: наезд двух print на одни пиксели выглядит как каша, и лечится он только пересчётом координат, а не перезагрузкой. Мой расчёт на салфетке: дата из десяти знаков занимает колонки 0–9, температура из четырёх — 16–19, между ними пять пустых колонок буфера — запас есть, а значит, зона поместится и с минусовой температурой.
#include <Wire.h>#include <RTClib.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1);RTC_DS3231 rtc; String z2(int v) { // 5 -> "05" if (v < 10) return "0" + String(v); return String(v);} void setup() { if (rtc.begin() == false) { while (1) { } // без часов экран не нужен } display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE);} void loop() { DateTime now = rtc.now(); String sep = ":"; if (now.second() % 2 == 1) sep = " "; // двоеточие мигает String clock = z2(now.hour()) + sep + z2(now.minute()) + sep + z2(now.second()); display.clearDisplay(); display.setTextSize(2); display.setCursor(16, 20); // (128-96)/2 = 16 display.print(clock); display.setTextSize(1); display.setCursor(0, 56); display.print(z2(now.day()) + "." + z2(now.month()) + "." + String(now.year())); display.setCursor(96, 56); display.print(rtc.getTemperature(), 1); display.display(); delay(500);}Цена кадра: буфер и display()
У SSD1306 нет знакогенератора: библиотека рисует всё в буфер ОЗУ размером 128 × 64 / 8 = 1024 байта — половина всей SRAM классической Uno (2048 байта). До вызова display() ни один пиксел не движется: clearDisplay, setCursor и print меняют только память, и лишь display() выгружает весь буфер на матрицу по I2C. Механика этого разделения разобрана в уроке про буфер и clearDisplay — здесь важна цена: 1024 байта при штатных 100 кГц шины — это порядка 90 мс тишины в loop, а после wire.setClock(400000) — около 25 мс.
Отсюда практический вывод: гонять кадры чаще десяти раз в секунду бессмысленно — шина не успеет. Мой ритм для часов: полный кадр раз в 500 мс, ровно под мигание двоеточия. Если проект вмещает ещё и датчики, кадр — удобный тактовый генератор: обновление экрана раз в полсекунды совмещается с опросом датчика в том же проходе, и всё живёт на millis-таймерах без единого delay() между кадрами. Сравнение по цене и скорости с клеточным LCD — в таблице OLED против LCD: там же цифры по потреблению, где OLED заметно прожорливее.
И про кириллицу, чтобы вы не наступили на мои грабли: в стандартной матрице 5×7 Adafruit_GFX кириллицы нет — латиница и цифры, всё. Надписи латиницей на циферблате — не лень, а свойство шрифта: русские слова потребуют u8g2 со своими шрифтами или самодельной матрицы. Для часов это не потери: HH:MM:SS и DD.MM интернациональны, а температуру я подписываю буквой C — она в шрифте есть. Проверьте заранее любой текст, который хотите вывести: если в нём русские буквы, план раскладки меняется до пайки.
| setTextSize | Символ, px | Знаков в строке | Строк на 128x64 | Годится для |
|---|---|---|---|---|
| 1 | 6×8 | 21 | 8 | дата, подписи, мелкий статус |
| 2 | 12×16 | 10 | 4 | циферблат HH:MM:SS — 8 знаков из 10 |
| 3 | 18×24 | 7 | 2 | часы и минуты без секунд: 5 знаков из 7 |
| 4 | 24×32 | 5 | 2 | одна цифра — огромный счётчик секунд |
- Шрифт три. Поставьте setTextSize(3): строка HH:MM:SS не влезает — 8 × 18 = 144 пиксела против 128. Разбейте показание: часы и минуты крупно, секунды мелким шрифтом отдельной зоной.
- Секунды отдельно. Выведите секунды size 1 в правый нижний угол: setCursor(96, 48) — и посчитайте в клетках, сколько места осталось до наезда на строку даты.
- Дата крупно. Перенесите DD.MM в size 2 на верхнюю строку: десять знаков занимают все десять колонок крупного шрифта — впритык, без права на один лишний пробел.
- Ночной ритм. По часу: между 23:00 и 6:00 обновляйте кадр раз в секунду вместо двух раз — двоеточие продолжает мигать, а паузы на I2C сокращаются вдвое.
- Шрифт Adafruit_GFX — матрица 5×7 плюс зазор: 6×8 пикселов; setTextSize(2) удваивает всё: 12×16, 10 знаков в строке, 4 строки
- setCursor у OLED принимает пиксели, а не клетки: строка HH:MM:SS шириной 96 px центрируется с x = 16
- Видеобуфер 128×64/8 = 1024 байта — половина SRAM Uno; display() выгружает его целиком: около 25 мс на 400 кГц и около 90 мс на 100 кГц
- Мигающее двоеточие — подмена символа пробелом на нечётной секунде при сборке String, печать одной командой print на строку
- Одна строка display() в конце loop — без неё буфер полон, а экран молчит
- 1. Сколько символов влезает в строку 128 пикселов при setTextSize(2)?
- 2. Что принимает setCursor у Adafruit_SSD1306?
- 3. Сколько оперативной памяти Uno занимает видеобуфер экрана 128x64?
- 4. Почему строку времени лучше печатать одним print, а не посимвольно?
- 5. Что делает display.display()?
Крупный циферблат собран: геометрия посчитана, двоеточие дышит, дата с температурой — снизу. Дальше — будильники: два независимых таймера DS3231 следят за расписанием сами и дёргают флаги без опроса времени в loop — на большой экран просится статусная строка ALARM. Ритм обновления кадра при этом логично перевести на millis-таймеры из урока про millis вместо delay — 500 мс между кадрами оставляют циклу кучу свободного времени. А когда будете выбирать экран для следующего прибора, сравнение OLED и LCD поможет честно: за пиксели платите памятью буфера и мА, за клетки — читаемостью на солнце и в ночи.
Частые вопросы по теме урока
- Сколько символов влезает в строку 128 пикселов при setTextSize(2)?
- Символ крупного шрифта шириной 12 пикселов: 128 / 12 = 10,6 — десять знаков. Строка времени из восьми символов влезает с запасом в две колонки. 10
- Что принимает setCursor у Adafruit_SSD1306?
- OLED — пиксельный экран без знакогенератора: курсор считается в пикселях. Привычка от LCD ставить (колонка, строка) уводит курсор мимо: четыре пиксела — это почти край экрана, а не четвёртая клетка из шести. Пиксельные координаты x и y
- Сколько оперативной памяти Uno занимает видеобуфер экрана 128x64?
- 128 × 64 / 8 = 1024 байта. На Uno с её 2048 байтами это половина памяти — крупный буфер оставляет мало места под переменные, стек и библиотеки шины. 1024 байта — половина всей SRAM
- Почему строку времени лучше печатать одним print, а не посимвольно?
- Каждый вызов print — отдельный пакет на шине. Собранная заранее строка идёт одной командой, и loop меньше простаивает на передаче. Меньше команд по I2C — короче паузы в loop
- Что делает display.display()?
- Всё до display() меняет только память. Выгрузка 1024 байтов на матрицу происходит именно в этом вызове — без него экран молчит. Выгружает буфер на матрицу по I2C