Продолжим совершенствование нашего I2C-контроллера и расширение спектра применимости. В этот раз сделаем возможность burst-транзакций и выведем картинку SSD1306. Для этого необходимо детально разобрать механизм функционирования OLED-дисплея SSD1306 и сделать аппаратный контроллер с burst-передачей по I2C, и в качестве примера сделать генерацию визуализацию 3D-куба и текста. Получился ОЧЕНЬ объемный материал с объяснением всех механик примененных для решения данной задачи. И вся логика - сугубо в железе, без процессора, без микрокода и чисто в ПЛИС.
Всем кто интересуется кодингом под Verilog - добро пожаловать под кат!

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Цели и задачи
Итак, обозначим основные цели и задачи - построить проект, который:
Инициализирует OLED-дисплей SSD1306 (128x64, I2C, адрес 0x3C);
Формирует кадр в собственном фреймбуфере в железе без MCU, без softcore-процессора, без HLS-компилятора:
Статический текст “SSD1306” в верхней строке;
Каркасный псевдотрехмерный куб в центре, который вращается вокруг вертикальной оси по кнопке “Анимация”;
Подпись “STATIC” или “ANIM” в нижней строке.
Передает 1024 пикселя + 1 control-байт в одной I2C-транзакции и обновляет картинку “в режиме реального времени” с частотой около 10 FPS.
Помимо этого я обозначу две высокоуровневые задачи:
Инженерная. Это продолжение стендовой верификации ядра
i2c_master_core: мы гоняем по шине непрерывный поток ~95 мс длиной и убеждаемся, что ядро удерживает связь без сбоев ACK и потерь арбитража.Учебная. Мы проходим путь от ядра I2C-уровня “один байт за команду” до полноценной графической подсистемы с растеризатором, LUT sin/cos, пайплайном вершин и dual-port BRAM. Без процессора. Всё - явные автоматы и детерминированные операции.
В предыдущем проекте c EEPROM мы записывали данные и считывали их обратно. Там были короткие транзакции с ручной обработкой “для каждого байта вызови ядро через отдельный CMD_WRITE”. Здесь ситуация иная:
Параметр | EEPROM-тест | SSD1306-тест |
Байт на транзакцию | 3 (addr+data) | 33 (init) и 1027 (frame) |
Источник данных | фиксированный ROM | динамически рендерится в BRAM |
Индикация сцены | нет | OLED-дисплей 128×64 |
Латентность на байт | не критична | определяет FPS |
Сложность FSM | IDLE/START/ADDR/DATA | IDLE/DELAY/INIT/RENDER/FRAME/... |
Переход от “отправить 3 байта” к “отправить 1027 байт” обнажает потребность в двух новых аппаратных слоях:
burst-writer, автоматически формирующий последовательность START → WRITE(addr) → WRITE(data[0]) … WRITE(data[N-1]) → STOP;
рендер-модуль, который готовит содержимое фреймбуфера без вмешательства CPU.
Эти два слоя - сердцевина руководства.
Высокоуровневая схема будет выглядеть так:

SSD1306. Физический слой
Начнем с рассмотрения того, из чего состоит OLED-дисплей. SSD1306 - это драйвер/контроллер, выполненный в виде COG-чипа (chip-on-glass), припаянного непосредственно к стеклу OLED-панели 128х64. В руках разработчика обычно оказывается не сам чип, а готовый модуль на PCB: панель + драйвер + обвязка (конденсаторы charge pump’а, опциональный LDO-регулятор, подтягивающие резисторы). Именно с модулем мы и будем работать - отдельно SSD1306 без панели покупать смысла нет.
Органические светодиоды требуют достаточно высокого прямого напряжения для свечения - типично ~7 В на ячейку в пике. Подавать такое напряжение от 3.3 В-шины FPGA напрямую невозможно, поэтому внутри SSD1306 встроен charge pump (насос заряда на переключаемых конденсаторах). Он собирает 7.5...9 В из 3.3 В с помощью двух внешних конденсаторов (обычно 1 мкФ), подключенных к пинам C1P/C1N и C2P/C2N. На модулях эти конденсаторы распаяны на плате - вам их трогать не надо.
Ключевой момент: charge pump по умолчанию выключен. После подачи питания вы получаете чип в “почти-спящем” режиме - он реагирует на команды по I2C, принимает данные во внутреннюю GDDRAM, но ничего не светится. Включение pump’а делается командой:
0x8D (Set Charge Pump)
0x14 (Enable charge pump during display ON)
После этого дисплей нужно ещё перевести в ON командой 0xAF - тогда pump реально запускается, и строки матрицы начинают получать 7.5 В. Если вы подали всю инициализацию, но пропустили 0x8D, 0x14, картинка в GDDRAM есть (это можно проверить логическим анализатором на I2C), но экран остаётся абсолютно тёмным. Это типичная “тихая” ошибка новичка - код компилируется, шина работает, NACK-ов нет, а дисплей просто чёрный.
Питание модуля:
Линия | Напряжение | Ток (типовой) | Примечание |
VCC/VDD | 3.3 В | 10 … 20 мА (весь экран горит) | от шины FPGA, желательно через отдельный фильтрующий конденсатор 10 мкФ рядом с модулем |
GND | 0 В | - | общая земля |
Пульсации charge pump’а (~800 кГц) могут наводиться на аналоговую часть через общую землю, поэтому на чувствительных проектах иногда разделяют GND на “цифровой” и “аналоговый” через ферритовый дроссель. В нашем проекте на AX301 этот нюанс некритичен: все сигналы цифровые, а модуль подключается короткими проводами.
SSD1306 поддерживает сразу несколько режимов связи, выбор делается аппаратно - пинами BS0…BS2 на самом чипе. Но на готовом модуле эти пины уже запаяны и доступны выводы только для одного интерфейса:
BS2 BS1 BS0 | Интерфейс | Пины модуля | Скорость |
0 1 0 | I2C | SDA, SCL | до 400 кГц (Fast-mode) |
0 0 0 | 4-wire SPI | SCLK, MOSI, DC, CS | до 10 МГц |
0 0 1 | 3-wire SPI | SCLK, MOSI, CS (D/C# передаётся 9-м битом) | до 10 МГц |
1 0 0 | 8080 parallel | D0..D7, /WR, /RD, /CS, DC | по циклу чтения/записи |
1 1 0 | 6800 parallel | D0..D7, E, R/W, /CS, DC | — |
Подавляющее большинство дешёвых модулей на Aliexpress / с китайских barebone-плат продаются в I2C-варианте с 4 выводами: VCC, GND, SCL, SDA. Именно этот вариант мы и подразумеваем в своей работе. Типовой схематик данного модуля:

Почему I2C, а не SPI? С точки зрения ПЛИС - SPI быстрее (мы могли бы обновлять экран с fps >100). Но:
Тема проекта - именно I2C (это лабораторный пример для I2C-мастера);
Нам достаточно ~8 fps, чтобы анимация воспринималась плавно.
I2C проще физически: 2 провода вместо 4+, не требует CS.
На выводах модуля можно увидеть ещё пару вариантов:
Адресный джампер
SA0- перемычка или 0-Ω резистор, которая подтягиваетSA0либо к GND (адрес0x3C), либо к VCC (0x3D). Пользуйтесь мультиметром, чтобы убедиться, куда он спаян на вашем конкретном модуле.Пин RES (reset) - опциональный, на большинстве плат подтянут к VCC через RC-цепочку. Если его нет на колодке - модуль сбрасывается автоматически при подаче питания.
Pull-up резисторы (4.7-10 кОм) на SDA/SCL уже установлены на плате модуля (обычно распаяны прямо рядом с COG-чипом). Дублировать их на стороне FPGA не надо - суммарное сопротивление pull-up’а уменьшится, что повысит ток через транзистор при tri-state=0, но не улучшит фронты. Если линии очень длинные (>20 см) и/или на шине висят ещё устройства - добавляйте внешние pull-up’ы, ориентируясь на общий расчёт.
На плате AX301 (Cyclone IV EP4CE10F17C8N) мы заняли два свободных GPIO-пина под I2C-мастер. В .qsf-файле они прописаны так:
set_location_assignment PIN_E8 -to i2c_scl
set_location_assignment PIN_E9 -to i2c_sda
set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to i2c_scl
set_instance_assignment -name IO_STANDARD "3.3-V LVCMOS" -to i2c_sda3.3-V LVCMOS - стандартный банк на AX301; уровни SSD1306 тоже 3.3 В, поэтому level-shifter не нужен. Длина проводов в типовом стендовом монтаже - 10…20 см. Этого достаточно мало, чтобы не заморачиваться с волновым сопротивлением и терминаторами. На больших длинах (>50 см) нужно либо снижать частоту SCL, либо пересчитывать pull-up.
Шина I2C по стандарту - open-drain: ни одно устройство не имеет право активно тянуть линию в 1, только в 0 или “отпустить” (перейти в высокоимпедансное состояние). В 1 линию возвращают исключительно внешние pull-up резисторы. Это нужно для двух вещей:
Multi-master / multi-slave - несколько устройств могут одновременно владеть шиной, и если они одновременно “хотят” в 1, конфликта не возникнет; а если один тянет в 0 - он “побеждает” (wired-AND). Это же свойство используется для арбитража в multi-master-системах.
Clock stretching - slave-устройство (например, медленный EEPROM) может “прижать” SCL к GND, пока оно не готово, и мастер обязан подождать.
FPGA-пин в Altera/Xilinx (и любой другой CMOS-логике) по умолчанию - двунаправленный push-pull. Чтобы эмулировать open-drain, мы никогда не выводим физическую 1, а выводим либо 0, либо high-Z (отключаем драйвер):
assign i2c_sda = sda_oen ? 1'bz : 1'b0;
assign i2c_scl = scl_oen ? 1'bz : 1'b0;В синтезе Quartus это выражение преобразуется в IOBUF-примитив (один bi-directional pin с отдельным oen-сигналом). Подтверждает ли синтезатор open-drain? Нет - на стороне FPGA это всё тот же обычный выход, но поведение эквивалентно open-drain благодаря tri-state’у.
Чтение линии делается прямо с того же пина:
assign sda_in = i2c_sda; // считываем уровень после pull-up / wired-AND
assign scl_in = i2c_scl;Асинхронный вход sda_in/scl_in обязательно синхронизируется 2-ступенчатым регистром перед использованием в FSM - иначе рискуем получить метастабильность.
На плате SSD1306 обычно стоит внешняя RC-цепочка R=100 кОм, C=100 нФ на пине /RES, дающая время reset’а ~10 мс после подачи питания - достаточно, чтобы чип вышел в стабильное состояние до первой команды. Поэтому перед отправкой init-последовательности мы ждём минимум 100 мс с момента подачи питания (реализовано в ssd1306_ctrl через счётчик post_por_cnt). Если активность на I2C начать слишком рано, первая команда 0xAE может прийти в момент, когда внутренний reset ещё не снят, и чип её проигнорирует - типовая ошибка “всё инициализируется, а экран чёрный”.
SSD1306. Базовый I2C адрес и control-byte
I2C SSD1306 - это стандартная двухуровневая адресация + “дополнительный смысловой слой”, специфичный именно для этого чипа (control-byte).
Оба слоя нужно понимать, чтобы:
уметь различать “я передаю команду настройки” и “я передаю пиксели”;
корректно строить короткие init-транзакции и длинные data-burst’ы;
отличать ошибки физического уровня (NACK на адрес) от ошибок контроллера (неправильный control-byte → мусор в GDDRAM).
I2C-адрес - 7-битный. Физически по шине передается 8-битный “адрес-байт”: старшие 7 бит - собственно адрес, младший бит - R/W:

Для SSD1306 компания Solomon Systech фиксирует в даташите старшие 6 бит адреса - 011110 (hex 0x3C как пред-аппер). Младший из 7 битов адреса задается пользователем через пин SA0:
SA0 (пин чипа) | 7-битный адрес | 8-битный ADDR-байт (write) | 8-битный ADDR-байт (read) |
GND (или не занят) |
|
|
|
VCC |
|
|
|
В нашем проекте используем 0x3C (на плате будет подписано как 0x78):
// quartus_ssd1306/src/ssd1306_ctrl.v
localparam [6:0] SSD_ADDR = 7'h3C;
...
bw_slave_addr <= SSD_ADDR;Внутри i2c_burst_writer этот 7-битный адрес автоматически дополняется до 8-битного ADDR-байта:
// rtl/i2c_burst_writer.v
wire [7:0] addr_byte = {slave_addr_i, 1'b0}; // W=0 — только записьREAD-операции на SSD1306 тоже поддерживаются (чтение статуса, чтение GDDRAM), но в нашем проекте не используются - модуль работает строго “вслепую”, только WRITE.
Полезный диагностический приём: до полной инициализации послать на шину транзакцию START + ADDR(0x3C, W) + STOP без байтов данных. Если дисплей присутствует и корректно запитан, он ответит ACK на ADDR-байт; если нет - NACK. Этот механизм легко встроить в верхний уровень:
// псевдокод
bw_byte_count <= 16'd0; // 0 data-байт
bw_start <= 1'b1;
// ждём done_o:
if (bw_error) begin
// нет SSD1306 на шине: не переходим в PH_INIT,
// зажигаем LED ошибки
endВ текущем ssd1306_ctrl probe-шаг не реализован - мы сразу уходим в PH_INIT. Это осознанный trade-off: в нашем лабораторном применении дисплей всегда подключен, а на NACK первой init-команды мы всё равно выставим err_o.
После того как по шине прошёл байт адреса и slave ответил ACK, все последующие байты в этой транзакции попадают в SSD1306. Но чип должен как-то понять: это команда (которую нужно декодировать и исполнить) или данные для GDDRAM (которые нужно записать в видеопамять)?
Решение - control-byte, специальный формат первого байта после ADDR. Его разметка:

Два значащих бита:
D/C#(Data / Command) - 0 → дальнейший поток интерпретируется как команды; 1 → как данные для GDDRAM.Co(Continuation) - 0 → режим «до конца транзакции»; 1 → режим “один следующий байт, потом снова control-byte”.
Пересечение этих двух битов даёт 4 комбинации:
Co | D/C# | hex | Смысл |
0 | 0 | 0x00 | Stream-commands: все байты до STOP — команды |
0 | 1 | 0x40 | Stream-data: все байты до STOP — пиксели ( |
1 | 0 | 0x80 | Single-command: ровно один следующий байт — команда, затем снова control-byte |
1 | 1 | 0xC0 | Single-data: один следующий байт — данные, затем control-byte |
Почему нужно четыре режима, а не два?
Режимы с
Co=0быстрые, но монотонные: в рамках одной транзакции передаем либо только команды, либо только данные. Смена режима требует STOP + новый START.Режимы с
Co=1гибкие, но с накладными расходами: в одной транзакции можно чередовать команды и данные, но перед каждым таким байтом летит “служебный” control-байт - +9 бит I2C (8 data + 1 ACK), т. е. ~10% накладных расходов даже в лучшем случае.
Для нашей задачи (full-frame update + init) идеально подходят только 0x00 и 0x40 - это вот и есть тот самый ключевой архитектурный выигрыш I2C-режима SSD1306.
SSD1306. Практика - транзакции init и frame
Init-транзакция. Мы отправляем ~30 команд подряд - все они идут одним stream’ом с control-байтом 0x00:

На I2C это = 1 + (1 + ~30) = ~32 байта-цикла по 9 бит (8 data + ACK) = ~288 бит = ~1.44 мс на 200 кГц. Пренебрежимо малый overhead.
Frame-транзакция (наша основная). Отправляем все 1024 байта GDDRAM одним stream-data потоком с control-байтом 0x40:

Итого 1 + 1 + 1024 = 1026 байт-циклов по 9 бит = 9234 бит = ~46 мс на 200 кГц. Это и определяет нашу частоту обновления ≈ 20 fps в идеале; с учётом рендеринга и latency получается ~8…15 fps - см. оценку в README.
Сравнение с построчной записью (которая потребовалась бы, если бы control-byte поддерживал только Co=1):
Способ | Байтов по I2C | Время @200 кГц | fps (max) |
Один frame-burst (наш) | 1026 | ~46 мс | ~22 |
По странице (8 burst’ов, по 128 байт) | 8×(2+128)=1040 | ~46.8 мс | ~21 (≈ то же) |
По одному байту (со сбросом курсора) | ~5000 | ~225 мс | ~4.4 |
С Co=1 (control каждый байт) | ~2048 | ~92 мс | ~11 |
Вывод: burst с Co=0 - не просто удобная, а единственная разумная стратегия для 20+ fps по I2C на 200 кГц.
Когда мы в режиме stream-data (0x40) посылаем байт N+1, куда он попадёт в GDDRAM? Это определяется текущим адресным режимом, который задаётся командой 0x20:
0x20, 0x00- Horizontal Addressing (мы используем именно его): курсор (col, page) наращивается по столбцам, в конце столбца (col=127) переходит на следующую страницу, в конце страницы (page=7) оборачивается обратно в (0, 0). Идеально для “отправил 1024 байта - и весь экран обновился”.0x20, 0x01- Vertical Addressing: (col, page) идёт сначала по страницам, потом переключается на следующий столбец.0x20, 0x02- Page Addressing: курсор перекатывается только по столбцам; переход на следующую страницу не автоматический.
В init-последовательности ssd1306_ctrl есть команды:
0x20, 0x00, // horizontal addressing
0x21, 0x00, 0x7F, // set column range [0, 127]
0x22, 0x00, 0x07, // set page range [0, 7]Которые совместно говорят: “обновление идёт по всему экрану (128×8), в горизонтальном порядке”. Благодаря этому мы можем начинать каждый frame-burst просто с 0x40 и 1024 байтов - без повторной установки курсора: после предыдущей транзакции курсор сам вернулся в (0, 0).
Если случайно оставить адресный режим по умолчанию (0x20, 0x02 - page-mode, исходное значение после POR), то после 128-го байта данных курсор не перейдет на следующую страницу, и все последующие байты будут записываться в ту же первую страницу поверх уже записанных. На экране вместо картинки мы увидим только верхнюю полоску 8 пикселей, а остальное 56 строк черные. Это еще одна типовая “тихая” ошибка новичка - все ACK’и есть, байты прошли, а результат неверный.
Поэтому структура наших будущих транзакций должна иметь обозначенный выше вид. Обе транзакции формируются одним и тем же i2c_burst_writer - он не знает про SSD1306 и про control-byte. ssd1306_ctrl просто:
Задаёт
slave_addr_i = 0x3C;Задаёт
byte_count_i = INIT_LENили1025;В качестве источника данных выдаёт либо ROM (init-поток), либо
{0x40, fb[0..1023]}(frame-поток).
Благодаря такой развязке, если завтра понадобится подключить BME280 или EEPROM - вся логика поменяется только на уровне “источника данных” и “состава команд”, а I2C-пайплайн останется неизменным.
SSD1306. Организация видеопамяти
Внутри SSD1306 картинка хранится в так называемой GDDRAM (Graphic Display Data RAM) - статической SRAM емкостью 128 х 64 бита = 8192 бит = 1024 байта. Каждый бит однозначно соответствует одному физическому пикселю OLED-матрицы (1 - пиксель светится, 0 - нет). С точки зрения FPGA это просто набор из 1024 байт, которые нужно заливать в правильном порядке и с правильной раскладкой битов внутри байта. Ниже разбираем эту раскладку детально.
В большинстве обычных TFT / LCD-контроллеров один байт памяти соответствует одному-двум горизонтальным пикселям (1 bpp или 4 bpp). В SSD1306 архитектура принципиально иная: байт располагается вертикально, т. е. один байт описывает столбец из 8 пикселей по вертикали.
Это сделано не “ради оригинальности”, а из-за устройства самой OLED-матрицы: SSD1306 внутри содержит 64 сегментных драйвера (по одному на каждую строку) и 128 common-драйверов (по одному на столбец). Обновление выполняется “колонками” - на каждом цикле мультиплексора чип активирует ровно одну строку и одновременно выставляет на сегментные драйверы состояния всех 128 столбцов. GDDRAM “подогнана” под этот режим: внутри неё память адресуется как (column x page), где page - это группа из 8 строк, обновляемых одним циклом внутреннего сдвигового регистра.
Отсюда и разбиение на 8 страниц (pages) по 8 строк:

Размерности:
Элемент | Количество | Пояснение |
Page | 8 | = rows / 8 = 64 / 8 |
Column | 128 | соответствует одному common-драйверу |
Pixel | 8192 = 128 × 64 | вся матрица |
Byte | 1024 = 8 × 128 | один байт = 1 столбец в 1 странице |
Каждый байт GDDRAM хранит 8 вертикальных пикселей одного столбца внутри одной страницы. Конвенция - little-endian по вертикали:
byte = 8'b b7 b6 b5 b4 b3 b2 b1 b0
│ │ │ │ │ │ │ │
│ │ │ │ │ │ │ └── top (row = page*8 + 0)
│ │ │ │ │ │ └───── row 1 (row = page*8 + 1)
│ │ │ │ │ └──────── row 2 (row = page*8 + 2)
│ │ │ │ └─────────── row 3
│ │ │ └────────────── row 4
│ │ └───────────────── row 5
│ └──────────────────── row 6
└─────────────────────── bottom (row = page*8 + 7)
bit = 0 ⇒ пиксель не горит (чёрный)
bit = 1 ⇒ пиксель светитсяКонкретные примеры:
Байт | Двоичное | Что зажигается (в пределах одного столбца страницы) |
0x00 | 00000000 | пусто — 8 чёрных пикселей |
0xFF | 11111111 | полный столбец 8 пикселей горит |
0x01 | 00000001 | только верхний пиксель (row = page*8 + 0) |
0x80 | 10000000 | только нижний пиксель (row = page*8 + 7) |
0x0F | 00001111 | верхние 4 пикселя горят, нижние 4 — нет |
0xF0 | 11110000 | верхние 4 тёмные, нижние 4 горят |
0x3C | 00111100 | «серединка» столбца — rows 2..5 горят |
0xAA | 10101010 | «в шахматку» по вертикали |
Развёрнутый пример: байт 8'b00001111 = 0x0F, записанный в (page=2, col=10), зажжёт пиксели по строкам 16-19 (это биты 0...3 = page x 8 + 0..3), а строки 20-23 останутся темными.
Ещё пример - горизонтальная линия в ровно 1 пиксель высотой на всю ширину экрана по строке 30:
строка 30 = page 3, sub-row 6 (3 x 8 + 6 = 30);
значит нужно записать bit 6 = 1, все остальные биты = 0 ⇒ байт 0x40;
в 128 колонках страницы 3 подряд кладём 0x40 × 128.
Горизонтальная линия в страницу другого уровня (скажем строка 31, page=3, sub-row 7) - это всё ещё page 3, но байт 0x80. А горизонтальная линия в строке 32 - уже page 4, байт 0x01.
Для нашего scene-renderer’а очень удобны три формулы: из (x, y) в (page, col, bit) для записи, и обратно - для дебага.
Дано: x ∈ [0..127] — номер столбца (col)
y ∈ [0..63] — номер строки (row)
fb_addr — линейный адрес байта в буфере 0..1023
Прямое преобразование (pixel → byte + bit):
col = x
page = y >> 3 // y / 8
sub_row = y & 3'b111 // y % 8
fb_addr = (page << 7) | col // page * 128 + col
bit_mask = 1 << sub_row
Обратное (byte+bit → pixel):
col = fb_addr[6:0]
page = fb_addr[9:7]
y = (page << 3) | sub_row
x = colВ Verilog-коде scene_renderer именно эти формулы и используются ниже. Заметьте, что обе операции целочисленно-степенные по 2: деление на 8 = сдвиг на 3, mod 8 = AND с 3'b111, page * 128 = сдвиг на 7. Никаких умножений и делителей - значит, всё синтезируется в простую комбинаторику без LUT-алгоритмов.
Поскольку байт кодирует сразу 8 вертикальных пикселей, операция “зажечь один произвольный пиксель (x, y)” не может быть просто записью байта - это перезатрёт другие 7 пикселей в том же столбце страницы.
Нужен классический Read-Modify-Write:
// Псевдокод
uint8_t b = fb[fb_addr]; // read
b |= (1 << sub_row); // set target pixel
// b &= ~(1 << sub_row); // или clear, если "стираем"
fb[fb_addr] = b; // writeВ Verilog на BRAM это занимает три такта:
RD: поставить
raddr = fb_addr, защёлкнутьqчерез такт.MOD: скомбинировать
q | bit_mask(или с AND).WR: записать
fb[waddr] = modified.
В scene_renderer.v этот pipeline реализован состояниями S_EDGE_RD → S_EDGE_WR (см. FSM рендеринга линии Bresenham в соответствующем разделе). Он работает только для “цветных” пикселей; для полного сброса всего экрана (заливка 0x00) можно писать байтами без RMW - это делается быстрее в S_CLEAR.
Важное замечание: именно из-за RMW большинство шрифтов в SSD1306-проектах специально делают высотой 8 пикселей и выровнены по странице - тогда буква занимает ровно 1 байт по вертикали и рисуется одним прямым write без RMW. Мы в проекте применяем эту же оптимизацию для текста: см. раздел о tex-рендере.
В scene_renderer.v используется внутренний фреймбуфер как dual-port BRAM:
reg [7:0] fb [0:1023];
// Порт записи (из рендера)
always @(posedge clk_i) begin
if (fb_we)
fb[fb_waddr] <= fb_wdata;
// Порт чтения для I2C-потока:
rdata_o <= fb[raddr_i];
// Порт чтения для RMW внутри рендера:
fb_rdata_rmw <= fb[fb_raddr_rmw];
endРазмер BRAM - 1024 × 8 бит = 8 Кбит, ровно один блок M9K (9 Кбит) Cyclone IV, настроенный как 1024 х 8. Никаких “урезаний” или паддингов.
Линейный адрес fb_addr = page х 128 + col совпадает с тем самым порядком, в котором SSD1306 принимает пиксели в горизонтальном режиме. Это даёт нулевую ре-упаковку: байт с индексом idx из фреймбуфера можно сразу отправлять в I2C как idx-й data-байт frame-транзакции.
// В ssd1306_ctrl.v, вход data-источника burst-writer’а:
wire [9:0] pix_idx = src_idx[9:0]; // idx ∈ 0..1023
scene_rdata <= fb[pix_idx]; // ровно то, что нужно послатьКогда отлаживаешь рендер без дисплея (например, в симуляторе или на виртуальной модели), удобно иметь следующий мысленный чек-лист:
Весь экран горит? → Все 1024 байта =
0xFF;Чёрный экран? → Все 1024 байта =
0x00;Один пиксель (0, 0) горит? →
fb[0] = 0x01, всё остальное 0;Один пиксель (0, 63) горит? →
fb[128 * 7 + 0] = 0x80, остальное 0. Это и есть левый-нижний угол.Один пиксель (127, 63) горит? →
fb[128 * 7 + 127] = 0x80, т. е.fb[1023] = 0x80;Горизонтальная линия y=0? →
fb[0..127] = 0x01, остальные 896 байт =0x00;Вертикальная линия x=0? →
fb[0], fb[128], fb[256], ..., fb[896] = 0xFF; остальные =0x00(8 байтов, по одному на каждую страницу).
Эти простые тесты позволяют, не прикасаясь к дисплею, убедиться, что рендер корректно формирует фреймбуфер и правильно ориентирует координаты.
SSD1306. Адресация памяти: горизонтальный режим
Выше мы разобрали, как устроена GDDRAM - 1024 байта, в которых каждый байт - это вертикальный столбец 8 пикселей внутри одной из 8 страниц. Теперь нужно понять как SSD1306 получает эти байты по I2C: после control-byte 0x40 чип начинает записывать входящие data-байты в GDDRAM, но по какому адресу - определяется текущим режимом адресации и двумя “окнами” (col range, page range). Неправильная настройка режима - одна из самых частых причин визуальных артефактов (“картинка правильная, но съехала на четверть экрана”, “нижняя половина экрана - мусор”, “видно только верхнюю строчку” и т. п.).
SSD1306 хранит один указатель на текущую ячейку GDDRAM - условная пара (col_ptr, page_ptr), где:
col_ptr ∈ [0..127]- номер столбца;page_ptr ∈ [0..7]- номер страницы.
Каждый принятый data-байт записывается в GDDRAM[page_ptr, col_ptr], после чего курсор автоматически инкрементируется по правилам, зависящим от текущего режима адресации (см. ниже). После автоинкремента, если курсор вышел за пределы “окна” адресации, он либо “заворачивается” внутрь окна (wrap-around), либо переходит по определённому правилу к следующему участку.
Дополнительно курсор ограничен окном - прямоугольной областью, в которой ему разрешено двигаться:
column range: [col_start, col_end], задаётся командой0x21,col_start, col_end;page range: [page_start, page_end], задаётся командой0x22,page_start, page_end.
По умолчанию (после POR) окна охватывают весь экран: col ∈ [0, 127], page ∈ [0, 7]. Но эти значения можно сузить и работать только с частью GDDRAM - например, обновлять только центральную полосу экрана без замены остального.
Команды настройки окон и режима - это всегда Co=0, D/C#=0 - байты («команды»), т. е. идут по control-byte 0x00. В нашем проекте они собраны в init_rom и отправляются один раз за init.
SSD1306 поддерживает три режима, переключаемых командой 0x20 mode:
Command sequence: 0x20 0xMM
───── ────
code mode: 00 = Horizontal
01 = Vertical
02 = Page (default после POR)
03 = invalid (игнорируется)Page addressing (0x20, 0x02). Самый простой режим, поведение по умолчанию после power-on. Курсор живёт внутри одной страницы; page_ptr никогда не меняется автоматически - его нужно задавать вручную командой “Set Page Start Address” (0xB0...0xB7). col_ptr инкрементируется после каждого записанного байта и, достигнув col_end, заворачивается в col_start.
Поведение: col_ptr++ каждый байт
col_ptr > col_end → col_ptr = col_start (остаётся та же page)
page_ptr не меняется автоматическиЭтот режим создавался для “живых” приложений, где дисплей обновляется не целиком, а точечно: нарисовал строку - перешёл на другую страницу и нарисовал ещё одну. Но для нашей задачи “слить весь frame одним burst’ом” он неудобен: после первых 128 байт курсор заворачивается обратно в (col=0, page=0) и начинает затирать первую страницу. Видимый эффект - только верхняя полоска 8 пикселей показывает ожидаемое, остальные 56 строк - либо темные, либо с мусором от прошлых кадров.
Horizontal addressing (0x20, 0x00) - наш выбор. “Пишет как текст на странице”: сначала заполняется целая страница по столбцам, потом курсор переходит на следующую страницу.
Поведение: col_ptr++ каждый байт
col_ptr > col_end → col_ptr = col_start, page_ptr++
page_ptr > page_end → page_ptr = page_start (wrap через весь окно)Именно это нам и нужно для одного frame-burst’а на весь экран. При окне col ∈ [0, 127], page ∈ [0, 7] один проход по всему окну - ровно 128 × 8 = 1024 байта, после чего курсор возвращается в (0, 0) и готов принимать следующий frame без повторной настройки.
Визуально траектория курсора:

Это совпадает с тем, как мы обычно читаем текст: слева-направо, сверху-вниз. Отсюда и название “horizontal”.
Vertical addressing (0x20, 0x01). Зеркало horizontal: сначала заполняются все страницы одного столбца (8 байт на столбец), потом курсор переходит на следующий столбец.
Поведение: page_ptr++ каждый байт
page_ptr > page_end → page_ptr = page_start, col_ptr++
col_ptr > col_end → col_ptr = col_startНа практике полезен для специфических случаев, когда “рисовать” удобнее столбцами - например, спектрограмма/бар-графы, у которых столбец = один “датчик”. Для нашего проекта не используется.
Можно задаться вопросом. Почему horizontal + полное окно - идеальный выбор. Ключевая мысль: при horizontal + full-screen window линейный адрес FPGA-фреймбуфера напрямую совпадает с адресом внутри GDDRAM. Никаких пересчётов и вкраплений команд не нужно:
fb_idx ∈ [0..1023] — линейный индекс в BRAM
page = fb_idx[9:7] — старшие 3 бита = номер страницы
col = fb_idx[6:0] — младшие 7 бит = номер столбца
fb_idx ≡ page * 128 + col
= тот же порядок, в котором SSD1306 запишет байты в GDDRAM
в horizontal-modeЭто свойство (линейный адрес = физический адрес в GDDRAM) и называется zero-copy mapping: буфер в BRAM не нужно переупаковывать перед отправкой - просто гонишь по I2C байт за байтом с возрастающим fb_idx, и чип сам размещает их правильно. В коде:
// В ssd1306_ctrl.v
wire [9:0] pix_idx = src_idx[9:0]; // 0..1023
scene_rdata <= fb[pix_idx]; // читаем из BRAM
// burst-writer отправляет scene_rdata как очередной data-байтЕсли бы мы использовали vertical или page mode, пришлось бы либо:
хранить фреймбуфер в “извращённой” раскладке (с точки зрения рендера) - это усложнит логику
scene_renderer;делать сложный обход при отправке - усложнит
ctrlиburst-writer.
Ни тот, ни другой путь не оправдан.
Теперь про настройку окон (column/page range). Даже в horizontal-режиме всегда полезно явно задать окно:
; Set column range [0..127]
0x21
0x00 ; col_start
0x7F ; col_end (127)
; Set page range [0..7]
0x22
0x00 ; page_start
0x07 ; page_endЭто страхует от ситуации, когда предыдущий код (bootloader, тестовая прошивка, предыдущая версия init) оставил окно суженным - например, col ∈ [10, 117], и наши 1024 байта начнут размазываться в окно шириной 108 × 8 вместо 128 × 8, что визуально даст “смещение и повторение картинки”.
В init_rom ssd1306_ctrl.v эти команды идут в самом начале, сразу после установки режима адресации:
0x20, 0x00, // horizontal addressing mode
0x21, 0x00, 0x7F, // column start/end: 0..127
0x22, 0x00, 0x07, // page start/end: 0..7Что происходит между двумя frame-burst’ами? После этой тройки курсор автоматически устанавливается в (col=0, page=0) - мы можем сразу начинать первый frame-burst, ничего больше не трогая. Важный побочный эффект horizontal + wrap-around: после завершения frame-burst’а (отправили 1024 байта, послали STOP) курсор находится не в конце окна, а снова в начале (col=0, page=0). Это случилось автоматически при прохождении (page=7, col=127) → wrap → (page=0, col=0).
Значит, между двумя frame-burst’ами не нужно ничего настраивать: ни menu-команды, ни сбрасывать курсор, ни заново посылать 0x21/0x22. Достаточно:
... (рендер следующего кадра в BRAM) ...
START → 0x78 → 0x40 → pixel[0] → ... → pixel[1023] → STOPИ так каждые ~46 мс. Экономия и на I2C-трафике, и на логике контроллера.
Если случайно между двумя frame-burst’ами отправили какую-то команду (например, по ошибке control-byte 0x00 вместо 0x40 в начале транзакции), то первые data-байты будут интерпретированы как команды - и это может не только испортить кадр, но и сбить настройки дисплея: контрастность, адресный режим, окно. Симптомы в этом случае обычно драматические: экран мигает/гаснет/показывает “нарезку из случайных фрагментов”. Верный путь отладки - поставить лог-анализатор на шину и проверить control-byte каждой транзакции.
Проговорю немного про адресный режим и отладку. Существует несколько типовых “симптомов → причин”, связанных с адресацией.
Симптом | Вероятная причина |
Всё, что нарисовано, видно только в верхних 8 строках; нижние 56 — либо тёмные, либо мусор | осталось page addressing (режим по умолчанию), курсор заворачивается внутри page 0 |
Картинка сдвинута вправо/влево на N пикселей и «заворачивается» на противоположной стороне | column range задан не [0, 127], а меньше; либо не совпадает col_start с нашим fb_idx=0 |
Нижние несколько страниц всегда тёмные | page range задан короче 8 (например, [0, 5]); все байты сверх 128×6 = 768 пропадают |
Картинка «стоит боком» (то, что ожидали увидеть горизонтально, пошло вертикально) | включён vertical addressing (0x20, 0x01) |
Перестал работать автовозврат курсора в (0,0) после frame’а | в init забыли 0x20, 0x00, либо где-то перезаписали режим командой-«сюрпризом» |
Картинка перевёрнута или отзеркалена | это не проблема адресного режима, а команд 0xA1 (segment remap) и 0xC8 (COM scan dir) — см. раздел 2.5 |
Таким образом, корректное сочетание 0x20, 0x00 + 0x21, 0, 127 + 0x22, 0, 7 - это не декорация, а необходимое условие для того, чтобы работал наш zero-copy frame-burst.
Последовательность инициализации
После подачи питания и внутреннего reset’а (RC-цепочка на пине /RES) SSD1306 оказывается в неопределённо-безопасном, но визуально неработоспособном состоянии: панель выключена, charge pump выключен, регистры настроек - в неизвестных значениях (часть сброшена в default, часть - нет, в зависимости от ревизии чипа). Чтобы получить предсказуемую картинку 128×64 в нужной ориентации и с нужной яркостью, обязательно нужно единожды выполнить последовательность команд - будем называть её init-sequence.
Это 17 команд, некоторые без аргументов, некоторые с одним, одна (0x21) и ещё одна (0x22) - с двумя. Вместе с обязательным control-byte 0x00, который обязан идти первым data-байтом транзакции, они складываются в 32-байтовый поток. Это то самое число, которое зашито как INIT_LEN = 6'd32 в ssd1306_ctrl.v и лежит в init_rom по индексам 0..31.
Инициализация строится по принципу “сначала выключить панель” - “всё настроить” - “включить”. Это важно по двум причинам:
Многие регистры при изменении “на лету” вызывают визуальные артефакты: моргания, полосы, скачки яркости. Если экран выключен - артефакты никому не видны.
Команды “MUX Ratio”, “COM Pins”, “Display Clock” при ошибочных аргументах при включенной панели могут ввести контроллер строк в состояние, когда ток через отдельные OLED-ячейки превышает безопасный, и пиксели могут «выгорать». Это редкий, но задокументированный в даташите риск.
Поэтому первая команда init’а - всегда 0xAE (Display OFF), а последняя - 0xAF (Display ON). Всё между ними можно перестраивать, ничего не опасаясь.
Все 18 команд инициализации удобно разбить на 5 логических групп по назначению. Ниже - каждая группа с пояснениями.
Группа А. Панель в безопасное состояние
Команда | Аргумент | Назначение |
0xAE | — | Display OFF — отключает драйверы строк/столбцов. Все следующие команды можно менять без артефактов и без риска для матрицы. |
Группа B. Параметры синхронизации и размерности
Эти команды определяют, как чип сканирует панель - с какой частотой, сколько строк физически есть, с какими сдвигами и пр.
Команда | Аргумент | Назначение |
0xD5 | 0x80 | Display Clock Divide / F_osc — задаёт внутреннюю частоту тактирования драйвера дисплея. |
0xA8 | 0x3F | Multiplex Ratio — количество строк, которые реально подключены к COM-драйверам. |
0xD3 | 0x00 | Display Offset — сдвиг первой строки по вертикали. 0x00 = без сдвига. Используется, если панель физически смонтирована со смещением (в наших модулях — нет) |
0x40 | — | Display Start Line = 0. Определяет, какая строка GDDRAM соответствует верхней строке экрана. Изменением этого регистра можно делать «программный вертикальный скроллинг», нам это не нужно |
0xDA | 0x12 | COM Pins Hardware Config — указывает чипу, как физически разведены COM-линии. Для 128×64-панели с типовой «альтернативной» разводкой значение = |
Группа C. Питание OLED - charge pump
Эта команда - та самая “невидимая бомба замедленного действия”, которую постоянно забывают разработчики-новички. Все шаги init прошли, NACK’ов нет, 0xAF отправлено - а экран чёрный. Проверить первым делом, что 0x8D, 0x14 реально есть в потоке, и что оно идёт до 0xAF.
Команда | Аргумент | Назначение |
0x8D | 0x14 | Charge Pump Enable. |
Группа D. Адресация GDDRAM
Это то, что мы подробно обсудили в одном из разделов выше. Здесь мы фиксируем режим адресации в horizontal и задаём окно на весь экран.
Команда | Аргумент | Назначение |
0x20 | 0x00 | Memory Addressing Mode = horizontal (см. 2.4.2) |
0x21 | 0x00, 0x7F | Column Address |
0x22 | 0x00, 0x07 | Page Address |
После выполнения этих трёх команд внутренний курсор устанавливается в (col=0, page=0), и мы можем сразу начинать слать frame-burst.
Группа E. Ориентация и внешний вид
Эти команды определяют геометрию изображения относительно физических координат панели, яркость и качество свечения.
Команда | Аргумент | Назначение |
0xA1 | — | Segment Remap: |
0xC8 | — | COM Scan Direction: |
0x81 | 0xCF | Contrast Control = |
0xD9 | 0xF1 | Pre-charge Period: |
0xDB | 0x40 | VCOMH Deselect Level ≈ |
0xA4 | — | Display From RAM (не «все пиксели принудительно вкл.»). Парная к |
0xA6 | — | Normal Display (не инвертировать). Парная к |
Группа F. Включение
Команда | Аргумент | Назначение |
0xAF | — | Display ON — включает драйверы COM/SEG и даёт charge pump’у начать реальную работу. Экран «оживает» именно на этой команде |
Итоговая таблица (в порядке выполнения)
Точно в этом порядке байты зашиты в init_rom в ssd1306_ctrl.v (индексы src_idx = 0..31). Первый байт - не команда SSD1306, а control-byte, признак “все следующие байты - команды”:
idx | Байт | Команда / аргумент | Группа |
0 | 0x00 | control-byte: stream-commands (Co=0, D/C#=0) | — |
1 | 0xAE | Display OFF | A |
2 | 0xD5 | Set Display Clock Div | B |
3 | 0x80 | ├─ arg: div = 1 | B |
4 | 0xA8 | Set Multiplex Ratio | B |
5 | 0x3F | ├─ arg: MUX = 64 | B |
6 | 0xD3 | Set Display Offset | B |
7 | 0x00 | ├─ arg: offset = 0 | B |
8 | 0x40 | Set Display Start Line = 0 | B |
9 | 0x8D | Charge Pump setting | C |
10 | 0x14 | ├─ arg: enable pump | C |
11 | 0x20 | Memory Addressing Mode | D |
12 | 0x00 | ├─ arg: horizontal | D |
13 | 0xA1 | Segment Remap (mirror X) | E |
14 | 0xC8 | COM Scan Direction (mirror Y) | E |
15 | 0xDA | COM Pins Config | B |
16 | 0x12 | ├─ arg: 128×64 alt, no remap | B |
17 | 0x81 | Contrast Control | E |
18 | 0xCF | ├─ arg: contrast = 207 | E |
19 | 0xD9 | Pre-charge Period | E |
20 | 0xF1 | ├─ arg: phase1=1, phase2=15 | E |
21 | 0xDB | VCOMH Deselect Level | E |
22 | 0x40 | ├─ arg: ~0.77·VCC | E |
23 | 0xA4 | Display From RAM | E |
24 | 0xA6 | Normal Display | E |
25 | 0x21 | Set Column Address | D |
26 | 0x00 | ├─ arg: col start = 0 | D |
27 | 0x7F | ├─ arg: col end = 127 | D |
28 | 0x22 | Set Page Address | D |
29 | 0x00 | ├─ arg: page start = 0 | D |
30 | 0x07 | ├─ arg: page end = 7 | D |
31 | 0xAF | Display ON | F |
Итого 32 байта = INIT_LEN. Control-byte 0x00 (idx=0) не отдельный артефакт сверху ROM’а, а его физически первый элемент: это упрощает data-source (burst-writer запрашивает 32 байта, ROM отдаёт 32 байта, ничего мультиплексировать не нужно).
Как это физически передаётся по I2C
Вся init-последовательность идёт одной I2C-транзакцией в режиме stream-commands. Первый data-байт в этой транзакции - 0x00 (control-byte, Co=0, D/C#=0 → “все последующие байты до STOP - команды”), остальные - собственно команды SSD1306:
START → 0x78 → init_rom[0..31] → STOP
(S) ADDR │ │
│ └── init_rom[1..31]: 0xAE, 0xD5, ..., 0xAF
└── init_rom[0] = 0x00 (control-byte)Общая длина на I2C = 1 + 32 = 33 байта-цикла × 9 бит (8 data + ACK) = 297 бит ≈ 1.49 мс на 200 кГц. Пренебрежимо малый расход шины.
В ssd1306_ctrl.v это управляется так:
// Фаза PH_INIT:
bw_slave_addr <= SSD_ADDR; // 0x3C
bw_byte_count <= {10'd0, INIT_LEN}; // 32
bw_start <= 1'b1;
phase <= PH_INITW;Источник данных - функция init_rom(src_idx[5:0]) в самом ssd1306_ctrl.v (большое case-выражение). Control-byte 0x00 лежит внутри ROM по индексу 0, поэтому никаких дополнительных мультиплексоров не требуется - burst-writer просто запрашивает src_idx = 0..31, и получает init_rom[src_idx] как data-байт:
wire [7:0] init_byte = init_rom(src_idx[5:0]);
wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte;В отличие от frame-транзакции, где control-byte 0x40 приходится подставлять отдельным мукс-условием (src_idx == 0 ? 0x40 : fb[idx-1]), для init никакой разницы между control-byte и обычной командой нет - оба идут по одному и тому же пути через init_rom.
Так же, как в разделе выше приведу таблицу “симптом → вероятная ошибка init”:
Симптом | Вероятная причина |
Экран полностью тёмный, картинка не видна | забыта команда 0x8D, 0x14 (charge pump) или 0xAF (display ON) |
Экран полностью светится (все пиксели) | вместо 0xA4 ушла команда 0xA5 (force all ON, тест-режим) |
Экран инвертирован (чёрное по белому) | вместо 0xA6 ушла 0xA7 |
Картинка отзеркалена по X | забыта 0xA1 (или послана 0xA0) |
Картинка отзеркалена по Y | забыта 0xC8 (или послана 0xC0) |
Каждая вторая строка чёрная, картинка «растянута» вдвое | неверный аргумент 0xDA: нужно 0x12 для 128×64, а не 0x02 |
Верхняя половина «съехала» в нижнюю или наоборот | неверный MUX Ratio (0xA8) — не соответствует физическому количеству строк |
Экран мерцает, видны полосы | неверный Display Clock Divide (0xD5) или Pre-charge Period (0xD9) |
Нет ACK уже на первый байт после адреса | дисплей не запитан / не подключен / wrong адрес (см. 2.2.2) |
Первые несколько команд идут, а потом NACK | длинная транзакция и slave «захлебнулся» (редко для 200 кГц; скорее всего — нестабильное питание модуля) |
Картинка правильная, но не мигает / не обновляется | init прошёл, но нет frame-burst’ов: смотреть в раздел 2.4 и логи ssd1306_ctrl |
Этот список - по сути, чек-лист пост-мортем анализа, когда что-то пошло не так. Каждый пункт - однозначная корреляция симптом ↔ конкретный байт init_rom’а.
Важно осознавать: init выполняется один раз после power-on. В ssd1306_ctrl.v переход из PH_INITW (ожидание done от burst-writer’а после init-транзакции) идёт в PH_RENDER (начинается рендер первого кадра), и обратно в PH_INIT мы никогда не возвращаемся без reset’а чипа.
Это естественно: все init-настройки хранятся в статических регистрах SSD1306 и не сбрасываются, пока не отключится питание или не придёт команда на /RES. Перепосылать их каждый кадр было бы бесполезной тратой I2C-трафика (и ~1.5 мс на 200 кГц, т. е. 3% времени на кадр при 46 мс на один frame-burst).
Если же по какой-то причине экран “сбился” (например, из-за сильной ЭМИ-наводки по SDA/SCL или программной ошибки, отправившей “команду” вместо данных), единственный надёжный способ восстановиться - аппаратный reset: либо дёрнуть физический RES-пин, либо отключить и снова подать питание. В лабораторном стенде это делается нажатием кнопки reset на AX301.
Реализуем burst-передачу
Перед тем как обсуждать burst-передачу, нужно чётко понять, какой уровень абстракции предоставляет нам уже готовое ядро rtl/i2c_master_core.v, которое мы успешно реализовали в прошлых статьях. Это определит, что придётся построить поверх него (i2c_burst_writer) и почему именно так.
Ядро реализует bit/byte-level мастер-контроллер I2C. Оно берёт на себя всю низкоуровневую работу:
формирование SCL-клока с настраиваемой частотой (через
ena_i- импульс “каждую четверть периода SCL”, генерируемый внешним prescaler’ом);формирование
START/STOP/RESTART-условий с правильными setup/hold временами;подача и прием бит из сдвигового регистра
tx_shift_r/rx_shift_r;детекция ACK/NACK от slave’а (сэмпл SDA на 9-м бит-слоте);
детекция clock stretching (ядро ждёт, пока SCL физически не отпустится в
high, даже если внутренний prescaler уже хочет продолжать);детекция arbitration lost (сравнение “что мы выставили на SDA” с “что реально на шине” в момент, когда мы ожидали 1);
tri-state управление SDA/SCL (
sda_oen_o,scl_oen_oв open-drain-семантике);индикация
busy_o(шина занята - былSTARTбез ещёSTOP).
Всё, что выше уровня “одной атомарной единицы”, ядро не делает. Оно не знает:
последовательности из нескольких байт - каждый байт запрашивается отдельной командой
CMD_WRITE;как соотносятся адрес и данные - для ядра оба просто “байты”;
про control-byte, про GDDRAM, про “burst” - эти понятия живут на более высоком уровне;
про retries при NACK, про таймауты при clock-stretching - решения принимает верхний уровень.
Такое разделение называется separation of concerns: ядро - это “аппаратный контроллер шины”, а всё, что выше - “протокол устройства”. Мы можем переиспользовать одно и то же ядро для SSD1306, EEPROM, BME280, без единой строчки правок в нём.
Интерфейс ядра
Полный порт-лист i2c_master_core (из rtl/i2c_master_core.v):
// Clock & reset
input wire clk_i;
input wire rstn_i;
input wire ena_i; // 1-такт импульс, каждую четверть SCL
// Command interface
input wire cmd_valid_i; // level, поднят пока команда не принята
input wire [2:0] cmd_i; // код команды (см. ниже)
input wire [7:0] din_i; // TX data при WRITE
output reg [7:0] dout_o; // RX data после READ
output reg rx_ack_o; // ACK от slave (0=ACK, 1=NACK)
output reg ready_o; // 1 — ядро готово принять команду
// Status
output reg arb_lost_o; // sticky: потеря арбитража
input wire arb_lost_clear_i; // импульс сброса arb_lost
output reg busy_o; // шина занята (START без STOP)
// Пины шины I2C (open-drain)
input wire scl_i;
output reg scl_oen_o;
input wire sda_i;
output reg sda_oen_o;Выделим три группы сигналов:
ena_i+prescaler- задаёт частоту SCL. На верхнем уровне (ssd1306_test_top.v) стоит простой счётчик-делитель, который каждую четверть периода SCL выдаёт 1-тактовый импульс. Ядро не использует никакого собственного clock-enable, поэтому его можно тактировать и 50 МГц, и 500 кГц - реальная частота SCL определяетсяena_i. Для нашего проекта периодena_i= 1/800 кГц = 1.25 мкс → частота SCL = 800/4 = 200 кГц.Command interface (
cmd_valid_i/cmd_i/din_i + ready_o/rx_ack_o/dout_o) - то, через что мы дёргаем ядро снаружи. Ровно эти сигналы и проходят черезi2c_burst_writerкак прокси.Status + pads - наружу: индикаторы и физические пины. arb_lost_o/busy_o используются и top-level’ом (для индикации), и burst-writer’ом (для реакции на ошибки);
sda_oen_o/scl_oen_oидут прямо на tri-state-буферы FPGA.
Ядро принимает 6 кодов команд на шине cmd_i[2:0]. Полная сводка:
Код | Мнемоника | Что делает ядро (по шине I2C) | din_i используется? | rx_ack_o валиден после? |
0 | CMD_NOP | ничего (встроенный no-op) | нет | — |
1 | CMD_START | выдаёт START-условие (SDA↓ при SCL=1), ставит busy_o=1 | нет | — |
2 | CMD_WRITE | сдвигает 8 бит из din_i на SDA, на 9-м слоте сэмплирует ACK | да | да (0=ACK, 1=NACK) |
3 | CMD_READ | сдвигает 8 бит из SDA в dout_o, на 9-м слоте выдаёт ACK/NACK согласно din_i[0] | нет (только din_i[0] как ACK-контроль) | — |
4 | CMD_STOP | выдаёт STOP-условие (SDA↑ при SCL=1), снимает busy_o | нет | — |
5 | CMD_RESTART | выдаёт repeated START (без STOP) — переход чтение↔запись в одной транзакции | нет | — |
Важно - команды атомарные: один CMD_WRITE = ровно один байт по шине, один CMD_START = ровно одно START-условие. Ядро не умеет, скажем, “записать 1024 байта одной командой”. Чтобы записать N байт, нужно подать N + 3 команд: START + WRITE(addr) + WRITE(data) × N + STOP. Именно из этой атомарности и вырастает необходимость в burst-writer’е.
Для нашего проекта используются только команды 1, 2, 4 (START / WRITE / STOP). CMD_READ, CMD_RESTART, CMD_NOP остаются “про запас” (для EEPROM, для диагностики и т. д.) - это запас гибкости, который позволит позже переиспользовать ту же архитектуру для других устройств без переделки ядра.
Обмен между ядром и внешним миром идёт по двухфазному рукопожатию:
Ядро: ─── ready=1 ────────── ready=0 ──────── ready=1 ──────
↑ ↑
Ктл.: ────── cmd_valid=1 ──── cmd_valid=0 ────────────────
↑
ядро принимает команду
(в этот такт cmd_i, din_i должны быть валидны)Правила:
Когда ядро свободно, оно держит
ready_o = 1.Внешний контроллер (burst-writer) поднимает
cmd_valid_i = 1только если видитready_o = 1. Одновременно выставляетcmd_iи (если команда -WRITE)din_i.Ядро защёлкивает команду, опускает
ready_o = 0, начинает выполнение. Процесс длится от десятков до сотен системных тактов в зависимости от команды и clock-stretching’а.По завершении ядро снова поднимает
ready_o = 1. В этом же тактеrx_ack_o / dout_oвалидны (дляWRITE/READсоответственно).Внешний контроллер может либо сразу подать следующую команду (удерживая
cmd_valid_i = 1без опускания), либо снятьcmd_valid_iи подождать.
Типичные длительности:
CMD_START: ~2 фазы SCL = ~2 × 1.25 мкс = 2.5 мкс ≈ 125 системных тактов @ 50 МГц;
CMD_WRITE / CMD_READ: 9 бит-слотов × 4 фазы = ~36 фаз ≈ 45 мкс ≈ 2250 тактов;
CMD_STOP: ~2 фазы ≈ 125 тактов.
Значит, полный «цикл одного байта» = WRITE-команда ≈ 45 мкс - это и есть нижняя граница времени передачи 1 байта по I2C на 200 кГц.
Можно было бы встроить в само ядро счётчик байтов и поток-источник, и тем самым “проглотить” функциональность burst-writer’а. Мы этого не делаем по трём причинам:
Переиспользуемость. Не все устройства хотят burst: у EEPROM Page Write - строго 32 байта на page (далее нужен новый ADDR-фрейм). BME280 - write 1 регистр, read 6 байт (smart retry). Если вложить “burst на N байт” внутрь ядра, под разные устройства придётся параметризовать, добавлять режимы и т. д. - сложность лавинообразно растёт.
Тестируемость. С ядром как "1 команда = 1 atom" можно гонять микро-тесты по одной команде и покрывать всю матрицу ACK/NACK/arb-lost. С burst-ядром тест-векторы исчисляются миллионами комбинаций.
Сложность FSM. Разделение “bit/byte-FSM” и “burst-FSM” даёт два маленьких автомата (~100 LE каждый) вместо одного большого (~500 LE) с большим радиусом разработки. Это прямо влияет на timing closure на Cyclone IV.
Этот выбор - классический пример принципа Single Responsibility из программирования, перенесенного в RTL.
Интерфейс burst-writer’а
Теперь сосредоточимся на проработке интерфейса для будущего burst-интерфейса:

Модуль параметризуется единственным параметром:
Параметр | Значение | Назначение |
CNT_W | 16 по умолчанию | ширина счётчика byte_count_i; 16 бит ⇒ максимум 65 535 data-байт в одной транзакции |
Теперь рассмотрим один за другим сигналы модуля.
Служебные сигналы
clk_i - системный тактовый сигнал (50 МГц в нашем проекте). Все регистры модуля - positive-edge. Никаких отдельных clock-domain’ов внутри burst-writer’а нет: он работает в той же частоте, что и i2c_master_core, поэтому между ними нет ни CDC-проблем, ни FIFO-буферов.
rstn_i - асинхронный active-low reset. Устанавливает FSM в S_IDLE, счётчики в 0, cmd_valid_o = 0, done_o = 0, error_o = 0. Синхронизация reset’а - задача top-level (в нашем проекте мы полагаемся на то, что кнопка сброса N13 проходит через встроенный в Cyclone IV debouncer + power-on reset контроллер и стабилизируется задолго до первой активности клока).
Сторона управления (control-side)
start_i - импульс запуска транзакции. Ожидается как один такт 1, но модуль устойчив к удлинённому импульсу: захват slave_addr_i и byte_count_i происходит в единственном переходе из S_IDLE, повторные высокие уровни игнорируются, пока FSM не вернётся в S_IDLE.
Типичный паттерн на стороне ssd1306_ctrl:
PH_INIT: begin
bw_start <= 1'b1; // assert на 1 такт
bw_byte_count <= {10'd0, INIT_LEN};
phase <= PH_INITW;
end
// ... в следующем такте ssd1306_ctrl уже в PH_INITW,
// а bw_start автоматически сброшен:
// bw_start <= 1'b0; в начале always-блокаslave_addr_i[6:0] - 7-битный I2C-адрес slave-устройства. Модуль сам сформирует полный 8-битный стартовый байт: addr_byte = {slave_addr_i, 1'b0} (последний бит = 0 для операции записи). Поддержка операций READ в burst-writer’е не реализована - только WRITE. Если нужен READ-burst (для EEPROM Random Read), делают отдельный модуль.
Значение slave_addr_i защёлкивается одновременно с start_i и остаётся стабильным внутри модуля до завершения транзакции. После защёлкивания сигнал на входе может меняться - это не повлияет на текущую передачу.
byte_count_i[CNT_W-1:0] - число data-байт, которое нужно отправить после байта адреса. Поясняю: полная I2C-транзакция в нашем случае выглядит как START + ADDR + DATA×N + STOP, где N = byte_count_i. Сам байт адреса в счётчик не входит.
Граничные случаи:
byte_count_i = 0- транзакцияSTART + ADDR + STOP. Полезно для “прощупывания” адреса (I2C device discovery): если адрес отвечает ACK, “error_o = 0”, иначе “error_o = 1”.byte_count_i = INIT_LEN = 32- init SSD1306.byte_count_i = DATA_LEN = 1025- передача фреймбуфера (1 control-байт + 1024 пикселя).
Значение защёлкивается в cnt одновременно со start_i и декрементируется на каждом успешно переданном data-байте.
busy_o - уровень-индикатор активности: 1, пока state != S_IDLE. Используется верхним уровнем для того, чтобы:
не ассертить
start_iповторно, пока предыдущая транзакция идёт;зажечь “busy-LED” (в нашем проекте -
LED[0]);проверки “можно ли отпустить шину” при реакции на внешние события.
Однократная проверка !busy_o перед новой командой + однотактовый start_i - канонический паттерн использования.
done_o - одноцикловый импульс завершения. Assert-ится ровно на 1 такт при переходе из S_DONE обратно в S_IDLE. Это важно подчеркнуть: done_o - не уровень. Если верхний уровень пропустит такт (например, не успеет семплировать в синхронном регистре) - импульс будет потерян.
В ssd1306_ctrl мы ловим его одной строкой:
PH_INITW: begin
if (bw_done) begin // захватит импульс в любом случае,
... // т. к. проверяется каждый такт
end
endАльтернативный вариант конечно можно реализовать, то есть держать done-защёлку, но это избыточно, когда получатель - тоже синхронный FSM.
error_o - уровень-флаг ошибки, валиден одновременно с done_o. Причины для установки:
slave ответил NACK на байт адреса (
rx_ack_i = 1послеADDR_WAIT);slave ответил NACK на любой data-байт;
произошла потеря арбитража (
arb_lost_i = 1) в момент активной транзакции.
Флаг сохраняется вплоть до следующего start_i (тогда в S_IDLE он очищается: error_o <= 1'b0). Это значит, что верхний уровень может сэмплировать error_o даже несколько тактов после done_o, если ему так удобнее (например, для зажигания LED через одну дополнительную защелку).
Поток данных (data-side)
Интерфейс преднамеренно асинхронен - burst-writer не знает природы источника данных. Это может быть:
case-функция (ROM) - как наш
init_romвssd1306_ctrl;dual-port BRAM - как
scene_rendererдля пиксельных данных;FIFO, заполняемый с UART/SPI;
потоковый генератор (counter, PRBS);
даже внешний AXI-stream - через небольшой адаптер.
Протокол обмена - простая req/valid-рукопожатие:

data_req_o — уровень-запрос очередного data-байта. Комбинаторно равен (state == S_DATA_REQ). То есть:
поднимается ровно в тот момент, когда FSM входит в
S_DATA_REQ;держится в 1, пока не придёт
data_valid_i = 1;в том же такте, когда
data_valid_i = 1, FSM переходит вS_DATA_CMD, иdata_req_oавтоматически падает.
Таким образом, источник обязан выдать данные в ответ на req. Если источник “не знает” данных (например, FIFO пустой) - он может держать data_valid_i = 0 сколь угодно долго, burst-writer будет терпеливо ждать, не снимая data_req_o. Это обеспечивает совместимость с медленными источниками без риска потерять байты.
data_i[7:0] - сам байт данных. Должен быть валиден в любом такте, когда источник поднимает data_valid_i = 1. Для комбинаторных источников часто удобнее вообще не различать req и valid - подавать данные постоянно (они меняются синхронно с data_req_o).
data_valid_i - квитанция «байт валиден на data_i».
Тактика использования зависит от источника:
Тип источника | Связь с data_req_o |
Комбинаторный (ROM, мукс) |
|
Регистровый 1-такт (BRAM sync) | Данные уже готовы к моменту |
FIFO, переменная латентность |
|
В нашем проекте реализован первый вариант: data_valid_i = bw_data_req. Это экономит 1 такт на каждом байте (незначительно, но даром).
Интерфейс к ядру i2c_master_core
Эти сигналы - прямой прокси ядра. Burst-writer не добавляет к ним никаких преобразований, только вставляет свои значения в нужное время.
cmd_valid_o - уровень-защёлка, говорящий ядру: “на входе cmd_i/din_i валидная команда, забирай”. Двухфазное рукопожатие:
burst-writer поднимает
cmd_valid_o = 1, когдаready_i = 1(ядро готово);держит в
1до детекцииready_i = 0(ядро приняло команду);в том же такте сбрасывает
cmd_valid_oи уходит вWAIT-состояние.
Важно: одновременно с cmd_valid_o должны быть валидны cmd_o и din_o. Поэтому мы присваиваем их в тех же тактах и в том же состоянии, что и cmd_valid_o.
cmd_o[2:0] - код I2C-команды, подаваемый ядру:
Код | Мнемоника | Когда выдаём burst-writer’ом |
1 | CMD_START | в S_START_CMD, один раз в начале транзакции |
2 | CMD_WRITE | в S_ADDR_CMD (адрес) и в S_DATA_CMD (каждый байт данных) |
4 | CMD_STOP | в S_STOP_CMD, один раз в конце (или при NACK) |
din_o[7:0] - байт для передачи ядру по I2C. В разных состояниях подаются разные источники:
S_ADDR_CMD→{slave_addr_i, 1'b0}(сохранено в addr_byte);S_DATA_CMD→data_i(защёлкнуто вdin_oпри рукопожатии с источником).
Для CMD_START, CMD_STOP значение din_o ядру безразлично - оно игнорируется.
ready_i - уровень-готовность ядра принять следующую команду. Семантика:
ready_i = 1вS_IDLEядра - ждёт новую команду;ready_i = 0- ядро занято (идёт START, передача бита, clock-stretching от slave’а, и т. д.);ready_iснова поднимается в1на такте, следующем после завершения команды - в этот момент можно одновременно читатьrx_ack_o/dout_oи ассертить следующую команду.
Использование в burst-writer’е - в двух местах:
_CMD-состояния: условие для ассерта cmd_valid_o - “ждать, пока ready_i = 1”;_WAIT-состояния: ждать, пока ready_i снова не станет 1 (значит команда выполнена).
rx_ack_i- ACK/NACK от slave-устройства после только что переданного WRITE-байта. Значение:
rx_ack_i = 0- ACK, всё хорошо, можно передавать следующий байт.rx_ack_i = 1- NACK, slave не подтвердил приём. Burst-writer интерпретирует это как ошибку: ставитnack_flag, прерывает передачу и отправляет STOP, чтобы корректно освободить шину.
rx_ack_i валиден только в такте, когда ready_i поднимается после WRITE-команды. В другие моменты его значение - “недокументированный мусор”, его не нужно смотреть.
arb_lost_i - флаг потери арбитража от ядра. Sticky-уровень (удерживается до внешнего arb_lost_clear_i ядра). Активируется, если ядро обнаружило, что какой-то другой мастер “перехватил” шину: ядро ждало SDA = 1 (отпустило линию), но физически прочитало SDA = 0 при SCL = 1 - значит, кто-то другой тянет линию к земле.
В burst-writer’е обработка arb-lost имеет высший приоритет - это единственное условие, которое может прервать передачу посреди байта:
if (arb_lost_i && busy_o) begin
cmd_valid_o <= 1'b0; // перестать подавать команды ядру
nack_flag <= 1'b1; // выставить error_o в S_DONE
state <= S_DONE; // аварийный выход
endSTOP не отправляется - шина у другого мастера, любая попытка записи только создаст коллизию. Ядро само отпускает SDA/SCL, мы молча сдаёмся, сигналим error_o, и ждём от верхнего уровня команды arb_lost_clear_i + повторного start_i для retry.
Сводная таблица:
Сигнал | Dir | Ширина | Тип | Назначение |
clk_i | in | 1 | clock | системный такт (50 МГц) |
rstn_i | in | 1 | async reset | active-low сброс |
start_i | in | 1 | pulse | запуск новой I2C-транзакции |
slave_addr_i | in | 7 | level | 7-битный адрес slave (W=0 добавляется сам) |
byte_count_i | in | CNT_W | level | сколько data-байт после адреса |
busy_o | out | 1 | level | транзакция идёт (state ≠ IDLE) |
done_o | out | 1 | 1-cycle pulse | завершение транзакции |
error_o | out | 1 | level | NACK или arb-lost, валиден c done_o |
data_req_o | out | 1 | level | запрос очередного data-байта |
data_i | in | 8 | level | сам data-байт от источника |
data_valid_i | in | 1 | level | квитанция «data_i валиден» |
cmd_valid_o | out | 1 | level | команда для ядра валидна |
cmd_o | out | 3 | level | код команды (START/WRITE/STOP) |
din_o | out | 8 | level | байт для передачи по I2C |
ready_i | in | 1 | level | ядро готово принять/завершило команду |
rx_ack_i | in | 1 | level | ACK(0)/NACK(1) от slave (валиден с rising ready_i) |
arb_lost_i | in | 1 | level | потеря арбитража на шине |
Сам модуль не знает, откуда берутся данные - это может быть ROM, BRAM, FIFO или генератор. Это разделение ответственности - основная абстракция проекта: burst-writer - “менеджер транзакции”, а что передавать - забота верхнего уровня.
Модуль i2c_burst_writer - автомат пакетной записи
Теперь детально разберем исходный код rtl/i2c_burst_writer.v с построчным объяснением. Модуль сравнительно небольшой (~200 строк Verilog-кода), но именно он превращает “атомарное” I2C-ядро в полноценный burst-transport, и именно он задаёт фреймворк, под который пишутся все device-контроллеры (SSD1306, а в будущем - EEPROM, BME280 и др.).
Место модуля между ssd1306_ctrl (верхний уровень) и i2c_master_core (ядро шины):

Высоуровневый контракт: модуль принимает (slave_addr, byte_count, data-stream) и генерирует полную транзакцию I2C автоматически управляя ядром через его атомарные команды:
START → WRITE(addr) → WRITE(data0) → … → WRITE(data_{N-1}) → STOPРассмотрим объявление модуля и параметр CNT_W. Начало файла rtl/i2c_burst_writer.v:
module i2c_burst_writer #(
parameter CNT_W = 16
)(
input wire clk_i,
input wire rstn_i,
// Control
input wire start_i,
input wire [6:0] slave_addr_i,
input wire [CNT_W-1:0] byte_count_i,
output wire busy_o,
output reg done_o,
output reg error_o,
// Data source
output wire data_req_o,
input wire [7:0] data_i,
input wire data_valid_i,
// i2c_master_core command interface
output reg cmd_valid_o,
output reg [2:0] cmd_o,
output reg [7:0] din_o,
input wire ready_i,
input wire rx_ack_i,
input wire arb_lost_i
);CNT_W - единственный параметр, задающий разрядность счётчика байт byte_count_i. Значение по умолчанию - 16 бит, что даёт потолок 65 535 байт в одной транзакции. Этого с запасом хватает и для 32-байтового init SSD1306, и для 1025-байтового frame-burst, и даже для “длинных” устройств вроде EEPROM 24LC64 (страницы по 32, однако полная RAM - 8 КиБ).
Семантика портов полностью совпадает с таблицей раздела приведенного выше - там каждый сигнал расписан подробно.
Теперь объявим внутренние регистры и именованные константы. Сразу после port-list’а:
localparam [2:0] CMD_START = 3'd1,
CMD_WRITE = 3'd2,
CMD_STOP = 3'd4;
localparam [3:0] S_IDLE = 4'd0,
S_START_CMD = 4'd1,
S_START_WAIT = 4'd2,
S_ADDR_CMD = 4'd3,
S_ADDR_WAIT = 4'd4,
S_DATA_REQ = 4'd5,
S_DATA_CMD = 4'd6,
S_DATA_WAIT = 4'd7,
S_STOP_CMD = 4'd8,
S_STOP_WAIT = 4'd9,
S_DONE = 4'd10;
reg [3:0] state;
reg [CNT_W-1:0] cnt;
reg [7:0] addr_byte;
reg nack_flag;CMD_START/WRITE/STOP - мы используем только три команды ядра из шести существующих. CMD_READ/CMD_RESTART/CMD_NOP не нужны для WRITE-burst’а - поэтому в модуле их мнемоник даже не объявляем.
S_* - 11 состояний FSM. 4 бит ([3:0]) достаточно для 16 значений, с запасом. Каждое состояние - логическая единица транзакции: CMD = “ассертить команду ядру”, WAIT = “ждать завершения”, _REQ = “запросить байт у источника”, и т. д.
state - сам регистр FSM.
cnt - счётчик оставшихся data-байт (без байта адреса). Защёлкивается из byte_count_i при start_i и декрементируется после каждого успешно переданного data-байта.
addr_byte - полный 8-битный ADDR-байт, собранный из 7-битного slave_addr_i и младшего W=0. Защёлкивается при start_i, чтобы оставаться стабильным на весь срок транзакции, даже если наверху slave_addr_i изменится.
nack_flag - липкий однобитный флаг «в ходе транзакции был NACK или arb-lost». Выставляется в 1 при ошибке, переносится в error_o в S_DONE.
Далее рассмотрим комбинаторные выходы busy_o и data_req_o. Сразу после объявлений регистров объявим их:
assign busy_o = (state != S_IDLE);
assign data_req_o = (state == S_DATA_REQ);Оба выхода - чистая комбинаторика от регистра state, без участия других сигналов. Это важно:
busy_o - видно наружу мгновенно в тот же такт, когда FSM переходит в не-S_IDLE. Это позволяет верхнему уровню точно синхронизировать start_i с ним (“ждём busy_o = 0, потом поднимаем start_i на 1 такт”).
data_req_o - также мгновенно поднимается при входе в S_DATA_REQ и падает при выходе. Для комбинаторного источника данных (ROM/мукс) это означает “сейчас же защёлкнуть байт”.
Комбинаторное выражение через state также гарантирует, что никакие лишние такты не будут потрачены на “перезапись ready/req в отдельный регистр” - меньше защёлок, прощё тайминги.
Синхронный блок и сброс
Вся логика модуля живёт в одном always @(posedge clk_i or negedge rstn_i):
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i) begin
state <= S_IDLE;
cnt <= {CNT_W{1'b0}};
addr_byte <= 8'd0;
nack_flag <= 1'b0;
cmd_valid_o <= 1'b0;
cmd_o <= 3'd0;
din_o <= 8'd0;
done_o <= 1'b0;
error_o <= 1'b0;
end else begin
done_o <= 1'b0;
…Асинхронный active-low reset (negedge rstn_i) ставит FSM в S_IDLE и очищает все выходы. В начале else-ветки стоит однострочный pre-assign done_o <= 1'b0; - это то самое “default-выражение”, которое делает done_o одноцикловым импульсом: когда в S_DONE мы пишем done_o <= 1'b1, в следующем такте этот pre-assign вернёт его в 0 (если в этом такте не выполнится другой assign).
Далее добавляем страж arbitration-lost который будет работать с наивысшим приоритетом. Сразу после done_o <= 1'b0:
if (arb_lost_i && busy_o) begin
cmd_valid_o <= 1'b0;
nack_flag <= 1'b1;
state <= S_DONE;
end else begin
case (state)
…Это единственный случай, когда FSM прерывает свою работу посередине любого состояния. Логика:
arb_lost_iвалиден только когда ядро обнаружило физический конфликт на SDA с другим мастером. Стоит sticky-уровень доarb_lost_clear_iядра.Проверка
&& busy_oгарантирует, что мы не реагируем, если arb-lost пришёл вS_IDLE(тогда мы не являемся источником конфликта, это чужая проблема).Действие: опустить
cmd_valid_o(чтобы ядро не принимало новых команд), поднятьnack_flag(для error_o) и уйти вS_DONE.STOPне отправляется - нельзя: шину физически удерживает другой мастер, любая наша попытка записи превратится в повторный конфликт. Ядро само отпускает SDA/SCL при детекции al_event.
Этот страж оборачивает весь case, чтобы перекрывать любой переход FSM. Приоритет выше, чем обычные переходы - это классический pattern “safety first”.
Состояние S_IDLE
S_IDLE: begin
if (start_i) begin
addr_byte <= {slave_addr_i, 1'b0};
cnt <= byte_count_i;
nack_flag <= 1'b0;
error_o <= 1'b0;
state <= S_START_CMD;
end
endВ S_IDLE модуль ждёт start_i. При его обнаружении:
Защёлкивает 8-битный
addr_byte: 7-битный адрес сдвигается на 1 влево, младший бит = 0 (W - write).Защёлкивает
cntизbyte_count_i. С этого момента значение внутри модуля стабильно, даже если верхний уровень изменитbyte_count_i.Сбрасывает
nack_flagиerror_o- предыдущая ошибка забывается, новая транзакция начинается с “чистого листа”.Переходит в
S_START_CMD.
Если start_i = 0 - остаёмся в S_IDLE, busy_o = 0, data_req_o = 0, ничего не выходит на ядро. Это состояние покоя.
Пара состояний S_START_CMD / S_START_WAIT
Выдача START-условия на шину:
S_START_CMD: begin
cmd_o <= CMD_START;
if (ready_i)
cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_START_WAIT;
end
end
S_START_WAIT: begin
if (ready_i)
state <= S_ADDR_CMD;
endЭто - эталонный шаблон «cmd + wait», повторяющийся во всех _CMD/ _WAIT-парах модуля. Разберём по тактам:
такт state cmd_o cmd_valid_o ready_i
1 S_START_CMD START 0 1 ← видим ready=1 → готовимся
2 S_START_CMD START 1 1 ← выставили valid=1,ядро ещё готово
3 S_START_CMD START 1 0 ← ядро приняло, опустило ready
4 S_START_CMD START 0 0 ← мы увидели (!ready & valid),
опустили valid, уходим в WAIT
5 S_START_WAIT — 0 0 ← ядро работает
... (много тактов, генерация START на шине) ...
N S_START_WAIT — 0 1 ← ready снова 1 → команда выполнена
N+1 S_ADDR_CMD — 0 1 ← переходим к следующей команде
Ключевая идея - двухфазное рукопожатие, которое рассмотрим ниже: сначала ассерт при ready_i = 1, затем деассерт при ready_i = 0. Это точнее, чем просто “ассерт при ready_i = 1 на 1 такт”, потому что если по каким-то причинам ядро среагирует не на 1-м такте (clock-stretching, внутренняя задержка), мы не “промахнёмся” - cmd_valid_o будет удерживаться, пока не увидим реальный ответ.
Почему нельзя просто if (!ready_i) cmd_valid_o <= 0? Потому что ready_i = 0 в начале означает “ядро занято прошлой командой” (а не “ядро приняло нашу”). Надо отличать эти две ситуации. Комбинация “cmd_valid_o = 1 и ready_i = 0” однозначна - она возможна только если ядро приняло нашу текущую команду.
Пара S_ADDR_CMD / S_ADDR_WAIT
Отправка 8-битного ADDR-байта на шину:
S_ADDR_CMD: begin
cmd_o <= CMD_WRITE;
din_o <= addr_byte;
if (ready_i)
cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_ADDR_WAIT;
end
end
S_ADDR_WAIT: begin
if (ready_i) begin
if (rx_ack_i) begin
nack_flag <= 1'b1;
state <= S_STOP_CMD;
end else if (cnt == {CNT_W{1'b0}})
state <= S_STOP_CMD;
else
state <= S_DATA_REQ;
end
endS_ADDR_CMD - копия шаблона START, но с cmd_o = CMD_WRITE и din_o = addr_byte. Оба сигнала выставляются одновременно с cmd_valid_o; ядро, принимая команду, считывает их.
В S_ADDR_WAIT происходит первая разветвлённая логика:
rx_ack_i == 1(NACK от slave’а) — slave не ответил на адрес. Ставимnack_flag = 1, уходим вS_STOP_CMD— даже при ошибке нужно корректно отпустить шину.cnt == 0— случай «пробы адреса»:byte_count_iбыл равен 0, data-байтов нет. Идём сразу вS_STOP_CMD, без ошибки. Это тот самый I2C probe.иначе — нормальный путь: переходим в
S_DATA_REQдля первого data-байта.
Триада data-состояний S_DATA_REQ / S_DATA_CMD / S_DATA_WAIT
Это ядро burst-логики - цикл по всем data-байтам.
S_DATA_REQ: begin
if (data_valid_i) begin
din_o <= data_i;
state <= S_DATA_CMD;
end
end
S_DATA_CMD: begin
cmd_o <= CMD_WRITE;
if (ready_i)
cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_DATA_WAIT;
end
end
S_DATA_WAIT: begin
if (ready_i) begin
cnt <= cnt - {{(CNT_W-1){1'b0}}, 1'b1};
if (rx_ack_i) begin
nack_flag <= 1'b1;
state <= S_STOP_CMD;
end else if (cnt == {{(CNT_W-1){1'b0}}, 1'b1})
state <= S_STOP_CMD;
else
state <= S_DATA_REQ;
end
endПолный цикл одного data-байта:
S_DATA_REQ: выставленdata_req_o = 1. Ждём, пока источник подниметdata_valid_i = 1. В момент, когда оба сигнала совпадают (data_req_o = 1иdata_valid_i = 1) - защёлкиваемdin_o <= data_iи переходим вS_DATA_CMD. В том же тактеdata_req_oавтоматически становится 0 (потому чтоstate != S_DATA_REQ).S_DATA_CMD: тот же шаблон «cmd+valid», что и для START/ADDR.cmd_o = MD_WRITE,din_oуже загружен. Когда ядро приняло - переходим вS_DATA_WAIT.S_DATA_WAIT: ядро работает, физически сдвигает байт на SDA. Когда закончитready_i = 1снова):
- декрементcnt- без-=(Verilog не знает), явным сложением с-1. Выражение{{(CNT_W-1){1'b0}}, 1'b1}- это1в ширинеCNT_Wбит (для 16-бит это16'h0001).
- проверкаrx_ack_i: NACK →nack_flag = 1,S_STOP_CMD;
- если после декрементаcntстал0(т. е. только что отправлен последний data-байт) -S_STOP_CMD;
- иначе - возврат вS_DATA_REQза следующим байтом.
Тонкость сравнения на «последний байт». Сравнение делается до декремента - сравниваем с 1, а не 0 (cnt == 1 означает «только что отправили 1-й из оставшихся - он был последним»). В коде это выглядит как cnt == {{(CNT_W-1){1'b0}}, 1'b1} - тот же 1 в CNT_W бит. cnt <= cnt - 1 в том же такте просто отражает в регистре новое состояние - на FSM-переход это не влияет, т. к. переход рассчитан до новой записи.
Число тактов на один байт — складывается из:
1 такт на
S_DATA_REQ(если источник мгновенно валиден);~2 такта на
S_DATA_CMD(peak ready, опустить valid);~2250 тактов на
S_DATA_WAIT(ядро гонит 9 бит по SCL = 1125 мкс ≈ 45 мкс).
Итого ~45 мкс на байт - ограничение именно физической частоты SCL. Логика burst-writer’а тут занимает всего ~3–5 тактов (0.1 мкс) - пренебрежимо мало.
Пара S_STOP_CMD / S_STOP_WAIT
Финальное STOP-условие и выход:
S_STOP_CMD: begin
cmd_o <= CMD_STOP;
if (ready_i)
cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_STOP_WAIT;
end
end
S_STOP_WAIT: begin
if (ready_i)
state <= S_DONE;
endАбсолютно такой же шаблон «cmd+wait», как для START, только с cmd_o = CMD_STOP. Завершает транзакцию, после чего ядро ставит busy_o = 0 на самой шине, cmd_valid_o уже 0, FSM — в S_STOP_WAITдо тех пор, пока ядро снова не освободится (ready_i = 1), после чего уходит в S_DONE.
Состояние S_DONE и default
S_DONE: begin
done_o <= 1'b1;
error_o <= nack_flag;
state <= S_IDLE;
end
default: state <= S_IDLE;В S_DONE модуль:
Поднимает
done_o = 1. В следующем же такте pre-assign (done_o <= 1'b0в начале always-блока) вернёт его в 0. Так получается одноцикловый импульс.Переносит
nack_flagвerror_o.error_o- level-флаг, удерживается до следующегоstart_i(где он очистится вS_IDLE).Возвращается в
S_IDLE.default: state <= S_IDLE;- страховка от попадания в непредусмотренное значение регистраstate(глитч при загрузке FPGA, однобитная SEU-ошибка в BRAM-регистре, и пр.). Возврат вS_IDLE- безопасный recovery.
Шаблон двухфазного рукопожатия — диаграмма
Для каждой выдаваемой команды (START / WRITE(addr) / WRITE(data) / STOP) используется один и тот же рисунок сигналов:

Фаза CMD. Мы ассертим cmd_valid_o = 1, когда ready_i = 1 (ядро готово), и деассертим, когда ready_i падает (ядро приняло команду).
Фаза WAIT. Мы молча ждём, когда ready_i снова станет 1 — это сигнал «команда выполнена, результат готов». Для WRITE это одновременно и «ACK/NACK от slave зафиксирован в rx_ack_i».
Почему не if (!ready_i) напрямую? Потому что ready_i = 0 может означать «ядро занято предыдущей командой» — в этом случае ассертить нельзя. Пара ready_i=1 → cmd_valid_o=1, затем cmd_valid_o=1 && ready_i=0 → деассерт однозначно описывает «ядро приняло нашу команду».
Интерфейс источника данных
Связь с источником (ROM / BRAM / FIFO / генератор) — асинхронный req/valid-протокол:

Для комбинаторного источника (ROM-функция, процедурный мукс) data_valid_i = data_req_o напрямую — данные появляются в том же такте, когда поднят req.
Для регистрового источника (BRAM с синхронным чтением) допустима задержка 1–2 такта: burst-writer будет ждать в S_DATA_REQ, пока data_valid_i не поднимется. В нашем проекте между двумя запросами проходит ~45 мкс (время I2C-байта), поэтому 1-тактовая задержка BRAM’а растворяется в этом интервале.
В ssd1306_ctrl используется комбинаторная связка:
// из quartus_ssd1306/src/ssd1306_ctrl.v
wire [7:0] init_byte = init_rom(src_idx[5:0]);
wire [7:0] data_byte = (src_idx == 11'd0) ? 8'h40 : scene_rdata;
wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte;
assign bw_data_valid = bw_data_req; // комбинаторное равенствоinit_byte — comb-ROM (функция), scene_rdata — регистровый выход BRAM, но к моменту запроса BRAM уже успел обновиться (за счёт подставления raddr_i = src_idx заблаговременно).
Обработка ошибок — сводка
Три возможных сценария сбоя, как они обрабатываются:
Событие | Момент обнаружения | Обработка | Результат |
|---|---|---|---|
NACK на адресе |
|
|
|
NACK на данных |
| аналогично: | то же |
Arb-lost | любое состояние, | страж: |
|
Верхний уровень (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 — ширина счётчика байт:
| max | Типичное применение |
|---|---|---|
8 | 255 | маленькие slave’ы (AT24C02 EEPROM, 256 байт) |
10 | 1023 | в точности наш frame (1025 > 1023 — уже не влезет, нужно ≥11 или ≥16) |
16 | 65535 | наш выбор — любой разумный sensor/display/EEPROM |
20 | 1048575 | для длинных flash-транзакций |
Расход ресурсов на счётчик линейный: 8 бит → 8 регистров + 8-бит компаратор; 16 бит → 16 + 16. Для Cyclone IV разница пренебрежимая (~20 LE). Поэтому ставим «железобетонные» 16 бит и забываем о лимите.
Полный листинг i2c_burst_writer.v (с аннотациями)
Ниже — полный исходный код модуля с комментариями, указывающими связь с разобранными выше подразделами.
// ---------------------------------------------------------------------------
// I2C Burst Writer — auto-sequences multi-byte I2C write transactions.
// ---------------------------------------------------------------------------
module i2c_burst_writer #(
parameter CNT_W = 16 // [4.2] ширина счётчика, 16 бит по умолчанию
)(
input wire clk_i,
input wire rstn_i,
// Control — см. 3.3.2
input wire start_i,
input wire [6:0] slave_addr_i,
input wire [CNT_W-1:0] byte_count_i,
output wire busy_o,
output reg done_o,
output reg error_o,
// Data source — см. 3.3.3 и 4.14
output wire data_req_o,
input wire [7:0] data_i,
input wire data_valid_i,
// i2c_master_core command interface — см. 3.1 и 4.13
output reg cmd_valid_o,
output reg [2:0] cmd_o,
output reg [7:0] din_o,
input wire ready_i,
input wire rx_ack_i,
input wire arb_lost_i
);
// ----- Мнемоники команд ядра (см. 3.1.3) -----
localparam [2:0] CMD_START = 3'd1,
CMD_WRITE = 3'd2,
CMD_STOP = 3'd4;
// ----- Состояния FSM (см. 4.3 и диаграмму в 4.18) -----
localparam [3:0] S_IDLE = 4'd0,
S_START_CMD = 4'd1,
S_START_WAIT = 4'd2,
S_ADDR_CMD = 4'd3,
S_ADDR_WAIT = 4'd4,
S_DATA_REQ = 4'd5,
S_DATA_CMD = 4'd6,
S_DATA_WAIT = 4'd7,
S_STOP_CMD = 4'd8,
S_STOP_WAIT = 4'd9,
S_DONE = 4'd10;
reg [3:0] state;
reg [CNT_W-1:0] cnt;
reg [7:0] addr_byte;
reg nack_flag;
// ----- Комбинаторные выходы (см. 4.4) -----
assign busy_o = (state != S_IDLE);
assign data_req_o = (state == S_DATA_REQ);
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i) begin
// ----- Асинхронный reset (см. 4.5) -----
state <= S_IDLE;
cnt <= {CNT_W{1'b0}};
addr_byte <= 8'd0;
nack_flag <= 1'b0;
cmd_valid_o <= 1'b0;
cmd_o <= 3'd0;
din_o <= 8'd0;
done_o <= 1'b0;
error_o <= 1'b0;
end else begin
// ----- done_o — одноцикловый импульс (см. 4.5, 4.12) -----
done_o <= 1'b0;
// ----- Страж arb-lost, высший приоритет (см. 4.6) -----
if (arb_lost_i && busy_o) begin
cmd_valid_o <= 1'b0;
nack_flag <= 1'b1;
state <= S_DONE;
end else begin
case (state)
// ============ S_IDLE (см. 4.7) ============
S_IDLE: begin
if (start_i) begin
addr_byte <= {slave_addr_i, 1'b0};
cnt <= byte_count_i;
nack_flag <= 1'b0;
error_o <= 1'b0;
state <= S_START_CMD;
end
end
// ============ START (см. 4.8) ============
S_START_CMD: begin
cmd_o <= CMD_START;
if (ready_i) cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_START_WAIT;
end
end
S_START_WAIT: begin
if (ready_i) state <= S_ADDR_CMD;
end
// ============ WRITE(addr) (см. 4.9) ============
S_ADDR_CMD: begin
cmd_o <= CMD_WRITE;
din_o <= addr_byte;
if (ready_i) cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_ADDR_WAIT;
end
end
S_ADDR_WAIT: begin
if (ready_i) begin
if (rx_ack_i) begin
nack_flag <= 1'b1;
state <= S_STOP_CMD; // NACK на адресе → STOP с ошибкой
end else if (cnt == {CNT_W{1'b0}})
state <= S_STOP_CMD; // probe (byte_count=0) → STOP без ошибки
else
state <= S_DATA_REQ; // норма → к data-потоку
end
end
// ============ Data-loop (см. 4.10) ============
S_DATA_REQ: begin
if (data_valid_i) begin
din_o <= data_i; // защёлкиваем байт от источника
state <= S_DATA_CMD;
end
end
S_DATA_CMD: begin
cmd_o <= CMD_WRITE;
if (ready_i) cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_DATA_WAIT;
end
end
S_DATA_WAIT: begin
if (ready_i) begin
cnt <= cnt - {{(CNT_W-1){1'b0}}, 1'b1}; // cnt--
if (rx_ack_i) begin
nack_flag <= 1'b1;
state <= S_STOP_CMD; // NACK → STOP с ошибкой
end else if (cnt == {{(CNT_W-1){1'b0}}, 1'b1})
state <= S_STOP_CMD; // последний байт — к STOP
else
state <= S_DATA_REQ; // ещё байт
end
end
// ============ STOP (см. 4.11) ============
S_STOP_CMD: begin
cmd_o <= CMD_STOP;
if (ready_i) cmd_valid_o <= 1'b1;
if (cmd_valid_o && !ready_i) begin
cmd_valid_o <= 1'b0;
state <= S_STOP_WAIT;
end
end
S_STOP_WAIT: begin
if (ready_i) state <= S_DONE;
end
// ============ DONE (см. 4.12) ============
S_DONE: begin
done_o <= 1'b1; // 1-такт импульс
error_o <= nack_flag; // level-флаг до следующего start
state <= S_IDLE;
end
default: state <= S_IDLE; // safety recovery
endcase
end // arb_lost guard
end
end
endmoduleПолная диаграмма состояний
Пусть тут будет сгенерированная диаграмма состояний со всем набором пояснений.

Архитектура механизма подготовки изображения без использования процессора
Данная часть статьи - является в некотором смысле переломной. До этого момента речь шла исключительно о транспортном уровне: как передать 1024 байта по I2C. Теперь мы задаём ортогональный вопрос - откуда взять эти 1024 байта, чтобы они складывались в осмысленную картинку (статический текст + вращающийся 3D-куб), и при этом не использовать процессор.
Это уже принципиально другой класс задач. На процессоре (Nios II / ARM / x86) подобный рендер пишется в 150 строк C-кода за 15 минут - и все проблемы (деление, умножение, косинусы, ветвления, работа со стеком) остаются заботой компилятора и ядра. В RTL же мы вручную раскладываем весь алгоритм на комбинационные и последовательностные схемы, регистры, BRAM, lookup-таблицы. Но взамен получаем детерминированную длительность кадра, нулевую зависимость от компиляторов и инструментария - проект можно скомпилировать в любой версии Quartus с 2010 года и результат будет бит-в-бит одинаковым.
Это одно из ключевых архитектурных ограничений проекта, сформулированное в постановке задачи. Конкретно нельзя использовать:
soft-core CPU в виде HDL-ядра - ни Nios II/e (Altera), ни PicoRV32, ни SERV, ни Cortex-M0 DesignStart;
HLS-инструменты - Intel HLS, Vivado HLS, Bluespec и т. п. (они, строго говоря, и не являются “процессорами”, но скрывают процессорное мышление за высокоуровневым C-кодом);
LUT-based softcore-симуляторы - “миниатюрные” CPU-подобные конструкции на ~500 LE, которые встречают иногда в учебных проектах.
Это ограничение имеет и практический смысл, и образовательный:
Практика. На платах класса AX301 (EP4CE10F17, ~10 кLE, без жёсткого CPU и без ARM-стека) soft-core съест 600-1000 LE и даст не более 25 MIPS. Это мало. Рендер 3D-куба на 25 MIPS без DSP и без FP - ~100 мс/кадр, что ставит крест на 10+ fps. Прямой аппаратный рендер на том же кристалле занимает ~40 мкс - в 2500 раз быстрее.
Образование. Без CPU заставляет разработчика вручную разложить задачу на микро-пайплайн из сложения, умножения, сдвига, case-lookup, BRAM-доступа и FSM - и увидеть, как именно работают эти примитивы. Это основа FPGA-культуры.
Что разрешено и из чего мы будем собирать рендер:
Примитив | Что даёт | Наш способ применения |
|---|---|---|
Целочисленная арифметика ( | синтезируется в adders/subtractors/DSP-multipliers | вершины куба, Брезенхэм, проекция |
Сдвиги ( | бесплатная комбинаторика (reroute) | /8, *128 в адресной арифметике |
Битовые маски ( | LUT-логика | RMW-byte в фреймбуфере |
| ROM | sin/cos LUT, font ROM, vertex ROM |
| инлайн ROM / логика | быстрые вычисления без состояний |
FSM ( | явный конечный автомат | оркестровка пайплайна рендера |
BRAM ( | встроенная dual-port память | фреймбуфер 1 КиБ |
Что не разрешено и требует обходных решений:
Отсутствует | Обходной путь |
|---|---|
Целочисленное деление | замена на сдвиг (если /2^k) или на Брезенхэма-подобные алгоритмы |
Плавающая точка | фикс-пойнт: у нас всё в |
| 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 нужны в разных фазах:
Фаза | Действие | Порт | Частота доступа |
|---|---|---|---|
| пишет 1024 раза | write | каждый такт |
| не трогает FB (вычисляет вершины в регистрах) | — | — |
| для каждого пикселя: read (старый байт) → modify → write | read+write | ~1 RMW / 3 такта |
| пишет 8-битный столбец буквы (без RMW, т. к. page-aligned) | write | каждый такт |
I2C-передача (параллельно) | читает 1024 раза последовательно | read | раз в ~45 мкс |
Выводы:
Write-порт — один, общий для всех фаз рендера.
Read-порты — нужно два независимых:
один для RMW-чтения (
fb_rdata_rmw) во время рендера,второй для I2C-стрима (
rdata_o) со стороныssd1306_ctrl.
Это ровно то, что позволяет M9K в режиме Simple-Dual-Port с двумя read-адресами.
Практический код и инфер
В Cyclone IV Quartus infers BRAM из следующего шаблона:
reg [7:0] fb [0:1023];
reg [9:0] fb_waddr;
reg [7:0] fb_wdata;
reg fb_we;
reg [9:0] fb_raddr_rmw;
reg [7:0] fb_rdata_rmw;
always @(posedge clk_i) begin
if (fb_we)
fb[fb_waddr] <= fb_wdata; // write port
rdata_o <= fb[raddr_i]; // read port 1 (I2C)
fb_rdata_rmw <= fb[fb_raddr_rmw]; // read port 2 (RMW)
endСинтезатор видит:
одну запись
if (fb_we) fb[…] <= fb_wdata;→ соответствует одному write-порту;два независимых
<= fb[addr]безif→ два read-порта.
Результат — один M9K (9 216 бит) с двумя read-портами и одним write-портом. Latency read’а — 1 такт: данные по raddr_i появляются в rdata_o через такт. Это ключевой параметр, с которым нужно считаться в FSM рендера.
Параллельность рендера и I2C
Два read-порта — это и есть архитектурная основа для параллельной работы рендера и I2C-передачи:

В теории можно было бы делать рендер во время передачи предыдущего кадра (double-buffering). Мы этого не делаем — наш FSM ssd1306_ctrl сериализует render→I2C→render→I2C (см. следующий раздел 5.4.5). Причина проста: при одном буфере и коротком рендере (~0.08% времени кадра) усложнение схемы двойной буферизации не окупается. Но сам dual-port M9K всё равно обязателен — без него read-порт I2C мешал бы write-порту рендера даже при сериализации.
Latency чтения - 1 такт
Важная особенность always @(posedge clk) синхронного BRAM: между подачей raddr и появлением данных на rdata проходит ровно один такт:
такт 1 2 3 4
raddr : X FB_ADDR Y Y
rdata : ? ? fb[FB_ADDR] fb[Y]
↑ данные готовыЭто значит, что FSM RMW-операции должен быть спроектирован с учётом этой задержки. В scene_renderer.v это реализовано как трёхстадийный pipe:
S_EDGE_RD— выставитьfb_raddr_rmw;(следующий такт) — ждать, данные появляются в
fb_rdata_rmw;S_EDGE_WR— прочитатьfb_rdata_rmw, вычислить модификацию, выставитьfb_waddr/fb_wdata/fb_we.
Для стороны I2C эта задержка вообще незаметна: между двумя подряд идущими запросами data_req_o проходит ~45 мкс (2 250 тактов), а read-latency = 1 такт, соотношение 1:2250 — она полностью «растворяется» в интервале.
Порядок стадий рендера кадра
Теперь, когда архитектура (BRAM-фреймбуфер + dual-port) выбрана, нужно определить порядок фаз рендера. Не любой порядок допустим: некоторые фазы записывают в FB поверх результатов предыдущих, и неправильная последовательность даст не ту картинку.
Рассмотрим композицию сцены - что на что ложится. Сцена состоит из трёх слоёв:

Слои разнесены по страницам, т. е. не пересекаются по памяти GDDRAM. Это — не случайно, а сознательный выбор: тексты живут в pages 0 и 7, куб — в pages 2..5. Благодаря такому расположению:
нет конфликтов RMW: запись текста не перекрывает байты куба и наоборот;
текст не требует RMW: каждая буква высотой 8 пикселей и выровнена ровно по странице, поэтому один write-запрос перезаписывает весь столбец буквы целиком (
0x00 → 0x___). Быстрее и проще чем RMW рёбер куба;чистая последовательность фаз: сначала стираем весь FB, потом рисуем каждый слой в любом порядке — результат одинаков.
Диаграмма фаз

Итого: ~1900 тактов на 50 МГц ≈ 38 мкс.
Тайминг-бюджет кадра
Фаза | Такты | мкс @50 МГц | Что делает |
|---|---|---|---|
S_CLEAR | 1024 | 20.5 | заливка нулями (1 write / такт) |
S_ROT_SETUP | 1 | 0.02 | sin/cos из LUT |
S_ROT_* (8 вершин) | 32 | 0.64 | поворот + проекция |
S_EDGE_* (12 рёбер) | ~720 | 14.4 | Брезенхэм 12 линий со RMW |
S_TEXT_* (2 строки) | ~110 | 2.2 | blit букв без RMW |
S_DONE | 1 | 0.02 | поднять ready_o |
Всего | ~1900 | ~38 мкс |
Сравним с бюджетом передачи одного I2C-frame’а: ~46 мс = 46 000 мкс. Рендер занимает 0.08% времени кадра. Значит, даже при сериализации render→I2C мы теряем менее 0.1% производительности на рендер. Упрощая архитектуру до одного буфера и сериализованного pipeline, мы платим практически ничего.
Детерминизм длительности
Большинство фаз имеют строго фиксированную длительность: S_CLEAR = 1024, S_ROT_* = 32, S_DONE = 1. Только две фазы условно-зависят от содержимого сцены:
S_EDGE_*: длительность зависит от проекций рёбер. Чем более «по диагонали» спроецировано ребро, тем больше пикселей Брезенхэм отрисует. Максимальная длина ребра на 128×64 — около 45 пикселей (диагональ всего экрана); минимальная — когда ребро сморщено в точку (≈ 1 пиксель). На типовой конфигурации (куб заполняет ~40 пикселей по каждой оси) разброс 12 рёбер суммарно — от ~550 до ~750 тактов.S_TEXT_*: зависит от строки (ANIMкороче, чемSTATIC). Разница — ~20 тактов между двумя вариантами.
Итого, полный разброс времени рендера — ±200 тактов ≈ ±4 мкс. На фоне 46 мс передачи это пренебрежимо мало. Архитектурно это означает, что можно с уверенностью закладывать «46.04 ± 0.004 мс» на весь цикл кадра.
Почему сериализация, а не pipelining
На первый взгляд очевидный путь оптимизации — double buffering: пока кадр N передаётся по I2C, рендерим кадр N+1 в отдельный буфер. Это дало бы fps, ограниченный только передачей (~21 fps на 200 кГц).
Мы этого не делаем по следующим причинам:
Нет пользы. Даже без pipelining мы получаем ~21 fps (46 мс передачи + 38 мкс рендера). Для человеческого глаза разница между 21.0 fps и 21.1 fps отсутствует.
Pipelining стоит +1 M9K. Второй буфер 1 КиБ + логика переключения front/back потребует ~150 LE и 1 M9K. Не катастрофично, но и не оправдано при нулевом видимом эффекте.
Усложнение синхронизации. При pipelining нужно аккуратно отслеживать «какой буфер сейчас передаётся» и «в какой пишет рендерер», чтобы избежать race conditions. Это дополнительные состояния в FSM
ssd1306_ctrl.Меньше ошибок. Сериализованный pipeline проще отлаживать: в любой момент точно один кадр либо рендерится, либо передаётся — не оба сразу.
Итоговый выбор — строго последовательный pipeline:

Это ровно то, что реализовано в ssd1306_ctrl.v через фазы PH_RENDER → PH_RENDW → PH_FRAME → PH_FRAMEW → PH_ANEXT → PH_RENDER → … (подробно в разделе 7).
Разрешимые и неразрешимые нагрузки
Полезно явно зафиксировать, что эта архитектура может и что не может в пределах одного кадра.
Нагрузка | Оценка тактов | Решаемо? |
|---|---|---|
Заливка всего экрана цветом | 1024 | ✓ тривиально |
Несколько десятков линий | ~1500 | ✓ |
Текст в 1–2 строки (до 20 символов) | ~300 | ✓ |
Закрашенный прямоугольник | 128 × h | ✓ |
Закрашенный круг (scanline-fill) | ~500 | ✓ возможно, но не реализовано |
3D-куб Y-rotation | ~750 | ✓ реализовано |
3D-куб с удалением невидимых граней (backface cull) | +~100 | ✓ возможно |
Bitmap-иконка 32×32 | ~64 байта × 4 такта = 256 | ✓ |
Скролл-анимация текста (shift FB на 1 пиксель) | ~1024 × 4 | ⚠ на границе (4096 тактов ≈ 80 мкс) |
Поворот 3D-объекта по 3 осям (не только Y) | ~2000 | ✓ |
Текстурированный куб (texture mapping) | ~10000+ | ✗ слишком медленно |
Растеризация закрашенных полигонов Z-buffer’ом | ~50000+ | ✗ не помещается в такте кадра |
Иначе говоря: векторная графика и статические bitmap’ы — да; сложная растровая графика с Z-буфером — нет (для этого нужен либо больший FPGA, либо GPU-ядро). Для наших задач (текст + куб) запас есть огромный.
Итого
«Без CPU» — архитектурный принцип, а не ограничение. Он заставляет нас разложить задачу на аппаратные примитивы и даёт взамен детерминизм + скорость.
BRAM-фреймбуфер — единственный разумный способ для композиции сложных сцен; процедурный подход не масштабируется до 12 линий + текста.
Dual-port M9K позволяет I2C-стриму и рендеру работать параллельно на уровне BRAM, даже при логической сериализации их FSM.
Sequential pipeline render→TX даёт ~21 fps при минимальной сложности. Double buffering не нужен.
Тайминг-бюджет 1900 тактов рендера на 2.3M тактов передачи — огромный запас, позволяющий свободно добавлять сцены (ещё текст, ещё объекты, flood-fill, и т. п.), пока не превысим ~1M тактов (что маловероятно).
Дальше — часть 6, где мы построчно разберём реализацию scene_renderer.v с таблицами sin/cos, vertex ROM, font ROM, Брезенхэмом и текст-blit’ом.
Модуль scene_renderer - фрейм-рендер в BRAM
Часть 5 обосновала, почему рендер сделан именно как фреймбуфер-в-BRAM с sequential pipeline. В этой части разберём что именно написано в файле quartus_ssd1306/src/scene_renderer.v построчно — с фрагментами кода и пояснением каждого решения.
Модуль выглядит внушительно (~640 строк Verilog) за счёт больших case-таблиц ROM’ов (sin, font, vertex, edge), но логики в нём мало: ядро — это ~15 состояний FSM и ~30 регистров. Разложим его на логические блоки.
Роль модуля и интерфейс
scene_renderer подключается снаружи несколькими потоками сигналов:

Интерфейс модуля из scene_renderer.v, строки 21–32:
module scene_renderer (
input wire clk_i,
input wire rstn_i,
input wire start_i, // 1-такт импульс запуска рендера
input wire mode_i, // 0 = static (нижняя строка "STATIC")
// 1 = animation (нижняя строка "ANIM",
// и угол берётся из angle_i)
input wire [5:0] angle_i, // 0..63 ≡ 0..2π, угол поворота куба по Y
output reg ready_o, // 1 ↔ кадр готов, можно начинать I2C TX
input wire [9:0] raddr_i, // порт чтения из FB для I2C-стрима
output reg [7:0] rdata_o
);Назначение сигналов:
clk_i/rstn_i— стандартные clock/reset. Синхронно с ядром шины (50 МГц).start_i— импульс «начать рендер кадра». FSM изS_IDLEпереходит вS_CLEAR, параметрыmode_i/angle_iзащёлкиваются одновременно.mode_i— статический vs анимационный режим. Влияет на нижний текст (STATIC/ANIM). В static-режимеangle_iтакже учитывается — просто верхний уровень держит его постоянным.angle_i— 6-битный угол, 64 шага по окружности (один шаг ≈ 5.625°). Детализация умышленно грубая: рендер1 + 46 ≈ 46 мсна кадр, а 64 шага дадут ~2.8 s на полный оборот — вполне плавно воспринимается глазом.ready_o— level-флаг «кадр готов». Устанавливается в 1 вS_DONE, сбрасывается при следующемstart_i.raddr_i/rdata_o— независимый read-порт BRAM для I2C-передачи. Адрес — линейный 10-бит индекс0..1023(см. 2.3.3 про zero-copy mapping). Данные появляются с задержкой 1 такт (sync BRAM).
Центральная идея модуля: по команде start_i пересобрать весь кадр (128×64 пикселей = 1024 байта) в dual-port BRAM, потом держать его там, пока приходят raddr_i-запросы от верхнего уровня.
Параметры и именованные константы
Блок констант в начале модуля (строки 37–46):
localparam signed [7:0] S = 8'sd12;
localparam [3:0] NUM_EDGES = 4'd12;
localparam [6:0] TOP_COL0 = 7'd43;
localparam [6:0] BOT_STA_COL0 = 7'd46;
localparam [6:0] BOT_ANI_COL0 = 7'd52;
localparam [2:0] TOP_PAGE = 3'd0;
localparam [2:0] BOT_PAGE = 3'd7;
localparam [2:0] TOP_LEN_M1 = 3'd6; // 7 chars − 1
localparam [2:0] STA_LEN_M1 = 3'd5; // 6 chars − 1
localparam [2:0] ANI_LEN_M1 = 3'd3; // 4 chars − 1Константа | Значение | Смысл |
|---|---|---|
| signed 12 | полуребро куба в координатах модели; т. е. куб 24×24×24 в model-space |
| 12 | число рёбер куба (4 сверху + 4 снизу + 4 вертикальных) |
| 0 | страница верхнего текста (самая верхняя полоска экрана) |
| 7 | страница нижнего текста (самая нижняя полоска) |
| 43 | стартовый столбец |
| 46 | стартовый столбец |
| 52 | стартовый столбец |
| 6 | длина |
| 5 | длина |
| 3 | длина |
Все эти числа известны на этапе компиляции. Менять текст или размер куба можно простой правкой localparam — FSM ничего не пересчитает «в runtime», но пересинтез займёт обычные ~10 секунд.
Фреймбуфер — dual-port BRAM (строки 48–65)
reg [7:0] fb [0:1023];
reg [9:0] fb_waddr;
reg [7:0] fb_wdata;
reg fb_we;
reg [9:0] fb_raddr_rmw;
reg [7:0] fb_rdata_rmw;
always @(posedge clk_i) begin
if (fb_we)
fb[fb_waddr] <= fb_wdata;
rdata_o <= fb[raddr_i];
fb_rdata_rmw <= fb[fb_raddr_rmw];
endЭто реализация BRAM’а, подробно разобранная в 5.3:
fb— массив 1024×8 бит. Quartus infers его как один M9K-блок (9 216 бит; мы используем 8 192).Один write-порт:
fb_we/fb_waddr/fb_wdata— управляется FSM рендера.Два read-порта:
rdata_o←fb[raddr_i]— для внешнего чтения (I2C TX).fb_rdata_rmw←fb[fb_raddr_rmw]— для внутреннего RMW-чтения (см. стадиюS_EDGE_*).
Read-latency у обоих read-портов — 1 такт: на такте
Nвыставлен адрес, на тактеN+1данные — в регистре.
Вся логика записи и чтения умещается в один always с обычным positive-edge триггером, без initial-инициализации (содержимое при загрузке FPGA undefined и обнуляется фазой S_CLEAR).
sin/cos LUT - quarter-wave symmetry (строки 68-107)
Для поворота куба нужны значения sin и cos. Мы не можем позволить себе CORDIC или ряд Тейлора в железе — сложно и долго. Поэтому используем lookup-таблицу 17 × 9-бит signed:
function signed [8:0] sin_q;
input [4:0] q; // q ∈ 0..16
case (q)
5'd0 : sin_q = 9'sd0; 5'd1 : sin_q = 9'sd12;
5'd2 : sin_q = 9'sd25; 5'd3 : sin_q = 9'sd37;
5'd4 : sin_q = 9'sd49; 5'd5 : sin_q = 9'sd60;
5'd6 : sin_q = 9'sd71; 5'd7 : sin_q = 9'sd81;
5'd8 : sin_q = 9'sd90; 5'd9 : sin_q = 9'sd98;
5'd10: sin_q = 9'sd106; 5'd11: sin_q = 9'sd112;
5'd12: sin_q = 9'sd117; 5'd13: sin_q = 9'sd122;
5'd14: sin_q = 9'sd125; 5'd15: sin_q = 9'sd126;
5'd16: sin_q = 9'sd127; default: sin_q = 9'sd0;
endcase
endfunctionsin_q(q) = round(127 · sin(q · π/32)), для четверти окружности (0 → π/2 в 17 шагов). Q0.7 fixed-point — максимум ±127 помещается в знаковом 8-битном числе (но для удобства арифметики берём 9-битное signed, чтобы корректно работало -sin_q(qi) у границы −128..127).
Зачем 17 значений, а не все 64? — quarter-wave symmetry. sin имеет две симметрии:
Относительно π/2:
sin(π/2 + x) = sin(π/2 − x)(в рамках одной четверти это «зеркалит» вторую половину первой четверти к первой).Относительно π:
sin(π + x) = −sin(x)(третья и четвёртая четверти — просто минус первой и второй).
Используя эти свойства, из 17 значений можно восстановить всю 64-точечную таблицу. Экономия: ~74% на размере ROM. В терминах Cyclone IV это 17 case-веток LUT против 64 — разница в ~50 LE.
sin6 — обёртка, которая по полному углу 0..63 достаёт нужное значение с правильным знаком:
function signed [8:0] sin6;
input [5:0] theta;
reg [4:0] qi;
reg signed [8:0] mag;
begin
qi = theta[4] ? (5'd16 - {1'b0, theta[3:0]})
: {1'b0, theta[3:0]};
mag = sin_q(qi);
sin6 = theta[5] ? -mag : mag;
end
endfunctionРазбор:
theta[5]— старший бит: он переключает знак (третья/четвёртая четверти →−mag).theta[4]— средний бит: мы в первой или второй «половине» полупериода.theta[4]=0— прямой обход (0..15 → qi=0..15);theta[4]=1— зеркальный (16..31 → qi=16..1).theta[3:0]— младшие 4 бита: индекс внутри четверти 0..15.qi— нормализованный индекс дляsin_q, значение 0..16.
cos6 — тривиально через сдвиг фазы на π/2 (= 16 шагов):
function signed [8:0] cos6;
input [5:0] theta;
cos6 = sin6(theta + 6'd16); // cos(θ) = sin(θ + π/2)
endfunctionВся триада sin_q → sin6 → cos6 — это чистая комбинаторика (synthesizable functions), синтезатор раскрутит их в LUT-дерево глубиной 2–3 уровня.
Геометрия куба - vertex ROM и edge ROM (строки 110-192)
Куб центрирован в (0, 0, 0) с полуребром S = 12. Координаты каждой из 8 вершин хранятся в трёх ROM’ах vx_rom/vy_rom/vz_rom:
function signed [7:0] vx_rom;
input [2:0] i;
case (i)
3'd0: vx_rom = -S; 3'd1: vx_rom = S;
3'd2: vx_rom = S; 3'd3: vx_rom = -S;
3'd4: vx_rom = -S; 3'd5: vx_rom = S;
3'd6: vx_rom = S; 3'd7: vx_rom = -S;
endcase
endfunction
// vy_rom, vz_rom аналогичноСуммарно получается таблица:
i | vx | vy | vz | Вершина (в model-space) |
|---|---|---|---|---|
0 | −S | −S | −S | верх-зад-лево |
1 | +S | −S | −S | верх-зад-право |
2 | +S | −S | +S | верх-перед-право |
3 | −S | −S | +S | верх-перед-лево |
4 | −S | +S | −S | низ-зад-лево |
5 | +S | +S | −S | низ-зад-право |
6 | +S | +S | +S | низ-перед-право |
7 | −S | +S | +S | низ-перед-лево |
Соглашение осей: +X — вправо, +Y — вниз (экранное), +Z — на зрителя. Верхняя грань куба — вершины 0..3, нижняя — 4..7; парами «над-под» идут (0,4), (1,5), (2,6), (3,7).
edge_v0(e)/edge_v1(e) — таблицы, дающие по номеру ребра e ∈ 0..11 два номера вершин v0/v1, которые оно соединяет:
function [2:0] edge_v0;
input [3:0] e;
case (e)
4'd0: edge_v0 = 3'd0; 4'd1: edge_v0 = 3'd1;
... // всего 12 записей
endcase
endfunction
// edge_v1 аналогично, с парными номерамиСтруктура 12 рёбер:
e | v0 → v1 | Тип ребра |
|---|---|---|
0 | 0 → 1 | верхнее заднее |
1 | 1 → 2 | верхнее правое |
2 | 2 → 3 | верхнее переднее |
3 | 3 → 0 | верхнее левое |
4 | 4 → 5 | нижнее заднее |
5 | 5 → 6 | нижнее правое |
6 | 6 → 7 | нижнее переднее |
7 | 7 → 4 | нижнее левое |
8 | 0 → 4 | вертикальное зад-лево |
9 | 1 → 5 | вертикальное зад-право |
10 | 2 → 6 | вертикальное перед-право |
11 | 3 → 7 | вертикальное перед-лево |
4 верхних + 4 нижних + 4 вертикальных = 12, как и положено у куба.
Шрифт 5х7 и лексиконы (строки 194-320)
16 глифов, адресуемых по 4-битному char-номеру:
0:'S'
1:'D'
2:'1'
3:'3'
4:'0'
5:'6'
6:' '
7:'C'
8:'U'
9:'B'
10:'E'
11:'A'
12:'I'
13:'T'
14:'N' 15:'M'Каждый глиф — 5 столбцов по 7 бит (биты 0..6; бит 7 = 0, для падинга). Адрес внутри ROM — 7-битный {char[3:0], col[2:0]}, т. е. каждому символу отведено 8 столбцов (5 значащих + 3 пустых). Это делается для удобства адресной арифметики: адрес кратен 8, сдвиг на 3 бита = умножение.
function [7:0] font_byte;
input [6:0] addr;
case (addr)
// 'S' — код символа 0
7'd0: font_byte = 8'h26; 7'd1: font_byte = 8'h49;
7'd2: font_byte = 8'h49; 7'd3: font_byte = 8'h49;
7'd4: font_byte = 8'h32;
// 'D' — код символа 1
7'd8: font_byte = 8'h7F; 7'd9: font_byte = 8'h41;
7'd10: font_byte = 8'h41; 7'd11: font_byte = 8'h41;
7'd12: font_byte = 8'h3E;
...
endcase
endfunctionФормат одного столбца: little-endian по вертикали — биты 0..6 соответствуют строкам сверху вниз (бит 0 — верхний пиксель). Это совпадает с раскладкой GDDRAM (см. 2.3.2), поэтому каждый байт glyph’а копируется в FB без битового reverse.
Визуализация буквы 'S' (5 столбцов × 7 строк):

Аналогично и для остальных 15 символов.
Три функции-ROM отображают позицию в строке на код символа:
function [3:0] top_char; // "SSD1306"
input [2:0] i;
case (i)
3'd0: top_char = 4'd0; // S
3'd1: top_char = 4'd0; // S
3'd2: top_char = 4'd1; // D
3'd3: top_char = 4'd2; // 1
3'd4: top_char = 4'd3; // 3
3'd5: top_char = 4'd4; // 0
3'd6: top_char = 4'd5; // 6
endcase
endfunction
function [3:0] bot_sta_char; // "STATIC"
case (i)
3'd0: bot_sta_char = 4'd0; // S
3'd1: bot_sta_char = 4'd13; // T
3'd2: bot_sta_char = 4'd11; // A
3'd3: bot_sta_char = 4'd13; // T
3'd4: bot_sta_char = 4'd12; // I
3'd5: bot_sta_char = 4'd7; // C
endcase
endfunction
function [3:0] bot_ani_char; // "ANIM"
case (i)
3'd0: bot_ani_char = 4'd11; // A
3'd1: bot_ani_char = 4'd14; // N
3'd2: bot_ani_char = 4'd12; // I
3'd3: bot_ani_char = 4'd15; // M
endcase
endfunctionВсе — чистая комбинаторика, ~10 LE каждая.
Регистры состояния FSM (строки 322-374)
reg signed [8:0] vtx_px [0:7]; // 8 вершин после поворота — X
reg signed [8:0] vtx_py [0:7]; // Y
reg [4:0] state; // 15 состояний FSM (5 бит с запасом)
reg mode_r; // защёлкнутый mode_i
reg [5:0] angle_r; // защёлкнутый angle_i
// S_CLEAR
reg [10:0] clr_idx; // 0..1023, потому 11 бит чтобы не обернуться
// S_ROT_*
reg signed [8:0] sin_th, cos_th; // sin/cos угла текущего кадра
reg [2:0] vtx_idx; // 0..7
reg signed [7:0] cur_vx, cur_vy, cur_vz;
reg signed [17:0] prod_xc, prod_zs, prod_xs, prod_zc; // 4 умножения
// S_EDGE_* (Брезенхэм)
reg [3:0] edge_idx; // 0..11
reg signed [8:0] bx, by; // текущий пиксель
reg signed [8:0] bx_end, by_end; // конечный пиксель ребра
reg signed [8:0] bdx; // +|dx|
reg signed [8:0] bdy; // −|dy|
reg signed [1:0] bsx, bsy; // ±1 для каждой оси
reg signed [10:0] berr; // аккумулятор Брезенхэма
reg [7:0] pix_mask; // 1 << by[2:0]
// S_TEXT_*
reg [2:0] txt_char_idx; // 0..7 (до TOP_LEN_M1)
reg [2:0] txt_col_idx; // 0..4 — текущий столбец буквы
reg txt_phase; // 0=top, 1=bottomИтого ~35 регистров + массивы vtx_px/vtx_py (8×9 бит каждый) — они либо лягут в M9K, либо в LE как registered 16×9 RAM. В нашем случае (размер 8 элементов) Quartus обычно использует LE — экономически выгоднее, чем занимать M9K под 144 бита.
Комбинационные хелперы (строки 376-436)
Эти wire-выражения — комбинационные «функции», которые каждое состояние FSM использует, но сами они не имеют регистров.
Commit поворота
wire signed [17:0] rot_xp_full = prod_xc - prod_zs; // vx·cos − vz·sin
wire signed [17:0] rot_zp_full = prod_xs + prod_zc; // vx·sin + vz·cos
wire signed [10:0] rot_xp = rot_xp_full[17:7]; // /128
wire signed [10:0] rot_zp = rot_zp_full[17:7]; // /128
wire signed [10:0] rot_px = rot_xp + 11'sd64; // +64: центр по X
wire signed [10:0] cur_vy_ext = {{3{cur_vy[7]}}, cur_vy};
wire signed [10:0] rot_py = cur_vy_ext + (rot_zp >>> 2) + 11'sd32;Ключевой момент — вся 2D-проекция сделана комбинационно. В S_ROT_STORE мы читаем rot_px/rot_py и защёлкиваем их в vtx_px[vtx_idx]/vtx_py[vtx_idx].
Формулы:
x' = (vx·cos − vz·sin) / 128 + 64— поворот вокруг Y + центровка (экран 128 пикселей по X, центр = 64).z' = (vx·sin + vz·cos) / 128— глубина после поворота.y' = vy + z' / 4 + 32— псевдо-3D проекция: вместо полного перспективного деления добавляем четвертьz'кy. Это даёт эффект «передний план опущен вниз, задний — вверх».+32— центр по Y (экран 64 пикселя, центр = 32).
Деление «/128» — арифметический сдвиг [17:7] на ширину. /4 — сдвиг >>> 2 (arithmetic right, сохранение знака для signed).
Инициализация Брезенхэма
wire [2:0] e0 = edge_v0(edge_idx);
wire [2:0] e1 = edge_v1(edge_idx);
wire signed [8:0] p0x = vtx_px[e0];
wire signed [8:0] p0y = vtx_py[e0];
wire signed [8:0] p1x = vtx_px[e1];
wire signed [8:0] p1y = vtx_py[e1];
wire signed [8:0] init_dx = (p1x >= p0x) ? (p1x - p0x) : (p0x - p1x);
wire signed [8:0] init_dy_n = (p1y >= p0y) ? (p0y - p1y) : (p1y - p0y); // ≤ 0
wire signed [1:0] init_sx = (p0x < p1x) ? 2'sd1 : -2'sd1;
wire signed [1:0] init_sy = (p0y < p1y) ? 2'sd1 : -2'sd1;
wire signed [10:0] init_err = {{2{init_dx[8]}}, init_dx}
+ {{2{init_dy_n[8]}}, init_dy_n};Всё это — инициализация алгоритма Брезенхэма комбинаторно из двух вершин ребра (e0, e1). В S_EDGE_INIT эти значения защёлкиваются в регистры bx/by/bx_end/by_end/bdx/bdy/ bsx/bsy/berr.
Обратите внимание на знаковый формат dy: init_dy_n специально сделан отрицательным (≤ 0), чтобы применить классическую формулу Брезенхэма с одним сумматором ошибки без ветвлений на знак (err += dx + dy работает и для роста X, и для убывания Y).
Шаг Брезенхэма
wire signed [10:0] bdx_ext = {{2{bdx[8]}}, bdx};
wire signed [10:0] bdy_ext = {{2{bdy[8]}}, bdy};
wire signed [10:0] b_e2 = berr <<< 1;
wire step_x = (b_e2 >= bdy_ext);
wire step_y = (b_e2 <= bdx_ext);
wire signed [8:0] bsx_ext = {{7{bsx[1]}}, bsx};
wire signed [8:0] bsy_ext = {{7{bsy[1]}}, bsy};
wire signed [10:0] berr_nx = berr + (step_x ? bdy_ext : 11'sd0)
+ (step_y ? bdx_ext : 11'sd0);Это стандартное условие Брезенхэма:
e2 = 2·err;если
e2 ≥ dy_n(т. е.errположителен) — сделать шаг по X;если
e2 ≤ dx— сделать шаг по Y;обновить
errдобавлениемdy_nи/илиdxв зависимости от того, какие шаги были сделаны.
Все *_ext-сигналы — это знаковое расширение узких полей до 11 бит, чтобы сравнения не переполнялись.
Адрес пикселя и маска
wire in_screen = (bx >= 9'sd0) && (bx <= 9'sd127)
&& (by >= 9'sd0) && (by <= 9'sd63);
wire [9:0] pix_addr = {by[5:3], bx[6:0]}; // page*128 + col
wire [7:0] new_mask = 8'd1 << by[2:0]; // бит внутри столбцаПреобразование (bx, by) в (fb_addr, bit_mask) (см. 2.3.3):
page = by[5:3]— деление y/8;col = bx[6:0]— просто x;fb_addr = {page, col}— конкатенация, эквивалентноpage*128+col;bit_mask = 1 << by[2:0]—y mod 8как позиция бита.
in_screen — боковая обрезка: если пиксель за пределами 128×64, мы не пишем, но алгоритм Брезенхэма всё равно продолжает шагать (см. S_EDGE_SETUP).
Адрес текущего текстового символа
wire [3:0] cur_char = (!txt_phase) ? top_char(txt_char_idx)
: (mode_r ? bot_ani_char(txt_char_idx)
: bot_sta_char(txt_char_idx));
wire [2:0] cur_page = (!txt_phase) ? TOP_PAGE : BOT_PAGE;
wire [6:0] cur_col0 = (!txt_phase) ? TOP_COL0
: (mode_r ? BOT_ANI_COL0 : BOT_STA_COL0);
wire [2:0] cur_last = (!txt_phase) ? TOP_LEN_M1
: (mode_r ? ANI_LEN_M1 : STA_LEN_M1);
wire [5:0] txt_char_mul6 = {txt_char_idx, 2'b00} + {1'b0, txt_char_idx, 1'b0};
wire [6:0] txt_base_col = cur_col0 + {1'b0, txt_char_mul6};
wire [6:0] txt_col = txt_base_col + {4'd0, txt_col_idx};
wire [9:0] txt_addr = {cur_page, txt_col};txt_char_mul6 — хитрое умножение char_idx * 6 без DSP: char_idx · 4 + char_idx · 2 = (char_idx<<2) + (char_idx<<1). Конкатенация {idx, 2'b00} — это idx*4, {idx, 1'b0} — idx*2. Так мы получаем char*6 одним сумматором на 6 бит.
FSM - общая структура (строка 442)
Весь рендер — один большой case (state) с 15 состояниями:

Общая обёртка имеет ещё один важный штрих в самом конце:
// pre-assign в начале else-ветки: fb_we по умолчанию = 0
fb_we <= 1'b0;
case (state)
...
endcase
// post-assign: сбрасывает ready при повторном старте
if (start_i && state != S_IDLE) ready_o <= 1'b0;fb_we <= 1'b0— pre-assign, аналогичный тому, что делаетdone_o <= 0вi2c_burst_writer: write-enable по умолчанию снят, и любое состояние, которое хочет записать, должно явно поставитьfb_we <= 1'b1. Это защищает от случайной «утечки» write’а в неожиданных состояниях.post-assign
if (start_i && state != IDLE)— нужен, чтобы при «повторном» запуске (например, если верхний уровень решил перезапустить рендер до того, как текущий закончился) флагready_oсбрасывался. В норме это не происходит.
Стадия S_IDLE - ожидание старта (строки 486-494)
S_IDLE: begin
if (start_i) begin
mode_r <= mode_i;
angle_r <= angle_i;
ready_o <= 1'b0;
clr_idx <= 11'd0;
state <= S_CLEAR;
end
endЗащёлкиваем mode_i/angle_i, сбрасываем ready_o и счётчик клирования, уходим в S_CLEAR. Важно, что mode_i/angle_i могут меняться у верхнего уровня между стартами (анимация инкрементит angle), но внутри одного кадра мы пользуемся защёлкнутыми значениями. Иначе рендер мог бы «поплыть» в середине.
Стадия S_CLEAR - заливка FB нулями (строки 497-506)
S_CLEAR: begin
fb_we <= 1'b1;
fb_waddr <= clr_idx[9:0];
fb_wdata <= 8'h00;
if (clr_idx == 11'd1023) begin
clr_idx <= 11'd0;
state <= S_ROT_SETUP;
end else
clr_idx <= clr_idx + 11'd1;
endСчётчик 0..1023, на каждом такте один write 0x00. Ровно 1024 такта = 20.5 мкс на 50 МГц. Почему не ускорить? Можно было бы сконфигурировать M9K как 256×32 (4 байта за такт) — получилось бы 256 тактов. Но:
это изменит layout BRAM’а и усложнит read-порты;
20.5 мкс на clear при 46 мс кадра — 0.04%, экономить не на чем.
11-битный clr_idx нужен, чтобы корректно обернуться: `11'd1023
1 = 11'd1024
, а если использовать 10-битный счётчик,10'd10231 = 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;
end1 такт. Защёлкиваем 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;
endRead-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 |
|
M9K blocks | 1 | FB 1024×8 |
9×9 multipliers | 4 | один такт в |
Fmax | >80 МГц | комб. пути Брезенхэма и rot-commit — < 6 нс |
Производительность рендера кадра:
Фаза | Тактов | @50 МГц |
|---|---|---|
| 1024 | 20.5 мкс |
| 25 | 0.5 мкс |
| ~900 | 18 мкс |
| ~120 | 2.4 мкс |
| 1 | 0.02 мкс |
Всего | ~2070 | ~41 мкс |
На фоне 46 мс I2C-передачи — ~0.09% времени кадра. Рендер не является узким местом; узкое место — физическая частота SCL = 200 кГц. Хочется выше fps — либо ускоряем I2C (до 400 кГц Fast-mode), либо переходим на SPI.
Что можно (и нельзя) добавить в рендер
Архитектура оставляет большой простор для расширений:
Легко добавить:
ещё одну строку текста (больше
txt_phase→ 2 бита, новый лексикон);2D-примитивы: прямоугольник (fill page-aligned rows), круг (Bresenham variant);
простую анимацию иконок (bitmap в ROM, blit как текст);
поворот куба по двум осям (+
sin_phi/cos_phi, +2 умножения).
Сложнее, но можно:
фиксированные перспективные проекции (деление замещается умножением на 1/z из LUT);
несколько объектов (нужен список объектов в ROM);
«flood fill» замкнутой области (сканлайн-fill по страницам).
Не влезет без radical redesign:
полноценный 3D-рендер с Z-buffer’ом (нужно + 1 M9K под Z-буфер,
умножения для интерполяции);
сглаживание (anti-aliasing) — требует 2-битного или даже 4-битного канала прозрачности, что несовместимо с 1bpp OLED;
текстурированные полигоны — смотри полноценный GPU.
Для нашей образовательной задачи (текст + куб) архитектура избыточна, и это хорошо: есть куда расти без переделки основы.
Модуль ssd1306_ctrl — оркестратор
Модуль quartus_ssd1306/src/ssd1306_ctrl.v — верхнеуровневый оркестратор всей системы SSD1306-рендера. Если i2c_burst_writer знает только «как слать WRITE-burst по I2C», а scene_renderer — «как нарисовать кадр в BRAM», то ssd1306_ctrl знает про SSD1306: в каком порядке делать init, render, frame TX, как обрабатывать ошибки, как реагировать на кнопки и как включать/выключать анимацию.
Это самый «богатый по логике» модуль проекта (~380 строк), но структурно простой: одно большое FSM по фазам PH_* плюс три вспомогательные структуры (init ROM, data-source mux, src_idx counter). Ниже — разбор по блокам.

ssd1306_ctrl — единственный модуль, знающий:
что у SSD1306 I2C-адрес 0x3C, что control-byte для frame —
0x40, что init-последовательность — 32 байта;что перед первой командой после POR нужно ждать 100 мс;
что анимация идёт через инкремент
angleмежду кадрами;что при NACK нужно сбросить
initedи при повторном нажатии пройти полный init-цикл заново.
Остальные модули (i2c_master_core, i2c_burst_writer, scene_renderer) — общие, без знаний о SSD1306, и могут быть переиспользованы для других устройств.
module ssd1306_ctrl #(
parameter CLK_FREQ = 50_000_000,
parameter I2C_ADDR = 7'h3C,
parameter DELAY_MS = 100
)(
input wire clk_i,
input wire rstn_i,
input wire start_i, // KEY2 — статический кадр
input wire anim_i, // KEY3 — анимация / стоп
output wire busy_o,
output reg done_o,
output reg err_o,
output reg [10:0] progress_o, // текущий src_idx (для 7-seg)
output wire animating_o,
// i2c_master_core command interface — прокси через burst_writer
output wire cmd_valid_o,
output wire [2:0] cmd_o,
output wire [7:0] din_o,
input wire ready_i,
input wire rx_ack_i,
input wire arb_lost_i,
output wire arb_lost_clear_o
);Три параметра:
Параметр | По умолчанию | Смысл |
|---|---|---|
| 50 000 000 | частота |
|
| 7-битный I2C-адрес SSD1306 |
| 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 бита):
Фаза | Что делает |
|---|---|
| покой, ждём нажатия KEY2 / KEY3 |
| 100 мс POR-задержка, один раз после сброса |
| асинхронный pulse |
| ждём |
| pulse |
| ждём |
| pulse |
| ждём |
| инкремент |
| успешное завершение; принимает новые команды |
| ошибка; принимает повторный запуск с |
Регистры состояния:
Регистр | Ширина | Назначение |
|---|---|---|
| 4 бит | текущее состояние FSM |
| 23 бит | счётчик POR-задержки ( |
| 11 бит | индекс текущего байта в транзакции; 0..32 на init, 0..1024 на frame |
| 1 бит | флаг «чип инициализирован» — живёт между транзакциями |
| 1 бит | передаётся в |
| 1 бит | идёт ли сейчас анимационный цикл |
| 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-byte0x40, а не первому пикселю. Его читать не из 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:
Фаза |
|
|
|---|---|---|
| 0 |
|
| 1 |
|
| 2..31 |
|
| 0 |
|
| 1 |
|
| 2..1024 |
|
src_idx — счётчик позиции в транзакции (строки 180–187)
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i)
src_idx <= 11'd0;
else if (phase == PH_INIT || phase == PH_FRAME)
src_idx <= 11'd0; // новый burst → начать с 0
else if (bw_data_req)
src_idx <= src_idx + 11'd1; // инкремент на каждом запросе
endЭто отдельный синхронный блок, работающий параллельно с основным FSM. Правила:
При сбросе —
src_idx = 0.При входе в
PH_INIT/PH_FRAME—src_idx = 0(начало новой транзакции). Заметьте, что это однократный тик: эти фазы сразу же переходят вPH_INITW/PH_FRAMEWна следующем такте.Во всех остальных фазах — инкремент на каждый
bw_data_req = 1(запрос следующего байта от burst-writer’а).
Важно: src_idx сбрасывается в PH_INIT/PH_FRAME, а не в PH_IDLE. Это потому, что в PH_IDLE мы ещё ничего не запрашиваем — состояние счётчика неважно. Зато между фазами PH_INITW и PH_FRAMEW счётчик может сохранять старое значение — но оно всё равно будет сброшено в начале каждой новой транзакции.
Сброс происходит одновременно с ассертом bw_start, а burst-writer тратит несколько тактов в S_START_CMD → S_ADDR_CMD перед первым data_req_o, поэтому к моменту первого запроса src_idx уже стабильно = 0.
Почему связка «BRAM 1-такт + burst I2C» работает
Ключевой вопрос: BRAM даёт задержку 1 такт между подачей адреса и выходом данных, а burst-writer ожидает данные в тот же такт, когда поднимает bw_data_req. Как это согласуется?
Ответ — огромный интервал между запросами. Между двумя последовательными data_req_o burst-writer проводит:
~9 I2C-бит × 4 ena-тика × 125 такт/ena ≈ 4500 тактов на передачу одного байта по I2C;
1–2 такта на CMD/WAIT-переходы;
задержка BRAM = 1 такт.
Ровно 1 такт задержки BRAM полностью растворяется в 4500 тактах между запросами. Логика работает так:
такт N: src_idx = K; scene_raddr = K-1;
такт N+1: (BRAM обновился) scene_rdata = fb[K-1];
такт N+2: bw_data_req pulse → byte latched as data_i = fb[K-1] ✓
... 4500 тактов I2C ...
такт N+4502: src_idx = K+1; scene_raddr = K;
такт N+4503: scene_rdata = fb[K];
такт N+4504: next data_req → byte fb[K] ✓В момент, когда burst-writer реально нуждается в новом байте, BRAM уже давно (на 4500 тактов раньше) обновил scene_rdata под актуальный адрес. Поэтому data_valid_i = bw_data_req работает без проблем — это комбинаторное равенство (строка 372).
Единственный крайний случай — первый запрос при src_idx = 0: нужен 0x40, а не содержимое BRAM. Обрабатывается комб-муксом data_byte (см. 7.6). К моменту второго запроса (src_idx = 1) BRAM уже подал fb[0] на scene_rdata.
Комбинационные выходы busy_o, animating_o, arb_lost_clear_o (строки 171–177)
assign busy_o = (phase != PH_IDLE && phase != PH_OK && phase != PH_ERR);
assign animating_o = anim_run;
wire trigger = start_i || anim_i;
assign arb_lost_clear_o = trigger && (phase == PH_IDLE ||
phase == PH_OK ||
phase == PH_ERR);busy_o — модуль занят транзакцией (init / render / frame). На 7-segment / LED top-level-а индицирует «идёт работа». В фазах покоя (IDLE, OK, ERR) ложен.
animating_o — дублирует anim_run. Зажигает отдельный LED «идёт анимация», чтобы пользователь видел разницу между статикой и анимацией.
arb_lost_clear_o — сигнал для i2c_master_core, очищающий sticky-флаг arb-lost. Активируется при нажатии любой кнопки в «пассивных» фазах (IDLE/OK/ERR). Логика: если предыдущая транзакция провалилась из-за arb-lost, флаг в ядре залип; при повторном нажатии кнопки мы его очищаем и начинаем новую транзакцию «с чистого листа».
Счётчик anim_stop_req (строки 190–198)
reg anim_stop_req;
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i)
anim_stop_req <= 1'b0;
else if (anim_i && anim_run)
anim_stop_req <= 1'b1;
else if (phase == PH_OK || phase == PH_IDLE)
anim_stop_req <= 1'b0;
endОтдельный однобитный регистр для обработки «второго нажатия KEY3»:
Если пользователь нажимает KEY3 во время активной анимации (
anim_i && anim_run) → запоминаем запрос на остановку (anim_stop_req = 1).Остановка срабатывает в
PH_FRAMEW: еслиanim_run && !anim_stop_req→ уходим вPH_ANEXT(продолжаем анимацию); иначе →PH_OK(стоп).Очистка флага — в
PH_OKилиPH_IDLE(когда анимация уже остановлена).
Почему так? Если пользователь нажал KEY3 в середине передачи кадра, мы не прерываем кадр — это оставило бы I2C-шину в неопределённом состоянии. Флаг anim_stop_req просто «запоминает» намерение, а реальный exit происходит на границе кадра, когда передача завершена корректно.
Главный FSM — сброс (строки 204–216)
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i) begin
phase <= PH_IDLE;
delay_cnt <= 23'd0;
bw_start <= 1'b0;
bw_byte_count <= 16'd0;
scene_start <= 1'b0;
done_o <= 1'b0;
err_o <= 1'b0;
progress_o <= 11'd0;
inited <= 1'b0;
mode <= 1'b0;
anim_run <= 1'b0;
angle <= 6'd0;
end else begin
bw_start <= 1'b0; // pre-assign — одноцикловые импульсы
scene_start <= 1'b0;
done_o <= 1'b0;
...Асинхронный сброс ставит FSM в PH_IDLE, всё очищает. В начале else-ветки — три pre-assign’а (bw_start, scene_start, done_o → 0), обеспечивающих одноцикловые импульсы (тот же паттерн, что и в i2c_burst_writer, см. 4.5).
Заметьте, что inited не сбрасывается pre-assign’ом — он level-флаг, живёт между фазами.
Стадия PH_IDLE (строки 224–248)
PH_IDLE: begin
if (start_i) begin
mode <= 1'b0;
anim_run <= 1'b0;
angle <= 6'd0;
err_o <= 1'b0;
if (inited)
phase <= PH_RENDER;
else begin
delay_cnt <= DELAY_CYCLES;
phase <= PH_DELAY;
end
end else if (anim_i) begin
mode <= 1'b1;
anim_run <= 1'b1;
angle <= 6'd0;
err_o <= 1'b0;
if (inited)
phase <= PH_RENDER;
else begin
delay_cnt <= DELAY_CYCLES;
phase <= PH_DELAY;
end
end
endДве симметричные ветки по нажатию KEY2 (start) и KEY3 (anim), отличия только в mode и anim_run:
KEY2 (
start_i):mode=0,anim_run=0→ один статический кадр.KEY3 (
anim_i):mode=1,anim_run=1→ циклическая анимация.
В обоих случаях: очищаем предыдущую ошибку (err_o=0), обнуляем угол (angle=0), переходим либо в PH_RENDER (если inited), либо в PH_DELAY (первый запуск после reset).
Стадия PH_DELAY (строки 251–256)
PH_DELAY: begin
if (delay_cnt == 23'd0)
phase <= PH_INIT;
else
delay_cnt <= delay_cnt - 23'd1;
endПростой 23-битный счётчик. При CLK_FREQ=50 МГц, DELAY_MS=100: DELAY_CYCLES = 5 000 000 — отсчитываем 100 мс. Затем переходим в PH_INIT.
Эта фаза один раз после power-on, плюс каждый раз после PH_ERR (сброс флага inited → повторный init с задержкой).
Пара PH_INIT / PH_INITW (строки 259–277)
PH_INIT: begin
bw_start <= 1'b1;
bw_byte_count <= {10'd0, INIT_LEN};
phase <= PH_INITW;
end
PH_INITW: begin
progress_o <= src_idx;
if (bw_done) begin
if (bw_error) begin
err_o <= 1'b1;
inited <= 1'b0;
phase <= PH_ERR;
end else begin
inited <= 1'b1;
phase <= PH_RENDER;
end
end
endPH_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
endPH_FRAME — одноцикловый pulse bw_start + установка bw_byte_count = 1025 (control-byte + 1024 пикселя).
PH_FRAMEW — трёхветочный exit:
Ошибка (
bw_error) —PH_ERR,anim_run=0. Если у нас был цикл анимации, он тоже прерывается.Нормально + анимация + не-стоп —
PH_ANEXT(инкремент угла, новый кадр).Нормально + не-анимация или стоп-запрос —
PH_OKс pulse’омdone_o.
Стадия PH_ANEXT (строки 315–318)
PH_ANEXT: begin
angle <= angle + 6'd1;
phase <= PH_RENDER;
endИнкремент angle по модулю 64 (auto-wrap 6-битного сложения). Полный оборот за 64 кадра = 64 × 46 мс ≈ 3 секунды. Плавно и приятно для глаза.
Стадия PH_OK (строки 321–337)
PH_OK: begin
if (start_i) begin
mode <= 1'b0;
anim_run <= 1'b0;
angle <= 6'd0;
err_o <= 1'b0;
phase <= inited ? PH_RENDER : PH_DELAY;
if (!inited) delay_cnt <= DELAY_CYCLES;
end else if (anim_i) begin
mode <= 1'b1;
anim_run <= 1'b1;
angle <= 6'd0;
err_o <= 1'b0;
phase <= inited ? PH_RENDER : PH_DELAY;
if (!inited) delay_cnt <= DELAY_CYCLES;
end
endПолный аналог PH_IDLE, но с одним ключевым отличием: не сбрасывает inited. Благодаря этому:
Если
inited = 1(чип уже инициализирован) — сразу идём вPH_RENDER, экономя 100 мс + 32 байта I2C (~1.5 мс) = ~101.5 мс per повторный запуск.Если
inited = 0(ещё не init или послеPH_ERR) — полный цикл черезPH_DELAY→PH_INIT.
Это делает UX приятным: после первого запуска каждое следующее нажатие кнопки даёт мгновенную реакцию.
Стадия PH_ERR (строки 339–349)
PH_ERR: begin
if (start_i || anim_i) begin
inited <= 1'b0;
mode <= anim_i ? 1'b1 : 1'b0;
anim_run <= anim_i;
angle <= 6'd0;
err_o <= 1'b0;
delay_cnt <= DELAY_CYCLES;
phase <= PH_DELAY;
end
endТерминальное состояние после ошибки. При любом нажатии кнопки:
всегда сбрасывает
inited = 0и идёт черезPH_DELAY → PH_INIT— полный цикл инициализации (предположение: дисплей мог быть физически отключён / сбросился / сбоил, нужна перенастройка);выбирает режим: static или anim в зависимости от нажатой кнопки.
Это важная семантика для восстановления: ошибка NACK почти наверняка означает, что чип либо отключён, либо потерял синхронизацию. Просто перезапуск без init бесполезен — пойдёт ещё один NACK.
Подключение i2c_burst_writer (строки 359–379)
i2c_burst_writer #(
.CNT_W (16)
) u_burst (
.clk_i (clk_i),
.rstn_i (rstn_i),
.start_i (bw_start),
.slave_addr_i (I2C_ADDR),
.byte_count_i (bw_byte_count),
.busy_o (bw_busy),
.done_o (bw_done),
.error_o (bw_error),
.data_req_o (bw_data_req),
.data_i (bw_data),
.data_valid_i (bw_data_req), // комбинаторная связка (см. 4.14)
.cmd_valid_o (cmd_valid_o),
.cmd_o (cmd_o),
.din_o (din_o),
.ready_i (ready_i),
.rx_ack_i (rx_ack_i),
.arb_lost_i (arb_lost_i)
);Ключевые моменты:
CNT_W = 16— с запасом на 65 535 байт. Достаточно и для init (32), и для frame (1025), и для любых будущих расширений.data_valid_i = bw_data_req— комбинаторное равенство. Источник всегда валиден мгновенно (init-ROM — комб-функция; scene_rdata — BRAM с latency=1, но интервалы между запросами в 4500 раз больше).cmd_valid_o/cmd_o/din_o— выходы burst-writer’а пробрасываются прозрачно наверх, черезssd1306_ctrlна внешнийi2c_master_core. Никаких дополнительных мультиплексоров.arb_lost_i— проксируется от ядра к burst-writer’у; аarb_lost_clear_oобратно, изssd1306_ctrlк ядру.
Ресурсы и характеристики модуля
Реалистичная оценка утилизации ssd1306_ctrl на Cyclone IV EP4CE10 (без u_scene и u_burst, только собственная логика):
Ресурс | Использовано | Комментарий |
|---|---|---|
Logic Elements (LE) | ~150 | FSM + init ROM + data mux + src_idx |
Registers | ~50 |
|
M9K blocks | 0 | весь ROM — |
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 в |
9×9 multipliers | 4 | в |
Это ~12% LE ресурса EP4CE10 (10 320 LE) — остаются 88% на остальную логику (7-seg driver, debouncers, LED-индикация).
Что можно улучшить / расширить
Dual-buffer анимация. Добавить второй BRAM +
ping-pongфлаг: пока кадр N передаётся, рендерим N+1 в другой буфер. Даст fps, ограниченный только передачей (~21 fps). Стоимость — +1 M9K, +20 LE.Retry при NACK. Сейчас по
bw_errorмы сразу уходим вPH_ERR. Можно добавить счётчик retries (до 3) с возвратом вPH_DELAY→PH_INIT. Полезно для шумной шины / вибрации.Watchdog по времени кадра. Если
PH_FRAMEWдлится больше 100 мс — что-то зависло (clock stretching slave’а, физическая проблема). Таймаут + принудительный STOP +PH_ERR.Несколько сцен. Добавить вход
scene_sel_iи передавать вscene_renderer, чтобы показать разные сцены по разным кнопкам. Ctrl-логика не меняется.I2C probe перед init. См. 2.2.2 — можно отправить transaction с
byte_count=0передPH_INIT, чтобы проверить наличие дисплея; если NACK → сразуPH_ERRбез 100 мс ожидания.
Фазовая диаграмма

Top-level: сборка системы на плате
Top-level модуль quartus_ssd1306/src/ssd1306_test_top.v — это «склейка», объединяющая все рассмотренные ранее компоненты в цельный проект для платы ALINX AX301 (Cyclone IV EP4CE6F17C8). Он не содержит никакой «интересной» логики (нет 3D, нет I2C-FSM), но делает критически важную работу: согласует физический мир (пины, кварц, дребезг, pull-up) с RTL-логикой.
Эта часть разбирает top-level поблочно, с разбором кода каждого фрагмента, расчёта всех констант и полным пин-ассайнментом из .qsf-файла.
Роль top-level и иерархия

Top-level не «знает» про SSD1306, I2C-протокол, 3D-куб. Он лишь:
Генерирует
core_enaизclk_50m— прескалер для работы I2C-ядра на 100 кГц.Подключает двунаправленные пины
i2c_sda/i2c_sclчерез tri-state + синхронизаторы.Антидребезжит две кнопки KEY2/KEY3.
Собирает статус (
busy,done,err,animating) на 4 LED.Разворачивает 11-битный
progress_oна 3 HEX-цифры 7-сегментного дисплея + 1 статус-цифра.Инстанциирует три главных модуля:
i2c_master_core,ssd1306_ctrl,seg_scan,ax_debounce(×2).
Объявление модуля и порты (строки 18–30)
module ssd1306_test_top (
input wire clk_50m,
input wire rst_n,
input wire key_start, // KEY2 — active-low, статика
input wire key_anim, // KEY3 — active-low, анимация
inout wire i2c_sda, // двунаправленные I2C
inout wire i2c_scl,
output wire [3:0] led, // 4 LED, active-high
output wire [5:0] seg_sel, // 6 цифр, active-low select
output wire [7:0] seg_data // 8 сегментов, active-low
);Все порты — физические пины FPGA:
Порт | Направление | Уровень активности | Назначение |
|---|---|---|---|
| in | — | кварц 50 МГц, источник системного такта |
| in | active-low | кнопка RESET |
| in | active-low | KEY2, «статический кадр» |
| in | active-low | KEY3, «анимация» (повторно — стоп) |
| inout | open-drain | линия данных I2C |
| inout | open-drain | линия тактов I2C |
| out | active-high | 4 индикатора статуса |
| out | active-low | селектор цифры (one-hot, активный 0) |
| out | active-low | сегменты + DP (0=горит) |
Прескалер I2C (строки 35–53)
Ядру i2c_master_core нужен импульс ena_i раз в четверть периода SCL, чтобы генерировать все 4 фазы I2C-бита.
SCL_freq = clk_sys / (4 · (PRE_TOP + 1))
100 кГц = 50 МГц / (4 · 125)
PRE_TOP = 124Формула:
Код прескалера:
localparam [6:0] PRE_TOP = 7'd124;
reg [6:0] pre_cnt;
reg core_ena;
always @(posedge clk_50m or negedge rst_n) begin
if (!rst_n) begin
pre_cnt <= 7'd0;
core_ena <= 1'b0;
end else begin
if (pre_cnt == PRE_TOP) begin
pre_cnt <= 7'd0;
core_ena <= 1'b1; // 1 такт из каждых 125
end else begin
pre_cnt <= pre_cnt + 7'd1;
core_ena <= 1'b0;
end
end
endРазбор:
7-битный счётчик
pre_cnt(0..127, достаточно для 124).1 такт
core_enaиз каждых 125 тактов. I2C-ядро использует его как «разрешение сдвига» своего бит-FSM.Скважность 1/125 — это ena-стробы, они не являются клоком. В синтезе будет один клок-домен
clk_50m, аcore_enaпросто включает/выключает последовательную логику в ядре — это хорошая практика CDC-free дизайна.
Таблица альтернативных скоростей:
Режим I2C |
|
| 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 | ~ |
3 | ~ |
2 ступени — стандарт де-факто для CDC. 3+ ступени используют для сверхкритических приложений (авиация, медицина).
Антидребезг кнопок (строки 82–92 + ax_debounce.v)
Объявление в top-level:
wire key_start_pulse, key_anim_pulse;
ax_debounce #(.CLK_FREQ(50_000_000), .DEBOUNCE_MS(20)) u_deb_start (
.clk_i(clk_50m), .rstn_i(rst_n),
.key_i(key_start), .key_pulse_o(key_start_pulse)
);
ax_debounce #(.CLK_FREQ(50_000_000), .DEBOUNCE_MS(20)) u_deb_anim (
.clk_i(clk_50m), .rstn_i(rst_n),
.key_i(key_anim), .key_pulse_o(key_anim_pulse)
);Два экземпляра, по одному на каждую кнопку. Параметр DEBOUNCE_MS = 20 — типичное значение для механических кнопок (большинство «звенят» 5–10 мс при нажатии и 10–20 мс при отпускании).
Внутри ax_debounce.v (45 строк, полный разбор):
module ax_debounce #(
parameter CLK_FREQ = 50_000_000,
parameter DEBOUNCE_MS = 20
)(
input wire clk_i,
input wire rstn_i,
input wire key_i,
output reg key_pulse_o
);
localparam CNT_MAX = (CLK_FREQ / 1000) * DEBOUNCE_MS;
reg [19:0] cnt;
reg key_d;
reg key_stable;
always @(posedge clk_i or negedge rstn_i) begin
if (!rstn_i) begin
cnt <= 20'd0;
key_d <= 1'b1;
key_stable <= 1'b1;
key_pulse_o <= 1'b0;
end else begin
key_pulse_o <= 1'b0; // pre-assign: импульс 1 такт
key_d <= key_i; // однократная выборка
if (key_d != key_stable) begin // есть рассогласование
if (cnt >= CNT_MAX[19:0] - 20'd1) begin
cnt <= 20'd0;
key_stable <= key_d; // принять новое состояние
if (!key_d)
key_pulse_o <= 1'b1; // фронт 1→0 = нажатие
end else
cnt <= cnt + 20'd1;
end else
cnt <= 20'd0; // сброс — состояние стабильно
end
end
endmoduleАлгоритм на пальцах:
key_d— входной сэмпл прошлого такта;key_stable— текущее «признанное» состояние.Если совпадают — сигнал стабилен, счётчик сброшен.
Если не совпадают — наращиваем счётчик. Как только он достигает
CNT_MAX - 1= 20 мс × 50 МГц / 1000 = 1 000 000 — признаём новое состояние стабильным.Только на фронте
1 → 0(нажатие, т. к. активность-low) генерируем одноцикловыйkey_pulse_o. На отпускании — нет.
Такая схема:
Одно нажатие даёт ровно один
key_pulse_o— идеально для FSMssd1306_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 ( | горит во время передачи (init / frame) |
LED1 ( | горит после последнего успешного кадра |
LED2 ( | горит, если была ошибка (NACK / arb-lost) |
LED3 ( | мигает в такт анимации, показывает «цикл идёт» |
Типовой сценарий:
Включение платы: все 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/Aseg_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 |
|
start передачи frame |
|
передано 0x0200 байт |
|
передано 0x0400 = 1024 |
|
идёт анимация, кадр N |
|
ошибка |
|
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 | Замечание |
|---|---|---|
| E1 | кварц 50 МГц, GCLK-пин |
| N13 | кнопка RESET (active-low) |
| M15 | KEY2 (active-low) |
| M16 | KEY3 (active-low) |
| E8 | совместно с EEPROM 24C04 (0x50) |
| E9 | |
| E10 | active-high |
| F9 | active-high |
| C9 | active-high |
| D9 | active-high |
| N9 | active-low |
| P9 | |
| M10 | |
| N11 | |
| P11 | |
| M11 | |
| R14 | active-low |
| N16 | |
| P16 | |
| T15 | |
| P15 | |
| N12 | |
| N15 | |
| 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) — в |
I/O pins | 24 | 26% (24 / 92) — без учёта конфиг-пинов |
Fmax (clk_50m) | >80 МГц | 1.6× от требуемых 50 МГц |
По памяти и DSP остаётся много запаса — можно добавить dual-buffer (7.22) или вторую сцену, не выходя за рамки чипа.
Типичные ошибки сборки и их решение
Симптом | Причина | Решение |
|---|---|---|
| забыли source | убедиться, что |
| напрямую | использовать |
нет реакции на кнопки | неправильный polarity (ожидается active-low) | в |
дисплей не показывает ничего | не подтянуты pull-up на SDA/SCL | см. 2.1.5, резисторы 4.7 кОм |
«зависает» на init | адрес 0x3D (правая конфигурация 0x78/0x7A) | проверить |
unconstrained I/O warnings | нет | добавить ограничения (см. 8.13) |
Fmax < 50 МГц | длинные комб-пути | обычно не возникает с этой логикой; при расширении — |
timing violation на | ошибочная реплика BRAM в LE | включить |
Краткий чек-лист запуска на плате
Проверка питания: VCCIO 3.3V, VCC_INT 1.2V на FPGA.
Подключить OLED к I2C-шине AX301: SDA → E9, SCL → E8, VCC → 3.3V (или 5V, зависит от модели модуля), GND → GND.
Прошить
.sofчерез USB-Blaster.После reset (кнопка RESET на плате) — все LED должны быть выключены, 7-seg должен показывать
- - - - - -.Нажать KEY2 — через ~100 мс зажигается
LED0, идёт init; через ~1.5 мс — передача frame; через ~95 мс —LED1загорается, на экране OLED виден статический кадр.Нажать KEY3 —
LED0+LED3горят постоянно, OLED показывает вращающийся куб. Повторное KEY3 — остановка.Если
LED2(ошибка) — проверить pull-up, физическое подключение, прозвонить шину.
Тайминг и производительность
Длительность одной I2C-операции
1 квартал SCL = PRE_TOP + 1 = 125 тактов = 2.5 мкс
1 бит SCL = 4 кварта = 10 мкс
1 байт I2C = 9 бит (8 data + ACK) = 90 мкс
START / STOP = 4 кварта = 10 мксInit-транзакция (32 command-байта)
START : 10 мкс
Address : 90 мкс
Data × 32 : 2880 мкс
STOP : 10 мкс
─────────────────────────
Всего ≈ 2.99 мсFrame-транзакция (1025 data-байт)
START : 10 мкс
Address : 90 мкс
Data × 1025 : 92 250 мкс
STOP : 10 мкс
─────────────────────────────
Всего ≈ 92.36 мсРендер одного кадра
Clear : 1024 такт ≈ 20 мкс
Vertex rotate : 32 такт ≈ 0.64 мкс
Edge rasterise : ~720 такт ≈ 14 мкс
Text blit : ~100 такт ≈ 2 мкс
──────────────────────────────────────
Итого рендер ≈ 1900 такт ≈ 38 мксПолный бюджет на первый кадр (с init)
Power-on delay : 100.00 мс
Init transaction : 3.00 мс
Render scene : 0.04 мс
Frame transaction : 92.36 мс
───────────────────────────
Итого ≈ 195.4 мс (≈ 0.2 сек до первой картинки)Полный бюджет на последующие кадры (анимация)
Render scene : 0.04 мс
Frame transaction : 92.36 мс
───────────────────
Итого ≈ 92.4 мс → ≈ 10.8 FPSПри переходе на Fast-mode (400 кГц) время кадра сократится до ≈ 23 мс, что даст ≈ 40 FPS — куб будет вращаться в 4 раза плавнее.
Счётчик прогресса
progress_o на 7-сегментном дисплее показывает текущий src_idx в HEX.
В фазе init: 000 → 020 (32 dec)
В фазе frame: 000 → 401 (1025 dec)
Если счётчик застрял на значении X — значит, slave перестал ACK-ать на байте X. Это мощный отладочный инструмент.
Отладка и типичные ошибки
Экран тёмный, LED[1] горит
Значит, передача прошла без NACK, но дисплей не засветился. Возможные причины:
Charge pump не включён. Проверить команду
0x8D, 0x14вinit_rom(байты 9 и 10). Без неё OLED-ячейки не получат 7 В.Wrong multiplex ratio. Для 128×64 должно быть
0xA8, 0x3F. Для 128×32 —0xA8, 0x1F.Display ON отсутствует. Последняя команда
0xAF(байт 31).Слишком тёмная контрастность. Проверить
0x81, 0xCF. Можно увеличить до0xFF.
LED[2] загорается сразу после кнопки
Это NACK на байте адреса. Смотрим на 7-сегмент:
seg_d1..seg_d3= 000 → NACK на адресе0x78.seg_d1..seg_d3= 001 → NACK на первом байте данных (маловероятно для init).
Возможные причины:
Неверный I2C-адрес. SSD1306 может быть 0x3C или 0x3D в зависимости от пина SA0. Проверить модуль.
Не подано питание на модуль OLED.
SDA/SCL перепутаны. Запустить логический анализатор, проверить START-условие: 1→0 на SDA при SCL=1.
Pull-up отсутствуют. Плата AX301 имеет встроенные, но если используется отладочная шина — добавить 4.7 кОм к 3.3 В.
Изображение перевёрнуто / зеркально
Модули SSD1306 от разных производителей «смонтированы» по-разному. Переключите:
0xA1↔0xA0— горизонтальное зеркало.0xC8↔0xC0— вертикальное зеркало.
Для нашей конфигурации 0xA1, 0xC8 — корректно для большинства «красно-зелёных» 128×64 модулей на I2C.
Куб «едет» или занимает не всё место
Причины:
Границы адресации не установлены. Проверить
0x21 00 7Fи0x22 00 07.Выход вершины за ±31 по Y. Проверить, что
S = 12и(z >>> 2) + vy + 32 ∈ [0, 63]. При большемSкуб может «срезаться» по верху/низу.
Точки рисуются «в клетке»
Если Брезенхэм даёт ступенчатую картинку с разрывами — проверяем:
Знак
dy. Должен быть отрицательным (классический Брезенхэм используетdy = -|y1 - y0|). В нашем коде этоinit_dy_n.Направление
sx, sy. Должны соответствовать знаку шага.Сравнение
e2 >=vse2 <=. Для 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_ackbw_state, bw_data_req, bw_data, bw_done, bw_errorphase, src_idx, scene_readysda_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 |
| 124 → 100 кГц |
I2C-адрес SSD1306 |
| 7'h3C |
Задержка питания |
| 100 мс |
Полуребро куба |
| 12 |
Позиция верхнего текста |
| 43 |
Период дребезга |
| 20 мс |
Послесловие
Этот проект — демонстрация того, как далеко может уйти fixed-function pipeline в FPGA без привлечения софтверных абстракций. Весь контур от кнопки до OLED — это ~2 000 Verilog-строк, детерминированные автоматы, случайная BRAM и квартир-волновая LUT. Ни байта программного кода. Ни одного тактового цикла, потраченного впустую.
Одновременно этот проект — стенд-надёжности для ядра i2c_master_core: он гоняет по шине 1060 байт подряд каждые 95 мс, непрерывно. Любая мелкая ошибка в бит-слотах, ACK-сэмплировании или обработке clock-stretching проявится как «застрявший счётчик» на 7-сегменте — что немедленно укажет адрес проблемы.
Удачной компиляции.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

























