Фоторамку на Uno и экране ST7735 128x160 я собрал за один вечер — вот только первую картинку она показала на третий день. Сначала экран был чёрным, потом заливался вертикальными полосами мусора, потом на нём проступил узнаваемый узор, но вверх ногами и скошенный по диагонали. Файл с карты открывался, байты читались — все файловые операции из урока про режимы File работали как часы, а изображение всё равно разваливалось на глазах. Виноваты оказались три вещи, о которых формат BMP молчит: пиксели начинаются не там, где я думал, строки идут снизу вверх, а каждая строка дополнена выравниванием до кратности четырём байтам. Один рисунок шириной 51 пиксель превращался в косые полосы ровно из-за одного пропущенного байта паддинга.
Разберём заголовок BMP поле за полем: где лежит смещение пикселей и почему нельзя верить константе 54; как 24-битный пиксель сжать в RGB565 для TFT; что делать с bottom-up строками и выравниванием; и когда вместо живого разбора проще конвертировать картинку заранее в сырой файл.
Почему вообще BMP, а не PNG или JPEG: формат без сжатия — редкое счастье для микроконтроллера. Пиксели лежат в файле подряд, читать их можно последовательно, ничего не распаковывая, и в оперативку Uno всё помещается по одному ряду за раз. JPEG без декодера не открыть, PNG требует inflate — а BMP это длинная лента байтов с короткой шапкой. Ленту и будем читать.
Прикинем объём: кадр 128x160 в 24-битном BMP — это 61440 байт плюс шапка, в RGB565 — 40960. На карте и то и другое ничто, разница в другом: 24-битный файл гоняет по шине в полтора раза больше байтов, и каждый пиксель ещё надо пересчитать из трёх байтов в два. На Uno это миллисекунды, накопленные тысячами пикселей: сырой RGB565 выигрывает не место на карте, а секунды вывода.
Заголовок: подпись, смещение, ширина и высота
Файл открывается, первые два байта отдают 0x42 и 0x4D — буквы BM, подпись формата. Дальше самое полезное. По байту 10 лежит смещение пиксельных данных — четыре байта, little-endian, младший байт первым. Учебники и половина статей в интернете пишут: пиксели начинаются с 54-го байта. Для классического 24-битного файла без палитры это правда, но поле по байту 10 существует не зря: между заголовком и пикселями редакторы могут положить палитру или служебные структуры, и смещение спокойно бывает 70 или 122. Я упорно читал с 54-го и получал постоянный сдвиг на шестнадцать байт — картинку не опознать, но и не мусор: полосы с фрагментами узора. С тех пор правило одно: offset читаю из файла и стартую с него.
Из заголовка нужны ещё три числа. Ширина картинки — четыре байта по смещению 18, высота — по смещению 22, обе со знаком: отрицательная высота встречается и означает, что строки идут сверху вниз. Бит на пиксель — два байта по смещению 28: нас интересует 24, три байта на пиксель. И внимание к порядку каналов: внутри 24-битного BMP они идут BGR, синий первым. Перепутаете с RGB — небо станет оранжевым, трава фиолетовой, и это ещё симпатичный результат: я видел и более странные палитры.
| Смещение | Поле | Размер | Как использую |
|---|---|---|---|
| 0 | Подпись 0x42 0x4D | 2 байта | проверка, что это BMP, а не переименованный PNG |
| 10 | Смещение пикселей | 4 байта | отсюда начинается чтение пикселей, не с 54 |
| 18 | Ширина | 4 байта | число столбцов вывода |
| 22 | Высота | 4 байта | число строк; со знаком, минус = сверху вниз |
| 28 | Бит на пиксель | 2 байта | нужно 24; 32-битные файлы не беру |
Пиксели: RGB565, bottom-up и паддинг строк
Экран ST7735 и почти вся мелкая TFT-периферия понимает цвет в RGB565: пять бит на красный, шесть на зелёный, пять на синий, два байта на пиксель. Пересчёт из 24-битного грубый, но честный: у каждого канала отрезаем младшие биты — r8 >> 3, g8 >> 2, b8 >> 3 — и склеиваем в шестнадцать бит: (r5 << 11) | (g6 << 5) | b5. Зелёного бита больше не случайно: глаз чувствительнее к нему, и на плавных градиентах ступеньки вылезают именно в зелёном канале. На отдельной картинке разницу между 24 и 16 битами я на глаз не отличаю; про состав цвета подробнее — в уроке про CRGB и каналы.
Второй сюрприз — порядок строк. BMP хранит пиксели снизу вверх: первая строка файла — нижняя строка картинки. Выведете как читается — получите изображение вверх ногами, ровно мой второй заход. Лечение на одно действие: номер экранной строки считать как высота минус один минус номер файловой строки. Третий сюрприз — выравнивание: каждая строка файла дополнена паддингом до кратности четырём байтам. Ширина 50 пикселей — это 150 байт пикселей плюс два байта выравнивания, всего 152. Формула: pad = (4 - (ширина * 3) % 4) % 4. Забыли — каждая следующая строка съезжает на байт-два, и картинка косится, как моя на ширине 51.
И почему построчно: у Uno 2 КБ оперативки, а кадр 128x160 в RGB565 — это 40960 байт, в двадцать раз больше всей памяти. Даже один ряд 24-битных пикселей — 384-480 байт, и тот размещается с оглядкой на остальной скетч; куда девается память и как её считать, разбирали в уроке про PROGMEM и ОЗУ. Схема вывода: прочитал заголовок, перешёл seek-ом на смещение, в цикле по строкам — чтение ряда, пересчёт цветов, вывод на экран, следующий ряд. Скорость складывается из двух частей: чтение 61 КБ 24-битного файла по SPI-шине занимает на Uno порядка секунды, а вот попиксельные вызовы библиотеки экрана легко съедают ещё две-три секунды. Пишите в окно дисплея пачками — выиграете больше, чем на любой оптимизации разбора заголовка. Время первой картинки я честно засекал секундомером: шесть с половиной секунд на кадр, и только потом нашёл виновника — попиксельные вызовы drawPixel, а не карта.
#include <SPI.h>#include <SD.h> const int chipSelect = 10; unsigned long read32(File &f) { unsigned long v = f.read(); v |= (unsigned long)f.read() << 8; v |= (unsigned long)f.read() << 16; v |= (unsigned long)f.read() << 24; return v;} int read16(File &f) { int v = f.read(); v |= f.read() << 8; return v;} void setup() { Serial.begin(9600); if (!SD.begin(chipSelect)) { Serial.println("SD error"); while (true) { } } File bmp = SD.open("pic.bmp", FILE_READ); if (!bmp) { Serial.println("net pic.bmp"); while (true) { } } if (bmp.read() != 0x42 || bmp.read() != 0x4D) { Serial.println("eto ne BMP"); while (true) { } } bmp.seek(10); unsigned long offset = read32(bmp); // начало пикселей bmp.seek(18); long w = read32(bmp); // ширина long h = read32(bmp); // высота read16(bmp); // planes, пропускаем int bpp = read16(bmp); // бит на пиксель Serial.print("w="); Serial.print(w); Serial.print(" h="); Serial.print(h); Serial.print(" bpp="); Serial.print(bpp); Serial.print(" offset="); Serial.println(offset); long rowBytes = w * 3; long pad = (4 - (rowBytes % 4)) % 4; for (long y = h - 1; y >= 0; y--) { // bottom-up: нижняя строка первая long fileRow = h - 1 - y; // номер строки в файле bmp.seek(offset + fileRow * (rowBytes + pad)); for (long x = 0; x < w; x++) { int b = bmp.read(); // BGR: синий первым int g = bmp.read(); int r = bmp.read(); unsigned int c565 = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3); if (x % 40 == 0 && y % 40 == 0) { // редкие пиксели в Serial Serial.print("x="); Serial.print(x); Serial.print(" y="); Serial.print(y); Serial.print(" c565=0x"); Serial.println(c565, HEX); } // здесь вызов экрана: tft.drawPixel(x, y, c565); } } Serial.println("kadr prochitan"); bmp.close();} void loop() { }Честный вопрос: зачем каждый раз парсить заголовок, если картинка на карте одна и не меняется. Мой ответ после трёх фоторамок: для заставки — не надо. Конвертируйте заранее: на компьютере скриптом или онлайн-конвертером превратите BMP в сырой файл RGB565 — ровно ширина умножить на высоту умножить на два байта, без заголовка, без BGR, без паддинга, строки уже сверху вниз. Скетч ужимается до seek и цикла чтения пар байт, а кадр выходит быстрее: на моей связке живой разбор 24-битного BMP 128x160 занимал шесть-восемь секунд, сырой RGB565 — две-три. Цена простоты: файл привязан к конкретному экрану, и имя вроде 128x160.raw приходится подписывать — на карте или хотя бы в блокноте. Инструменты любые: скрипт на Python в двадцать строк, утилита image2cpp, экспорт из графического редактора в RAW. Мой путь — маленький скрипт: читает BMP, выкидывает шапку, пересчитывает каналы и пишет файл; код живёт в пятьдесят строк и не менялся годами.
Для галереи, где пользователь сам меняет картинки, разбор BMP оправдан: один скетч ест любые 24-битные файлы, и загрузка новой картинки — это просто копирование на карту. Отладка таких вещей идёт печатью пикселей в Serial, и тут пригодится урок про bin, hex и dec: цвет в hex вида 0xF81F сверяется с даташитом экрана на лету. Теперь симулятор. Настоящую карту памяти и экран он не подключает — SD в симуляторе не эмулируется, поэтому разбор показан моделью: скетч печатает поля заголовка и строит строки псевдопикселями символами, честно пропуская паддинг каждой строки.
- 1. Где в BMP начинается пиксельная часть файла?
- 2. В каком порядке каналы лежат в 24-битном BMP?
- 3. Почему картинка выводится вверх ногами, если читать строки по порядку?
- 4. Ширина 51 пиксель, 24 бита. Сколько байт занимает строка на карте вместе с выравниванием?
- 5. Почему кадр 128x160 не держат в оперативке целиком?
- Смещение пикселей читайте из файла по байту 10: честные 54 байта бывают не у всех файлов, у редакторов бывает 70 и 122
- Каналы в 24-битном BMP идут BGR; пересчёт в RGB565: r>>3, g>>2, b>>3, склейка в два байта
- Строки идут снизу вверх: экранная строка = высота минус один минус файловая
- Каждая строка дополнена до кратности 4 байтам: pad = (4 - w*3 % 4) % 4; без пропуска картинка косится
- Кадр в RAM не влезает: 128x160 в RGB565 — это 40 КБ против 2 КБ у Uno, читайте построчно
- Для неизменной заставки конвертируйте в сырой RGB565 заранее: без заголовка и паддинга кадр выходит в два-три раза быстрее
Частые вопросы по теме урока
- Где в BMP начинается пиксельная часть файла?
- У файла без палитры смещение равно 54, но редакторы кладут между заголовком и пикселями свои структуры. Читайте поле по байту 10 и стартуйте с него. Там, куда указывает поле смещения по байту 10
- В каком порядке каналы лежат в 24-битном BMP?
- На каждый пиксель идут три байта: синий, зелёный, красный. Перепутаете — получите инвертированную палитру: оранжевое небо вместо синего. BGR — синий первым
- Почему картинка выводится вверх ногами, если читать строки по порядку?
- Классический BMP идёт bottom-up. Номер экранной строки считайте как высота минус один минус номер файловой строки. BMP хранит строки снизу вверх: первая строка файла — нижняя
- Ширина 51 пиксель, 24 бита. Сколько байт занимает строка на карте вместе с выравниванием?
- 51 умножить на 3 — 153 байта пикселей, паддинг до кратности четырём добавляет один байт: всего 154. 154
- Почему кадр 128x160 не держат в оперативке целиком?
- 40960 байт против 2048 у Uno. Отсюда построчная схема: ряд прочитали — пересчитали — вывели — следующий. В RGB565 это 40 КБ, а у Uno всего 2 КБ оперативки