Создаем I2C Master Controller на Verilog. Burst-транзакции и дисплей SSD1306

Страницы:  1

Ответить
 

Professor Seleznov


Продолжим совершенствование нашего I2C-контроллера и расширение спектра применимости. В этот раз сделаем возможность burst-транзакций и выведем картинку SSD1306. Для этого необходимо детально разобрать механизм функционирования OLED-дисплея SSD1306 и сделать аппаратный контроллер с burst-передачей по I2C, и в качестве примера сделать генерацию визуализацию 3D-куба и текста. Получился ОЧЕНЬ объемный материал с объяснением всех механик примененных для решения данной задачи. И вся логика - сугубо в железе, без процессора, без микрокода и чисто в ПЛИС.
Всем кто интересуется кодингом под Verilog - добро пожаловать под кат!
pic
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке 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.
Эти два слоя - сердцевина руководства. 
Высокоуровневая схема будет выглядеть так:
pic
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.  Именно этот вариант мы и подразумеваем в своей работе. Типовой схематик данного модуля:
pic
Почему 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:
pic
Для 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. Его разметка:
pic
Два значащих бита:
  • 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:
pic
На I2C это = 1 + (1 + ~30) = ~32 байта-цикла по 9 бит (8 data + ACK) = ~288 бит = ~1.44 мс на 200 кГц.  Пренебрежимо малый overhead.
Frame-транзакция (наша основная). Отправляем все 1024 байта GDDRAM одним stream-data потоком с control-байтом 0x40:
pic
Итого 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 строк:
pic
Размерности:
Элемент Количество Пояснение
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 без повторной настройки.
Визуально траектория курсора:
pic
Это совпадает с тем, как мы обычно читаем текст: слева-направо, сверху-вниз. Отсюда и название “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 Level0.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_rrx_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-интерфейса:
pic
Модуль параметризуется единственным параметром:
Параметр Значение Назначение
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-рукопожатие:
pic
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_CMDdata_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 (ядро шины):
pic
Высоуровневый контракт: модуль принимает (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_oerror_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) используется один и тот же рисунок сигналов:
pic
Фаза 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-протокол:
pic
Для комбинаторного источника (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
Полная диаграмма состояний
Пусть тут будет сгенерированная диаграмма состояний со всем набором пояснений.
pic
Архитектура механизма подготовки изображения без использования процессора
Данная часть статьи - является в некотором смысле переломной. До этого момента речь шла исключительно о транспортном уровне: как передать 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 (старый байт) → modifywrite 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-передачи:
pic
В теории можно было бы делать рендер во время передачи предыдущего кадра (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 поверх результатов предыдущих, и неправильная последовательность даст не ту картинку.
Рассмотрим композицию сцены - что на что ложится. Сцена состоит из трёх слоёв:
pic
Слои разнесены по страницам, т. е. не пересекаются по памяти GDDRAM. Это — не случайно, а сознательный выбор: тексты живут в pages 0 и 7, куб — в pages 2..5. Благодаря такому расположению:
  • нет конфликтов RMW: запись текста не перекрывает байты куба и наоборот;
  • текст не требует RMW: каждая буква высотой 8 пикселей и выровнена ровно по странице, поэтому один write-запрос перезаписывает весь столбец буквы целиком (0x00 → 0x___). Быстрее и проще чем RMW рёбер куба;
  • чистая последовательность фаз: сначала стираем весь FB, потом рисуем каждый слой в любом порядке — результат одинаков.
Диаграмма фаз
pic
Итого: ~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:
pic
Это ровно то, что реализовано в 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 подключается снаружи несколькими потоками сигналов:
pic
Интерфейс модуля из 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_ofb[raddr_i] — для внешнего чтения (I2C TX).
    • fb_rdata_rmwfb[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_qsin6cos6 — это чистая комбинаторика (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 строк):
pic
Аналогично и для остальных 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 состояниями:
pic
Общая обёртка имеет ещё один важный штрих в самом конце:
// 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). Ниже — разбор по блокам.
pic
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_FRAMEsrc_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_o0), обеспечивающих одноцикловые импульсы (тот же паттерн, что и в 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_DELAYPH_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_DELAYPH_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 мс ожидания.
Фазовая диаграмма
pic
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 и иерархия
pic
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
Формула:
pic
Код прескалера:
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 ~
pic
секунд ≈ 31 год
3 ~
pic
секунд

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 виден статический кадр.
      • Нажать KEY3LED0 + 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 от разных производителей «смонтированы» по-разному. Переключите:
      • 0xA10xA0 — горизонтальное зеркало.
      • 0xC80xC0 — вертикальное зеркало.
      Для нашей конфигурации 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% при первом пополнении.
      pic
      Воспользоваться-Источник
  •  
    Loading...
    Error