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

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

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

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

Для SSD1306 компания Solomon Systech фиксирует в даташите старшие 6 бит адреса — 011110 (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 |
0×00 |
Stream‑commands: все байты до STOP — команды |
0 |
1 |
0×40 |
Stream‑data: все байты до STOP — пиксели ( |
1 |
0 |
0×80 |
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 ⇒ пиксель светится
Конкретные примеры:
Байт |
Двоичное |
Что зажигается (в пределах одного столбца страницы) |
0×00 |
00 000 000 |
пусто — 8 чёрных пикселей |
0xFF |
11 111 111 |
полный столбец 8 пикселей горит |
0×01 |
00 000 001 |
только верхний пиксель (row = page*8 + 0) |
0×80 |
10 000 000 |
только нижний пиксель (row = page*8 + 7) |
0×0F |
00 001 111 |
верхние 4 пикселя горят, нижние 4 — нет |
0xF0 |
11 110 000 |
верхние 4 тёмные, нижние 4 горят |
0×3C |
00 111 100 |
«серединка» столбца — rows 2..5 горят |
0xAA |
10 101 010 |
«в шахматку» по вертикали |
Развёрнутый пример: байт 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 ⇒ байт 0×40;
в 128 колонках страницы 3 подряд кладём 0×40 × 128.
Горизонтальная линия в страницу другого уровня (скажем строка 31, page=3, sub‑row 7) — это всё ещё page 3, но байт 0×80. А горизонтальная линия в строке 32 — уже page 4, байт 0×01.
Для нашего 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 в соответствующем разделе). Он работает только для «цветных» пикселей; для полного сброса всего экрана (заливка 0×00) можно писать байтами без 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 0×40 чип начинает записывать входящие 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 (0×20, 0×02). Самый простой режим, поведение по умолчанию после 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 (0×20, 0×00) — наш выбор. «Пишет как текст на странице»: сначала заполняется целая страница по столбцам, потом курсор переходит на следующую страницу.
Поведение: 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 (0×20, 0×01). Зеркало 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‑команды, ни сбрасывать курсор, ни заново посылать 0×21/0×22. Достаточно:»
... (рендер следующего кадра в 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 (0×20, 0×01) |
Перестал работать автовозврат курсора в (0,0) после frame«а» |
в init забыли 0×20, 0×00, либо где‑то перезаписали режим командой‑«сюрпризом» |
Картинка перевёрнута или отзеркалена |
это не проблема адресного режима, а команд 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 |
0×80 |
Display Clock Divide / F_osc — задаёт внутреннюю частоту тактирования драйвера дисплея. |
0xA8 |
0×3F |
Multiplex Ratio — количество строк, которые реально подключены к COM‑драйверам. |
0xD3 |
0×00 |
Display Offset — сдвиг первой строки по вертикали. 0×00 = без сдвига. Используется, если панель физически смонтирована со смещением (в наших модулях — нет) |
0×40 |
— |
Display Start Line = 0. Определяет, какая строка GDDRAM соответствует верхней строке экрана. Изменением этого регистра можно делать «программный вертикальный скроллинг», нам это не нужно |
0xDA |
0×12 |
COM Pins Hardware Config — указывает чипу, как физически разведены COM‑линии. Для 128×64-панели с типовой «альтернативной» разводкой значение = |
Группа C. Питание OLED — charge pump
Эта команда — та самая «невидимая бомба замедленного действия», которую постоянно забывают разработчики‑новички. Все шаги init прошли, NACK«ов нет, 0xAF отправлено — а экран чёрный. Проверить первым делом, что 0×8D, 0×14 реально есть в потоке, и что оно идёт до 0xAF.»
Команда |
Аргумент |
Назначение |
0×8D |
0×14 |
Charge Pump Enable. |
Группа D. Адресация GDDRAM
Это то, что мы подробно обсудили в одном из разделов выше. Здесь мы фиксируем режим адресации в horizontal и задаём окно на весь экран.
Команда |
Аргумент |
Назначение |
0×20 |
0×00 |
Memory Addressing Mode = horizontal (см. 2.4.2) |
0×21 |
0×00, 0×7F |
Column Address |
0×22 |
0×00, 0×07 |
Page Address |
После выполнения этих трёх команд внутренний курсор устанавливается в (col=0, page=0), и мы можем сразу начинать слать frame‑burst.
Группа E. Ориентация и внешний вид
Эти команды определяют геометрию изображения относительно физических координат панели, яркость и качество свечения.
Команда |
Аргумент |
Назначение |
0xA1 |
— |
Segment Remap: |
0xC8 |
— |
COM Scan Direction: |
0×81 |
0xCF |
Contrast Control = |
0xD9 |
0xF1 |
Pre‑charge Period: |
0xDB |
0×40 |
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 |
0×00 |
control‑byte: stream‑commands (Co=0, D/C#=0) |
— |
1 |
0xAE |
Display OFF |
A |
2 |
0xD5 |
Set Display Clock Div |
B |
3 |
0×80 |
├─ arg: div = 1 |
B |
4 |
0xA8 |
Set Multiplex Ratio |
B |
5 |
0×3F |
├─ arg: MUX = 64 |
B |
6 |
0xD3 |
Set Display Offset |
B |
7 |
0×00 |
├─ arg: offset = 0 |
B |
8 |
0×40 |
Set Display Start Line = 0 |
B |
9 |
0×8D |
Charge Pump setting |
C |
10 |
0×14 |
├─ arg: enable pump |
C |
11 |
0×20 |
Memory Addressing Mode |
D |
12 |
0×00 |
├─ 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 |
0×12 |
├─ arg: 128×64 alt, no remap |
B |
17 |
0×81 |
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 |
0×40 |
├─ arg: ~0.77·VCC |
E |
23 |
0xA4 |
Display From RAM |
E |
24 |
0xA6 |
Normal Display |
E |
25 |
0×21 |
Set Column Address |
D |
26 |
0×00 |
├─ arg: col start = 0 |
D |
27 |
0×7F |
├─ arg: col end = 127 |
D |
28 |
0×22 |
Set Page Address |
D |
29 |
0×00 |
├─ arg: page start = 0 |
D |
30 |
0×07 |
├─ 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»:
Симптом |
Вероятная причина |
Экран полностью тёмный, картинка не видна |
забыта команда 0×8D, 0×14 (charge pump) или 0xAF (display ON) |
Экран полностью светится (все пиксели) |
вместо 0xA4 ушла команда 0xA5 (force all ON, тест‑режим) |
Экран инвертирован (чёрное по белому) |
вместо 0xA6 ушла 0xA7 |
Картинка отзеркалена по X |
забыта 0xA1 (или послана 0xA0) |
Картинка отзеркалена по Y |
забыта 0xC8 (или послана 0xC0) |
Каждая вторая строка чёрная, картинка «растянута» вдвое |
неверный аргумент 0xDA: нужно 0×12 для 128×64, а не 0×02 |
Верхняя половина «съехала» в нижнюю или наоборот |
неверный 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; // аварийный выход end
STOP не отправляется — шина у другого мастера, любая попытка записи только создаст коллизию. Ядро само отпускает SDA/SCL, мы молча сдаёмся, сигналим error_o, и ждём от верхнего уровня команды arb_lost_clear_i + повторного start_i для retry.
Сводная таблица:
Сигнал |
Dir |
Ширина |
Тип |
Назначение |
clk_i |
in |
1 |
clock |
системный такт (50 МГц) |
rstn_i |
in |
1 |
async reset |
active‑low сброс |
start_i |
in |
1 |
pulse |
запуск новой I2C‑транзакции |
slave_addr_i |
in |
7 |
level |
7-битный адрес slave (W=0 добавляется сам) |
byte_count_i |
in |
CNT_W |
level |
сколько data‑байт после адреса |
busy_o |
out |
1 |
level |
транзакция идёт (state ≠ IDLE) |
done_o |
out |
1 |
1-cycle pulse |
завершение транзакции |
error_o |
out |
1 |
level |
NACK или arb‑lost, валиден c done_o |
data_req_o |
out |
1 |
level |
запрос очередного data‑байта |
data_i |
in |
8 |
level |
сам data‑байт от источника |
data_valid_i |
in |
1 |
level |
квитанция «data_i валиден» |
cmd_valid_o |
out |
1 |
level |
команда для ядра валидна |
cmd_o |
out |
3 |
level |
код команды (START/WRITE/STOP) |
din_o |
out |
8 |
level |
байт для передачи по I2C |
ready_i |
in |
1 |
level |
ядро готово принять/завершило команду |
rx_ack_i |
in |
1 |
level |
ACK(0)/NACK(1) от slave (валиден с rising ready_i) |
arb_lost_i |
in |
1 |
level |
потеря арбитража на шине |
Сам модуль не знает, откуда берутся данные — это может быть ROM, BRAM, FIFO или генератор. Это разделение ответственности — основная абстракция проекта: burst‑writer — «менеджер транзакции», а что передавать — забота верхнего уровня.
Модуль i2c_burst_writer — автомат пакетной записи
Теперь детально разберем исходный код rtl/i2c_burst_writer.v с построчным объяснением. Модуль сравнительно небольшой (~200 строк Verilog‑кода), но именно он превращает «атомарное» I2C‑ядро в полноценный burst‑transport, и именно он задаёт фреймворк, под который пишутся все device‑контроллеры (SSD1306, а в будущем — EEPROM, BME280 и др.).
Место модуля между ssd1306_ctrl (верхний уровень) и i2c_master_core (ядро шины):

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

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

Для комбинаторного источника (ROM‑функция, процедурный мукс) data_valid_i = data_req_o напрямую — данные появляются в том же такте, когда поднят req.
Для регистрового источника (BRAM с синхронным чтением) допустима задержка 1–2 такта: burst‑writer будет ждать в S_DATA_REQ, пока data_valid_i не поднимется. В нашем проекте между двумя запросами проходит ~45 мкс (время I2C‑байта), поэтому 1-тактовая задержка BRAM«а растворяется в этом интервале.»
В ssd1306_ctrl используется комбинаторная связка:
// из quartus_ssd1306/src/ssd1306_ctrl.v wire [7:0] init_byte = init_rom(src_idx[5:0]); wire [7:0] data_byte = (src_idx == 11'd0) ? 8'h40 : scene_rdata; wire [7:0] bw_data = (phase == PH_INITW) ? init_byte : data_byte; assign bw_data_valid = bw_data_req; // комбинаторное равенство
init_byte — comb‑ROM (функция), scene_rdata — регистровый выход BRAM, но к моменту запроса BRAM уже успел обновиться (за счёт подставления raddr_i = src_idx заблаговременно).
Обработка ошибок — сводка
Три возможных сценария сбоя, как они обрабатываются:
Событие |
Момент обнаружения |
Обработка |
Результат |
|---|---|---|---|
NACK на адресе |
|
|
|
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 |
65 535 |
наш выбор — любой разумный sensor/display/EEPROM |
20 |
1 048 575 |
для длинных 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 endfunction
sin_q(q) = round(127 · sin(q · π/32)), для четверти окружности (0 → π/2 в 17 шагов). Q0.7 fixed‑point — максимум ±127 помещается в знаковом 8-битном числе (но для удобства арифметики берём 9-битное signed, чтобы корректно работало -sin_q(qi) у границы −128..127).
Зачем 17 значений, а не все 64? — quarter‑wave symmetry. sin имеет две симметрии:
Относительно π/2:
sin(π/2 + x) = sin(π/2 − x)(в рамках одной четверти это «зеркалит» вторую половину первой четверти к первой).Относительно π:
sin(π + x) = −sin(x)(третья и четвёртая четверти — просто минус первой и второй).
Используя эти свойства, из 17 значений можно восстановить всю 64-точечную таблицу. Экономия: ~74% на размере ROM. В терминах Cyclone IV это 17 case‑веток LUT против 64 — разница в ~50 LE.
sin6 — обёртка, которая по полному углу 0..63 достаёт нужное значение с правильным знаком:
function signed [8:0] sin6; input [5:0] theta; reg [4:0] qi; reg signed [8:0] mag; begin qi = theta[4] ? (5'd16 - {1'b0, theta[3:0]}) : {1'b0, theta[3:0]}; mag = sin_q(qi); sin6 = theta[5] ? -mag : mag; end endfunction
Разбор:
theta[5]— старший бит: он переключает знак (третья/четвёртая четверти →−mag).theta[4]— средний бит: мы в первой или второй «половине» полупериода.theta[4]=0— прямой обход (0..15 → qi=0..15);theta[4]=1— зеркальный (16..31 → qi=16..1).theta[3:0]— младшие 4 бита: индекс внутри четверти 0..15.qi— нормализованный индекс дляsin_q, значение 0..16.
cos6 — тривиально через сдвиг фазы на π/2 (= 16 шагов):
function signed [8:0] cos6; input [5:0] theta; cos6 = sin6(theta + 6'd16); // cos(θ) = sin(θ + π/2) endfunction
Вся триада sin_q → sin6 → cos6 — это чистая комбинаторика (synthesizable functions), синтезатор раскрутит их в LUT‑дерево глубиной 2–3 уровня.
Геометрия куба — vertex ROM и edge ROM (строки 110–192)
Куб центрирован в (0, 0, 0) с полуребром S = 12. Координаты каждой из 8 вершин хранятся в трёх ROM«ах vx_rom/vy_rom/vz_rom:»
function signed [7:0] vx_rom; input [2:0] i; case (i) 3'd0: vx_rom = -S; 3'd1: vx_rom = S; 3'd2: vx_rom = S; 3'd3: vx_rom = -S; 3'd4: vx_rom = -S; 3'd5: vx_rom = S; 3'd6: vx_rom = S; 3'd7: vx_rom = -S; endcase endfunction // vy_rom, vz_rom аналогично
Суммарно получается таблица:
i |
vx |
vy |
vz |
Вершина (в model‑space) |
|---|---|---|---|---|
0 |
−S |
−S |
−S |
верх‑зад‑лево |
1 |
+S |
−S |
−S |
верх‑зад‑право |
2 |
+S |
−S |
+S |
верх‑перед‑право |
3 |
−S |
−S |
+S |
верх‑перед‑лево |
4 |
−S |
+S |
−S |
низ‑зад‑лево |
5 |
+S |
+S |
−S |
низ‑зад‑право |
6 |
+S |
+S |
+S |
низ‑перед‑право |
7 |
−S |
+S |
+S |
низ‑перед‑лево |
Соглашение осей: +X — вправо, +Y — вниз (экранное), +Z — на зрителя. Верхняя грань куба — вершины 0..3, нижняя — 4..7; парами «над‑под» идут (0,4), (1,5), (2,6), (3,7).
edge_v0(e)/edge_v1(e) — таблицы, дающие по номеру ребра e ∈ 0..11 два номера вершин v0/v1, которые оно соединяет:
function [2:0] edge_v0; input [3:0] e; case (e) 4'd0: edge_v0 = 3'd0; 4'd1: edge_v0 = 3'd1; ... // всего 12 записей endcase endfunction // edge_v1 аналогично, с парными номерами
Структура 12 рёбер:
e |
v0 → v1 |
Тип ребра |
|---|---|---|
0 |
0 → 1 |
верхнее заднее |
1 |
1 → 2 |
верхнее правое |
2 |
2 → 3 |
верхнее переднее |
3 |
3 → 0 |
верхнее левое |
4 |
4 → 5 |
нижнее заднее |
5 |
5 → 6 |
нижнее правое |
6 |
6 → 7 |
нижнее переднее |
7 |
7 → 4 |
нижнее левое |
8 |
0 → 4 |
вертикальное зад‑лево |
9 |
1 → 5 |
вертикальное зад‑право |
10 |
2 → 6 |
вертикальное перед‑право |
11 |
3 → 7 |
вертикальное перед‑лево |
4 верхних + 4 нижних + 4 вертикальных = 12, как и положено у куба.
Шрифт 5×7 и лексиконы (строки 194–320)
16 глифов, адресуемых по 4-битному char‑номеру:
0:'S' 1:'D' 2:'1' 3:'3' 4:'0' 5:'6' 6:' ' 7:'C' 8:'U' 9:'B' 10:'E' 11:'A' 12:'I' 13:'T' 14:'N' 15:'M'
Каждый глиф — 5 столбцов по 7 бит (биты 0..6; бит 7 = 0, для падинга). Адрес внутри ROM — 7-битный {char[3:0], col[2:0]}, то есть каждому символу отведено 8 столбцов (5 значащих + 3 пустых). Это делается для удобства адресной арифметики: адрес кратен 8, сдвиг на 3 бита = умножение.
function [7:0] font_byte; input [6:0] addr; case (addr) // 'S' — код символа 0 7'd0: font_byte = 8'h26; 7'd1: font_byte = 8'h49; 7'd2: font_byte = 8'h49; 7'd3: font_byte = 8'h49; 7'd4: font_byte = 8'h32; // 'D' — код символа 1 7'd8: font_byte = 8'h7F; 7'd9: font_byte = 8'h41; 7'd10: font_byte = 8'h41; 7'd11: font_byte = 8'h41; 7'd12: font_byte = 8'h3E; ... endcase endfunction
Формат одного столбца: little‑endian по вертикали — биты 0..6 соответствуют строкам сверху вниз (бит 0 — верхний пиксель). Это совпадает с раскладкой GDDRAM (см. 2.3.2), поэтому каждый байт glyph«а копируется в FB без битового reverse.»
Визуализация буквы 'S' (5 столбцов × 7 строк):

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

Общая обёртка имеет ещё один важный штрих в самом конце:
// pre-assign в начале else-ветки: fb_we по умолчанию = 0 fb_we <= 1'b0; case (state) ... endcase // post-assign: сбрасывает ready при повторном старте if (start_i && state != S_IDLE) ready_o <= 1'b0;
fb_we <= 1'b0— pre‑assign, аналогичный тому, что делаетdone_o <= 0вi2c_burst_writer: write‑enable по умолчанию снят, и любое состояние, которое хочет записать, должно явно поставитьfb_we <= 1'b1. Это защищает от случайной «утечки» write«а в неожиданных состояниях.»post‑assign
if (start_i && state != IDLE)— нужен, чтобы при «повторном» запуске (например, если верхний уровень решил перезапустить рендер до того, как текущий закончился) флагready_oсбрасывался. В норме это не происходит.
Стадия S_IDLE — ожидание старта (строки 486–494)
S_IDLE: begin if (start_i) begin mode_r <= mode_i; angle_r <= angle_i; ready_o <= 1'b0; clr_idx <= 11'd0; state <= S_CLEAR; end end
Защёлкиваем mode_i/angle_i, сбрасываем ready_o и счётчик клирования, уходим в S_CLEAR. Важно, что mode_i/angle_i могут меняться у верхнего уровня между стартами (анимация инкрементит angle), но внутри одного кадра мы пользуемся защёлкнутыми значениями. Иначе рендер мог бы «поплыть» в середине.
Стадия S_CLEAR — заливка FB нулями (строки 497–506)
S_CLEAR: begin fb_we <= 1'b1; fb_waddr <= clr_idx[9:0]; fb_wdata <= 8'h00; if (clr_idx == 11'd1023) begin clr_idx <= 11'd0; state <= S_ROT_SETUP; end else clr_idx <= clr_idx + 11'd1; end
Счётчик 0..1023, на каждом такте один write 0x00. Ровно 1024 такта = 20.5 мкс на 50 МГц. Почему не ускорить? Можно было бы сконфигурировать M9K как 256×32 (4 байта за такт) — получилось бы 256 тактов. Но:
это изменит layout BRAM«а и усложнит read‑порты;»
20.5 мкс на clear при 46 мс кадра — 0.04%, экономить не на чем.
11-битный clr_idx нужен, чтобы корректно обернуться: `11'd1023
1 = 11'd1024
, а если использовать 10-битный счётчик,10'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; end
1 такт. Защёлкиваем sin/cos угла текущего кадра. Далее во всём цикле по 8 вершинам эти значения не меняются. vtx_idx = 0.
S_ROT_LOAD (строки 516–521)
S_ROT_LOAD: begin cur_vx <= vx_rom(vtx_idx); cur_vy <= vy_rom(vtx_idx); cur_vz <= vz_rom(vtx_idx); state <= S_ROT_MUL; end
Достаём базовые (vx, vy, vz) текущей вершины из ROM«ов. Поскольку vx_rom/vy_rom/vz_rom — комбинаторные функции, значения доступны в том же такте.»
S_ROT_MUL (строки 523–529)
S_ROT_MUL: begin prod_xc <= cur_vx * cos_th; prod_zs <= cur_vz * sin_th; prod_xs <= cur_vx * sin_th; prod_zc <= cur_vz * cos_th; state <= S_ROT_STORE; end
Четыре умножения signed 8×9 → 18 бит параллельно. Каждое из них синтезируется как DSP‑блок 9×9 в Cyclone IV. EP4CE10 имеет 23 таких блока; мы используем 4 одновременно — ~17% DSP.
S_ROT_STORE (строки 531–541)
S_ROT_STORE: begin vtx_px[vtx_idx] <= rot_px[8:0]; vtx_py[vtx_idx] <= rot_py[8:0]; if (vtx_idx == 3'd7) begin edge_idx <= 4'd0; state <= S_EDGE_INIT; end else begin vtx_idx <= vtx_idx + 3'd1; state <= S_ROT_LOAD; end end
Защёлкиваем результат комбинационных хелперов из 6.8.1 в массив vtx_px/vtx_py. Затем либо следующая вершина (цикл через S_ROT_LOAD), либо выход на рёбра.
Итого по ротации: 1 + 8 × 3 = 25 тактов на 8 вершин = 0.5 мкс. Куб целиком преобразуется меньше чем за полмикросекунды.
Пенталогия S_EDGE_* — Брезенхэм по 12 рёбрам
S_EDGE_INIT (строки 544–555)
S_EDGE_INIT: begin bx <= p0x; by <= p0y; bx_end <= p1x; by_end <= p1y; bdx <= init_dx; bdy <= init_dy_n; bsx <= init_sx; bsy <= init_sy; berr <= init_err; state <= S_EDGE_SETUP; end
Загружаем стартовую точку, конечную точку, dx, −dy, направления sx/sy и начальную ошибку err. Все эти значения заранее вычислены комбинаторно (см. 6.8.2) из vtx_px[e0]/vtx_py[e1].
S_EDGE_SETUP (строки 557–565)
S_EDGE_SETUP: begin if (in_screen) begin fb_raddr_rmw <= pix_addr; pix_mask <= new_mask; state <= S_EDGE_RD; end else begin state <= S_EDGE_STEP; // skip clip end end
Проверяем clip по экрану:
В экране: запрашиваем чтение
fb[pix_addr](выставляемfb_raddr_rmw), защёлкиваемpix_mask, идём вS_EDGE_RD(ждать ответ BRAM).Вне экрана: пропускаем RD/WR, сразу в
S_EDGE_STEP(сделать шаг Брезенхэма без записи).
S_EDGE_RD (строки 567–569)
S_EDGE_RD: begin state <= S_EDGE_WR; end
Пустой такт. Он нужен, потому что BRAM имеет latency 1: адрес выставили в S_EDGE_SETUP, данные в fb_rdata_rmw появятся через 1 такт. В S_EDGE_RD мы просто ждём, а в S_EDGE_WR используем эти данные.
S_EDGE_WR (строки 571–576)
S_EDGE_WR: begin fb_we <= 1'b1; fb_waddr <= fb_raddr_rmw; fb_wdata <= fb_rdata_rmw | pix_mask; state <= S_EDGE_STEP; end
Read‑modify‑write: ставим на записываемый адрес тот же, с которого читали (fb_waddr <= fb_raddr_rmw), данные — старое значение | pix_mask (добавляем один бит). Это «рисует» один пиксель в FB, не трогая соседние 7 пикселей того же байта.
S_EDGE_STEP (строки 578–595)
S_EDGE_STEP: begin if (bx == bx_end && by == by_end) begin if (edge_idx == (NUM_EDGES - 4'd1)) begin txt_phase <= 1'b0; txt_char_idx <= 3'd0; txt_col_idx <= 3'd0; state <= S_TEXT_INIT; end else begin edge_idx <= edge_idx + 4'd1; state <= S_EDGE_INIT; end end else begin berr <= berr_nx; if (step_x) bx <= bx + bsx_ext; if (step_y) by <= by + bsy_ext; state <= S_EDGE_SETUP; end end
Два случая:
-
Достигли конца ребра (
bx == bx_end && by == by_end):если это последнее ребро (
edge_idx == 11) — переходим к рендерингу текста (S_TEXT_INIT);иначе — инкремент
edge_idx, обратно вS_EDGE_INITдля следующего ребра.
-
Не конец — классический шаг Брезенхэма:
berrобновляется суммойbdy_ext/bdx_extсогласно флагамstep_x/step_y(комб‑выражения из 6.8.3);bx/byинкрементируются на±1соответственно;возврат в
S_EDGE_SETUPдля рисования следующего пикселя.
Итого по рёбрам: 12 рёбер × (средняя длина ~30 пикселей) × 3 такта/пиксель = ~1080 тактов. На практике чуть меньше (некоторые рёбра очень короткие после проекции), обычно ~720–1000 тактов = 14–20 мкс.
Триада S_TEXT_* — рендер текста
S_TEXT_INIT (строки 598–600)
S_TEXT_INIT: begin state <= S_TEXT_WR; end
Пустой такт для переключения состояния. Нужен, потому что txt_phase/txt_char_idx/txt_col_idx были сброшены в предыдущем состоянии (в S_EDGE_STEP при переходе), и комбинационные cur_char/cur_page/cur_col0/txt_addr должны «устояться».
S_TEXT_WR (строки 602–607)
S_TEXT_WR: begin fb_we <= 1'b1; fb_waddr <= txt_addr; fb_wdata <= font_byte({cur_char, txt_col_idx}); state <= S_TEXT_NEXT; end
Один такт — одна запись в FB. Ключевая особенность: здесь нет RMW. Мы пишем байт целиком (fb_wdata <= font_byte(...)), предполагая, что в этом байте не было ничего важного до нас.
Это законно, потому что:
верхний текст в page 0, нижний — в page 7;
куб в pages 2..5;
страницы не пересекаются → текст не затирает пиксели куба.
Отсюда — 2× ускорение по сравнению с RMW: 1 такт на столбец буквы вместо 3 (READ → WAIT → WRITE).
S_TEXT_NEXT (строки 609–628)
S_TEXT_NEXT: begin if (txt_col_idx == 3'd4) begin txt_col_idx <= 3'd0; if (txt_char_idx == cur_last) begin if (!txt_phase) begin txt_phase <= 1'b1; txt_char_idx <= 3'd0; state <= S_TEXT_WR; end else begin state <= S_DONE; end end else begin txt_char_idx <= txt_char_idx + 3'd1; state <= S_TEXT_WR; end end else begin txt_col_idx <= txt_col_idx + 3'd1; state <= S_TEXT_WR; end end
Вложенные счётчики «по столбцам / по буквам / по строкам»:
Если
txt_col_idx < 4— просто инкрементим столбец, возвращаемся вS_TEXT_WR.-
Если столбец = 4 (последний в букве) — сбрасываем столбец в 0:
если буква не последняя — инкрементим
txt_char_idx, следующая буква;если буква последняя в строке — переключаем
txt_phaseна нижнюю строку (либо уходим вS_DONE, если нижняя уже отрисована).
Итого по тексту: верхняя строка 7 букв × 5 столбцов × 2 такта = 70 тактов; нижняя строка (STATIC = 6 букв × 5 = 60; ANIM = 4 × 5 = 40) — ~40–60 тактов. Суммарно ~110–130 тактов = 2.2–2.6 мкс.
Стадия S_DONE (строки 630–633)
S_DONE: begin ready_o <= 1'b1; state <= S_IDLE; end
Один такт: поднимаем флаг «кадр готов» и возвращаемся в покой. Верхний уровень увидит ready_o = 1 на следующем такте и начнёт PH_FRAME.
Внешний read‑port для I2C TX
Ранее мы уже показали код dual‑port BRAM. Ключевой момент: пока ssd1306_ctrl через raddr_i читает байты фреймбуфера для I2C‑передачи, мы в scene_renderer ничего не делаем — FSM сидит в S_IDLE с ready_o = 1. Следующий start_i придёт только после того, как ssd1306_ctrl завершит передачу (PH_FRAMEW → PH_ANEXT → PH_RENDER → новый start_i).
В теории между S_DONE и следующим S_IDLE → start_i FSM могла бы уже начать перерисовывать кадр N+1 в тот же FB — но это бы затёрло байты, которые ещё не переданы по I2C. Поэтому ssd1306_ctrl сериализует: сначала TX завершается, потом новый start_i в рендер.
Ресурсы и характеристики модуля
Реалистичная оценка утилизации scene_renderer на Cyclone IV EP4CE10:
Ресурс |
Использовано |
Комментарий |
|---|---|---|
Logic Elements (LE) |
~900 |
FSM + комб‑хелперы + font/sin/vertex LUT |
Registers |
~150 |
|
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‑адрес 0×3C, что 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 end
PH_INIT — одноцикловое состояние: выставляет bw_start = 1 (на один такт благодаря pre‑assign bw_start <= 0) и переходит в PH_INITW.
PH_INITW — ожидание завершения:
каждый такт обновляет
progress_oтекущимsrc_idx(для визуализации на 7-сегменте);-
по
bw_done:если ошибка (NACK / arb‑lost) →
PH_ERR,inited = 0;иначе →
PH_RENDER,inited = 1.
Пара PH_RENDER / PH_RENDW (строки 280–288)
PH_RENDER: begin scene_start <= 1'b1; phase <= PH_RENDW; end PH_RENDW: begin if (scene_ready) phase <= PH_FRAME; end
Симметрично init: PH_RENDER выставляет scene_start на 1 такт (pre‑assign обеспечивает), PH_RENDW ждёт scene_ready. ~41 мкс рендера по факту (см. 6.17).
Ошибки рендера в принципе невозможны — FSM scene_renderer детерминирован, всегда дойдёт до S_DONE.
Пара PH_FRAME / PH_FRAMEW (строки 291–312)
PH_FRAME: begin bw_start <= 1'b1; bw_byte_count <= {5'd0, DATA_LEN}; // 16-bit = 0x0401 = 1025 phase <= PH_FRAMEW; end PH_FRAMEW: begin progress_o <= src_idx; if (bw_done) begin if (bw_error) begin err_o <= 1'b1; anim_run <= 1'b0; phase <= PH_ERR; end else if (anim_run && !anim_stop_req) phase <= PH_ANEXT; else begin done_o <= 1'b1; anim_run <= 1'b0; phase <= PH_OK; end end end
PH_FRAME — одноцикловый pulse bw_start + установка bw_byte_count = 1025 (control‑byte + 1024 пикселя).
PH_FRAMEW — трёхветочный exit:
Ошибка (
bw_error) —PH_ERR,anim_run=0. Если у нас был цикл анимации, он тоже прерывается.Нормально + анимация + не‑стоп —
PH_ANEXT(инкремент угла, новый кадр).Нормально + не‑анимация или стоп‑запрос —
PH_OKс pulse«омdone_o.»
Стадия PH_ANEXT (строки 315–318)
PH_ANEXT: begin angle <= angle + 6'd1; phase <= PH_RENDER; end
Инкремент angle по модулю 64 (auto‑wrap 6-битного сложения). Полный оборот за 64 кадра = 64 × 46 мс ≈ 3 секунды. Плавно и приятно для глаза.
Стадия PH_OK (строки 321–337)
PH_OK: begin if (start_i) begin mode <= 1'b0; anim_run <= 1'b0; angle <= 6'd0; err_o <= 1'b0; phase <= inited ? PH_RENDER : PH_DELAY; if (!inited) delay_cnt <= DELAY_CYCLES; end else if (anim_i) begin mode <= 1'b1; anim_run <= 1'b1; angle <= 6'd0; err_o <= 1'b0; phase <= inited ? PH_RENDER : PH_DELAY; if (!inited) delay_cnt <= DELAY_CYCLES; end end
Полный аналог PH_IDLE, но с одним ключевым отличием: не сбрасывает inited. Благодаря этому:
Если
inited = 1(чип уже инициализирован) — сразу идём вPH_RENDER, экономя 100 мс + 32 байта I2C (~1.5 мс) = ~101.5 мс per повторный запуск.Если
inited = 0(ещё не init или послеPH_ERR) — полный цикл черезPH_DELAY→PH_INIT.
Это делает UX приятным: после первого запуска каждое следующее нажатие кнопки даёт мгновенную реакцию.
Стадия PH_ERR (строки 339–349)
PH_ERR: begin if (start_i || anim_i) begin inited <= 1'b0; mode <= anim_i ? 1'b1 : 1'b0; anim_run <= anim_i; angle <= 6'd0; err_o <= 1'b0; delay_cnt <= DELAY_CYCLES; phase <= PH_DELAY; end end
Терминальное состояние после ошибки. При любом нажатии кнопки:
всегда сбрасывает
inited = 0и идёт черезPH_DELAY → PH_INIT— полный цикл инициализации (предположение: дисплей мог быть физически отключён / сбросился / сбоил, нужна перенастройка);выбирает режим: static или anim в зависимости от нажатой кнопки.
Это важная семантика для восстановления: ошибка NACK почти наверняка означает, что чип либо отключён, либо потерял синхронизацию. Просто перезапуск без init бесполезен — пойдёт ещё один NACK.
Подключение i2c_burst_writer (строки 359–379)
i2c_burst_writer #( .CNT_W (16) ) u_burst ( .clk_i (clk_i), .rstn_i (rstn_i), .start_i (bw_start), .slave_addr_i (I2C_ADDR), .byte_count_i (bw_byte_count), .busy_o (bw_busy), .done_o (bw_done), .error_o (bw_error), .data_req_o (bw_data_req), .data_i (bw_data), .data_valid_i (bw_data_req), // комбинаторная связка (см. 4.14) .cmd_valid_o (cmd_valid_o), .cmd_o (cmd_o), .din_o (din_o), .ready_i (ready_i), .rx_ack_i (rx_ack_i), .arb_lost_i (arb_lost_i) );
Ключевые моменты:
CNT_W = 16— с запасом на 65 535 байт. Достаточно и для init (32), и для frame (1025), и для любых будущих расширений.data_valid_i = bw_data_req— комбинаторное равенство. Источник всегда валиден мгновенно (init‑ROM — комб‑функция; scene_rdata — BRAM с latency=1, но интервалы между запросами в 4500 раз больше).cmd_valid_o/cmd_o/din_o— выходы burst‑writer«а пробрасываются прозрачно наверх, черезssd1306_ctrlна внешнийi2c_master_core. Никаких дополнительных мультиплексоров.»arb_lost_i— проксируется от ядра к burst‑writer«у; аarb_lost_clear_oобратно, изssd1306_ctrlк ядру.»
Ресурсы и характеристики модуля
Реалистичная оценка утилизации ssd1306_ctrl на Cyclone IV EP4CE10 (без u_scene и u_burst, только собственная логика):
Ресурс |
Использовано |
Комментарий |
|---|---|---|
Logic Elements (LE) |
~150 |
FSM + init ROM + data mux + src_idx |
Registers |
~50 |
|
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/A
seg_d5, seg_d4 — пустые, ничего не отображают.
seg_d3 — старшие 3 бита
progress_o(0..7), дополнены нулём до 4 бит.seg_d2 — средние 4 бита.
seg_d1 — младшие 4 бита.
-
seg_d0 — статус‑цифра по приоритету:
ssd_err→'E'(горит всё время ошибки);ssd_animating→'A'(анимация активна);ssd_done_latch→'0'(успех, защёлка);иначе →
'-'(idle или busy без завершения).
Пример отображения в середине передачи frame:
Событие |
Экран |
|---|---|
idle |
|
start передачи frame |
|
передано 0×0200 байт |
|
передано 0×0400 = 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 до 65 535 и перекатывается. Частота —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 (0×50) |
|
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 (адрес 0×50). SSD1306 на 0×3C не конфликтует с ним по адресу — но физически шина одна, 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 |
адрес 0×3D (правая конфигурация 0×78/0×7A) |
проверить |
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 может быть 0×3C или 0×3D в зависимости от пина 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‑карта.
Несколько дисплеев. Разделить по нескольким адресам (0×3C, 0×3D) или использовать 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% при первом пополнении.

Strijar
Спасибо - интересно! А точно надо было в одну статью все это собирать? Тут как минимум штуки на 3 (;
andreyzaostrovnykh Автор
Благодарствую! :) Не хотелось оттягивать все и скорее хотелось прыгнуть в Zynq :) следующая статья будет про это