Свой первый долгоживущий прибор на OLED я угробил классически: часы на Uno работали, но стоило добавить второй шрифт и пару строк текста, как плата начала посменно сходить с ума — то текст превратится в кашу, то кнопки перестанут отзываться, то после сброса экран стоит чёрный. Диагноз поставил не я, а сосед по форуму, увидевший скетч: у меня в SRAM жил буфер дисплея на 1024 байта, о котором я вообще не думал, а сверху на него я навесил String-ы и библиотеку датчика. Половина оперативки Uno была занята ещё до первой моей переменной.
Арифметика безжалостна и проста. Экран 128×64 — это 8192 пикселя, каждый хранится одним битом, восемь пикселей в байте: 8192 делим на 8, получаем 1024 байта. У Uno и Nano всего 2048 байт SRAM — буфер забирает половину сразу после display.begin(). Столько же занимает и память самой панели: её GDDRAM — те же 8 страниц по 128 столбцов, где каждый байт кодирует 8 точек по вертикали. Библиотека Adafruit_SSD1306 держит собственную копию кадра в SRAM контроллера и работает только с ней — прямой записи в стекло у этой библиотеки нет в принципе.
Отсюда весь цикл работы, который я теперь проговариваю как мантру: clearDisplay — рисуй — display(). Стирание, рисование, показ. Важно, что на каком шаге что происходит: clearDisplay обнуляет ТОЛЬКО копию буфера в SRAM, шина I2C при этом молчит, и на стекле по-прежнему горит старый кадр. Рисование — print, примитивы, drawPixel — правит те же байты в памяти. И только display() вываливает все 1024 байта на панель одной посылкой. Порядок разобран до пикселей в базовом уроке про буфер, здесь же — цена каждого шага.
Копия кадра в SRAM — не расточительство библиотеки, а плата за комфорт и отсутствие мерцания. Пока вы рисуете, на стекле горит готовая картинка: полукадровых смазов, частичных прорисовок и прочего мельтешения не бывает в принципе. Панель при этом хранит свои 1024 байта в GDDRAM и честно показывает последний полученный кадр, даже если контроллер завис, — на этой особенности держатся и загрузочные заставки, и хитрости с аппаратным скроллом. Заодно из копии следует главное правило отладки: если на экране чушь, она попала туда через display(), и искать её надо в том, что вы нарисовали в буфере, а не в проводах.
Что сколько стоит
Пока вы рисуете — процессор перекладывает байты внутри своей SRAM, и это дёшево: десяток примитивов и строк занимают микросекунды-единицы миллисекунд. Дорогой шаг один — display(): по I2C уходят все 1024 байта плюс служебные байты-заголовки. На стандартных 100 кГц шины одна только полезная нагрузка — около 9216 бит — занимает примерно 90 миллисекунд на кадр. На быстром режиме 400 кГц, который панель официально поддерживает, тот же кадр уезжает за 23 мс. Разница — между слайд-шоу и вполне живой анимацией, и решается она одной строкой Wire.setClock(400000) после begin().
| Статья расхода | Сколько | Где живёт | Замечание |
|---|---|---|---|
| Буфер кадра 128×64 | 1024 байта | SRAM Uno | половина из 2048, занята сразу после begin() |
| Экран 128×32 | 512 байт | SRAM | компактная панель экономит половину буфера |
| display() на 100 кГц | около 90 мс | шина I2C | порядка 11 кадров в секунду — потолок |
| display() на 400 кГц | около 23 мс | шина I2C | до 40+ FPS, хватает для анимации |
| Код Adafruit_GFX | около 5 КБ | флеш | на 32 КБ Uno заметно, но не критично |
Цифры из таблицы проверяются на живой плате за минуту — скетч ниже считает кадры в секунду и печатает отчёт в монитор порта. Если увидите около 10-11 FPS, шина стоит на 100 кГц: добавьте Wire.setClock(400000) сразу после begin() и сравните. Мой рекорд на Uno с бутербродом из примитивов — 38 кадров в секунду на быстром режиме; подробный разбор замера на I2C и SPI я делал в уроке про FPS и скорость шины. Читать цифры из монитора удобно тем же способом, что и любые числа с платы, — парсинг чисел в Serial тут ни при чём, но монитор пригодится.
За цифрой 23 мс прячется и менее очевидная цена: display() занимает loop на всё время посылки. В эти миллисекунды скетч не читает кнопки и не опрашивает датчики — не из-за delay, а потому, что шина занята. Для индикатора незаметно, для прибора с интерфейсом принципиально: при 25-30 кадрах в секунду между кадрами остаётся 10-15 мс свободного времени, и весь отклик кнопок живёт именно там. Отсюда мой первый подозреваемый, когда кнопка срабатывает через раз на экранном приборе: слишком частая перерисовка, съевшая свободные проходы loop.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); unsigned long lastShot = 0;int frames = 0; void setup() { Serial.begin(9600); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); Wire.setClock(400000); // быстрый режим I2C; уберите строку - увидите ~11 FPS display.setTextColor(SSD1306_WHITE); display.setTextSize(1);} void loop() { // кадр из примитивов, чтобы буфер был реально нагружен display.clearDisplay(); for (int i = 0; i < 12; i++) { display.drawRect(i * 10, i * 5, 40, 25, SSD1306_WHITE); } display.setCursor(0, 56); display.print("Rabotaem..."); display.display(); // здесь 1024 байта уходят на панель frames++; if (millis() - lastShot >= 1000) { // раз в секунду - отчёт lastShot = millis(); Serial.print("FPS: "); Serial.println(frames); frames = 0; }}Три следствия из всей этой механики. Первое: display() всегда шлёт кадр целиком, без дельт — библиотека не сравнивает старый буфер с новым. Хотите экономить шину — рисуйте реже или разбирайте частичное обновление, у меня про это отдельный урок. Второе: пока display() не вызвана, стекло показывает предыдущий кадр, — поэтому анимация из полного цикла clear-рисуй-show вообще не мерцает, в отличие от стирания прямо на стекле, как делают старые текстовые дисплеи. Третье: clearDisplay обязателен, когда новая зона кадра короче старой — иначе старые символы проступают под новыми; я ловил это на числах, у которых меняется разрядность.
Теперь про бюджет памяти, из-за которого я и начал этот урок. Планка выживания для Uno или Nano: буфер 1024 байта, два буфера Serial по 64 байта, стек — и всё остальное должно уложиться в оставшиеся несколько сотен байт. Строки-литералы держите во флеше через F() — Serial.print(F(tekst)) не занимает SRAM, а каждый обычный литерал съедает байты из общего котла. Динамические String в долгоживущих приборах я не применяю вообще: фрагментация бьёт по куче незаметно, а симптомы — та самая каша на экране — раскапываются мучительно. Экран 128×32 с буфером вдвое меньше — лёгкий выход, у него есть отдельный урок.
Структура буфера объясняет и забавный эффект, о который спотыкаются при рисовании битмапов. Байты идут по страницам: один байт — вертикальный столбик из восьми точек, младший бит — верхняя точка. Картинка, собранная конвертером в горизонтальном порядке байтов, ложится на панель полосами или боком: инструмент должен складывать байты ровно так, как их ждёт SSD1306, — тонкости этого ремесла разобраны в уроке про image2cpp. Тот же страничный порядок делает частичную перезапись полос возможной — библиотека, впрочем, ей не пользуется и всегда шлёт кадр целиком.
Посмотреть на буфер своими глазами
Буфер удобен тем, что его механику видно даже без платы: ниже тренажёр держит кадр как текстовую сетку и показывает, что происходит, когда clearDisplay пропускают. Значение меняет разрядность, и старые символы остаются лежать в своих ячейках — ровно так же на настоящей панели проступают хвосты прошлых кадров. Зажмите кнопку — скетч вернёт стирание в цикл, и сетка очистится. С первого экрана и текстовой механикой панели всё начинается в уроке про первый запуск SSD1306, если эта база где-то просела.
Запустите FPS-скетч из этого урока на своей связке и запишите два числа — с setClock и без: они пригодятся при выборе такта для анимации в третьем уроке серии. У меня на Uno с проводами через макетку быстрый режим давал 37 кадров в секунду, после укорачивания проводов — 40: шина чувствительна к длине, и на грани режимов это видно прямо по счётчику.
Остаток SRAM печатает и компилятор: строка про глобальные переменные после сборки на Uno с OLED перестаёт быть абстракцией — у моей сборки глобальные съедали порядка 60 процентов сразу. Дальше каждый массив, каждая строка без F() и каждая новая библиотека откусывают от хвоста. Моё правило: если остаток опустился ниже двухсот байт, прибор считается неблагонадёжным, даже если всё работает, — запас нужен стеку, а он возьмёт его сам, в самый неподходящий момент.
- 1. Сколько SRAM занимает буфер экрана 128×64?
- 2. Что происходит со стеклом при вызове clearDisplay()?
- 3. Сколько занимает display() на шине 400 кГц?
- 4. Почему анимация через буфер не мерцает?
- 5. Строка Serial.print(F("tekst")) вместо Serial.print("tekst") нужна, чтобы:
- Кадр 128×64 = 8192 пикселя = 1024 байта; на Uno это половина всей SRAM.
- Цикл кадра: clearDisplay (только SRAM), рисование (только SRAM), display() (все 1024 байта на шину).
- Цена display(): около 90 мс на 100 кГц, около 23 мс на 400 кГц — Wire.setClock(400000) после begin().
- display() шлёт кадр целиком без дельт; экономия — реже рисовать или частичное обновление.
- Строки — во флеш через F(), String не использовать: остаток SRAM у Uno измеряется сотнями байт.
Частые вопросы по теме урока
- Сколько SRAM занимает буфер экрана 128×64?
- 8192 пикселя по одному биту — 1024 байта. На Uno это ровно половина всей SRAM, и она занята сразу после begin(). 1024 байта
- Что происходит со стеклом при вызове clearDisplay()?
- clearDisplay работает только с копией кадра в памяти контроллера. Шина молчит, панель показывает прежнее — потому и нет мерцания. Ничего: обнулился буфер в SRAM, старый кадр горит до display()
- Сколько занимает display() на шине 400 кГц?
- 1024 байта плюс заголовки — порядка 23 мс на быстром режиме. На стандартных 100 кГц тот же кадр едет около 90 мс. около 23 мс
- Почему анимация через буфер не мерцает?
- Стирание и рисование живут в SRAM, на стекле всегда есть законченная картинка. Дельты библиотека не шлёт — только весь кадр. Кадр собирается в памяти и уходит на панель одной посылкой
- Строка Serial.print(F("tekst")) вместо Serial.print("tekst") нужна, чтобы:
- Макрос F() оставляет строку во флеше и подсовывает её по байту. Каждый обычный литерал лежит в SRAM — а там уже половина занята буфером. держать литерал во флеше и не занимать SRAM