На часах в мастерской я захотел, чтобы мигала одна точка-разделитель между часами и минутами. Пересобирать весь кадр ради одной точки показалось расточительством, и я полез за drawPixel — и наступил на две грабли подряд. Точка не появилась там, где я её ждал: я писал x = 130, а экран кончается на 127. А когда добрался до раскладки буфера, мусор рассыпался через столбец, потому что умножал страницу не на ту ширину. Ни одного сообщения в Serial за весь вечер — библиотека просто молчала, и первые десять минут я грешил на проводку.
Здесь нижний этаж графики: ведь линии, круги и буквы библиотека в итоге складывает в те же точки. Разберём координатную сетку 0-127 и 0-63, что случается за границей (ничего — и это коварно), как байты буфера лежат на экране и что даёт прямой доступ через getBuffer(). Арифметика тут школьная — деление, остаток, сдвиг, — но цена промаха высокая: испорченный экран или зависающая плата. Модель двух кадров — рисуем в ОЗУ, показываем по вызову — уже разобрана в уроке про clearDisplay и display(), она тут фундамент.
Координаты: от нуля до 127, от нуля до 63
Начало координат — левый верхний угол. Первое число — x от нуля до 127, второе — y от нуля до 63. Точка (0, 0) — левый верхний пиксель, (127, 63) — правый нижний. После LCD 16x2 это сбивает: там setCursor ведёт счёт в знакоместах, здесь — в пикселях, и числа другого масштаба.
Границы обходятся без шума: drawPixel(200, 40, SSD1306_WHITE) не рисует ничего и не жалуется. Внутри функции стоит проверка выхода за край — и всё, ни предупреждения компилятора, ни строчки в Serial. Точка за границей видна как отсутствие пикселя, поэтому моя привычка: прежде чем подозревать провода и питание, пересчитать координаты на бумажке.
Третий аргумент умеет больше, чем вкл-выкл. SSD1306_WHITE зажигает точку, SSD1306_BLACK гасит, а SSD1306_INVERSE переворачивает: горевший пиксель гаснет, погашенный загорается. Двойной вызов по одной точке возвращает как было — так делают мигающий разделитель часов: по таймеру из урока про millis вместо delay переворачиваем точку каждые полсекунды, и кадр при этом никто не пересобирает.
Пример с меню, где это окупается на каждом кадре. Пунктов три, выделение — точка слева от строки. Я храню прежний номер пункта, по нажатию кнопки переворачиваю инверсией точку у старой строки (погасла) и у новой (загорелась) — два вызова drawPixel на всё меню. Вариант без инверсии тоже работает: гасить точку BLACK, ставить WHITE, но INVERSE честнее возвращает состояние, если где-то продублировали вызов. И помните про счёт с нуля: пиксель x = 127 существует, диапазон от нуля до 127 — это ровно 128 позиций; моя привычка после LCD — на бумажке подписывать у экрана нулевой столбец и нулевую строку.
| Задача | Инструмент | Что учесть |
|---|---|---|
| Одна точка по условию | drawPixel(x, y, цвет) | За границей молча не рисует; проверки на вашей стороне |
| Мигающий курсор или разделитель | drawPixel с SSD1306_INVERSE по таймеру | Повторный вызов возвращает пиксель как был |
| Линия или фигура | drawLine и примитивы из урока о фигурах | Внутри всё равно точки, но быстрее и короче |
| Свой виджет по пикселям | Буфер напрямую через getBuffer() | Без проверок границ: промах портит соседние байты |
| Горит ли точка сейчас | Чтение (buf[idx] >> бит) & 1 | Индекс тот же, что на запись; хит-тест и сравнение кадров |
Буфер изнутри: столбцы и страницы
Внутри буфер — 1024 байта, и лежат они не по строкам, а по столбцам: один байт отвечает за восемь вертикальных пикселей одного столбца. Столбцов 128, восьмёрок-страниц 64 / 8 = 8, итого 128 × 8 = 1024. Байт на экране стоит вертикально: младший бит — верхний пиксель восьмёрки, старший — нижний.
Индекс байта для точки: x + (y / 8) * 128. Для (10, 20): страница 20 / 8 = 2, индекс 10 + 2 * 128 = 266, бит внутри 20 % 8 = 4. Моя вторая грабля из начала урока была ровно тут: я умножал страницу на восемь, а шаг между страницами равен ШИРИНЕ буфера, у 128х64 это 128. Восьмёрка — высота страницы, и её место в делении и взятии остатка, не в умножении индекса.
Зачем знать раскладку, если есть drawPixel? Скорость и объём кода. Залить столбец через drawPixel — 64 вызова с проверками и битовой арифметикой внутри каждого; через буфер — восемь записей: buf[столбец + страница * 128] = 0xFF. На одиночных точках разницы вы не заметите, на заливке зон — в десятки раз меньше работы процессора. И честная поправка: оба способа не трогают сам экран — при display() по шине всё равно пойдёт весь кадр, это следствие из урока про частичное обновление и RAM. Экономим процессор, не провод.
Буфер читается тем же индексом: (buf[idx] >> бит) & 1 отвечает, горит ли пиксель. Отсюда растут неприметные применения: сравнение кадров между вызовами (менялось ли хоть что-то), и хит-тест в играх — перед шагом спрайта проверяете горящие пиксели под следующей позицией, и столкновение вычисляется по буферу без отдельной карты препятствий. Страницу целиком гасит цикл из 128 записей buf[i] = 0, а заготовку кадра из PROGMEM копирует memcpy_P — приёмы работы из урока про PROGMEM.
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); display.setTextSize(1); // углы: края координатной сетки, по точке в каждом display.drawPixel(0, 0, SSD1306_WHITE); display.drawPixel(127, 0, SSD1306_WHITE); display.drawPixel(0, 63, SSD1306_WHITE); display.drawPixel(127, 63, SSD1306_WHITE); // диагональ точками: один вызов в цикле for (int i = 0; i < 12; i++) { display.drawPixel(8 + i, 20 + i, SSD1306_WHITE); } // вертикальный пунктир байтами: восемь записей вместо 64 вызовов. // байт 0xFF = столбец из восьми горящих пикселей uint8_t* buf = display.getBuffer(); for (int page = 0; page < 8; page = page + 2) { buf[64 + page * 128] = 0xFF; // столбец x = 64, страница целиком } display.display();} void loop() {}Первый практический сценарий — мигающие мелочи: разделитель часов, курсор меню, сигнал о связи. Одна точка или колонка точек, переключаемая по таймеру, — пара микросекунд процессора против полного прохода по всем зонам. Второй — свои виджеты, которых нет в библиотеке: бегущую осциллограмму в уроке про графики датчика удобнее вести сдвигом столбцов байтами и новыми точками drawPixel — нижний уровень даёт контроль над каждым пикселем. Третий — правка спрайтов из прошлого урока: иконку не обязательно перегенерировать конвертером ради одного блика, drawPixel по координате дорисует.
И граница применимости, чтобы вы не изобрели мой велосипед: если меняется больше пары десятков точек, честнее пересобрать кадр примитивами и отправить одним display(). Пиксельная возня окупается на одиночных точках и готовых столбцах байтов; на сложных формах код из drawPixel превращается в простыню координат, которую никто не захочет читать, — тут уже битмапы из урока про иконки. Мой водораздел простой: точка — от состояния, форма — из массива. Как только форму начинают задавать условиями в цикле, я возвращаюсь к конвертеру и байтам.
- Координаты в пикселях: x от 0 до 127, y от 0 до 63, начало слева вверху; точка за границей молча не рисуется
- SSD1306_INVERSE переворачивает бит: мигающий разделитель — два вызова drawPixel по таймеру, без пересборки кадра
- Буфер лежит по столбцам: байт — восемь вертикальных пикселей, индекс x + (y / 8) * 128, младший бит — верхний
- Столбец байтами — 8 записей вместо 64 вызовов drawPixel; display() всё равно шлёт все 1024 байта — экономим процессор, не шину
- getBuffer() отдаёт буфер без проверок границ: промах индекса портит соседние переменные
- 1. Что нарисует drawPixel(200, 40, SSD1306_WHITE) на экране 128x64?
- 2. Сколько байтов буфера отвечают за один столбец экрана 128x64?
- 3. Какой бит в байте буфера соответствует верхнему пикселю страницы?
- 4. Индекс байта для пикселя x = 10, y = 20 в буфере 128x64?
- 5. Что экономит точечная перерисовка через drawPixel и getBuffer, а что нет?
Домашнее задание: на плате повторите углы и диагональ из скетча, потом погасите верхнюю страницу одним циклом байтов через getBuffer() и верните на неё несколько точек drawPixel — почувствуете разницу в цене между байтом и точкой. А затем поставьте честный эксперимент с границей: drawPixel(130, 40) и наблюдение, что из этого следует (ничего — и это ответ).
Точность до пикселя, впрочем, бесполезна, если вместо слова Температура на экране мусор: штатные шрифты библиотеки русские буквы не знают. Путь к кириллице — следующий урок этого раздела.
Частые вопросы по теме урока
- Что нарисует drawPixel(200, 40, SSD1306_WHITE) на экране 128x64?
- Внутри drawPixel стоит проверка границ и тихий выход. Никаких сообщений нет — отсутствие пикселя единственный симптом. Ничего: точка за границей молча отбрасывается
- Сколько байтов буфера отвечают за один столбец экрана 128x64?
- Один байт — восемь вертикальных пикселей, на столбец высотой 64 их нужно 64 / 8 = 8. Всего 128 столбцов на 8 страниц. 8
- Какой бит в байте буфера соответствует верхнему пикселю страницы?
- Байт стоит вертикально: младший бит — верхний пиксель восьмёрки, старший — нижний. Поэтому пиксель ставится сдвигом 1 сдвинутым на y по модулю 8. Младший, бит 0
- Индекс байта для пикселя x = 10, y = 20 в буфере 128x64?
- Страница 20 / 8 = 2, индекс 10 + 2 * 128 = 266, бит внутри — 4. Умножать страницу надо на ширину экрана, не на высоту страницы. 266
- Что экономит точечная перерисовка через drawPixel и getBuffer, а что нет?
- Библиотека всегда отправляет все 1024 байта буфера. Выигрыш — в процессорном времени и простоте кадра; за меньшим трафиком идёт к page-режиму U8g2. Экономит процессор; по шине при display() всё равно идёт весь кадр