Матрица 8x8 из WS2812 досталась мне собранной: 64 пикселя, наклеенные змейкой на чёрную подложку. Полчаса я гонял по ней радугу и волны из урока про эффекты, а потом захотел то, ради чего такие матрицы вообще покупают: вывести текст. Температуру, обратный отсчёт, хоть приветствие. И упёрся в стену: fill_rainbow рисует радугу, fadeToBlackBy — хвосты, а функции «нарисуй букву» в FastLED нет и в помине. Лента умела всё, кроме своей главной работы.
В этом уроке соберём недостающее своими руками: мини-шрифт 5x7 из массива байтов, раскладку бита в пиксель и прокрутку строки по матрице. Если библиотека ещё не стоит — вернитесь на минуту к уроку про установку FastLED. Всё проверяется прямо в симуляторе на этой странице: движок честно считает битовые сдвиги, поэтому строка бежит в браузере ровно так же, как на настоящей плате.
Буква как пять байтов
Матрица 8x8 — это сетка: восемь столбцов по восемь строк, 64 пикселя, и каждый слушает тот самый один провод, чью анатомию я разбирал в уроке про протокол WS2812. Букву рисуют в окне пять столбцов на семь строк; нижний ряд и правый столбец остаются пустыми и работают зазором между буквами. Пропорция 5x7 не выдумана под Arduino: это стандарт со времён текстовых LCD на контроллере HD44780, где такой знакогенератор зашит в ПЗУ с начала восьмидесятых. В окне 8x8 буква смотрится крупно и читается с двух-трёх метров, а на сборках 8x32 на экране уже умещается пара слов целиком.
Хранить букву удобно по столбцам: пять байтов, по одному на столбец, и в каждом байте семь рабочих битов — бит 0 отвечает за верхнюю строку, бит 6 за седьмую. Возьмём «A»: её столбцы — 0x7C, 0x12, 0x11, 0x12, 0x7C. Первый байт в двоичном виде 01111100: горят биты со второго по шестой — левый борт вертикальной палкой. Второй, 0x12, — 00010010: биты 1 и 4, кусочки перекладины. Пять столбцов подряд — и на матрице читается «A». Выбор «по столбцам, а не по строкам» не вкусовщина: окно прокрутки движется по столбцам, и на каждый шаг ему нужны ровно вертикальные срезы текста — храня по столбцам, вы читаете байты в том же порядке, в каком рисуете.
| Байт | Двоичный вид | Что горит в столбце |
|---|---|---|
| 0x7C | 01111100 | строки 2–6 — вертикальный борт «A» |
| 0x12 | 00010010 | строки 1 и 4 — перекладина «A» |
| 0x7F | 01111111 | все семь строк — борта буквы «H» |
| 0x5F | 01011111 | столбик и точка — восклицательный знак |
Дальше два массива, и на них держится весь вывод текста. font[] — словарь глифов: пять байтов на букву, подряд. msg[] — сам текст, но не строка, а список номеров: надпись «HELLO!» — это {0, 1, 2, 2, 3, 4}. Разделение честное: сменить надпись — правите только msg; добавить новую букву — дописываете пять чисел в font. Я держу в комментариях к каждому глифу его рисунок из решёток: через месяц без подсказки уже не вспомнишь, какой из байтов что рисует.
PROGMEM: таблице положено жить во flash
У Uno два этажа памяти: 32 КБ flash под скетч и 2 КБ SRAM под переменные. Обычный массив живёт в SRAM и держит место, даже когда вы к нему не прикасаетесь. Ключевое слово PROGMEM отправляет данные во flash, а читают их оттуда вручную — pgm_read_byte_near(font + индекс). Строки-литералы спасают так же, через F("..."). Плата платит за чтение из flash парой лишних тактов на байт; шрифт, который тратит пять чтений на столбец, этой цены не замечает.
Посчитаем, зачем вся эта возня. Печатных символов ASCII — 95, по пять байтов на каждый: 475 байтов, почти четверть от 2 КБ ОЗУ Uno. Рядом живут буфер ленты — 64 пикселя по 3 байта, это ещё 192 байта, — стек, переменные и Serial. Скетч с шрифтом в ОЗУ ещё дышит, но добавьте второй шрифт, буфер подлиннее и пару строковых переменных — начнётся порча данных с самыми непредсказуемыми симптомами, от сбитых счётчиков до бессвязных символов в мониторе. Предупредить дешевле, чем чинить.
Нюанс, о котором редко говорят: PROGMEM — фишка AVR. На ESP32 программная и обычная память лежат в одном адресном пространстве, макрос превращается в пустышку, и таблицы можно не прятать. В симуляторе ниже PROGMEM тоже не используется — движок его не понимает, там обычный массив, и это честно: поведение то же, разница только в том, какой этаж памяти ест скетч на живой плате. Скелет для Uno я привожу сразу с PROGMEM: переводить обычный массив в PROGMEM-версию в последний момент обиднее, чем сразу приучиться.
#include <FastLED.h> #define MATRIX_PIN 6#define NUM_LEDS 64 CRGB leds[NUM_LEDS]; // Шрифт 5x7: пять байтов на букву, бит 0 — верхняя строкаconst byte font[] PROGMEM = { 0x7F, 0x08, 0x08, 0x08, 0x7F, // H 0x7F, 0x49, 0x49, 0x49, 0x41, // E 0x7F, 0x40, 0x40, 0x40, 0x40, // L 0x3E, 0x41, 0x41, 0x41, 0x3E, // O 0x00, 0x00, 0x5F, 0x00, 0x00 // !}; // Текст как список номеров глифов: H E L L O !const byte msg[] = {0, 1, 2, 2, 3, 4}; // Лента на подложке намотана змейкой: чётные ряды слева направоint XY(int x, int y) { if (y % 2 == 0) { return y * 8 + x; } return y * 8 + (7 - x);} int offset = 0;unsigned long lastFrame = 0; void setup() { FastLED.addLeds<WS2812B, MATRIX_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(120);} void loop() { if (millis() - lastFrame >= 100) { lastFrame = millis(); FastLED.clear(); int total = sizeof(msg) * 6; // 5 столбцов буквы + 1 на зазор for (int col = 0; col < 8; col++) { int wrap = (offset + col) % total; int glyph = msg[wrap / 6]; int c = wrap % 6; if (c < 5) { byte bits = pgm_read_byte_near(font + glyph * 5 + c); for (int row = 0; row < 7; row++) { if ((bits >> row) & 1) { leds[XY(col, row)] = CRGB::Orange; } } } } FastLED.show(); offset = (offset + 1) % total; }}Прокрутка устроена как окно, ползущее по невидимой длинной строке. Переменная offset — номер столбца, который в этот момент виден слева. Для каждого из восьми столбцов окна считаем wrap = (offset + col) % total, где total — длина всей надписи в столбцах: по шесть на букву, пять рабочих плюс один пустой на зазор. Из wrap номер глифа получается делением на шесть, а номер столбца внутри глифа — остатком; если выпал пятый столбец, рисовать нечего — это зазор.
Байт столбца раскладывается в пиксели одним циклом: бит с номером row получают как (bits >> row) & 1 — сдвигом вправо и отсечкой младшего бита. Функция XY переводит пару «столбец, строка» в номер пикселя ленты; намотка змейкой — чётные ряды слева направо, нечётные наоборот — у заводских подложек самая частая. Если картинка у вас отражена, чинить нужно именно XY, а не шрифт.
Скорость подбирается руками: кадр раз в 100 мс — это десять столбцов в секунду, буква проходит окно за 0,6 секунды, «HELLO!» из 36 столбцов делает полный круг за 3,6. Быстрее 70 мс глаз перестаёт читать, медленнее 150 мс движение рассыпается на ступеньки. Я стартую со 100 и подгоняю под расстояние до зрителя. По ресурсам всё скромно: show() для 64 пикселей занимает около 1,9 мс, цикл из сдвигов и масок — микросекунды, так что кадр в 100 мс загружает плату на пару процентов.
Два приёма на будущее, оба — одна строка. Остановить строку на нужном слове: заморозьте offset, когда левый край окна дойдёт до нужного столбца, подождите два-три секунды на millis() и продолжайте. Сменить направление: вычитайте единицу вместо прибавления и прибавляйте total к отрицательному остатку. Так табло умеет показывать сообщение, выдерживать паузу, уезжать и приезжать снова — это уже законченное поведение уличного экрана, собранное из пары if.
Приём «байт — столбец, бит — строка» экономнее, чем кажется: тем же способом рисуются любые спрайты — сердечки, стрелки, цифры обратного отсчёта. Мой термометр в теплице так и работает: цифра температуры — это три-четыре глифа, которые обновляются раз в минуту, а между обновлениями по полю проходит текущий прогноз. Кадр здесь держится на millis(), как и положено по каркасу без delay(), а цвет можно отдать на откуп HSV — градиенты по строкам проще всего собираются через колесо оттенков. Четыре задачи по возрастанию:
- Смените надпись на «OLE»: msg станет {3, 2, 1}. Шрифт не трогайте — вся работа в списке номеров.
- Раскрасьте строки градиентом: вместо одного цвета ставьте CHSV(96, 255, 255 - row * 30) — буквы потемнеют книзу.
- Сделайте зазор в два столбца: total считайте как букв × 7 и добавьте пропуск при расчёте столбца внутри глифа.
- Пустите строку в обратную сторону: offset уменьшайте и прибавляйте total, чтобы остаток не ушёл в минус.
- Глиф 5x7 — пять байтов, по столбцу на байт; бит 0 — верхняя строка, бит 6 — седьмая
- Текст хранится отдельным массивом номеров: смена надписи не трогает шрифт
- Полный шрифт на 95 символов — 475 байтов, четверть ОЗУ Uno; таблице место в PROGMEM
- Прокрутка = окно 8 столбцов, offset и формула wrap = (offset + col) % total
- Кадр 100 мс даёт 10 столбцов в секунду; отражения чинятся в XY, перевёрнутые биты — в сдвиге
- 1. Сколько байтов занимает одна буква в шрифте 5x7 при хранении по столбцам?
- 2. Что означает единица в бите 3 байта столбца?
- 3. Зачем таблицу шрифта кладут в PROGMEM?
- 4. Что такое offset в алгоритме прокрутки?
- 5. Надпись из 10 букв при шаге окна 100 мс — сколько идёт полный круг?
Теперь у вашей матрицы есть собственный голос: она пишет. Следующий шаг по этой тропе — заставить поле играть: змейка из следующего урока использует те же 64 пикселя и ту же функцию XY, только вместо букв по ним побежит сама игра. А когда и поле, и надпись покажутся тесными, придёт время сборок из нескольких лент на разных пинах — об этом третьим уроком.
Частые вопросы по теме урока
- Сколько байтов занимает одна буква в шрифте 5x7 при хранении по столбцам?
- По столбцу на байт, по семь рабочих битов в каждом: 35 пикселей буквы упакованы в пять байтов. Пять — по одному на столбец
- Что означает единица в бите 3 байта столбца?
- Бит 0 — верхняя строка, значит бит 3 — четвёртая строка сверху. Единица в бите означает включённый пиксель. Пиксель четвёртой строки сверху горит — строки считаются с нуля
- Зачем таблицу шрифта кладут в PROGMEM?
- SRAM на Uno всего 2 КБ, flash — 32 КБ. PROGMEM держит таблицу во flash и забирает байты оттуда по одному. Чтобы не занимать ОЗУ: таблица живёт во flash, а по требованию читается pgm_read_byte_near
- Что такое offset в алгоритме прокрутки?
- Окно 8 столбцов смотрит на длинную строку; offset — координата левого края окна, каждый кадр она сдвигается на единицу. Номер столбца длинной строки, который виден слева в окне матрицы
- Надпись из 10 букв при шаге окна 100 мс — сколько идёт полный круг?
- Буква занимает 6 столбцов вместе с зазором: 10 букв — 60 столбцов. При 10 столбцах в секунду круг — 6 секунд. 6 секунд: 60 столбцов при десяти столбцах в секунду