|
Professor Seleznov
|
Продолжим совершенствование нашего I2C-контроллера и расширение спектра применимости. В этот раз сделаем возможность burst-транзакций и выведем картинку SSD1306. Для этого необходимо детально разобрать механизм функционирования OLED-дисплея SSD1306 и сделать аппаратный контроллер с burst-передачей по I2C, и в качестве примера сделать генерацию визуализацию 3D-куба и текста. Получился ОЧЕНЬ объемный материал с объяснением всех механик примененных для решения данной задачи. И вся логика - сугубо в железе, без процессора, без микрокода и чисто в ПЛИС. Всем кто интересуется кодингом под Verilog - добро пожаловать под кат!
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Цели и задачи Итак, обозначим основные цели и задачи - построить проект, который:
- Инициализирует OLED-дисплей SSD1306 (128x64, I2C, адрес 0x3C);
- Формирует кадр в собственном фреймбуфере в железе без MCU, без softcore-процессора, без HLS-компилятора:
- Статический текст “SSD1306” в верхней строке;
- Каркасный псевдотрехмерный куб в центре, который вращается вокруг вертикальной оси по кнопке “Анимация”;
- Подпись “STATIC” или “ANIM” в нижней строке.
- Передает 1024 пикселя + 1 control-байт в одной I2C-транзакции и обновляет картинку “в режиме реального времени” с частотой около 10 FPS.
Помимо этого я обозначу две высокоуровневые задачи:
- Инженерная. Это продолжение стендовой верификации ядра i2c_master_core: мы гоняем по шине непрерывный поток ~95 мс длиной и убеждаемся, что ядро удерживает связь без сбоев ACK и потерь арбитража.
- Учебная. Мы проходим путь от ядра I2C-уровня “один байт за команду” до полноценной графической подсистемы с растеризатором, LUT sin/cos, пайплайном вершин и dual-port BRAM. Без процессора. Всё - явные автоматы и детерминированные операции.
В предыдущем проекте c EEPROM мы записывали данные и считывали их обратно. Там были короткие транзакции с ручной обработкой “для каждого байта вызови ядро через отдельный CMD_WRITE”. Здесь ситуация иная:
| Параметр |
EEPROM-тест |
SSD1306-тест |
| Байт на транзакцию |
3 (addr+data) |
33 (init) и 1027 (frame) |
| Источник данных |
фиксированный ROM |
динамически рендерится в BRAM |
| Индикация сцены |
нет |
OLED-дисплей 128×64 |
| Латентность на байт |
не критична |
определяет FPS |
| Сложность FSM |
IDLE/START/ADDR/DATA |
IDLE/DELAY/INIT/RENDER/FRAME/... |
Переход от “отправить 3 байта” к “отправить 1027 байт” обнажает потребность в двух новых аппаратных слоях:
- burst-writer, автоматически формирующий последовательность START → WRITE(addr) → WRITE(data[0]) … WRITE(data[N-1]) → STOP;
- рендер-модуль, который готовит содержимое фреймбуфера без вмешательства CPU.
Эти два слоя - сердцевина руководства. Высокоуровневая схема будет выглядеть так:
 SSD1306. Физический слой Начнем с рассмотрения того, из чего состоит OLED-дисплей. SSD1306 - это драйвер/контроллер, выполненный в виде COG-чипа (chip-on-glass), припаянного непосредственно к стеклу OLED-панели 128х64. В руках разработчика обычно оказывается не сам чип, а готовый модуль на PCB: панель + драйвер + обвязка (конденсаторы charge pump’а, опциональный LDO-регулятор, подтягивающие резисторы). Именно с модулем мы и будем работать - отдельно SSD1306 без панели покупать смысла нет. Органические светодиоды требуют достаточно высокого прямого напряжения для свечения - типично ~7 В на ячейку в пике. Подавать такое напряжение от 3.3 В-шины FPGA напрямую невозможно, поэтому внутри SSD1306 встроен charge pump (насос заряда на переключаемых конденсаторах). Он собирает 7.5...9 В из 3.3 В с помощью двух внешних конденсаторов (обычно 1 мкФ), подключенных к пинам C1P/C1N и C2P/C2N. На модулях эти конденсаторы распаяны на плате - вам их трогать не надо. Ключевой момент: charge pump по умолчанию выключен. После подачи питания вы получаете чип в “почти-спящем” режиме - он реагирует на команды по I2C, принимает данные во внутреннюю GDDRAM, но ничего не светится. Включение pump’а делается командой: 0x8D (Set Charge Pump) 0x14 (Enable charge pump during display ON) После этого дисплей нужно ещё перевести в ON командой 0xAF - тогда pump реально запускается, и строки матрицы начинают получать 7.5 В. Если вы подали всю инициализацию, но пропустили 0x8D, 0x14, картинка в GDDRAM есть (это можно проверить логическим анализатором на I2C), но экран остаётся абсолютно тёмным. Это типичная “тихая” ошибка новичка - код компилируется, шина работает, NACK-ов нет, а дисплей просто чёрный. Питание модуля:
| Линия |
Напряжение |
Ток (типовой) |
Примечание |
| VCC/VDD |
3.3 В |
10 … 20 мА (весь экран горит) |
от шины FPGA, желательно через отдельный фильтрующий конденсатор 10 мкФ рядом с модулем |
| GND |
0 В |
- |
общая земля |
Пульсации charge pump’а (~800 кГц) могут наводиться на аналоговую часть через общую землю, поэтому на чувствительных проектах иногда разделяют GND на “цифровой” и “аналоговый” через ферритовый дроссель. В нашем проекте на AX301 этот нюанс некритичен: все сигналы цифровые, а модуль подключается короткими проводами. SSD1306 поддерживает сразу несколько режимов связи, выбор делается аппаратно - пинами BS0…BS2 на самом чипе. Но на готовом модуле эти пины уже запаяны и доступны выводы только для одного интерфейса:
| BS2 BS1 BS0 |
Интерфейс |
Пины модуля |
Скорость |
| 0 1 0 |
I2C |
SDA, SCL |
до 400 кГц (Fast-mode) |
| 0 0 0 |
4-wire SPI |
SCLK, MOSI, DC, CS |
до 10 МГц |
| 0 0 1 |
3-wire SPI |
SCLK, MOSI, CS (D/C# передаётся 9-м битом) |
до 10 МГц |
| 1 0 0 |
8080 parallel |
D0..D7, /WR, /RD, /CS, DC |
по циклу чтения/записи |
| 1 1 0 |
6800 parallel |
D0..D7, E, R/W, /CS, DC |
— |
Подавляющее большинство дешёвых модулей на Aliexpress / с китайских barebone-плат продаются в I2C-варианте с 4 выводами: VCC, GND, SCL, SDA. Именно этот вариант мы и подразумеваем в своей работе. Типовой схематик данного модуля:
 Почему I2C, а не SPI? С точки зрения ПЛИС - SPI быстрее (мы могли бы обновлять экран с fps >100). Но:
- Тема проекта - именно I2C (это лабораторный пример для I2C-мастера);
- Нам достаточно ~8 fps, чтобы анимация воспринималась плавно.
- I2C проще физически: 2 провода вместо 4+, не требует CS.
На выводах модуля можно увидеть ещё пару вариантов:
- Адресный джампер SA0 - перемычка или 0-Ω резистор, которая подтягивает SA0 либо к GND (адрес 0x3C), либо к VCC (0x3D). Пользуйтесь мультиметром, чтобы убедиться, куда он спаян на вашем конкретном модуле.
- Пин RES (reset) - опциональный, на большинстве плат подтянут к VCC через RC-цепочку. Если его нет на колодке - модуль сбрасывается автоматически при подаче питания.
Pull-up резисторы (4.7-10 кОм) на SDA/SCL уже установлены на плате модуля (обычно распаяны прямо рядом с COG-чипом). Дублировать их на стороне FPGA не надо - суммарное сопротивление pull-up’а уменьшится, что повысит ток через транзистор при tri-state=0, но не улучшит фронты. Если линии очень длинные (>20 см) и/или на шине висят ещё устройства - добавляйте внешние pull-up’ы, ориентируясь на общий расчёт. На плате AX301 (Cyclone IV EP4CE10F17C8N) мы заняли два свободных GPIO-пина под I2C-мастер. В .qsf-файле они прописаны так:
set_location_assignment PIN_E8 -to i2c_scl set_location_assignment PIN_E9 -to i2c_sda set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to i2c_scl set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to i2c_sda
3.3-V LVCMOS - стандартный банк на AX301; уровни SSD1306 тоже 3.3 В, поэтому level-shifter не нужен. Длина проводов в типовом стендовом монтаже - 10…20 см. Этого достаточно мало, чтобы не заморачиваться с волновым сопротивлением и терминаторами. На больших длинах (>50 см) нужно либо снижать частоту SCL, либо пересчитывать pull-up. Шина I2C по стандарту - open-drain: ни одно устройство не имеет право активно тянуть линию в 1, только в 0 или “отпустить” (перейти в высокоимпедансное состояние). В 1 линию возвращают исключительно внешние pull-up резисторы. Это нужно для двух вещей:
- Multi-master / multi-slave - несколько устройств могут одновременно владеть шиной, и если они одновременно “хотят” в 1, конфликта не возникнет; а если один тянет в 0 - он “побеждает” (wired-AND). Это же свойство используется для арбитража в multi-master-системах.
- Clock stretching - slave-устройство (например, медленный EEPROM) может “прижать” SCL к GND, пока оно не готово, и мастер обязан подождать.
FPGA-пин в Altera/Xilinx (и любой другой CMOS-логике) по умолчанию - двунаправленный push-pull. Чтобы эмулировать open-drain, мы никогда не выводим физическую 1, а выводим либо 0, либо high-Z (отключаем драйвер):
assign i2c_sda = sda_oen ? 1'bz : 1'b0; assign i2c_scl = scl_oen ? 1'bz : 1'b0;
В синтезе Quartus это выражение преобразуется в IOBUF-примитив (один bi-directional pin с отдельным oen-сигналом). Подтверждает ли синтезатор open-drain? Нет - на стороне FPGA это всё тот же обычный выход, но поведение эквивалентно open-drain благодаря tri-state’у. Чтение линии делается прямо с того же пина:
assign sda_in = i2c_sda; // считываем уровень после pull-up / wired-AND assign scl_in = i2c_scl;
Асинхронный вход sda_in/scl_in обязательно синхронизируется 2-ступенчатым регистром перед использованием в FSM - иначе рискуем получить метастабильность. На плате SSD1306 обычно стоит внешняя RC-цепочка R=100 кОм, C=100 нФ на пине /RES, дающая время reset’а ~10 мс после подачи питания - достаточно, чтобы чип вышел в стабильное состояние до первой команды. Поэтому перед отправкой init-последовательности мы ждём минимум 100 мс с момента подачи питания (реализовано в ssd1306_ctrl через счётчик post_por_cnt). Если активность на I2C начать слишком рано, первая команда 0xAE может прийти в момент, когда внутренний reset ещё не снят, и чип её проигнорирует - типовая ошибка “всё инициализируется, а экран чёрный”. SSD1306. Базовый I2C адрес и control-byte I2C SSD1306 - это стандартная двухуровневая адресация + “дополнительный смысловой слой”, специфичный именно для этого чипа (control-byte). Оба слоя нужно понимать, чтобы:
- уметь различать “я передаю команду настройки” и “я передаю пиксели”;
- корректно строить короткие init-транзакции и длинные data-burst’ы;
- отличать ошибки физического уровня (NACK на адрес) от ошибок контроллера (неправильный control-byte → мусор в GDDRAM).
I2C-адрес - 7-битный. Физически по шине передается 8-битный “адрес-байт”: старшие 7 бит - собственно адрес, младший бит - R/W:
 Для SSD1306 компания Solomon Systech фиксирует в даташите старшие 6 бит адреса - 011110 (hex0x3C как пред-аппер). Младший из 7 битов адреса задается пользователем через пин SA0:
| SA0 (пин чипа) |
7-битный адрес |
8-битный ADDR-байт (write) |
8-битный ADDR-байт (read) |
| GND (или не занят) |
0x3C |
0x78 |
0x79 |
| VCC |
0x3D |
0x7A |
0x7B |
В нашем проекте используем 0x3C (на плате будет подписано как 0x78):
// quartus_ssd1306/src/ssd1306_ctrl.v localparam [6:0] SSD_ADDR = 7'h3C; ... bw_slave_addr <= SSD_ADDR;
Внутри i2c_burst_writer этот 7-битный адрес автоматически дополняется до 8-битного ADDR-байта:
// rtl/i2c_burst_writer.v wire [7:0] addr_byte = {slave_addr_i, 1'b0}; // W=0 — только запись
READ-операции на SSD1306 тоже поддерживаются (чтение статуса, чтение GDDRAM), но в нашем проекте не используются - модуль работает строго “вслепую”, только WRITE. Полезный диагностический приём: до полной инициализации послать на шину транзакцию START + ADDR(0x3C, W) + STOP без байтов данных. Если дисплей присутствует и корректно запитан, он ответит ACK на ADDR-байт; если нет - NACK. Этот механизм легко встроить в верхний уровень:
// псевдокод bw_byte_count <= 16'd0; // 0 data-байт bw_start <= 1'b1; // ждём done_o: if (bw_error) begin // нет SSD1306 на шине: не переходим в PH_INIT, // зажигаем LED ошибки end
В текущем ssd1306_ctrl probe-шаг не реализован - мы сразу уходим в PH_INIT. Это осознанный trade-off: в нашем лабораторном применении дисплей всегда подключен, а на NACK первой init-команды мы всё равно выставим err_o. После того как по шине прошёл байт адреса и slave ответил ACK, все последующие байты в этой транзакции попадают в SSD1306. Но чип должен как-то понять: это команда (которую нужно декодировать и исполнить) или данные для GDDRAM (которые нужно записать в видеопамять)? Решение - control-byte, специальный формат первого байта после ADDR. Его разметка:
 Два значащих бита:
- D/C# (Data / Command) - 0 → дальнейший поток интерпретируется как команды; 1 → как данные для GDDRAM.
- Co (Continuation) - 0 → режим «до конца транзакции»; 1 → режим “один следующий байт, потом снова control-byte”.
Пересечение этих двух битов даёт 4 комбинации:
| Co |
D/C# |
hex |
Смысл |
| 0 |
0 |
0x00 |
Stream-commands: все байты до STOP — команды |
| 0 |
1 |
0x40 |
Stream-data: все байты до STOP — пиксели (D[7:0] записываются в GDDRAM по текущему адресу) |
| 1 |
0 |
0x80 |
Single-command: ровно один следующий байт — команда, затем снова control-byte |
| 1 |
1 |
0xC0 |
Single-data: один следующий байт — данные, затем control-byte |
Почему нужно четыре режима, а не два?
- Режимы с Co=0 быстрые, но монотонные: в рамках одной транзакции передаем либо только команды, либо только данные. Смена режима требует STOP + новый START.
- Режимы с Co=1 гибкие, но с накладными расходами: в одной транзакции можно чередовать команды и данные, но перед каждым таким байтом летит “служебный” control-байт - +9 бит I2C (8 data + 1 ACK), т. е. ~10% накладных расходов даже в лучшем случае.
Для нашей задачи (full-frame update + init) идеально подходят только 0x00 и 0x40 - это вот и есть тот самый ключевой архитектурный выигрыш I2C-режима SSD1306. SSD1306. Практика - транзакции init и frame Init-транзакция. Мы отправляем ~30 команд подряд - все они идут одним stream’ом с control-байтом 0x00:
 На I2C это = 1 + (1 + ~30) = ~32 байта-цикла по 9 бит (8 data + ACK) = ~288 бит = ~1.44 мс на 200 кГц. Пренебрежимо малый overhead. Frame-транзакция (наша основная). Отправляем все 1024 байта GDDRAM одним stream-data потоком с control-байтом 0x40:
 Итого 1 + 1 + 1024 = 1026 байт-циклов по 9 бит = 9234 бит = ~46 мс на 200 кГц. Это и определяет нашу частоту обновления ≈ 20 fps в идеале; с учётом рендеринга и latency получается ~8…15 fps - см. оценку в README. Сравнение с построчной записью (которая потребовалась бы, если бы control-byte поддерживал толькоCo=1):
| Способ |
Байтов по I2C |
Время @200 кГц |
fps (max) |
| Один frame-burst (наш) |
1026 |
~46 мс |
~22 |
| По странице (8 burst’ов, по 128 байт) |
8×(2+128)=1040 |
~46.8 мс |
~21 (≈ то же) |
| По одному байту (со сбросом курсора) |
~5000 |
~225 мс |
~4.4 |
| С Co=1 (control каждый байт) |
~2048 |
~92 мс |
~11 |
Вывод: burst с Co=0 - не просто удобная, а единственная разумная стратегия для 20+ fps по I2C на 200 кГц. Когда мы в режиме stream-data (0x40) посылаем байт N+1, куда он попадёт в GDDRAM? Это определяется текущим адресным режимом, который задаётся командой 0x20:
- 0x20, 0x00 - Horizontal Addressing (мы используем именно его): курсор (col, page) наращивается по столбцам, в конце столбца (col=127) переходит на следующую страницу, в конце страницы (page=7) оборачивается обратно в (0, 0). Идеально для “отправил 1024 байта - и весь экран обновился”.
- 0x20, 0x01- Vertical Addressing: (col, page) идёт сначала по страницам, потом переключается на следующий столбец.
- 0x20, 0x02- Page Addressing: курсор перекатывается только по столбцам; переход на следующую страницу не автоматический.
В init-последовательности ssd1306_ctrl есть команды:
0x20, 0x00, // horizontal addressing 0x21, 0x00, 0x7F, // set column range [0, 127] 0x22, 0x00, 0x07, // set page range [0, 7]
Которые совместно говорят: “обновление идёт по всему экрану (128×8), в горизонтальном порядке”. Благодаря этому мы можем начинать каждый frame-burst просто с 0x40 и 1024 байтов - без повторной установки курсора: после предыдущей транзакции курсор сам вернулся в (0, 0). Если случайно оставить адресный режим по умолчанию (0x20, 0x02 - page-mode, исходное значение после POR), то после 128-го байта данных курсор не перейдет на следующую страницу, и все последующие байты будут записываться в ту же первую страницу поверх уже записанных. На экране вместо картинки мы увидим только верхнюю полоску 8 пикселей, а остальное 56 строк черные. Это еще одна типовая “тихая” ошибка новичка - все ACK’и есть, байты прошли, а результат неверный. Поэтому структура наших будущих транзакций должна иметь обозначенный выше вид. Обе транзакции формируются одним и тем же i2c_burst_writer - он не знает про SSD1306 и про control-byte. ssd1306_ctrl просто:
- Задаётslave_addr_i = 0x3C;
- Задаёт byte_count_i = INIT_LEN или 1025;
- В качестве источника данных выдаёт либо ROM (init-поток), либо {0x40, fb[0..1023]} (frame-поток).
Благодаря такой развязке, если завтра понадобится подключить BME280 или EEPROM - вся логика поменяется только на уровне “источника данных” и “состава команд”, а I2C-пайплайн останется неизменным. SSD1306. Организация видеопамяти Внутри SSD1306 картинка хранится в так называемой GDDRAM (Graphic Display Data RAM) - статической SRAM емкостью 128 х 64 бита = 8192 бит = 1024 байта. Каждый бит однозначно соответствует одному физическому пикселю OLED-матрицы (1 - пиксель светится, 0 - нет). С точки зрения FPGA это просто набор из 1024 байт, которые нужно заливать в правильном порядке и с правильной раскладкой битов внутри байта. Ниже разбираем эту раскладку детально. В большинстве обычных TFT / LCD-контроллеров один байт памяти соответствует одному-двум горизонтальным пикселям (1 bpp или 4 bpp). В SSD1306 архитектура принципиально иная: байт располагается вертикально, т. е. один байт описывает столбец из 8 пикселей по вертикали. Это сделано не “ради оригинальности”, а из-за устройства самой OLED-матрицы: SSD1306 внутри содержит 64 сегментных драйвера (по одному на каждую строку) и 128 common-драйверов (по одному на столбец). Обновление выполняется “колонками” - на каждом цикле мультиплексора чип активирует ровно одну строку и одновременно выставляет на сегментные драйверы состояния всех 128 столбцов. GDDRAM “подогнана” под этот режим: внутри неё память адресуется как (column x page), где page - это группа из 8 строк, обновляемых одним циклом внутреннего сдвигового регистра. Отсюда и разбиение на 8 страниц (pages) по 8 строк:
 Размерности:
| Элемент |
Количество |
Пояснение |
| Page |
8 |
= rows / 8 = 64 / 8 |
| Column |
128 |
соответствует одному common-драйверу |
| Pixel |
8192 = 128 × 64 |
вся матрица |
| Byte |
1024 = 8 × 128 |
один байт = 1 столбец в 1 странице |
Каждый байт GDDRAM хранит 8 вертикальных пикселей одного столбца внутри одной страницы. Конвенция - little-endian по вертикали:
byte = 8'b b7 b6 b5 b4 b3 b2 b1 b0 │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ └── top (row = page*8 + 0) │ │ │ │ │ │ └───── row 1 (row = page*8 + 1) │ │ │ │ │ └──────── row 2 (row = page*8 + 2) │ │ │ │ └─────────── row 3 │ │ │ └────────────── row 4 │ │ └───────────────── row 5 │ └──────────────────── row 6 └─────────────────────── bottom (row = page*8 + 7) bit = 0 ⇒ пиксель не горит (чёрный) bit = 1 ⇒ пиксель светится
Конкретные примеры:
| Байт |
Двоичное |
Что зажигается (в пределах одного столбца страницы) |
| 0x00 |
00000000 |
пусто — 8 чёрных пикселей |
| 0xFF |
11111111 |
полный столбец 8 пикселей горит |
| 0x01 |
00000001 |
только верхний пиксель (row = page*8 + 0) |
| 0x80 |
10000000 |
только нижний пиксель (row = page*8 + 7) |
| 0x0F |
00001111 |
верхние 4 пикселя горят, нижние 4 — нет |
| 0xF0 |
11110000 |
верхние 4 тёмные, нижние 4 горят |
| 0x3C |
00111100 |
«серединка» столбца — rows 2..5 горят |
| 0xAA |
10101010 |
«в шахматку» по вертикали |
Развёрнутый пример: байт 8'b00001111 = 0x0F, записанный в (page=2, col=10), зажжёт пиксели по строкам 16-19 (это биты 0...3 = page x 8 + 0..3), а строки 20-23 останутся темными. Ещё пример - горизонтальная линия в ровно 1 пиксель высотой на всю ширину экрана по строке 30:
- строка 30 = page 3, sub-row 6 (3 x 8 + 6 = 30);
- значит нужно записать bit 6 = 1, все остальные биты = 0 ⇒ байт 0x40;
- в 128 колонках страницы 3 подряд кладём 0x40 × 128.
Горизонтальная линия в страницу другого уровня (скажем строка 31, page=3, sub-row 7) - это всё ещё page 3, но байт 0x80. А горизонтальная линия в строке 32 - уже page 4, байт 0x01. Для нашего scene-renderer’а очень удобны три формулы: из (x, y) в (page, col, bit) для записи, и обратно - для дебага.
Дано: x ∈ [0..127] — номер столбца (col) y ∈ [0..63] — номер строки (row) fb_addr — линейный адрес байта в буфере 0..1023 Прямое преобразование (pixel → byte + bit): col = x page = y >> 3 // y / 8 sub_row = y & 3'b111 // y % 8 fb_addr = (page << 7) | col // page * 128 + col bit_mask = 1 << sub_row Обратное (byte+bit → pixel): col = fb_addr[6:0] page = fb_addr[9:7] y = (page << 3) | sub_row x = col
В Verilog-коде scene_renderer именно эти формулы и используются ниже. Заметьте, что обе операции целочисленно-степенные по 2: деление на 8 = сдвиг на 3, mod 8 = AND с 3'b111, page * 128 = сдвиг на 7. Никаких умножений и делителей - значит, всё синтезируется в простую комбинаторику без LUT-алгоритмов. Поскольку байт кодирует сразу 8 вертикальных пикселей, операция “зажечь один произвольный пиксель (x, y)” не может быть просто записью байта - это перезатрёт другие 7 пикселей в том же столбце страницы. Нужен классический Read-Modify-Write:
// Псевдокод uint8_t b = fb[fb_addr]; // read b |= (1 << sub_row); // set target pixel // b &= ~(1 << sub_row); // или clear, если "стираем" fb[fb_addr] = b; // write
В Verilog на BRAM это занимает три такта:
- RD: поставить raddr = fb_addr, защёлкнуть q через такт.
- MOD: скомбинировать q | bit_mask (или с AND).
- WR: записать fb[waddr] = modified.
В scene_renderer.v этот pipeline реализован состояниями S_EDGE_RD → S_EDGE_WR (см. FSM рендеринга линии Bresenham в соответствующем разделе). Он работает только для “цветных” пикселей; для полного сброса всего экрана (заливка 0x00) можно писать байтами без RMW - это делается быстрее в S_CLEAR. Важное замечание: именно из-за RMW большинство шрифтов в SSD1306-проектах специально делают высотой 8 пикселей и выровнены по странице - тогда буква занимает ровно 1 байт по вертикали и рисуется одним прямым write без RMW. Мы в проекте применяем эту же оптимизацию для текста: см. раздел о tex-рендере. В scene_renderer.v используется внутренний фреймбуфер как dual-port BRAM:
reg [7:0] fb [0:1023]; // Порт записи (из рендера) always @(posedge clk_i) begin if (fb_we) fb[fb_waddr] <= fb_wdata; // Порт чтения для I2C-потока: rdata_o <= fb[raddr_i]; // Порт чтения для RMW внутри рендера: fb_rdata_rmw <= fb[fb_raddr_rmw]; end
Размер BRAM - 1024 × 8 бит = 8 Кбит, ровно один блок M9K (9 Кбит) Cyclone IV, настроенный как 1024 х 8. Никаких “урезаний” или паддингов. Линейный адрес fb_addr = page х 128 + col совпадает с тем самым порядком, в котором SSD1306 принимает пиксели в горизонтальном режиме. Это даёт нулевую ре-упаковку: байт с индексом idx из фреймбуфера можно сразу отправлять в I2C как idx-й data-байт frame-транзакции.
// В ssd1306_ctrl.v, вход data-источника burst-writer’а: wire [9:0] pix_idx = src_idx[9:0]; // idx ∈ 0..1023 scene_rdata <= fb[pix_idx]; // ровно то, что нужно послать
Когда отлаживаешь рендер без дисплея (например, в симуляторе или на виртуальной модели), удобно иметь следующий мысленный чек-лист:
- Весь экран горит? → Все 1024 байта = 0xFF;
- Чёрный экран? → Все 1024 байта = 0x00;
- Один пиксель (0, 0) горит? → fb[0] = 0x01, всё остальное 0;
- Один пиксель (0, 63) горит? → fb[128 * 7 + 0] = 0x80, остальное 0. Это и есть левый-нижний угол.
- Один пиксель (127, 63) горит? → fb[128 * 7 + 127] = 0x80, т. е. fb[1023] = 0x80;
- Горизонтальная линия y=0? → fb[0..127] = 0x01, остальные 896 байт = 0x00;
- Вертикальная линия x=0? → fb[0], fb[128], fb[256], ..., fb[896] = 0xFF; остальные = 0x00 (8 байтов, по одному на каждую страницу).
Эти простые тесты позволяют, не прикасаясь к дисплею, убедиться, что рендер корректно формирует фреймбуфер и правильно ориентирует координаты. SSD1306. Адресация памяти: горизонтальный режим Выше мы разобрали, как устроена GDDRAM - 1024 байта, в которых каждый байт - это вертикальный столбец 8 пикселей внутри одной из 8 страниц. Теперь нужно понять как SSD1306 получает эти байты по I2C: после control-byte 0x40 чип начинает записывать входящие data-байты в GDDRAM, но по какому адресу - определяется текущим режимом адресации и двумя “окнами” (col range, page range). Неправильная настройка режима - одна из самых частых причин визуальных артефактов (“картинка правильная, но съехала на четверть экрана”, “нижняя половина экрана - мусор”, “видно только верхнюю строчку” и т. п.). SSD1306 хранит один указатель на текущую ячейку GDDRAM - условная пара (col_ptr, page_ptr), где:
- col_ptr ∈ [0..127] - номер столбца;
- page_ptr ∈ [0..7] - номер страницы.
Каждый принятый data-байт записывается в GDDRAM[page_ptr, col_ptr], после чего курсор автоматически инкрементируется по правилам, зависящим от текущего режима адресации (см. ниже). После автоинкремента, если курсор вышел за пределы “окна” адресации, он либо “заворачивается” внутрь окна (wrap-around), либо переходит по определённому правилу к следующему участку. Дополнительно курсор ограничен окном - прямоугольной областью, в которой ему разрешено двигаться:
- column range: [col_start, col_end], задаётся командой 0x21, col_start, col_end;
- page range: [page_start, page_end], задаётся командой 0x22,page_start, page_end.
По умолчанию (после POR) окна охватывают весь экран: col ∈ [0, 127], page ∈ [0, 7]. Но эти значения можно сузить и работать только с частью GDDRAM - например, обновлять только центральную полосу экрана без замены остального. Команды настройки окон и режима - это всегда Co=0, D/C#=0 - байты («команды»), т. е. идут по control-byte 0x00. В нашем проекте они собраны в init_rom и отправляются один раз за init. SSD1306 поддерживает три режима, переключаемых командой 0x20 mode:
Command sequence: 0x20 0xMM ───── ──── code mode: 00 = Horizontal 01 = Vertical 02 = Page (default после POR) 03 = invalid (игнорируется)
Page addressing (0x20, 0x02). Самый простой режим, поведение по умолчанию после power-on. Курсор живёт внутри одной страницы; page_ptr никогда не меняется автоматически - его нужно задавать вручную командой “Set Page Start Address” (0xB0...0xB7). col_ptr инкрементируется после каждого записанного байта и, достигнув col_end, заворачивается в col_start.
Поведение: col_ptr++ каждый байт col_ptr > col_end → col_ptr = col_start (остаётся та же page) page_ptr не меняется автоматически
Этот режим создавался для “живых” приложений, где дисплей обновляется не целиком, а точечно: нарисовал строку - перешёл на другую страницу и нарисовал ещё одну. Но для нашей задачи “слить весь frame одним burst’ом” он неудобен: после первых 128 байт курсор заворачивается обратно в (col=0, page=0) и начинает затирать первую страницу. Видимый эффект - только верхняя полоска 8 пикселей показывает ожидаемое, остальные 56 строк - либо темные, либо с мусором от прошлых кадров. Horizontal addressing (0x20, 0x00) - наш выбор. “Пишет как текст на странице”: сначала заполняется целая страница по столбцам, потом курсор переходит на следующую страницу.
Поведение: col_ptr++ каждый байт col_ptr > col_end → col_ptr = col_start, page_ptr++ page_ptr > page_end → page_ptr = page_start (wrap через весь окно)
Именно это нам и нужно для одного frame-burst’а на весь экран. При окне col ∈ [0, 127], page ∈ [0, 7] один проход по всему окну - ровно 128 × 8 = 1024 байта, после чего курсор возвращается в (0, 0) и готов принимать следующий frame без повторной настройки. Визуально траектория курсора:
 Это совпадает с тем, как мы обычно читаем текст: слева-направо, сверху-вниз. Отсюда и название “horizontal”. Vertical addressing (0x20, 0x01). Зеркало horizontal: сначала заполняются все страницы одного столбца (8 байт на столбец), потом курсор переходит на следующий столбец.
Поведение: page_ptr++ каждый байт page_ptr > page_end → page_ptr = page_start, col_ptr++ col_ptr > col_end → col_ptr = col_start
На практике полезен для специфических случаев, когда “рисовать” удобнее столбцами - например, спектрограмма/бар-графы, у которых столбец = один “датчик”. Для нашего проекта не используется. Можно задаться вопросом. Почему horizontal + полное окно - идеальный выбор. Ключевая мысль: при horizontal + full-screen window линейный адрес FPGA-фреймбуфера напрямую совпадает с адресом внутри GDDRAM. Никаких пересчётов и вкраплений команд не нужно:
fb_idx ∈ [0..1023] — линейный индекс в BRAM page = fb_idx[9:7] — старшие 3 бита = номер страницы col = fb_idx[6:0] — младшие 7 бит = номер столбца fb_idx ≡ page * 128 + col = тот же порядок, в котором SSD1306 запишет байты в GDDRAM в horizontal-mode
Это свойство (линейный адрес = физический адрес в GDDRAM) и называется zero-copy mapping: буфер в BRAM не нужно переупаковывать перед отправкой - просто гонишь по I2C байт за байтом с возрастающим fb_idx, и чип сам размещает их правильно. В коде:
// В ssd1306_ctrl.v wire [9:0] pix_idx = src_idx[9:0]; // 0..1023 scene_rdata <= fb[pix_idx]; // читаем из BRAM // burst-writer отправляет scene_rdata как очередной data-байт
Если бы мы использовали vertical или page mode, пришлось бы либо:
- хранить фреймбуфер в “извращённой” раскладке (с точки зрения рендера) - это усложнит логику scene_renderer;
- делать сложный обход при отправке - усложнит ctrl и burst-writer.
Ни тот, ни другой путь не оправдан. Теперь про настройку окон (column/page range). Даже в horizontal-режиме всегда полезно явно задать окно:
; Set column range [0..127] 0x21 0x00 ; col_start 0x7F ; col_end (127) ; Set page range [0..7] 0x22 0x00 ; page_start 0x07 ; page_end
Это страхует от ситуации, когда предыдущий код (bootloader, тестовая прошивка, предыдущая версия init) оставил окно суженным - например, col ∈ [10, 117], и наши 1024 байта начнут размазываться в окно шириной 108 × 8 вместо 128 × 8, что визуально даст “смещение и повторение картинки”. В init_rom ssd1306_ctrl.v эти команды идут в самом начале, сразу после установки режима адресации:
0x20, 0x00, // horizontal addressing mode 0x21, 0x00, 0x7F, // column start/end: 0..127 0x22, 0x00, 0x07, // page start/end: 0..7
Что происходит между двумя frame-burst’ами? После этой тройки курсор автоматически устанавливается в (col=0, page=0) - мы можем сразу начинать первый frame-burst, ничего больше не трогая. Важный побочный эффект horizontal + wrap-around: после завершения frame-burst’а (отправили 1024 байта, послали STOP) курсор находится не в конце окна, а снова в начале (col=0, page=0). Это случилось автоматически при прохождении (page=7, col=127) → wrap → (page=0, col=0). Значит, между двумя frame-burst’ами не нужно ничего настраивать: ни menu-команды, ни сбрасывать курсор, ни заново посылать 0x21/0x22. Достаточно:
... (рендер следующего кадра в BRAM) ... START → 0x78 → 0x40 → pixel[0] → ... → pixel[1023] → STOP
И так каждые ~46 мс. Экономия и на I2C-трафике, и на логике контроллера. Если случайно между двумя frame-burst’ами отправили какую-то команду (например, по ошибке control-byte 0x00 вместо 0x40 в начале транзакции), то первые data-байты будут интерпретированы как команды - и это может не только испортить кадр, но и сбить настройки дисплея: контрастность, адресный режим, окно. Симптомы в этом случае обычно драматические: экран мигает/гаснет/показывает “нарезку из случайных фрагментов”. Верный путь отладки - поставить лог-анализатор на шину и проверить control-byte каждой транзакции. Проговорю немного про адресный режим и отладку. Существует несколько типовых “симптомов → причин”, связанных с адресацией.
| Симптом |
Вероятная причина |
| Всё, что нарисовано, видно только в верхних 8 строках; нижние 56 — либо тёмные, либо мусор |
осталось page addressing (режим по умолчанию), курсор заворачивается внутри page 0 |
| Картинка сдвинута вправо/влево на N пикселей и «заворачивается» на противоположной стороне |
column range задан не [0, 127], а меньше; либо не совпадает col_start с нашим fb_idx=0 |
| Нижние несколько страниц всегда тёмные |
page range задан короче 8 (например, [0, 5]); все байты сверх 128×6 = 768 пропадают |
| Картинка «стоит боком» (то, что ожидали увидеть горизонтально, пошло вертикально) |
включён vertical addressing (0x20, 0x01) |
| Перестал работать автовозврат курсора в (0,0) после frame’а |
в init забыли 0x20, 0x00, либо где-то перезаписали режим командой-«сюрпризом» |
| Картинка перевёрнута или отзеркалена |
это не проблема адресного режима, а команд 0xA1 (segment remap) и 0xC8 (COM scan dir) — см. раздел 2.5 |
Таким образом, корректное сочетание 0x20, 0x00 + 0x21, 0, 127 + 0x22, 0, 7 - это не декорация, а необходимое условие для того, чтобы работал наш zero-copy frame-burst. Последовательность инициализации После подачи питания и внутреннего reset’а (RC-цепочка на пине /RES) SSD1306 оказывается в неопределённо-безопасном, но визуально неработоспособном состоянии: панель выключена, charge pump выключен, регистры настроек - в неизвестных значениях (часть сброшена в default, часть - нет, в зависимости от ревизии чипа). Чтобы получить предсказуемую картинку 128×64 в нужной ориентации и с нужной яркостью, обязательно нужно единожды выполнить последовательность команд - будем называть её init-sequence. Это 17 команд, некоторые без аргументов, некоторые с одним, одна (0x21) и ещё одна (0x22) - с двумя. Вместе с обязательным control-byte 0x00, который обязан идти первым data-байтом транзакции, они складываются в 32-байтовый поток. Это то самое число, которое зашито как INIT_LEN = 6'd32 в ssd1306_ctrl.v и лежит в init_rom по индексам 0..31. Инициализация строится по принципу “сначала выключить панель” - “всё настроить” - “включить”. Это важно по двум причинам:
- Многие регистры при изменении “на лету” вызывают визуальные артефакты: моргания, полосы, скачки яркости. Если экран выключен - артефакты никому не видны.
- Команды “MUX Ratio”, “COM Pins”, “Display Clock” при ошибочных аргументах при включенной панели могут ввести контроллер строк в состояние, когда ток через отдельные OLED-ячейки превышает безопасный, и пиксели могут «выгорать». Это редкий, но задокументированный в даташите риск.
Поэтому первая команда init’а - всегда 0xAE (Display OFF), а последняя - 0xAF (Display ON). Всё между ними можно перестраивать, ничего не опасаясь. Все 18 команд инициализации удобно разбить на 5 логических групп по назначению. Ниже - каждая группа с пояснениями. Группа А. Панель в безопасное состояние
| Команда |
Аргумент |
Назначение |
| 0xAE |
— |
Display OFF — отключает драйверы строк/столбцов. Все следующие команды можно менять без артефактов и без риска для матрицы. |
Группа B. Параметры синхронизации и размерности Эти команды определяют, как чип сканирует панель - с какой частотой, сколько строк физически есть, с какими сдвигами и пр.
| Команда |
Аргумент |
Назначение |
| 0xD5 |
0x80 |
Display Clock Divide / F_osc — задаёт внутреннюю частоту тактирования драйвера дисплея. 0x80 = divider=1, частота осциллятора - номинальная (~540 кГц). На большее не стоит - падает время стабилизации пикселя, на меньшее - начинает виднеться мерцание |
| 0xA8 |
0x3F |
Multiplex Ratio — количество строк, которые реально подключены к COM-драйверам. 0x3F = 63 = 64 строки (адресуемые от 0 до 63). Для 128×32 модулей было бы 0x1F = 31 |
| 0xD3 |
0x00 |
Display Offset — сдвиг первой строки по вертикали. 0x00 = без сдвига. Используется, если панель физически смонтирована со смещением (в наших модулях — нет) |
| 0x40 |
— |
Display Start Line = 0. Определяет, какая строка GDDRAM соответствует верхней строке экрана. Изменением этого регистра можно делать «программный вертикальный скроллинг», нам это не нужно |
| 0xDA |
0x12 |
COM Pins Hardware Config — указывает чипу, как физически разведены COM-линии. Для 128×64-панели с типовой «альтернативной» разводкой значение = 0x12; для 128×32 было бы 0x02. Ошибка здесь — главная причина артефакта «каждая вторая строка — чёрная» |
Группа C. Питание OLED - charge pump Эта команда - та самая “невидимая бомба замедленного действия”, которую постоянно забывают разработчики-новички. Все шаги init прошли, NACK’ов нет, 0xAF отправлено - а экран чёрный. Проверить первым делом, что 0x8D, 0x14 реально есть в потоке, и что оно идёт до 0xAF.
| Команда |
Аргумент |
Назначение |
| 0x8D |
0x14 |
Charge Pump Enable. 0x14 — включить pump; 0x10 — выключить. Без этой команды внутреннее высокое напряжение (~7.5 В) не поднимается, и при любой команде включения панели экран останется тёмным. |
Группа D. Адресация GDDRAM Это то, что мы подробно обсудили в одном из разделов выше. Здесь мы фиксируем режим адресации в horizontal и задаём окно на весь экран.
| Команда |
Аргумент |
Назначение |
| 0x20 |
0x00 |
Memory Addressing Mode = horizontal (см. 2.4.2) |
| 0x21 |
0x00, 0x7F |
Column Address range = [0, 127] — весь экран по горизонтали |
| 0x22 |
0x00, 0x07 |
Page Address range = [0, 7] — все 8 страниц |
После выполнения этих трёх команд внутренний курсор устанавливается в (col=0, page=0), и мы можем сразу начинать слать frame-burst. Группа E. Ориентация и внешний вид Эти команды определяют геометрию изображения относительно физических координат панели, яркость и качество свечения.
| Команда |
Аргумент |
Назначение |
| 0xA1 |
— |
Segment Remap: col 127 ↔ SEG 0. Зеркалит картинку по горизонтали. Нужна, потому что на нашем модуле COG-чип развёрнут «шлейфом вниз», и без этого remap’а текст выглядел бы зеркальным |
| 0xC8 |
— |
COM Scan Direction: COM[N-1] → COM[0]. Зеркалит по вертикали. По той же физической причине, что и 0xA1 |
| 0x81 |
0xCF |
Contrast Control = 0xCF = 207 / 255 ≈ 81%. Прямая регулировка тока через OLED-ячейки. Выше — ярче и чуть быстрее выгорание, ниже — тусклее и бережнее |
| 0xD9 |
0xF1 |
Pre-charge Period: phase1 = 1, phase2 = 15. Определяет, сколько тактов DCLK подаются «подзарядные» импульсы перед основным свечением. 0xF1 — заводская рекомендация Solomon для charge pump’а |
| 0xDB |
0x40 |
VCOMH Deselect Level ≈ 0.77 × VCC. Уровень на невыбранных строках, влияет на контраст и время релаксации |
| 0xA4 |
— |
Display From RAM (не «все пиксели принудительно вкл.»). Парная к 0xA5 (диагностическая «все пиксели вкл.», используется для теста панели) |
| 0xA6 |
— |
Normal Display (не инвертировать). Парная к 0xA7 (инверсия 0↔1) |
Группа F. Включение
| Команда |
Аргумент |
Назначение |
| 0xAF |
— |
Display ON — включает драйверы COM/SEG и даёт charge pump’у начать реальную работу. Экран «оживает» именно на этой команде |
Итоговая таблица (в порядке выполнения) Точно в этом порядке байты зашиты в init_rom вssd1306_ctrl.v (индексы src_idx = 0..31). Первый байт - не команда SSD1306, а control-byte, признак “все следующие байты - команды”:
| idx |
Байт |
Команда / аргумент |
Группа |
| 0 |
0x00 |
control-byte: stream-commands (Co=0, D/C#=0) |
— |
| 1 |
0xAE |
Display OFF |
A |
| 2 |
0xD5 |
Set Display Clock Div |
B |
| 3 |
0x80 |
├─ arg: div = 1 |
B |
| 4 |
0xA8 |
Set Multiplex Ratio |
B |
| 5 |
0x3F |
├─ arg: MUX = 64 |
B |
| 6 |
0xD3 |
Set Display Offset |
B |
| 7 |
0x00 |
├─ arg: offset = 0 |
B |
| 8 |
0x40 |
Set Display Start Line = 0 |
B |
| 9 |
0x8D |
Charge Pump setting |
C |
| 10 |
0x14 |
├─ arg: enable pump |
C |
| 11 |
0x20 |
Memory Addressing Mode |
D |
| 12 |
0x00 |
├─ arg: horizontal |
D |
| 13 |
0xA1 |
Segment Remap (mirror X) |
E |
| 14 |
0xC8 |
COM Scan Direction (mirror Y) |
E |
| 15 |
0xDA |
COM Pins Config |
B |
| 16 |
0x12 |
├─ arg: 128×64 alt, no remap |
B |
| 17 |
0x81 |
Contrast Control |
E |
| 18 |
0xCF |
├─ arg: contrast = 207 |
E |
| 19 |
0xD9 |
Pre-charge Period |
E |
| 20 |
0xF1 |
├─ arg: phase1=1, phase2=15 |
E |
| 21 |
0xDB |
VCOMH Deselect Level |
E |
| 22 |
0x40 |
├─ arg: ~0.77·VCC |
E |
| 23 |
0xA4 |
Display From RAM |
E |
| 24 |
0xA6 |
Normal Display |
E |
| 25 |
0x21 |
Set Column Address |
D |
| 26 |
0x00 |
├─ arg: col start = 0 |
D |
| 27 |
0x7F |
├─ arg: col end = 127 |
D |
| 28 |
0x22 |
Set Page Address |
D |
| 29 |
0x00 |
├─ arg: page start = 0 |
D |
| 30 |
0x07 |
├─ arg: page end = 7 |
D |
| 31 |
0xAF |
Display ON |
F |
Итого 32 байта = INIT_LEN. Control-byte 0x00 (idx=0) не отдельный артефакт сверху ROM’а, а его физически первый элемент: это упрощает data-source (burst-writer запрашивает 32 байта, ROM отдаёт 32 байта, ничего мультиплексировать не нужно). Как это физически передаётся по I2C Вся init-последовательность идёт одной I2C-транзакцией в режиме stream-commands. Первый data-байт в этой транзакции - 0x00 (control-byte, Co=0, D/C#=0 → “все последующие байты до STOP - команды”), остальные - собственно команды SSD1306:
START → 0x78 → init_rom[0..31] → STOP (S) ADDR │ │ │ └── init_rom[1..31]: 0xAE, 0xD5, ..., 0xAF └── init_rom[0] = 0x00 (control-byte)
Общая длина на I2C = 1 + 32 = 33 байта-цикла × 9 бит (8 data + ACK) = 297 бит ≈ 1.49 мс на 200 кГц. Пренебрежимо малый расход шины. В ssd1306_ctrl.v это управляется так:
// Фаза PH_INIT: bw_slave_addr <= SSD_ADDR; // 0x3C bw_byte_count <= {10'd0, INIT_LEN}; // 32 bw_start <= 1'b1; phase <= PH_INITW;
Источник данных - функция init_rom(src_idx[5:0]) в самом ssd1306_ctrl.v (большое case-выражение). Control-byte 0x00 лежит внутри ROM по индексу 0, поэтому никаких дополнительных мультиплексоров не требуется - burst-writer просто запрашивает src_idx = 0..31, и получает init_rom[src_idx] как data-байт:
wire [7:0] init_byte = init_rom(src_idx[5:0]); wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte;
В отличие от frame-транзакции, где control-byte 0x40 приходится подставлять отдельным мукс-условием (src_idx == 0 ? 0x40 : fb[idx-1]), для init никакой разницы между control-byte и обычной командой нет - оба идут по одному и тому же пути через init_rom. Так же, как в разделе выше приведу таблицу “симптом → вероятная ошибка init”:
| Симптом |
Вероятная причина |
| Экран полностью тёмный, картинка не видна |
забыта команда 0x8D, 0x14 (charge pump) или 0xAF (display ON) |
| Экран полностью светится (все пиксели) |
вместо 0xA4 ушла команда 0xA5 (force all ON, тест-режим) |
| Экран инвертирован (чёрное по белому) |
вместо 0xA6 ушла 0xA7 |
| Картинка отзеркалена по X |
забыта 0xA1 (или послана 0xA0) |
| Картинка отзеркалена по Y |
забыта 0xC8 (или послана 0xC0) |
| Каждая вторая строка чёрная, картинка «растянута» вдвое |
неверный аргумент 0xDA: нужно 0x12 для 128×64, а не 0x02 |
| Верхняя половина «съехала» в нижнюю или наоборот |
неверный MUX Ratio (0xA8) — не соответствует физическому количеству строк |
| Экран мерцает, видны полосы |
неверный Display Clock Divide (0xD5) или Pre-charge Period (0xD9) |
| Нет ACK уже на первый байт после адреса |
дисплей не запитан / не подключен / wrong адрес (см. 2.2.2) |
| Первые несколько команд идут, а потом NACK |
длинная транзакция и slave «захлебнулся» (редко для 200 кГц; скорее всего — нестабильное питание модуля) |
| Картинка правильная, но не мигает / не обновляется |
init прошёл, но нет frame-burst’ов: смотреть в раздел 2.4 и логи ssd1306_ctrl |
Этот список - по сути, чек-лист пост-мортем анализа, когда что-то пошло не так. Каждый пункт - однозначная корреляция симптом ↔ конкретный байт init_rom’а. Важно осознавать: init выполняется один раз после power-on. В ssd1306_ctrl.v переход из PH_INITW (ожидание done от burst-writer’а после init-транзакции) идёт в PH_RENDER (начинается рендер первого кадра), и обратно в PH_INIT мы никогда не возвращаемся без reset’а чипа. Это естественно: все init-настройки хранятся в статических регистрах SSD1306 и не сбрасываются, пока не отключится питание или не придёт команда на /RES. Перепосылать их каждый кадр было бы бесполезной тратой I2C-трафика (и ~1.5 мс на 200 кГц, т. е. 3% времени на кадр при 46 мс на один frame-burst). Если же по какой-то причине экран “сбился” (например, из-за сильной ЭМИ-наводки по SDA/SCL или программной ошибки, отправившей “команду” вместо данных), единственный надёжный способ восстановиться - аппаратный reset: либо дёрнуть физический RES-пин, либо отключить и снова подать питание. В лабораторном стенде это делается нажатием кнопки reset на AX301. Реализуем burst-передачу Перед тем как обсуждать burst-передачу, нужно чётко понять, какой уровень абстракции предоставляет нам уже готовое ядро rtl/i2c_master_core.v, которое мы успешно реализовали в прошлых статьях. Это определит, что придётся построить поверх него (i2c_burst_writer) и почему именно так. Ядро реализует bit/byte-level мастер-контроллер I2C. Оно берёт на себя всю низкоуровневую работу:
- формирование SCL-клока с настраиваемой частотой (через ena_i - импульс “каждую четверть периода SCL”, генерируемый внешним prescaler’ом);
- формирование START/STOP/RESTART-условий с правильными setup/hold временами;
- подача и прием бит из сдвигового регистра tx_shift_r / rx_shift_r;
- детекция ACK/NACK от slave’а (сэмпл SDA на 9-м бит-слоте);
- детекция clock stretching (ядро ждёт, пока SCL физически не отпустится в high, даже если внутренний prescaler уже хочет продолжать);
- детекция arbitration lost (сравнение “что мы выставили на SDA” с “что реально на шине” в момент, когда мы ожидали 1);
- tri-state управление SDA/SCL (sda_oen_o, scl_oen_o в open-drain-семантике);
- индикация busy_o (шина занята - был START без ещё STOP).
Всё, что выше уровня “одной атомарной единицы”, ядро не делает. Оно не знает:
- последовательности из нескольких байт - каждый байт запрашивается отдельной командой CMD_WRITE;
- как соотносятся адрес и данные - для ядра оба просто “байты”;
- про control-byte, про GDDRAM, про “burst” - эти понятия живут на более высоком уровне;
- про retries при NACK, про таймауты при clock-stretching - решения принимает верхний уровень.
Такое разделение называется separation of concerns: ядро - это “аппаратный контроллер шины”, а всё, что выше - “протокол устройства”. Мы можем переиспользовать одно и то же ядро для SSD1306, EEPROM, BME280, без единой строчки правок в нём. Интерфейс ядра Полный порт-лист i2c_master_core (из rtl/i2c_master_core.v):
// Clock & reset input wire clk_i; input wire rstn_i; input wire ena_i; // 1-такт импульс, каждую четверть SCL // Command interface input wire cmd_valid_i; // level, поднят пока команда не принята input wire [2:0] cmd_i; // код команды (см. ниже) input wire [7:0] din_i; // TX data при WRITE output reg [7:0] dout_o; // RX data после READ output reg rx_ack_o; // ACK от slave (0=ACK, 1=NACK) output reg ready_o; // 1 — ядро готово принять команду // Status output reg arb_lost_o; // sticky: потеря арбитража input wire arb_lost_clear_i; // импульс сброса arb_lost output reg busy_o; // шина занята (START без STOP) // Пины шины I2C (open-drain) input wire scl_i; output reg scl_oen_o; input wire sda_i; output reg sda_oen_o;
Выделим три группы сигналов:
- ena_i + prescaler - задаёт частоту SCL. На верхнем уровне (ssd1306_test_top.v) стоит простой счётчик-делитель, который каждую четверть периода SCL выдаёт 1-тактовый импульс. Ядро не использует никакого собственного clock-enable, поэтому его можно тактировать и 50 МГц, и 500 кГц - реальная частота SCL определяется ena_i. Для нашего проекта период ena_i = 1/800 кГц = 1.25 мкс → частота SCL = 800/4 = 200 кГц.
- Command interface (cmd_valid_i/cmd_i/din_i + ready_o/rx_ack_o/dout_o) - то, через что мы дёргаем ядро снаружи. Ровно эти сигналы и проходят через i2c_burst_writer как прокси.
- Status + pads - наружу: индикаторы и физические пины. arb_lost_o/busy_o используются и top-level’ом (для индикации), и burst-writer’ом (для реакции на ошибки); sda_oen_o/scl_oen_o идут прямо на tri-state-буферы FPGA.
Ядро принимает 6 кодов команд на шине cmd_i[2:0]. Полная сводка:
| Код |
Мнемоника |
Что делает ядро (по шине I2C) |
din_i используется? |
rx_ack_o валиден после? |
| 0 |
CMD_NOP |
ничего (встроенный no-op) |
нет |
— |
| 1 |
CMD_START |
выдаёт START-условие (SDA↓ при SCL=1), ставит busy_o=1 |
нет |
— |
| 2 |
CMD_WRITE |
сдвигает 8 бит из din_i на SDA, на 9-м слоте сэмплирует ACK |
да |
да (0=ACK, 1=NACK) |
| 3 |
CMD_READ |
сдвигает 8 бит из SDA в dout_o, на 9-м слоте выдаёт ACK/NACK согласно din_i[0] |
нет (только din_i[0] как ACK-контроль) |
— |
| 4 |
CMD_STOP |
выдаёт STOP-условие (SDA↑ при SCL=1), снимает busy_o |
нет |
— |
| 5 |
CMD_RESTART |
выдаёт repeated START (без STOP) — переход чтение↔запись в одной транзакции |
нет |
— |
Важно - команды атомарные: один CMD_WRITE = ровно один байт по шине, один CMD_START = ровно одно START-условие. Ядро не умеет, скажем, “записать 1024 байта одной командой”. Чтобы записать N байт, нужно подать N + 3 команд: START + WRITE(addr) + WRITE(data) × N + STOP. Именно из этой атомарности и вырастает необходимость в burst-writer’е. Для нашего проекта используются только команды 1, 2, 4 (START / WRITE / STOP). CMD_READ, CMD_RESTART, CMD_NOP остаются “про запас” (для EEPROM, для диагностики и т. д.) - это запас гибкости, который позволит позже переиспользовать ту же архитектуру для других устройств без переделки ядра. Обмен между ядром и внешним миром идёт по двухфазному рукопожатию:
Ядро: ─── ready=1 ────────── ready=0 ──────── ready=1 ────── ↑ ↑ Ктл.: ────── cmd_valid=1 ──── cmd_valid=0 ──────────────── ↑ ядро принимает команду (в этот такт cmd_i, din_i должны быть валидны)
Правила:
- Когда ядро свободно, оно держит ready_o = 1.
- Внешний контроллер (burst-writer) поднимает cmd_valid_i = 1 только если видит ready_o = 1. Одновременно выставляет cmd_i и (если команда - WRITE) din_i.
- Ядро защёлкивает команду, опускает ready_o = 0, начинает выполнение. Процесс длится от десятков до сотен системных тактов в зависимости от команды и clock-stretching’а.
- По завершении ядро снова поднимает ready_o = 1. В этом же такте rx_ack_o / dout_o валидны (для WRITE/READ соответственно).
- Внешний контроллер может либо сразу подать следующую команду (удерживая cmd_valid_i = 1 без опускания), либо снять cmd_valid_i и подождать.
Типичные длительности:
- CMD_START: ~2 фазы SCL = ~2 × 1.25 мкс = 2.5 мкс ≈ 125 системных тактов @ 50 МГц;
- CMD_WRITE / CMD_READ: 9 бит-слотов × 4 фазы = ~36 фаз ≈ 45 мкс ≈ 2250 тактов;
- CMD_STOP: ~2 фазы ≈ 125 тактов.
Значит, полный «цикл одного байта» = WRITE-команда ≈ 45 мкс - это и есть нижняя граница времени передачи 1 байта по I2C на 200 кГц. Можно было бы встроить в само ядро счётчик байтов и поток-источник, и тем самым “проглотить” функциональность burst-writer’а. Мы этого не делаем по трём причинам:
- Переиспользуемость. Не все устройства хотят burst: у EEPROM Page Write - строго 32 байта на page (далее нужен новый ADDR-фрейм). BME280 - write 1 регистр, read 6 байт (smart retry). Если вложить “burst на N байт” внутрь ядра, под разные устройства придётся параметризовать, добавлять режимы и т. д. - сложность лавинообразно растёт.
- Тестируемость. С ядром как "1 команда = 1 atom" можно гонять микро-тесты по одной команде и покрывать всю матрицу ACK/NACK/arb-lost. С burst-ядром тест-векторы исчисляются миллионами комбинаций.
- Сложность FSM. Разделение “bit/byte-FSM” и “burst-FSM” даёт два маленьких автомата (~100 LE каждый) вместо одного большого (~500 LE) с большим радиусом разработки. Это прямо влияет на timing closure на Cyclone IV.
Этот выбор - классический пример принципа Single Responsibility из программирования, перенесенного в RTL. Интерфейс burst-writer’а Теперь сосредоточимся на проработке интерфейса для будущего burst-интерфейса:
 Модуль параметризуется единственным параметром:
| Параметр |
Значение |
Назначение |
| CNT_W |
16 по умолчанию |
ширина счётчика byte_count_i; 16 бит ⇒ максимум 65 535 data-байт в одной транзакции |
Теперь рассмотрим один за другим сигналы модуля. Служебные сигналы clk_i - системный тактовый сигнал (50 МГц в нашем проекте). Все регистры модуля - positive-edge. Никаких отдельных clock-domain’ов внутри burst-writer’а нет: он работает в той же частоте, что и i2c_master_core, поэтому между ними нет ни CDC-проблем, ни FIFO-буферов. rstn_i - асинхронный active-low reset. Устанавливает FSM в S_IDLE, счётчики в 0, cmd_valid_o = 0, done_o = 0, error_o = 0. Синхронизация reset’а - задача top-level (в нашем проекте мы полагаемся на то, что кнопка сброса N13 проходит через встроенный в Cyclone IV debouncer + power-on reset контроллер и стабилизируется задолго до первой активности клока). Сторона управления (control-side) start_i - импульс запуска транзакции. Ожидается как один такт 1, но модуль устойчив к удлинённому импульсу: захват slave_addr_i и byte_count_i происходит в единственном переходе из S_IDLE, повторные высокие уровни игнорируются, пока FSM не вернётся в S_IDLE. Типичный паттерн на стороне ssd1306_ctrl:
PH_INIT: begin bw_start <= 1'b1; // assert на 1 такт bw_byte_count <= {10'd0, INIT_LEN}; phase <= PH_INITW; end // ... в следующем такте ssd1306_ctrl уже в PH_INITW, // а bw_start автоматически сброшен: // bw_start <= 1'b0; в начале always-блока
slave_addr_i[6:0]- 7-битный I2C-адрес slave-устройства. Модуль сам сформирует полный 8-битный стартовый байт:addr_byte = {slave_addr_i, 1'b0} (последний бит = 0 для операции записи). Поддержка операций READ в burst-writer’е не реализована - только WRITE. Если нужен READ-burst (для EEPROM Random Read), делают отдельный модуль. Значение slave_addr_i защёлкивается одновременно с start_i и остаётся стабильным внутри модуля до завершения транзакции. После защёлкивания сигнал на входе может меняться - это не повлияет на текущую передачу. byte_count_i[CNT_W-1:0]- число data-байт, которое нужно отправить после байта адреса. Поясняю: полная I2C-транзакция в нашем случае выглядит как START + ADDR + DATA×N + STOP, где N = byte_count_i. Сам байт адреса в счётчик не входит. Граничные случаи:
- byte_count_i = 0- транзакция START + ADDR + STOP. Полезно для “прощупывания” адреса (I2C device discovery): если адрес отвечает ACK, “error_o = 0”, иначе “error_o = 1”.
- byte_count_i = INIT_LEN = 32 - init SSD1306.
- byte_count_i = DATA_LEN = 1025 - передача фреймбуфера (1 control-байт + 1024 пикселя).
Значение защёлкивается в cnt одновременно со start_i и декрементируется на каждом успешно переданном data-байте. busy_o - уровень-индикатор активности: 1, пока state != S_IDLE. Используется верхним уровнем для того, чтобы:
- не ассертить start_i повторно, пока предыдущая транзакция идёт;
- зажечь “busy-LED” (в нашем проекте - LED[0]);
- проверки “можно ли отпустить шину” при реакции на внешние события.
Однократная проверка !busy_o перед новой командой + однотактовый start_i - канонический паттерн использования. done_o - одноцикловый импульс завершения. Assert-ится ровно на 1 такт при переходе из S_DONE обратно в S_IDLE. Это важно подчеркнуть: done_o - не уровень. Если верхний уровень пропустит такт (например, не успеет семплировать в синхронном регистре) - импульс будет потерян. В ssd1306_ctrl мы ловим его одной строкой:
PH_INITW: begin if (bw_done) begin // захватит импульс в любом случае, ... // т. к. проверяется каждый такт end end
Альтернативный вариант конечно можно реализовать, то есть держать done-защёлку, но это избыточно, когда получатель - тоже синхронный FSM. error_o - уровень-флаг ошибки, валиден одновременно с done_o. Причины для установки:
- slave ответил NACK на байт адреса (rx_ack_i = 1 после ADDR_WAIT);
- slave ответил NACK на любой data-байт;
- произошла потеря арбитража (arb_lost_i = 1) в момент активной транзакции.
Флаг сохраняется вплоть до следующего start_i (тогда в S_IDLE он очищается: error_o <= 1'b0). Это значит, что верхний уровень может сэмплировать error_o даже несколько тактов после done_o, если ему так удобнее (например, для зажигания LED через одну дополнительную защелку). Поток данных (data-side) Интерфейс преднамеренно асинхронен - burst-writer не знает природы источника данных. Это может быть:
- case-функция (ROM) - как наш init_rom в ssd1306_ctrl;
- dual-port BRAM - как scene_renderer для пиксельных данных;
- FIFO, заполняемый с UART/SPI;
- потоковый генератор (counter, PRBS);
- даже внешний AXI-stream - через небольшой адаптер.
Протокол обмена - простая req/valid-рукопожатие:
 data_req_o— уровень-запрос очередного data-байта. Комбинаторно равен (state == S_DATA_REQ). То есть:
- поднимается ровно в тот момент, когда FSM входит в S_DATA_REQ;
- держится в 1, пока не придёт data_valid_i = 1;
- в том же такте, когда data_valid_i = 1, FSM переходит в S_DATA_CMD, и data_req_o автоматически падает.
Таким образом, источник обязан выдать данные в ответ на req. Если источник “не знает” данных (например, FIFO пустой) - он может держать data_valid_i = 0 сколь угодно долго, burst-writer будет терпеливо ждать, не снимая data_req_o. Это обеспечивает совместимость с медленными источниками без риска потерять байты. data_i[7:0] - сам байт данных. Должен быть валиден в любом такте, когда источник поднимает data_valid_i = 1. Для комбинаторных источников часто удобнее вообще не различать req и valid - подавать данные постоянно (они меняются синхронно с data_req_o). data_valid_i - квитанция «байт валиден на data_i». Тактика использования зависит от источника:
| Тип источника |
Связь с data_req_o |
| Комбинаторный (ROM, мукс) |
assign data_valid_i = data_req_o; |
| Регистровый 1-такт (BRAM sync) |
Данные уже готовы к моменту req (в нашем проекте обеспечено тем, что BRAM успевает обновиться задолго до следующего req); тогда тоже data_valid_i = data_req_o. Альтернативно — регистровая задержка always @(posedge clk) data_valid_i <= data_req_o; — тогда burst-writer подождёт 1 такт. |
| FIFO, переменная латентность |
data_valid_i = !fifo_empty, и после чтения — один такт data_valid_i = 1 на передний фронт data_req_o. |
В нашем проекте реализован первый вариант: data_valid_i = bw_data_req. Это экономит 1 такт на каждом байте (незначительно, но даром). Интерфейс к ядру i2c_master_core Эти сигналы - прямой прокси ядра. Burst-writer не добавляет к ним никаких преобразований, только вставляет свои значения в нужное время. cmd_valid_o - уровень-защёлка, говорящий ядру: “на входе cmd_i/din_i валидная команда, забирай”. Двухфазное рукопожатие:
- burst-writer поднимает cmd_valid_o = 1, когда ready_i = 1 (ядро готово);
- держит в 1 до детекции ready_i = 0 (ядро приняло команду);
- в том же такте сбрасывает cmd_valid_o и уходит в WAIT-состояние.
Важно: одновременно с cmd_valid_o должны быть валидны cmd_o и din_o. Поэтому мы присваиваем их в тех же тактах и в том же состоянии, что и cmd_valid_o. cmd_o[2:0] - код I2C-команды, подаваемый ядру:
| Код |
Мнемоника |
Когда выдаём burst-writer’ом |
| 1 |
CMD_START |
в S_START_CMD, один раз в начале транзакции |
| 2 |
CMD_WRITE |
в S_ADDR_CMD (адрес) и в S_DATA_CMD (каждый байт данных) |
| 4 |
CMD_STOP |
в S_STOP_CMD, один раз в конце (или при NACK) |
din_o[7:0] - байт для передачи ядру по I2C. В разных состояниях подаются разные источники:
- S_ADDR_CMD → {slave_addr_i, 1'b0} (сохранено в addr_byte);
- S_DATA_CMD → data_i (защёлкнуто в din_o при рукопожатии с источником).
Для CMD_START, CMD_STOP значение din_o ядру безразлично - оно игнорируется. ready_i - уровень-готовность ядра принять следующую команду. Семантика:
- ready_i = 1 в S_IDLE ядра - ждёт новую команду;
- ready_i = 0 - ядро занято (идёт START, передача бита, clock-stretching от slave’а, и т. д.);
- ready_i снова поднимается в 1 на такте, следующем после завершения команды - в этот момент можно одновременно читать rx_ack_o / dout_o и ассертить следующую команду.
Использование в burst-writer’е - в двух местах:
- _CMD-состояния: условие для ассерта cmd_valid_o - “ждать, пока ready_i = 1”;
- _WAIT-состояния: ждать, пока ready_i снова не станет 1 (значит команда выполнена).
rx_ack_i- ACK/NACK от slave-устройства после только что переданного WRITE-байта. Значение:
- rx_ack_i = 0 - ACK, всё хорошо, можно передавать следующий байт.
- rx_ack_i = 1 - NACK, slave не подтвердил приём. Burst-writer интерпретирует это как ошибку: ставит nack_flag, прерывает передачу и отправляет STOP, чтобы корректно освободить шину.
rx_ack_i валиден только в такте, когда ready_i поднимается после WRITE-команды. В другие моменты его значение - “недокументированный мусор”, его не нужно смотреть. arb_lost_i - флаг потери арбитража от ядра. Sticky-уровень (удерживается до внешнего arb_lost_clear_i ядра). Активируется, если ядро обнаружило, что какой-то другой мастер “перехватил” шину: ядро ждало SDA = 1 (отпустило линию), но физически прочитало SDA = 0 при SCL = 1 - значит, кто-то другой тянет линию к земле. В burst-writer’е обработка arb-lost имеет высший приоритет - это единственное условие, которое может прервать передачу посреди байта:
if (arb_lost_i && busy_o) begin cmd_valid_o <= 1'b0; // перестать подавать команды ядру nack_flag <= 1'b1; // выставить error_o в S_DONE state <= S_DONE; // аварийный выход end
STOP не отправляется - шина у другого мастера, любая попытка записи только создаст коллизию. Ядро само отпускает SDA/SCL, мы молча сдаёмся, сигналим error_o, и ждём от верхнего уровня команды arb_lost_clear_i + повторного start_i для retry. Сводная таблица:
| Сигнал |
Dir |
Ширина |
Тип |
Назначение |
| clk_i |
in |
1 |
clock |
системный такт (50 МГц) |
| rstn_i |
in |
1 |
async reset |
active-low сброс |
| start_i |
in |
1 |
pulse |
запуск новой I2C-транзакции |
| slave_addr_i |
in |
7 |
level |
7-битный адрес slave (W=0 добавляется сам) |
| byte_count_i |
in |
CNT_W |
level |
сколько data-байт после адреса |
| busy_o |
out |
1 |
level |
транзакция идёт (state ≠ IDLE) |
| done_o |
out |
1 |
1-cycle pulse |
завершение транзакции |
| error_o |
out |
1 |
level |
NACK или arb-lost, валиден c done_o |
| data_req_o |
out |
1 |
level |
запрос очередного data-байта |
| data_i |
in |
8 |
level |
сам data-байт от источника |
| data_valid_i |
in |
1 |
level |
квитанция «data_i валиден» |
| cmd_valid_o |
out |
1 |
level |
команда для ядра валидна |
| cmd_o |
out |
3 |
level |
код команды (START/WRITE/STOP) |
| din_o |
out |
8 |
level |
байт для передачи по I2C |
| ready_i |
in |
1 |
level |
ядро готово принять/завершило команду |
| rx_ack_i |
in |
1 |
level |
ACK(0)/NACK(1) от slave (валиден с rising ready_i) |
| arb_lost_i |
in |
1 |
level |
потеря арбитража на шине |
Сам модуль не знает, откуда берутся данные - это может быть ROM, BRAM, FIFO или генератор. Это разделение ответственности - основная абстракция проекта: burst-writer - “менеджер транзакции”, а что передавать - забота верхнего уровня. Модуль i2c_burst_writer - автомат пакетной записи Теперь детально разберем исходный код rtl/i2c_burst_writer.v с построчным объяснением. Модуль сравнительно небольшой (~200 строк Verilog-кода), но именно он превращает “атомарное” I2C-ядро в полноценный burst-transport, и именно он задаёт фреймворк, под который пишутся все device-контроллеры (SSD1306, а в будущем - EEPROM, BME280 и др.). Место модуля между ssd1306_ctrl (верхний уровень) и i2c_master_core (ядро шины):
 Высоуровневый контракт: модуль принимает (slave_addr, byte_count, data-stream) и генерирует полную транзакцию I2C автоматически управляя ядром через его атомарные команды:
START → WRITE(addr) → WRITE(data0) → … → WRITE(data_{N-1}) → STOP
Рассмотрим объявление модуля и параметр CNT_W. Начало файла rtl/i2c_burst_writer.v:
module i2c_burst_writer #( parameter CNT_W = 16 )( input wire clk_i, input wire rstn_i, // Control input wire start_i, input wire [6:0] slave_addr_i, input wire [CNT_W-1:0] byte_count_i, output wire busy_o, output reg done_o, output reg error_o, // Data source output wire data_req_o, input wire [7:0] data_i, input wire data_valid_i, // i2c_master_core command interface output reg cmd_valid_o, output reg [2:0] cmd_o, output reg [7:0] din_o, input wire ready_i, input wire rx_ack_i, input wire arb_lost_i );
CNT_W - единственный параметр, задающий разрядность счётчика байт byte_count_i. Значение по умолчанию - 16 бит, что даёт потолок 65 535 байт в одной транзакции. Этого с запасом хватает и для 32-байтового init SSD1306, и для 1025-байтового frame-burst, и даже для “длинных” устройств вроде EEPROM 24LC64 (страницы по 32, однако полная RAM - 8 КиБ). Семантика портов полностью совпадает с таблицей раздела приведенного выше - там каждый сигнал расписан подробно. Теперь объявим внутренние регистры и именованные константы. Сразу после port-list’а:
localparam [2:0] CMD_START = 3'd1, CMD_WRITE = 3'd2, CMD_STOP = 3'd4; localparam [3:0] S_IDLE = 4'd0, S_START_CMD = 4'd1, S_START_WAIT = 4'd2, S_ADDR_CMD = 4'd3, S_ADDR_WAIT = 4'd4, S_DATA_REQ = 4'd5, S_DATA_CMD = 4'd6, S_DATA_WAIT = 4'd7, S_STOP_CMD = 4'd8, S_STOP_WAIT = 4'd9, S_DONE = 4'd10; reg [3:0] state; reg [CNT_W-1:0] cnt; reg [7:0] addr_byte; reg nack_flag;
- CMD_START/WRITE/STOP - мы используем только три команды ядра из шести существующих. CMD_READ/CMD_RESTART/CMD_NOP не нужны для WRITE-burst’а - поэтому в модуле их мнемоник даже не объявляем.
- S_* - 11 состояний FSM. 4 бит ([3:0]) достаточно для 16 значений, с запасом. Каждое состояние - логическая единица транзакции: CMD = “ассертить команду ядру”, WAIT = “ждать завершения”, _REQ = “запросить байт у источника”, и т. д.
- state - сам регистр FSM.
- cnt - счётчик оставшихся data-байт (без байта адреса). Защёлкивается из byte_count_i при start_i и декрементируется после каждого успешно переданного data-байта.
- addr_byte - полный 8-битный ADDR-байт, собранный из 7-битного slave_addr_i и младшего W=0. Защёлкивается при start_i, чтобы оставаться стабильным на весь срок транзакции, даже если наверху slave_addr_i изменится.
- nack_flag - липкий однобитный флаг «в ходе транзакции был NACK или arb-lost». Выставляется в 1 при ошибке, переносится в error_o в S_DONE.
Далее рассмотрим комбинаторные выходы busy_o и data_req_o. Сразу после объявлений регистров объявим их:
assign busy_o = (state != S_IDLE); assign data_req_o = (state == S_DATA_REQ);
Оба выхода - чистая комбинаторика от регистра state, без участия других сигналов. Это важно:
- busy_o - видно наружу мгновенно в тот же такт, когда FSM переходит в не-S_IDLE. Это позволяет верхнему уровню точно синхронизировать start_i с ним (“ждём busy_o = 0, потом поднимаем start_i на 1 такт”).
- data_req_o - также мгновенно поднимается при входе в S_DATA_REQ и падает при выходе. Для комбинаторного источника данных (ROM/мукс) это означает “сейчас же защёлкнуть байт”.
Комбинаторное выражение через state также гарантирует, что никакие лишние такты не будут потрачены на “перезапись ready/req в отдельный регистр” - меньше защёлок, прощё тайминги. Синхронный блок и сброс Вся логика модуля живёт в одном always @(posedge clk_i or negedge rstn_i):
always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) begin state <= S_IDLE; cnt <= {CNT_W{1'b0}}; addr_byte <= 8'd0; nack_flag <= 1'b0; cmd_valid_o <= 1'b0; cmd_o <= 3'd0; din_o <= 8'd0; done_o <= 1'b0; error_o <= 1'b0; end else begin done_o <= 1'b0; …
Асинхронный active-low reset (negedge rstn_i) ставит FSM в S_IDLE и очищает все выходы. В начале else-ветки стоит однострочный pre-assign done_o <= 1'b0; - это то самое “default-выражение”, которое делает done_o одноцикловым импульсом: когда в S_DONE мы пишем done_o <= 1'b1, в следующем такте этот pre-assign вернёт его в 0 (если в этом такте не выполнится другой assign). Далее добавляем страж arbitration-lost который будет работать с наивысшим приоритетом. Сразу после done_o <= 1'b0:
if (arb_lost_i && busy_o) begin cmd_valid_o <= 1'b0; nack_flag <= 1'b1; state <= S_DONE; end else begin case (state) …
Это единственный случай, когда FSM прерывает свою работу посередине любого состояния. Логика:
- arb_lost_i валиден только когда ядро обнаружило физический конфликт на SDA с другим мастером. Стоит sticky-уровень до arb_lost_clear_i ядра.
- Проверка && busy_o гарантирует, что мы не реагируем, если arb-lost пришёл в S_IDLE (тогда мы не являемся источником конфликта, это чужая проблема).
- Действие: опустить cmd_valid_o (чтобы ядро не принимало новых команд), поднять nack_flag (для error_o) и уйти в S_DONE.
- STOP не отправляется - нельзя: шину физически удерживает другой мастер, любая наша попытка записи превратится в повторный конфликт. Ядро само отпускает SDA/SCL при детекции al_event.
Этот страж оборачивает весь case, чтобы перекрывать любой переход FSM. Приоритет выше, чем обычные переходы - это классический pattern “safety first”. Состояние S_IDLE
S_IDLE: begin if (start_i) begin addr_byte <= {slave_addr_i, 1'b0}; cnt <= byte_count_i; nack_flag <= 1'b0; error_o <= 1'b0; state <= S_START_CMD; end end
В S_IDLE модуль ждёт start_i. При его обнаружении:
- Защёлкивает 8-битный addr_byte: 7-битный адрес сдвигается на 1 влево, младший бит = 0 (W - write).
- Защёлкивает cnt из byte_count_i. С этого момента значение внутри модуля стабильно, даже если верхний уровень изменит byte_count_i.
- Сбрасывает nack_flag и error_o - предыдущая ошибка забывается, новая транзакция начинается с “чистого листа”.
- Переходит в S_START_CMD.
Если start_i = 0 - остаёмся в S_IDLE, busy_o = 0, data_req_o = 0, ничего не выходит на ядро. Это состояние покоя. Пара состояний S_START_CMD / S_START_WAIT Выдача START-условия на шину:
S_START_CMD: begin cmd_o <= CMD_START; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_START_WAIT; end end S_START_WAIT: begin if (ready_i) state <= S_ADDR_CMD; end
Это - эталонный шаблон «cmd + wait», повторяющийся во всех _CMD/ _WAIT-парах модуля. Разберём по тактам:
такт state cmd_o cmd_valid_o ready_i 1 S_START_CMD START 0 1 ← видим ready=1 → готовимся 2 S_START_CMD START 1 1 ← выставили valid=1,ядро ещё готово 3 S_START_CMD START 1 0 ← ядро приняло, опустило ready 4 S_START_CMD START 0 0 ← мы увидели (!ready & valid), опустили valid, уходим в WAIT 5 S_START_WAIT — 0 0 ← ядро работает ... (много тактов, генерация START на шине) ... N S_START_WAIT — 0 1 ← ready снова 1 → команда выполнена N+1 S_ADDR_CMD — 0 1 ← переходим к следующей команде
Ключевая идея - двухфазное рукопожатие, которое рассмотрим ниже: сначала ассерт при ready_i = 1, затем деассерт при ready_i = 0. Это точнее, чем просто “ассерт при ready_i = 1 на 1 такт”, потому что если по каким-то причинам ядро среагирует не на 1-м такте (clock-stretching, внутренняя задержка), мы не “промахнёмся” - cmd_valid_o будет удерживаться, пока не увидим реальный ответ. Почему нельзя просто if (!ready_i) cmd_valid_o <= 0? Потому что ready_i = 0 в начале означает “ядро занято прошлой командой” (а не “ядро приняло нашу”). Надо отличать эти две ситуации. Комбинация “cmd_valid_o = 1 и ready_i = 0” однозначна - она возможна только если ядро приняло нашу текущую команду. Пара S_ADDR_CMD / S_ADDR_WAIT Отправка 8-битного ADDR-байта на шину:
S_ADDR_CMD: begin cmd_o <= CMD_WRITE; din_o <= addr_byte; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_ADDR_WAIT; end end S_ADDR_WAIT: begin if (ready_i) begin if (rx_ack_i) begin nack_flag <= 1'b1; state <= S_STOP_CMD; end else if (cnt == {CNT_W{1'b0}}) state <= S_STOP_CMD; else state <= S_DATA_REQ; end end
S_ADDR_CMD - копия шаблона START, но с cmd_o = CMD_WRITE и din_o = addr_byte. Оба сигнала выставляются одновременно с cmd_valid_o; ядро, принимая команду, считывает их. В S_ADDR_WAIT происходит первая разветвлённая логика:
- rx_ack_i == 1 (NACK от slave’а) — slave не ответил на адрес. Ставим nack_flag = 1, уходим в S_STOP_CMD — даже при ошибке нужно корректно отпустить шину.
- cnt == 0 — случай «пробы адреса»: byte_count_i был равен 0, data-байтов нет. Идём сразу в S_STOP_CMD, без ошибки. Это тот самый I2C probe.
- иначе — нормальный путь: переходим в S_DATA_REQ для первого data-байта.
Триада data-состояний S_DATA_REQ / S_DATA_CMD / S_DATA_WAIT Это ядро burst-логики - цикл по всем data-байтам.
S_DATA_REQ: begin if (data_valid_i) begin din_o <= data_i; state <= S_DATA_CMD; end end S_DATA_CMD: begin cmd_o <= CMD_WRITE; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_DATA_WAIT; end end S_DATA_WAIT: begin if (ready_i) begin cnt <= cnt - {{(CNT_W-1){1'b0}}, 1'b1}; if (rx_ack_i) begin nack_flag <= 1'b1; state <= S_STOP_CMD; end else if (cnt == {{(CNT_W-1){1'b0}}, 1'b1}) state <= S_STOP_CMD; else state <= S_DATA_REQ; end end
Полный цикл одного data-байта:
- S_DATA_REQ: выставлен data_req_o = 1. Ждём, пока источник поднимет data_valid_i = 1. В момент, когда оба сигнала совпадают (data_req_o = 1 и data_valid_i = 1) - защёлкиваем din_o <= data_i и переходим в S_DATA_CMD. В том же такте data_req_o автоматически становится 0 (потому что state != S_DATA_REQ).
- S_DATA_CMD: тот же шаблон «cmd+valid», что и для START/ADDR. cmd_o = MD_WRITE, din_o уже загружен. Когда ядро приняло - переходим в S_DATA_WAIT.
- S_DATA_WAIT: ядро работает, физически сдвигает байт на SDA. Когда закончит ready_i = 1 снова):
- декремент cnt - без -= (Verilog не знает), явным сложением с -1. Выражение {{(CNT_W-1){1'b0}}, 1'b1} - это 1 в ширине CNT_W бит (для 16-бит это 16'h0001).
- проверка rx_ack_i: NACK → nack_flag = 1, S_STOP_CMD;
- если после декремента cnt стал 0 (т. е. только что отправлен последний data-байт) - S_STOP_CMD;
- иначе - возврат в S_DATA_REQ за следующим байтом.
Тонкость сравнения на «последний байт». Сравнение делается до декремента - сравниваем с 1, а не 0 (cnt == 1 означает «только что отправили 1-й из оставшихся - он был последним»). В коде это выглядит как cnt == {{(CNT_W-1){1'b0}}, 1'b1} - тот же 1 в CNT_W бит. cnt <= cnt - 1 в том же такте просто отражает в регистре новое состояние - на FSM-переход это не влияет, т. к. переход рассчитан до новой записи. Число тактов на один байт — складывается из:
- 1 такт на S_DATA_REQ (если источник мгновенно валиден);
- ~2 такта на S_DATA_CMD (peak ready, опустить valid);
- ~2250 тактов на S_DATA_WAIT (ядро гонит 9 бит по SCL = 1125 мкс ≈ 45 мкс).
Итого ~45 мкс на байт - ограничение именно физической частоты SCL. Логика burst-writer’а тут занимает всего ~3–5 тактов (0.1 мкс) - пренебрежимо мало. Пара S_STOP_CMD / S_STOP_WAIT Финальное STOP-условие и выход:
S_STOP_CMD: begin cmd_o <= CMD_STOP; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_STOP_WAIT; end end S_STOP_WAIT: begin if (ready_i) state <= S_DONE; end
Абсолютно такой же шаблон «cmd+wait», как для START, только с cmd_o = CMD_STOP. Завершает транзакцию, после чего ядро ставит busy_o = 0 на самой шине, cmd_valid_o уже 0, FSM — в S_STOP_WAITдо тех пор, пока ядро снова не освободится (ready_i = 1), после чего уходит в S_DONE. Состояние S_DONE и default
S_DONE: begin done_o <= 1'b1; error_o <= nack_flag; state <= S_IDLE; end default: state <= S_IDLE;
В S_DONE модуль:
- Поднимает done_o = 1. В следующем же такте pre-assign (done_o <= 1'b0 в начале always-блока) вернёт его в 0. Так получается одноцикловый импульс.
- Переносит nack_flag в error_o. error_o - level-флаг, удерживается до следующего start_i (где он очистится в S_IDLE).
- Возвращается в S_IDLE. default: state <= S_IDLE; - страховка от попадания в непредусмотренное значение регистра state (глитч при загрузке FPGA, однобитная SEU-ошибка в BRAM-регистре, и пр.). Возврат вS_IDLE - безопасный recovery.
Шаблон двухфазного рукопожатия — диаграмма Для каждой выдаваемой команды (START / WRITE(addr) / WRITE(data) / STOP) используется один и тот же рисунок сигналов:
 Фаза CMD. Мы ассертим cmd_valid_o = 1, когда ready_i = 1 (ядро готово), и деассертим, когда ready_i падает (ядро приняло команду). Фаза WAIT. Мы молча ждём, когда ready_i снова станет 1 — это сигнал «команда выполнена, результат готов». Для WRITE это одновременно и «ACK/NACK от slave зафиксирован в rx_ack_i». Почему не if (!ready_i) напрямую? Потому что ready_i = 0 может означать «ядро занято предыдущей командой» — в этом случае ассертить нельзя. Пара ready_i=1 → cmd_valid_o=1, затем cmd_valid_o=1 && ready_i=0 → деассерт однозначно описывает «ядро приняло нашу команду». Интерфейс источника данных Связь с источником (ROM / BRAM / FIFO / генератор) — асинхронный req/valid-протокол:
 Для комбинаторного источника (ROM-функция, процедурный мукс) data_valid_i = data_req_o напрямую — данные появляются в том же такте, когда поднят req. Для регистрового источника (BRAM с синхронным чтением) допустима задержка 1–2 такта: burst-writer будет ждать в S_DATA_REQ, пока data_valid_i не поднимется. В нашем проекте между двумя запросами проходит ~45 мкс (время I2C-байта), поэтому 1-тактовая задержка BRAM’а растворяется в этом интервале. В ssd1306_ctrl используется комбинаторная связка:
// из quartus_ssd1306/src/ssd1306_ctrl.v wire [7:0] init_byte = init_rom(src_idx[5:0]); wire [7:0] data_byte = (src_idx == 11'd0) ? 8'h40 : scene_rdata; wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte; assign bw_data_valid = bw_data_req; // комбинаторное равенство
init_byte — comb-ROM (функция), scene_rdata — регистровый выход BRAM, но к моменту запроса BRAM уже успел обновиться (за счёт подставления raddr_i = src_idx заблаговременно). Обработка ошибок — сводка Три возможных сценария сбоя, как они обрабатываются:
| Событие |
Момент обнаружения |
Обработка |
Результат |
| NACK на адресе |
S_ADDR_WAIT, rx_ack_i = 1 |
nack_flag = 1, переход в S_STOP_CMD (шину надо корректно освободить) |
done_o = 1 + error_o = 1 через S_DONE |
| NACK на данных |
S_DATA_WAIT, rx_ack_i = 1 |
аналогично: nack_flag = 1, S_STOP_CMD |
то же |
| Arb-lost |
любое состояние, arb_lost_i = 1 && busy_o |
страж: cmd_valid_o = 0, nack_flag = 1, переход в S_DONE без STOP (шина чужая) |
done_o = 1 + error_o = 1 |
Верхний уровень (ssd1306_ctrl) различает эти три случая по контексту (какая фаза была активна в момент bw_done): если это была PH_INITW — значит сбой на init, если PH_FRAMEW — значит сбой на frame. В любом случае уход в PH_ERR и зажигание LED ошибки. Retry реализован как нажатие кнопки сброса или кнопки «повторить» (см. раздел о top-level). Параметризация и масштабирование
i2c_burst_writer #(.CNT_W(16)) u_burst (…);
CNT_W — ширина счётчика байт:
| CNT_W |
max byte_count_i |
Типичное применение |
| 8 |
255 |
маленькие slave’ы (AT24C02 EEPROM, 256 байт) |
| 10 |
1023 |
в точности наш frame (1025 > 1023 — уже не влезет, нужно ≥11 или ≥16) |
| 16 |
65535 |
наш выбор — любой разумный sensor/display/EEPROM |
| 20 |
1048575 |
для длинных flash-транзакций |
Расход ресурсов на счётчик линейный: 8 бит → 8 регистров + 8-бит компаратор; 16 бит → 16 + 16. Для Cyclone IV разница пренебрежимая (~20 LE). Поэтому ставим «железобетонные» 16 бит и забываем о лимите. Полный листинг i2c_burst_writer.v (с аннотациями) Ниже — полный исходный код модуля с комментариями, указывающими связь с разобранными выше подразделами.
// --------------------------------------------------------------------------- // I2C Burst Writer — auto-sequences multi-byte I2C write transactions. // --------------------------------------------------------------------------- module i2c_burst_writer #( parameter CNT_W = 16 // [4.2] ширина счётчика, 16 бит по умолчанию )( input wire clk_i, input wire rstn_i, // Control — см. 3.3.2 input wire start_i, input wire [6:0] slave_addr_i, input wire [CNT_W-1:0] byte_count_i, output wire busy_o, output reg done_o, output reg error_o, // Data source — см. 3.3.3 и 4.14 output wire data_req_o, input wire [7:0] data_i, input wire data_valid_i, // i2c_master_core command interface — см. 3.1 и 4.13 output reg cmd_valid_o, output reg [2:0] cmd_o, output reg [7:0] din_o, input wire ready_i, input wire rx_ack_i, input wire arb_lost_i ); // ----- Мнемоники команд ядра (см. 3.1.3) ----- localparam [2:0] CMD_START = 3'd1, CMD_WRITE = 3'd2, CMD_STOP = 3'd4; // ----- Состояния FSM (см. 4.3 и диаграмму в 4.18) ----- localparam [3:0] S_IDLE = 4'd0, S_START_CMD = 4'd1, S_START_WAIT = 4'd2, S_ADDR_CMD = 4'd3, S_ADDR_WAIT = 4'd4, S_DATA_REQ = 4'd5, S_DATA_CMD = 4'd6, S_DATA_WAIT = 4'd7, S_STOP_CMD = 4'd8, S_STOP_WAIT = 4'd9, S_DONE = 4'd10; reg [3:0] state; reg [CNT_W-1:0] cnt; reg [7:0] addr_byte; reg nack_flag; // ----- Комбинаторные выходы (см. 4.4) ----- assign busy_o = (state != S_IDLE); assign data_req_o = (state == S_DATA_REQ); always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) begin // ----- Асинхронный reset (см. 4.5) ----- state <= S_IDLE; cnt <= {CNT_W{1'b0}}; addr_byte <= 8'd0; nack_flag <= 1'b0; cmd_valid_o <= 1'b0; cmd_o <= 3'd0; din_o <= 8'd0; done_o <= 1'b0; error_o <= 1'b0; end else begin // ----- done_o — одноцикловый импульс (см. 4.5, 4.12) ----- done_o <= 1'b0; // ----- Страж arb-lost, высший приоритет (см. 4.6) ----- if (arb_lost_i && busy_o) begin cmd_valid_o <= 1'b0; nack_flag <= 1'b1; state <= S_DONE; end else begin case (state) // ============ S_IDLE (см. 4.7) ============ S_IDLE: begin if (start_i) begin addr_byte <= {slave_addr_i, 1'b0}; cnt <= byte_count_i; nack_flag <= 1'b0; error_o <= 1'b0; state <= S_START_CMD; end end // ============ START (см. 4.8) ============ S_START_CMD: begin cmd_o <= CMD_START; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_START_WAIT; end end S_START_WAIT: begin if (ready_i) state <= S_ADDR_CMD; end // ============ WRITE(addr) (см. 4.9) ============ S_ADDR_CMD: begin cmd_o <= CMD_WRITE; din_o <= addr_byte; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_ADDR_WAIT; end end S_ADDR_WAIT: begin if (ready_i) begin if (rx_ack_i) begin nack_flag <= 1'b1; state <= S_STOP_CMD; // NACK на адресе → STOP с ошибкой end else if (cnt == {CNT_W{1'b0}}) state <= S_STOP_CMD; // probe (byte_count=0) → STOP без ошибки else state <= S_DATA_REQ; // норма → к data-потоку end end // ============ Data-loop (см. 4.10) ============ S_DATA_REQ: begin if (data_valid_i) begin din_o <= data_i; // защёлкиваем байт от источника state <= S_DATA_CMD; end end S_DATA_CMD: begin cmd_o <= CMD_WRITE; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_DATA_WAIT; end end S_DATA_WAIT: begin if (ready_i) begin cnt <= cnt - {{(CNT_W-1){1'b0}}, 1'b1}; // cnt-- if (rx_ack_i) begin nack_flag <= 1'b1; state <= S_STOP_CMD; // NACK → STOP с ошибкой end else if (cnt == {{(CNT_W-1){1'b0}}, 1'b1}) state <= S_STOP_CMD; // последний байт — к STOP else state <= S_DATA_REQ; // ещё байт end end // ============ STOP (см. 4.11) ============ S_STOP_CMD: begin cmd_o <= CMD_STOP; if (ready_i) cmd_valid_o <= 1'b1; if (cmd_valid_o && !ready_i) begin cmd_valid_o <= 1'b0; state <= S_STOP_WAIT; end end S_STOP_WAIT: begin if (ready_i) state <= S_DONE; end // ============ DONE (см. 4.12) ============ S_DONE: begin done_o <= 1'b1; // 1-такт импульс error_o <= nack_flag; // level-флаг до следующего start state <= S_IDLE; end default: state <= S_IDLE; // safety recovery endcase end // arb_lost guard end end endmodule
Полная диаграмма состояний Пусть тут будет сгенерированная диаграмма состояний со всем набором пояснений.
 Архитектура механизма подготовки изображения без использования процессора Данная часть статьи - является в некотором смысле переломной. До этого момента речь шла исключительно о транспортном уровне: как передать 1024 байта по I2C. Теперь мы задаём ортогональный вопрос - откуда взять эти 1024 байта, чтобы они складывались в осмысленную картинку (статический текст + вращающийся 3D-куб), и при этом не использовать процессор. Это уже принципиально другой класс задач. На процессоре (Nios II / ARM / x86) подобный рендер пишется в 150 строк C-кода за 15 минут - и все проблемы (деление, умножение, косинусы, ветвления, работа со стеком) остаются заботой компилятора и ядра. В RTL же мы вручную раскладываем весь алгоритм на комбинационные и последовательностные схемы, регистры, BRAM, lookup-таблицы. Но взамен получаем детерминированную длительность кадра, нулевую зависимость от компиляторов и инструментария - проект можно скомпилировать в любой версии Quartus с 2010 года и результат будет бит-в-бит одинаковым. Это одно из ключевых архитектурных ограничений проекта, сформулированное в постановке задачи. Конкретно нельзя использовать:
- soft-core CPU в виде HDL-ядра - ни Nios II/e (Altera), ни PicoRV32, ни SERV, ни Cortex-M0 DesignStart;
- HLS-инструменты - Intel HLS, Vivado HLS, Bluespec и т. п. (они, строго говоря, и не являются “процессорами”, но скрывают процессорное мышление за высокоуровневым C-кодом);
- LUT-based softcore-симуляторы - “миниатюрные” CPU-подобные конструкции на ~500 LE, которые встречают иногда в учебных проектах.
Это ограничение имеет и практический смысл, и образовательный:
- Практика. На платах класса AX301 (EP4CE10F17, ~10 кLE, без жёсткого CPU и без ARM-стека) soft-core съест 600-1000 LE и даст не более 25 MIPS. Это мало. Рендер 3D-куба на 25 MIPS без DSP и без FP - ~100 мс/кадр, что ставит крест на 10+ fps. Прямой аппаратный рендер на том же кристалле занимает ~40 мкс - в 2500 раз быстрее.
- Образование. Без CPU заставляет разработчика вручную разложить задачу на микро-пайплайн из сложения, умножения, сдвига, case-lookup, BRAM-доступа и FSM - и увидеть, как именно работают эти примитивы. Это основа FPGA-культуры.
Что разрешено и из чего мы будем собирать рендер:
| Примитив |
Что даёт |
Наш способ применения |
| Целочисленная арифметика (+, -, * в signed/unsigned) |
синтезируется в adders/subtractors/DSP-multipliers |
вершины куба, Брезенхэм, проекция |
| Сдвиги (<<, >>) |
бесплатная комбинаторика (reroute) |
/8, *128 в адресной арифметике |
| Битовые маски (&, |, ^) |
LUT-логика |
RMW-byte в фреймбуфере |
| case — lookup-table |
ROM |
sin/cos LUT, font ROM, vertex ROM |
| function в Verilog (чистая комбинаторика) |
инлайн ROM / логика |
быстрые вычисления без состояний |
| FSM (state + case) |
явный конечный автомат |
оркестровка пайплайна рендера |
| BRAM (reg [7:0] mem [0:N] + sync read) |
встроенная dual-port память |
фреймбуфер 1 КиБ |
Что не разрешено и требует обходных решений:
| Отсутствует |
Обходной путь |
| Целочисленное деление |
замена на сдвиг (если /2^k) или на Брезенхэма-подобные алгоритмы |
| Плавающая точка |
фикс-пойнт: у нас всё в signed Q1.7 (амплитуда sin/cos = 127) |
| sin/cos как функция |
lookup-таблица 17 значений для четверть-волны + симметрии |
| Динамическое выделение памяти |
фиксированные массивы заранее известного размера |
| Рекурсия |
развёрнутые циклы или явный FSM |
| Неограниченный стек |
вся логика stateless или через явные регистры |
Ограничение «только железо» порождает несколько сильных архитек турных принципов:
- Fixed-function pipeline. Набор стадий рендера определён на этапе синтеза и не может быть переставлен «в runtime». Это значит, что, например, нельзя «в одном кадре сначала нарисовать куб, потом текст; а в другом — сначала текст, потом куб». Порядок жёстко зашит в FSM.
- Всё известно заранее. Количество вершин (8), количество рёбер (12), количество букв в верхней строке (7), длина sin-таблицы (17 значений на четверть) — все это параметры времени синтеза. Никаких «дайте мне массив длины N, где N будет известно в runtime».
- Детерминизм. Время от start_i до ready_o = 1 одинаково для каждого кадра (с точностью до параметров, влияющих на Брезенхэма — длины рёбер зависят от угла). Нет кэш-промахов, сваливаний в обработчик прерываний и пр. Фактический разброс рендера — ±50 тактов на 50 МГц ≈ ±1 мкс.
- Прямая наблюдаемость. Любой промежуточный сигнал (вершина после поворота, счётчик пиксельной стороны, индекс буквы) — это физический провод в схеме, который можно вывести на test-probe или в SignalTap. Никакой «виртуальной памяти».
Первый большой архитектурный выбор внутри рендера - где жить пикселям во время вычисления кадра. Есть две принципиально разные школы. Подход A: процедурная генерация “на лету” Идея: когда i2c_burst_writer запрашивает N-й байт фреймбуфера, мы тут же вычисляем его значение по формуле f(col, page, t), где col = N mod 128, page = N / 128, t — какой-нибудь счётчик времени для анимации.
function [7:0] gen_pattern; input [6:0] col; input [2:0] page; input [5:0] t; begin gen_pattern = /* какое-то выражение от col, page, t */; end endfunction
Плюсы:
- Ноль BRAM. Картинка не хранится нигде — она вычисляется в момент запроса. На скромных FPGA (5K LE) это может быть критично.
- Мгновенная реакция на параметры. Меняется t — на следующем же такте это отражается в выдаваемых байтах.
- Простота. Один case на 10 строк может дать узор «шахматная доска», «горизонтальные полосы», «градиент» и т. п.
Минусы:
- Привязка к функциональной разделимости. f(col, page, t) должна быть чистой функцией — зависеть только от своих аргументов. Это жёсткое ограничение: например, для рисования линии (x0, y0) → (x1, y1) нужна сквозная пошаговая эволюция x/y, которая не представима как f(col, page).
- Взрыв сложности для композиции. Если на экране должны быть и текст, и куб одновременно — формула выглядит как f_cube(c, p, t) OR f_text(c, p), и внутри f_cube ещё разворачивается проверка «лежит ли пиксель на одной из 12 линий, тангенциальных к куба после поворота». Для каждого пикселя (128×64 = 8192 раз за кадр) эта проверка запускается заново. Комбинаторно это очень «жирно» на LUT.
- Невозможность произвольных алгоритмов. Брезенхэм — итеративен по природе. Заставить его работать «на лету» без BRAM невозможно.
В первой версии проекта (до переделки на scene-renderer) именно этот подход и использовался: gen_pattern(col, page) давал заранее захардкоженные паттерны — полосы, рамки, крест. Это работало, но не масштабировалось до текста + 3D-куба. Подход B: фреймбуфер в BRAM Идея: выделить отдельный буфер 1 КиБ в BRAM, в который рендерер пишет кадр до начала I2C-передачи, а i2c_burst_writer во время передачи его читает.
reg [7:0] fb [0:1023]; // Write port (рендерер) always @(posedge clk) if (we) fb[waddr] <= wdata; // Read port (burst-writer) always @(posedge clk) rdata <= fb[raddr];
Плюсы:
- Произвольные алгоритмы. Рендерер может использовать любой итеративный алгоритм (Брезенхэм, flood-fill, Z-buffer, stencil), так как результат аккумулируется в BRAM по мере вычисления.
- Композиция. Текст и куб рисуются независимо: сначала один проход «очистить FB», потом проход «нарисовать 12 линий», потом проход «нарисовать текст». Каждый проход не знает о других и не пересекается по коду.
- Хорошо ложится на Cyclone IV. Один M9K-блок = 9 216 бит > 8 192 бит (наш fb). Синтез выводит буфер именно в M9K, не расходуя LUT.
- Dual-port позволяет рендеру и burst-writer’у работать параллельно.
Минусы:
- Стоит 1 M9K. У EP4CE10 их 30, у EP4CE6 — 15 — всё равно огромный запас. Для крошечных FPGA (EP4CE6 в dev-плате без M9K) было бы критично, у нас — нет.
- Задержка рендера. Рендер занимает ~38 мкс (см. 5.4). Это не влияет на нас при fps < 25, но в теории могло бы (см. 5.5).
Выбор: подход B (фреймбуфер) Причины:
- Нам нужен Брезенхэм для рёбер куба — других эффективных способов нет. Значит, процедурный подход не годится в принципе.
- У нас есть M9K-блоки с избытком — архитектурный «бесплатный обед».
- Композиция текст+куб+анимация в одном кадре тривиально представима как последовательность проходов.
Архитектурный вывод: фреймбуфер 1 КиБ в dual-port BRAM — единственный разумный путь. Именно это и реализовано в scene_renderer.v:
// quartus_ssd1306/src/scene_renderer.v reg [7:0] fb [0:1023]; always @(posedge clk_i) begin if (fb_we) fb[fb_waddr] <= fb_wdata; rdata_o <= fb[raddr_i]; // порт B: для I2C fb_rdata_rmw <= fb[fb_raddr_rmw]; // порт RMW для рендера (см. ниже) end
Схема «пишу-читаю» dual-port RAM Один M9K в Cyclone IV может быть сконфигурирован как True Dual- Port (два независимых read+write порта) или как Simple Dual- Port (один write, один read). Нам хватает Simple-режима, но с двумя read-портами на стороне рендера. 5.3.1. Требуемые каналы доступа Анализируем, какие операции с BRAM нужны в разных фазах:
| Фаза |
Действие |
Порт |
Частота доступа |
| S_CLEAR |
пишет 1024 раза 0x00 |
write |
каждый такт |
| S_ROT_* |
не трогает FB (вычисляет вершины в регистрах) |
— |
— |
| S_EDGE_* |
для каждого пикселя: read (старый байт) → modify → write |
read+write |
~1 RMW / 3 такта |
| S_TEXT_* |
пишет 8-битный столбец буквы (без RMW, т. к. page-aligned) |
write |
каждый такт |
| I2C-передача (параллельно) |
читает 1024 раза последовательно |
read |
раз в ~45 мкс |
Выводы:
- Write-порт — один, общий для всех фаз рендера.
- Read-порты — нужно два независимых:
- один для RMW-чтения (fb_rdata_rmw) во время рендера,
- второй для I2C-стрима (rdata_o) со стороны ssd1306_ctrl.
Это ровно то, что позволяет M9K в режиме Simple-Dual-Port с двумя read-адресами. Практический код и инфер В Cyclone IV Quartus infers BRAM из следующего шаблона:
reg [7:0] fb [0:1023]; reg [9:0] fb_waddr; reg [7:0] fb_wdata; reg fb_we; reg [9:0] fb_raddr_rmw; reg [7:0] fb_rdata_rmw; always @(posedge clk_i) begin if (fb_we) fb[fb_waddr] <= fb_wdata; // write port rdata_o <= fb[raddr_i]; // read port 1 (I2C) fb_rdata_rmw <= fb[fb_raddr_rmw]; // read port 2 (RMW) end
Синтезатор видит:
- одну запись if (fb_we) fb[…] <= fb_wdata; → соответствует одному write-порту;
- два независимых <= fb[addr] без if → два read-порта.
Результат — один M9K (9 216 бит) с двумя read-портами и одним write-портом. Latency read’а — 1 такт: данные по raddr_i появляются в rdata_o через такт. Это ключевой параметр, с которым нужно считаться в FSM рендера. Параллельность рендера и I2C Два read-порта — это и есть архитектурная основа для параллельной работы рендера и I2C-передачи:
 В теории можно было бы делать рендер во время передачи предыдущего кадра (double-buffering). Мы этого не делаем — наш FSM ssd1306_ctrl сериализует render→I2C→render→I2C (см. следующий раздел 5.4.5). Причина проста: при одном буфере и коротком рендере (~0.08% времени кадра) усложнение схемы двойной буферизации не окупается. Но сам dual-port M9K всё равно обязателен — без него read-порт I2C мешал бы write-порту рендера даже при сериализации. Latency чтения - 1 такт Важная особенность always @(posedge clk) синхронного BRAM: между подачей raddr и появлением данных на rdata проходит ровно один такт:
такт 1 2 3 4 raddr : X FB_ADDR Y Y rdata : ? ? fb[FB_ADDR] fb[Y] ↑ данные готовы
Это значит, что FSM RMW-операции должен быть спроектирован с учётом этой задержки. В scene_renderer.v это реализовано как трёхстадийный pipe:
- S_EDGE_RD — выставить fb_raddr_rmw;
- (следующий такт) — ждать, данные появляются в fb_rdata_rmw;
- S_EDGE_WR — прочитать fb_rdata_rmw, вычислить модификацию, выставить fb_waddr/fb_wdata/fb_we.
Для стороны I2C эта задержка вообще незаметна: между двумя подряд идущими запросами data_req_o проходит ~45 мкс (2 250 тактов), а read-latency = 1 такт, соотношение 1:2250 — она полностью «растворяется» в интервале. Порядок стадий рендера кадра Теперь, когда архитектура (BRAM-фреймбуфер + dual-port) выбрана, нужно определить порядок фаз рендера. Не любой порядок допустим: некоторые фазы записывают в FB поверх результатов предыдущих, и неправильная последовательность даст не ту картинку. Рассмотрим композицию сцены - что на что ложится. Сцена состоит из трёх слоёв:
 Слои разнесены по страницам, т. е. не пересекаются по памяти GDDRAM. Это — не случайно, а сознательный выбор: тексты живут в pages 0 и 7, куб — в pages 2..5. Благодаря такому расположению:
- нет конфликтов RMW: запись текста не перекрывает байты куба и наоборот;
- текст не требует RMW: каждая буква высотой 8 пикселей и выровнена ровно по странице, поэтому один write-запрос перезаписывает весь столбец буквы целиком (0x00 → 0x___). Быстрее и проще чем RMW рёбер куба;
- чистая последовательность фаз: сначала стираем весь FB, потом рисуем каждый слой в любом порядке — результат одинаков.
Диаграмма фаз
 Итого: ~1900 тактов на 50 МГц ≈ 38 мкс. Тайминг-бюджет кадра
| Фаза |
Такты |
мкс @50 МГц |
Что делает |
| S_CLEAR |
1024 |
20.5 |
заливка нулями (1 write / такт) |
| S_ROT_SETUP |
1 |
0.02 |
sin/cos из LUT |
| S_ROT_* (8 вершин) |
32 |
0.64 |
поворот + проекция |
| S_EDGE_* (12 рёбер) |
~720 |
14.4 |
Брезенхэм 12 линий со RMW |
| S_TEXT_* (2 строки) |
~110 |
2.2 |
blit букв без RMW |
| S_DONE |
1 |
0.02 |
поднять ready_o |
| Всего |
~1900 |
~38 мкс |
|
Сравним с бюджетом передачи одного I2C-frame’а: ~46 мс = 46 000 мкс. Рендер занимает 0.08% времени кадра. Значит, даже при сериализации render→I2C мы теряем менее 0.1% производительности на рендер. Упрощая архитектуру до одного буфера и сериализованного pipeline, мы платим практически ничего. Детерминизм длительности Большинство фаз имеют строго фиксированную длительность: S_CLEAR = 1024, S_ROT_* = 32, S_DONE = 1. Только две фазы условно-зависят от содержимого сцены:
- S_EDGE_*: длительность зависит от проекций рёбер. Чем более «по диагонали» спроецировано ребро, тем больше пикселей Брезенхэм отрисует. Максимальная длина ребра на 128×64 — около 45 пикселей (диагональ всего экрана); минимальная — когда ребро сморщено в точку (≈ 1 пиксель). На типовой конфигурации (куб заполняет ~40 пикселей по каждой оси) разброс 12 рёбер суммарно — от ~550 до ~750 тактов.
- S_TEXT_*: зависит от строки (ANIM короче, чем STATIC). Разница — ~20 тактов между двумя вариантами.
Итого, полный разброс времени рендера — ±200 тактов ≈ ±4 мкс. На фоне 46 мс передачи это пренебрежимо мало. Архитектурно это означает, что можно с уверенностью закладывать «46.04 ± 0.004 мс» на весь цикл кадра. Почему сериализация, а не pipelining На первый взгляд очевидный путь оптимизации — double buffering: пока кадр N передаётся по I2C, рендерим кадр N+1 в отдельный буфер. Это дало бы fps, ограниченный только передачей (~21 fps на 200 кГц). Мы этого не делаем по следующим причинам:
- Нет пользы. Даже без pipelining мы получаем ~21 fps (46 мс передачи + 38 мкс рендера). Для человеческого глаза разница между 21.0 fps и 21.1 fps отсутствует.
- Pipelining стоит +1 M9K. Второй буфер 1 КиБ + логика переключения front/back потребует ~150 LE и 1 M9K. Не катастрофично, но и не оправдано при нулевом видимом эффекте.
- Усложнение синхронизации. При pipelining нужно аккуратно отслеживать «какой буфер сейчас передаётся» и «в какой пишет рендерер», чтобы избежать race conditions. Это дополнительные состояния в FSM ssd1306_ctrl.
- Меньше ошибок. Сериализованный pipeline проще отлаживать: в любой момент точно один кадр либо рендерится, либо передаётся — не оба сразу.
Итоговый выбор — строго последовательный pipeline:
 Это ровно то, что реализовано в ssd1306_ctrl.v через фазы PH_RENDER → PH_RENDW → PH_FRAME → PH_FRAMEW → PH_ANEXT → PH_RENDER → … (подробно в разделе 7). Разрешимые и неразрешимые нагрузки Полезно явно зафиксировать, что эта архитектура может и что не может в пределах одного кадра.
| Нагрузка |
Оценка тактов |
Решаемо? |
| Заливка всего экрана цветом |
1024 |
✓ тривиально |
| Несколько десятков линий |
~1500 |
✓ |
| Текст в 1–2 строки (до 20 символов) |
~300 |
✓ |
| Закрашенный прямоугольник |
128 × h |
✓ |
| Закрашенный круг (scanline-fill) |
~500 |
✓ возможно, но не реализовано |
| 3D-куб Y-rotation |
~750 |
✓ реализовано |
| 3D-куб с удалением невидимых граней (backface cull) |
+~100 |
✓ возможно |
| Bitmap-иконка 32×32 |
~64 байта × 4 такта = 256 |
✓ |
| Скролл-анимация текста (shift FB на 1 пиксель) |
~1024 × 4 |
⚠ на границе (4096 тактов ≈ 80 мкс) |
| Поворот 3D-объекта по 3 осям (не только Y) |
~2000 |
✓ |
| Текстурированный куб (texture mapping) |
~10000+ |
✗ слишком медленно |
| Растеризация закрашенных полигонов Z-buffer’ом |
~50000+ |
✗ не помещается в такте кадра |
Иначе говоря: векторная графика и статические bitmap’ы — да; сложная растровая графика с Z-буфером — нет (для этого нужен либо больший FPGA, либо GPU-ядро). Для наших задач (текст + куб) запас есть огромный. Итого
- «Без CPU» — архитектурный принцип, а не ограничение. Он заставляет нас разложить задачу на аппаратные примитивы и даёт взамен детерминизм + скорость.
- BRAM-фреймбуфер — единственный разумный способ для композиции сложных сцен; процедурный подход не масштабируется до 12 линий + текста.
- Dual-port M9K позволяет I2C-стриму и рендеру работать параллельно на уровне BRAM, даже при логической сериализации их FSM.
- Sequential pipeline render→TX даёт ~21 fps при минимальной сложности. Double buffering не нужен.
- Тайминг-бюджет 1900 тактов рендера на 2.3M тактов передачи — огромный запас, позволяющий свободно добавлять сцены (ещё текст, ещё объекты, flood-fill, и т. п.), пока не превысим ~1M тактов (что маловероятно).
Дальше — часть 6, где мы построчно разберём реализацию scene_renderer.v с таблицами sin/cos, vertex ROM, font ROM, Брезенхэмом и текст-blit’ом. Модуль scene_renderer - фрейм-рендер в BRAM Часть 5 обосновала, почему рендер сделан именно как фреймбуфер-в-BRAM с sequential pipeline. В этой части разберём что именно написано в файле quartus_ssd1306/src/scene_renderer.v построчно — с фрагментами кода и пояснением каждого решения. Модуль выглядит внушительно (~640 строк Verilog) за счёт больших case-таблиц ROM’ов (sin, font, vertex, edge), но логики в нём мало: ядро — это ~15 состояний FSM и ~30 регистров. Разложим его на логические блоки. Роль модуля и интерфейс scene_renderer подключается снаружи несколькими потоками сигналов:
 Интерфейс модуля из scene_renderer.v, строки 21–32:
module scene_renderer ( input wire clk_i, input wire rstn_i, input wire start_i, // 1-такт импульс запуска рендера input wire mode_i, // 0 = static (нижняя строка "STATIC") // 1 = animation (нижняя строка "ANIM", // и угол берётся из angle_i) input wire [5:0] angle_i, // 0..63 ≡ 0..2π, угол поворота куба по Y output reg ready_o, // 1 ↔ кадр готов, можно начинать I2C TX input wire [9:0] raddr_i, // порт чтения из FB для I2C-стрима output reg [7:0] rdata_o );
Назначение сигналов:
- clk_i / rstn_i — стандартные clock/reset. Синхронно с ядром шины (50 МГц).
- start_i — импульс «начать рендер кадра». FSM из S_IDLE переходит в S_CLEAR, параметры mode_i/angle_i защёлкиваются одновременно.
- mode_i — статический vs анимационный режим. Влияет на нижний текст (STATIC / ANIM). В static-режиме angle_i также учитывается — просто верхний уровень держит его постоянным.
- angle_i — 6-битный угол, 64 шага по окружности (один шаг ≈ 5.625°). Детализация умышленно грубая: рендер 1 + 46 ≈ 46 мс на кадр, а 64 шага дадут ~2.8 s на полный оборот — вполне плавно воспринимается глазом.
- ready_o — level-флаг «кадр готов». Устанавливается в 1 в S_DONE, сбрасывается при следующем start_i.
- raddr_i / rdata_o — независимый read-порт BRAM для I2C-передачи. Адрес — линейный 10-бит индекс 0..1023 (см. 2.3.3 про zero-copy mapping). Данные появляются с задержкой 1 такт (sync BRAM).
Центральная идея модуля: по команде start_i пересобрать весь кадр (128×64 пикселей = 1024 байта) в dual-port BRAM, потом держать его там, пока приходят raddr_i-запросы от верхнего уровня. Параметры и именованные константы Блок констант в начале модуля (строки 37–46):
localparam signed [7:0] S = 8'sd12; localparam [3:0] NUM_EDGES = 4'd12; localparam [6:0] TOP_COL0 = 7'd43; localparam [6:0] BOT_STA_COL0 = 7'd46; localparam [6:0] BOT_ANI_COL0 = 7'd52; localparam [2:0] TOP_PAGE = 3'd0; localparam [2:0] BOT_PAGE = 3'd7; localparam [2:0] TOP_LEN_M1 = 3'd6; // 7 chars − 1 localparam [2:0] STA_LEN_M1 = 3'd5; // 6 chars − 1 localparam [2:0] ANI_LEN_M1 = 3'd3; // 4 chars − 1
| Константа |
Значение |
Смысл |
| S |
signed 12 |
полуребро куба в координатах модели; т. е. куб 24×24×24 в model-space |
| NUM_EDGES |
12 |
число рёбер куба (4 сверху + 4 снизу + 4 вертикальных) |
| TOP_PAGE |
0 |
страница верхнего текста (самая верхняя полоска экрана) |
| BOT_PAGE |
7 |
страница нижнего текста (самая нижняя полоска) |
| TOP_COL0 |
43 |
стартовый столбец "SSD1306" (7 символов по 6 столбцов = 42 пикс; (128-42)/2 ≈ 43 — центровка) |
| BOT_STA_COL0 |
46 |
стартовый столбец "STATIC" (6 × 6 = 36; (128-36)/2 = 46) |
| BOT_ANI_COL0 |
52 |
стартовый столбец "ANIM" (4 × 6 = 24; (128-24)/2 = 52) |
| TOP_LEN_M1 |
6 |
длина "SSD1306" − 1 (используется как терминатор «всё, все буквы отрисованы») |
| STA_LEN_M1 |
5 |
длина "STATIC" − 1 |
| ANI_LEN_M1 |
3 |
длина "ANIM" − 1 |
Все эти числа известны на этапе компиляции. Менять текст или размер куба можно простой правкой localparam — FSM ничего не пересчитает «в runtime», но пересинтез займёт обычные ~10 секунд. Фреймбуфер — dual-port BRAM (строки 48–65)
reg [7:0] fb [0:1023]; reg [9:0] fb_waddr; reg [7:0] fb_wdata; reg fb_we; reg [9:0] fb_raddr_rmw; reg [7:0] fb_rdata_rmw; always @(posedge clk_i) begin if (fb_we) fb[fb_waddr] <= fb_wdata; rdata_o <= fb[raddr_i]; fb_rdata_rmw <= fb[fb_raddr_rmw]; end
Это реализация BRAM’а, подробно разобранная в 5.3:
- fb — массив 1024×8 бит. Quartus infers его как один M9K-блок (9 216 бит; мы используем 8 192).
- Один write-порт: fb_we/fb_waddr/fb_wdata — управляется FSM рендера.
- Два read-порта:
- rdata_o ← fb[raddr_i] — для внешнего чтения (I2C TX).
- fb_rdata_rmw ← fb[fb_raddr_rmw] — для внутреннего RMW-чтения (см. стадию S_EDGE_*).
- Read-latency у обоих read-портов — 1 такт: на такте N выставлен адрес, на такте N+1 данные — в регистре.
Вся логика записи и чтения умещается в один always с обычным positive-edge триггером, без initial-инициализации (содержимое при загрузке FPGA undefined и обнуляется фазой S_CLEAR). sin/cos LUT - quarter-wave symmetry (строки 68-107) Для поворота куба нужны значения sin и cos. Мы не можем позволить себе CORDIC или ряд Тейлора в железе — сложно и долго. Поэтому используем lookup-таблицу 17 × 9-бит signed:
function signed [8:0] sin_q; input [4:0] q; // q ∈ 0..16 case (q) 5'd0 : sin_q = 9'sd0; 5'd1 : sin_q = 9'sd12; 5'd2 : sin_q = 9'sd25; 5'd3 : sin_q = 9'sd37; 5'd4 : sin_q = 9'sd49; 5'd5 : sin_q = 9'sd60; 5'd6 : sin_q = 9'sd71; 5'd7 : sin_q = 9'sd81; 5'd8 : sin_q = 9'sd90; 5'd9 : sin_q = 9'sd98; 5'd10: sin_q = 9'sd106; 5'd11: sin_q = 9'sd112; 5'd12: sin_q = 9'sd117; 5'd13: sin_q = 9'sd122; 5'd14: sin_q = 9'sd125; 5'd15: sin_q = 9'sd126; 5'd16: sin_q = 9'sd127; default: sin_q = 9'sd0; endcase endfunction
sin_q(q) = round(127 · sin(q · π/32)), для четверти окружности (0 → π/2 в 17 шагов). Q0.7 fixed-point — максимум ±127 помещается в знаковом 8-битном числе (но для удобства арифметики берём 9-битное signed, чтобы корректно работало -sin_q(qi) у границы −128..127). Зачем 17 значений, а не все 64? — quarter-wave symmetry. sin имеет две симметрии:
- Относительно π/2: sin(π/2 + x) = sin(π/2 − x) (в рамках одной четверти это «зеркалит» вторую половину первой четверти к первой).
- Относительно π: sin(π + x) = −sin(x) (третья и четвёртая четверти — просто минус первой и второй).
Используя эти свойства, из 17 значений можно восстановить всю 64-точечную таблицу. Экономия: ~74% на размере ROM. В терминах Cyclone IV это 17 case-веток LUT против 64 — разница в ~50 LE. sin6 — обёртка, которая по полному углу 0..63 достаёт нужное значение с правильным знаком:
function signed [8:0] sin6; input [5:0] theta; reg [4:0] qi; reg signed [8:0] mag; begin qi = theta[4] ? (5'd16 - {1'b0, theta[3:0]}) : {1'b0, theta[3:0]}; mag = sin_q(qi); sin6 = theta[5] ? -mag : mag; end endfunction
Разбор:
- theta[5] — старший бит: он переключает знак (третья/четвёртая четверти → −mag).
- theta[4] — средний бит: мы в первой или второй «половине» полупериода. theta[4]=0 — прямой обход (0..15 → qi=0..15); theta[4]=1 — зеркальный (16..31 → qi=16..1).
- theta[3:0] — младшие 4 бита: индекс внутри четверти 0..15.
- qi — нормализованный индекс для sin_q, значение 0..16.
cos6 — тривиально через сдвиг фазы на π/2 (= 16 шагов):
function signed [8:0] cos6; input [5:0] theta; cos6 = sin6(theta + 6'd16); // cos(θ) = sin(θ + π/2) endfunction
Вся триада sin_q → sin6 → cos6 — это чистая комбинаторика (synthesizable functions), синтезатор раскрутит их в LUT-дерево глубиной 2–3 уровня. Геометрия куба - vertex ROM и edge ROM (строки 110-192) Куб центрирован в (0, 0, 0) с полуребром S = 12. Координаты каждой из 8 вершин хранятся в трёх ROM’ах vx_rom/vy_rom/vz_rom:
function signed [7:0] vx_rom; input [2:0] i; case (i) 3'd0: vx_rom = -S; 3'd1: vx_rom = S; 3'd2: vx_rom = S; 3'd3: vx_rom = -S; 3'd4: vx_rom = -S; 3'd5: vx_rom = S; 3'd6: vx_rom = S; 3'd7: vx_rom = -S; endcase endfunction // vy_rom, vz_rom аналогично
Суммарно получается таблица:
| i |
vx |
vy |
vz |
Вершина (в model-space) |
| 0 |
−S |
−S |
−S |
верх-зад-лево |
| 1 |
+S |
−S |
−S |
верх-зад-право |
| 2 |
+S |
−S |
+S |
верх-перед-право |
| 3 |
−S |
−S |
+S |
верх-перед-лево |
| 4 |
−S |
+S |
−S |
низ-зад-лево |
| 5 |
+S |
+S |
−S |
низ-зад-право |
| 6 |
+S |
+S |
+S |
низ-перед-право |
| 7 |
−S |
+S |
+S |
низ-перед-лево |
Соглашение осей: +X — вправо, +Y — вниз (экранное), +Z — на зрителя. Верхняя грань куба — вершины 0..3, нижняя — 4..7; парами «над-под» идут (0,4), (1,5), (2,6), (3,7). edge_v0(e)/edge_v1(e) — таблицы, дающие по номеру ребра e ∈ 0..11 два номера вершин v0/v1, которые оно соединяет:
function [2:0] edge_v0; input [3:0] e; case (e) 4'd0: edge_v0 = 3'd0; 4'd1: edge_v0 = 3'd1; ... // всего 12 записей endcase endfunction // edge_v1 аналогично, с парными номерами
Структура 12 рёбер:
| e |
v0 → v1 |
Тип ребра |
| 0 |
0 → 1 |
верхнее заднее |
| 1 |
1 → 2 |
верхнее правое |
| 2 |
2 → 3 |
верхнее переднее |
| 3 |
3 → 0 |
верхнее левое |
| 4 |
4 → 5 |
нижнее заднее |
| 5 |
5 → 6 |
нижнее правое |
| 6 |
6 → 7 |
нижнее переднее |
| 7 |
7 → 4 |
нижнее левое |
| 8 |
0 → 4 |
вертикальное зад-лево |
| 9 |
1 → 5 |
вертикальное зад-право |
| 10 |
2 → 6 |
вертикальное перед-право |
| 11 |
3 → 7 |
вертикальное перед-лево |
4 верхних + 4 нижних + 4 вертикальных = 12, как и положено у куба. Шрифт 5х7 и лексиконы (строки 194-320) 16 глифов, адресуемых по 4-битному char-номеру:
0:'S' 1:'D' 2:'1' 3:'3' 4:'0' 5:'6' 6:' ' 7:'C' 8:'U' 9:'B' 10:'E' 11:'A' 12:'I' 13:'T' 14:'N' 15:'M'
Каждый глиф — 5 столбцов по 7 бит (биты 0..6; бит 7 = 0, для падинга). Адрес внутри ROM — 7-битный {char[3:0], col[2:0]}, т. е. каждому символу отведено 8 столбцов (5 значащих + 3 пустых). Это делается для удобства адресной арифметики: адрес кратен 8, сдвиг на 3 бита = умножение.
function [7:0] font_byte; input [6:0] addr; case (addr) // 'S' — код символа 0 7'd0: font_byte = 8'h26; 7'd1: font_byte = 8'h49; 7'd2: font_byte = 8'h49; 7'd3: font_byte = 8'h49; 7'd4: font_byte = 8'h32; // 'D' — код символа 1 7'd8: font_byte = 8'h7F; 7'd9: font_byte = 8'h41; 7'd10: font_byte = 8'h41; 7'd11: font_byte = 8'h41; 7'd12: font_byte = 8'h3E; ... endcase endfunction
Формат одного столбца: little-endian по вертикали — биты 0..6 соответствуют строкам сверху вниз (бит 0 — верхний пиксель). Это совпадает с раскладкой GDDRAM (см. 2.3.2), поэтому каждый байт glyph’а копируется в FB без битового reverse. Визуализация буквы 'S' (5 столбцов × 7 строк):
 Аналогично и для остальных 15 символов. Три функции-ROM отображают позицию в строке на код символа:
function [3:0] top_char; // "SSD1306" input [2:0] i; case (i) 3'd0: top_char = 4'd0; // S 3'd1: top_char = 4'd0; // S 3'd2: top_char = 4'd1; // D 3'd3: top_char = 4'd2; // 1 3'd4: top_char = 4'd3; // 3 3'd5: top_char = 4'd4; // 0 3'd6: top_char = 4'd5; // 6 endcase endfunction function [3:0] bot_sta_char; // "STATIC" case (i) 3'd0: bot_sta_char = 4'd0; // S 3'd1: bot_sta_char = 4'd13; // T 3'd2: bot_sta_char = 4'd11; // A 3'd3: bot_sta_char = 4'd13; // T 3'd4: bot_sta_char = 4'd12; // I 3'd5: bot_sta_char = 4'd7; // C endcase endfunction function [3:0] bot_ani_char; // "ANIM" case (i) 3'd0: bot_ani_char = 4'd11; // A 3'd1: bot_ani_char = 4'd14; // N 3'd2: bot_ani_char = 4'd12; // I 3'd3: bot_ani_char = 4'd15; // M endcase endfunction
Все — чистая комбинаторика, ~10 LE каждая. Регистры состояния FSM (строки 322-374)
reg signed [8:0] vtx_px [0:7]; // 8 вершин после поворота — X reg signed [8:0] vtx_py [0:7]; // Y reg [4:0] state; // 15 состояний FSM (5 бит с запасом) reg mode_r; // защёлкнутый mode_i reg [5:0] angle_r; // защёлкнутый angle_i // S_CLEAR reg [10:0] clr_idx; // 0..1023, потому 11 бит чтобы не обернуться // S_ROT_* reg signed [8:0] sin_th, cos_th; // sin/cos угла текущего кадра reg [2:0] vtx_idx; // 0..7 reg signed [7:0] cur_vx, cur_vy, cur_vz; reg signed [17:0] prod_xc, prod_zs, prod_xs, prod_zc; // 4 умножения // S_EDGE_* (Брезенхэм) reg [3:0] edge_idx; // 0..11 reg signed [8:0] bx, by; // текущий пиксель reg signed [8:0] bx_end, by_end; // конечный пиксель ребра reg signed [8:0] bdx; // +|dx| reg signed [8:0] bdy; // −|dy| reg signed [1:0] bsx, bsy; // ±1 для каждой оси reg signed [10:0] berr; // аккумулятор Брезенхэма reg [7:0] pix_mask; // 1 << by[2:0] // S_TEXT_* reg [2:0] txt_char_idx; // 0..7 (до TOP_LEN_M1) reg [2:0] txt_col_idx; // 0..4 — текущий столбец буквы reg txt_phase; // 0=top, 1=bottom
Итого ~35 регистров + массивы vtx_px/vtx_py (8×9 бит каждый) — они либо лягут в M9K, либо в LE как registered 16×9 RAM. В нашем случае (размер 8 элементов) Quartus обычно использует LE — экономически выгоднее, чем занимать M9K под 144 бита. Комбинационные хелперы (строки 376-436) Эти wire-выражения — комбинационные «функции», которые каждое состояние FSM использует, но сами они не имеют регистров. Commit поворота
wire signed [17:0] rot_xp_full = prod_xc - prod_zs; // vx·cos − vz·sin wire signed [17:0] rot_zp_full = prod_xs + prod_zc; // vx·sin + vz·cos wire signed [10:0] rot_xp = rot_xp_full[17:7]; // /128 wire signed [10:0] rot_zp = rot_zp_full[17:7]; // /128 wire signed [10:0] rot_px = rot_xp + 11'sd64; // +64: центр по X wire signed [10:0] cur_vy_ext = {{3{cur_vy[7]}}, cur_vy}; wire signed [10:0] rot_py = cur_vy_ext + (rot_zp >>> 2) + 11'sd32;
Ключевой момент — вся 2D-проекция сделана комбинационно. В S_ROT_STORE мы читаем rot_px/rot_py и защёлкиваем их в vtx_px[vtx_idx]/vtx_py[vtx_idx]. Формулы:
- x' = (vx·cos − vz·sin) / 128 + 64 — поворот вокруг Y + центровка (экран 128 пикселей по X, центр = 64).
- z' = (vx·sin + vz·cos) / 128 — глубина после поворота.
- y' = vy + z' / 4 + 32 — псевдо-3D проекция: вместо полного перспективного деления добавляем четверть z' к y. Это даёт эффект «передний план опущен вниз, задний — вверх». +32 — центр по Y (экран 64 пикселя, центр = 32).
Деление «/128» — арифметический сдвиг [17:7] на ширину. /4 — сдвиг >>> 2 (arithmetic right, сохранение знака для signed). Инициализация Брезенхэма
wire [2:0] e0 = edge_v0(edge_idx); wire [2:0] e1 = edge_v1(edge_idx); wire signed [8:0] p0x = vtx_px[e0]; wire signed [8:0] p0y = vtx_py[e0]; wire signed [8:0] p1x = vtx_px[e1]; wire signed [8:0] p1y = vtx_py[e1]; wire signed [8:0] init_dx = (p1x >= p0x) ? (p1x - p0x) : (p0x - p1x); wire signed [8:0] init_dy_n = (p1y >= p0y) ? (p0y - p1y) : (p1y - p0y); // ≤ 0 wire signed [1:0] init_sx = (p0x < p1x) ? 2'sd1 : -2'sd1; wire signed [1:0] init_sy = (p0y < p1y) ? 2'sd1 : -2'sd1; wire signed [10:0] init_err = {{2{init_dx[8]}}, init_dx} + {{2{init_dy_n[8]}}, init_dy_n};
Всё это — инициализация алгоритма Брезенхэма комбинаторно из двух вершин ребра (e0, e1). В S_EDGE_INIT эти значения защёлкиваются в регистры bx/by/bx_end/by_end/bdx/bdy/ bsx/bsy/berr. Обратите внимание на знаковый формат dy: init_dy_n специально сделан отрицательным (≤ 0), чтобы применить классическую формулу Брезенхэма с одним сумматором ошибки без ветвлений на знак (err += dx + dy работает и для роста X, и для убывания Y). Шаг Брезенхэма
wire signed [10:0] bdx_ext = {{2{bdx[8]}}, bdx}; wire signed [10:0] bdy_ext = {{2{bdy[8]}}, bdy}; wire signed [10:0] b_e2 = berr <<< 1; wire step_x = (b_e2 >= bdy_ext); wire step_y = (b_e2 <= bdx_ext); wire signed [8:0] bsx_ext = {{7{bsx[1]}}, bsx}; wire signed [8:0] bsy_ext = {{7{bsy[1]}}, bsy}; wire signed [10:0] berr_nx = berr + (step_x ? bdy_ext : 11'sd0) + (step_y ? bdx_ext : 11'sd0);
Это стандартное условие Брезенхэма:
- e2 = 2·err;
- если e2 ≥ dy_n (т. е. err положителен) — сделать шаг по X;
- если e2 ≤ dx — сделать шаг по Y;
- обновить err добавлением dy_n и/или dx в зависимости от того, какие шаги были сделаны.
Все *_ext-сигналы — это знаковое расширение узких полей до 11 бит, чтобы сравнения не переполнялись. Адрес пикселя и маска
wire in_screen = (bx >= 9'sd0) && (bx <= 9'sd127) && (by >= 9'sd0) && (by <= 9'sd63); wire [9:0] pix_addr = {by[5:3], bx[6:0]}; // page*128 + col wire [7:0] new_mask = 8'd1 << by[2:0]; // бит внутри столбца
Преобразование (bx, by) в (fb_addr, bit_mask) (см. 2.3.3):
- page = by[5:3] — деление y/8;
- col = bx[6:0] — просто x;
- fb_addr = {page, col} — конкатенация, эквивалентно page*128+col;
- bit_mask = 1 << by[2:0] — y mod 8 как позиция бита.
in_screen — боковая обрезка: если пиксель за пределами 128×64, мы не пишем, но алгоритм Брезенхэма всё равно продолжает шагать (см. S_EDGE_SETUP). Адрес текущего текстового символа
wire [3:0] cur_char = (!txt_phase) ? top_char(txt_char_idx) : (mode_r ? bot_ani_char(txt_char_idx) : bot_sta_char(txt_char_idx)); wire [2:0] cur_page = (!txt_phase) ? TOP_PAGE : BOT_PAGE; wire [6:0] cur_col0 = (!txt_phase) ? TOP_COL0 : (mode_r ? BOT_ANI_COL0 : BOT_STA_COL0); wire [2:0] cur_last = (!txt_phase) ? TOP_LEN_M1 : (mode_r ? ANI_LEN_M1 : STA_LEN_M1); wire [5:0] txt_char_mul6 = {txt_char_idx, 2'b00} + {1'b0, txt_char_idx, 1'b0}; wire [6:0] txt_base_col = cur_col0 + {1'b0, txt_char_mul6}; wire [6:0] txt_col = txt_base_col + {4'd0, txt_col_idx}; wire [9:0] txt_addr = {cur_page, txt_col};
txt_char_mul6 — хитрое умножение char_idx * 6 без DSP: char_idx · 4 + char_idx · 2 = (char_idx<<2) + (char_idx<<1). Конкатенация {idx, 2'b00} — это idx*4, {idx, 1'b0} — idx*2. Так мы получаем char*6 одним сумматором на 6 бит. FSM - общая структура (строка 442) Весь рендер — один большой case (state) с 15 состояниями:
 Общая обёртка имеет ещё один важный штрих в самом конце:
// pre-assign в начале else-ветки: fb_we по умолчанию = 0 fb_we <= 1'b0; case (state) ... endcase // post-assign: сбрасывает ready при повторном старте if (start_i && state != S_IDLE) ready_o <= 1'b0;
- fb_we <= 1'b0 — pre-assign, аналогичный тому, что делает done_o <= 0 в i2c_burst_writer: write-enable по умолчанию снят, и любое состояние, которое хочет записать, должно явно поставить fb_we <= 1'b1. Это защищает от случайной «утечки» write’а в неожиданных состояниях.
- post-assign if (start_i && state != IDLE) — нужен, чтобы при «повторном» запуске (например, если верхний уровень решил перезапустить рендер до того, как текущий закончился) флаг ready_o сбрасывался. В норме это не происходит.
Стадия S_IDLE - ожидание старта (строки 486-494)
S_IDLE: begin if (start_i) begin mode_r <= mode_i; angle_r <= angle_i; ready_o <= 1'b0; clr_idx <= 11'd0; state <= S_CLEAR; end end
Защёлкиваем mode_i/angle_i, сбрасываем ready_o и счётчик клирования, уходим в S_CLEAR. Важно, что mode_i/angle_i могут меняться у верхнего уровня между стартами (анимация инкрементит angle), но внутри одного кадра мы пользуемся защёлкнутыми значениями. Иначе рендер мог бы «поплыть» в середине. Стадия S_CLEAR - заливка FB нулями (строки 497-506)
S_CLEAR: begin fb_we <= 1'b1; fb_waddr <= clr_idx[9:0]; fb_wdata <= 8'h00; if (clr_idx == 11'd1023) begin clr_idx <= 11'd0; state <= S_ROT_SETUP; end else clr_idx <= clr_idx + 11'd1; end
Счётчик 0..1023, на каждом такте один write 0x00. Ровно 1024 такта = 20.5 мкс на 50 МГц. Почему не ускорить? Можно было бы сконфигурировать M9K как 256×32 (4 байта за такт) — получилось бы 256 тактов. Но:
- это изменит layout BRAM’а и усложнит read-порты;
- 20.5 мкс на clear при 46 мс кадра — 0.04%, экономить не на чем.
11-битный clr_idx нужен, чтобы корректно обернуться: `11'd1023
- 1 = 11'd1024, а если использовать 10-битный счётчик,10'd1023
- 1 = 10'd0— но мы проверяем== 1023` до инкремента, так что в принципе хватило бы и 10 бит. 11-битный — перестраховка на случай, если кто-то в будущем захочет увеличить FB.
Триада S_ROT_* — поворот и проекция S_ROT_SETUP (строки 509–514)
S_ROT_SETUP: begin sin_th <= sin6(angle_r); cos_th <= cos6(angle_r); vtx_idx <= 3'd0; state <= S_ROT_LOAD; end
1 такт. Защёлкиваем sin/cos угла текущего кадра. Далее во всём цикле по 8 вершинам эти значения не меняются. vtx_idx = 0. S_ROT_LOAD (строки 516–521)
S_ROT_LOAD: begin cur_vx <= vx_rom(vtx_idx); cur_vy <= vy_rom(vtx_idx); cur_vz <= vz_rom(vtx_idx); state <= S_ROT_MUL; end
Достаём базовые (vx, vy, vz) текущей вершины из ROM’ов. Поскольку vx_rom/vy_rom/vz_rom — комбинаторные функции, значения доступны в том же такте. S_ROT_MUL (строки 523–529)
S_ROT_MUL: begin prod_xc <= cur_vx * cos_th; prod_zs <= cur_vz * sin_th; prod_xs <= cur_vx * sin_th; prod_zc <= cur_vz * cos_th; state <= S_ROT_STORE; end
Четыре умножения signed 8×9 → 18 бит параллельно. Каждое из них синтезируется как DSP-блок 9×9 в Cyclone IV. EP4CE10 имеет 23 таких блока; мы используем 4 одновременно — ~17% DSP. S_ROT_STORE (строки 531–541)
S_ROT_STORE: begin vtx_px[vtx_idx] <= rot_px[8:0]; vtx_py[vtx_idx] <= rot_py[8:0]; if (vtx_idx == 3'd7) begin edge_idx <= 4'd0; state <= S_EDGE_INIT; end else begin vtx_idx <= vtx_idx + 3'd1; state <= S_ROT_LOAD; end end
Защёлкиваем результат комбинационных хелперов из 6.8.1 в массив vtx_px/vtx_py. Затем либо следующая вершина (цикл через S_ROT_LOAD), либо выход на рёбра. Итого по ротации: 1 + 8 × 3 = 25 тактов на 8 вершин = 0.5 мкс. Куб целиком преобразуется меньше чем за полмикросекунды. Пенталогия S_EDGE_* — Брезенхэм по 12 рёбрам S_EDGE_INIT (строки 544–555)
S_EDGE_INIT: begin bx <= p0x; by <= p0y; bx_end <= p1x; by_end <= p1y; bdx <= init_dx; bdy <= init_dy_n; bsx <= init_sx; bsy <= init_sy; berr <= init_err; state <= S_EDGE_SETUP; end
Загружаем стартовую точку, конечную точку, dx, −dy, направления sx/sy и начальную ошибку err. Все эти значения заранее вычислены комбинаторно (см. 6.8.2) из vtx_px[e0]/vtx_py[e1]. S_EDGE_SETUP (строки 557–565)
S_EDGE_SETUP: begin if (in_screen) begin fb_raddr_rmw <= pix_addr; pix_mask <= new_mask; state <= S_EDGE_RD; end else begin state <= S_EDGE_STEP; // skip clip end end
Проверяем clip по экрану:
- В экране: запрашиваем чтение fb[pix_addr] (выставляем fb_raddr_rmw), защёлкиваем pix_mask, идём в S_EDGE_RD (ждать ответ BRAM).
- Вне экрана: пропускаем RD/WR, сразу в S_EDGE_STEP (сделать шаг Брезенхэма без записи).
S_EDGE_RD (строки 567–569)
S_EDGE_RD: begin state <= S_EDGE_WR; end
Пустой такт. Он нужен, потому что BRAM имеет latency 1: адрес выставили в S_EDGE_SETUP, данные в fb_rdata_rmw появятся через 1 такт. В S_EDGE_RD мы просто ждём, а в S_EDGE_WR используем эти данные. S_EDGE_WR (строки 571–576)
S_EDGE_WR: begin fb_we <= 1'b1; fb_waddr <= fb_raddr_rmw; fb_wdata <= fb_rdata_rmw | pix_mask; state <= S_EDGE_STEP; end
Read-modify-write: ставим на записываемый адрес тот же, с которого читали (fb_waddr <= fb_raddr_rmw), данные — старое значение | pix_mask (добавляем один бит). Это «рисует» один пиксель в FB, не трогая соседние 7 пикселей того же байта. S_EDGE_STEP (строки 578–595)
S_EDGE_STEP: begin if (bx == bx_end && by == by_end) begin if (edge_idx == (NUM_EDGES - 4'd1)) begin txt_phase <= 1'b0; txt_char_idx <= 3'd0; txt_col_idx <= 3'd0; state <= S_TEXT_INIT; end else begin edge_idx <= edge_idx + 4'd1; state <= S_EDGE_INIT; end end else begin berr <= berr_nx; if (step_x) bx <= bx + bsx_ext; if (step_y) by <= by + bsy_ext; state <= S_EDGE_SETUP; end end
Два случая:
- Достигли конца ребра (bx == bx_end && by == by_end):
- если это последнее ребро (edge_idx == 11) — переходим к рендерингу текста (S_TEXT_INIT);
- иначе — инкремент edge_idx, обратно в S_EDGE_INIT для следующего ребра.
- Не конец — классический шаг Брезенхэма:
- berr обновляется суммой bdy_ext/bdx_ext согласно флагам step_x/step_y (комб-выражения из 6.8.3);
- bx/by инкрементируются на ±1 соответственно;
- возврат в S_EDGE_SETUP для рисования следующего пикселя.
Итого по рёбрам: 12 рёбер × (средняя длина ~30 пикселей) × 3 такта/пиксель = ~1080 тактов. На практике чуть меньше (некоторые рёбра очень короткие после проекции), обычно ~720–1000 тактов = 14–20 мкс. Триада S_TEXT_* — рендер текста S_TEXT_INIT (строки 598–600)
S_TEXT_INIT: begin state <= S_TEXT_WR; end
Пустой такт для переключения состояния. Нужен, потому что txt_phase/txt_char_idx/txt_col_idx были сброшены в предыдущем состоянии (в S_EDGE_STEP при переходе), и комбинационные cur_char/cur_page/cur_col0/txt_addr должны «устояться». S_TEXT_WR (строки 602–607)
S_TEXT_WR: begin fb_we <= 1'b1; fb_waddr <= txt_addr; fb_wdata <= font_byte({cur_char, txt_col_idx}); state <= S_TEXT_NEXT; end
Один такт — одна запись в FB. Ключевая особенность: здесь нет RMW. Мы пишем байт целиком (fb_wdata <= font_byte(...)), предполагая, что в этом байте не было ничего важного до нас. Это законно, потому что:
- верхний текст в page 0, нижний — в page 7;
- куб в pages 2..5;
- страницы не пересекаются → текст не затирает пиксели куба.
Отсюда — 2× ускорение по сравнению с RMW: 1 такт на столбец буквы вместо 3 (READ → WAIT → WRITE). S_TEXT_NEXT (строки 609–628)
S_TEXT_NEXT: begin if (txt_col_idx == 3'd4) begin txt_col_idx <= 3'd0; if (txt_char_idx == cur_last) begin if (!txt_phase) begin txt_phase <= 1'b1; txt_char_idx <= 3'd0; state <= S_TEXT_WR; end else begin state <= S_DONE; end end else begin txt_char_idx <= txt_char_idx + 3'd1; state <= S_TEXT_WR; end end else begin txt_col_idx <= txt_col_idx + 3'd1; state <= S_TEXT_WR; end end
Вложенные счётчики «по столбцам / по буквам / по строкам»:
- Если txt_col_idx < 4 — просто инкрементим столбец, возвращаемся в S_TEXT_WR.
- Если столбец = 4 (последний в букве) — сбрасываем столбец в 0:
- если буква не последняя — инкрементим txt_char_idx, следующая буква;
- если буква последняя в строке — переключаем txt_phase на нижнюю строку (либо уходим в S_DONE, если нижняя уже отрисована).
Итого по тексту: верхняя строка 7 букв × 5 столбцов × 2 такта = 70 тактов; нижняя строка (STATIC = 6 букв × 5 = 60; ANIM = 4 × 5 = 40) — ~40–60 тактов. Суммарно ~110–130 тактов = 2.2–2.6 мкс. Стадия S_DONE (строки 630–633)
S_DONE: begin ready_o <= 1'b1; state <= S_IDLE; end
Один такт: поднимаем флаг «кадр готов» и возвращаемся в покой. Верхний уровень увидит ready_o = 1 на следующем такте и начнёт PH_FRAME. Внешний read-port для I2C TX Ранее мы уже показали код dual-port BRAM. Ключевой момент: пока ssd1306_ctrl через raddr_i читает байты фреймбуфера для I2C-передачи, мы в scene_renderer ничего не делаем — FSM сидит в S_IDLE с ready_o = 1. Следующий start_i придёт только после того, как ssd1306_ctrl завершит передачу (PH_FRAMEW → PH_ANEXT → PH_RENDER → новый start_i). В теории между S_DONE и следующим S_IDLE → start_i FSM могла бы уже начать перерисовывать кадр N+1 в тот же FB — но это бы затёрло байты, которые ещё не переданы по I2C. Поэтому ssd1306_ctrl сериализует: сначала TX завершается, потом новый start_i в рендер. Ресурсы и характеристики модуля Реалистичная оценка утилизации scene_renderer на Cyclone IV EP4CE10:
| Ресурс |
Использовано |
Комментарий |
| Logic Elements (LE) |
~900 |
FSM + комб-хелперы + font/sin/vertex LUT |
| Registers |
~150 |
state, bx/by/..., vtx_px/py (144 бит), счётчики |
| M9K blocks |
1 |
FB 1024×8 |
| 9×9 multipliers |
4 |
один такт в S_ROT_MUL, остальное время не заняты |
| Fmax |
>80 МГц |
комб. пути Брезенхэма и rot-commit — < 6 нс |
Производительность рендера кадра:
| Фаза |
Тактов |
@50 МГц |
| S_CLEAR |
1024 |
20.5 мкс |
| S_ROT_* (8 × 3 + 1) |
25 |
0.5 мкс |
| S_EDGE_* (12 × ~30 × 3) |
~900 |
18 мкс |
| S_TEXT_* (верх + низ) |
~120 |
2.4 мкс |
| S_DONE |
1 |
0.02 мкс |
| Всего |
~2070 |
~41 мкс |
На фоне 46 мс I2C-передачи — ~0.09% времени кадра. Рендер не является узким местом; узкое место — физическая частота SCL = 200 кГц. Хочется выше fps — либо ускоряем I2C (до 400 кГц Fast-mode), либо переходим на SPI. Что можно (и нельзя) добавить в рендер Архитектура оставляет большой простор для расширений: Легко добавить:
- ещё одну строку текста (больше txt_phase → 2 бита, новый лексикон);
- 2D-примитивы: прямоугольник (fill page-aligned rows), круг (Bresenham variant);
- простую анимацию иконок (bitmap в ROM, blit как текст);
- поворот куба по двум осям (+sin_phi/cos_phi, +2 умножения).
Сложнее, но можно:
- фиксированные перспективные проекции (деление замещается умножением на 1/z из LUT);
- несколько объектов (нужен список объектов в ROM);
- «flood fill» замкнутой области (сканлайн-fill по страницам).
Не влезет без radical redesign:
- полноценный 3D-рендер с Z-buffer’ом (нужно + 1 M9K под Z-буфер,
- умножения для интерполяции);
- сглаживание (anti-aliasing) — требует 2-битного или даже 4-битного канала прозрачности, что несовместимо с 1bpp OLED;
- текстурированные полигоны — смотри полноценный GPU.
Для нашей образовательной задачи (текст + куб) архитектура избыточна, и это хорошо: есть куда расти без переделки основы. Модуль ssd1306_ctrl — оркестратор Модуль quartus_ssd1306/src/ssd1306_ctrl.v — верхнеуровневый оркестратор всей системы SSD1306-рендера. Если i2c_burst_writer знает только «как слать WRITE-burst по I2C», а scene_renderer — «как нарисовать кадр в BRAM», то ssd1306_ctrl знает про SSD1306: в каком порядке делать init, render, frame TX, как обрабатывать ошибки, как реагировать на кнопки и как включать/выключать анимацию. Это самый «богатый по логике» модуль проекта (~380 строк), но структурно простой: одно большое FSM по фазам PH_* плюс три вспомогательные структуры (init ROM, data-source mux, src_idx counter). Ниже — разбор по блокам.
 ssd1306_ctrl — единственный модуль, знающий:
- что у SSD1306 I2C-адрес 0x3C, что control-byte для frame — 0x40, что init-последовательность — 32 байта;
- что перед первой командой после POR нужно ждать 100 мс;
- что анимация идёт через инкремент angle между кадрами;
- что при NACK нужно сбросить inited и при повторном нажатии пройти полный init-цикл заново.
Остальные модули (i2c_master_core, i2c_burst_writer, scene_renderer) — общие, без знаний о SSD1306, и могут быть переиспользованы для других устройств.
module ssd1306_ctrl #( parameter CLK_FREQ = 50_000_000, parameter I2C_ADDR = 7'h3C, parameter DELAY_MS = 100 )( input wire clk_i, input wire rstn_i, input wire start_i, // KEY2 — статический кадр input wire anim_i, // KEY3 — анимация / стоп output wire busy_o, output reg done_o, output reg err_o, output reg [10:0] progress_o, // текущий src_idx (для 7-seg) output wire animating_o, // i2c_master_core command interface — прокси через burst_writer output wire cmd_valid_o, output wire [2:0] cmd_o, output wire [7:0] din_o, input wire ready_i, input wire rx_ack_i, input wire arb_lost_i, output wire arb_lost_clear_o );
Три параметра:
| Параметр |
По умолчанию |
Смысл |
| CLK_FREQ |
50 000 000 |
частота clk_i в Гц. Используется для расчёта DELAY_CYCLES |
| I2C_ADDR |
7'h3C |
7-битный I2C-адрес SSD1306 |
| DELAY_MS |
100 |
задержка после POR перед первой командой |
progress_o[10:0] — 11-битный счётчик текущего src_idx (0..1024), выводится на 7-сегментные индикаторы top-level’ом для визуализации «сколько байт передано». При init — пробегает 0..32, при frame — 0..1024. Init ROM (строки 55–98) 32-байтовая таблица init-последовательности, защитная переменная и функция-ROM:
localparam [5:0] INIT_LEN = 6'd32; function [7:0] init_rom; input [5:0] idx; case (idx) 6'd0: init_rom = 8'h00; // Control byte: command stream 6'd1: init_rom = 8'hAE; // Display OFF 6'd2: init_rom = 8'hD5; // Set display clock divide 6'd3: init_rom = 8'h80; 6'd4: init_rom = 8'hA8; // Set multiplex ratio ... 6'd31: init_rom = 8'hAF; // Display ON default: init_rom = 8'h00; endcase endfunction
Полное содержимое и назначение каждого байта уже разобраны в разделе 2.5 (пять логических групп команд + итоговая таблица 2.5.3). Здесь важно только структурное:
- control-byte 0x00 идёт как idx=0 — это не отдельный сигнал, а просто первый байт ROM’а. Благодаря этому в data-source mux (см. 7.6) не нужен дополнительный условный мультиплексор для init — просто bw_data = init_rom(src_idx).
- Длина ровно 32 = INIT_LEN, передаётся в bw_byte_count при старте init-транзакции.
- default: init_rom = 8'h00 — страховка от случайного идущего дальше индекса (теоретически не достижимо, т. к. byte_count = 32 → burst-writer остановится на idx=31).
Фаза-FSM: состояния и регистры (строки 103–130)
localparam [10:0] DATA_LEN = 11'd1025; // 1 control-byte + 1024 пикселя localparam [3:0] PH_IDLE = 4'd0, PH_DELAY = 4'd1, PH_INIT = 4'd2, PH_INITW = 4'd3, PH_RENDER = 4'd4, PH_RENDW = 4'd5, PH_FRAME = 4'd6, PH_FRAMEW = 4'd7, PH_ANEXT = 4'd8, PH_OK = 4'd9, PH_ERR = 4'd10; reg [3:0] phase; reg [22:0] delay_cnt; reg [10:0] src_idx; reg inited; reg mode; reg anim_run; reg [5:0] angle; localparam [22:0] DELAY_CYCLES = (CLK_FREQ / 1000) * DELAY_MS;
11 состояний FSM (4 бита):
| Фаза |
Что делает |
| PH_IDLE |
покой, ждём нажатия KEY2 / KEY3 |
| PH_DELAY |
100 мс POR-задержка, один раз после сброса |
| PH_INIT |
асинхронный pulse bw_start = 1 для init-транзакции |
| PH_INITW |
ждём bw_done от init; ошибка → PH_ERR |
| PH_RENDER |
pulse scene_start = 1 для рендера |
| PH_RENDW |
ждём scene_ready |
| PH_FRAME |
pulse bw_start = 1 для frame-TX |
| PH_FRAMEW |
ждём bw_done от frame; ошибка → PH_ERR |
| PH_ANEXT |
инкремент angle, обратно в PH_RENDER |
| PH_OK |
успешное завершение; принимает новые команды |
| PH_ERR |
ошибка; принимает повторный запуск с inited=0 |
Регистры состояния:
| Регистр |
Ширина |
Назначение |
| phase |
4 бит |
текущее состояние FSM |
| delay_cnt |
23 бит |
счётчик POR-задержки (DELAY_CYCLES = 5 000 000, 23 бит хватает) |
| src_idx |
11 бит |
индекс текущего байта в транзакции; 0..32 на init, 0..1024 на frame |
| inited |
1 бит |
флаг «чип инициализирован» — живёт между транзакциями |
| mode |
1 бит |
передаётся в scene_renderer.mode_i — 0=static, 1=anim |
| anim_run |
1 бит |
идёт ли сейчас анимационный цикл |
| angle |
6 бит |
текущий угол куба, 0..63 ≡ 0..2π |
Расчёт DELAY_CYCLES: 100 мс × 50 МГц = 5 000 000 тактов. Это параметризовано через CLK_FREQ и DELAY_MS, т. е. при сборке под другую частоту (например, 100 МГц) значение автоматически пересчитается. Формула (CLK_FREQ / 1000) * DELAY_MS — сначала делим на 1000 (чтобы получить кГц), потом умножаем на мс. Подключение scene_renderer (строки 145–159)
reg scene_start; wire scene_ready; wire [7:0] scene_rdata; wire [9:0] scene_raddr = (src_idx == 11'd0) ? 10'd0 : (src_idx[9:0] - 10'd1); scene_renderer u_scene ( .clk_i (clk_i), .rstn_i (rstn_i), .start_i (scene_start), .mode_i (mode), .angle_i (angle), .ready_o (scene_ready), .raddr_i (scene_raddr), .rdata_o (scene_rdata) );
Ключевая деталь — вычисление scene_raddr:
scene_raddr = (src_idx == 0) ? 0 : (src_idx - 1);
Почему src_idx - 1, а не просто src_idx? Потому что в frame-транзакции:
- src_idx = 0 соответствует байту control-byte 0x40, а не первому пикселю. Его читать не из BRAM — его подмешивает контроллер (см. 7.6).
- src_idx = 1..1024 соответствует пикселям fb[0..1023].
Сдвиг src_idx - 1 даёт правильное отображение «запрос № N по I2C = байт № (N-1) в BRAM». При src_idx = 0 выражение src_idx - 10'd1 даст 1023 (wrap), но результат на этом такте всё равно не используется — комб-мукс data_byte на этом такте выдаёт 0x40, а scene_rdata игнорируется. Сторожевое src_idx == 0 ? 0 : ... нужно не для корректности read’а (он и так не используется), а для временно́го: если scene_raddr = 1023 на первом такте frame, BRAM начнёт на следующем такте выдавать fb[1023] (последний байт), а мы в этот момент хотим как раз fb[0]. Поэтому на такте 0 мы выставляем raddr=0 заранее, чтобы на такте 1 уже получить fb[0] в scene_rdata. Data-source mux (строки 167–169) Ключевая строка — мультиплексор источника данных для i2c_burst_writer:
wire [7:0] init_byte = init_rom(src_idx[5:0]); wire [7:0] data_byte = (src_idx == 11'd0) ? 8'h40 : scene_rdata; wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte;
Три яруса мультиплексирования:
- init_byte — первая ветка, в init-режиме. Просто читает init_rom(src_idx[5:0]). Тут нет ни BRAM’а, ни control-byte’а (он уже зашит в init_rom[0] = 0x00).
- data_byte — ветка в frame-режиме. Два случая:
- src_idx == 0 → выдаём 0x40 (frame control-byte);
- src_idx >= 1 → выдаём scene_rdata (читаем из BRAM через scene_raddr = src_idx - 1).
- bw_data — общий выбор между init и frame по текущей фазе.
Таблица, показывающая, что именно отдаёт mux по мере бега src_idx:
| Фаза |
src_idx |
bw_data |
| PH_INITW |
0 |
init_rom[0] = 0x00 (control-byte) |
| PH_INITW |
1 |
init_rom[1] = 0xAE (Display OFF) |
| PH_INITW |
2..31 |
init_rom[2..31] (остальные команды) |
| PH_FRAMEW |
0 |
0x40 (frame control-byte) |
| PH_FRAMEW |
1 |
scene_rdata (адрес = 0, byte fb[0]) |
| PH_FRAMEW |
2..1024 |
scene_rdata (адрес = idx-1, fb[1..1023]) |
src_idx — счётчик позиции в транзакции (строки 180–187)
always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) src_idx <= 11'd0; else if (phase == PH_INIT || phase == PH_FRAME) src_idx <= 11'd0; // новый burst → начать с 0 else if (bw_data_req) src_idx <= src_idx + 11'd1; // инкремент на каждом запросе end
Это отдельный синхронный блок, работающий параллельно с основным FSM. Правила:
- При сбросе — src_idx = 0.
- При входе в PH_INIT/PH_FRAME — src_idx = 0 (начало новой транзакции). Заметьте, что это однократный тик: эти фазы сразу же переходят в PH_INITW/PH_FRAMEW на следующем такте.
- Во всех остальных фазах — инкремент на каждый bw_data_req = 1 (запрос следующего байта от burst-writer’а).
Важно: src_idx сбрасывается в PH_INIT/PH_FRAME, а не в PH_IDLE. Это потому, что в PH_IDLE мы ещё ничего не запрашиваем — состояние счётчика неважно. Зато между фазами PH_INITW и PH_FRAMEW счётчик может сохранять старое значение — но оно всё равно будет сброшено в начале каждой новой транзакции. Сброс происходит одновременно с ассертом bw_start, а burst-writer тратит несколько тактов в S_START_CMD → S_ADDR_CMD перед первым data_req_o, поэтому к моменту первого запроса src_idx уже стабильно = 0. Почему связка «BRAM 1-такт + burst I2C» работает Ключевой вопрос: BRAM даёт задержку 1 такт между подачей адреса и выходом данных, а burst-writer ожидает данные в тот же такт, когда поднимает bw_data_req. Как это согласуется? Ответ — огромный интервал между запросами. Между двумя последовательными data_req_o burst-writer проводит:
- ~9 I2C-бит × 4 ena-тика × 125 такт/ena ≈ 4500 тактов на передачу одного байта по I2C;
- 1–2 такта на CMD/WAIT-переходы;
- задержка BRAM = 1 такт.
Ровно 1 такт задержки BRAM полностью растворяется в 4500 тактах между запросами. Логика работает так:
такт N: src_idx = K; scene_raddr = K-1; такт N+1: (BRAM обновился) scene_rdata = fb[K-1]; такт N+2: bw_data_req pulse → byte latched as data_i = fb[K-1] ✓ ... 4500 тактов I2C ... такт N+4502: src_idx = K+1; scene_raddr = K; такт N+4503: scene_rdata = fb[K]; такт N+4504: next data_req → byte fb[K] ✓
В момент, когда burst-writer реально нуждается в новом байте, BRAM уже давно (на 4500 тактов раньше) обновил scene_rdata под актуальный адрес. Поэтому data_valid_i = bw_data_req работает без проблем — это комбинаторное равенство (строка 372). Единственный крайний случай — первый запрос при src_idx = 0: нужен 0x40, а не содержимое BRAM. Обрабатывается комб-муксом data_byte (см. 7.6). К моменту второго запроса (src_idx = 1) BRAM уже подал fb[0] на scene_rdata. Комбинационные выходы busy_o, animating_o, arb_lost_clear_o (строки 171–177)
assign busy_o = (phase != PH_IDLE && phase != PH_OK && phase != PH_ERR); assign animating_o = anim_run; wire trigger = start_i || anim_i; assign arb_lost_clear_o = trigger && (phase == PH_IDLE || phase == PH_OK || phase == PH_ERR);
busy_o — модуль занят транзакцией (init / render / frame). На 7-segment / LED top-level-а индицирует «идёт работа». В фазах покоя (IDLE, OK, ERR) ложен. animating_o — дублирует anim_run. Зажигает отдельный LED «идёт анимация», чтобы пользователь видел разницу между статикой и анимацией. arb_lost_clear_o — сигнал для i2c_master_core, очищающий sticky-флаг arb-lost. Активируется при нажатии любой кнопки в «пассивных» фазах (IDLE/OK/ERR). Логика: если предыдущая транзакция провалилась из-за arb-lost, флаг в ядре залип; при повторном нажатии кнопки мы его очищаем и начинаем новую транзакцию «с чистого листа». Счётчик anim_stop_req (строки 190–198)
reg anim_stop_req; always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) anim_stop_req <= 1'b0; else if (anim_i && anim_run) anim_stop_req <= 1'b1; else if (phase == PH_OK || phase == PH_IDLE) anim_stop_req <= 1'b0; end
Отдельный однобитный регистр для обработки «второго нажатия KEY3»:
- Если пользователь нажимает KEY3 во время активной анимации (anim_i && anim_run) → запоминаем запрос на остановку (anim_stop_req = 1).
- Остановка срабатывает в PH_FRAMEW: если anim_run && !anim_stop_req → уходим в PH_ANEXT (продолжаем анимацию); иначе → PH_OK (стоп).
- Очистка флага — в PH_OK или PH_IDLE (когда анимация уже остановлена).
Почему так? Если пользователь нажал KEY3 в середине передачи кадра, мы не прерываем кадр — это оставило бы I2C-шину в неопределённом состоянии. Флаг anim_stop_req просто «запоминает» намерение, а реальный exit происходит на границе кадра, когда передача завершена корректно. Главный FSM — сброс (строки 204–216)
always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) begin phase <= PH_IDLE; delay_cnt <= 23'd0; bw_start <= 1'b0; bw_byte_count <= 16'd0; scene_start <= 1'b0; done_o <= 1'b0; err_o <= 1'b0; progress_o <= 11'd0; inited <= 1'b0; mode <= 1'b0; anim_run <= 1'b0; angle <= 6'd0; end else begin bw_start <= 1'b0; // pre-assign — одноцикловые импульсы scene_start <= 1'b0; done_o <= 1'b0; ...
Асинхронный сброс ставит FSM в PH_IDLE, всё очищает. В начале else-ветки — три pre-assign’а (bw_start, scene_start, done_o → 0), обеспечивающих одноцикловые импульсы (тот же паттерн, что и в i2c_burst_writer, см. 4.5). Заметьте, что inited не сбрасывается pre-assign’ом — он level-флаг, живёт между фазами. Стадия PH_IDLE (строки 224–248)
PH_IDLE: begin if (start_i) begin mode <= 1'b0; anim_run <= 1'b0; angle <= 6'd0; err_o <= 1'b0; if (inited) phase <= PH_RENDER; else begin delay_cnt <= DELAY_CYCLES; phase <= PH_DELAY; end end else if (anim_i) begin mode <= 1'b1; anim_run <= 1'b1; angle <= 6'd0; err_o <= 1'b0; if (inited) phase <= PH_RENDER; else begin delay_cnt <= DELAY_CYCLES; phase <= PH_DELAY; end end end
Две симметричные ветки по нажатию KEY2 (start) и KEY3 (anim), отличия только в mode и anim_run:
- KEY2 (start_i): mode=0, anim_run=0 → один статический кадр.
- KEY3 (anim_i): mode=1, anim_run=1 → циклическая анимация.
В обоих случаях: очищаем предыдущую ошибку (err_o=0), обнуляем угол (angle=0), переходим либо в PH_RENDER (если inited), либо в PH_DELAY (первый запуск после reset). Стадия PH_DELAY (строки 251–256)
PH_DELAY: begin if (delay_cnt == 23'd0) phase <= PH_INIT; else delay_cnt <= delay_cnt - 23'd1; end
Простой 23-битный счётчик. При CLK_FREQ=50 МГц, DELAY_MS=100: DELAY_CYCLES = 5 000 000 — отсчитываем 100 мс. Затем переходим в PH_INIT. Эта фаза один раз после power-on, плюс каждый раз после PH_ERR (сброс флага inited → повторный init с задержкой). Пара PH_INIT / PH_INITW (строки 259–277)
PH_INIT: begin bw_start <= 1'b1; bw_byte_count <= {10'd0, INIT_LEN}; phase <= PH_INITW; end PH_INITW: begin progress_o <= src_idx; if (bw_done) begin if (bw_error) begin err_o <= 1'b1; inited <= 1'b0; phase <= PH_ERR; end else begin inited <= 1'b1; phase <= PH_RENDER; end end end
PH_INIT — одноцикловое состояние: выставляет bw_start = 1 (на один такт благодаря pre-assign bw_start <= 0) и переходит в PH_INITW. PH_INITW — ожидание завершения:
- каждый такт обновляет progress_o текущим src_idx (для визуализации на 7-сегменте);
- по bw_done:
- если ошибка (NACK / arb-lost) → PH_ERR, inited = 0;
- иначе → PH_RENDER, inited = 1.
Пара PH_RENDER / PH_RENDW (строки 280–288)
PH_RENDER: begin scene_start <= 1'b1; phase <= PH_RENDW; end PH_RENDW: begin if (scene_ready) phase <= PH_FRAME; end
Симметрично init: PH_RENDER выставляет scene_start на 1 такт (pre-assign обеспечивает), PH_RENDW ждёт scene_ready. ~41 мкс рендера по факту (см. 6.17). Ошибки рендера в принципе невозможны — FSM scene_renderer детерминирован, всегда дойдёт до S_DONE. Пара PH_FRAME / PH_FRAMEW (строки 291–312)
PH_FRAME: begin bw_start <= 1'b1; bw_byte_count <= {5'd0, DATA_LEN}; // 16-bit = 0x0401 = 1025 phase <= PH_FRAMEW; end PH_FRAMEW: begin progress_o <= src_idx; if (bw_done) begin if (bw_error) begin err_o <= 1'b1; anim_run <= 1'b0; phase <= PH_ERR; end else if (anim_run && !anim_stop_req) phase <= PH_ANEXT; else begin done_o <= 1'b1; anim_run <= 1'b0; phase <= PH_OK; end end end
PH_FRAME — одноцикловый pulse bw_start + установка bw_byte_count = 1025 (control-byte + 1024 пикселя). PH_FRAMEW — трёхветочный exit:
- Ошибка (bw_error) — PH_ERR, anim_run=0. Если у нас был цикл анимации, он тоже прерывается.
- Нормально + анимация + не-стоп — PH_ANEXT (инкремент угла, новый кадр).
- Нормально + не-анимация или стоп-запрос — PH_OK с pulse’ом done_o.
Стадия PH_ANEXT (строки 315–318)
PH_ANEXT: begin angle <= angle + 6'd1; phase <= PH_RENDER; end
Инкремент angle по модулю 64 (auto-wrap 6-битного сложения). Полный оборот за 64 кадра = 64 × 46 мс ≈ 3 секунды. Плавно и приятно для глаза. Стадия PH_OK (строки 321–337)
PH_OK: begin if (start_i) begin mode <= 1'b0; anim_run <= 1'b0; angle <= 6'd0; err_o <= 1'b0; phase <= inited ? PH_RENDER : PH_DELAY; if (!inited) delay_cnt <= DELAY_CYCLES; end else if (anim_i) begin mode <= 1'b1; anim_run <= 1'b1; angle <= 6'd0; err_o <= 1'b0; phase <= inited ? PH_RENDER : PH_DELAY; if (!inited) delay_cnt <= DELAY_CYCLES; end end
Полный аналог PH_IDLE, но с одним ключевым отличием: не сбрасывает inited. Благодаря этому:
- Если inited = 1 (чип уже инициализирован) — сразу идём в PH_RENDER, экономя 100 мс + 32 байта I2C (~1.5 мс) = ~101.5 мс per повторный запуск.
- Если inited = 0 (ещё не init или после PH_ERR) — полный цикл через PH_DELAY → PH_INIT.
Это делает UX приятным: после первого запуска каждое следующее нажатие кнопки даёт мгновенную реакцию. Стадия PH_ERR (строки 339–349)
PH_ERR: begin if (start_i || anim_i) begin inited <= 1'b0; mode <= anim_i ? 1'b1 : 1'b0; anim_run <= anim_i; angle <= 6'd0; err_o <= 1'b0; delay_cnt <= DELAY_CYCLES; phase <= PH_DELAY; end end
Терминальное состояние после ошибки. При любом нажатии кнопки:
- всегда сбрасывает inited = 0 и идёт через PH_DELAY → PH_INIT — полный цикл инициализации (предположение: дисплей мог быть физически отключён / сбросился / сбоил, нужна перенастройка);
- выбирает режим: static или anim в зависимости от нажатой кнопки.
Это важная семантика для восстановления: ошибка NACK почти наверняка означает, что чип либо отключён, либо потерял синхронизацию. Просто перезапуск без init бесполезен — пойдёт ещё один NACK. Подключение i2c_burst_writer (строки 359–379)
i2c_burst_writer #( .CNT_W (16) ) u_burst ( .clk_i (clk_i), .rstn_i (rstn_i), .start_i (bw_start), .slave_addr_i (I2C_ADDR), .byte_count_i (bw_byte_count), .busy_o (bw_busy), .done_o (bw_done), .error_o (bw_error), .data_req_o (bw_data_req), .data_i (bw_data), .data_valid_i (bw_data_req), // комбинаторная связка (см. 4.14) .cmd_valid_o (cmd_valid_o), .cmd_o (cmd_o), .din_o (din_o), .ready_i (ready_i), .rx_ack_i (rx_ack_i), .arb_lost_i (arb_lost_i) );
Ключевые моменты:
- CNT_W = 16 — с запасом на 65 535 байт. Достаточно и для init (32), и для frame (1025), и для любых будущих расширений.
- data_valid_i = bw_data_req — комбинаторное равенство. Источник всегда валиден мгновенно (init-ROM — комб-функция; scene_rdata — BRAM с latency=1, но интервалы между запросами в 4500 раз больше).
- cmd_valid_o/cmd_o/din_o — выходы burst-writer’а пробрасываются прозрачно наверх, через ssd1306_ctrl на внешний i2c_master_core. Никаких дополнительных мультиплексоров.
- arb_lost_i — проксируется от ядра к burst-writer’у; а arb_lost_clear_o обратно, из ssd1306_ctrl к ядру.
Ресурсы и характеристики модуля Реалистичная оценка утилизации ssd1306_ctrl на Cyclone IV EP4CE10 (без u_scene и u_burst, только собственная логика):
| Ресурс |
Использовано |
Комментарий |
| Logic Elements (LE) |
~150 |
FSM + init ROM + data mux + src_idx |
| Registers |
~50 |
phase, src_idx, angle, mode, anim_run, inited, delay_cnt (23 бит) |
| M9K blocks |
0 |
весь ROM — case-функция, помещается в LE |
| Multipliers |
0 |
нет арифметики, только сравнения и инкременты |
| Fmax |
>100 МГц |
короткие комб-пути |
Для всего поддерева ssd1306_ctrl (включая u_scene и u_burst):
| Ресурс |
Использовано |
Комментарий |
| LE |
~1 250 |
150 (этот модуль) + 900 (scene_renderer) + 200 (burst_writer) |
| Registers |
~250 |
50 + 150 + 50 |
| M9K blocks |
1 |
только framebuffer в scene_renderer |
| 9×9 multipliers |
4 |
в scene_renderer.S_ROT_MUL |
Это ~12% LE ресурса EP4CE10 (10 320 LE) — остаются 88% на остальную логику (7-seg driver, debouncers, LED-индикация). Что можно улучшить / расширить
- Dual-buffer анимация. Добавить второй BRAM + ping-pong флаг: пока кадр N передаётся, рендерим N+1 в другой буфер. Даст fps, ограниченный только передачей (~21 fps). Стоимость — +1 M9K, +20 LE.
- Retry при NACK. Сейчас по bw_error мы сразу уходим в PH_ERR. Можно добавить счётчик retries (до 3) с возвратом в PH_DELAY → PH_INIT. Полезно для шумной шины / вибрации.
- Watchdog по времени кадра. Если PH_FRAMEW длится больше 100 мс — что-то зависло (clock stretching slave’а, физическая проблема). Таймаут + принудительный STOP + PH_ERR.
- Несколько сцен. Добавить вход scene_sel_i и передавать в scene_renderer, чтобы показать разные сцены по разным кнопкам. Ctrl-логика не меняется.
- I2C probe перед init. См. 2.2.2 — можно отправить transaction с byte_count=0 перед PH_INIT, чтобы проверить наличие дисплея; если NACK → сразу PH_ERR без 100 мс ожидания.
Фазовая диаграмма
 Top-level: сборка системы на плате Top-level модуль quartus_ssd1306/src/ssd1306_test_top.v — это «склейка», объединяющая все рассмотренные ранее компоненты в цельный проект для платы ALINX AX301 (Cyclone IV EP4CE6F17C8). Он не содержит никакой «интересной» логики (нет 3D, нет I2C-FSM), но делает критически важную работу: согласует физический мир (пины, кварц, дребезг, pull-up) с RTL-логикой. Эта часть разбирает top-level поблочно, с разбором кода каждого фрагмента, расчёта всех констант и полным пин-ассайнментом из .qsf-файла. Роль top-level и иерархия
 Top-level не «знает» про SSD1306, I2C-протокол, 3D-куб. Он лишь:
- Генерирует core_ena из clk_50m — прескалер для работы I2C-ядра на 100 кГц.
- Подключает двунаправленные пины i2c_sda/i2c_scl через tri-state + синхронизаторы.
- Антидребезжит две кнопки KEY2/KEY3.
- Собирает статус (busy, done, err, animating) на 4 LED.
- Разворачивает 11-битный progress_o на 3 HEX-цифры 7-сегментного дисплея + 1 статус-цифра.
- Инстанциирует три главных модуля: i2c_master_core, ssd1306_ctrl, seg_scan, ax_debounce (×2).
Объявление модуля и порты (строки 18–30)
module ssd1306_test_top ( input wire clk_50m, input wire rst_n, input wire key_start, // KEY2 — active-low, статика input wire key_anim, // KEY3 — active-low, анимация inout wire i2c_sda, // двунаправленные I2C inout wire i2c_scl, output wire [3:0] led, // 4 LED, active-high output wire [5:0] seg_sel, // 6 цифр, active-low select output wire [7:0] seg_data // 8 сегментов, active-low );
Все порты — физические пины FPGA:
| Порт |
Направление |
Уровень активности |
Назначение |
| clk_50m |
in |
— |
кварц 50 МГц, источник системного такта |
| rst_n |
in |
active-low |
кнопка RESET |
| key_start |
in |
active-low |
KEY2, «статический кадр» |
| key_anim |
in |
active-low |
KEY3, «анимация» (повторно — стоп) |
| i2c_sda |
inout |
open-drain |
линия данных I2C |
| i2c_scl |
inout |
open-drain |
линия тактов I2C |
| led[3:0] |
out |
active-high |
4 индикатора статуса |
| seg_sel[5:0] |
out |
active-low |
селектор цифры (one-hot, активный 0) |
| seg_data[7:0] |
out |
active-low |
сегменты + DP (0=горит) |
Прескалер I2C (строки 35–53) Ядру i2c_master_core нужен импульс ena_i раз в четверть периода SCL, чтобы генерировать все 4 фазы I2C-бита.
SCL_freq = clk_sys / (4 · (PRE_TOP + 1)) 100 кГц = 50 МГц / (4 · 125) PRE_TOP = 124
Формула:

Код прескалера:
localparam [6:0] PRE_TOP = 7'd124; reg [6:0] pre_cnt; reg core_ena; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin pre_cnt <= 7'd0; core_ena <= 1'b0; end else begin if (pre_cnt == PRE_TOP) begin pre_cnt <= 7'd0; core_ena <= 1'b1; // 1 такт из каждых 125 end else begin pre_cnt <= pre_cnt + 7'd1; core_ena <= 1'b0; end end end
Разбор:
- 7-битный счётчик pre_cnt (0..127, достаточно для 124).
- 1 такт core_ena из каждых 125 тактов. I2C-ядро использует его как «разрешение сдвига» своего бит-FSM.
- Скважность 1/125 — это ena-стробы, они не являются клоком. В синтезе будет один клок-домен clk_50m, а core_ena просто включает/выключает последовательную логику в ядре — это хорошая практика CDC-free дизайна.
Таблица альтернативных скоростей:
| Режим I2C |
f_SCL |
PRE_TOP |
1 байт (SCL) |
1 байт + ACK (clk_50m) |
| Standard |
100 кГц |
124 |
90 мкс |
4 500 тактов |
| Fast |
400 кГц |
30 (≈391 кГц) |
23 мкс |
1 150 тактов |
| Fast+ |
1 МГц |
11 (≈1.04 МГц) |
9 мкс |
460 тактов |
| Slow |
10 кГц |
1249 |
900 мкс |
45 000 тактов |
SSD1306 официально поддерживает до 400 кГц; при качественной разводке и коротких проводах работает и до 1 МГц. Разрядность PRE_TOP тогда нужно увеличить под нужное значение. Весь остальной RTL остаётся без изменений. Tri-state буферы и синхронизаторы (строки 58–77) Две ключевые задачи физического слоя:
- Двунаправленные пины i2c_sda/i2c_scl — open-drain, FPGA их «отпускает» для чтения, «притягивает к земле» для передачи 0.
- Входные сигналы SDA/SCL приходят из другого клок-домена (или вовсе асинхронны) — их нужно синхронизировать.
Tri-state буферы (строки 61–64):
wire sda_pad_in, scl_pad_in; wire sda_oen, scl_oen; assign i2c_sda = sda_oen ? 1'bz : 1'b0; assign i2c_scl = scl_oen ? 1'bz : 1'b0; assign sda_pad_in = i2c_sda; assign scl_pad_in = i2c_scl;
Разбор:
- sda_oen = 1 — FPGA отпускает пин, линию удерживает внешний pull-up (4.7 кОм, см. 2.1.5), или её тянет slave.
- sda_oen = 0 — FPGA выдаёт 0, притягивая линию к GND. Никогда не выдаём 1 напрямую (это нарушило бы open-drain и вызвало КЗ, если slave одновременно тянет 0).
- assign ... = i2c_sda — простое чтение того же пина (верификация реального состояния, с учётом slave и pull-up).
Синтезатор Quartus автоматически создаст IOBUF/BIDIR буфер на пине (определит по inout + условной логике). 2-ступенчатый синхронизатор (строки 66–77):
reg [1:0] sda_sync, scl_sync; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) begin sda_sync <= 2'b11; scl_sync <= 2'b11; end else begin sda_sync <= {sda_sync[0], sda_pad_in}; scl_sync <= {scl_sync[0], scl_pad_in}; end end wire sda_s = sda_sync[1]; wire scl_s = scl_sync[1];
Разбор:
- Два последовательных FF на каждую линию: sda_pad_in → sda_sync[0] → sda_sync[1].
- Задержка — 2 такта clk_50m = 40 нс. При 100 кГц SCL (10 мкс на бит) это пренебрежимо.
- Сброс в 2'b11 — правильное состояние для idle шины (линии отпущены). Это критически важно: после сброса мы должны «видеть» шину как свободную, иначе FSM ядра может посчитать её занятой.
Почему 2 ступени? Одиночный FF может попасть в метастабильность — неопределённое состояние между 0 и 1 — если входной фронт пришёлся в «апертурное окно» триггера. Восстановление из метастабильности занимает случайное время, что может сбить логику. Каждая дополнительная ступень экспоненциально снижает MTBF метастабильности. Для 50 МГц clk и типового триггера Altera:
| Ступеней |
MTBF (мин. оценка Altera) |
| 1 |
~1 секунда |
| 2 |
~

секунд ≈ 31 год |
| 3 |
~

секунд |
2 ступени — стандарт де-факто для CDC. 3+ ступени используют для сверхкритических приложений (авиация, медицина). Антидребезг кнопок (строки 82–92 + ax_debounce.v) Объявление в top-level:
wire key_start_pulse, key_anim_pulse; ax_debounce #(.CLK_FREQ(50_000_000), .DEBOUNCE_MS(20)) u_deb_start ( .clk_i(clk_50m), .rstn_i(rst_n), .key_i(key_start), .key_pulse_o(key_start_pulse) ); ax_debounce #(.CLK_FREQ(50_000_000), .DEBOUNCE_MS(20)) u_deb_anim ( .clk_i(clk_50m), .rstn_i(rst_n), .key_i(key_anim), .key_pulse_o(key_anim_pulse) );
Два экземпляра, по одному на каждую кнопку. Параметр DEBOUNCE_MS = 20 — типичное значение для механических кнопок (большинство «звенят» 5–10 мс при нажатии и 10–20 мс при отпускании). Внутри ax_debounce.v (45 строк, полный разбор):
module ax_debounce #( parameter CLK_FREQ = 50_000_000, parameter DEBOUNCE_MS = 20 )( input wire clk_i, input wire rstn_i, input wire key_i, output reg key_pulse_o ); localparam CNT_MAX = (CLK_FREQ / 1000) * DEBOUNCE_MS; reg [19:0] cnt; reg key_d; reg key_stable; always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) begin cnt <= 20'd0; key_d <= 1'b1; key_stable <= 1'b1; key_pulse_o <= 1'b0; end else begin key_pulse_o <= 1'b0; // pre-assign: импульс 1 такт key_d <= key_i; // однократная выборка if (key_d != key_stable) begin // есть рассогласование if (cnt >= CNT_MAX[19:0] - 20'd1) begin cnt <= 20'd0; key_stable <= key_d; // принять новое состояние if (!key_d) key_pulse_o <= 1'b1; // фронт 1→0 = нажатие end else cnt <= cnt + 20'd1; end else cnt <= 20'd0; // сброс — состояние стабильно end end endmodule
Алгоритм на пальцах:
- key_d — входной сэмпл прошлого такта; key_stable — текущее «признанное» состояние.
- Если совпадают — сигнал стабилен, счётчик сброшен.
- Если не совпадают — наращиваем счётчик. Как только он достигает CNT_MAX - 1 = 20 мс × 50 МГц / 1000 = 1 000 000 — признаём новое состояние стабильным.
- Только на фронте 1 → 0 (нажатие, т. к. активность-low) генерируем одноцикловый key_pulse_o. На отпускании — нет.
Такая схема:
- Одно нажатие даёт ровно один key_pulse_o — идеально для FSM ssd1306_ctrl.
- Любые дребезги короче 20 мс игнорируются.
- Нажатия короче 20 мс тоже игнорируются — это защищает от помех. Пользователь так быстро нажать физически не может.
- Задержка распознавания — 20 мс. Нечувствительно для человека.
CNT_MAX = 1 000 000 = 0xF4240 — помещается в 20 бит (максимум 0xFFFFF = 1 048 575). Разрядность cnt подобрана с запасом под параметр DEBOUNCE_MS до ~20 мс. I2C Master Core — подключение (строки 97–119)
wire cmd_valid, ready, rx_ack, arb_lost, busy; wire arb_lost_clear; wire [2:0] cmd; wire [7:0] din; i2c_master_core u_core ( .clk_i (clk_50m), .rstn_i (rst_n), .ena_i (core_ena), // от прескалера, 100 кГц × 4 .cmd_valid_i (cmd_valid), // от burst_writer .cmd_i (cmd), .din_i (din), .dout_o (), // не используется (read mode off) .rx_ack_o (rx_ack), .ready_o (ready), .arb_lost_o (arb_lost), .arb_lost_clear_i (arb_lost_clear), .busy_o (busy), .scl_i (scl_s), // синхронизированный вход .scl_oen_o (scl_oen), .sda_i (sda_s), .sda_oen_o (sda_oen) );
Ключевые связи:
- ena_i = core_ena — строба для квартала SCL-периода (см. 8.3);
- cmd_valid_i/cmd_i/din_i — от u_ssd → u_burst, командный интерфейс;
- scl_i/sda_i — синхронизированные входы (scl_s/sda_s), не сырые пиновые;
- scl_oen_o/sda_oen_o — управление tri-state буферами (см. 8.4);
- busy_o не подключен к LED — есть более информативный ssd_busy от верхнего уровня.
- dout_o не используется — мы только пишем на SSD1306, никогда не читаем.
SSD1306 Controller — подключение (строки 124–148)
wire ssd_busy, ssd_done, ssd_err, ssd_animating; wire [10:0] ssd_progress; ssd1306_ctrl #( .CLK_FREQ (50_000_000), .I2C_ADDR (7'h3C), .DELAY_MS (100) ) u_ssd ( .clk_i (clk_50m), .rstn_i (rst_n), .start_i (key_start_pulse), // KEY2 после дебаунса .anim_i (key_anim_pulse), // KEY3 после дебаунса .busy_o (ssd_busy), .done_o (ssd_done), .err_o (ssd_err), .progress_o (ssd_progress), .animating_o (ssd_animating), .cmd_valid_o (cmd_valid), // → u_core .cmd_o (cmd), .din_o (din), .ready_i (ready), // ← u_core .rx_ack_i (rx_ack), .arb_lost_i (arb_lost), .arb_lost_clear_o (arb_lost_clear) );
Все три параметра выставлены «явно» — несмотря на то, что это значения по умолчанию. Это защита от случайных изменений defaults в ssd1306_ctrl.v. u_ssd автоматически внутри инстанциирует u_burst (i2c_burst_writer) и u_scene (scene_renderer) — их не нужно руками подключать на top-level, это часть контракта ssd1306_ctrl. LED-индикация (строки 153–166)
reg ssd_done_latch; always @(posedge clk_50m or negedge rst_n) begin if (!rst_n) ssd_done_latch <= 1'b0; else if (ssd_done) ssd_done_latch <= 1'b1; else if (key_start_pulse || key_anim_pulse) ssd_done_latch <= 1'b0; end assign led[0] = ssd_busy; // занят assign led[1] = ssd_done_latch; // успех (latch!) assign led[2] = ssd_err; // ошибка assign led[3] = ssd_animating; // анимация
Разбор: ssd_done_latch — SR-защёлка:
- Set по ssd_done (одноцикловый impulse из ssd1306_ctrl). Если бы подключили его напрямую к LED, пользователь бы увидел всего 20 нс вспышки = незаметно.
- Reset на новое нажатие кнопки — чтобы «погасить» успех предыдущего кадра и показать «идёт новый».
LED активно-high (от .qsf: PIN_E10, PIN_F9, PIN_C9, PIN_D9). На AX301 это жёлтые индикаторы возле FPGA. Семантика для пользователя:
| LED |
Смысл |
| LED0 (busy) |
горит во время передачи (init / frame) |
| LED1 (done) |
горит после последнего успешного кадра |
| LED2 (err) |
горит, если была ошибка (NACK / arb-lost) |
| LED3 (animating) |
мигает в такт анимации, показывает «цикл идёт» |
Типовой сценарий:
- Включение платы: все LED выключены, идёт POR-reset.
- Нажатие KEY2: LED0 загорается на ~46 мс, затем LED1.
- Нажатие KEY3: LED0 + LED3 горят постоянно (цикл); повторное KEY3 → LED0 гаснет, LED1 загорается (последний кадр завершён).
- Если дисплей отключён: LED0 мигает, через ~1 мс LED2 загорается, LED0 гаснет.
7-сегментный дисплей — hex-шрифт (строки 171–196) Функция конвертации hex-числа в битовое представление сегментов:
function [7:0] seg_hex; input [3:0] val; begin case (val) 4'h0: seg_hex = 8'hC0; // 1100 0000 4'h1: seg_hex = 8'hF9; // 1111 1001 4'h2: seg_hex = 8'hA4; 4'h3: seg_hex = 8'hB0; 4'h4: seg_hex = 8'h99; 4'h5: seg_hex = 8'h92; 4'h6: seg_hex = 8'h82; 4'h7: seg_hex = 8'hF8; 4'h8: seg_hex = 8'h80; 4'h9: seg_hex = 8'h90; 4'hA: seg_hex = 8'h88; 4'hB: seg_hex = 8'h83; 4'hC: seg_hex = 8'hC6; 4'hD: seg_hex = 8'hA1; 4'hE: seg_hex = 8'h86; 4'hF: seg_hex = 8'h8E; default: seg_hex = 8'hFF; // всё выключено endcase end endfunction
Битовая раскладка (7-segment common-anode, active-low):
a ─── f│ │b │ g │ ─── e│ │c │ │ ─── ·dp d
| Бит |
Сегмент |
0 = горит |
| [0] |
a (верх) |
|
| [1] |
b (верх-право) |
|
| [2] |
c (низ-право) |
|
| [3] |
d (низ) |
|
| [4] |
e (низ-лево) |
|
| [5] |
f (верх-лево) |
|
| [6] |
g (средний) |
|
| [7] |
dp (точка) |
|
Пример для '0': все кроме g и dp → биты abcdefg. = 0000001. = бинарно 11000000 = 0xC0. ✓ Дополнительные символы:
localparam [7:0] SEG_BLANK = 8'hFF; // все выключены localparam [7:0] SEG_DASH = 8'hBF; // только g (средний) localparam [7:0] SEG_A = 8'h88; // то же, что 'A'
И ещё прямо в mux-строке:
- 8'h86 = 'E' (a, d, e, f, g) — отображение ошибки.
7-сегментный дисплей — mux и содержимое (строки 198–224)
wire [7:0] seg_d0 = ssd_err ? 8'h86 : // 'E' ssd_animating ? SEG_A : // 'A' ssd_done_latch ? seg_hex(4'h0) // '0' : SEG_DASH; // '-' wire [7:0] seg_d1 = seg_hex(ssd_progress[3:0]); wire [7:0] seg_d2 = seg_hex(ssd_progress[7:4]); wire [7:0] seg_d3 = seg_hex({1'b0, ssd_progress[10:8]}); wire [7:0] seg_d4 = SEG_BLANK; wire [7:0] seg_d5 = SEG_BLANK; seg_scan #(.SCAN_BITS(16)) u_seg ( .clk_i (clk_50m), .rstn_i (rst_n), .seg_data_0(seg_d0), .seg_data_1(seg_d1), .seg_data_2(seg_d2), .seg_data_3(seg_d3), .seg_data_4(seg_d4), .seg_data_5(seg_d5), .seg_sel (seg_sel), .seg_data (seg_data) );
Раскладка 6 цифр на плате AX301 (слева направо):
[seg_d5] [seg_d4] [seg_d3] [seg_d2] [seg_d1] [seg_d0] ( · ) ( · ) (prog[10:8]) (prog[7:4]) (prog[3:0]) (status) BLANK BLANK hex top hex mid hex low -/0/E/A
- seg_d5, seg_d4 — пустые, ничего не отображают.
- seg_d3 — старшие 3 бита progress_o (0..7), дополнены нулём до 4 бит.
- seg_d2 — средние 4 бита.
- seg_d1 — младшие 4 бита.
- seg_d0 — статус-цифра по приоритету:
- ssd_err → 'E' (горит всё время ошибки);
- ssd_animating → 'A' (анимация активна);
- ssd_done_latch → '0' (успех, защёлка);
- иначе → '-' (idle или busy без завершения).
Пример отображения в середине передачи frame:
| Событие |
Экран |
| idle |
- - - - - - (все dashes) |
| start передачи frame |
- - 0 0 0 - (progress=0..0) |
| передано 0x0200 байт |
- - 2 0 0 - |
| передано 0x0400 = 1024 |
- - 4 0 0 0 (успех) |
| идёт анимация, кадр N |
- - X X X A (X меняется) |
| ошибка |
- - - - - E |
7-сегментный сканер — seg_scan.v (43 строки)
module seg_scan #( parameter SCAN_BITS = 16 )( input wire clk_i, input wire rstn_i, input wire [7:0] seg_data_0, ... input wire [7:0] seg_data_5, output reg [5:0] seg_sel, output reg [7:0] seg_data ); reg [SCAN_BITS-1:0] scan_cnt; wire [2:0] scan_idx = scan_cnt[SCAN_BITS-1 : SCAN_BITS-3]; always @(posedge clk_i or negedge rstn_i) begin if (!rstn_i) scan_cnt <= {SCAN_BITS{1'b0}}; else scan_cnt <= scan_cnt + 1'b1; end always @(*) begin case (scan_idx) 3'd0: begin seg_sel = 6'b111110; seg_data = seg_data_0; end 3'd1: begin seg_sel = 6'b111101; seg_data = seg_data_1; end 3'd2: begin seg_sel = 6'b111011; seg_data = seg_data_2; end 3'd3: begin seg_sel = 6'b110111; seg_data = seg_data_3; end 3'd4: begin seg_sel = 6'b101111; seg_data = seg_data_4; end 3'd5: begin seg_sel = 6'b011111; seg_data = seg_data_5; end default: begin seg_sel = 6'b111111; seg_data = 8'hFF; end endcase end endmodule
Алгоритм:
- 16-битный счётчик scan_cnt считает от 0 до 65535 и перекатывается. Частота — clk_50m / 2^16 = ~763 Гц.
- Старшие 3 бита scan_cnt[15:13] = scan_idx задают текущую активную цифру (0..7, из них реально используются 0..5).
- Частота обновления одной цифры = 763 / 8 ≈ 95 Гц. Выше порога мерцания (~60 Гц) — человеческий глаз видит стабильное изображение.
- Скважность — 1/8 на цифру, т. к. 2 пустых слота (6, 7) + активна только одна из шести. Реальная яркость ≈ 12.5% от «все цифры всегда горят». Это стандарт для мультиплексированных индикаторов.
- seg_sel — one-hot active-low (6'b111110 — активна цифра 0, остальные off). Так индикатор работает на AX301.
Назначение пинов в .qsf — полная таблица Из quartus_ssd1306/ssd1306_test_top.qsf (извлечено дословно): Глобальные параметры:
FAMILY = "Cyclone IV E" DEVICE = EP4CE6F17C8 TOP_LEVEL_ENTITY = ssd1306_test_top PROJECT_OUTPUT_DIRECTORY = output_files MIN_CORE_JUNCTION_TEMP = 0 MAX_CORE_JUNCTION_TEMP = 85 NOMINAL_CORE_SUPPLY_VOLTAGE = 1.2V LAST_QUARTUS_VERSION = "25.1std.0 Standard Edition"
Source files:
src/ssd1306_test_top.v src/ssd1306_ctrl.v src/scene_renderer.v src/seg_scan.v src/ax_debounce.v ../rtl/i2c_master_core.v ../rtl/i2c_burst_writer.v ssd1306_test_top.sdc
Все файлы с IO_STANDARD = 3.3-V LVTTL. Пины:
| Сигнал |
Пин FPGA |
Замечание |
| clk_50m |
E1 |
кварц 50 МГц, GCLK-пин |
| rst_n |
N13 |
кнопка RESET (active-low) |
| key_start |
M15 |
KEY2 (active-low) |
| key_anim |
M16 |
KEY3 (active-low) |
| i2c_scl |
E8 |
совместно с EEPROM 24C04 (0x50) |
| i2c_sda |
E9 |
|
| led[0] |
E10 |
active-high |
| led[1] |
F9 |
active-high |
| led[2] |
C9 |
active-high |
| led[3] |
D9 |
active-high |
| seg_sel[0] |
N9 |
active-low |
| seg_sel[1] |
P9 |
|
| seg_sel[2] |
M10 |
|
| seg_sel[3] |
N11 |
|
| seg_sel[4] |
P11 |
|
| seg_sel[5] |
M11 |
|
| seg_data[0] (a) |
R14 |
active-low |
| seg_data[1] (b) |
N16 |
|
| seg_data[2] (c) |
P16 |
|
| seg_data[3] (d) |
T15 |
|
| seg_data[4] (e) |
P15 |
|
| seg_data[5] (f) |
N12 |
|
| seg_data[6] (g) |
N15 |
|
| seg_data[7] (dp) |
R16 |
|
Altera configuration pins (обязательные для корректной настройки flash-программирования, не меняются):
PIN_C1 → ~ALTERA_ASDO_DATA1~ PIN_D2 → ~ALTERA_FLASH_nCE_nCSO~ PIN_H1 → ~ALTERA_DCLK~ PIN_H2 → ~ALTERA_DATA0~ PIN_F16 → ~ALTERA_nCEO~
Важно! В предыдущей версии гайда были указаны пины E6 и D1 для I2C — это устаревшая версия. Актуальная разводка из текущего .qsf: i2c_scl = E8, i2c_sda = E9. Оба пина выведены на общую I2C-шину AX301, к которой уже подключён bootloader-EEPROM 24C04 (адрес 0x50). SSD1306 на 0x3C не конфликтует с ним по адресу — но физически шина одна, pull-up резисторы на плате 4.7 кОм на каждую линию. SDC-файл и временные ограничения В .qsf прописан один SDC:
SDC_FILE ssd1306_test_top.sdc
Минимально нужный контент SDC:
create_clock -name clk_50m -period 20.000 [get_ports clk_50m] derive_pll_clocks derive_clock_uncertainty # I2C пины — псевдо-статические (100 кГц), ослабляем ограничения set_false_path -from [get_ports {i2c_sda i2c_scl rst_n key_start key_anim}] -to [all_registers] set_false_path -from [all_registers] -to [get_ports {i2c_sda i2c_scl led
seg_sel
- seg_data
- }]Пояснения:
- create_clock — объявляем 50 МГц клок на clk_50m.
- derive_pll_clocks — автоматическое объявление любых производных клоков (если будут PLL). Для этого проекта не нужно, но стандартная практика.
- set_false_path на I2C/кнопки — это async-входы, синхронизированные 2-FF (см. 8.4). TimeQuest не должен их анализировать как комбинационные цепочки.
- set_false_path на выходы — SDA/SCL работают на 100 кГц (период 10 мкс = 500 тактов), не критичны по сетапу. LED и 7-seg — совсем медленные.
Без этих false_path TimeQuest будет ругаться на «unconstrained I/O» и выдавать неинформативные warnings. Сборка проекта — команды Workflow в Quartus (из корня quartus_ssd1306/):
# Компиляция (analysis + synthesis + fitter + asm + timing) quartus_sh --flow compile ssd1306_test_top # Только синтез (быстрее) quartus_map ssd1306_test_top # Только fitter (если синтез не менялся) quartus_fit ssd1306_test_top # Generate .sof quartus_asm ssd1306_test_top # Прошить через USB-Blaster quartus_pgm -c "USB-Blaster [USB-0]" -m JTAG -o "p;output_files/ssd1306_test_top.sof" # Прошить в SPI-flash (постоянная конфигурация) quartus_cpf -c -q 12.0MHz -g 3.3 -n p output_files/ssd1306_test_top.sof output_files/ssd1306_test_top.jic quartus_pgm -c "USB-Blaster [USB-0]" -m JTAG -o "ipv;output_files/ssd1306_test_top.jic"
Первый вариант (.sof) — заливает в SRAM FPGA, теряется при выключении питания. Второй (.jic) — в EPCS-flash, загружается автоматически при включении. Утилизация ресурсов — ожидаемые цифры После компиляции на EP4CE6F17C8 (6 272 LE, 30 M9K, 15 9×9 DSP, 92 I/O):
| Ресурс |
Использовано |
От максимума |
| Logic Elements |
~1 350 |
21% (1 350 / 6 272) |
| Registers |
~300 |
~5% |
| M9K blocks |
1 |
3.3% (1 / 30) — только framebuffer |
| 9×9 DSP |
4 |
27% (4 / 15) — в scene_renderer.S_ROT_MUL |
| I/O pins |
24 |
26% (24 / 92) — без учёта конфиг-пинов |
| Fmax (clk_50m) |
>80 МГц |
1.6× от требуемых 50 МГц |
По памяти и DSP остаётся много запаса — можно добавить dual-buffer (7.22) или вторую сцену, не выходя за рамки чипа. Типичные ошибки сборки и их решение
| Симптом |
Причина |
Решение |
| unresolved reference to I2C_ADDR |
забыли source .qsf для rtl/ |
убедиться, что ../rtl/i2c_master_core.v и ../rtl/i2c_burst_writer.v в .qsf |
| tri-state buffer cannot drive 1 |
напрямую assign sda = 1'b1 |
использовать assign sda = oen ? 1'bz : 1'b0 |
| нет реакции на кнопки |
неправильный polarity (ожидается active-low) |
в .qsf проверить WEAK_PULL_UP_RESISTOR = ON для keys, либо внешний pull-up |
| дисплей не показывает ничего |
не подтянуты pull-up на SDA/SCL |
см. 2.1.5, резисторы 4.7 кОм |
| «зависает» на init |
адрес 0x3D (правая конфигурация 0x78/0x7A) |
проверить I2C_ADDR в .qsf и режим OLED; в нашем случае 7'h3C |
| unconstrained I/O warnings |
нет .sdc-файла |
добавить ограничения (см. 8.13) |
| Fmax < 50 МГц |
длинные комб-пути |
обычно не возникает с этой логикой; при расширении — pipeline registers |
| timing violation на scene_rdata |
ошибочная реплика BRAM в LE |
включить -auto_ram_recognition on в Quartus |
Краткий чек-лист запуска на плате
- Проверка питания: VCCIO 3.3V, VCC_INT 1.2V на FPGA.
- Подключить OLED к I2C-шине AX301: SDA → E9, SCL → E8, VCC → 3.3V (или 5V, зависит от модели модуля), GND → GND.
- Прошить .sof через USB-Blaster.
- После reset (кнопка RESET на плате) — все LED должны быть выключены, 7-seg должен показывать - - - - - -.
- Нажать KEY2 — через ~100 мс зажигается LED0, идёт init; через ~1.5 мс — передача frame; через ~95 мс — LED1 загорается, на экране OLED виден статический кадр.
- Нажать KEY3 — LED0 + LED3 горят постоянно, OLED показывает вращающийся куб. Повторное KEY3 — остановка.
- Если LED2 (ошибка) — проверить pull-up, физическое подключение, прозвонить шину.
Тайминг и производительность Длительность одной I2C-операции
1 квартал SCL = PRE_TOP + 1 = 125 тактов = 2.5 мкс 1 бит SCL = 4 кварта = 10 мкс 1 байт I2C = 9 бит (8 data + ACK) = 90 мкс START / STOP = 4 кварта = 10 мкс
Init-транзакция (32 command-байта)
START : 10 мкс Address : 90 мкс Data × 32 : 2880 мкс STOP : 10 мкс ───────────────────────── Всего ≈ 2.99 мс
Frame-транзакция (1025 data-байт)
START : 10 мкс Address : 90 мкс Data × 1025 : 92 250 мкс STOP : 10 мкс ───────────────────────────── Всего ≈ 92.36 мс
Рендер одного кадра
Clear : 1024 такт ≈ 20 мкс Vertex rotate : 32 такт ≈ 0.64 мкс Edge rasterise : ~720 такт ≈ 14 мкс Text blit : ~100 такт ≈ 2 мкс ────────────────────────────────────── Итого рендер ≈ 1900 такт ≈ 38 мкс
Полный бюджет на первый кадр (с init)
Power-on delay : 100.00 мс Init transaction : 3.00 мс Render scene : 0.04 мс Frame transaction : 92.36 мс ─────────────────────────── Итого ≈ 195.4 мс (≈ 0.2 сек до первой картинки)
Полный бюджет на последующие кадры (анимация)
Render scene : 0.04 мс Frame transaction : 92.36 мс ─────────────────── Итого ≈ 92.4 мс → ≈ 10.8 FPS
При переходе на Fast-mode (400 кГц) время кадра сократится до ≈ 23 мс, что даст ≈ 40 FPS — куб будет вращаться в 4 раза плавнее. Счётчик прогресса progress_o на 7-сегментном дисплее показывает текущий src_idx в HEX.
- В фазе init: 000 → 020 (32 dec)
- В фазе frame: 000 → 401 (1025 dec)
Если счётчик застрял на значении X — значит, slave перестал ACK-ать на байте X. Это мощный отладочный инструмент. Отладка и типичные ошибки Экран тёмный, LED[1] горит Значит, передача прошла без NACK, но дисплей не засветился. Возможные причины:
- Charge pump не включён. Проверить команду 0x8D, 0x14 в init_rom (байты 9 и 10). Без неё OLED-ячейки не получат 7 В.
- Wrong multiplex ratio. Для 128×64 должно быть 0xA8, 0x3F. Для 128×32 — 0xA8, 0x1F.
- Display ON отсутствует. Последняя команда 0xAF (байт 31).
- Слишком тёмная контрастность. Проверить 0x81, 0xCF. Можно увеличить до 0xFF.
LED[2] загорается сразу после кнопки Это NACK на байте адреса. Смотрим на 7-сегмент:
- seg_d1..seg_d3 = 000 → NACK на адресе 0x78.
- seg_d1..seg_d3 = 001 → NACK на первом байте данных (маловероятно для init).
Возможные причины:
- Неверный I2C-адрес. SSD1306 может быть 0x3C или 0x3D в зависимости от пина SA0. Проверить модуль.
- Не подано питание на модуль OLED.
- SDA/SCL перепутаны. Запустить логический анализатор, проверить START-условие: 1→0 на SDA при SCL=1.
- Pull-up отсутствуют. Плата AX301 имеет встроенные, но если используется отладочная шина — добавить 4.7 кОм к 3.3 В.
Изображение перевёрнуто / зеркально Модули SSD1306 от разных производителей «смонтированы» по-разному. Переключите:
- 0xA1 ↔ 0xA0 — горизонтальное зеркало.
- 0xC8 ↔ 0xC0 — вертикальное зеркало.
Для нашей конфигурации 0xA1, 0xC8 — корректно для большинства «красно-зелёных» 128×64 модулей на I2C. Куб «едет» или занимает не всё место Причины:
- Границы адресации не установлены. Проверить 0x21 00 7F и 0x22 00 07.
- Выход вершины за ±31 по Y. Проверить, что S = 12 и (z >>> 2) + vy + 32 ∈ [0, 63]. При большем S куб может «срезаться» по верху/низу.
Точки рисуются «в клетке» Если Брезенхэм даёт ступенчатую картинку с разрывами — проверяем:
- Знак dy. Должен быть отрицательным (классический Брезенхэм использует dy = -|y1 - y0|). В нашем коде это init_dy_n.
- Направление sx, sy. Должны соответствовать знаку шага.
- Сравнение e2 >= vs e2 <=. Для x-шага: e2 >= bdy_ext (bdy_ext ≤ 0), для y-шага: e2 <= bdx_ext (bdx_ext ≥ 0).
LED[0] горит постоянно, счётчик не меняется Шина заблокирована. Типичные варианты:
- SSD1306 удерживает SCL низким (clock stretching). Наше ядро умеет это обрабатывать в фазе 1 datapath, проверяем scl_i. Если физически внешняя сила удерживает SCL — проверить pull-up и отсутствие замыкания на GND.
- Arbitration lost: другой мастер захватил шину. У нас их не должно быть. Возможно, EEPROM 24LC04 застряла в транзакции. Решение — сброс FPGA (кнопка rst_n).
SignalTap / виртуальный логический анализатор Для отладки рекомендуется вставить SignalTap на сигналы:
- cmd_valid, cmd, din, ready, rx_ack
- bw_state, bw_data_req, bw_data, bw_done, bw_error
- phase, src_idx, scene_ready
- sda_oen, scl_oen, sda_i, scl_i
Один SignalTap instance с глубиной 2048 покрывает 2048 × 20 нс = 40 мкс, достаточно чтобы зафиксировать выдачу одного I2C-байта полностью. Ресурсы FPGA и запас для расширений По результатам компиляции Quartus (Cyclone IV EP4CE6F17C8):
| Ресурс |
Использовано |
Всего |
% |
| Logic Elements (LE) |
≈ 1 500 |
6 272 |
24 % |
| Registers |
≈ 900 |
≈ 12 500 |
7 % |
| M9K Block RAM |
1 (FB 1 KiB) |
30 |
3 % |
| Embedded 9×9 multipliers |
4 |
15 |
27 % |
| Total pins |
24 |
180 |
13 % |
| f_max clk_50m |
> 75 МГц |
≥ 50 МГц |
✓ |
Что это означает:
- У нас 30× запас по BRAM (можно сделать двойную буферизацию, или расширить FB до 4 KiB для 256×64 дисплея).
- 4 свободных умножителя (можно поворачивать вокруг двух осей одновременно).
- 4700 LE свободно — можно добавить UART-консоль, VGA-вывод, CORDIC-ядро для плавной 3D-графики.
Идеи по расширению проекта
- Поворот вокруг двух осей. Добавить вторую фазу поворота (вокруг X), вторую LUT сдвигом на π/2. Удвоит число умножений, но 8 множителей у нас есть.
- Перспективная проекция. Заменить orthographic на px = (x · FOCAL) / (FOCAL + z'). Потребуется хардверный делитель или reciprocal-LUT.
- Скрытие задних рёбер. Вычислять z-среднее каждого ребра, сортировать, рисовать от дальнего к ближнему; либо использовать normal-vector culling. В обоих случаях нужен Z-buffer (ещё один M9K-блок).
- Кастомный шрифт и UTF-8. Расширить font ROM до 256 символов (8 × 256 = 2 KiB). Тексты хранить как string ROM с длиной.
- Запись пользовательской картинки. Загружать в FB bitmap из внешнего источника: SPI-flash, UART, AXI-DMA, SD-карта.
- Несколько дисплеев. Разделить по нескольким адресам (0x3C, 0x3D) или использовать I2C mux PCA9548A.
- Оптимизация. Вынести CLEAR в параллельный always-блок с широким портом M9K (64-bit), ускорив очистку в 8 раз до 128 тактов.
Ключевые параметры и где их менять
| Параметр |
Где |
Значение по умолчанию |
| Частота SCL |
prescaler, PRE_TOP |
124 → 100 кГц |
| I2C-адрес SSD1306 |
ssd1306_ctrl, I2C_ADDR |
7'h3C |
| Задержка питания |
ssd1306_ctrl, DELAY_MS |
100 мс |
| Полуребро куба |
scene_renderer, S |
12 |
| Позиция верхнего текста |
scene_renderer, TOP_COL0 |
43 |
| Период дребезга |
ax_debounce, DEBOUNCE_MS |
20 мс |
Послесловие Этот проект — демонстрация того, как далеко может уйти fixed-function pipeline в FPGA без привлечения софтверных абстракций. Весь контур от кнопки до OLED — это ~2 000 Verilog-строк, детерминированные автоматы, случайная BRAM и квартир-волновая LUT. Ни байта программного кода. Ни одного тактового цикла, потраченного впустую. Одновременно этот проект — стенд-надёжности для ядра i2c_master_core: он гоняет по шине 1060 байт подряд каждые 95 мс, непрерывно. Любая мелкая ошибка в бит-слотах, ACK-сэмплировании или обработке clock-stretching проявится как «застрявший счётчик» на 7-сегменте — что немедленно укажет адрес проблемы. Удачной компиляции.-Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget. Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться-Источник
|