Продолжим совершенствование нашего 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). Но:

  1. Тема проекта — именно I2C (это лабораторный пример для I2C‑мастера);

  2. Нам достаточно ~8 fps, чтобы анимация воспринималась плавно.

  3. 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 резисторы. Это нужно для двух вещей:

  1. Multi‑master / multi‑slave — несколько устройств могут одновременно владеть шиной, и если они одновременно «хотят» в 1, конфликта не возникнет; а если один тянет в 0 — он «побеждает» (wired‑AND). Это же свойство используется для арбитража в multi‑master‑системах.

  2. 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 (или не занят)

0x3C

0x78

0x79

VCC

0x3D

0x7A

0x7B

В нашем проекте используем 0x3C (на плате будет подписано как 0x78):

// quartus_ssd1306/src/ssd1306_ctrl.v

localparam [6:0] SSD_ADDR = 7'h3C;

...

bw_slave_addr <= SSD_ADDR;

Внутри i2c_burst_writer этот 7-битный адрес автоматически дополняется до 8-битного ADDR‑байта:

// rtl/i2c_burst_writer.v

wire [7:0] addr_byte = {slave_addr_i, 1'b0};   // W=0 — только запись

READ‑операции на SSD1306 тоже поддерживаются (чтение статуса, чтение GDDRAM), но в нашем проекте не используются — модуль работает строго «вслепую», только WRITE.

Полезный диагностический приём: до полной инициализации послать на шину транзакцию START + ADDR(0x3C, W) + STOP без байтов данных. Если дисплей присутствует и корректно запитан, он ответит ACK на ADDR‑байт; если нет — NACK. Этот механизм легко встроить в верхний уровень:

// псевдокод
bw_byte_count <= 16'd0;   // 0 data-байт
bw_start      <= 1'b1;
// ждём done_o:
if (bw_error) begin
    // нет SSD1306 на шине: не переходим в PH_INIT,
    // зажигаем LED ошибки
end

В текущем ssd1306_ctrl probe‑шаг не реализован — мы сразу уходим в PH_INIT. Это осознанный trade‑off: в нашем лабораторном применении дисплей всегда подключен, а на NACK первой init‑команды мы всё равно выставим err_o.

После того как по шине прошёл байт адреса и slave ответил ACK, все последующие байты в этой транзакции попадают в SSD1306. Но чип должен как‑то понять: это команда (которую нужно декодировать и исполнить) или данные для GDDRAM (которые нужно записать в видеопамять)?

Решение — control‑byte, специальный формат первого байта после ADDR. Его разметка:

Два значащих бита:

  • D/C# (Data / Command) — 0 → дальнейший поток интерпретируется как команды; 1 → как данные для GDDRAM.

  • Co (Continuation) — 0 → режим «до конца транзакции»; 1 → режим «один следующий байт, потом снова control‑byte».

Пересечение этих двух битов даёт 4 комбинации:

Co

D/C#

hex

Смысл

0

0

0×00

Stream‑commands: все байты до STOP — команды

0

1

0×40

Stream‑data: все байты до STOP — пиксели (D[7:0] записываются в GDDRAM по текущему адресу)

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 просто:

  1. Задаёт slave_addr_i = 0x3C;

  2. Задаёт byte_count_i = INIT_LEN или 1025;

  3. В качестве источника данных выдаёт либо 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 это занимает три такта:

  1. RD: поставить raddr = fb_addr, защёлкнуть q через такт.

  2. MOD: скомбинировать q | bit_mask (или с AND).

  3. 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.

Инициализация строится по принципу «сначала выключить панель» — «всё настроить» — «включить». Это важно по двум причинам:

  1. Многие регистры при изменении «на лету» вызывают визуальные артефакты: моргания, полосы, скачки яркости. Если экран выключен — артефакты никому не видны.

  2. Команды «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 — задаёт внутреннюю частоту тактирования драйвера дисплея. 0x80 = divider=1, частота осциллятора — номинальная (~540 кГц). На большее не стоит — падает время стабилизации пикселя, на меньшее — начинает виднеться мерцание

0xA8

0×3F

Multiplex Ratio — количество строк, которые реально подключены к COM‑драйверам. 0x3F = 63 = 64 строки (адресуемые от 0 до 63). Для 128×32 модулей было бы 0x1F = 31

0xD3

0×00

Display Offset — сдвиг первой строки по вертикали. 0×00 = без сдвига. Используется, если панель физически смонтирована со смещением (в наших модулях — нет)

0×40

Display Start Line = 0. Определяет, какая строка GDDRAM соответствует верхней строке экрана. Изменением этого регистра можно делать «программный вертикальный скроллинг», нам это не нужно

0xDA

0×12

COM Pins Hardware Config — указывает чипу, как физически разведены COM‑линии. Для 128×64-панели с типовой «альтернативной» разводкой значение = 0x12; для 128×32 было бы 0x02. Ошибка здесь — главная причина артефакта «каждая вторая строка — чёрная»

Группа C. Питание OLED — charge pump

Эта команда — та самая «невидимая бомба замедленного действия», которую постоянно забывают разработчики‑новички. Все шаги init прошли, NACK«ов нет, 0xAF отправлено — а экран чёрный. Проверить первым делом, что 0×8D, 0×14 реально есть в потоке, и что оно идёт до 0xAF.»

Команда

Аргумент

Назначение

0×8D

0×14

Charge Pump Enable. 0x14 — включить pump; 0x10 — выключить. Без этой команды внутреннее высокое напряжение (~7.5 В) не поднимается, и при любой команде включения панели экран останется тёмным.

Группа D. Адресация GDDRAM

Это то, что мы подробно обсудили в одном из разделов выше. Здесь мы фиксируем режим адресации в horizontal и задаём окно на весь экран.

Команда

Аргумент

Назначение

0×20

0×00

Memory Addressing Mode = horizontal (см. 2.4.2)

0×21

0×00, 0×7F

Column Address range = [0, 127] — весь экран по горизонтали

0×22

0×00, 0×07

Page Address range = [0, 7] — все 8 страниц

После выполнения этих трёх команд внутренний курсор устанавливается в (col=0, page=0), и мы можем сразу начинать слать frame‑burst.

Группа E. Ориентация и внешний вид

Эти команды определяют геометрию изображения относительно физических координат панели, яркость и качество свечения.

Команда

Аргумент

Назначение

0xA1

Segment Remap: col 127 ↔ SEG 0. Зеркалит картинку по горизонтали. Нужна, потому что на нашем модуле COG‑чип развёрнут «шлейфом вниз», и без этого remap«а текст выглядел бы зеркальным»

0xC8

COM Scan Direction: COM[N-1] → COM[0]. Зеркалит по вертикали. По той же физической причине, что и 0xA1

0×81

0xCF

Contrast Control = 0xCF = 207 / 255 ≈ 81%. Прямая регулировка тока через OLED‑ячейки. Выше — ярче и чуть быстрее выгорание, ниже — тусклее и бережнее

0xD9

0xF1

Pre‑charge Period: phase1 = 1, phase2 = 15. Определяет, сколько тактов DCLK подаются «подзарядные» импульсы перед основным свечением. 0xF1 — заводская рекомендация Solomon для charge pump«а»

0xDB

0×40

VCOMH Deselect Level0.77 × VCC. Уровень на невыбранных строках, влияет на контраст и время релаксации

0xA4

Display From RAM (не «все пиксели принудительно вкл.»). Парная к 0xA5 (диагностическая «все пиксели вкл.», используется для теста панели)

0xA6

Normal Display (не инвертировать). Парная к 0xA7 (инверсия 0↔1)

Группа F. Включение

Команда

Аргумент

Назначение

0xAF

Display ON — включает драйверы COM/SEG и даёт charge pump«у начать реальную работу. Экран „оживает“ именно на этой команде»

Итоговая таблица (в порядке выполнения)

Точно в этом порядке байты зашиты в init_rom в ssd1306_ctrl.v (индексы src_idx = 0..31). Первый байт — не команда SSD1306, а control‑byte, признак «все следующие байты — команды»:

idx

Байт

Команда / аргумент

Группа

0

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 должны быть валидны)

Правила:

  1. Когда ядро свободно, оно держит ready_o = 1

  2. Внешний контроллер (burst‑writer) поднимает cmd_valid_i = 1 только если видит ready_o = 1. Одновременно выставляет cmd_i и (если команда — WRITE) din_i.

  3. Ядро защёлкивает команду, опускает ready_o = 0, начинает выполнение. Процесс длится от десятков до сотен системных тактов в зависимости от команды и clock‑stretching«а.»

  4. По завершении ядро снова поднимает ready_o = 1. В этом же такте rx_ack_o / dout_o валидны (для WRITE/READ соответственно).

  5. Внешний контроллер может либо сразу подать следующую команду (удерживая 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«а. Мы этого не делаем по трём причинам:»

  1. Переиспользуемость. Не все устройства хотят burst: у EEPROM Page Write — строго 32 байта на page (далее нужен новый ADDR‑фрейм). BME280 — write 1 регистр, read 6 байт (smart retry). Если вложить «burst на N байт» внутрь ядра, под разные устройства придётся параметризовать, добавлять режимы и так далее — сложность лавинообразно растёт.

  2. Тестируемость. С ядром как «1 команда = 1 atom» можно гонять микро‑тесты по одной команде и покрывать всю матрицу ACK/NACK/arb‑lost. С burst‑ядром тест‑векторы исчисляются миллионами комбинаций.

  3. Сложность FSM. Разделение «bit/byte‑FSM» и «burst‑FSM» даёт два маленьких автомата (~100 LE каждый) вместо одного большого (~500 LE) с большим радиусом разработки. Это прямо влияет на timing closure на Cyclone IV.

Этот выбор — классический пример принципа Single Responsibility из программирования, перенесенного в RTL.

Интерфейс burst‑writer«а»

Теперь сосредоточимся на проработке интерфейса для будущего burst‑интерфейса:

Модуль параметризуется единственным параметром:

Параметр

Значение

Назначение

CNT_W

16 по умолчанию

ширина счётчика byte_count_i; 16 бит ⇒ максимум 65 535 data‑байт в одной транзакции

Теперь рассмотрим один за другим сигналы модуля. 

Служебные сигналы

clk_i — системный тактовый сигнал (50 МГц в нашем проекте). Все регистры модуля — positive‑edge. Никаких отдельных clock‑domain«ов внутри burst‑writer»а нет: он работает в той же частоте, что и i2c_master_core, поэтому между ними нет ни CDC‑проблем, ни FIFO‑буферов.

rstn_i — асинхронный active‑low reset. Устанавливает FSM в S_IDLE, счётчики в 0, cmd_valid_o = 0, done_o = 0, error_o = 0. Синхронизация reset«а — задача top‑level (в нашем проекте мы полагаемся на то, что кнопка сброса N13 проходит через встроенный в Cyclone IV debouncer + power‑on reset контроллер и стабилизируется задолго до первой активности клока).»

Сторона управления (control‑side)

start_i — импульс запуска транзакции. Ожидается как один такт 1, но модуль устойчив к удлинённому импульсу: захват slave_addr_i и byte_count_i происходит в единственном переходе из S_IDLE, повторные высокие уровни игнорируются, пока FSM не вернётся в S_IDLE.

Типичный паттерн на стороне ssd1306_ctrl:

PH_INIT: begin
    bw_start      <= 1'b1;          // assert на 1 такт
    bw_byte_count <= {10'd0, INIT_LEN};
    phase         <= PH_INITW;
end
  
// ... в следующем такте ssd1306_ctrl уже в PH_INITW,
// а bw_start автоматически сброшен:
//   bw_start <= 1'b0;  в начале always-блока

slave_addr_i[6:0] — 7-битный I2C‑адрес slave‑устройства. Модуль сам сформирует полный 8-битный стартовый байт: addr_byte = {slave_addr_i, 1'b0} (последний бит = 0 для операции записи). Поддержка операций READ в burst‑writer«е не реализована — только WRITE. Если нужен READ‑burst (для EEPROM Random Read), делают отдельный модуль.»

Значение slave_addr_i защёлкивается одновременно с start_i и остаётся стабильным внутри модуля до завершения транзакции. После защёлкивания сигнал на входе может меняться — это не повлияет на текущую передачу.

byte_count_i[CNT_W-1:0] — число data‑байт, которое нужно отправить после байта адреса. Поясняю: полная I2C‑транзакция в нашем случае выглядит как START + ADDR + DATA×N + STOP, где N = byte_count_i. Сам байт адреса в счётчик не входит.

Граничные случаи:

  • byte_count_i = 0 — транзакция START + ADDR + STOP. Полезно для «прощупывания» адреса (I2C device discovery): если адрес отвечает ACK, «error_o = 0», иначе «error_o = 1».

  • byte_count_i = INIT_LEN = 32 — init SSD1306.

  • byte_count_i = DATA_LEN = 1025 — передача фреймбуфера (1 control‑байт + 1024 пикселя).

Значение защёлкивается в cnt одновременно со start_i и декрементируется на каждом успешно переданном data‑байте.

busy_o — уровень‑индикатор активности: 1, пока state != S_IDLE. Используется верхним уровнем для того, чтобы:

  • не ассертить start_i повторно, пока предыдущая транзакция идёт; 

  • зажечь «busy‑LED» (в нашем проекте — LED[0]);

  • проверки «можно ли отпустить шину» при реакции на внешние события.

Однократная проверка !busy_o перед новой командой + однотактовый start_i — канонический паттерн использования.

done_o — одноцикловый импульс завершения. Assert‑ится ровно на 1 такт при переходе из S_DONE обратно в S_IDLE. Это важно подчеркнуть: done_o — не уровень. Если верхний уровень пропустит такт (например, не успеет семплировать в синхронном регистре) — импульс будет потерян.

В ssd1306_ctrl мы ловим его одной строкой:

PH_INITW: begin
    if (bw_done) begin          // захватит импульс в любом случае,
        ...                     // т. к. проверяется каждый такт
    end
end

Альтернативный вариант конечно можно реализовать, то есть держать done‑защёлку, но это избыточно, когда получатель — тоже синхронный FSM.

error_o — уровень‑флаг ошибки, валиден одновременно с done_o. Причины для установки:

  • slave ответил NACK на байт адреса (rx_ack_i = 1 после ADDR_WAIT);

  • slave ответил NACK на любой data‑байт;

  • произошла потеря арбитража (arb_lost_i = 1) в момент активной транзакции.

Флаг сохраняется вплоть до следующего start_i (тогда в S_IDLE он очищается: error_o <= 1'b0). Это значит, что верхний уровень может сэмплировать error_o даже несколько тактов после done_o, если ему так удобнее (например, для зажигания LED через одну дополнительную защелку).

Поток данных (data‑side)

Интерфейс преднамеренно асинхронен — burst‑writer не знает природы источника данных. Это может быть:

  • case‑функция (ROM) — как наш init_rom в ssd1306_ctrl;

  • dual‑port BRAM — как scene_renderer для пиксельных данных;

  • FIFO, заполняемый с UART/SPI;

  • потоковый генератор (counter, PRBS);

  • даже внешний AXI‑stream — через небольшой адаптер.

Протокол обмена — простая req/valid‑рукопожатие:

data_req_o уровень‑запрос очередного data‑байта. Комбинаторно равен (state == S_DATA_REQ). То есть:

  • поднимается ровно в тот момент, когда FSM входит в S_DATA_REQ;

  • держится в 1, пока не придёт data_valid_i = 1;

  • в том же такте, когда data_valid_i = 1, FSM переходит в S_DATA_CMD, и data_req_o автоматически падает.

Таким образом, источник обязан выдать данные в ответ на req. Если источник «не знает» данных (например, FIFO пустой) — он может держать data_valid_i = 0 сколь угодно долго, burst‑writer будет терпеливо ждать, не снимая data_req_o. Это обеспечивает совместимость с медленными источниками без риска потерять байты.

data_i[7:0] — сам байт данных. Должен быть валиден в любом такте, когда источник поднимает data_valid_i = 1. Для комбинаторных источников часто удобнее вообще не различать req и valid — подавать данные постоянно (они меняются синхронно с data_req_o).

data_valid_i — квитанция «байт валиден на data_i».

Тактика использования зависит от источника:

Тип источника

Связь с data_req_o

Комбинаторный (ROM, мукс)

assign data_valid_i = data_req_o;

Регистровый 1-такт (BRAM sync)

Данные уже готовы к моменту req (в нашем проекте обеспечено тем, что BRAM успевает обновиться задолго до следующего req); тогда тоже data_valid_i = data_req_o. Альтернативно — регистровая задержка always @(posedge clk) data_valid_i <= data_req_o; — тогда burst‑writer подождёт 1 такт.

FIFO, переменная латентность

data_valid_i = !fifo_empty, и после чтения — один такт data_valid_i = 1 на передний фронт data_req_o.

В нашем проекте реализован первый вариант: data_valid_i = bw_data_req. Это экономит 1 такт на каждом байте (незначительно, но даром).

Интерфейс к ядру i2c_master_core

Эти сигналы — прямой прокси ядра. Burst‑writer не добавляет к ним никаких преобразований, только вставляет свои значения в нужное время.

cmd_valid_o — уровень‑защёлка, говорящий ядру: «на входе cmd_i/din_i валидная команда, забирай». Двухфазное рукопожатие:

  • burst‑writer поднимает cmd_valid_o = 1, когда ready_i = 1 (ядро готово);

  • держит в 1 до детекции ready_i = 0 (ядро приняло команду);

  • в том же такте сбрасывает cmd_valid_o и уходит в WAIT‑состояние.

Важно: одновременно с cmd_valid_o должны быть валидны cmd_o и din_o. Поэтому мы присваиваем их в тех же тактах и в том же состоянии, что и cmd_valid_o.

cmd_o[2:0] — код I2C‑команды, подаваемый ядру:

Код

Мнемоника

Когда выдаём burst‑writer«ом»

1

CMD_START

в S_START_CMD, один раз в начале транзакции

2

CMD_WRITE

в S_ADDR_CMD (адрес) и в S_DATA_CMD (каждый байт данных)

4

CMD_STOP

в S_STOP_CMD, один раз в конце (или при NACK)

din_o[7:0] — байт для передачи ядру по I2C. В разных состояниях подаются разные источники:

  • S_ADDR_CMD{slave_addr_i, 1'b0} (сохранено в addr_byte);

  • S_DATA_CMDdata_i (защёлкнуто в din_o при рукопожатии с источником).

Для CMD_START, CMD_STOP значение din_o ядру безразлично — оно игнорируется.

ready_i — уровень‑готовность ядра принять следующую команду. Семантика:

  • ready_i = 1 в S_IDLE ядра — ждёт новую команду;

  • ready_i = 0 — ядро занято (идёт START, передача бита, clock‑stretching от slave«а, и так далее);»

  • ready_i снова поднимается в 1 на такте, следующем после завершения команды — в этот момент можно одновременно читать rx_ack_o / dout_o и ассертить следующую команду.

Использование в burst‑writer«е — в двух местах:»

  • _CMD-состояния: условие для ассерта cmd_valid_o — «ждать, пока ready_i = 1»;

  • _WAIT-состояния: ждать, пока ready_i снова не станет 1 (значит команда выполнена).

rx_ack_i— ACK/NACK от slave‑устройства после только что переданного WRITE‑байта. Значение:

  • rx_ack_i = 0 — ACK, всё хорошо, можно передавать следующий байт.

  • rx_ack_i = 1 — NACK, slave не подтвердил приём. Burst‑writer интерпретирует это как ошибку: ставит nack_flag, прерывает передачу и отправляет STOP, чтобы корректно освободить шину.

rx_ack_i валиден только в такте, когда ready_i поднимается после WRITE‑команды. В другие моменты его значение — «недокументи­рованный мусор», его не нужно смотреть.

arb_lost_i — флаг потери арбитража от ядра. Sticky‑уровень (удерживается до внешнего arb_lost_clear_i ядра). Активируется, если ядро обнаружило, что какой‑то другой мастер «перехватил» шину: ядро ждало SDA = 1 (отпустило линию), но физически прочитало SDA = 0 при SCL = 1 — значит, кто‑то другой тянет линию к земле.

В burst‑writer«е обработка arb‑lost имеет высший приоритет — это единственное условие, которое может прервать передачу посреди байта:»

if (arb_lost_i && busy_o) begin
    cmd_valid_o <= 1'b0;     // перестать подавать команды ядру
    nack_flag   <= 1'b1;     // выставить error_o в S_DONE
    state       <= S_DONE;   // аварийный выход
end

STOP не отправляется — шина у другого мастера, любая попытка записи только создаст коллизию. Ядро само отпускает SDA/SCL, мы молча сдаёмся, сигналим error_o, и ждём от верхнего уровня команды arb_lost_clear_i + повторного start_i для retry.

Сводная таблица:

Сигнал

Dir

Ширина

Тип

Назначение

clk_i

in

1

clock

системный такт (50 МГц)

rstn_i

in

1

async reset

active‑low сброс

start_i

in

1

pulse

запуск новой I2C‑транзакции

slave_addr_i

in

7

level

7-битный адрес slave (W=0 добавляется сам)

byte_count_i

in

CNT_W

level

сколько data‑байт после адреса

busy_o

out

1

level

транзакция идёт (state ≠ IDLE)

done_o

out

1

1-cycle pulse

завершение транзакции

error_o

out

1

level

NACK или arb‑lost, валиден c done_o

data_req_o

out

1

level

запрос очередного data‑байта

data_i

in

8

level

сам data‑байт от источника

data_valid_i

in

1

level

квитанция «data_i валиден»

cmd_valid_o

out

1

level

команда для ядра валидна

cmd_o

out

3

level

код команды (START/WRITE/STOP)

din_o

out

8

level

байт для передачи по I2C

ready_i

in

1

level

ядро готово принять/завершило команду

rx_ack_i

in

1

level

ACK(0)/NACK(1) от slave (валиден с rising ready_i)

arb_lost_i

in

1

level

потеря арбитража на шине

Сам модуль не знает, откуда берутся данные — это может быть ROM, BRAM, FIFO или генератор. Это разделение ответственности — основная абстракция проекта: burst‑writer — «менеджер транзакции», а что передавать — забота верхнего уровня.

Модуль i2c_burst_writer — автомат пакетной записи

Теперь детально разберем исходный код rtl/i2c_burst_writer.v с построчным объяснением. Модуль сравнительно небольшой (~200 строк Verilog‑кода), но именно он превращает «атомарное» I2C‑ядро в полноценный burst‑transport, и именно он задаёт фреймворк, под который пишутся все device‑контроллеры (SSD1306, а в будущем — EEPROM, BME280 и др.).

Место модуля между ssd1306_ctrl (верхний уровень) и i2c_master_core (ядро шины):

Высоуровневый контракт: модуль принимает (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. При его обнаружении:

  1. Защёлкивает 8-битный addr_byte: 7-битный адрес сдвигается на 1 влево, младший бит = 0 (W — write).

  2. Защёлкивает cnt из byte_count_i. С этого момента значение внутри модуля стабильно, даже если верхний уровень изменит byte_count_i.

  3. Сбрасывает nack_flag и error_o — предыдущая ошибка забывается, новая транзакция начинается с «чистого листа».

  4. Переходит в 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‑байта:

  1. 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).

  2. S_DATA_CMD: тот же шаблон «cmd+valid», что и для START/ADDR. cmd_o = MD_WRITE, din_o уже загружен. Когда ядро приняло — переходим в S_DATA_WAIT.

  3. S_DATA_WAIT: ядро работает, физически сдвигает байт на SDA. Когда закончит ready_i = 1 снова):
    декремент cnt — без -= (Verilog не знает), явным сложением с -1. Выражение {{(CNT_W-1){1'b0}}, 1'b1} — это 1 в ширине CNT_W бит (для 16-бит это 16'h0001).
    — проверка rx_ack_i: NACK → nack_flag = 1, S_STOP_CMD;
    — если после декремента cnt стал 0 (то есть только что отправлен последний data‑байт) — S_STOP_CMD;
    — иначе — возврат в S_DATA_REQ за следующим байтом.

Тонкость сравнения на «последний байт». Сравнение делается до декремента — сравниваем с 1, а не 0 (cnt == 1 означает «только что отправили 1-й из оставшихся — он был последним»). В коде это выглядит как cnt == {{(CNT_W-1){1'b0}}, 1'b1} — тот же 1 в CNT_W бит. cnt <= cnt - 1 в том же такте просто отражает в регистре новое состояние — на FSM‑переход это не влияет, так как переход рассчитан до новой записи.

Число тактов на один байт — складывается из:

  • 1 такт на S_DATA_REQ (если источник мгновенно валиден);

  • ~2 такта на S_DATA_CMD (peak ready, опустить valid);

  • ~2250 тактов на S_DATA_WAIT (ядро гонит 9 бит по SCL = 1125 мкс ≈ 45 мкс).

Итого ~45 мкс на байт — ограничение именно физической частоты SCL. Логика burst‑writer«а тут занимает всего ~3–5 тактов (0.1 мкс) — пренебрежимо мало.»

Пара S_STOP_CMD / S_STOP_WAIT

Финальное STOP‑условие и выход:

S_STOP_CMD: begin
    cmd_o <= CMD_STOP;
    if (ready_i)
        cmd_valid_o <= 1'b1;
    if (cmd_valid_o && !ready_i) begin
        cmd_valid_o <= 1'b0;
        state       <= S_STOP_WAIT;
    end
end

S_STOP_WAIT: begin
    if (ready_i)
        state <= S_DONE;
end

Абсолютно такой же шаблон «cmd+wait», как для START, только с cmd_o = CMD_STOP. Завершает транзакцию, после чего ядро ставит busy_o = 0 на самой шине, cmd_valid_o уже 0, FSM — в S_STOP_WAITдо тех пор, пока ядро снова не освободится (ready_i = 1), после чего уходит в S_DONE.

Состояние S_DONE и default

S_DONE: begin
    done_o  <= 1'b1;
    error_o <= nack_flag;
    state   <= S_IDLE;
end

default: state <= S_IDLE;

В S_DONE модуль:

  • Поднимает done_o = 1. В следующем же такте pre‑assign (done_o <= 1'b0 в начале always‑блока) вернёт его в 0. Так получается одноцикловый импульс.

  • Переносит nack_flag в error_o. error_o — level‑флаг, удерживается до следующего start_i (где он очистится в S_IDLE).

  • Возвращается в S_IDLE. default: state <= S_IDLE; — страховка от попадания в непредусмотренное значение регистра state (глитч при загрузке FPGA, однобитная SEU‑ошибка в BRAM‑регистре, и пр.). Возврат вS_IDLE — безопасный recovery.

Шаблон двухфазного рукопожатия — диаграмма

Для каждой выдаваемой команды (START / WRITE(addr) / WRITE(data) / STOP) используется один и тот же рисунок сигналов:

Фаза CMD. Мы ассертим cmd_valid_o = 1, когда ready_i = 1 (ядро готово), и деассертим, когда ready_i падает (ядро приняло команду).

Фаза WAIT. Мы молча ждём, когда ready_i снова станет 1 — это сигнал «команда выполнена, результат готов». Для WRITE это одновременно и «ACK/NACK от slave зафиксирован в rx_ack_i».

Почему не if (!ready_i) напрямую? Потому что ready_i = 0 может означать «ядро занято предыдущей командой» — в этом случае ассертить нельзя. Пара ready_i=1 → cmd_valid_o=1, затем cmd_valid_o=1 && ready_i=0 → деассерт однозначно описывает «ядро приняло нашу команду».

Интерфейс источника данных

Связь с источником (ROM / BRAM / FIFO / генератор) — асинхронный req/valid‑протокол:

Для комбинаторного источника (ROM‑функция, процедурный мукс) data_valid_i = data_req_o напрямую — данные появляются в том же такте, когда поднят req.

Для регистрового источника (BRAM с синхронным чтением) допустима задержка 1–2 такта: burst‑writer будет ждать в S_DATA_REQ, пока data_valid_i не поднимется. В нашем проекте между двумя запросами проходит ~45 мкс (время I2C‑байта), поэтому 1-тактовая задержка BRAM«а растворяется в этом интервале.»

В ssd1306_ctrl используется комбинаторная связка:

// из quartus_ssd1306/src/ssd1306_ctrl.v
wire [7:0] init_byte = init_rom(src_idx[5:0]);
wire [7:0] data_byte = (src_idx == 11'd0) ? 8'h40 : scene_rdata;
wire [7:0] bw_data   = (phase == PH_INITW) ? init_byte : data_byte;

assign bw_data_valid = bw_data_req;  // комбинаторное равенство

init_byte — comb‑ROM (функция), scene_rdata — регистровый выход BRAM, но к моменту запроса BRAM уже успел обновиться (за счёт подставления raddr_i = src_idx заблаговременно).

Обработка ошибок — сводка

Три возможных сценария сбоя, как они обрабатываются:

Событие

Момент обнаружения

Обработка

Результат

NACK на адресе

S_ADDR_WAIT, rx_ack_i = 1

nack_flag = 1, переход в S_STOP_CMD (шину надо корректно освободить)

done_o = 1 + error_o = 1 через S_DONE

NACK на данных

S_DATA_WAIT, rx_ack_i = 1

аналогично: nack_flag = 1, S_STOP_CMD

то же

Arb‑lost

любое состояние, arb_lost_i = 1 && busy_o

страж: cmd_valid_o = 0, nack_flag = 1, переход в S_DONE без STOP (шина чужая)

done_o = 1 + error_o = 1

Верхний уровень (ssd1306_ctrl) различает эти три случая по контексту (какая фаза была активна в момент bw_done): если это была PH_INITW — значит сбой на init, если PH_FRAMEW — значит сбой на frame. В любом случае уход в PH_ERR и зажигание LED ошибки. Retry реализован как нажатие кнопки сброса или кнопки «повторить» (см. раздел о top‑level).

Параметризация и масштабирование

i2c_burst_writer #(.CNT_W(16)) u_burst (…);

CNT_W — ширина счётчика байт:

CNT_W

max byte_count_i

Типичное применение

8

255

маленькие slave«ы (AT24C02 EEPROM, 256 байт)»

10

1023

в точности наш frame (1025 > 1023 — уже не влезет, нужно ≥11 или ≥16)

16

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‑культуры.

Что разрешено и из чего мы будем собирать рендер:

Примитив

Что даёт

Наш способ применения

Целочисленная арифметика (+, -, * в signed/unsigned)

синтезируется в adders/subtractors/DSP‑multipliers

вершины куба, Брезенхэм, проекция

Сдвиги (<<, >>)

бесплатная комбинаторика (reroute)

/8, *128 в адресной арифметике

Битовые маски (&, |, ^)

LUT‑логика

RMW‑byte в фреймбуфере

case — lookup‑table

ROM

sin/cos LUT, font ROM, vertex ROM

function в Verilog (чистая комбинаторика)

инлайн ROM / логика

быстрые вычисления без состояний

FSM (state + case)

явный конечный автомат

оркестровка пайплайна рендера

BRAM (reg [7:0] mem [0:N] + sync read)

встроенная dual‑port память

фреймбуфер 1 КиБ

Что не разрешено и требует обходных решений:

Отсутствует

Обходной путь

Целочисленное деление

замена на сдвиг (если /2^k) или на Брезенхэма‑подобные алгоритмы

Плавающая точка

фикс‑пойнт: у нас всё в signed Q1.7 (амплитуда sin/cos = 127)

sin/cos как функция

lookup‑таблица 17 значений для четверть‑волны + симметрии

Динамическое выделение памяти

фиксированные массивы заранее известного размера

Рекурсия

развёрнутые циклы или явный FSM

Неограниченный стек

вся логика stateless или через явные регистры

Ограничение «только железо» порождает несколько сильных архитек­ турных принципов:

  1. Fixed‑function pipeline. Набор стадий рендера определён на этапе синтеза и не может быть переставлен «в runtime». Это значит, что, например, нельзя «в одном кадре сначала нарисовать куб, потом текст; а в другом — сначала текст, потом куб». Порядок жёстко зашит в FSM.

  2. Всё известно заранее. Количество вершин (8), количество рёбер (12), количество букв в верхней строке (7), длина sin‑таблицы (17 значений на четверть) — все это параметры времени синтеза. Никаких «дайте мне массив длины N, где N будет известно в runtime».

  3. Детерминизм. Время от start_i до ready_o = 1 одинаково для каждого кадра (с точностью до параметров, влияющих на Брезенхэма — длины рёбер зависят от угла). Нет кэш‑промахов, сваливаний в обработчик прерываний и пр. Фактический разброс рендера — ±50 тактов на 50 МГц ≈ ±1 мкс.

  4. Прямая наблюдаемость. Любой промежуточный сигнал (вершина после поворота, счётчик пиксельной стороны, индекс буквы) — это физический провод в схеме, который можно вывести на 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 (фреймбуфер)

Причины:

  1. Нам нужен Брезенхэм для рёбер куба — других эффективных способов нет. Значит, процедурный подход не годится в принципе.

  2. У нас есть M9K‑блоки с избытком — архитектурный «бесплатный обед».

  3. Композиция текст+куб+анимация в одном кадре тривиально представима как последовательность проходов.

Архитектурный вывод: фреймбуфер 1 КиБ в dual‑port BRAM — единственный разумный путь. Именно это и реализовано в scene_renderer.v:

// quartus_ssd1306/src/scene_renderer.v
reg  [7:0] fb [0:1023];

always @(posedge clk_i) begin
    if (fb_we)
        fb[fb_waddr] <= fb_wdata;
    rdata_o      <= fb[raddr_i];        // порт B: для I2C
    fb_rdata_rmw <= fb[fb_raddr_rmw];   // порт RMW для рендера (см. ниже)
end

Схема «пишу‑читаю» dual‑port RAM

Один M9K в Cyclone IV может быть сконфигурирован как True Dual‑ Port (два независимых read+write порта) или как Simple Dual‑ Port (один write, один read). Нам хватает Simple‑режима, но с двумя read‑портами на стороне рендера.

5.3.1. Требуемые каналы доступа

Анализируем, какие операции с BRAM нужны в разных фазах:

Фаза

Действие

Порт

Частота доступа

S_CLEAR

пишет 1024 раза 0x00

write

каждый такт

S_ROT_*

не трогает FB (вычисляет вершины в регистрах)

S_EDGE_*

для каждого пикселя: read (старый байт) → modifywrite

read+write

~1 RMW / 3 такта

S_TEXT_*

пишет 8-битный столбец буквы (без RMW, так как page‑aligned)

write

каждый такт

I2C‑передача (параллельно)

читает 1024 раза последовательно

read

раз в ~45 мкс

Выводы:

  • Write‑порт — один, общий для всех фаз рендера.

  • Read‑порты — нужно два независимых:

    • один для RMW‑чтения (fb_rdata_rmw) во время рендера,

    • второй для I2C‑стрима (rdata_o) со стороны ssd1306_ctrl.

Это ровно то, что позволяет M9K в режиме Simple‑Dual‑Port с двумя read‑адресами.

Практический код и инфер

В Cyclone IV Quartus infers BRAM из следующего шаблона:

reg [7:0] fb [0:1023];

reg  [9:0] fb_waddr;
reg  [7:0] fb_wdata;
reg        fb_we;

reg  [9:0] fb_raddr_rmw;
reg  [7:0] fb_rdata_rmw;

always @(posedge clk_i) begin
    if (fb_we)
        fb[fb_waddr] <= fb_wdata;       // write port

    rdata_o      <= fb[raddr_i];         // read port 1 (I2C)
    fb_rdata_rmw <= fb[fb_raddr_rmw];    // read port 2 (RMW)
end

Синтезатор видит:

  • одну запись if (fb_we) fb[…] <= fb_wdata; → соответствует одному write‑порту;

  • два независимых <= fb[addr] без if → два read‑порта.

Результат — один M9K (9 216 бит) с двумя read‑портами и одним write‑портом. Latency read«а — 1 такт: данные по raddr_i появляются в rdata_o через такт. Это ключевой параметр, с которым нужно считаться в FSM рендера.»

Параллельность рендера и I2C

Два read‑порта — это и есть архитектурная основа для параллельной работы рендера и I2C‑передачи:

В теории можно было бы делать рендер во время передачи предыдущего кадра (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 кГц).

Мы этого не делаем по следующим причинам:

  1. Нет пользы. Даже без pipelining мы получаем ~21 fps (46 мс передачи + 38 мкс рендера). Для человеческого глаза разница между 21.0 fps и 21.1 fps отсутствует.

  2. Pipelining стоит +1 M9K. Второй буфер 1 КиБ + логика переключения front/back потребует ~150 LE и 1 M9K. Не катастрофично, но и не оправдано при нулевом видимом эффекте.

  3. Усложнение синхронизации. При pipelining нужно аккуратно отслеживать «какой буфер сейчас передаётся» и «в какой пишет рендерер», чтобы избежать race conditions. Это дополнительные состояния в FSM ssd1306_ctrl.

  4. Меньше ошибок. Сериализованный 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‑ядро). Для наших задач (текст + куб) запас есть огромный.»

Итого

  1. «Без CPU» — архитектурный принцип, а не ограничение. Он заставляет нас разложить задачу на аппаратные примитивы и даёт взамен детерминизм + скорость.

  2. BRAM‑фреймбуфер — единственный разумный способ для композиции сложных сцен; процедурный подход не масштабируется до 12 линий + текста.

  3. Dual‑port M9K позволяет I2C‑стриму и рендеру работать параллельно на уровне BRAM, даже при логической сериализации их FSM.

  4. Sequential pipeline render→TX даёт ~21 fps при минимальной сложности. Double buffering не нужен.

  5. Тайминг‑бюджет 1900 тактов рендера на 2.3M тактов передачи — огромный запас, позволяющий свободно добавлять сцены (ещё текст, ещё объекты, flood‑fill, и тому подобное), пока не превысим ~1M тактов (что маловероятно).

Дальше — часть 6, где мы построчно разберём реализацию scene_renderer.v с таблицами sin/cos, vertex ROM, font ROM, Брезенхэмом и текст‑blit«ом.»

Модуль scene_renderer — фрейм‑рендер в BRAM

Часть 5 обосновала, почему рендер сделан именно как фреймбуфер‑в-BRAM с sequential pipeline. В этой части разберём что именно написано в файле quartus_ssd1306/src/scene_renderer.v построчно — с фрагментами кода и пояснением каждого решения.

Модуль выглядит внушительно (~640 строк Verilog) за счёт больших case‑таблиц ROM«ов (sin, font, vertex, edge), но логики в нём мало: ядро — это ~15 состояний FSM и ~30 регистров. Разложим его на логические блоки.»

Роль модуля и интерфейс

scene_renderer подключается снаружи несколькими потоками сигналов:

Интерфейс модуля из scene_renderer.v, строки 21–32:

module scene_renderer (
    input  wire        clk_i,
    input  wire        rstn_i,

    input  wire        start_i,   // 1-такт импульс запуска рендера
    input  wire        mode_i,    // 0 = static  (нижняя строка "STATIC")
                                  // 1 = animation (нижняя строка "ANIM",
                                  //                и угол берётся из angle_i)
    input  wire [5:0]  angle_i,   // 0..63 ≡ 0..2π, угол поворота куба по Y
    output reg         ready_o,   // 1 ↔ кадр готов, можно начинать I2C TX

    input  wire [9:0]  raddr_i,   // порт чтения из FB для I2C-стрима
    output reg  [7:0]  rdata_o
);

Назначение сигналов:

  • clk_i / rstn_i — стандартные clock/reset. Синхронно с ядром шины (50 МГц).

  • start_i — импульс «начать рендер кадра». FSM из S_IDLE переходит в S_CLEAR, параметры mode_i/angle_i защёлкиваются одновременно.

  • mode_i — статический vs анимационный режим. Влияет на нижний текст (STATIC / ANIM). В static‑режиме angle_i также учитывается — просто верхний уровень держит его постоянным.

  • angle_i — 6-битный угол, 64 шага по окружности (один шаг ≈ 5.625°). Детализация умышленно грубая: рендер 1 + 46 ≈ 46 мс на кадр, а 64 шага дадут ~2.8 s на полный оборот — вполне плавно воспринимается глазом.

  • ready_o — level‑флаг «кадр готов». Устанавливается в 1 в S_DONE, сбрасывается при следующем start_i.

  • raddr_i / rdata_o — независимый read‑порт BRAM для I2C‑передачи. Адрес — линейный 10-бит индекс 0..1023 (см. 2.3.3 про zero‑copy mapping). Данные появляются с задержкой 1 такт (sync BRAM).

Центральная идея модуля: по команде start_i пересобрать весь кадр (128×64 пикселей = 1024 байта) в dual‑port BRAM, потом держать его там, пока приходят raddr_i‑запросы от верхнего уровня.

Параметры и именованные константы

Блок констант в начале модуля (строки 37–46):

localparam signed [7:0] S            = 8'sd12;
localparam [3:0]        NUM_EDGES    = 4'd12;
localparam [6:0]        TOP_COL0     = 7'd43;
localparam [6:0]        BOT_STA_COL0 = 7'd46;
localparam [6:0]        BOT_ANI_COL0 = 7'd52;
localparam [2:0]        TOP_PAGE     = 3'd0;
localparam [2:0]        BOT_PAGE     = 3'd7;
localparam [2:0]        TOP_LEN_M1   = 3'd6;   // 7 chars − 1
localparam [2:0]        STA_LEN_M1   = 3'd5;   // 6 chars − 1
localparam [2:0]        ANI_LEN_M1   = 3'd3;   // 4 chars − 1

Константа

Значение

Смысл

S

signed 12

полуребро куба в координатах модели; то есть куб 24×24×24 в model‑space

NUM_EDGES

12

число рёбер куба (4 сверху + 4 снизу + 4 вертикальных)

TOP_PAGE

0

страница верхнего текста (самая верхняя полоска экрана)

BOT_PAGE

7

страница нижнего текста (самая нижняя полоска)

TOP_COL0

43

стартовый столбец "SSD1306" (7 символов по 6 столбцов = 42 пикс; (128-42)/2 ≈ 43 — центровка)

BOT_STA_COL0

46

стартовый столбец "STATIC" (6 × 6 = 36; (128-36)/2 = 46)

BOT_ANI_COL0

52

стартовый столбец "ANIM" (4 × 6 = 24; (128-24)/2 = 52)

TOP_LEN_M1

6

длина "SSD1306" − 1 (используется как терминатор «всё, все буквы отрисованы»)

STA_LEN_M1

5

длина "STATIC" − 1

ANI_LEN_M1

3

длина "ANIM" − 1

Все эти числа известны на этапе компиляции. Менять текст или размер куба можно простой правкой localparam — FSM ничего не пересчитает «в runtime», но пересинтез займёт обычные ~10 секунд.

Фреймбуфер — dual‑port BRAM (строки 48–65)

reg  [7:0] fb [0:1023];

reg  [9:0] fb_waddr;
reg  [7:0] fb_wdata;
reg        fb_we;

reg  [9:0] fb_raddr_rmw;
reg  [7:0] fb_rdata_rmw;

always @(posedge clk_i) begin
    if (fb_we)
        fb[fb_waddr] <= fb_wdata;
    rdata_o      <= fb[raddr_i];
    fb_rdata_rmw <= fb[fb_raddr_rmw];
end

Это реализация BRAM«а, подробно разобранная в 5.3:»

  • fb — массив 1024×8 бит. Quartus infers его как один M9K‑блок (9 216 бит; мы используем 8 192).

  • Один write‑порт: fb_we/fb_waddr/fb_wdata — управляется FSM рендера.

  • Два read‑порта:

    • rdata_ofb[raddr_i] — для внешнего чтения (I2C TX).

    • fb_rdata_rmwfb[fb_raddr_rmw] — для внутреннего RMW‑чтения (см. стадию S_EDGE_*).

  • Read‑latency у обоих read‑портов — 1 такт: на такте N выставлен адрес, на такте N+1 данные — в регистре.

Вся логика записи и чтения умещается в один always с обычным positive‑edge триггером, без initial‑инициализации (содержимое при загрузке FPGA undefined и обнуляется фазой S_CLEAR).

sin/cos LUT — quarter‑wave symmetry (строки 68–107)

Для поворота куба нужны значения sin и cos. Мы не можем позволить себе CORDIC или ряд Тейлора в железе — сложно и долго. Поэтому используем lookup‑таблицу 17 × 9-бит signed:

function signed [8:0] sin_q;
    input [4:0] q;       // q ∈ 0..16
    case (q)
        5'd0 : sin_q = 9'sd0;    5'd1 : sin_q = 9'sd12;
        5'd2 : sin_q = 9'sd25;   5'd3 : sin_q = 9'sd37;
        5'd4 : sin_q = 9'sd49;   5'd5 : sin_q = 9'sd60;
        5'd6 : sin_q = 9'sd71;   5'd7 : sin_q = 9'sd81;
        5'd8 : sin_q = 9'sd90;   5'd9 : sin_q = 9'sd98;
        5'd10: sin_q = 9'sd106;  5'd11: sin_q = 9'sd112;
        5'd12: sin_q = 9'sd117;  5'd13: sin_q = 9'sd122;
        5'd14: sin_q = 9'sd125;  5'd15: sin_q = 9'sd126;
        5'd16: sin_q = 9'sd127;  default: sin_q = 9'sd0;
    endcase
endfunction

sin_q(q) = round(127 · sin(q · π/32)), для четверти окружности (0 → π/2 в 17 шагов). Q0.7 fixed‑point — максимум ±127 помещается в знаковом 8-битном числе (но для удобства арифметики берём 9-битное signed, чтобы корректно работало -sin_q(qi) у границы −128..127).

Зачем 17 значений, а не все 64? — quarter‑wave symmetry. sin имеет две симметрии:

  1. Относительно π/2: sin(π/2 + x) = sin(π/2 − x) (в рамках одной четверти это «зеркалит» вторую половину первой четверти к первой).

  2. Относительно π: sin(π + x) = −sin(x) (третья и четвёртая четверти — просто минус первой и второй).

Используя эти свойства, из 17 значений можно восстановить всю 64-точечную таблицу. Экономия: ~74% на размере ROM. В терминах Cyclone IV это 17 case‑веток LUT против 64 — разница в ~50 LE.

sin6 — обёртка, которая по полному углу 0..63 достаёт нужное значение с правильным знаком:

function signed [8:0] sin6;
    input [5:0] theta;
    reg   [4:0] qi;
    reg signed [8:0] mag;
    begin
        qi  = theta[4] ? (5'd16 - {1'b0, theta[3:0]})
                       :          {1'b0, theta[3:0]};
        mag = sin_q(qi);
        sin6 = theta[5] ? -mag : mag;
    end
endfunction

Разбор:

  • theta[5] — старший бит: он переключает знак (третья/четвёртая четверти → −mag).

  • theta[4] — средний бит: мы в первой или второй «половине» полупериода. theta[4]=0 — прямой обход (0..15 → qi=0..15); theta[4]=1 — зеркальный (16..31 → qi=16..1).

  • theta[3:0] — младшие 4 бита: индекс внутри четверти 0..15.

  • qi — нормализованный индекс для sin_q, значение 0..16.

cos6 — тривиально через сдвиг фазы на π/2 (= 16 шагов):

function signed [8:0] cos6;
    input [5:0] theta;
    cos6 = sin6(theta + 6'd16);   // cos(θ) = sin(θ + π/2)
endfunction

Вся триада sin_qsin6cos6 — это чистая комбинаторика (synthesizable functions), синтезатор раскрутит их в LUT‑дерево глубиной 2–3 уровня.

Геометрия куба — vertex ROM и edge ROM (строки 110–192)

Куб центрирован в (0, 0, 0) с полуребром S = 12. Координаты каждой из 8 вершин хранятся в трёх ROM«ах vx_rom/vy_rom/vz_rom

function signed [7:0] vx_rom;
    input [2:0] i;
    case (i)
        3'd0: vx_rom = -S;  3'd1: vx_rom =  S;
        3'd2: vx_rom =  S;  3'd3: vx_rom = -S;
        3'd4: vx_rom = -S;  3'd5: vx_rom =  S;
        3'd6: vx_rom =  S;  3'd7: vx_rom = -S;
    endcase
endfunction
// vy_rom, vz_rom аналогично

Суммарно получается таблица:

i

vx

vy

vz

Вершина (в model‑space)

0

−S

−S

−S

верх‑зад‑лево

1

+S

−S

−S

верх‑зад‑право

2

+S

−S

+S

верх‑перед‑право

3

−S

−S

+S

верх‑перед‑лево

4

−S

+S

−S

низ‑зад‑лево

5

+S

+S

−S

низ‑зад‑право

6

+S

+S

+S

низ‑перед‑право

7

−S

+S

+S

низ‑перед‑лево

Соглашение осей: +X — вправо, +Y — вниз (экранное), +Z — на зрителя. Верхняя грань куба — вершины 0..3, нижняя — 4..7; парами «над‑под» идут (0,4), (1,5), (2,6), (3,7).

edge_v0(e)/edge_v1(e) — таблицы, дающие по номеру ребра e ∈ 0..11 два номера вершин v0/v1, которые оно соединяет:

function [2:0] edge_v0;
    input [3:0] e;
    case (e)
        4'd0:  edge_v0 = 3'd0;  4'd1:  edge_v0 = 3'd1;
        ... // всего 12 записей
    endcase
endfunction
          
// edge_v1 аналогично, с парными номерами

Структура 12 рёбер:

e

v0 → v1

Тип ребра

0

0 → 1

верхнее заднее

1

1 → 2

верхнее правое

2

2 → 3

верхнее переднее

3

3 → 0

верхнее левое

4

4 → 5

нижнее заднее

5

5 → 6

нижнее правое

6

6 → 7

нижнее переднее

7

7 → 4

нижнее левое

8

0 → 4

вертикальное зад‑лево

9

1 → 5

вертикальное зад‑право

10

2 → 6

вертикальное перед‑право

11

3 → 7

вертикальное перед‑лево

4 верхних + 4 нижних + 4 вертикальных = 12, как и положено у куба.

Шрифт 5×7 и лексиконы (строки 194–320)

16 глифов, адресуемых по 4-битному char‑номеру:

0:'S'   
1:'D'   
2:'1'   
3:'3'   
4:'0'   
5:'6'  
6:' '   
7:'C'
8:'U'  
9:'B'   
10:'E'  
11:'A'  
12:'I'  
13:'T'  

14:'N'  15:'M'

Каждый глиф — 5 столбцов по 7 бит (биты 0..6; бит 7 = 0, для падинга). Адрес внутри ROM — 7-битный {char[3:0], col[2:0]}, то есть каждому символу отведено 8 столбцов (5 значащих + 3 пустых). Это делается для удобства адресной арифметики: адрес кратен 8, сдвиг на 3 бита = умножение.

function [7:0] font_byte;
    input [6:0] addr;
    case (addr)
        // 'S' — код символа 0
        7'd0:  font_byte = 8'h26; 7'd1:  font_byte = 8'h49;
        7'd2:  font_byte = 8'h49; 7'd3:  font_byte = 8'h49;
        7'd4:  font_byte = 8'h32;
        // 'D' — код символа 1
        7'd8:  font_byte = 8'h7F; 7'd9:  font_byte = 8'h41;
        7'd10: font_byte = 8'h41; 7'd11: font_byte = 8'h41;
        7'd12: font_byte = 8'h3E;
        ...
    endcase
endfunction

Формат одного столбца: little‑endian по вертикали — биты 0..6 соответствуют строкам сверху вниз (бит 0 — верхний пиксель). Это совпадает с раскладкой GDDRAM (см. 2.3.2), поэтому каждый байт glyph«а копируется в FB без битового reverse

Визуализация буквы 'S' (5 столбцов × 7 строк):

Аналогично и для остальных 15 символов.

Три функции‑ROM отображают позицию в строке на код символа:

function [3:0] top_char;        // "SSD1306"
    input [2:0] i;
    case (i)
        3'd0: top_char = 4'd0;   // S
        3'd1: top_char = 4'd0;   // S
        3'd2: top_char = 4'd1;   // D
        3'd3: top_char = 4'd2;   // 1
        3'd4: top_char = 4'd3;   // 3
        3'd5: top_char = 4'd4;   // 0
        3'd6: top_char = 4'd5;   // 6
    endcase
endfunction

function [3:0] bot_sta_char;    // "STATIC"
    case (i)
        3'd0: bot_sta_char = 4'd0;   // S
        3'd1: bot_sta_char = 4'd13;  // T
        3'd2: bot_sta_char = 4'd11;  // A
        3'd3: bot_sta_char = 4'd13;  // T
        3'd4: bot_sta_char = 4'd12;  // I
        3'd5: bot_sta_char = 4'd7;   // C
    endcase
endfunction

function [3:0] bot_ani_char;    // "ANIM"
    case (i)
        3'd0: bot_ani_char = 4'd11;  // A
        3'd1: bot_ani_char = 4'd14;  // N
        3'd2: bot_ani_char = 4'd12;  // I
        3'd3: bot_ani_char = 4'd15;  // M
    endcase
endfunction

Все — чистая комбинаторика, ~10 LE каждая.

Регистры состояния FSM (строки 322–374)

reg signed [8:0] vtx_px [0:7];   // 8 вершин после поворота — X
reg signed [8:0] vtx_py [0:7];   //                           Y

reg [4:0]  state;                 // 15 состояний FSM (5 бит с запасом)
reg        mode_r;                // защёлкнутый mode_i
reg [5:0]  angle_r;               // защёлкнутый angle_i

// S_CLEAR
reg [10:0] clr_idx;               // 0..1023, потому 11 бит чтобы не обернуться

// S_ROT_*
reg signed [8:0]  sin_th, cos_th; // sin/cos угла текущего кадра
reg        [2:0]  vtx_idx;        // 0..7
reg signed [7:0]  cur_vx, cur_vy, cur_vz;
reg signed [17:0] prod_xc, prod_zs, prod_xs, prod_zc;  // 4 умножения

// S_EDGE_* (Брезенхэм)
reg [3:0]         edge_idx;       // 0..11
reg signed [8:0]  bx, by;         // текущий пиксель
reg signed [8:0]  bx_end, by_end; // конечный пиксель ребра
reg signed [8:0]  bdx;            // +|dx|
reg signed [8:0]  bdy;            // −|dy|
reg signed [1:0]  bsx, bsy;       // ±1 для каждой оси
reg signed [10:0] berr;           // аккумулятор Брезенхэма
reg [7:0]         pix_mask;       // 1 << by[2:0]

// S_TEXT_*
reg [2:0]  txt_char_idx;          // 0..7 (до TOP_LEN_M1)
reg [2:0]  txt_col_idx;           // 0..4 — текущий столбец буквы
reg        txt_phase;             // 0=top, 1=bottom

Итого ~35 регистров + массивы vtx_px/vtx_py (8×9 бит каждый) — они либо лягут в M9K, либо в LE как registered 16×9 RAM. В нашем случае (размер 8 элементов) Quartus обычно использует LE — экономически выгоднее, чем занимать M9K под 144 бита.

Комбинационные хелперы (строки 376–436)

Эти wire‑выражения — комбинационные «функции», которые каждое состояние FSM использует, но сами они не имеют регистров.

Commit поворота

wire signed [17:0] rot_xp_full = prod_xc - prod_zs;   // vx·cos − vz·sin
wire signed [17:0] rot_zp_full = prod_xs + prod_zc;   // vx·sin + vz·cos
wire signed [10:0] rot_xp      = rot_xp_full[17:7];   // /128
wire signed [10:0] rot_zp      = rot_zp_full[17:7];   // /128
wire signed [10:0] rot_px      = rot_xp + 11'sd64;    // +64: центр по X
wire signed [10:0] cur_vy_ext  = {{3{cur_vy[7]}}, cur_vy};
wire signed [10:0] rot_py      = cur_vy_ext + (rot_zp >>> 2) + 11'sd32;

Ключевой момент — вся 2D‑проекция сделана комбинационно. В S_ROT_STORE мы читаем rot_px/rot_py и защёлкиваем их в vtx_px[vtx_idx]/vtx_py[vtx_idx].

Формулы:

  • x' = (vx·cos − vz·sin) / 128 + 64 — поворот вокруг Y + центровка (экран 128 пикселей по X, центр = 64).

  • z' = (vx·sin + vz·cos) / 128 — глубина после поворота.

  • y' = vy + z' / 4 + 32 — псевдо-3D проекция: вместо полного перспективного деления добавляем четверть z' к y. Это даёт эффект «передний план опущен вниз, задний — вверх». +32 — центр по Y (экран 64 пикселя, центр = 32).

Деление «/128» — арифметический сдвиг [17:7] на ширину. /4 — сдвиг >>> 2 (arithmetic right, сохранение знака для signed).

Инициализация Брезенхэма

wire [2:0]         e0         = edge_v0(edge_idx);
wire [2:0]         e1         = edge_v1(edge_idx);
wire signed [8:0]  p0x        = vtx_px[e0];
wire signed [8:0]  p0y        = vtx_py[e0];
wire signed [8:0]  p1x        = vtx_px[e1];
wire signed [8:0]  p1y        = vtx_py[e1];
wire signed [8:0]  init_dx    = (p1x >= p0x) ? (p1x - p0x) : (p0x - p1x);
wire signed [8:0]  init_dy_n  = (p1y >= p0y) ? (p0y - p1y) : (p1y - p0y);  // ≤ 0
wire signed [1:0]  init_sx    = (p0x < p1x) ?  2'sd1 : -2'sd1;
wire signed [1:0]  init_sy    = (p0y < p1y) ?  2'sd1 : -2'sd1;
wire signed [10:0] init_err   = {{2{init_dx[8]}},   init_dx}
                              + {{2{init_dy_n[8]}}, init_dy_n};

Всё это — инициализация алгоритма Брезенхэма комбинаторно из двух вершин ребра (e0, e1). В S_EDGE_INIT эти значения защёлкиваются в регистры bx/by/bx_end/by_end/bdx/bdy/ bsx/bsy/berr.

Обратите внимание на знаковый формат dy: init_dy_n специально сделан отрицательным (≤ 0), чтобы применить классическую формулу Брезенхэма с одним сумматором ошибки без ветвлений на знак (err += dx + dy работает и для роста X, и для убывания Y).

Шаг Брезенхэма

wire signed [10:0] bdx_ext = {{2{bdx[8]}}, bdx};
wire signed [10:0] bdy_ext = {{2{bdy[8]}}, bdy};
wire signed [10:0] b_e2    = berr <<< 1;
wire               step_x  = (b_e2 >= bdy_ext);
wire               step_y  = (b_e2 <= bdx_ext);
wire signed [8:0]  bsx_ext = {{7{bsx[1]}}, bsx};
wire signed [8:0]  bsy_ext = {{7{bsy[1]}}, bsy};
wire signed [10:0] berr_nx = berr + (step_x ? bdy_ext : 11'sd0)
                                  + (step_y ? bdx_ext : 11'sd0);

Это стандартное условие Брезенхэма:

  • e2 = 2·err;

  • если e2 ≥ dy_n (то есть err положителен) — сделать шаг по X;

  • если e2 ≤ dx — сделать шаг по Y;

  • обновить err добавлением dy_n и/или dx в зависимости от того, какие шаги были сделаны.

Все *_ext‑сигналы — это знаковое расширение узких полей до 11 бит, чтобы сравнения не переполнялись.

Адрес пикселя и маска

wire in_screen = (bx >= 9'sd0) && (bx <= 9'sd127)
              && (by >= 9'sd0) && (by <= 9'sd63);
wire [9:0] pix_addr  = {by[5:3], bx[6:0]};  // page*128 + col
wire [7:0] new_mask  = 8'd1 << by[2:0];     // бит внутри столбца

Преобразование (bx, by) в (fb_addr, bit_mask) (см. 2.3.3):

  • page = by[5:3] — деление y/8;

  • col = bx[6:0] — просто x;

  • fb_addr = {page, col} — конкатенация, эквивалентно page*128+col;

  • bit_mask = 1 << by[2:0] — y mod 8 как позиция бита.

in_screen — боковая обрезка: если пиксель за пределами 128×64, мы не пишем, но алгоритм Брезенхэма всё равно продолжает шагать (см. S_EDGE_SETUP).

Адрес текущего текстового символа

wire [3:0] cur_char = (!txt_phase) ? top_char(txt_char_idx)
                                   : (mode_r ? bot_ani_char(txt_char_idx)
                                             : bot_sta_char(txt_char_idx));
wire [2:0] cur_page = (!txt_phase) ? TOP_PAGE : BOT_PAGE;
wire [6:0] cur_col0 = (!txt_phase) ? TOP_COL0
                                   : (mode_r ? BOT_ANI_COL0 : BOT_STA_COL0);
wire [2:0] cur_last = (!txt_phase) ? TOP_LEN_M1
                                   : (mode_r ? ANI_LEN_M1 : STA_LEN_M1);

wire [5:0] txt_char_mul6 = {txt_char_idx, 2'b00} + {1'b0, txt_char_idx, 1'b0};
wire [6:0] txt_base_col  = cur_col0 + {1'b0, txt_char_mul6};
wire [6:0] txt_col       = txt_base_col + {4'd0, txt_col_idx};
wire [9:0] txt_addr      = {cur_page, txt_col};

txt_char_mul6 — хитрое умножение char_idx * 6 без DSP: char_idx · 4 + char_idx · 2 = (char_idx<<2) + (char_idx<<1). Конкатенация {idx, 2'b00} — это idx*4, {idx, 1'b0} — idx*2. Так мы получаем char*6 одним сумматором на 6 бит.

FSM — общая структура (строка 442)

Весь рендер — один большой case (state) с 15 состояниями:

Общая обёртка имеет ещё один важный штрих в самом конце:

// pre-assign в начале else-ветки: fb_we по умолчанию = 0
fb_we <= 1'b0;

case (state)
    ...
endcase

// post-assign: сбрасывает ready при повторном старте
if (start_i && state != S_IDLE) ready_o <= 1'b0;
  • fb_we <= 1'b0 — pre‑assign, аналогичный тому, что делает done_o <= 0 в i2c_burst_writer: write‑enable по умолчанию снят, и любое состояние, которое хочет записать, должно явно поставить fb_we <= 1'b1. Это защищает от случайной «утечки» write«а в неожиданных состояниях.»

  • post‑assign if (start_i && state != IDLE) — нужен, чтобы при «повторном» запуске (например, если верхний уровень решил перезапустить рендер до того, как текущий закончился) флаг ready_o сбрасывался. В норме это не происходит.

Стадия S_IDLE — ожидание старта (строки 486–494)

S_IDLE: begin
    if (start_i) begin
        mode_r  <= mode_i;
        angle_r <= angle_i;
        ready_o <= 1'b0;
        clr_idx <= 11'd0;
        state   <= S_CLEAR;
    end
end

Защёлкиваем mode_i/angle_i, сбрасываем ready_o и счётчик клирования, уходим в S_CLEAR. Важно, что mode_i/angle_i могут меняться у верхнего уровня между стартами (анимация инкрементит angle), но внутри одного кадра мы пользуемся защёлкнутыми значениями. Иначе рендер мог бы «поплыть» в середине.

Стадия S_CLEAR — заливка FB нулями (строки 497–506)

S_CLEAR: begin
    fb_we    <= 1'b1;
    fb_waddr <= clr_idx[9:0];
    fb_wdata <= 8'h00;
    if (clr_idx == 11'd1023) begin
        clr_idx <= 11'd0;
        state   <= S_ROT_SETUP;
    end else
        clr_idx <= clr_idx + 11'd1;
end

Счётчик 0..1023, на каждом такте один write 0x00. Ровно 1024 такта = 20.5 мкс на 50 МГц. Почему не ускорить? Можно было бы сконфигурировать M9K как 256×32 (4 байта за такт) — получилось бы 256 тактов. Но:

  • это изменит layout BRAM«а и усложнит read‑порты;»

  • 20.5 мкс на clear при 46 мс кадра — 0.04%, экономить не на чем.

11-битный clr_idx нужен, чтобы корректно обернуться: `11'd1023

  • 1 = 11'd1024, а если использовать 10-битный счётчик, 10'd1023

  • 1 = 10'd0— но мы проверяем== 1023` до инкремента, так что в принципе хватило бы и 10 бит. 11-битный — перестраховка на случай, если кто‑то в будущем захочет увеличить FB.

Триада S_ROT_* — поворот и проекция

S_ROT_SETUP (строки 509–514)

S_ROT_SETUP: begin
    sin_th  <= sin6(angle_r);
    cos_th  <= cos6(angle_r);
    vtx_idx <= 3'd0;
    state   <= S_ROT_LOAD;
end

1 такт. Защёлкиваем sin/cos угла текущего кадра. Далее во всём цикле по 8 вершинам эти значения не меняются. vtx_idx = 0.

S_ROT_LOAD (строки 516–521)

S_ROT_LOAD: begin
    cur_vx <= vx_rom(vtx_idx);
    cur_vy <= vy_rom(vtx_idx);
    cur_vz <= vz_rom(vtx_idx);
    state  <= S_ROT_MUL;
end

Достаём базовые (vx, vy, vz) текущей вершины из ROM«ов. Поскольку vx_rom/vy_rom/vz_rom — комбинаторные функции, значения доступны в том же такте.»

S_ROT_MUL (строки 523–529)

S_ROT_MUL: begin
    prod_xc <= cur_vx * cos_th;
    prod_zs <= cur_vz * sin_th;
    prod_xs <= cur_vx * sin_th;
    prod_zc <= cur_vz * cos_th;
    state   <= S_ROT_STORE;
end

Четыре умножения signed 8×9 → 18 бит параллельно. Каждое из них синтезируется как DSP‑блок 9×9 в Cyclone IV. EP4CE10 имеет 23 таких блока; мы используем 4 одновременно — ~17% DSP.

S_ROT_STORE (строки 531–541)

S_ROT_STORE: begin
    vtx_px[vtx_idx] <= rot_px[8:0];
    vtx_py[vtx_idx] <= rot_py[8:0];
    if (vtx_idx == 3'd7) begin
        edge_idx <= 4'd0;
        state    <= S_EDGE_INIT;
    end else begin
        vtx_idx <= vtx_idx + 3'd1;
        state   <= S_ROT_LOAD;
    end
end

Защёлкиваем результат комбинационных хелперов из 6.8.1 в массив vtx_px/vtx_py. Затем либо следующая вершина (цикл через S_ROT_LOAD), либо выход на рёбра.

Итого по ротации: 1 + 8 × 3 = 25 тактов на 8 вершин = 0.5 мкс. Куб целиком преобразуется меньше чем за полмикросекунды.

Пенталогия S_EDGE_* — Брезенхэм по 12 рёбрам

S_EDGE_INIT (строки 544–555)

S_EDGE_INIT: begin
    bx     <= p0x;
    by     <= p0y;
    bx_end <= p1x;
    by_end <= p1y;
    bdx    <= init_dx;
    bdy    <= init_dy_n;
    bsx    <= init_sx;
    bsy    <= init_sy;
    berr   <= init_err;
    state  <= S_EDGE_SETUP;
end

Загружаем стартовую точку, конечную точку, dx, −dy, направления sx/sy и начальную ошибку err. Все эти значения заранее вычислены комбинаторно (см. 6.8.2) из vtx_px[e0]/vtx_py[e1].

S_EDGE_SETUP (строки 557–565)

S_EDGE_SETUP: begin
    if (in_screen) begin
        fb_raddr_rmw <= pix_addr;
        pix_mask     <= new_mask;
        state        <= S_EDGE_RD;
    end else begin
        state <= S_EDGE_STEP;        // skip clip
    end
end

Проверяем clip по экрану:

  • В экране: запрашиваем чтение fb[pix_addr] (выставляем fb_raddr_rmw), защёлкиваем pix_mask, идём в S_EDGE_RD (ждать ответ BRAM).

  • Вне экрана: пропускаем RD/WR, сразу в S_EDGE_STEP (сделать шаг Брезенхэма без записи).

S_EDGE_RD (строки 567–569)

S_EDGE_RD: begin
    state <= S_EDGE_WR;
end

Пустой такт. Он нужен, потому что BRAM имеет latency 1: адрес выставили в S_EDGE_SETUP, данные в fb_rdata_rmw появятся через 1 такт. В S_EDGE_RD мы просто ждём, а в S_EDGE_WR используем эти данные.

S_EDGE_WR (строки 571–576)

S_EDGE_WR: begin
    fb_we    <= 1'b1;
    fb_waddr <= fb_raddr_rmw;
    fb_wdata <= fb_rdata_rmw | pix_mask;
    state    <= S_EDGE_STEP;
end

Read‑modify‑write: ставим на записываемый адрес тот же, с которого читали (fb_waddr <= fb_raddr_rmw), данные — старое значение | pix_mask (добавляем один бит). Это «рисует» один пиксель в FB, не трогая соседние 7 пикселей того же байта.

S_EDGE_STEP (строки 578–595)

S_EDGE_STEP: begin
    if (bx == bx_end && by == by_end) begin
        if (edge_idx == (NUM_EDGES - 4'd1)) begin
            txt_phase    <= 1'b0;
            txt_char_idx <= 3'd0;
            txt_col_idx  <= 3'd0;
            state        <= S_TEXT_INIT;
        end else begin
            edge_idx <= edge_idx + 4'd1;
            state    <= S_EDGE_INIT;
        end
    end else begin
        berr <= berr_nx;
        if (step_x) bx <= bx + bsx_ext;
        if (step_y) by <= by + bsy_ext;
        state <= S_EDGE_SETUP;
    end
end

Два случая:

  1. Достигли конца ребра (bx == bx_end && by == by_end):

    • если это последнее ребро (edge_idx == 11) — переходим к рендерингу текста (S_TEXT_INIT);

    • иначе — инкремент edge_idx, обратно в S_EDGE_INIT для следующего ребра.

  2. Не конец — классический шаг Брезенхэма:

    • 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

Вложенные счётчики «по столбцам / по буквам / по строкам»:

  1. Если txt_col_idx < 4 — просто инкрементим столбец, возвращаемся в S_TEXT_WR.

  2. Если столбец = 4 (последний в букве) — сбрасываем столбец в 0:

    • если буква не последняя — инкрементим txt_char_idx, следующая буква;

    • если буква последняя в строке — переключаем txt_phase на нижнюю строку (либо уходим в S_DONE, если нижняя уже отрисована).

Итого по тексту: верхняя строка 7 букв × 5 столбцов × 2 такта = 70 тактов; нижняя строка (STATIC = 6 букв × 5 = 60; ANIM = 4 × 5 = 40) — ~40–60 тактов. Суммарно ~110–130 тактов = 2.2–2.6 мкс.

Стадия S_DONE (строки 630–633)

S_DONE: begin
    ready_o <= 1'b1;
    state   <= S_IDLE;
end

Один такт: поднимаем флаг «кадр готов» и возвращаемся в покой. Верхний уровень увидит ready_o = 1 на следующем такте и начнёт PH_FRAME.

Внешний read‑port для I2C TX

Ранее мы уже показали код dual‑port BRAM. Ключевой момент: пока ssd1306_ctrl через raddr_i читает байты фреймбуфера для I2C‑передачи, мы в scene_renderer ничего не делаем — FSM сидит в S_IDLE с ready_o = 1. Следующий start_i придёт только после того, как ssd1306_ctrl завершит передачу (PH_FRAMEW → PH_ANEXT → PH_RENDER → новый start_i).

В теории между S_DONE и следующим S_IDLE → start_i FSM могла бы уже начать перерисовывать кадр N+1 в тот же FB — но это бы затёрло байты, которые ещё не переданы по I2C. Поэтому ssd1306_ctrl сериализует: сначала TX завершается, потом новый start_i в рендер.

Ресурсы и характеристики модуля

Реалистичная оценка утилизации scene_renderer на Cyclone IV EP4CE10:

Ресурс

Использовано

Комментарий

Logic Elements (LE)

~900

FSM + комб‑хелперы + font/sin/vertex LUT

Registers

~150

state, bx/by/..., vtx_px/py (144 бит), счётчики

M9K blocks

1

FB 1024×8

9×9 multipliers

4

один такт в S_ROT_MUL, остальное время не заняты

Fmax

>80 МГц

комб. пути Брезенхэма и rot‑commit — < 6 нс

Производительность рендера кадра:

Фаза

Тактов

@50 МГц

S_CLEAR

1024

20.5 мкс

S_ROT_* (8 × 3 + 1)

25

0.5 мкс

S_EDGE_* (12 × ~30 × 3)

~900

18 мкс

S_TEXT_* (верх + низ)

~120

2.4 мкс

S_DONE

1

0.02 мкс

Всего

~2070

~41 мкс

На фоне 46 мс I2C‑передачи — ~0.09% времени кадра. Рендер не является узким местом; узкое место — физическая частота SCL = 200 кГц. Хочется выше fps — либо ускоряем I2C (до 400 кГц Fast‑mode), либо переходим на SPI.

Что можно (и нельзя) добавить в рендер

Архитектура оставляет большой простор для расширений:

Легко добавить:

  • ещё одну строку текста (больше txt_phase → 2 бита, новый лексикон);

  • 2D‑примитивы: прямоугольник (fill page‑aligned rows), круг (Bresenham variant);

  • простую анимацию иконок (bitmap в ROM, blit как текст);

  • поворот куба по двум осям (+sin_phi/cos_phi, +2 умножения).

Сложнее, но можно:

  • фиксированные перспективные проекции (деление замещается умножением на 1/z из LUT);

  • несколько объектов (нужен список объектов в ROM);

  • «flood fill» замкнутой области (сканлайн‑fill по страницам).

Не влезет без radical redesign:

  • полноценный 3D‑рендер с Z‑buffer«ом (нужно + 1 M9K под Z‑буфер,»

    • умножения для интерполяции);

  • сглаживание (anti‑aliasing) — требует 2-битного или даже 4-битного канала прозрачности, что несовместимо с 1bpp OLED;

  • текстурированные полигоны — смотри полноценный GPU.

Для нашей образовательной задачи (текст + куб) архитектура избыточна, и это хорошо: есть куда расти без переделки основы.

Модуль ssd1306_ctrl — оркестратор

Модуль quartus_ssd1306/src/ssd1306_ctrl.v — верхнеуровневый оркестратор всей системы SSD1306-рендера. Если i2c_burst_writer знает только «как слать WRITE‑burst по I2C», а scene_renderer — «как нарисовать кадр в BRAM», то ssd1306_ctrl знает про SSD1306: в каком порядке делать init, render, frame TX, как обрабатывать ошибки, как реагировать на кнопки и как включать/выключать анимацию.

Это самый «богатый по логике» модуль проекта (~380 строк), но структурно простой: одно большое FSM по фазам PH_* плюс три вспомогательные структуры (init ROM, data‑source mux, src_idx counter). Ниже — разбор по блокам.

ssd1306_ctrl — единственный модуль, знающий:

  • что у SSD1306 I2C‑адрес 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
);

Три параметра:

Параметр

По умолчанию

Смысл

CLK_FREQ

50 000 000

частота clk_i в Гц. Используется для расчёта DELAY_CYCLES

I2C_ADDR

7'h3C

7-битный I2C‑адрес SSD1306

DELAY_MS

100

задержка после POR перед первой командой

progress_o[10:0] — 11-битный счётчик текущего src_idx (0..1024), выводится на 7-сегментные индикаторы top‑level«ом для визуализации „сколько байт передано“. При init — пробегает 0..32, при frame — 0..1024.»

Init ROM (строки 55–98)

32-байтовая таблица init‑последовательности, защитная переменная и функция‑ROM:

localparam [5:0] INIT_LEN = 6'd32;

function [7:0] init_rom;
    input [5:0] idx;
    case (idx)
        6'd0:  init_rom = 8'h00;   // Control byte: command stream
        6'd1:  init_rom = 8'hAE;   // Display OFF
        6'd2:  init_rom = 8'hD5;   // Set display clock divide
        6'd3:  init_rom = 8'h80;
        6'd4:  init_rom = 8'hA8;   // Set multiplex ratio
        ...
        6'd31: init_rom = 8'hAF;   // Display ON
        default: init_rom = 8'h00;
    endcase
endfunction

Полное содержимое и назначение каждого байта уже разобраны в разделе 2.5 (пять логических групп команд + итоговая таблица 2.5.3). Здесь важно только структурное:

  • control‑byte 0x00 идёт как idx=0 — это не отдельный сигнал, а просто первый байт ROM«а. Благодаря этому в data‑source mux (см. 7.6) не нужен дополнительный условный мультиплексор для init — просто bw_data = init_rom(src_idx)

  • Длина ровно 32 = INIT_LEN, передаётся в bw_byte_count при старте init‑транзакции.

  • default: init_rom = 8'h00 — страховка от случайного идущего дальше индекса (теоретически не достижимо, так как byte_count = 32 → burst‑writer остановится на idx=31).

Фаза‑FSM: состояния и регистры (строки 103–130)

localparam [10:0] DATA_LEN = 11'd1025;   // 1 control-byte + 1024 пикселя

localparam [3:0] PH_IDLE   = 4'd0,
                 PH_DELAY  = 4'd1,
                 PH_INIT   = 4'd2,
                 PH_INITW  = 4'd3,
                 PH_RENDER = 4'd4,
                 PH_RENDW  = 4'd5,
                 PH_FRAME  = 4'd6,
                 PH_FRAMEW = 4'd7,
                 PH_ANEXT  = 4'd8,
                 PH_OK     = 4'd9,
                 PH_ERR    = 4'd10;

reg  [3:0]  phase;
reg  [22:0] delay_cnt;
reg  [10:0] src_idx;
reg         inited;
reg         mode;
reg         anim_run;
reg  [5:0]  angle;

localparam [22:0] DELAY_CYCLES = (CLK_FREQ / 1000) * DELAY_MS;

11 состояний FSM (4 бита):

Фаза

Что делает

PH_IDLE

покой, ждём нажатия KEY2 / KEY3

PH_DELAY

100 мс POR‑задержка, один раз после сброса

PH_INIT

асинхронный pulse bw_start = 1 для init‑транзакции

PH_INITW

ждём bw_done от init; ошибка → PH_ERR

PH_RENDER

pulse scene_start = 1 для рендера

PH_RENDW

ждём scene_ready

PH_FRAME

pulse bw_start = 1 для frame‑TX

PH_FRAMEW

ждём bw_done от frame; ошибка → PH_ERR

PH_ANEXT

инкремент angle, обратно в PH_RENDER

PH_OK

успешное завершение; принимает новые команды

PH_ERR

ошибка; принимает повторный запуск с inited=0

Регистры состояния:

Регистр

Ширина

Назначение

phase

4 бит

текущее состояние FSM

delay_cnt

23 бит

счётчик POR‑задержки (DELAY_CYCLES = 5 000 000, 23 бит хватает)

src_idx

11 бит

индекс текущего байта в транзакции; 0..32 на init, 0..1024 на frame

inited

1 бит

флаг «чип инициализирован» — живёт между транзакциями

mode

1 бит

передаётся в scene_renderer.mode_i — 0=static, 1=anim

anim_run

1 бит

идёт ли сейчас анимационный цикл

angle

6 бит

текущий угол куба, 0..63 ≡ 0..2π

Расчёт DELAY_CYCLES: 100 мс × 50 МГц = 5 000 000 тактов. Это параметризовано через CLK_FREQ и DELAY_MS, то есть при сборке под другую частоту (например, 100 МГц) значение автоматически пересчитается. Формула (CLK_FREQ / 1000) * DELAY_MS — сначала делим на 1000 (чтобы получить кГц), потом умножаем на мс.

Подключение scene_renderer (строки 145–159)

reg         scene_start;
wire        scene_ready;
wire [7:0]  scene_rdata;
wire [9:0]  scene_raddr = (src_idx == 11'd0) ? 10'd0 : (src_idx[9:0] - 10'd1);

scene_renderer u_scene (
    .clk_i   (clk_i),
    .rstn_i  (rstn_i),
    .start_i (scene_start),
    .mode_i  (mode),
    .angle_i (angle),
    .ready_o (scene_ready),
    .raddr_i (scene_raddr),
    .rdata_o (scene_rdata)
);

Ключевая деталь — вычисление scene_raddr:

scene_raddr = (src_idx == 0) ? 0 : (src_idx - 1);

Почему src_idx - 1, а не просто src_idx? Потому что в frame‑транзакции:

  • src_idx = 0 соответствует байту control‑byte 0x40, а не первому пикселю. Его читать не из BRAM — его подмешивает контроллер (см. 7.6).

  • src_idx = 1..1024 соответствует пикселям fb[0..1023].

Сдвиг src_idx - 1 даёт правильное отображение «запрос № N по I2C = байт № (N-1) в BRAM». При src_idx = 0 выражение src_idx - 10'd1 даст 1023 (wrap), но результат на этом такте всё равно не используется — комб‑мукс data_byte на этом такте выдаёт 0x40, а scene_rdata игнорируется.

Сторожевое src_idx == 0 ? 0 : ... нужно не для корректности read«а (он и так не используется), а для временно́го: если scene_raddr = 1023 на первом такте frame, BRAM начнёт на следующем такте выдавать fb[1023] (последний байт), а мы в этот момент хотим как раз fb[0]. Поэтому на такте 0 мы выставляем raddr=0 заранее, чтобы на такте 1 уже получить fb[0] в scene_rdata

Data‑source mux (строки 167–169)

Ключевая строка — мультиплексор источника данных для i2c_burst_writer:

wire [7:0]  init_byte  = init_rom(src_idx[5:0]);
wire [7:0]  data_byte  = (src_idx == 11'd0) ? 8'h40 : scene_rdata;
wire [7:0]  bw_data    = (phase == PH_INITW) ? init_byte : data_byte;

Три яруса мультиплексирования:

  1. init_byte — первая ветка, в init‑режиме. Просто читает init_rom(src_idx[5:0]). Тут нет ни BRAM«а, ни control‑byte»а (он уже зашит в init_rom[0] = 0x00).

  2. data_byte — ветка в frame‑режиме. Два случая:

    • src_idx == 0 → выдаём 0x40 (frame control‑byte);

    • src_idx >= 1 → выдаём scene_rdata (читаем из BRAM через scene_raddr = src_idx - 1).

  3. bw_data — общий выбор между init и frame по текущей фазе.

Таблица, показывающая, что именно отдаёт mux по мере бега src_idx:

Фаза

src_idx

bw_data

PH_INITW

0

init_rom[0] = 0x00 (control‑byte)

PH_INITW

1

init_rom[1] = 0xAE (Display OFF)

PH_INITW

2..31

init_rom[2..31] (остальные команды)

PH_FRAMEW

0

0x40 (frame control‑byte)

PH_FRAMEW

1

scene_rdata (адрес = 0, byte fb[0])

PH_FRAMEW

2..1024

scene_rdata (адрес = idx-1, fb[1..1023])

src_idx — счётчик позиции в транзакции (строки 180–187)

always @(posedge clk_i or negedge rstn_i) begin
    if (!rstn_i)
        src_idx <= 11'd0;
    else if (phase == PH_INIT || phase == PH_FRAME)
        src_idx <= 11'd0;                // новый burst → начать с 0
    else if (bw_data_req)
        src_idx <= src_idx + 11'd1;      // инкремент на каждом запросе
end

Это отдельный синхронный блок, работающий параллельно с основным FSM. Правила:

  • При сбросе — src_idx = 0.

  • При входе в PH_INIT/PH_FRAME — src_idx = 0 (начало новой транзакции). Заметьте, что это однократный тик: эти фазы сразу же переходят в PH_INITW/PH_FRAMEW на следующем такте.

  • Во всех остальных фазах — инкремент на каждый bw_data_req = 1 (запрос следующего байта от burst‑writer«а).»

Важно: src_idx сбрасывается в PH_INIT/PH_FRAME, а не в PH_IDLE. Это потому, что в PH_IDLE мы ещё ничего не запрашиваем — состояние счётчика неважно. Зато между фазами PH_INITW и PH_FRAMEW счётчик может сохранять старое значение — но оно всё равно будет сброшено в начале каждой новой транзакции.

Сброс происходит одновременно с ассертом bw_start, а burst‑writer тратит несколько тактов в S_START_CMD → S_ADDR_CMD перед первым data_req_o, поэтому к моменту первого запроса src_idx уже стабильно = 0.

Почему связка «BRAM 1-такт + burst I2C» работает

Ключевой вопрос: BRAM даёт задержку 1 такт между подачей адреса и выходом данных, а burst‑writer ожидает данные в тот же такт, когда поднимает bw_data_req. Как это согласуется?

Ответ — огромный интервал между запросами. Между двумя последовательными data_req_o burst‑writer проводит:

  • ~9 I2C‑бит × 4 ena‑тика × 125 такт/ena ≈ 4500 тактов на передачу одного байта по I2C;

  • 1–2 такта на CMD/WAIT‑переходы;

  • задержка BRAM = 1 такт.

Ровно 1 такт задержки BRAM полностью растворяется в 4500 тактах между запросами. Логика работает так:

такт N:       src_idx = K;  scene_raddr = K-1;
такт N+1:     (BRAM обновился)  scene_rdata = fb[K-1];
такт N+2:     bw_data_req pulse → byte latched as data_i = fb[K-1]  ✓

... 4500 тактов I2C ...

такт N+4502:  src_idx = K+1;  scene_raddr = K;
такт N+4503:  scene_rdata = fb[K];
такт N+4504:  next data_req → byte fb[K]  ✓

В момент, когда burst‑writer реально нуждается в новом байте, BRAM уже давно (на 4500 тактов раньше) обновил scene_rdata под актуальный адрес. Поэтому data_valid_i = bw_data_req работает без проблем — это комбинаторное равенство (строка 372).

Единственный крайний случай — первый запрос при src_idx = 0: нужен 0x40, а не содержимое BRAM. Обрабатывается комб‑муксом data_byte (см. 7.6). К моменту второго запроса (src_idx = 1) BRAM уже подал fb[0] на scene_rdata.

Комбинационные выходы busy_o, animating_o, arb_lost_clear_o (строки 171–177)

assign busy_o      = (phase != PH_IDLE && phase != PH_OK && phase != PH_ERR);
assign animating_o = anim_run;

wire trigger = start_i || anim_i;
assign arb_lost_clear_o = trigger && (phase == PH_IDLE ||
                                      phase == PH_OK   ||
                                      phase == PH_ERR);

busy_o — модуль занят транзакцией (init / render / frame). На 7-segment / LED top‑level‑а индицирует «идёт работа». В фазах покоя (IDLE, OK, ERR) ложен.

animating_o — дублирует anim_run. Зажигает отдельный LED «идёт анимация», чтобы пользователь видел разницу между статикой и анимацией.

arb_lost_clear_o — сигнал для i2c_master_core, очищающий sticky‑флаг arb‑lost. Активируется при нажатии любой кнопки в «пассивных» фазах (IDLE/OK/ERR). Логика: если предыдущая транзакция провалилась из‑за arb‑lost, флаг в ядре залип; при повторном нажатии кнопки мы его очищаем и начинаем новую транзакцию «с чистого листа».

Счётчик anim_stop_req (строки 190–198)

reg anim_stop_req;

always @(posedge clk_i or negedge rstn_i) begin
    if (!rstn_i)
        anim_stop_req <= 1'b0;
    else if (anim_i && anim_run)
        anim_stop_req <= 1'b1;
    else if (phase == PH_OK || phase == PH_IDLE)
        anim_stop_req <= 1'b0;
end

Отдельный однобитный регистр для обработки «второго нажатия KEY3»:

  1. Если пользователь нажимает KEY3 во время активной анимации (anim_i && anim_run) → запоминаем запрос на остановку (anim_stop_req = 1).

  2. Остановка срабатывает в PH_FRAMEW: если anim_run && !anim_stop_req → уходим в PH_ANEXT (продолжаем анимацию); иначе → PH_OK (стоп).

  3. Очистка флага — в PH_OK или PH_IDLE (когда анимация уже остановлена).

Почему так? Если пользователь нажал KEY3 в середине передачи кадра, мы не прерываем кадр — это оставило бы I2C‑шину в неопределённом состоянии. Флаг anim_stop_req просто «запоминает» намерение, а реальный exit происходит на границе кадра, когда передача завершена корректно.

Главный FSM — сброс (строки 204–216)

always @(posedge clk_i or negedge rstn_i) begin
    if (!rstn_i) begin
        phase         <= PH_IDLE;
        delay_cnt     <= 23'd0;
        bw_start      <= 1'b0;
        bw_byte_count <= 16'd0;
        scene_start   <= 1'b0;
        done_o        <= 1'b0;
        err_o         <= 1'b0;
        progress_o    <= 11'd0;
        inited        <= 1'b0;
        mode          <= 1'b0;
        anim_run      <= 1'b0;
        angle         <= 6'd0;
    end else begin
        bw_start    <= 1'b0;     // pre-assign — одноцикловые импульсы
        scene_start <= 1'b0;
        done_o      <= 1'b0;
        ...

Асинхронный сброс ставит FSM в PH_IDLE, всё очищает. В начале else‑ветки — три pre‑assign«а (bw_start, scene_start, done_o0), обеспечивающих одноцикловые импульсы (тот же паттерн, что и в i2c_burst_writer, см. 4.5).»

Заметьте, что inited не сбрасывается pre‑assign«ом — он level‑флаг, живёт между фазами.»

Стадия PH_IDLE (строки 224–248)

PH_IDLE: begin
    if (start_i) begin
        mode     <= 1'b0;
        anim_run <= 1'b0;
        angle    <= 6'd0;
        err_o    <= 1'b0;
        if (inited)
            phase <= PH_RENDER;
        else begin
            delay_cnt <= DELAY_CYCLES;
            phase     <= PH_DELAY;
        end
    end else if (anim_i) begin
        mode     <= 1'b1;
        anim_run <= 1'b1;
        angle    <= 6'd0;
        err_o    <= 1'b0;
        if (inited)
            phase <= PH_RENDER;
        else begin
            delay_cnt <= DELAY_CYCLES;
            phase     <= PH_DELAY;
        end
    end
end

Две симметричные ветки по нажатию KEY2 (start) и KEY3 (anim), отличия только в mode и anim_run:

  • KEY2 (start_i): mode=0, anim_run=0 → один статический кадр.

  • KEY3 (anim_i): mode=1, anim_run=1 → циклическая анимация.

В обоих случаях: очищаем предыдущую ошибку (err_o=0), обнуляем угол (angle=0), переходим либо в PH_RENDER (если inited), либо в PH_DELAY (первый запуск после reset).

Стадия PH_DELAY (строки 251–256)

PH_DELAY: begin
    if (delay_cnt == 23'd0)
        phase <= PH_INIT;
    else
        delay_cnt <= delay_cnt - 23'd1;
end

Простой 23-битный счётчик. При CLK_FREQ=50 МГц, DELAY_MS=100: DELAY_CYCLES = 5 000 000 — отсчитываем 100 мс. Затем переходим в PH_INIT.

Эта фаза один раз после power‑on, плюс каждый раз после PH_ERR (сброс флага inited → повторный init с задержкой).

Пара PH_INIT / PH_INITW (строки 259–277)

PH_INIT: begin
    bw_start      <= 1'b1;
    bw_byte_count <= {10'd0, INIT_LEN};
    phase         <= PH_INITW;
end

PH_INITW: begin
    progress_o <= src_idx;
    if (bw_done) begin
        if (bw_error) begin
            err_o  <= 1'b1;
            inited <= 1'b0;
            phase  <= PH_ERR;
        end else begin
            inited <= 1'b1;
            phase  <= PH_RENDER;
        end
    end
end

PH_INIT — одноцикловое состояние: выставляет bw_start = 1 (на один такт благодаря pre‑assign bw_start <= 0) и переходит в PH_INITW.

PH_INITW — ожидание завершения:

  • каждый такт обновляет progress_o текущим src_idx (для визуализации на 7-сегменте);

  • по bw_done:

    • если ошибка (NACK / arb‑lost) → PH_ERR, inited = 0;

    • иначе → PH_RENDER, inited = 1.

Пара PH_RENDER / PH_RENDW (строки 280–288)

PH_RENDER: begin
    scene_start <= 1'b1;
    phase       <= PH_RENDW;
end

PH_RENDW: begin
    if (scene_ready)
        phase <= PH_FRAME;
end

Симметрично init: PH_RENDER выставляет scene_start на 1 такт (pre‑assign обеспечивает), PH_RENDW ждёт scene_ready. ~41 мкс рендера по факту (см. 6.17).

Ошибки рендера в принципе невозможны — FSM scene_renderer детерминирован, всегда дойдёт до S_DONE.

Пара PH_FRAME / PH_FRAMEW (строки 291–312)

PH_FRAME: begin
    bw_start      <= 1'b1;
    bw_byte_count <= {5'd0, DATA_LEN};       // 16-bit = 0x0401 = 1025
    phase         <= PH_FRAMEW;
end

PH_FRAMEW: begin
    progress_o <= src_idx;
    if (bw_done) begin
        if (bw_error) begin
            err_o    <= 1'b1;
            anim_run <= 1'b0;
            phase    <= PH_ERR;
        end else if (anim_run && !anim_stop_req)
            phase <= PH_ANEXT;
        else begin
            done_o   <= 1'b1;
            anim_run <= 1'b0;
            phase    <= PH_OK;
        end
    end
end

PH_FRAME — одноцикловый pulse bw_start + установка bw_byte_count = 1025 (control‑byte + 1024 пикселя).

PH_FRAMEW — трёхветочный exit:

  1. Ошибка (bw_error) — PH_ERR, anim_run=0. Если у нас был цикл анимации, он тоже прерывается.

  2. Нормально + анимация + не‑стоп — PH_ANEXT (инкремент угла, новый кадр).

  3. Нормально + не‑анимация или стоп‑запрос — PH_OK с pulse«ом done_o

Стадия PH_ANEXT (строки 315–318)

PH_ANEXT: begin
    angle <= angle + 6'd1;
    phase <= PH_RENDER;
end

Инкремент angle по модулю 64 (auto‑wrap 6-битного сложения). Полный оборот за 64 кадра = 64 × 46 мс ≈ 3 секунды. Плавно и приятно для глаза.

Стадия PH_OK (строки 321–337)

PH_OK: begin
    if (start_i) begin
        mode     <= 1'b0;
        anim_run <= 1'b0;
        angle    <= 6'd0;
        err_o    <= 1'b0;
        phase    <= inited ? PH_RENDER : PH_DELAY;
        if (!inited) delay_cnt <= DELAY_CYCLES;
    end else if (anim_i) begin
        mode     <= 1'b1;
        anim_run <= 1'b1;
        angle    <= 6'd0;
        err_o    <= 1'b0;
        phase    <= inited ? PH_RENDER : PH_DELAY;
        if (!inited) delay_cnt <= DELAY_CYCLES;
    end
end

Полный аналог PH_IDLE, но с одним ключевым отличием: не сбрасывает inited. Благодаря этому:

  • Если inited = 1 (чип уже инициализирован) — сразу идём в PH_RENDER, экономя 100 мс + 32 байта I2C (~1.5 мс) = ~101.5 мс per повторный запуск.

  • Если inited = 0 (ещё не init или после PH_ERR) — полный цикл через PH_DELAYPH_INIT.

Это делает UX приятным: после первого запуска каждое следующее нажатие кнопки даёт мгновенную реакцию.

Стадия PH_ERR (строки 339–349)

PH_ERR: begin
    if (start_i || anim_i) begin
        inited    <= 1'b0;
        mode      <= anim_i ? 1'b1 : 1'b0;
        anim_run  <= anim_i;
        angle     <= 6'd0;
        err_o     <= 1'b0;
        delay_cnt <= DELAY_CYCLES;
        phase     <= PH_DELAY;
    end
end

Терминальное состояние после ошибки. При любом нажатии кнопки:

  • всегда сбрасывает inited = 0 и идёт через PH_DELAY → PH_INIT — полный цикл инициализации (предположение: дисплей мог быть физически отключён / сбросился / сбоил, нужна перенастройка);

  • выбирает режим: static или anim в зависимости от нажатой кнопки.

Это важная семантика для восстановления: ошибка NACK почти наверняка означает, что чип либо отключён, либо потерял синхронизацию. Просто перезапуск без init бесполезен — пойдёт ещё один NACK.

Подключение i2c_burst_writer (строки 359–379)

 i2c_burst_writer #(
    .CNT_W (16)
) u_burst (
    .clk_i        (clk_i),
    .rstn_i       (rstn_i),
    .start_i      (bw_start),
    .slave_addr_i (I2C_ADDR),
    .byte_count_i (bw_byte_count),
    .busy_o       (bw_busy),
    .done_o       (bw_done),
    .error_o      (bw_error),
    .data_req_o   (bw_data_req),
    .data_i       (bw_data),
    .data_valid_i (bw_data_req),      // комбинаторная связка (см. 4.14)
    .cmd_valid_o  (cmd_valid_o),
    .cmd_o        (cmd_o),
    .din_o        (din_o),
    .ready_i      (ready_i),
    .rx_ack_i     (rx_ack_i),
    .arb_lost_i   (arb_lost_i)
);

Ключевые моменты:

  • CNT_W = 16 — с запасом на 65 535 байт. Достаточно и для init (32), и для frame (1025), и для любых будущих расширений.

  • data_valid_i = bw_data_req — комбинаторное равенство. Источник всегда валиден мгновенно (init‑ROM — комб‑функция; scene_rdata — BRAM с latency=1, но интервалы между запросами в 4500 раз больше).

  • cmd_valid_o/cmd_o/din_o — выходы burst‑writer«а пробрасываются прозрачно наверх, через ssd1306_ctrl на внешний i2c_master_core. Никаких дополнительных мультиплексоров.»

  • arb_lost_i — проксируется от ядра к burst‑writer«у; а arb_lost_clear_o обратно, из ssd1306_ctrl к ядру.»

Ресурсы и характеристики модуля

Реалистичная оценка утилизации ssd1306_ctrl на Cyclone IV EP4CE10 (без u_scene и u_burst, только собственная логика):

Ресурс

Использовано

Комментарий

Logic Elements (LE)

~150

FSM + init ROM + data mux + src_idx

Registers

~50

phase, src_idx, angle, mode, anim_run, inited, delay_cnt (23 бит)

M9K blocks

0

весь ROM — case‑функция, помещается в LE

Multipliers

0

нет арифметики, только сравнения и инкременты

Fmax

>100 МГц

короткие комб‑пути

Для всего поддерева ssd1306_ctrl (включая u_scene и u_burst):

Ресурс

Использовано

Комментарий

LE

~1 250

150 (этот модуль) + 900 (scene_renderer) + 200 (burst_writer)

Registers

~250

50 + 150 + 50

M9K blocks

1

только framebuffer в scene_renderer

9×9 multipliers

4

в scene_renderer.S_ROT_MUL

Это ~12% LE ресурса EP4CE10 (10 320 LE) — остаются 88% на остальную логику (7-seg driver, debouncers, LED‑индикация).

Что можно улучшить / расширить

  • Dual‑buffer анимация. Добавить второй BRAM + ping-pong флаг: пока кадр N передаётся, рендерим N+1 в другой буфер. Даст fps, ограниченный только передачей (~21 fps). Стоимость — +1 M9K, +20 LE.

  • Retry при NACK. Сейчас по bw_error мы сразу уходим в PH_ERR. Можно добавить счётчик retries (до 3) с возвратом в PH_DELAYPH_INIT. Полезно для шумной шины / вибрации.

  • Watchdog по времени кадра. Если PH_FRAMEW длится больше 100 мс — что‑то зависло (clock stretching slave«а, физическая проблема). Таймаут + принудительный STOP + PH_ERR

  • Несколько сцен. Добавить вход scene_sel_i и передавать в scene_renderer, чтобы показать разные сцены по разным кнопкам. Ctrl‑логика не меняется.

  • I2C probe перед init. См. 2.2.2 — можно отправить transaction с byte_count=0 перед PH_INIT, чтобы проверить наличие дисплея; если NACK → сразу PH_ERR без 100 мс ожидания.

Фазовая диаграмма

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‑куб. Он лишь:

  1. Генерирует core_ena из clk_50m — прескалер для работы I2C‑ядра на 100 кГц.

  2. Подключает двунаправленные пины i2c_sda/i2c_scl через tri‑state + синхронизаторы.

  3. Антидребезжит две кнопки KEY2/KEY3.

  4. Собирает статус (busy, done, err, animating) на 4 LED.

  5. Разворачивает 11-битный progress_o на 3 HEX‑цифры 7-сегментного дисплея + 1 статус‑цифра.

  6. Инстанциирует три главных модуля: i2c_master_core, ssd1306_ctrl, seg_scan, ax_debounce (×2).

Объявление модуля и порты (строки 18–30)

module ssd1306_test_top (
    input  wire       clk_50m,
    input  wire       rst_n,
    input  wire       key_start,      // KEY2 — active-low, статика
    input  wire       key_anim,       // KEY3 — active-low, анимация

    inout  wire       i2c_sda,        // двунаправленные I2C
    inout  wire       i2c_scl,

    output wire [3:0] led,            // 4 LED, active-high
    output wire [5:0] seg_sel,        // 6 цифр, active-low select
    output wire [7:0] seg_data        // 8 сегментов, active-low
);

Все порты — физические пины FPGA:

Порт

Направление

Уровень активности

Назначение

clk_50m

in

кварц 50 МГц, источник системного такта

rst_n

in

active‑low

кнопка RESET

key_start

in

active‑low

KEY2, «статический кадр»

key_anim

in

active‑low

KEY3, «анимация» (повторно — стоп)

i2c_sda

inout

open‑drain

линия данных I2C

i2c_scl

inout

open‑drain

линия тактов I2C

led[3:0]

out

active‑high

4 индикатора статуса

seg_sel[5:0]

out

active‑low

селектор цифры (one‑hot, активный 0)

seg_data[7:0]

out

active‑low

сегменты + DP (0=горит)

Прескалер I2C (строки 35–53)

Ядру i2c_master_core нужен импульс ena_i раз в четверть периода SCL, чтобы генерировать все 4 фазы I2C‑бита.

SCL_freq  = clk_sys / (4 · (PRE_TOP + 1))
100 кГц   = 50 МГц  / (4 · 125)
PRE_TOP   = 124

Формула:

\text{PRE\_TOP} = \frac{f_{\text{clk}}}{4 \cdot f_{\text{SCL}}} - 1 = \frac{50 \cdot 10^6}{4 \cdot 100 \cdot 10^3} - 1 = 125 - 1 = 124

Код прескалера:

localparam [6:0] PRE_TOP = 7'd124;

reg [6:0] pre_cnt;
reg       core_ena;

always @(posedge clk_50m or negedge rst_n) begin
    if (!rst_n) begin
        pre_cnt  <= 7'd0;
        core_ena <= 1'b0;
    end else begin
        if (pre_cnt == PRE_TOP) begin
            pre_cnt  <= 7'd0;
            core_ena <= 1'b1;        // 1 такт из каждых 125
        end else begin
            pre_cnt  <= pre_cnt + 7'd1;
            core_ena <= 1'b0;
        end
    end
end

Разбор:

  • 7-битный счётчик pre_cnt (0..127, достаточно для 124).

  • 1 такт core_ena из каждых 125 тактов. I2C‑ядро использует его как «разрешение сдвига» своего бит‑FSM.

  • Скважность 1/125 — это ena‑стробы, они не являются клоком. В синтезе будет один клок‑домен clk_50m, а core_ena просто включает/выключает последовательную логику в ядре — это хорошая практика CDC‑free дизайна.

Таблица альтернативных скоростей:

Режим I2C

f_SCL

PRE_TOP

1 байт (SCL)

1 байт + ACK (clk_50m)

Standard

100 кГц

124

90 мкс

4 500 тактов

Fast

400 кГц

30 (≈391 кГц)

23 мкс

1 150 тактов

Fast+

1 МГц

11 (≈1.04 МГц)

9 мкс

460 тактов

Slow

10 кГц

1249

900 мкс

45 000 тактов

SSD1306 официально поддерживает до 400 кГц; при качественной разводке и коротких проводах работает и до 1 МГц. Разрядность PRE_TOP тогда нужно увеличить под нужное значение. Весь остальной RTL остаётся без изменений.

Tri‑state буферы и синхронизаторы (строки 58–77)

Две ключевые задачи физического слоя:

  1. Двунаправленные пины i2c_sda/i2c_scl — open‑drain, FPGA их «отпускает» для чтения, «притягивает к земле» для передачи 0.

  2. Входные сигналы 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

~10^{9} секунд ≈ 31 год

3

~10^{17} секунд

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

Алгоритм на пальцах:

  1. key_d — входной сэмпл прошлого такта; key_stable — текущее «признанное» состояние.

  2. Если совпадают — сигнал стабилен, счётчик сброшен.

  3. Если не совпадают — наращиваем счётчик. Как только он достигает CNT_MAX - 1 = 20 мс × 50 МГц / 1000 = 1 000 000 — признаём новое состояние стабильным.

  4. Только на фронте 1 → 0 (нажатие, так как активность‑low) генерируем одноцикловый key_pulse_o. На отпускании — нет.

Такая схема:

  • Одно нажатие даёт ровно один key_pulse_o — идеально для FSM ssd1306_ctrl.

  • Любые дребезги короче 20 мс игнорируются.

  • Нажатия короче 20 мс тоже игнорируются — это защищает от помех. Пользователь так быстро нажать физически не может.

  • Задержка распознавания — 20 мс. Нечувствительно для человека.

CNT_MAX = 1 000 000 = 0xF4240 — помещается в 20 бит (максимум 0xFFFFF = 1 048 575). Разрядность cnt подобрана с запасом под параметр DEBOUNCE_MS до ~20 мс.

I2C Master Core — подключение (строки 97–119)

wire       cmd_valid, ready, rx_ack, arb_lost, busy;
wire       arb_lost_clear;
wire [2:0] cmd;
wire [7:0] din;

i2c_master_core u_core (
    .clk_i            (clk_50m),
    .rstn_i           (rst_n),
    .ena_i            (core_ena),          // от прескалера, 100 кГц × 4
    .cmd_valid_i      (cmd_valid),         // от burst_writer
    .cmd_i            (cmd),
    .din_i            (din),
    .dout_o           (),                  // не используется (read mode off)
    .rx_ack_o         (rx_ack),
    .ready_o          (ready),
    .arb_lost_o       (arb_lost),
    .arb_lost_clear_i (arb_lost_clear),
    .busy_o           (busy),
    .scl_i            (scl_s),             // синхронизированный вход
    .scl_oen_o        (scl_oen),
    .sda_i            (sda_s),
    .sda_oen_o        (sda_oen)
);

Ключевые связи:

  • ena_i = core_ena — строба для квартала SCL‑периода (см. 8.3);

  • cmd_valid_i/cmd_i/din_i — от u_ssd → u_burst, командный интерфейс;

  • scl_i/sda_i — синхронизированные входы (scl_s/sda_s), не сырые пиновые;

  • scl_oen_o/sda_oen_o — управление tri‑state буферами (см. 8.4);

  • busy_o не подключен к LED — есть более информативный ssd_busy от верхнего уровня.

  • dout_o не используется — мы только пишем на SSD1306, никогда не читаем.

SSD1306 Controller — подключение (строки 124–148)

wire        ssd_busy, ssd_done, ssd_err, ssd_animating;
wire [10:0] ssd_progress;

ssd1306_ctrl #(
    .CLK_FREQ (50_000_000),
    .I2C_ADDR (7'h3C),
    .DELAY_MS (100)
) u_ssd (
    .clk_i            (clk_50m),
    .rstn_i           (rst_n),
    .start_i          (key_start_pulse),   // KEY2 после дебаунса
    .anim_i           (key_anim_pulse),    // KEY3 после дебаунса
    .busy_o           (ssd_busy),
    .done_o           (ssd_done),
    .err_o            (ssd_err),
    .progress_o       (ssd_progress),
    .animating_o      (ssd_animating),
    .cmd_valid_o      (cmd_valid),         // → u_core
    .cmd_o            (cmd),
    .din_o            (din),
    .ready_i          (ready),             // ← u_core
    .rx_ack_i         (rx_ack),
    .arb_lost_i       (arb_lost),
    .arb_lost_clear_o (arb_lost_clear)
);

Все три параметра выставлены «явно» — несмотря на то, что это значения по умолчанию. Это защита от случайных изменений defaults в ssd1306_ctrl.v.

u_ssd автоматически внутри инстанциирует u_burst (i2c_burst_writer) и u_scene (scene_renderer) — их не нужно руками подключать на top‑level, это часть контракта ssd1306_ctrl.

LED‑индикация (строки 153–166)

reg ssd_done_latch;
always @(posedge clk_50m or negedge rst_n) begin
    if (!rst_n)
        ssd_done_latch <= 1'b0;
    else if (ssd_done)
        ssd_done_latch <= 1'b1;
    else if (key_start_pulse || key_anim_pulse)
        ssd_done_latch <= 1'b0;
end

assign led[0] = ssd_busy;           // занят
assign led[1] = ssd_done_latch;     // успех (latch!)
assign led[2] = ssd_err;            // ошибка
assign led[3] = ssd_animating;      // анимация

Разбор:

ssd_done_latch — SR‑защёлка:

  • Set по ssd_done (одноцикловый impulse из ssd1306_ctrl). Если бы подключили его напрямую к LED, пользователь бы увидел всего 20 нс вспышки = незаметно.

  • Reset на новое нажатие кнопки — чтобы «погасить» успех предыдущего кадра и показать «идёт новый».

LED активно‑high (от .qsf: PIN_E10, PIN_F9, PIN_C9, PIN_D9). На AX301 это жёлтые индикаторы возле FPGA.

Семантика для пользователя:

LED

Смысл

LED0 (busy)

горит во время передачи (init / frame)

LED1 (done)

горит после последнего успешного кадра

LED2 (err)

горит, если была ошибка (NACK / arb‑lost)

LED3 (animating)

мигает в такт анимации, показывает «цикл идёт»

Типовой сценарий:

  • Включение платы: все LED выключены, идёт POR‑reset.

  • Нажатие KEY2: LED0 загорается на ~46 мс, затем LED1.

  • Нажатие KEY3: LED0 + LED3 горят постоянно (цикл); повторное KEY3 → LED0 гаснет, LED1 загорается (последний кадр завершён).

  • Если дисплей отключён: LED0 мигает, через ~1 мс LED2 загорается, LED0 гаснет.

7-сегментный дисплей — hex‑шрифт (строки 171–196)

Функция конвертации hex‑числа в битовое представление сегментов:

function [7:0] seg_hex;
    input [3:0] val;
    begin
        case (val)
            4'h0: seg_hex = 8'hC0;   // 1100 0000
            4'h1: seg_hex = 8'hF9;   // 1111 1001
            4'h2: seg_hex = 8'hA4;
            4'h3: seg_hex = 8'hB0;
            4'h4: seg_hex = 8'h99;
            4'h5: seg_hex = 8'h92;
            4'h6: seg_hex = 8'h82;
            4'h7: seg_hex = 8'hF8;
            4'h8: seg_hex = 8'h80;
            4'h9: seg_hex = 8'h90;
            4'hA: seg_hex = 8'h88;
            4'hB: seg_hex = 8'h83;
            4'hC: seg_hex = 8'hC6;
            4'hD: seg_hex = 8'hA1;
            4'hE: seg_hex = 8'h86;
            4'hF: seg_hex = 8'h8E;
            default: seg_hex = 8'hFF;  // всё выключено
        endcase
    end
endfunction

Битовая раскладка (7-segment common‑anode, active‑low):

     a
    ───
  f│   │b
   │ g │
    ───
  e│   │c
   │   │
    ───   ·dp
     d

Бит

Сегмент

0 = горит

[0]

a (верх)

[1]

b (верх‑право)

[2]

c (низ‑право)

[3]

d (низ)

[4]

e (низ‑лево)

[5]

f (верх‑лево)

[6]

g (средний)

[7]

dp (точка)

Пример для '0': все кроме g и dp → биты abcdefg. = 0000001. = бинарно 11000000 = 0xC0. ✓

Дополнительные символы:

localparam [7:0] SEG_BLANK = 8'hFF;    // все выключены
localparam [7:0] SEG_DASH  = 8'hBF;    // только g (средний)
localparam [7:0] SEG_A     = 8'h88;    // то же, что 'A'

И ещё прямо в mux‑строке:

  • 8'h86 = 'E' (a, d, e, f, g) — отображение ошибки.

7-сегментный дисплей — mux и содержимое (строки 198–224)

wire [7:0] seg_d0 = ssd_err        ? 8'h86 :            // 'E'
                    ssd_animating  ? SEG_A  :            // 'A'
                    ssd_done_latch ? seg_hex(4'h0)       // '0'
                                   : SEG_DASH;           // '-'
wire [7:0] seg_d1 = seg_hex(ssd_progress[3:0]);
wire [7:0] seg_d2 = seg_hex(ssd_progress[7:4]);
wire [7:0] seg_d3 = seg_hex({1'b0, ssd_progress[10:8]});
wire [7:0] seg_d4 = SEG_BLANK;
wire [7:0] seg_d5 = SEG_BLANK;

seg_scan #(.SCAN_BITS(16)) u_seg (
    .clk_i     (clk_50m),
    .rstn_i    (rst_n),
    .seg_data_0(seg_d0),
    .seg_data_1(seg_d1),
    .seg_data_2(seg_d2),
    .seg_data_3(seg_d3),
    .seg_data_4(seg_d4),
    .seg_data_5(seg_d5),
    .seg_sel   (seg_sel),
    .seg_data  (seg_data)
);

Раскладка 6 цифр на плате AX301 (слева направо):

 [seg_d5] [seg_d4]  [seg_d3]      [seg_d2]     [seg_d1]    [seg_d0]
  ( · )    ( · )   (prog[10:8])   (prog[7:4])  (prog[3:0]) (status)
  BLANK    BLANK     hex top       hex mid     hex low     -/0/E/A
  • seg_d5, seg_d4 — пустые, ничего не отображают.

  • seg_d3 — старшие 3 бита progress_o (0..7), дополнены нулём до 4 бит.

  • seg_d2 — средние 4 бита.

  • seg_d1 — младшие 4 бита.

  • seg_d0 — статус‑цифра по приоритету:

    1. ssd_err'E' (горит всё время ошибки);

    2. ssd_animating'A' (анимация активна);

    3. ssd_done_latch'0' (успех, защёлка);

    4. иначе → '-' (idle или busy без завершения).

Пример отображения в середине передачи frame:

Событие

Экран

idle

- - - - - - (все dashes)

start передачи frame

- - 0 0 0 - (progress=0..0)

передано 0×0200 байт

- - 2 0 0 -

передано 0×0400 = 1024

- - 4 0 0 0 (успех)

идёт анимация, кадр N

- - X X X A (X меняется)

ошибка

- - - - - E

7-сегментный сканер — seg_scan.v (43 строки)

module seg_scan #(
    parameter SCAN_BITS = 16
)(
    input  wire       clk_i,
    input  wire       rstn_i,
    input  wire [7:0] seg_data_0,
    ...
    input  wire [7:0] seg_data_5,
    output reg  [5:0] seg_sel,
    output reg  [7:0] seg_data
);
    reg [SCAN_BITS-1:0] scan_cnt;
    wire [2:0] scan_idx = scan_cnt[SCAN_BITS-1 : SCAN_BITS-3];

    always @(posedge clk_i or negedge rstn_i) begin
        if (!rstn_i)
            scan_cnt <= {SCAN_BITS{1'b0}};
        else
            scan_cnt <= scan_cnt + 1'b1;
    end

    always @(*) begin
        case (scan_idx)
            3'd0: begin seg_sel = 6'b111110; seg_data = seg_data_0; end
            3'd1: begin seg_sel = 6'b111101; seg_data = seg_data_1; end
            3'd2: begin seg_sel = 6'b111011; seg_data = seg_data_2; end
            3'd3: begin seg_sel = 6'b110111; seg_data = seg_data_3; end
            3'd4: begin seg_sel = 6'b101111; seg_data = seg_data_4; end
            3'd5: begin seg_sel = 6'b011111; seg_data = seg_data_5; end
            default: begin seg_sel = 6'b111111; seg_data = 8'hFF; end
        endcase
    end
endmodule

Алгоритм:

  1. 16-битный счётчик scan_cnt считает от 0 до 65 535 и перекатывается. Частота — clk_50m / 2^16 = ~763 Гц.

  2. Старшие 3 бита scan_cnt[15:13] = scan_idx задают текущую активную цифру (0..7, из них реально используются 0..5).

  3. Частота обновления одной цифры = 763 / 8 ≈ 95 Гц. Выше порога мерцания (~60 Гц) — человеческий глаз видит стабильное изображение.

  4. Скважность — 1/8 на цифру, так как 2 пустых слота (6, 7) + активна только одна из шести. Реальная яркость ≈ 12.5% от «все цифры всегда горят». Это стандарт для мультиплексированных индикаторов.

  5. seg_sel — one‑hot active‑low (6'b111110 — активна цифра 0, остальные off). Так индикатор работает на AX301.

Назначение пинов в.qsf — полная таблица

Из quartus_ssd1306/ssd1306_test_top.qsf (извлечено дословно):

Глобальные параметры:

FAMILY            = "Cyclone IV E"
DEVICE            = EP4CE6F17C8
TOP_LEVEL_ENTITY  = ssd1306_test_top
PROJECT_OUTPUT_DIRECTORY = output_files
MIN_CORE_JUNCTION_TEMP = 0
MAX_CORE_JUNCTION_TEMP = 85
NOMINAL_CORE_SUPPLY_VOLTAGE = 1.2V
LAST_QUARTUS_VERSION = "25.1std.0 Standard Edition"

Source files:

src/ssd1306_test_top.v
src/ssd1306_ctrl.v
src/scene_renderer.v
src/seg_scan.v
src/ax_debounce.v
../rtl/i2c_master_core.v
../rtl/i2c_burst_writer.v
ssd1306_test_top.sdc

Все файлы с IO_STANDARD = 3.3-V LVTTL.

Пины:

Сигнал

Пин FPGA

Замечание

clk_50m

E1

кварц 50 МГц, GCLK‑пин

rst_n

N13

кнопка RESET (active‑low)

key_start

M15

KEY2 (active‑low)

key_anim

M16

KEY3 (active‑low)

i2c_scl

E8

совместно с EEPROM 24C04 (0×50)

i2c_sda

E9

led[0]

E10

active‑high

led[1]

F9

active‑high

led[2]

C9

active‑high

led[3]

D9

active‑high

seg_sel[0]

N9

active‑low

seg_sel[1]

P9

seg_sel[2]

M10

seg_sel[3]

N11

seg_sel[4]

P11

seg_sel[5]

M11

seg_data[0] (a)

R14

active‑low

seg_data[1] (b)

N16

seg_data[2] ©

P16

seg_data[3] (d)

T15

seg_data[4] (e)

P15

seg_data[5] (f)

N12

seg_data[6] (g)

N15

seg_data[7] (dp)

R16

Altera configuration pins (обязательные для корректной настройки flash‑программирования, не меняются):

PIN_C1  → ~ALTERA_ASDO_DATA1~
PIN_D2  → ~ALTERA_FLASH_nCE_nCSO~
PIN_H1  → ~ALTERA_DCLK~
PIN_H2  → ~ALTERA_DATA0~
PIN_F16 → ~ALTERA_nCEO~

Важно! В предыдущей версии гайда были указаны пины E6 и D1 для I2C — это устаревшая версия. Актуальная разводка из текущего .qsf: i2c_scl = E8, i2c_sda = E9. Оба пина выведены на общую I2C‑шину AX301, к которой уже подключён bootloader‑EEPROM 24C04 (адрес 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) — в scene_renderer.S_ROT_MUL

I/O pins

24

26% (24 / 92) — без учёта конфиг‑пинов

Fmax (clk_50m)

>80 МГц

1.6× от требуемых 50 МГц

По памяти и DSP остаётся много запаса — можно добавить dual‑buffer (7.22) или вторую сцену, не выходя за рамки чипа.

Типичные ошибки сборки и их решение

Симптом

Причина

Решение

unresolved reference to I2C_ADDR

забыли source .qsf для rtl/

убедиться, что ../rtl/i2c_master_core.v и ../rtl/i2c_burst_writer.v в .qsf

tri-state buffer cannot drive 1

напрямую assign sda = 1'b1

использовать assign sda = oen ? 1'bz : 1'b0

нет реакции на кнопки

неправильный polarity (ожидается active‑low)

в .qsf проверить WEAK_PULL_UP_RESISTOR = ON для keys, либо внешний pull‑up

дисплей не показывает ничего

не подтянуты pull‑up на SDA/SCL

см. 2.1.5, резисторы 4.7 кОм

«зависает» на init

адрес 0×3D (правая конфигурация 0×78/0×7A)

проверить I2C_ADDR в .qsf и режим OLED; в нашем случае 7'h3C

unconstrained I/O warnings

нет .sdc‑файла

добавить ограничения (см. 8.13)

Fmax < 50 МГц

длинные комб‑пути

обычно не возникает с этой логикой; при расширении — pipeline registers

timing violation на scene_rdata

ошибочная реплика BRAM в LE

включить -auto_ram_recognition on в Quartus

Краткий чек‑лист запуска на плате

  1. Проверка питания: VCCIO 3.3V, VCC_INT 1.2V на FPGA.

  2. Подключить OLED к I2C‑шине AX301: SDA → E9, SCL → E8, VCC → 3.3V (или 5V, зависит от модели модуля), GND → GND.

  3. Прошить .sof через USB‑Blaster.

  4. После reset (кнопка RESET на плате) — все LED должны быть выключены, 7-seg должен показывать - - - - - -.

  5. Нажать KEY2 — через ~100 мс зажигается LED0, идёт init; через ~1.5 мс — передача frame; через ~95 мс — LED1 загорается, на экране OLED виден статический кадр.

  6. Нажать KEY3 — LED0 + LED3 горят постоянно, OLED показывает вращающийся куб. Повторное KEY3 — остановка.

  7. Если 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, но дисплей не засветился. Возможные причины:

  1. Charge pump не включён. Проверить команду 0x8D, 0x14 в init_rom (байты 9 и 10). Без неё OLED‑ячейки не получат 7 В.

  2. Wrong multiplex ratio. Для 128×64 должно быть 0xA8, 0x3F. Для 128×32 — 0xA8, 0x1F.

  3. Display ON отсутствует. Последняя команда 0xAF (байт 31).

  4. Слишком тёмная контрастность. Проверить 0x81, 0xCF. Можно увеличить до 0xFF.

LED[2] загорается сразу после кнопки

Это NACK на байте адреса. Смотрим на 7-сегмент:

  • seg_d1..seg_d3 = 000 → NACK на адресе 0x78.

  • seg_d1..seg_d3 = 001 → NACK на первом байте данных (маловероятно для init).

Возможные причины:

  1. Неверный I2C‑адрес. SSD1306 может быть 0×3C или 0×3D в зависимости от пина SA0. Проверить модуль.

  2. Не подано питание на модуль OLED.

  3. SDA/SCL перепутаны. Запустить логический анализатор, проверить START‑условие: 1→0 на SDA при SCL=1.

  4. Pull‑up отсутствуют. Плата AX301 имеет встроенные, но если используется отладочная шина — добавить 4.7 кОм к 3.3 В.

Изображение перевёрнуто / зеркально

Модули SSD1306 от разных производителей «смонтированы» по‑разному. Переключите:

  • 0xA10xA0 — горизонтальное зеркало.

  • 0xC80xC0 — вертикальное зеркало.

Для нашей конфигурации 0xA1, 0xC8 — корректно для большинства «красно‑зелёных» 128×64 модулей на I2C.

Куб «едет» или занимает не всё место

Причины:

  1. Границы адресации не установлены. Проверить 0x21 00 7F и 0x22 00 07.

  2. Выход вершины за ±31 по Y. Проверить, что S = 12 и (z >>> 2) + vy + 32 ∈ [0, 63]. При большем S куб может «срезаться» по верху/низу.

Точки рисуются «в клетке»

Если Брезенхэм даёт ступенчатую картинку с разрывами — проверяем:

  1. Знак dy. Должен быть отрицательным (классический Брезенхэм использует dy = -|y1 - y0|). В нашем коде это init_dy_n.

  2. Направление sx, sy. Должны соответствовать знаку шага.

  3. Сравнение e2 >= vs e2 <=. Для x‑шага: e2 >= bdy_ext (bdy_ext ≤ 0), для y‑шага: e2 <= bdx_ext (bdx_ext ≥ 0).

LED[0] горит постоянно, счётчик не меняется

Шина заблокирована. Типичные варианты:

  • SSD1306 удерживает SCL низким (clock stretching). Наше ядро умеет это обрабатывать в фазе 1 datapath, проверяем scl_i. Если физически внешняя сила удерживает SCL — проверить pull‑up и отсутствие замыкания на GND.

  • Arbitration lost: другой мастер захватил шину. У нас их не должно быть. Возможно, EEPROM 24LC04 застряла в транзакции. Решение — сброс FPGA (кнопка rst_n).

SignalTap / виртуальный логический анализатор

Для отладки рекомендуется вставить SignalTap на сигналы:

  • cmd_valid, cmd, din, ready, rx_ack

  • bw_state, bw_data_req, bw_data, bw_done, bw_error

  • phase, src_idx, scene_ready

  • sda_oen, scl_oen, sda_i, scl_i

Один SignalTap instance с глубиной 2048 покрывает 2048 × 20 нс = 40 мкс, достаточно чтобы зафиксировать выдачу одного I2C‑байта полностью.

Ресурсы FPGA и запас для расширений

По результатам компиляции Quartus (Cyclone IV EP4CE6F17C8):

Ресурс

Использовано

Всего

Logic Elements (LE)

≈ 1 500

6 272

24%

Registers

≈ 900

≈ 12 500

7%

M9K Block RAM

1 (FB 1 KiB)

30

3%

Embedded 9×9 multipliers

4

15

27%

Total pins

24

180

13%

f_max clk_50m

> 75 МГц

≥ 50 МГц

Что это означает:

  • У нас 30× запас по BRAM (можно сделать двойную буферизацию, или расширить FB до 4 KiB для 256×64 дисплея).

  • 4 свободных умножителя (можно поворачивать вокруг двух осей одновременно).

  • 4700 LE свободно — можно добавить UART‑консоль, VGA‑вывод, CORDIC‑ядро для плавной 3D‑графики.

Идеи по расширению проекта

  1. Поворот вокруг двух осей. Добавить вторую фазу поворота (вокруг X), вторую LUT сдвигом на π/2. Удвоит число умножений, но 8 множителей у нас есть.

  2. Перспективная проекция. Заменить orthographic на px = (x · FOCAL) / (FOCAL + z'). Потребуется хардверный делитель или reciprocal‑LUT.

  3. Скрытие задних рёбер. Вычислять z‑среднее каждого ребра, сортировать, рисовать от дальнего к ближнему; либо использовать normal‑vector culling. В обоих случаях нужен Z‑buffer (ещё один M9K‑блок).

  4. Кастомный шрифт и UTF-8. Расширить font ROM до 256 символов (8 × 256 = 2 KiB). Тексты хранить как string ROM с длиной.

  5. Запись пользовательской картинки. Загружать в FB bitmap из внешнего источника: SPI‑flash, UART, AXI‑DMA, SD‑карта.

  6. Несколько дисплеев. Разделить по нескольким адресам (0×3C, 0×3D) или использовать I2C mux PCA9548A.

  7. Оптимизация. Вынести CLEAR в параллельный always‑блок с широким портом M9K (64-bit), ускорив очистку в 8 раз до 128 тактов.

Ключевые параметры и где их менять

Параметр

Где

Значение по умолчанию

Частота SCL

prescaler, PRE_TOP

124 → 100 кГц

I2C‑адрес SSD1306

ssd1306_ctrl, I2C_ADDR

7'h3C

Задержка питания

ssd1306_ctrl, DELAY_MS

100 мс

Полуребро куба

scene_renderer, S

12

Позиция верхнего текста

scene_renderer, TOP_COL0

43

Период дребезга

ax_debounce, DEBOUNCE_MS

20 мс

Послесловие

Этот проект — демонстрация того, как далеко может уйти fixed‑function pipeline в FPGA без привлечения софтверных абстракций. Весь контур от кнопки до OLED — это ~2 000 Verilog‑строк, детерминированные автоматы, случайная BRAM и квартир‑волновая LUT. Ни байта программного кода. Ни одного тактового цикла, потраченного впустую.

Одновременно этот проект — стенд‑надёжности для ядра i2c_master_core: он гоняет по шине 1060 байт подряд каждые 95 мс, непрерывно. Любая мелкая ошибка в бит‑слотах, ACK‑сэмплировании или обработке clock‑stretching проявится как «застрявший счётчик» на 7-сегменте — что немедленно укажет адрес проблемы.

Удачной компиляции.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.

Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться