Откройте любой учебный материал по FPGA. С вероятностью девяносто процентов он закончится мигающим светодиодом, бегущей строкой на семисегментнике или, в лучшем случае, генератором VGA-сигнала с цветными полосами. Это нормальные учебные задачи, но у них есть общее свойство, о котором редко говорят вслух: они работают сразу или не работают совсем. Прошили — мигает. Не мигает — перепроверили три строчки кода, нашли опечатку, мигает.

Реальная инженерная работа с ПЛИС устроена иначе. Реальная задача — это когда ваша микросхема должна разговаривать с другой микросхемой по протоколу, у которого есть спецификация на сорок страниц, четыре режима тактирования и подводные камни в каждом втором абзаце даташита. Когда «вроде работает» — это худший из возможных результатов, потому что «вроде» означает «упадёт у заказчика». И когда между «код написан» и «устройство работает» лежат недели верификации и отладки.

Эта статья - следующий шаг в моем обучении и попытка показать полный путь: от фразы "нам нужен SPI-контроллер" до момента, когда логический анализатор показывает красивые пачки импульсов на ножках кристалла. Здесь будут не только архитектурные решения и листинги, но и все ошибки, которые были сделаны по дороге: testbench, который врал, потому что master и slave-модель содержали один и тот же баг; гонка на один такт, из-за которой тест прерываний падал при исправном железе; и финальный детектив — прошивка, в которой SPI-сигналы честно генерировались... на других ножках, потому что файл назначения пинов на диске оказался не тем, который мы правили. Каждая такая история — это методика: симптом, гипотеза, эксперимент, причина, фикс.

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…

Глава 1. О чём эта статья и почему она длинная

Что мы построим

Предмет статьи — SPI master контроллер уровня “можно вставлять в продукт”, а не “демо для старта”. Вот список того, что задуманно:

  • Все четыре режима SPI (комбинации CPOL/CPHA) — потому что в реальном мире флешка хочет mode 0, какой-нибудь ADC — mode 1, а датчик — mode 3.

  • Порядок бит MSB-first и LSB-first, переключаемый на лету.

  • Длина слова 8, 16, 24 или 32 бита — тоже на лету, без пересинтеза.

  • FIFO-буферы на передачу и приём — хост загружает пачку слов и занимается своими делами, а не дёргает каждый байт.

  • До четырёх линий выбора устройства (CS) с программируемыми задержками: CS-setup, CS-hold, пауза между словами в пакете.

  • Карта регистров с управлением, статусом, sticky-флагами ошибок и маскируемыми прерываниями — как у «взрослых» периферийных блоков в микроконтроллерах.

  • Один тактовый домен, чистый синтезируемый Verilog-2001, ни одного вендорского IP-блока — переносится на любую ПЛИС.

Рядом с контроллером — обвязка, которая превращает его из «модуля в вакууме» в самодостаточное устройство: ROM-секвенсор начальной инициализации (вместо процессора), автоматический опрос сенсорного контроллера XPT2046 и LCD-дисплей, на который выводится статус и живые координаты касания. Дисплей в этом проекте — не самоцель, а по сути приборная панель: способ видеть, что происходит внутри, не подключая отладчик.

Целевое железо — плата ALINX PIAX301V2 с Cyclone IV EP4CE6F17C8: один из самых дешёвых и распространённых кристаллов Altera/Intel. Итоговый дизайн занимает около 20% его логики — запас на ваши эксперименты остаётся огромный.

Если разложить время, потраченное на этот проект, по видам работ, получится примерно так:

Работа

Доля времени

Написание синтезируемого RTL

~30%

Testbench, модели, прогоны симуляции

~35%

Отладка по результатам симуляции

~15%

Quartus: пины, констрейнты, отчёты

~10%

Bring-up на железе и охота за сигналами

~10%

Это не особенность конкретного проекта — это норма отрасли. У новичков пропорция вызывает протест: «я же уже написал код, почему всё ещё ничего не работает?» Ответ простой: написанный код — это гипотеза. Верификация — эксперимент, который её проверяет. А bring-up — встреча гипотезы с физическим миром, у которого есть паразитные ёмкости, подтягивающие резисторы и файлы конфигурации, которые вы забыли сохранить. Поэтому статья устроена так же, как устроена работа: четверть объёма — про то, как написать контроллер, и три четверти — про то, как убедиться, что он работает как надо, и что делать, когда он не работает.

Что вы будете уметь после прочтения

Прочитав статью целиком и повторив шаги руками, вы сможете:

  1. Превратить размытую формулировку («нужно управлять SPI-устройствами») в техническое задание с измеримыми пунктами. 

  2. Разбить устройство на модули так, чтобы каждый можно было написать, протестировать и заменить независимо.

  3. Спроектировать карту регистров, которой удобно пользоваться со стороны процессора, — с правильными соглашениями про sticky-биты, W1C и self-clearing стробы.

  4. Написать синтезируемый Verilog без защёлок, гонок и прочей классики жанра.

  5. Построить независимую верификацию: эталонную модель устройства, self-checking testbench, матрицу покрытия по режимам.

  6. Пройти маршрут Quartus: проект, пины, временные констрейнты, чтение отчётов — и понять, какие предупреждения смертельны, а какие нет.

  7. Системно отлаживать железо: локализовать проблему за минимальное число экспериментов, а не тыкать щупом наугад.

И, пожалуй, главное: у вас появится рабочий шаблон проекта — структура каталогов, Makefile, стиль кода, подход к тестам, — который можно переиспользовать для любого следующего периферийного блока: I2C, UART, контроллера дисплея, чего угодно.

Что нужно знать на входе

Статья рассчитана на читателя, который:

  • умеет программировать хоть на чём-нибудь (понятия «переменная», «цикл», «функция» не требуют объяснения);

  • видел двоичную и шестнадцатеричную системы счисления;

  • слышал, что такое ПЛИС, и, возможно, мигал светодиодом.

Знание Verilog не требуется — весь необходимый синтаксис вводится в части I, а дальше закрепляется на живом коде. Если вы уже пишете на Verilog/VHDL — главы 5–6 можно пролистать по диагонали, но раздел про блокирующие/неблокирующие присваивания рекомендую не пропускать: половина багов начинающих растёт именно оттуда. 

Из железа понадобится плата с Cyclone IV (мы используем ALINX PIAX301V2, но подойдёт почти любая — отличаться будет только файл назначения пинов), LCD-модуль AN430 с сенсорной панелью для финальной части и — желательно, но не обязательно — логический анализатор за десять долларов. Всё ПО бесплатное: Quartus Prime Lite, Icarus Verilog, GTKWave.

Глава 2. Техническое задание — формулируем как взрослые

Зачем ТЗ проекту, у которого нет заказчика

Самое частое возражение: «это же мой собственный проект, какое ещё ТЗ, я и так знаю, что хочу». Опыт показывает обратное: именно в проектах без внешнего заказчика ТЗ нужнее всего, потому что без него вы — заказчик, который меняет требования каждый вечер. Сегодня «просто SPI», завтра «а пусть ещё и QSPI», послезавтра проект заброшен на стадии рефакторинга того, что вчера казалось важным.

У письменного ТЗ в нашем деле три конкретные функции:

Контракт с самим собой. Список того, что входит в проект, — и, что важнее, того, что не входит. Когда посреди реализации появится соблазн «добавить ещё вот это», вы посмотрите в документ и честно ответите: это новая фича, она пойдёт в версию 2.

Источник архитектуры. Каждое требование тянет за собой структурное решение. «Длина слова меняется на лету» — значит, длина не параметр синтеза, а регистр. «FIFO» — значит, появляются модули буферов и флаги переполнения. Хорошее ТЗ — это почти готовая блок-схема, нужно только аккуратно её вытащить (этим займёмся в главе 7).

Источник тестов. Это главный пункт. Каждая строка ТЗ должна быть проверяемой — настолько конкретной, чтобы по ней можно было написать тест с однозначным вердиктом PASS/FAIL. «Контроллер должен быстро передавать данные» — не требование, а пожелание. «Контроллер должен передавать слова 8/16/24/32 бита во всех четырёх режимах SPI, и принятые slave-моделью данные должны побитно совпадать с переданными» — требование: в части IV оно превратится в восемь конкретных тест-кейсов.

В конце главы мы сведём всё в таблицу трассируемости «требование → тест». Забегая вперёд: когда в главе 18 наш testbench напечатает ALL TESTS PASSED (18 cases), это будет означать не «вроде работает», а «каждый пункт ТЗ проверен и выполнен». Почувствуйте разницу.

Функциональные требования: разбор по пунктам

Пройдём по требованиям так, как они формулировались в начале проекта. Для каждого — три вопроса: что это значит, откуда берётся и как проверим.

Т1. Режимы SPI 0–3 (CPOL, CPHA)

Что значит. У SPI нет единого стандарта тактирования — есть четыре комбинации полярности клока (CPOL: уровень SCLK в покое) и фазы (CPHA: по какому фронту защёлкивать данные). Подробно разберём в главе 4; пока важно одно — режим задаётся устройством-слейвом, а не нашим желанием.

Откуда берётся. Из даташитов реальных устройств: SPI-флешки обычно mode 0 или 3, многие АЦП — mode 1, наш будущий тестовый XPT2046 — mode 0. Контроллер «только mode 0» — это контроллер «только для половины устройств на рынке».

Как проверим. Четыре тест-кейса, по одному на режим: передача и приём байта с независимой slave-моделью, которая сама защёлкивает данные по фронтам SCLK согласно режиму. Совпало побитно в обе стороны — PASS.

Т2. Порядок бит: MSB-first обязательно, LSB-first опционально

Что значит. Большинство устройств передают старший бит первым (MSB-first), но встречаются и обратные. Переключение — на лету, битом в регистре.

Как проверим. Тесты LSB-first на 8 и 16 бит плюс комбинированный «mode 1 + LSB» — комбинация двух нетривиальных опций ловит баги, которые каждая опция по отдельности не проявляет. Это правило хорошего покрытия: пересечения фич багоопаснее самих фич.

Т3. Длина слова 8/16/24/32 бита, на лету

Что значит. Регистр WORD_LEN, а не параметр синтеза. Контроллер тач-сенсора XPT2046, например, хочет 24-битные транзакции; флешке хватает 8. 

Как проверим. По тесту на каждую длину. Плюс негативный тест: запись недопустимой длины (например, 12) должна не повесить контроллер, а поднять флаг ошибки ERR_WORD_LEN — об этом ниже, в Т8.

Т4. FIFO на передачу и приём

Что значит. Хост загружает в TX FIFO пачку слов и уходит; контроллер сам передаёт их одно за другим. Принятые слова копятся в RX FIFO. Глубина — параметр синтеза (по умолчанию 8), потому что это компромисс ресурсов, а не поведение.

Откуда берётся. Без FIFO хост обязан успевать к каждому слову — для процессора это прерывание на каждый байт, безумие даже на 1 МГц SCLK. 

Как проверим. Burst-тест: загружаем три слова, даём один START, проверяем, что все три ушли подряд под одним CS и все три ответа легли в RX FIFO в правильном порядке.

Т5. Несколько линий CS с программируемым выбором

Что значит. Параметр NUM_CS (по умолчанию 4) и регистр CS_SELECT: к одной шине подключены несколько устройств, активное выбирается перед транзакцией.

Как проверим. Тест маршрутизации: выбираем CS1, проверяем что во время транзакции CS1 = 0, а CS0 остался = 1, и что после транзакции CS1 вернулся в 1.

Т6. Программируемые задержки CS-setup / CS-hold / inter-word

Что значит. Между опусканием CS и первым фронтом SCLK многие slave требуют паузу (CS-setup); аналогично перед поднятием CS (CS-hold) и между словами пакета (inter-word). Все три — поля одного регистра DELAY_CFG, в тактах системного клока.

Откуда берётся. Прямо из даташитов: у XPT2046, к примеру, есть минимальное время от CS до первого клока. Контроллер без задержек работает «на честном слове» — до первого привередливого устройства. 

Как проверим. В основном визуально по wave симуляции (расстояние между фронтами), плюс косвенно: все остальные тесты гоняются с ненулевыми задержками — если бы логика задержек ломала транзакции, упало бы всё.

Т7. Регистровый интерфейс «memory-mapped»

Что значит. Управление контроллером — через чтение/запись регистров по адресам, как у периферии микроконтроллера: wr_en, rd_en, addr, wr_data, rd_data, ready. Полная карта регистров — предмет главы 8.

Откуда берётся. Это интерфейсная конвенция, которая стыкуется с чем угодно: софт-процессор Nios II, внешний MCU, наш будущий ROM-секвенсор. Шину делаем нарочито простой и vendor-neutral — мост на Avalon-MM или AXI4-Lite при необходимости пишется за вечер.

Как проверим. Каждый тест использует этот интерфейс — он проверяется постоянно. Отдельно: тест значений регистров после сброса.

Т8. Флаги ошибок — sticky, с явной очисткой

Что значит. Контроллер должен сообщать о нештатных ситуациях: переполнение TX FIFO (хост пишет в полный буфер), переполнение RX FIFO (некому забирать данные), старт с пустым TX FIFO, недопустимая длина слова. Флаги — липкие (sticky): однажды взведённый флаг висит, пока хост явно не снимет его записью в регистр очистки (W1C — write-1-to-clear).

Откуда берётся. Ошибка, которая мелькнула на один такт и исчезла, — это ошибка, которую никто никогда не увидит. Sticky-семантика гарантирует: если что-то пошло не так между двумя опросами статуса — вы об этом узнаете.

Как проверим. По негативному тесту на каждый флаг: специально провоцируем ситуацию, проверяем что флаг взведён, квитируем, проверяем что снят.

Т9. Прерывания: done / rx_valid / error, с маской

Что значит. Один выход irq, три источника, регистр маски (что разрешено) и регистр статуса (что случилось). Альтернатива поллингу для систем с процессором.

Как проверим. Сценарный тест: маскируем всё → событие происходит → irq молчит; разрешаем источник → irq поднимается; квитируем → irq опускается. Забегая в главу 20: именно этот тест подарит нам красивую гонку на один такт.

Т10. Soft reset

Что значит. Бит в CONTROL, который приводит engine и FIFO в исходное состояние, не трогая конфигурационные регистры. Спасательный круг для хоста, если транзакция зависла из-за внешних причин. 

Как проверим. Тест: запускаем транзакцию, посреди неё — SOFT_RST, проверяем что BUSY снялся и контроллер готов к новой передаче.

Нефункциональные требования

Эти пункты не видны в списке фич, но определяют качество результата сильнее любого из Т1–Т10:

  • Н1. Один тактовый домен. Вся логика — на системном клоке 50 МГц; SCLK — не «клок», а обычный выходной сигнал, сформированный делителем. Почему это решение — фундамент всего проекта, разберём в главе 6.

  • Н2. Чистый Verilog-2001, ни одного вендорского примитива. Проект обязан синтезироваться и под Altera, и под Xilinx, и под Lattice, и симулироваться бесплатным Icarus Verilog.

  • Н3. Синтезируемость без сюрпризов. Никаких защёлок, никаких initial в рабочей логике, никаких #задержек вне testbench; определённое состояние после сброса у каждого регистра.

  • Н4. Параметризуемость того, что является ресурсным компромиссом. Глубина FIFO, число CS, число ступеней синхронизатора MISO — параметры. То, что является поведением (режим, длина слова), — регистры.

  • Н5. Верифицируемость. Каждое требование Т1–Т10 покрыто самопроверяющимся тестом. Это требование к процессу, и оно дороже всех остальных вместе взятых.

Таблица трассируемости: требование → тест

Так выглядит контракт в финальном виде. Третий столбец заполнен «задним числом» — реальными тестами из sim/tb_spi_master.v, которые мы напишем в части IV. В живом проекте таблица заполняется по мере написания тестов и служит детектором дыр в покрытии: пустая ячейка = непроверенное требование.

Требование

Тесты в tb_spi_master.v

Т1

Режимы SPI 0–3

mode0 MSB 8-bit, mode1, mode2, mode3

Т2

MSB / LSB-first

LSB 8-bit, LSB16, mode1+LSB

Т3

Длины слов 8/16/24/32

8-bit, 16-bit, 24-bit, 32-bit

Т4

FIFO, пакетные передачи

burst RX[0..2], burst slave last MOSI

Т5

Multi-CS

CS1 active, CS0 inactive, CS1 released

Т6

Программируемые задержки

ненулевой DELAY_CFG во всех тестах + волны

Т7

Регистровый интерфейс

используется каждым тестом; reset: *

Т8

Sticky-ошибки + W1C

ERR_START set, ERR_WORD_LEN set, TX overflow flag

Т9

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

IRQ_STATUS sticky bits, IRQ asserted, IRQ cleared

Т10

Soft reset

SOFT_RST: BUSY=0

Обратите внимание на дисциплинирующий эффект: формулировка «как проверим» заставила нас ещё до написания кода продумать негативные сценарии (запись в полный FIFO, старт с пустым, кривая длина слова). Контроллер, спроектированный без этих вопросов, встречает их впервые у пользователя.

Чего в ТЗ сознательно нет

Не менее важная половина контракта — список вычеркнутого:

  • Slave-режим SPI. Другая задача с другой архитектурой (там SCLK — действительно чужой клок, и CDC не избежать). Версия 2.

  • QSPI / dual SPI. Четыре линии данных нужны для скоростных флешек; нашим целям (датчики, тач, АЦП) хватает классики.  

  • DMA. Без процессора в проекте DMA бессмысленен; с процессором — это отдельный блок, который стыкуется к нашим FIFO позже.

  • Автоматический опрос устройств по расписанию. Появится в части VI — но как внешний модуль (spi_probe) поверх контроллера, а не как фича внутри него. Контроллер остаётся простым.

Резать скоуп — не признак слабости, а признак того, что проект будет закончен. Каждый вычеркнутый пункт — это недели сэкономленной работы и сотни строк кода, в которых не будет багов, потому что этих строк не будет. Всё вычеркнутое аккуратно сложено в главу 30 «Куда расти» — ничто не потеряно, всё просто отложено осознанно.

Контрольная точка главы

После этой главы у нас есть:

  • десять функциональных требований, каждое — с ответом «как проверим»;

  • пять нефункциональных требований к качеству и процессу;

  • скелет таблицы трассируемости, который заполнится в части IV;

  • явный список того, что в проект не входит.

Глава 3. Инструменты и железо

Плата: ALINX PIAX301V2 и кристалл EP4CE6F17C8

Проект собран на плате ALINX PIAX301V2 — недорогой отладочной плате с кристаллом Cyclone IV E EP4CE6F17C8. Расшифруем маркировку, потому что в ней — половина паспорта устройства:

  • EP4CE6 — семейство Cyclone IV E, ёмкость 6272 логических элемента (LE). Один LE — это четырёхвходовая таблица истинности плюс триггер; наш контроллер со всей обвязкой займёт около 1300 LE, то есть 20%.

  • F17 — корпус FBGA, 256 выводов, из них 180 доступны как I/O.

  • C8 — коммерческий температурный диапазон, speed grade 8 (самый медленный в линейке — и самый дешёвый; для наших 50 МГц хватает с запасом).

Внутри, помимо логики: ~270 Кбит блочной памяти (M9K), аппаратные умножители, два PLL. Мы из этого великолепия не используем почти ничего — сознательно: проект, которому хватает LE и триггеров, переносится на любую ПЛИС любого вендора без переделок.

Почему именно этот кристалл хорош для обучения: он маленький. Полная пересборка проекта в Quartus занимает около минуты — против десятков минут на больших кристаллах. Скорость итерации «поправил → пересобрал → проверил» в учебном проекте важнее любых характеристик.

Из обвязки платы нам понадобятся ровно три вещи:

Ресурс платы

Пин ПЛИС

Роль в проекте

Кварцевый генератор 50 МГц

E1

системный клок

Кнопка сброса

N13

reset_n (активный 0)

Разъём LCD-модуля

~35 пинов

дисплей + сенсорный SPI

И два документа, без которых к плате лучше не подходить: принципиальная схема платы (откуда мы возьмём привязку «сигнал → пин») и комплект демо-проектов производителя (откуда возьмём эталонную, заведомо работающую распиновку LCD). У ALINX и то и другое лежит в комплекте поставки. Если у вас другая плата — изменится только файл назначения пинов; весь RTL и все тесты останутся теми же. Это прямое следствие требования Н2 из прошлой главы.

LCD-модуль AN430: дисплей и наш будущий SPI-собеседник

К плате подключается модуль ALINX AN430: TFT-дисплей 4.3" (панель TM043NBH02, 480×272, параллельный RGB-интерфейс) с резистивной сенсорной панелью. Для нашего проекта модуль играет две роли, и обе — служебные: 

Дисплей — приборная панель. В части VI мы выведем на него статус обнаружения SPI-устройства и живые координаты касания. Это наш способ заглянуть внутрь работающего кристалла без отладчика.

Контроллер сенсора XPT2046 — тестовый SPI-slave. Вот это по-настоящему важно. XPT2046 — 12-битный АЦП с SPI-интерфейсом, который оцифровывает координаты нажатия. С точки зрения нашего контроллера это настоящее внешнее SPI-устройство со своим даташитом, своими требованиями (mode 0, не быстрее 2 МГц, 24-битные транзакции) и своим характером. Проверять SPI master на нём гораздо честнее, чем на закольцованном MOSI→MISO: петля проверяет провода, а живой чип проверяет протокол.

Если модуля AN430 у вас нет — подойдёт любой SPI-чип, который вы готовы подключить: флешка, АЦП, акселерометр. Изменятся команды в финальной части, но не суть.

Софт: четыре бесплатных инструмента

Весь инструментарий проекта бесплатен. Принципиально.

Quartus Prime Lite Edition — среда синтеза Intel/Altera. Lite-редакция не требует лицензии и полностью поддерживает Cyclone IV. Скачивается с сайта Intel FPGA (понадобится бесплатная учётная запись); при установке достаточно отметить поддержку семейства Cyclone IV E — полный дистрибутив со всеми семействами весит на порядок больше. Из всего комбайна мы будем пользоваться синтезом, фиттером, анализатором времени, Pin Planner и программатором.

Icarus Verilog (iverilog) — симулятор. Здесь нужно остановиться, потому что выбор неочевиден: «как же так, в комплекте с Quartus идёт ModelSim/Questa, он профессиональнее!» Идёт. Но наша симуляция — это цикл «правка → прогон → смотрим волны», повторяемый десятки раз в день. iverilog запускается из Makefile за секунды, не требует проектов, лицензионных серверов и GUI; прогон всех наших 29 тестов занимает меньше десяти секунд. Скорость итераций бьёт богатство функций — для нашего класса задач это правило без исключений. В Linux ставится одной командой:

sudo apt install iverilog gtkwave

GTKWave — просмотрщик волновых диаграмм (формат VCD, который пишет iverilog). Наши глаза на этапе отладки симуляции. 

make — связывает компиляцию и прогон тестов в одну команду. В любом Linux уже есть. 

Редактор — любой. Подсветка Verilog есть везде, от Vim до VS Code.

Логический анализатор: глаза на этапе железа

В части VI, когда дизайн уедет в кристалл, симулятор перестанет помогать — понадобится видеть реальные сигналы на реальных ножках. Для этого нужен логический анализатор, и хорошая новость: подойдёт самый дешёвый. 

Критерии для нашей задачи: SCLK у нас 1 МГц, значит по теореме Котельникова... на самом деле для цифровых сигналов правило проще — частота семплирования минимум в 4–5 раз выше самого быстрого сигнала. 24 МГц семплирования хватает с многократным запасом. Каналов нужно шесть (SCLK, CS, MOSI, MISO, BUSY, PENIRQ) — берём восьмиканальный. 

Под это описание попадают USB-анализаторы на чипе CY7C68013A (клоны Saleae Logic 8), которые стоят как пицца. Софт — открытый sigrok/PulseView с готовым декодером протокола SPI: он не просто покажет импульсы, а соберёт их обратно в байты и подпишет.

Забегая вперёд, в главу 27: анализатор честно покажет вам ровно то, что есть на ножке. Нужно лишь правильно выбрать, какую ножку щупать и когда. Этому посвящена целая глава.

Проверяем окружение за пять минут

Правило: окружение проверяется до начала работы, чтобы посреди главы 12 не выяснять, почему make не находится. Четыре команды — четыре ответа:

$ iverilog -V
Icarus Verilog version 12.0 (stable) ...

$ gtkwave --version
GTKWave Analyzer v3.3.116 ...


$ make --version
GNU Make 4.3 ...

$ quartus_sh --version
Quartus Prime Shell
Version 25.1std.0 Build 1129 ... Lite Edition

Если quartus_sh не находится — добавьте каталог bin установки Quartus в PATH (классическая строчка в ~/.bashrc или ~/.zshrc):

export PATH=$PATH:/opt/intelFPGA_lite/25.1std/quartus/bin

Точная версия любого из инструментов не критична: проект собирается Quartus от 13.1 до 25.1 и симулируется iverilog от версии 10.

Последний штрих — проверить, что плата определяется программатором: подключите USB-Blaster, запустите Quartus → Tools → Programmer → Hardware Setup. В списке должен появиться USB-Blaster. В Linux для этого обычно нужно одно udev-правило (разрешение на USB-устройство Altera) — если Programmer не видит кабель, это первое, что стоит проверить.

Структура каталогов 

Прежде чем написать первую строчку кода, создадим скелет проекта. Это тридцать секунд работы, которые дисциплинируют всё дальнейшее:

SPI/
├── rtl/          синтезируемый код (и только он)
│   └── lcd/      LCD-подсистема (появится в части VI)
├── sim/          testbench и модели — НЕ синтезируются никогда
├── quartus/      проект Quartus: QPF/QSF/SDC, выход сборки
├── docs/         документация и эта статья
├── Makefile      одна команда = прогон всех тестов
└── README.md     карта регистров и инструкции

Главный принцип — жёсткая граница между rtl/ и sim/. В rtl/ лежит только то, что поедет в кристалл: ни тестовых утилит, ни моделей. В sim/ — только то, что никогда в кристалл не поедет: testbench, модели устройств, всё с initial и #задержками. Эта граница соответствует границе в голове, и её размытие — типичный источник хаоса в проектах новичков. 

Переходим к теории: следующие три главы дадут ровно тот минимум знаний о SPI и Verilog, который понадобится в частях II и III.

Часть I. Необходимая теория

Глава 4. Протокол SPI за 30 минут

Четыре провода

SPI (Serial Peripheral Interface) — синхронный последовательный интерфейс, придуманный Motorola в восьмидесятых и с тех пор живущий в каждом втором чипе на планете: флешки, АЦП, дисплеи, датчики, SD-карты. Его сила — в простоте: четыре провода и никакого протокольного оверхеда.

  • SCLK (serial clock) — тактовый сигнал. Его генерирует только master, и это определяет всю асимметрию протокола: master решает, когда происходит обмен и с какой скоростью.

  • MOSI (master out, slave in) — данные от мастера к слейву. В даташитах слейвов этот же провод называется DIN или SDI. 

  • MISO (master in, slave out) — данные обратно. У слейвов — DOUT, SDO. 

  • CS# (chip select, он же SS# — slave select) — выбор устройства. Решётка в имени — знак активного низкого уровня: 0 = устройство выбрано, 1 = устройство спит и игнорирует шину. Пока CS# высокий,  slave обязан держать свой MISO в высокоимпедансном состоянии — это позволит потом повесить несколько слейвов на одну шину.

Обратите внимание, чего здесь нет: нет адресов (адресацию заменяет физический провод CS#), нет подтверждений (master не узнает, что slave отвалился, — это наша забота на уровне выше), нет согласования скорости (master обязан сам не превышать максимум из даташита slave). SPI — протокол-минималист, весь «интеллект» выносится в устройства.

Полный дуплекс: обмен, а не передача

Главное, что нужно понять про SPI, и то, о что спотыкаются все новички: SPI не передаёт данные — он их обменивает. Внутри master и slave стоит по сдвиговому регистру, и каждый такт SCLK они синхронно проворачиваются, образуя одно общее кольцо:

За 8 тактов байт мастера полностью переезжает в slave, а байт slave — в master. Одновременно. Всегда. Не бывает «только записи» или «только чтения»: когда вы «просто пишете» команду флешке, она в это же время что-то отдаёт на MISO (обычно мусор, который вы игнорируете). Когда вы «просто читаете» данные, вы обязаны что-то слать на MOSI (обычно нули или 0xFF, которые проигнорирует slave). 

Из этого следует практический вывод для архитектуры нашего контроллера: на каждое переданное слово в RX FIFO обязательно появится принятое. Хост сам решает, мусор это или данные.

CPOL и CPHA: четыре способа договориться о фронтах

Теперь самое интересное. Данные на проводе — это уровни, а защёлкивать их нужно в моменты. Какие моменты? Motorola в своё время не зафиксировала единственный вариант, и индустрия разошлась в четыре стороны. Параметра два:

  • CPOL (clock polarity) — какой уровень SCLK считается «покоем»: CPOL=0 — в покое низкий, CPOL=1 — в покое высокий. 

  • CPHA (clock phase) — по какому по счёту фронту защёлкивать (семплировать) данные: CPHA=0 — по первому фронту после активации CS, CPHA=1 — по второму.

Комбинации дают четыре режима. Нарисуем все, передаём байт 0xA5 (10100101, MSB-first). Стрелка 1 — момент семплирования (оба устройства защёлкивают входы), 2 — момент выставления следующего бита (drive/shift):

Запоминать содержимое картинки не нужно. Достаточно одного правила, которое потом превратится в одну строчку Verilog: На каждый бит приходится два фронта SCLK. Один из них — sample (оба устройства читают входы), другой — shift (оба выставляют следующий бит). CPHA говорит, какой из них первый: CPHA=0 — сначала sample, CPHA=1 — сначала shift. CPOL просто переворачивает клок.

Отсюда же видна особенность CPHA=0, которая позже заставит нас завести отдельное состояние FSM: раз первый фронт — sample, то первый бит должен быть выставлен на MOSI ещё до первого фронта, сразу после опускания CS#. В CPHA=1 такой проблемы нет — первый фронт сам выставляет данные.

Кто какой режим использует? Подавляющее большинство — mode 0 (флешки, SD-карты, наш XPT2046). Mode 3 — второй по популярности (многие флешки понимают оба). Mode 1 и 2 встречаются у АЦП/ЦАП. Контроллеру всё равно: для него это два бита конфигурации.

Порядок бит: MSB-first и LSB-first

Второй предмет договорённости — с какого конца слова начинать.

MSB-first (старший бит первым) — стандарт де-факто, так работает почти везде по этому правилу.

LSB-first — экзотика, но живая: некоторые дисплйные контроллеры и legacy-чипы.

Передаём 0xA5 = 10100101:

Заметьте ловушку для верификации: на байтах-палиндромах (0xA5, 0x5A, 0x00, 0xFF...) MSB и LSB-режимы дают одинаковую картину на проводе. Если тестировать LSB-first на 0xA5 — тест пройдёт даже при сломанном LSB-режиме. В главе 18 мы сознательно выберем несимметричные паттерны. В реализации (глава 14) у этой опции будет красивое решение: вместо того чтобы гонять сдвиговый регистр в разные стороны, мы будем индексировать биты слова функцией от номера такта — и MSB/LSB сведётся к выбору индекса.

Задержки: CS-setup, CS-hold, inter-word

В идеальном мире CS# опускается, и в тот же наносекундный миг летит первый фронт SCLK. В реальном — у каждого slave в даташите есть таблица «timing requirements», и в ней строчки вида:

  • t_CSS (CS setup) — минимум от спада CS# до первого фронта SCLK. Slave нужно время «проснуться»: включить выходной буфер, подготовить первый бит ответа.

  • t_CSH (CS hold) — минимум от последнего фронта SCLK до подъёма CS#. Иначе последний бит может не дозащёлкнуться.

  • межсловная пауза — если под одним CS# идут несколько слов, некоторым устройствам нужно время на обработку между ними (АЦП — на преобразование).

Наш контроллер сделает все три задержки программируемыми полями одного регистра (DELAY_CFG), в тактах системного клока. Это требование Т6 из главы 2 — теперь вы видите, откуда оно выросло.

Несколько устройств на одной шине

Классическая топология — общие SCLK/MOSI/MISO и индивидуальный CS# на каждое устройство:

Работает это благодаря тому самому правилу из 4.1: невыбранный slave держит MISO в Z-состоянии, и провод свободен для выбранного. Master обязан гарантировать, что активен максимум один CS# — в нашем контроллере это обеспечит регистр CS_SELECT (индекс, а не маска: аппаратно невозможно выбрать двоих). 

Существует и вторая топология — daisy chain, где MISO одного устройства заходит в MOSI следующего, образуя длинный общий сдвиговый регистр. Так соединяют, например, цепочки драйверов светодиодов. Наш контроллер с ней тоже совместим (это просто очень длинное слово), но в проекте она не используется — упоминаем для полноты картины.

Burst: пакеты под одним CS

Многие устройства трактуют непрерывность CS# как смысловую границу транзакции. Флешка: команда «читать» + адрес + поток данных — всё под одним CS#; подъём CS# означает «конец, забудь контекст». Поэтому контроллер должен уметь передать несколько слов подряд, не поднимая CS#

В нашей реализации это поведение по умолчанию: пока в TX FIFO есть слова, engine гонит их одно за другим (с программируемой межсловной паузой), и только опустошив FIFO — поднимает CS#. Хотите три отдельные транзакции — загружайте по одному слову и давайте три старта. Хотите пакет — загрузите три слова и дайте один старт. Требование Т4, тест «burst» из таблицы трассируемости.

Знакомьтесь: XPT2046, наш подопытный

Закроем главу портретом устройства, на котором всё это будет проверяться в железе. XPT2046 — контроллер резистивной сенсорной панели, по сути 12-битный АЦП с SPI-интерфейсом и мультиплексором каналов. Его параметры из даташита:

Параметр

Значение

Следствие для нас

Режим SPI

mode 0 (CPOL=0, CPHA=0)

конфигурация CONTROL

Макс. частота SCLK

~2 МГц

CLK_DIV: 50 МГц → 1 МГц

Формат транзакции

24 бита под одним CS

WORD_LEN = 24

Разрядность АЦП

12 бит

результат в битах [14:3]

Транзакция выглядит так: master шлёт командный байт (выбор канала — координата X или Y), затем ещё 16 тактов гонит нули, а slave в это время возвращает бит занятости и 12 бит результата. Подробный разбор формата — в главе 28, когда дойдём до чтения координат.

Почему он идеален для отладки: всегда под рукой (распаян на LCD-модуле), медленный (не предъявит претензий к нашим фронтам), с предсказуемым ответом (нет касания — значения у крайних значений шкалы, есть касание — числа в середине диапазона, и их видно глазами). Лучшего спарринг-партнёра для первого SPI-контроллера не придумать.

Шпаргалка главы

Уносим с собой шесть фактов — их хватит для всего проекта:

  1. Четыре провода; SCLK и CS# всегда драйвит master; невыбранный slave отпускает MISO в Z.

  2. SPI — это обмен: на каждое переданное слово приходит принятое.

  3. На бит — два фронта: sample и shift. CPHA выбирает их порядок, CPOL — полярность клока. CPHA=0 требует выставить первый бит до первого фронта.

  4. MSB-first почти везде; палиндромные байты маскируют ошибки порядка бит. Задержки CS-setup/hold и межсловные — не прихоть, а строки из даташитов; делаем их программируемыми.

  5. Burst = несколько слов под одним непрерывным CS#; для многих устройств подъём CS# — смысловая граница.

Теория протокола готова. Следующая глава — про язык, на котором мы всё это опишем: синтезируемый Verilog и его правила выживания.

Глава 5. Синтезируемый Verilog: правила выживания

Главная мысль: вы не пишете программу

Прежде чем разбирать синтаксис, нужно произвести одну операцию над собственной головой. Verilog выглядит как язык программирования — у него есть if, case, присваивания, и из-за этого мозг программиста читает его как последовательность команд. Это ошибка номер ноль, из которой растут все остальные.

Verilog-код — это чертёж железа. Строчки не «выполняются сверху вниз» — они описывают схему: триггеры, мультиплексоры, логические вентили и провода между ними. Всё, что вы описали, существует

одновременно и работает параллельно — как и положено железу. Синтезатор (Quartus) читает ваш текст и решает, какие именно вентили и триггеры соединить, чтобы поведение совпало с описанным.

Из этого следует определение слова «синтезируемый»: это подмножество языка, для которого синтезатор умеет построить схему. Симулятор куда всеяднее — он выполнит и задержки, и файловый ввод-вывод. Граница между «симулируется» и «синтезируется» — это ровно граница каталогов sim/ и rtl/ из главы 3.

Синтаксический минимум: модуль, порты, wire и reg

Кирпич любого проекта — модуль. Вот настоящий, без упрощений, модуль из нашего проекта (он появится в главе 11):

module spi_reset_sync #(
    parameter STAGES = 2              // параметр: задаётся при синтезе
) (
    input  wire clk,                  // входы — всегда wire
    input  wire rst_n_async,
    output wire rst_n_sync            // выход может быть wire или reg
);

    reg [STAGES-1:0] sync_chain;      // внутренний регистр на STAGES бит

    always @(posedge clk or negedge rst_n_async) begin
        if (!rst_n_async)
            sync_chain <= {STAGES{1'b0}};
        else
            sync_chain <= {sync_chain[STAGES-2:0], 1'b1};
    end

    assign rst_n_sync = sync_chain[STAGES-1];

endmodule

Что здесь есть:

  • parameter — настройка времени синтеза. Меняется при инстанцировании модуля, но не во время работы. Правило из главы 2: компромиссы ресурсов — параметры, поведение — регистры.

  • wire — провод. Не хранит ничего, просто соединяет. Значение ему назначают через assign или выход другого модуля. 

  • reg — объект, которому можно присваивать внутри always. Внимание, ловушка терминологии: reg не обязательно станет триггером! Это просто «переменная для always-блока». Станет ли она триггером или куском комбинационной логики — зависит от того, как вы присваиваете (об этом весь раздел 5.4).

  • always @(...) — описание процесса. Список чувствительности говорит синтезатору, что строить: @(posedge clk) — логика на триггерах, 

  • @(*) — чисто комбинационная схема. 

  • Литералы вида 1'b0 — один бит со значением 0; {STAGES{1'b0}} — повторение (STAGES нулей подряд); {a, b} — конкатенация битовых векторов. Эти три конструкции покрывают 90% битовой акробатики.

Два вида always: секвенциальный и комбинационный

В нашем проекте (и в 99% грамотного RTL) встречаются ровно два шаблона always-блока — и больше никаких.

Секвенциальный — описывает триггеры, срабатывает по фронту клока:

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        counter <= 16'd0;          // состояние после сброса
    else if (counter_enable)
        counter <= counter + 16'd1;
    // нет else — значит «держи прежнее значение»: это нормально,
    // у триггера есть память
end

Комбинационный — описывает логику без памяти (мультиплексоры, декодеры), срабатывает при изменении любого входа:

always @(*) begin                  // @(*) = «от всего, что читается внутри»
    rd_data_mux = 32'd0;           // default — ОБЯЗАТЕЛЕН (см. 5.5)
    case (addr)
        ADDR_STATUS:  rd_data_mux = status_word;
        ADDR_RX_DATA: rd_data_mux = rx_fifo_rdata;
        default:      rd_data_mux = 32'd0;
    endcase
end

Всё. Других видов в синтезируемом коде нашего проекта не будет. Если рука тянется написать always @(posedge clk or posedge some_signal) где some_signal — не сброс, остановитесь и перечитайте главу 6.

Blocking и non-blocking: единственное объяснение

Вот оно, место, где закопано больше всего новичковых багов. В Verilog два оператора присваивания: = (blocking) и <= (non-blocking). Правило, которое нужно выучить как таблицу умножения:

Секвенциальный always (@(posedge clk)) → только <=.

Комбинационный always (@(*)) → только =.

Никогда не смешивать в одном блоке.

Почему именно так — через минутное понимание семантики. Non-blocking <= работает как настоящий триггер: правые части всех таких присваиваний вычисляются по старым значениям, а обновление происходит «одновременно» в конце такта. Классический пример — обмен значениями:

always @(posedge clk) begin
    a <= b;    // оба правых значения взяты ДО фронта:
    b <= a;    // a и b честно меняются местами, как два триггера крест-накрест
end

Если написать то же с blocking:

always @(posedge clk) begin
    a = b;     // a уже перезаписан...
    b = a;     // ...и b получает НОВОЕ a, то есть b = b. Обмена нет.
end

— код станет зависеть от порядка строк, чего у параллельного железа быть не должно. Хуже того: симулятор и синтезатор могут понять такой код по-разному, и вы получите главный кошмар отрасли — дизайн, который ведёт себя в симуляции не так, как в кристалле. Вся наша часть IV строится на доверии к симуляции; это доверие начинается с дисциплины <=.

В комбинационных блоках, наоборот, нужен blocking: там вычисление — это цепочка зависимостей в пределах одного «мгновения», и промежуточные значения должны быть видны следующей строке немедленно.

Защёлки: как их случайно создать и почему это плохо

Сделаем намеренную ошибку. Хотим мультиплексор, пишем:

always @(*) begin
   if (sel)
       y = a;        // а что, если sel == 0?
end

Программистская интуиция говорит: «если sel ложен, ничего не происходит». Но это чертёж! «Ничего не происходит» для схемы означает «y сохраняет прежнее значение» — а сохранять комбинационная логика не умеет, у неё нет памяти. Синтезатор выкручивается единственным способом: вставляет элемент с памятью — защёлку (latch), прозрачную, пока sel=1. 

Чем защёлка плоха: она срабатывает по уровню, а не по фронту, поэтому весь аппарат временного анализа (глава 23) работает с ней плохо; она чувствительна к глитчам комбинационной логики; и главное — вы её не просили. Случайная защёлка — это всегда баг, даже если сегодня он не стреляет.

Защита — два механических правила:

  1. В комбинационном always первой строкой присваивайте default всем выходам блока (видно в примере из 5.3).

  2. В каждом case пишите ветку default:. Quartus при синтезе честно пишет предупреждение inferred latch — в главе 24 мы внесём его в список предупреждений, которые запрещено игнорировать.

Сброс: асинхронный, синхронный и наш выбор

У каждого триггера проекта должно быть определённое значение после сброса — это требование Н3 из главы 2. Вопрос — как сбрасывать. Два классических варианта:

Синхронный сброс — сброс проверяется только по фронту клока (if (!rst_n) внутри always @(posedge clk)). Плюс: сброс идеально вписан во временной анализ. Минус: без клока не сбросишься, и сброс потребляет логический вход LUT.

Асинхронный сброс — сброс в списке чувствительности (always @(posedge clk or negedge rst_n)). Плюс: срабатывает мгновенно и без клока; у триггеров Cyclone IV под него есть выделенный аппаратный вход (бесплатно!). Минус — коварный: если сброс снимается близко к фронту клока, часть триггеров выйдет из сброса в этом такте, а часть — в следующем. Состояние машины после сброса станет лотереей.

Индустриальный ответ — гибрид, «асинхронная установка, синхронное снятие» (async assert / sync deassert): сброс приходит на триггеры асинхронно (выигрываем аппаратный вход и работу без клока), но сам сигнал сброса пропускается через синхронизатор, который снимает его строго по фронту клока — одновременно для всех. Именно эту схему реализует spi_reset_sync из раздела 5.2 — присмотритесь к нему ещё раз: цепочка триггеров асинхронно падает в ноль и синхронно наполняется единицей.  Десять строк, решающих целый класс проблем, — в главе 11 разберём его построчно.

Чего в синтезируемом коде не бывает

Короткий список конструкций, при виде которых в файле из rtl/ нужно бить тревогу:

  • #10 (задержки) — у железа нет оператора «подожди 10 наносекунд». Задержки — только в testbench.

  • initial — в ASIC несинтезируем; в FPGA технически задаёт значение триггера после конфигурации, но мы не пользуемся: явный сброс переносимее и честнее. В testbench initial — основной инструмент.

  • force / release, файловый I/O, $display — инструменты симуляции. ($display внутри rtl-модуля под ifdef SIMULATION — приём легальный, но в этом проекте не понадобится.)

  • Двойное присваивание одного регистра из разных always-блоков — у сигнала может быть только один источник, как у провода — один драйвер.

И одно НЕ-табу, которое часто записывают в запретные по недоразумению: function. Синтезируемая функция — это просто именованный кусок комбинационной логики, и это прекрасный инструмент. В главе 14 функция bit_pos (вычисление индекса бита по номеру такта и порядку MSB/LSB) станет архитектурным стержнем всего engine. Ограничения: функция не может содержать задержек и обращений к нелокальным регистрам — то есть ровно «чистая комбинационка», что нам и нужно.

Шаблон FSM — заготовка для главы 14

Соберём всё выученное в шаблон конечного автомата — тот самый, по которому в главе 14 будет построен spi_engine:

// 1. Состояния — именованные константы
localparam [1:0] S_IDLE = 2'd0,
                S_RUN  = 2'd1,
                S_DONE = 2'd2;
reg [1:0] state;

// 2. Один секвенциальный блок: переходы + выходы
always @(posedge clk or negedge rst_n) begin
   if (!rst_n) begin
       state      <= S_IDLE;
       busy       <= 1'b0;
       done_pulse <= 1'b0;
   end else begin
       done_pulse <= 1'b0;            // default для импульсных выходов

       case (state)
           S_IDLE: begin
               busy <= 1'b0;
               if (start)
                   state <= S_RUN;
           end

           S_RUN: begin
               busy <= 1'b1;
               if (work_finished)
                   state <= S_DONE;
           end

           S_DONE: begin
               busy       <= 1'b0;
               done_pulse <= 1'b1;    // ровно один такт
               state      <= S_IDLE;
           end

           default: state <= S_IDLE;  // страховка от нелегальных кодов
       endcase
   end
end

Разберём решения шаблона — каждое неслучайно:

  • Один always, а не «два процесса» (комбинационный для переходов + секвенциальный для регистра состояния). Двухпроцессный стиль тоже легален и популярен в учебниках, но один блок исключает целый класс ошибок рассинхронизации и случайных защёлок в комбинационной половине.Для FSM наших размеров — выбор без компромиссов.

  • default: в case — даже если перечислены все состояния. После сбоя (наводка, метастабильность) регистр состояния может оказаться в незакодированном значении — автомат обязан вернуться в осмысленную точку, а не зависнуть.

  • Импульсные выходы сбрасываются default-строкой перед case (done_pulse <= 1'b0) — паттерн «строб длиной ровно один такт», который мы уже обсуждали с требованием Т9 и ещё не раз встретим.

Итог главы

  1. Verilog — чертёж параллельно исполняемой комбинаторной логики в железе, а не программа.

  2. Два вида always: @(posedge clk) с <= и @(*) с =. Не смешивать.

  3. reg — не обязательно триггер; триггером его делает секвенциальный always.

  4. Комбинационный блок без default-присваиваний = случайная защёлка = баг.

  5. Сброс: асинхронная установка, синхронное снятие, модуль-синхронизатор.

  6. #, initial, force — только в sim/. function — легальна и полезна.

  7. FSM: один секвенциальный always, localparam-состояния, default-ветка, импульсы через default-строку.

Остался последний теоретический блок — самое важное архитектурное решение проекта: почему вся схема живёт в одном тактовом домене и что это нам даёт. 

Глава 6. Один тактовый домен — главное архитектурное решение

Соблазн, с которого начинаются плохие SPI-контроллеры

Поставьте себя на место новичка, который только что прочитал главу 4 и открыл редактор. Задача: SPI работает по фронтам SCLK. Первая мысль, которая приходит в голову каждому: «SCLK — это же клок. Сгенерирую его делителем и буду тактировать сдвиговый регистр прямо от него: always @(posedge sclk). Красиво, логика буквально повторяет протокол!»

Мысль настолько естественная, что интернет полон SPI-контроллеров, написанных именно так. И почти все они — мины замедленного действия. Разберёмся, почему, — а заодно выучим главное архитектурное правило этого проекта.

Что на самом деле происходит, когда вы пишете always @(posedge sclk), где sclk — выход вашего же делителя? В дизайне появляется второй тактовый домен: часть триггеров живёт от 50 МГц, часть — от SCLK. А между этими частями неизбежно ходят сигналы: «старт» идёт из регистрового интерфейса (домен 50 МГц) в сдвиговую логику (домен SCLK), принятое слово — обратно. Каждый такой переход называется CDC (clock domain crossing), и каждый — отдельная инженерная проблема:

  1. Гонки и потерянные импульсы. Однотактный строб из быстрого домена медленный домен может просто не заметить — его фронт целиком уместится между двумя фронтами SCLK.

  2. Временной анализ. Каждый клок нужно описать в SDC, каждый переход между доменами — снабдить constraint'ами. Для производного клока, который ещё и останавливается между транзакциями (SCLK не тикает, пока шина простаивает!), это упражнение нетривиально даже для опытных. 

  3. Gated clock. SCLK активен только во время транзакции — значит, это «стробируемый клок», которые в FPGA считаются антипаттерном: клоковые сети кристалла рассчитаны на непрерывные глобальные клоки, а клок, собранный из логики, приходит к триггерам с непредсказуемым перекосом. 

И всё это — ради чего? Ради того, чтобы схема «была похожа на протокол». Плохая сделка.

Решение: один клок, события — по стробам

Наше архитектурное правило звучит так:

В дизайне ровно один тактовый домен — 50 МГц. Все триггеры проекта тактируются только им. SCLK — не клок, а обычный выходной регистр, которым FSM машет, когда считает нужным.

Как при этом устроить «события по фронтам SCLK»? Через clock enable — приём, на котором держится половина цифровой схемотехники. Вместо того чтобы тактироваться медленным клоком, мы тактируемся быстрым, но действуем только в те такты, когда счётчик-делитель говорит «пора»:

// Делитель: строб tick длиной один такт каждые (clk_div+1) тактов 50 МГц
always @(posedge clk) begin
   if (div_cnt == clk_div) begin
       div_cnt <= 16'd0;
       tick    <= 1'b1;           // «пора!» — ровно один такт
   end else begin
       div_cnt <= div_cnt + 16'd1;
       tick    <= 1'b0;
   end
end

// Вся SPI-логика: тот же клок 50 МГц, но шаги — только по стробу
always @(posedge clk) begin
   if (tick) begin
       sclk_reg <= ~sclk_reg;     // SCLK — просто регистр!
       // ... sample или shift, в зависимости от фазы ...
   end
end

Посмотрите, что произошло с проблемами из 6.1 — они не «решились», они исчезли как класс:

  • Нет второго домена — нет CDC внутри контроллера. «Старт» из регистрового интерфейса и данные из engine ходят по обычным проводам между триггерами одного клока; их корректность гарантирует обычный временной анализ, автоматически.

  • SDC-файл сводится к одной содержательной строке create_clock на 50 МГц (увидим в главе 23). 

  • SCLK как регистр идеально предсказуем: мы знаем, в какой такт он поднимется, и можем выставлять MOSI ровно за полпериода до фронта — семантика sample/shift из главы 4 ложится на FSM один в один.

Цена решения — гранулярность: частоты SCLK получаются делением 50 МГц на целое число, а максимум практически достижимого SCLK — десяток-другой МГц (нужно хотя бы несколько тактов системного клока на каждый полупериод). 

Сверимся с ТЗ из главы 2: XPT2046 просит ~1–2 МГц. Запас — на порядок. Компромисс принят, даже не торгуясь.

Но один сигнал всё-таки приходит «извне»: метастабильность

Внутри кристалла мы навели порядок. Но у контроллера есть вход MISO — и его фронты диктует slave, чужое устройство со своими задержками. Относительно нашего клока MISO меняется в произвольные моменты. То же касается кнопки сброса и сигнала PENIRQ от touch-контроллера. Это называется асинхронный вход, и с ним связано единственное по-настоящему физическое явление в этой статье.

У каждого триггера есть окно уязвимости вокруг фронта клока (setup/hold). Если входной сигнал меняется ровно в этом окне, триггер может на какое-то время зависнуть между нулём и единицей — выходное напряжение болтается у порога, и схема за триггером читает не пойми что. Это метастабильность. Важно понимать три вещи: 

  1. Это не баг кода — это физика. Никакой Verilog не запретит slave переключить MISO в неудачный момент. 

  2. Метастабильное состояние рассасывается само — экспоненциально быстро. Подавляющее большинство случаев разрешается за доли наносекунды; нужно лишь дать триггеру время.

  3. Опасна не сама метастабильность, а её распространение: если зависший выход триггера разойдётся по схеме (например, в FSM), разные части схемы прочтут его по-разному — и автомат прыгнет в нелегальное состояние. (Помните default: в case из главы 5? Вот и страховка на этот случай.)

Двухтриггерный синхронизатор

Стандартное лекарство стоит два триггера:

reg [1:0] miso_sync;
always @(posedge clk)
   miso_sync <= {miso_sync[0], spi_miso};   // FF1 ловит, FF2 очищает

wire miso_clean = miso_sync[1];              // только его читает FSM

Логика проста: FF1 принимает удар на себя — именно он рискует зависнуть. Но дальше его выход идёт не в схему, а в один-единственный триггер FF2, и до следующего фронта — целый период клока (20 нс на 50 МГц), за который метастабильность успевает рассосаться с вероятностью, от которой остаётся MTBF в тысячи лет. FF2 выдаёт уже гарантированно чистые нули и единицы.

Плата — задержка в 1–2 такта системного клока. Для нас это важный момент, проверим: не опоздает ли MISO к семплированию? SCLK у нас 1 МГц — полупериод 500 нс, то есть 25 тактов системного клока. Задержка  синхронизатора в 2 такта (40 нс) тонет в этом запасе. А вот если бы мы гнались за SCLK = 25 МГц, этот вопрос стал бы главным в дизайне — ещё одна причина, по которой ограничение частоты из 6.2 нас устраивает.

Правило проекта: каждый асинхронный вход проходит через 2-FF синхронизатор, и сырой сигнал не читает никто, кроме первого триггера. В нашем дизайне таких входов четыре, и у каждого будет свой синхронизатор:

Вход

Откуда

Где синхронизируется

reset_n

кнопка на плате

spi_reset_sync (гл. 11)

spi_miso

XPT2046

внутри spi_engine

spi_busy

XPT2046

top-level

spi_penirq

XPT2046

top-level

Однотактные стробы через 2-FF синхронизатор передавать нельзя (импульс может потеряться между фронтами) — но у нас, к счастью, все асинхронные входы — это уровни, так что хитрых схем с handshake не понадобится. Упоминаю, чтобы вы не применили рецепт за пределами его показаний.

А как же LCD с его 9 МГц?

В части про дисплей возникнет искушение номер два: пиксельный клок LCD — 9 МГц, «заведу-ка я PLL и отдельный клоковый домен под видеотракт». И это снова не нужно: применим тот же приём. Вся LCD-логика тактируется теми же 50 МГц, а пиксельные события задаёт строб pix_en от счётчика-делителя (50 / 5 = 10 МГц — в допуске панели). DCLK для дисплея, как и SCLK для SPI, — обычный выходной регистр. Один и тот же паттерн закрывает оба интерфейса проекта — это и есть признак правильно выбранной архитектуры.

Что мы только что купили

Подведём итог — что даёт проекту правило «один домен + стробы + 2-FF на входах»:

  1. Корректность по построению. Внутри домена все переносы данных защищены статическим временным анализом — целый класс багов (самых злых: редких и невоспроизводимых) исключён архитектурно.

  2. SDC из пяти строк вместо трактата о взаимоотношениях доменов. Меньше constraints — меньше шансов соврать инструменту. 

  3. Простая симуляция. Один клок в testbench, никаких искусственных рассинхронизаций. То, что мы проверим в части IV, будет соответствовать железу.

  4. Переносимость. Никаких PLL и вендорских примитивов — дизайн переедет на любой FPGA, где есть 50 МГц. 

  5. Отлаживаемость. Когда в части VI мы будем смотреть сигналы логическим анализатором, у всех событий будет один временной базис.

Шпаргалка главы

  1. Производный клок из логики (always @(posedge sclk)) = второй домен, CDC, gated clock и боль. Не делаем.

  2. Один домен 50 МГц; медленные события — через clock enable (стробы от делителя). SCLK и DCLK — обычные выходные регистры.

  3. Метастабильность — физика асинхронных входов; лечится временем на рассасывание.

  4. Каждый асинхронный вход → 2-FF синхронизатор; сырой сигнал не читает никто. Для стробов этот рецепт не годится (у нас их и нет).

  5. Задержку синхронизатора (2 такта) надо сверять с полупериодом SCLK — у нас запас 12×.

Теория закончена — и заметьте, её набралось всего три главы: протокол, язык, тактирование. Всё остальное знание мы будем добывать по ходу дела, когда оно понадобится. Впереди часть II: превращаем ТЗ из главы 2 в архитектуру — рисуем блочную схему контроллера, делим его на модули и проектируем регистровую карту, с которой будет работать хост.

PI-контроллер на FPGA: от идеи до сигналов на осциллографе

Откройте любой учебный материал по FPGA. С вероятностью девяносто процентов он закончится мигающим светодиодом, бегущей строкой на семисегментнике или, в лучшем случае, генератором VGA-сигнала с цветными полосами. Это нормальные учебные задачи, но у них есть общее свойство, о котором редко говорят вслух: они работают сразу или не работают совсем. Прошили — мигает. Не мигает — перепроверили три строчки кода, нашли опечатку, мигает.

Реальная инженерная работа с ПЛИС устроена иначе. Реальная задача — это когда ваша микросхема должна разговаривать с другой микросхемой по протоколу, у которого есть спецификация на сорок страниц, четыре режима тактирования и подводные камни в каждом втором абзаце даташита. Когда «вроде работает» — это худший из возможных результатов, потому что «вроде» означает «упадёт у заказчика». И когда между «код написан» и «устройство работает» лежат недели верификации и отладки.

Эта статья - следующий шаг в моем обучении и попытка показать полный путь: от фразы "нам нужен SPI-контроллер" до момента, когда логический анализатор показывает красивые пачки импульсов на ножках кристалла. Здесь будут не только архитектурные решения и листинги, но и все ошибки, которые были сделаны по дороге: testbench, который врал, потому что master и slave-модель содержали один и тот же баг; гонка на один такт, из-за которой тест прерываний падал при исправном железе; и финальный детектив — прошивка, в которой SPI-сигналы честно генерировались... на других ножках, потому что файл назначения пинов на диске оказался не тем, который мы правили. Каждая такая история — это методика: симптом, гипотеза, эксперимент, причина, фикс.

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…

Глава 1. О чём эта статья и почему она длинная

Что мы построим

Предмет статьи — SPI master контроллер уровня “можно вставлять в продукт”, а не “демо для старта”. Вот список того, что задуманно:

  • Все четыре режима SPI (комбинации CPOL/CPHA) — потому что в реальном мире флешка хочет mode 0, какой-нибудь ADC — mode 1, а датчик — mode 3.

  • Порядок бит MSB-first и LSB-first, переключаемый на лету.

  • Длина слова 8, 16, 24 или 32 бита — тоже на лету, без пересинтеза.

  • FIFO-буферы на передачу и приём — хост загружает пачку слов и занимается своими делами, а не дёргает каждый байт.

  • До четырёх линий выбора устройства (CS) с программируемыми задержками: CS-setup, CS-hold, пауза между словами в пакете.

  • Карта регистров с управлением, статусом, sticky-флагами ошибок и маскируемыми прерываниями — как у «взрослых» периферийных блоков в микроконтроллерах.

  • Один тактовый домен, чистый синтезируемый Verilog-2001, ни одного вендорского IP-блока — переносится на любую ПЛИС.

Рядом с контроллером — обвязка, которая превращает его из «модуля в вакууме» в самодостаточное устройство: ROM-секвенсор начальной инициализации (вместо процессора), автоматический опрос сенсорного контроллера XPT2046 и LCD-дисплей, на который выводится статус и живые координаты касания. Дисплей в этом проекте — не самоцель, а по сути приборная панель: способ видеть, что происходит внутри, не подключая отладчик.

Целевое железо — плата ALINX PIAX301V2 с Cyclone IV EP4CE6F17C8: один из самых дешёвых и распространённых кристаллов Altera/Intel. Итоговый дизайн занимает около 20% его логики — запас на ваши эксперименты остаётся огромный.

Если разложить время, потраченное на этот проект, по видам работ, получится примерно так:

Работа

Доля времени

Написание синтезируемого RTL

~30%

Testbench, модели, прогоны симуляции

~35%

Отладка по результатам симуляции

~15%

Quartus: пины, констрейнты, отчёты

~10%

Bring-up на железе и охота за сигналами

~10%

Это не особенность конкретного проекта — это норма отрасли. У новичков пропорция вызывает протест: «я же уже написал код, почему всё ещё ничего не работает?» Ответ простой: написанный код — это гипотеза. Верификация — эксперимент, который её проверяет. А bring-up — встреча гипотезы с физическим миром, у которого есть паразитные ёмкости, подтягивающие резисторы и файлы конфигурации, которые вы забыли сохранить. Поэтому статья устроена так же, как устроена работа: четверть объёма — про то, как написать контроллер, и три четверти — про то, как убедиться, что он работает как надо, и что делать, когда он не работает.

Что вы будете уметь после прочтения

Прочитав статью целиком и повторив шаги руками, вы сможете:

  1. Превратить размытую формулировку («нужно управлять SPI-устройствами») в техническое задание с измеримыми пунктами. 

  2. Разбить устройство на модули так, чтобы каждый можно было написать, протестировать и заменить независимо.

  3. Спроектировать карту регистров, которой удобно пользоваться со стороны процессора, — с правильными соглашениями про sticky-биты, W1C и self-clearing стробы.

  4. Написать синтезируемый Verilog без защёлок, гонок и прочей классики жанра.

  5. Построить независимую верификацию: эталонную модель устройства, self-checking testbench, матрицу покрытия по режимам.

  6. Пройти маршрут Quartus: проект, пины, временные констрейнты, чтение отчётов — и понять, какие предупреждения смертельны, а какие нет.

  7. Системно отлаживать железо: локализовать проблему за минимальное число экспериментов, а не тыкать щупом наугад.

И, пожалуй, главное: у вас появится рабочий шаблон проекта — структура каталогов, Makefile, стиль кода, подход к тестам, — который можно переиспользовать для любого следующего периферийного блока: I2C, UART, контроллера дисплея, чего угодно.

Что нужно знать на входе

Статья рассчитана на читателя, который:

  • умеет программировать хоть на чём-нибудь (понятия «переменная», «цикл», «функция» не требуют объяснения);

  • видел двоичную и шестнадцатеричную системы счисления;

  • слышал, что такое ПЛИС, и, возможно, мигал светодиодом.

Знание Verilog не требуется — весь необходимый синтаксис вводится в части I, а дальше закрепляется на живом коде. Если вы уже пишете на Verilog/VHDL — главы 5–6 можно пролистать по диагонали, но раздел про блокирующие/неблокирующие присваивания рекомендую не пропускать: половина багов начинающих растёт именно оттуда. 

Из железа понадобится плата с Cyclone IV (мы используем ALINX PIAX301V2, но подойдёт почти любая — отличаться будет только файл назначения пинов), LCD-модуль AN430 с сенсорной панелью для финальной части и — желательно, но не обязательно — логический анализатор за десять долларов. Всё ПО бесплатное: Quartus Prime Lite, Icarus Verilog, GTKWave.

Глава 2. Техническое задание — формулируем как взрослые

Зачем ТЗ проекту, у которого нет заказчика

Самое частое возражение: «это же мой собственный проект, какое ещё ТЗ, я и так знаю, что хочу». Опыт показывает обратное: именно в проектах без внешнего заказчика ТЗ нужнее всего, потому что без него вы — заказчик, который меняет требования каждый вечер. Сегодня «просто SPI», завтра «а пусть ещё и QSPI», послезавтра проект заброшен на стадии рефакторинга того, что вчера казалось важным.

У письменного ТЗ в нашем деле три конкретные функции:

Контракт с самим собой. Список того, что входит в проект, — и, что важнее, того, что не входит. Когда посреди реализации появится соблазн «добавить ещё вот это», вы посмотрите в документ и честно ответите: это новая фича, она пойдёт в версию 2.

Источник архитектуры. Каждое требование тянет за собой структурное решение. «Длина слова меняется на лету» — значит, длина не параметр синтеза, а регистр. «FIFO» — значит, появляются модули буферов и флаги переполнения. Хорошее ТЗ — это почти готовая блок-схема, нужно только аккуратно её вытащить (этим займёмся в главе 7).

Источник тестов. Это главный пункт. Каждая строка ТЗ должна быть проверяемой — настолько конкретной, чтобы по ней можно было написать тест с однозначным вердиктом PASS/FAIL. «Контроллер должен быстро передавать данные» — не требование, а пожелание. «Контроллер должен передавать слова 8/16/24/32 бита во всех четырёх режимах SPI, и принятые slave-моделью данные должны побитно совпадать с переданными» — требование: в части IV оно превратится в восемь конкретных тест-кейсов.

В конце главы мы сведём всё в таблицу трассируемости «требование → тест». Забегая вперёд: когда в главе 18 наш testbench напечатает ALL TESTS PASSED (18 cases), это будет означать не «вроде работает», а «каждый пункт ТЗ проверен и выполнен». Почувствуйте разницу.

Функциональные требования: разбор по пунктам

Пройдём по требованиям так, как они формулировались в начале проекта. Для каждого — три вопроса: что это значит, откуда берётся и как проверим.

Т1. Режимы SPI 0–3 (CPOL, CPHA)

Что значит. У SPI нет единого стандарта тактирования — есть четыре комбинации полярности клока (CPOL: уровень SCLK в покое) и фазы (CPHA: по какому фронту защёлкивать данные). Подробно разберём в главе 4; пока важно одно — режим задаётся устройством-слейвом, а не нашим желанием.

Откуда берётся. Из даташитов реальных устройств: SPI-флешки обычно mode 0 или 3, многие АЦП — mode 1, наш будущий тестовый XPT2046 — mode 0. Контроллер «только mode 0» — это контроллер «только для половины устройств на рынке».

Как проверим. Четыре тест-кейса, по одному на режим: передача и приём байта с независимой slave-моделью, которая сама защёлкивает данные по фронтам SCLK согласно режиму. Совпало побитно в обе стороны — PASS.

Т2. Порядок бит: MSB-first обязательно, LSB-first опционально

Что значит. Большинство устройств передают старший бит первым (MSB-first), но встречаются и обратные. Переключение — на лету, битом в регистре.

Как проверим. Тесты LSB-first на 8 и 16 бит плюс комбинированный «mode 1 + LSB» — комбинация двух нетривиальных опций ловит баги, которые каждая опция по отдельности не проявляет. Это правило хорошего покрытия: пересечения фич багоопаснее самих фич.

Т3. Длина слова 8/16/24/32 бита, на лету

Что значит. Регистр WORD_LEN, а не параметр синтеза. Контроллер тач-сенсора XPT2046, например, хочет 24-битные транзакции; флешке хватает 8. 

Как проверим. По тесту на каждую длину. Плюс негативный тест: запись недопустимой длины (например, 12) должна не повесить контроллер, а поднять флаг ошибки ERR_WORD_LEN — об этом ниже, в Т8.

Т4. FIFO на передачу и приём

Что значит. Хост загружает в TX FIFO пачку слов и уходит; контроллер сам передаёт их одно за другим. Принятые слова копятся в RX FIFO. Глубина — параметр синтеза (по умолчанию 8), потому что это компромисс ресурсов, а не поведение.

Откуда берётся. Без FIFO хост обязан успевать к каждому слову — для процессора это прерывание на каждый байт, безумие даже на 1 МГц SCLK. 

Как проверим. Burst-тест: загружаем три слова, даём один START, проверяем, что все три ушли подряд под одним CS и все три ответа легли в RX FIFO в правильном порядке.

Т5. Несколько линий CS с программируемым выбором

Что значит. Параметр NUM_CS (по умолчанию 4) и регистр CS_SELECT: к одной шине подключены несколько устройств, активное выбирается перед транзакцией.

Как проверим. Тест маршрутизации: выбираем CS1, проверяем что во время транзакции CS1 = 0, а CS0 остался = 1, и что после транзакции CS1 вернулся в 1.

Т6. Программируемые задержки CS-setup / CS-hold / inter-word

Что значит. Между опусканием CS и первым фронтом SCLK многие slave требуют паузу (CS-setup); аналогично перед поднятием CS (CS-hold) и между словами пакета (inter-word). Все три — поля одного регистра DELAY_CFG, в тактах системного клока.

Откуда берётся. Прямо из даташитов: у XPT2046, к примеру, есть минимальное время от CS до первого клока. Контроллер без задержек работает «на честном слове» — до первого привередливого устройства. 

Как проверим. В основном визуально по wave симуляции (расстояние между фронтами), плюс косвенно: все остальные тесты гоняются с ненулевыми задержками — если бы логика задержек ломала транзакции, упало бы всё.

Т7. Регистровый интерфейс «memory-mapped»

Что значит. Управление контроллером — через чтение/запись регистров по адресам, как у периферии микроконтроллера: wr_en, rd_en, addr, wr_data, rd_data, ready. Полная карта регистров — предмет главы 8.

Откуда берётся. Это интерфейсная конвенция, которая стыкуется с чем угодно: софт-процессор Nios II, внешний MCU, наш будущий ROM-секвенсор. Шину делаем нарочито простой и vendor-neutral — мост на Avalon-MM или AXI4-Lite при необходимости пишется за вечер.

Как проверим. Каждый тест использует этот интерфейс — он проверяется постоянно. Отдельно: тест значений регистров после сброса.

Т8. Флаги ошибок — sticky, с явной очисткой

Что значит. Контроллер должен сообщать о нештатных ситуациях: переполнение TX FIFO (хост пишет в полный буфер), переполнение RX FIFO (некому забирать данные), старт с пустым TX FIFO, недопустимая длина слова. Флаги — липкие (sticky): однажды взведённый флаг висит, пока хост явно не снимет его записью в регистр очистки (W1C — write-1-to-clear).

Откуда берётся. Ошибка, которая мелькнула на один такт и исчезла, — это ошибка, которую никто никогда не увидит. Sticky-семантика гарантирует: если что-то пошло не так между двумя опросами статуса — вы об этом узнаете.

Как проверим. По негативному тесту на каждый флаг: специально провоцируем ситуацию, проверяем что флаг взведён, квитируем, проверяем что снят.

Т9. Прерывания: done / rx_valid / error, с маской

Что значит. Один выход irq, три источника, регистр маски (что разрешено) и регистр статуса (что случилось). Альтернатива поллингу для систем с процессором.

Как проверим. Сценарный тест: маскируем всё → событие происходит → irq молчит; разрешаем источник → irq поднимается; квитируем → irq опускается. Забегая в главу 20: именно этот тест подарит нам красивую гонку на один такт.

Т10. Soft reset

Что значит. Бит в CONTROL, который приводит engine и FIFO в исходное состояние, не трогая конфигурационные регистры. Спасательный круг для хоста, если транзакция зависла из-за внешних причин. 

Как проверим. Тест: запускаем транзакцию, посреди неё — SOFT_RST, проверяем что BUSY снялся и контроллер готов к новой передаче.

Нефункциональные требования

Эти пункты не видны в списке фич, но определяют качество результата сильнее любого из Т1–Т10:

  • Н1. Один тактовый домен. Вся логика — на системном клоке 50 МГц; SCLK — не «клок», а обычный выходной сигнал, сформированный делителем. Почему это решение — фундамент всего проекта, разберём в главе 6.

  • Н2. Чистый Verilog-2001, ни одного вендорского примитива. Проект обязан синтезироваться и под Altera, и под Xilinx, и под Lattice, и симулироваться бесплатным Icarus Verilog.

  • Н3. Синтезируемость без сюрпризов. Никаких защёлок, никаких initial в рабочей логике, никаких #задержек вне testbench; определённое состояние после сброса у каждого регистра.

  • Н4. Параметризуемость того, что является ресурсным компромиссом. Глубина FIFO, число CS, число ступеней синхронизатора MISO — параметры. То, что является поведением (режим, длина слова), — регистры.

  • Н5. Верифицируемость. Каждое требование Т1–Т10 покрыто самопроверяющимся тестом. Это требование к процессу, и оно дороже всех остальных вместе взятых.

Таблица трассируемости: требование → тест

Так выглядит контракт в финальном виде. Третий столбец заполнен «задним числом» — реальными тестами из sim/tb_spi_master.v, которые мы напишем в части IV. В живом проекте таблица заполняется по мере написания тестов и служит детектором дыр в покрытии: пустая ячейка = непроверенное требование.

Требование

Тесты в tb_spi_master.v

Т1

Режимы SPI 0–3

mode0 MSB 8-bit, mode1, mode2, mode3

Т2

MSB / LSB-first

LSB 8-bit, LSB16, mode1+LSB

Т3

Длины слов 8/16/24/32

8-bit, 16-bit, 24-bit, 32-bit

Т4

FIFO, пакетные передачи

burst RX[0..2], burst slave last MOSI

Т5

Multi-CS

CS1 active, CS0 inactive, CS1 released

Т6

Программируемые задержки

ненулевой DELAY_CFG во всех тестах + волны

Т7

Регистровый интерфейс

используется каждым тестом; reset: *

Т8

Sticky-ошибки + W1C

ERR_START set, ERR_WORD_LEN set, TX overflow flag

Т9

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

IRQ_STATUS sticky bits, IRQ asserted, IRQ cleared

Т10

Soft reset

SOFT_RST: BUSY=0

Обратите внимание на дисциплинирующий эффект: формулировка «как проверим» заставила нас ещё до написания кода продумать негативные сценарии (запись в полный FIFO, старт с пустым, кривая длина слова). Контроллер, спроектированный без этих вопросов, встречает их впервые у пользователя.

Чего в ТЗ сознательно нет

Не менее важная половина контракта — список вычеркнутого:

  • Slave-режим SPI. Другая задача с другой архитектурой (там SCLK — действительно чужой клок, и CDC не избежать). Версия 2.

  • QSPI / dual SPI. Четыре линии данных нужны для скоростных флешек; нашим целям (датчики, тач, АЦП) хватает классики.  

  • DMA. Без процессора в проекте DMA бессмысленен; с процессором — это отдельный блок, который стыкуется к нашим FIFO позже.

  • Автоматический опрос устройств по расписанию. Появится в части VI — но как внешний модуль (spi_probe) поверх контроллера, а не как фича внутри него. Контроллер остаётся простым.

Резать скоуп — не признак слабости, а признак того, что проект будет закончен. Каждый вычеркнутый пункт — это недели сэкономленной работы и сотни строк кода, в которых не будет багов, потому что этих строк не будет. Всё вычеркнутое аккуратно сложено в главу 30 «Куда расти» — ничто не потеряно, всё просто отложено осознанно.

Контрольная точка главы

После этой главы у нас есть:

  • десять функциональных требований, каждое — с ответом «как проверим»;

  • пять нефункциональных требований к качеству и процессу;

  • скелет таблицы трассируемости, который заполнится в части IV;

  • явный список того, что в проект не входит.

Глава 3. Инструменты и железо

Плата: ALINX PIAX301V2 и кристалл EP4CE6F17C8

Проект собран на плате ALINX PIAX301V2 — недорогой отладочной плате с кристаллом Cyclone IV E EP4CE6F17C8. Расшифруем маркировку, потому что в ней — половина паспорта устройства:

  • EP4CE6 — семейство Cyclone IV E, ёмкость 6272 логических элемента (LE). Один LE — это четырёхвходовая таблица истинности плюс триггер; наш контроллер со всей обвязкой займёт около 1300 LE, то есть 20%.

  • F17 — корпус FBGA, 256 выводов, из них 180 доступны как I/O.

  • C8 — коммерческий температурный диапазон, speed grade 8 (самый медленный в линейке — и самый дешёвый; для наших 50 МГц хватает с запасом).

Внутри, помимо логики: ~270 Кбит блочной памяти (M9K), аппаратные умножители, два PLL. Мы из этого великолепия не используем почти ничего — сознательно: проект, которому хватает LE и триггеров, переносится на любую ПЛИС любого вендора без переделок.

Почему именно этот кристалл хорош для обучения: он маленький. Полная пересборка проекта в Quartus занимает около минуты — против десятков минут на больших кристаллах. Скорость итерации «поправил → пересобрал → проверил» в учебном проекте важнее любых характеристик.

Из обвязки платы нам понадобятся ровно три вещи:

Ресурс платы

Пин ПЛИС

Роль в проекте

Кварцевый генератор 50 МГц

E1

системный клок

Кнопка сброса

N13

reset_n (активный 0)

Разъём LCD-модуля

~35 пинов

дисплей + сенсорный SPI

И два документа, без которых к плате лучше не подходить: принципиальная схема платы (откуда мы возьмём привязку «сигнал → пин») и комплект демо-проектов производителя (откуда возьмём эталонную, заведомо работающую распиновку LCD). У ALINX и то и другое лежит в комплекте поставки. Если у вас другая плата — изменится только файл назначения пинов; весь RTL и все тесты останутся теми же. Это прямое следствие требования Н2 из прошлой главы.

LCD-модуль AN430: дисплей и наш будущий SPI-собеседник

К плате подключается модуль ALINX AN430: TFT-дисплей 4.3" (панель TM043NBH02, 480×272, параллельный RGB-интерфейс) с резистивной сенсорной панелью. Для нашего проекта модуль играет две роли, и обе — служебные: 

Дисплей — приборная панель. В части VI мы выведем на него статус обнаружения SPI-устройства и живые координаты касания. Это наш способ заглянуть внутрь работающего кристалла без отладчика.

Контроллер сенсора XPT2046 — тестовый SPI-slave. Вот это по-настоящему важно. XPT2046 — 12-битный АЦП с SPI-интерфейсом, который оцифровывает координаты нажатия. С точки зрения нашего контроллера это настоящее внешнее SPI-устройство со своим даташитом, своими требованиями (mode 0, не быстрее 2 МГц, 24-битные транзакции) и своим характером. Проверять SPI master на нём гораздо честнее, чем на закольцованном MOSI→MISO: петля проверяет провода, а живой чип проверяет протокол.

Если модуля AN430 у вас нет — подойдёт любой SPI-чип, который вы готовы подключить: флешка, АЦП, акселерометр. Изменятся команды в финальной части, но не суть.

Софт: четыре бесплатных инструмента

Весь инструментарий проекта бесплатен. Принципиально.

Quartus Prime Lite Edition — среда синтеза Intel/Altera. Lite-редакция не требует лицензии и полностью поддерживает Cyclone IV. Скачивается с сайта Intel FPGA (понадобится бесплатная учётная запись); при установке достаточно отметить поддержку семейства Cyclone IV E — полный дистрибутив со всеми семействами весит на порядок больше. Из всего комбайна мы будем пользоваться синтезом, фиттером, анализатором времени, Pin Planner и программатором.

Icarus Verilog (iverilog) — симулятор. Здесь нужно остановиться, потому что выбор неочевиден: «как же так, в комплекте с Quartus идёт ModelSim/Questa, он профессиональнее!» Идёт. Но наша симуляция — это цикл «правка → прогон → смотрим волны», повторяемый десятки раз в день. iverilog запускается из Makefile за секунды, не требует проектов, лицензионных серверов и GUI; прогон всех наших 29 тестов занимает меньше десяти секунд. Скорость итераций бьёт богатство функций — для нашего класса задач это правило без исключений. В Linux ставится одной командой:

sudo apt install iverilog gtkwave

GTKWave — просмотрщик волновых диаграмм (формат VCD, который пишет iverilog). Наши глаза на этапе отладки симуляции. 

make — связывает компиляцию и прогон тестов в одну команду. В любом Linux уже есть. 

Редактор — любой. Подсветка Verilog есть везде, от Vim до VS Code.

Логический анализатор: глаза на этапе железа

В части VI, когда дизайн уедет в кристалл, симулятор перестанет помогать — понадобится видеть реальные сигналы на реальных ножках. Для этого нужен логический анализатор, и хорошая новость: подойдёт самый дешёвый. 

Критерии для нашей задачи: SCLK у нас 1 МГц, значит по теореме Котельникова... на самом деле для цифровых сигналов правило проще — частота семплирования минимум в 4–5 раз выше самого быстрого сигнала. 24 МГц семплирования хватает с многократным запасом. Каналов нужно шесть (SCLK, CS, MOSI, MISO, BUSY, PENIRQ) — берём восьмиканальный. 

Под это описание попадают USB-анализаторы на чипе CY7C68013A (клоны Saleae Logic 8), которые стоят как пицца. Софт — открытый sigrok/PulseView с готовым декодером протокола SPI: он не просто покажет импульсы, а соберёт их обратно в байты и подпишет.

Забегая вперёд, в главу 27: анализатор честно покажет вам ровно то, что есть на ножке. Нужно лишь правильно выбрать, какую ножку щупать и когда. Этому посвящена целая глава.

Проверяем окружение за пять минут

Правило: окружение проверяется до начала работы, чтобы посреди главы 12 не выяснять, почему make не находится. Четыре команды — четыре ответа:

$ iverilog -V

Icarus Verilog version 12.0 (stable) ...

$ gtkwave --version

GTKWave Analyzer v3.3.116 ...

$ make --version

GNU Make 4.3 ...

$ quartus_sh --version

Quartus Prime Shell

Version 25.1std.0 Build 1129 ... Lite Edition

Если quartus_sh не находится — добавьте каталог bin установки Quartus в PATH (классическая строчка в ~/.bashrc или ~/.zshrc):

export PATH=$PATH:/opt/intelFPGA_lite/25.1std/quartus/bin

Точная версия любого из инструментов не критична: проект собирается Quartus от 13.1 до 25.1 и симулируется iverilog от версии 10.

Последний штрих — проверить, что плата определяется программатором: подключите USB-Blaster, запустите Quartus → Tools → Programmer → Hardware Setup. В списке должен появиться USB-Blaster. В Linux для этого обычно нужно одно udev-правило (разрешение на USB-устройство Altera) — если Programmer не видит кабель, это первое, что стоит проверить.

Структура каталогов 

Прежде чем написать первую строчку кода, создадим скелет проекта. Это тридцать секунд работы, которые дисциплинируют всё дальнейшее:

SPI/

├── rtl/          синтезируемый код (и только он)

│   └── lcd/      LCD-подсистема (появится в части VI)

├── sim/          testbench и модели — НЕ синтезируются никогда

├── quartus/      проект Quartus: QPF/QSF/SDC, выход сборки

├── docs/         документация и эта статья

├── Makefile      одна команда = прогон всех тестов

└── README.md     карта регистров и инструкции

Главный принцип — жёсткая граница между rtl/ и sim/. В rtl/ лежит только то, что поедет в кристалл: ни тестовых утилит, ни моделей. В sim/ — только то, что никогда в кристалл не поедет: testbench, модели устройств, всё с initial и #задержками. Эта граница соответствует границе в голове, и её размытие — типичный источник хаоса в проектах новичков. 

Переходим к теории: следующие три главы дадут ровно тот минимум знаний о SPI и Verilog, который понадобится в частях II и III.

Часть I. Необходимая теория

Глава 4. Протокол SPI за 30 минут

Четыре провода

SPI (Serial Peripheral Interface) — синхронный последовательный интерфейс, придуманный Motorola в восьмидесятых и с тех пор живущий в каждом втором чипе на планете: флешки, АЦП, дисплеи, датчики, SD-карты. Его сила — в простоте: четыре провода и никакого протокольного оверхеда.

  • SCLK (serial clock) — тактовый сигнал. Его генерирует только master, и это определяет всю асимметрию протокола: master решает, когда происходит обмен и с какой скоростью.

  • MOSI (master out, slave in) — данные от мастера к слейву. В даташитах слейвов этот же провод называется DIN или SDI. 

  • MISO (master in, slave out) — данные обратно. У слейвов — DOUT, SDO. 

  • CS# (chip select, он же SS# — slave select) — выбор устройства. Решётка в имени — знак активного низкого уровня: 0 = устройство выбрано, 1 = устройство спит и игнорирует шину. Пока CS# высокий,  slave обязан держать свой MISO в высокоимпедансном состоянии — это позволит потом повесить несколько слейвов на одну шину.

Обратите внимание, чего здесь нет: нет адресов (адресацию заменяет физический провод CS#), нет подтверждений (master не узнает, что slave отвалился, — это наша забота на уровне выше), нет согласования скорости (master обязан сам не превышать максимум из даташита slave). SPI — протокол-минималист, весь «интеллект» выносится в устройства.

Полный дуплекс: обмен, а не передача

Главное, что нужно понять про SPI, и то, о что спотыкаются все новички: SPI не передаёт данные — он их обменивает. Внутри master и slave стоит по сдвиговому регистру, и каждый такт SCLK они синхронно проворачиваются, образуя одно общее кольцо:

За 8 тактов байт мастера полностью переезжает в slave, а байт slave — в master. Одновременно. Всегда. Не бывает «только записи» или «только чтения»: когда вы «просто пишете» команду флешке, она в это же время что-то отдаёт на MISO (обычно мусор, который вы игнорируете). Когда вы «просто читаете» данные, вы обязаны что-то слать на MOSI (обычно нули или 0xFF, которые проигнорирует slave). 

Из этого следует практический вывод для архитектуры нашего контроллера: на каждое переданное слово в RX FIFO обязательно появится принятое. Хост сам решает, мусор это или данные.

CPOL и CPHA: четыре способа договориться о фронтах

Теперь самое интересное. Данные на проводе — это уровни, а защёлкивать их нужно в моменты. Какие моменты? Motorola в своё время не зафиксировала единственный вариант, и индустрия разошлась в четыре стороны. Параметра два:

  • CPOL (clock polarity) — какой уровень SCLK считается «покоем»: CPOL=0 — в покое низкий, CPOL=1 — в покое высокий. 

  • CPHA (clock phase) — по какому по счёту фронту защёлкивать (семплировать) данные: CPHA=0 — по первому фронту после активации CS, CPHA=1 — по второму.

Комбинации дают четыре режима. Нарисуем все, передаём байт 0xA5 (10100101, MSB-first). Стрелка 1 — момент семплирования (оба устройства защёлкивают входы), 2 — момент выставления следующего бита (drive/shift):

Запоминать содержимое картинки не нужно. Достаточно одного правила, которое потом превратится в одну строчку Verilog: На каждый бит приходится два фронта SCLK. Один из них — sample (оба устройства читают входы), другой — shift (оба выставляют следующий бит). CPHA говорит, какой из них первый: CPHA=0 — сначала sample, CPHA=1 — сначала shift. CPOL просто переворачивает клок.

Отсюда же видна особенность CPHA=0, которая позже заставит нас завести отдельное состояние FSM: раз первый фронт — sample, то первый бит должен быть выставлен на MOSI ещё до первого фронта, сразу после опускания CS#. В CPHA=1 такой проблемы нет — первый фронт сам выставляет данные.

Кто какой режим использует? Подавляющее большинство — mode 0 (флешки, SD-карты, наш XPT2046). Mode 3 — второй по популярности (многие флешки понимают оба). Mode 1 и 2 встречаются у АЦП/ЦАП. Контроллеру всё равно: для него это два бита конфигурации.

Порядок бит: MSB-first и LSB-first

Второй предмет договорённости — с какого конца слова начинать.

MSB-first (старший бит первым) — стандарт де-факто, так работает почти везде по этому правилу.

LSB-first — экзотика, но живая: некоторые дисплйные контроллеры и legacy-чипы.

Передаём 0xA5 = 10100101:

Заметьте ловушку для верификации: на байтах-палиндромах (0xA5, 0x5A, 0x00, 0xFF...) MSB и LSB-режимы дают одинаковую картину на проводе. Если тестировать LSB-first на 0xA5 — тест пройдёт даже при сломанном LSB-режиме. В главе 18 мы сознательно выберем несимметричные паттерны. В реализации (глава 14) у этой опции будет красивое решение: вместо того чтобы гонять сдвиговый регистр в разные стороны, мы будем индексировать биты слова функцией от номера такта — и MSB/LSB сведётся к выбору индекса.

Задержки: CS-setup, CS-hold, inter-word

В идеальном мире CS# опускается, и в тот же наносекундный миг летит первый фронт SCLK. В реальном — у каждого slave в даташите есть таблица «timing requirements», и в ней строчки вида:

  • t_CSS (CS setup) — минимум от спада CS# до первого фронта SCLK. Slave нужно время «проснуться»: включить выходной буфер, подготовить первый бит ответа.

  • t_CSH (CS hold) — минимум от последнего фронта SCLK до подъёма CS#. Иначе последний бит может не дозащёлкнуться.

  • межсловная пауза — если под одним CS# идут несколько слов, некоторым устройствам нужно время на обработку между ними (АЦП — на преобразование).

Наш контроллер сделает все три задержки программируемыми полями одного регистра (DELAY_CFG), в тактах системного клока. Это требование Т6 из главы 2 — теперь вы видите, откуда оно выросло.

Несколько устройств на одной шине

Классическая топология — общие SCLK/MOSI/MISO и индивидуальный CS# на каждое устройство:

Работает это благодаря тому самому правилу из 4.1: невыбранный slave держит MISO в Z-состоянии, и провод свободен для выбранного. Master обязан гарантировать, что активен максимум один CS# — в нашем контроллере это обеспечит регистр CS_SELECT (индекс, а не маска: аппаратно невозможно выбрать двоих). 

Существует и вторая топология — daisy chain, где MISO одного устройства заходит в MOSI следующего, образуя длинный общий сдвиговый регистр. Так соединяют, например, цепочки драйверов светодиодов. Наш контроллер с ней тоже совместим (это просто очень длинное слово), но в проекте она не используется — упоминаем для полноты картины.

Burst: пакеты под одним CS

Многие устройства трактуют непрерывность CS# как смысловую границу транзакции. Флешка: команда «читать» + адрес + поток данных — всё под одним CS#; подъём CS# означает «конец, забудь контекст». Поэтому контроллер должен уметь передать несколько слов подряд, не поднимая CS#

В нашей реализации это поведение по умолчанию: пока в TX FIFO есть слова, engine гонит их одно за другим (с программируемой межсловной паузой), и только опустошив FIFO — поднимает CS#. Хотите три отдельные транзакции — загружайте по одному слову и давайте три старта. Хотите пакет — загрузите три слова и дайте один старт. Требование Т4, тест «burst» из таблицы трассируемости.

Знакомьтесь: XPT2046, наш подопытный

Закроем главу портретом устройства, на котором всё это будет проверяться в железе. XPT2046 — контроллер резистивной сенсорной панели, по сути 12-битный АЦП с SPI-интерфейсом и мультиплексором каналов. Его параметры из даташита:

Параметр

Значение

Следствие для нас

Режим SPI

mode 0 (CPOL=0, CPHA=0)

конфигурация CONTROL

Макс. частота SCLK

~2 МГц

CLK_DIV: 50 МГц → 1 МГц

Формат транзакции

24 бита под одним CS

WORD_LEN = 24

Разрядность АЦП

12 бит

результат в битах [14:3]

Транзакция выглядит так: master шлёт командный байт (выбор канала — координата X или Y), затем ещё 16 тактов гонит нули, а slave в это время возвращает бит занятости и 12 бит результата. Подробный разбор формата — в главе 28, когда дойдём до чтения координат.

Почему он идеален для отладки: всегда под рукой (распаян на LCD-модуле), медленный (не предъявит претензий к нашим фронтам), с предсказуемым ответом (нет касания — значения у крайних значений шкалы, есть касание — числа в середине диапазона, и их видно глазами). Лучшего спарринг-партнёра для первого SPI-контроллера не придумать.

Шпаргалка главы

Уносим с собой шесть фактов — их хватит для всего проекта:

  1. Четыре провода; SCLK и CS# всегда драйвит master; невыбранный slave отпускает MISO в Z.

  2. SPI — это обмен: на каждое переданное слово приходит принятое.

  3. На бит — два фронта: sample и shift. CPHA выбирает их порядок, CPOL — полярность клока. CPHA=0 требует выставить первый бит до первого фронта.

  4. MSB-first почти везде; палиндромные байты маскируют ошибки порядка бит. Задержки CS-setup/hold и межсловные — не прихоть, а строки из даташитов; делаем их программируемыми.

  5. Burst = несколько слов под одним непрерывным CS#; для многих устройств подъём CS# — смысловая граница.

Теория протокола готова. Следующая глава — про язык, на котором мы всё это опишем: синтезируемый Verilog и его правила выживания.

Глава 5. Синтезируемый Verilog: правила выживания

Главная мысль: вы не пишете программу

Прежде чем разбирать синтаксис, нужно произвести одну операцию над собственной головой. Verilog выглядит как язык программирования — у него есть if, case, присваивания, и из-за этого мозг программиста читает его как последовательность команд. Это ошибка номер ноль, из которой растут все остальные.

Verilog-код — это чертёж железа. Строчки не «выполняются сверху вниз» — они описывают схему: триггеры, мультиплексоры, логические вентили и провода между ними. Всё, что вы описали, существует

одновременно и работает параллельно — как и положено железу. Синтезатор (Quartus) читает ваш текст и решает, какие именно вентили и триггеры соединить, чтобы поведение совпало с описанным.

Из этого следует определение слова «синтезируемый»: это подмножество языка, для которого синтезатор умеет построить схему. Симулятор куда всеяднее — он выполнит и задержки, и файловый ввод-вывод. Граница между «симулируется» и «синтезируется» — это ровно граница каталогов sim/ и rtl/ из главы 3.

Синтаксический минимум: модуль, порты, wire и reg

Кирпич любого проекта — модуль. Вот настоящий, без упрощений, модуль из нашего проекта (он появится в главе 11):

module spi_reset_sync #(

parameter STAGES = 2              // параметр: задаётся при синтезе

) (

input  wire clk,                  // входы — всегда wire

input  wire rst_n_async,

output wire rst_n_sync            // выход может быть wire или reg

);

reg [STAGES-1:0] sync_chain;      // внутренний регистр на STAGES бит

always @(posedge clk or negedge rst_n_async) begin

if (!rst_n_async)

sync_chain <= {STAGES{1'b0}};

else

sync_chain <= {sync_chain[STAGES-2:0], 1'b1};

end

assign rst_n_sync = sync_chain[STAGES-1];

endmodule

Что здесь есть:

  • parameter — настройка времени синтеза. Меняется при инстанцировании модуля, но не во время работы. Правило из главы 2: компромиссы ресурсов — параметры, поведение — регистры.

  • wire — провод. Не хранит ничего, просто соединяет. Значение ему назначают через assign или выход другого модуля. 

  • reg — объект, которому можно присваивать внутри always. Внимание, ловушка терминологии: reg не обязательно станет триггером! Это просто «переменная для always-блока». Станет ли она триггером или куском комбинационной логики — зависит от того, как вы присваиваете (об этом весь раздел 5.4).

  • always @(...) — описание процесса. Список чувствительности говорит синтезатору, что строить: @(posedge clk) — логика на триггерах, 

  • @(*) — чисто комбинационная схема. 

  • Литералы вида 1'b0 — один бит со значением 0; {STAGES{1'b0}} — повторение (STAGES нулей подряд); {a, b} — конкатенация битовых векторов. Эти три конструкции покрывают 90% битовой акробатики.

Два вида always: секвенциальный и комбинационный

В нашем проекте (и в 99% грамотного RTL) встречаются ровно два шаблона always-блока — и больше никаких.

Секвенциальный — описывает триггеры, срабатывает по фронту клока:

always @(posedge clk or negedge rst_n) begin

if (!rst_n)

counter <= 16'd0; // состояние после сброса

else if (counter_enable)

counter <= counter + 16'd1;

// нет else — значит «держи прежнее значение»: это нормально,

// у триггера есть память

end

Комбинационный — описывает логику без памяти (мультиплексоры, декодеры), срабатывает при изменении любого входа:

always @(*) begin                  // @(*) = «от всего, что читается внутри»

rd_data_mux = 32'd0;           // default — ОБЯЗАТЕЛЕН (см. 5.5)

case (addr)

ADDR_STATUS:  rd_data_mux = status_word;

ADDR_RX_DATA: rd_data_mux = rx_fifo_rdata;

default:      rd_data_mux = 32'd0;

endcase

end

Всё. Других видов в синтезируемом коде нашего проекта не будет. Если рука тянется написать always @(posedge clk or posedge some_signal) где some_signal — не сброс, остановитесь и перечитайте главу 6.

Blocking и non-blocking: единственное объяснение

Вот оно, место, где закопано больше всего новичковых багов. В Verilog два оператора присваивания: = (blocking) и <= (non-blocking). Правило, которое нужно выучить как таблицу умножения:

Секвенциальный always (@(posedge clk)) → только <=.

Комбинационный always (@(*)) → только =.

Никогда не смешивать в одном блоке.

Почему именно так — через минутное понимание семантики. Non-blocking <= работает как настоящий триггер: правые части всех таких присваиваний вычисляются по старым значениям, а обновление происходит «одновременно» в конце такта. Классический пример — обмен значениями:

always @(posedge clk) begin

a <= b;    // оба правых значения взяты ДО фронта:

b <= a;    // a и b честно меняются местами, как два триггера крест-накрест

end

Если написать то же с blocking:

always @(posedge clk) begin

a = b;     // a уже перезаписан...

b = a;     // ...и b получает НОВОЕ a, то есть b = b. Обмена нет.

end

— код станет зависеть от порядка строк, чего у параллельного железа быть не должно. Хуже того: симулятор и синтезатор могут понять такой код по-разному, и вы получите главный кошмар отрасли — дизайн, который ведёт себя в симуляции не так, как в кристалле. Вся наша часть IV строится на доверии к симуляции; это доверие начинается с дисциплины <=.

В комбинационных блоках, наоборот, нужен blocking: там вычисление — это цепочка зависимостей в пределах одного «мгновения», и промежуточные значения должны быть видны следующей строке немедленно.

Защёлки: как их случайно создать и почему это плохо

Сделаем намеренную ошибку. Хотим мультиплексор, пишем:

always @(*) begin

if (sel)

y = a;        // а что, если sel == 0?

end

Программистская интуиция говорит: «если sel ложен, ничего не происходит». Но это чертёж! «Ничего не происходит» для схемы означает «y сохраняет прежнее значение» — а сохранять комбинационная логика не умеет, у неё нет памяти. Синтезатор выкручивается единственным способом: вставляет элемент с памятью — защёлку (latch), прозрачную, пока sel=1. 

Чем защёлка плоха: она срабатывает по уровню, а не по фронту, поэтому весь аппарат временного анализа (глава 23) работает с ней плохо; она чувствительна к глитчам комбинационной логики; и главное — вы её не просили. Случайная защёлка — это всегда баг, даже если сегодня он не стреляет.

Защита — два механических правила:

  1. В комбинационном always первой строкой присваивайте default всем выходам блока (видно в примере из 5.3).

  2. В каждом case пишите ветку default:. Quartus при синтезе честно пишет предупреждение inferred latch — в главе 24 мы внесём его в список предупреждений, которые запрещено игнорировать.

Сброс: асинхронный, синхронный и наш выбор

У каждого триггера проекта должно быть определённое значение после сброса — это требование Н3 из главы 2. Вопрос — как сбрасывать. Два классических варианта:

Синхронный сброс — сброс проверяется только по фронту клока (if (!rst_n) внутри always @(posedge clk)). Плюс: сброс идеально вписан во временной анализ. Минус: без клока не сбросишься, и сброс потребляет логический вход LUT.

Асинхронный сброс — сброс в списке чувствительности (always @(posedge clk or negedge rst_n)). Плюс: срабатывает мгновенно и без клока; у триггеров Cyclone IV под него есть выделенный аппаратный вход (бесплатно!). Минус — коварный: если сброс снимается близко к фронту клока, часть триггеров выйдет из сброса в этом такте, а часть — в следующем. Состояние машины после сброса станет лотереей.

Индустриальный ответ — гибрид, «асинхронная установка, синхронное снятие» (async assert / sync deassert): сброс приходит на триггеры асинхронно (выигрываем аппаратный вход и работу без клока), но сам сигнал сброса пропускается через синхронизатор, который снимает его строго по фронту клока — одновременно для всех. Именно эту схему реализует spi_reset_sync из раздела 5.2 — присмотритесь к нему ещё раз: цепочка триггеров асинхронно падает в ноль и синхронно наполняется единицей.  Десять строк, решающих целый класс проблем, — в главе 11 разберём его построчно.

Чего в синтезируемом коде не бывает

Короткий список конструкций, при виде которых в файле из rtl/ нужно бить тревогу:

  • #10 (задержки) — у железа нет оператора «подожди 10 наносекунд». Задержки — только в testbench.

  • initial — в ASIC несинтезируем; в FPGA технически задаёт значение триггера после конфигурации, но мы не пользуемся: явный сброс переносимее и честнее. В testbench initial — основной инструмент.

  • force / release, файловый I/O, $display — инструменты симуляции. ($display внутри rtl-модуля под ifdef SIMULATION — приём легальный, но в этом проекте не понадобится.)

  • Двойное присваивание одного регистра из разных always-блоков — у сигнала может быть только один источник, как у провода — один драйвер.

И одно НЕ-табу, которое часто записывают в запретные по недоразумению: function. Синтезируемая функция — это просто именованный кусок комбинационной логики, и это прекрасный инструмент. В главе 14 функция bit_pos (вычисление индекса бита по номеру такта и порядку MSB/LSB) станет архитектурным стержнем всего engine. Ограничения: функция не может содержать задержек и обращений к нелокальным регистрам — то есть ровно «чистая комбинационка», что нам и нужно.

Шаблон FSM — заготовка для главы 14

Соберём всё выученное в шаблон конечного автомата — тот самый, по которому в главе 14 будет построен spi_engine:

// 1. Состояния — именованные константы

localparam [1:0] S_IDLE = 2'd0,

S_RUN  = 2'd1,

S_DONE = 2'd2;

reg [1:0] state;

// 2. Один секвенциальный блок: переходы + выходы

always @(posedge clk or negedge rst_n) begin

if (!rst_n) begin

state      <= S_IDLE;

busy       <= 1'b0;

done_pulse <= 1'b0;

end else begin

done_pulse <= 1'b0;            // default для импульсных выходов

case (state)

S_IDLE: begin

busy <= 1'b0;

if (start)

state <= S_RUN;

end

S_RUN: begin

busy <= 1'b1;

if (work_finished)

state <= S_DONE;

end

S_DONE: begin

busy       <= 1'b0;

done_pulse <= 1'b1;    // ровно один такт

state      <= S_IDLE;

end

default: state <= S_IDLE;  // страховка от нелегальных кодов

endcase

end

end

Разберём решения шаблона — каждое неслучайно:

  • Один always, а не «два процесса» (комбинационный для переходов + секвенциальный для регистра состояния). Двухпроцессный стиль тоже легален и популярен в учебниках, но один блок исключает целый класс ошибок рассинхронизации и случайных защёлок в комбинационной половине.Для FSM наших размеров — выбор без компромиссов.

  • default: в case — даже если перечислены все состояния. После сбоя (наводка, метастабильность) регистр состояния может оказаться в незакодированном значении — автомат обязан вернуться в осмысленную точку, а не зависнуть.

  • Импульсные выходы сбрасываются default-строкой перед case (done_pulse <= 1'b0) — паттерн «строб длиной ровно один такт», который мы уже обсуждали с требованием Т9 и ещё не раз встретим.

Итог главы

  1. Verilog — чертёж параллельно исполняемой комбинаторной логики в железе, а не программа.

  2. Два вида always: @(posedge clk) с <= и @(*) с =. Не смешивать.

  3. reg — не обязательно триггер; триггером его делает секвенциальный always.

  4. Комбинационный блок без default-присваиваний = случайная защёлка = баг.

  5. Сброс: асинхронная установка, синхронное снятие, модуль-синхронизатор.

  6. #, initial, force — только в sim/. function — легальна и полезна.

  7. FSM: один секвенциальный always, localparam-состояния, default-ветка, импульсы через default-строку.

Остался последний теоретический блок — самое важное архитектурное решение проекта: почему вся схема живёт в одном тактовом домене и что это нам даёт. 

Глава 6. Один тактовый домен — главное архитектурное решение

Соблазн, с которого начинаются плохие SPI-контроллеры

Поставьте себя на место новичка, который только что прочитал главу 4 и открыл редактор. Задача: SPI работает по фронтам SCLK. Первая мысль, которая приходит в голову каждому: «SCLK — это же клок. Сгенерирую его делителем и буду тактировать сдвиговый регистр прямо от него: always @(posedge sclk). Красиво, логика буквально повторяет протокол!»

Мысль настолько естественная, что интернет полон SPI-контроллеров, написанных именно так. И почти все они — мины замедленного действия. Разберёмся, почему, — а заодно выучим главное архитектурное правило этого проекта.

Что на самом деле происходит, когда вы пишете always @(posedge sclk), где sclk — выход вашего же делителя? В дизайне появляется второй тактовый домен: часть триггеров живёт от 50 МГц, часть — от SCLK. А между этими частями неизбежно ходят сигналы: «старт» идёт из регистрового интерфейса (домен 50 МГц) в сдвиговую логику (домен SCLK), принятое слово — обратно. Каждый такой переход называется CDC (clock domain crossing), и каждый — отдельная инженерная проблема:

  1. Гонки и потерянные импульсы. Однотактный строб из быстрого домена медленный домен может просто не заметить — его фронт целиком уместится между двумя фронтами SCLK.

  2. Временной анализ. Каждый клок нужно описать в SDC, каждый переход между доменами — снабдить constraint'ами. Для производного клока, который ещё и останавливается между транзакциями (SCLK не тикает, пока шина простаивает!), это упражнение нетривиально даже для опытных. 

  3. Gated clock. SCLK активен только во время транзакции — значит, это «стробируемый клок», которые в FPGA считаются антипаттерном: клоковые сети кристалла рассчитаны на непрерывные глобальные клоки, а клок, собранный из логики, приходит к триггерам с непредсказуемым перекосом. 

И всё это — ради чего? Ради того, чтобы схема «была похожа на протокол». Плохая сделка.

Решение: один клок, события — по стробам

Наше архитектурное правило звучит так:

В дизайне ровно один тактовый домен — 50 МГц. Все триггеры проекта тактируются только им. SCLK — не клок, а обычный выходной регистр, которым FSM машет, когда считает нужным.

Как при этом устроить «события по фронтам SCLK»? Через clock enable — приём, на котором держится половина цифровой схемотехники. Вместо того чтобы тактироваться медленным клоком, мы тактируемся быстрым, но действуем только в те такты, когда счётчик-делитель говорит «пора»:

// Делитель: строб tick длиной один такт каждые (clk_div+1) тактов 50 МГц

always @(posedge clk) begin

if (div_cnt == clk_div) begin

div_cnt <= 16'd0;

tick    <= 1'b1;           // «пора!» — ровно один такт

end else begin

div_cnt <= div_cnt + 16'd1;

tick    <= 1'b0;

end

end

// Вся SPI-логика: тот же клок 50 МГц, но шаги — только по стробу

always @(posedge clk) begin

if (tick) begin

sclk_reg <= ~sclk_reg;     // SCLK — просто регистр!

// ... sample или shift, в зависимости от фазы ...

end

end

Посмотрите, что произошло с проблемами из 6.1 — они не «решились», они исчезли как класс:

  • Нет второго домена — нет CDC внутри контроллера. «Старт» из регистрового интерфейса и данные из engine ходят по обычным проводам между триггерами одного клока; их корректность гарантирует обычный временной анализ, автоматически.

  • SDC-файл сводится к одной содержательной строке create_clock на 50 МГц (увидим в главе 23). 

  • SCLK как регистр идеально предсказуем: мы знаем, в какой такт он поднимется, и можем выставлять MOSI ровно за полпериода до фронта — семантика sample/shift из главы 4 ложится на FSM один в один.

Цена решения — гранулярность: частоты SCLK получаются делением 50 МГц на целое число, а максимум практически достижимого SCLK — десяток-другой МГц (нужно хотя бы несколько тактов системного клока на каждый полупериод). 

Сверимся с ТЗ из главы 2: XPT2046 просит ~1–2 МГц. Запас — на порядок. Компромисс принят, даже не торгуясь.

Но один сигнал всё-таки приходит «извне»: метастабильность

Внутри кристалла мы навели порядок. Но у контроллера есть вход MISO — и его фронты диктует slave, чужое устройство со своими задержками. Относительно нашего клока MISO меняется в произвольные моменты. То же касается кнопки сброса и сигнала PENIRQ от touch-контроллера. Это называется асинхронный вход, и с ним связано единственное по-настоящему физическое явление в этой статье.

У каждого триггера есть окно уязвимости вокруг фронта клока (setup/hold). Если входной сигнал меняется ровно в этом окне, триггер может на какое-то время зависнуть между нулём и единицей — выходное напряжение болтается у порога, и схема за триггером читает не пойми что. Это метастабильность. Важно понимать три вещи: 

  1. Это не баг кода — это физика. Никакой Verilog не запретит slave переключить MISO в неудачный момент. 

  2. Метастабильное состояние рассасывается само — экспоненциально быстро. Подавляющее большинство случаев разрешается за доли наносекунды; нужно лишь дать триггеру время.

  3. Опасна не сама метастабильность, а её распространение: если зависший выход триггера разойдётся по схеме (например, в FSM), разные части схемы прочтут его по-разному — и автомат прыгнет в нелегальное состояние. (Помните default: в case из главы 5? Вот и страховка на этот случай.)

Двухтриггерный синхронизатор

Стандартное лекарство стоит два триггера:

reg [1:0] miso_sync;

always @(posedge clk)

miso_sync <= {miso_sync[0], spi_miso};   // FF1 ловит, FF2 очищает

wire miso_clean = miso_sync[1];              // только его читает FSM

Логика проста: FF1 принимает удар на себя — именно он рискует зависнуть. Но дальше его выход идёт не в схему, а в один-единственный триггер FF2, и до следующего фронта — целый период клока (20 нс на 50 МГц), за который метастабильность успевает рассосаться с вероятностью, от которой остаётся MTBF в тысячи лет. FF2 выдаёт уже гарантированно чистые нули и единицы.

Плата — задержка в 1–2 такта системного клока. Для нас это важный момент, проверим: не опоздает ли MISO к семплированию? SCLK у нас 1 МГц — полупериод 500 нс, то есть 25 тактов системного клока. Задержка  синхронизатора в 2 такта (40 нс) тонет в этом запасе. А вот если бы мы гнались за SCLK = 25 МГц, этот вопрос стал бы главным в дизайне — ещё одна причина, по которой ограничение частоты из 6.2 нас устраивает.

Правило проекта: каждый асинхронный вход проходит через 2-FF синхронизатор, и сырой сигнал не читает никто, кроме первого триггера. В нашем дизайне таких входов четыре, и у каждого будет свой синхронизатор:

Вход

Откуда

Где синхронизируется

reset_n

кнопка на плате

spi_reset_sync (гл. 11)

spi_miso

XPT2046

внутри spi_engine

spi_busy

XPT2046

top-level

spi_penirq

XPT2046

top-level

Однотактные стробы через 2-FF синхронизатор передавать нельзя (импульс может потеряться между фронтами) — но у нас, к счастью, все асинхронные входы — это уровни, так что хитрых схем с handshake не понадобится. Упоминаю, чтобы вы не применили рецепт за пределами его показаний.

А как же LCD с его 9 МГц?

В части про дисплей возникнет искушение номер два: пиксельный клок LCD — 9 МГц, «заведу-ка я PLL и отдельный клоковый домен под видеотракт». И это снова не нужно: применим тот же приём. Вся LCD-логика тактируется теми же 50 МГц, а пиксельные события задаёт строб pix_en от счётчика-делителя (50 / 5 = 10 МГц — в допуске панели). DCLK для дисплея, как и SCLK для SPI, — обычный выходной регистр. Один и тот же паттерн закрывает оба интерфейса проекта — это и есть признак правильно выбранной архитектуры.

Что мы только что купили

Подведём итог — что даёт проекту правило «один домен + стробы + 2-FF на входах»:

  1. Корректность по построению. Внутри домена все переносы данных защищены статическим временным анализом — целый класс багов (самых злых: редких и невоспроизводимых) исключён архитектурно.

  2. SDC из пяти строк вместо трактата о взаимоотношениях доменов. Меньше constraints — меньше шансов соврать инструменту. 

  3. Простая симуляция. Один клок в testbench, никаких искусственных рассинхронизаций. То, что мы проверим в части IV, будет соответствовать железу.

  4. Переносимость. Никаких PLL и вендорских примитивов — дизайн переедет на любой FPGA, где есть 50 МГц. 

  5. Отлаживаемость. Когда в части VI мы будем смотреть сигналы логическим анализатором, у всех событий будет один временной базис.

Шпаргалка главы

  1. Производный клок из логики (always @(posedge sclk)) = второй домен, CDC, gated clock и боль. Не делаем.

  2. Один домен 50 МГц; медленные события — через clock enable (стробы от делителя). SCLK и DCLK — обычные выходные регистры.

  3. Метастабильность — физика асинхронных входов; лечится временем на рассасывание.

  4. Каждый асинхронный вход → 2-FF синхронизатор; сырой сигнал не читает никто. Для стробов этот рецепт не годится (у нас их и нет).

  5. Задержку синхронизатора (2 такта) надо сверять с полупериодом SCLK — у нас запас 12×.

Теория закончена — и заметьте, её набралось всего три главы: протокол, язык, тактирование. Всё остальное знание мы будем добывать по ходу дела, когда оно понадобится. Впереди часть II: превращаем ТЗ из главы 2 в архитектуру — рисуем блочную схему контроллера, делим его на модули и проектируем регистровую карту, с которой будет работать хост.

Часть II. Архитектура

Глава 7. От ТЗ к блок-схеме

Самый опасный момент проекта

Сейчас наступает момент, который не выглядит опасным, но определяет судьбу проекта сильнее любого другого: первое прикосновение к структуре. ТЗ написано, теория усвоена, руки чешутся открыть редактор и начать писать module spi_master(...). Вот этого делать как раз нельзя.

Если начать с кода, получится то, что получается у всех: один огромный модуль на восемьсот строк, где сдвиговый регистр, делитель частоты, регистры конфигурации и FIFO сплетены в клубок. Такой модуль почти наверняка заработает — в этом и коварство. Но его нельзя протестировать по частям (тест либо «всё работает», либо «где-то в восьмистах строках баг»), нельзя переиспользовать (FIFO впаян намертво), и каждое изменение требует держать в голове всю махину сразу. 

Поэтому день, когда пишется первая строчка RTL, мы отложим до главы 10. А сегодня — рисуем. Цель главы: превратить ТЗ из главы 2 в блок-схему из четырёх модулей с чётко проведёнными границами и подписанными контрактами на каждой границе.

Метод: ищем в ТЗ существительные и потоки

Декомпозиция кажется магией («откуда они знают, как резать?»), но у неё есть вполне механическое начало. Перечитайте ТЗ и выпишите два списка.

Список 1 — существительные, обозначающие вещи, которые что-то хранят или чем-то управляют:

  • регистры (CONTROL, STATUS, CLK_DIV...) — Т7;

  • буферы TX и RX — Т5;

  • конечный автомат транзакции — Т1–Т4, Т6;

  • делитель частоты — Т2;

  • флаги ошибок и прерывания — Т8, Т9.

Список 2 — потоки данных: кто кому что передаёт.

  • Хост пишет конфигурацию и данные → регистровый интерфейс.

  • Слова на передачу текут: хост → TX-буфер → автомат → провод MOSI.

  • Принятые слова текут обратно: провод MISO → автомат → RX-буфер → хост.

  • Статусы и ошибки текут: автомат → регистры → хост.

Теперь сгруппируем существительные так, чтобы внутри группы связи были плотными, а между группами — тонкими (несколько проводов с ясным смыслом). Это классический критерий «high cohesion, low coupling», и для нашего ТЗ группировка получается почти безальтернативной:

Группа существительных

Будущий модуль

Регистры, флаги, прерывания

spi_reg_if

Буфер (одинаковый для TX и RX!)

spi_fifo ×2

Автомат, делитель, сдвиг, провода

spi_engine

Соединить всё вместе

spi_master_top

Обратите внимание на FIFO: ТЗ говорит про два буфера, но как только мы выписали их свойства (синхронная запись, синхронное чтение, флаги full/empty), стало видно, что это один и тот же модуль в двух экземплярах. Первая выгода декомпозиции — ещё до единой строчки кода.

Блок-схема

Соберём группы в картинку. Это — главная диаграмма статьи; всё, что будет происходить в частях III и IV, происходит внутри неё:

И зоны ответственности — по одному абзацу на модуль, как договор о разделе территории.

spi_reg_if — дипломат. Единственный модуль, который разговаривает с хостом. Декодирует адреса, хранит конфигурационные регистры, собирает статусы и ошибки со всего контроллера в слово STATUS, превращает события в прерывание по маске. Ничего не знает про SPI: ни про фронты, ни про CPOL, ни про биты. Если завтра SPI заменить на UART — этот модуль изменится минимально.

spi_fifo — склад. Принимает слова с одной стороны, отдаёт с другой, сообщает «полон»/«пуст», фиксирует попытки переполнения. Не знает ни про хоста, ни про SPI — вообще ни про что, кроме своих указателей. Именно поэтому он встанет и в TX-, и в RX-тракт без единого изменения, а после проекта уйдёт в вашу личную библиотеку переиспользуемых модулей.

spi_engine — работяга. Здесь живёт весь протокол из главы 4: FSM транзакции, делитель частоты, семплирование и сдвиг, задержки CS, burst-логика. Engine не знает про адреса регистров и хоста — ему на входы приходят уже готовые уровни конфигурации (cpol, cpha, clk_div...) и строб start, а наружу он отдаёт провода SPI и события (busy, done, ошибки). Это будет самый сложный модуль проекта — и именно поэтому так важно, что вся сложность протокола заперта в нём одном.

spi_master_top — клей. Не содержит логики вообще: только инстанцирует три модуля выше и соединяет их провода. Звучит как формальность, но у «пустого» интеграционного уровня есть строгая роль: это единственное место, где видно всю картину связей, и единственный модуль, который придётся менять при перестановке частей.

Почему регистры отделены от engine

Граница «reg_if / engine» — наименее очевидная из всех (FIFO напрашиваются сами), поэтому обосную её отдельно. Соблазн слить их велик: и регистры, и FSM — «управляющая логика», и часть сигналов просто пробрасывается насквозь. Три причины не делать этого:

  1. Тестируемость. Engine с уровневыми входами конфигурации можно дёргать в testbench напрямую, без эмуляции шинных транзакций. И наоборот: регистровый интерфейс проверяется на чтение/запись без единой SPI-транзакции. Ошибка локализуется до модуля самим фактом того, какой тест упал.

  2. Замена шины. Сегодня у нас простая параллельная шина, завтра захочется Avalon-MM для Qsys или AXI-Lite. При слитой архитектуре это хирургия на живом FSM; при раздельной — замена одного модуля или вообще написание моста поверх (глава 30).

  3. Два разных мира семантики. Регистры живут в мире «адрес-данные-такт», engine — в мире «фронты-фазы-задержки». Когда они в одном модуле, эти миры начинают протекать друг в друга: появляется код, который при декодировании адреса заглядывает в фазу SCLK. Видели, отлаживали, больше не хотим.

Общий принцип, который стоит унести: границу модуля проводят там, где интерфейс тоньше, а семантика по сторонам — различнее. Между reg_if и engine интерфейс — десяток конфигурационных уровней и три строба. Тоньше в этом дизайне не бывает.

Контракты на границах

Блок-схема без подписанных интерфейсов — картинка, а не архитектура. Пройдём по каждой границе и зафиксируем контракт: какие сигналы, кто их драйвит и — самое важное — какова их временная семантика (уровень или строб). Путаница «уровень/строб» — источник доброй половины интеграционных багов, поэтому в таблицах эта колонка главная. 

Граница 1: хост ↔ spi_reg_if (она же — внешний интерфейс контроллера):

Сигнал

Направление

Тип

Семантика

wr_en

хост → ctrl

строб

1 такт; addr/wr_data валидны в этом же такте

rd_en

хост → ctrl

строб

1 такт; rd_data валиден в этом же такте (комбинационно)

addr

хост → ctrl

уровень

адрес регистра, 4 бита

wr_data

хост → ctrl

уровень

32 бита

rd_data

ctrl → хост

уровень

32 бита

ready

ctrl → хост

уровень

у нас всегда 1 (одна транзакция = один такт); в порту — задел под мост на медленную шину

irq

ctrl → хост

уровень

держится, пока есть неквитированные события

Заметьте решение: шина vendor-neutral — никакого Avalon или AXI, пять сигналов, которые поймёт любой. Подробное обоснование — в главе 8, тут лишь зафиксируем факт.

Граница 2: spi_reg_if ↔ FIFO (×2, зеркально для TX и RX):

Сигнал

Кто драйвит

Тип

Семантика

wr_en / rd_en

reg_if

строб

1 такт = одно слово

wr_data / rd_data

reg_if/FIFO

уровень

данные

full / empty

FIFO

уровень

состояние

overflow

FIFO

уровень

sticky-флаг до flush

Контракт записи: толкнул строб — слово ушло, в том же такте. Проверять full обязан отправитель (FIFO при записи в полный буфер слово отбросит и взведёт overflow — но это уже фиксация ошибки, а не штатный режим).

Граница 3: spi_reg_if → spi_engine (конфигурация и управление):

Сигнал

Тип

Семантика

cpol, cpha, lsb_first, enable

уровень

хост обязан не менять во время busy

clk_div, cs_select, word_len, delay_cfg

уровень

то же

start

строб

запрос транзакции; детали рукопожатия — глава 13

Пункт «не менять во время busy» — типичный пример контракта, который нельзя выразить в Verilog, только в документации и в дисциплине теста. Мы сознательно не строим аппаратную защиту от смены CPOL на лету (ТЗ, раздел исключений) — но обязаны записать это словами. Запишите и вы: невыразимая в коде часть контракта — самая хрупкая.

Граница 4: spi_engine → spi_reg_if (статусы):

Сигнал

Тип

Семантика

busy

уровень

транзакция в полёте

done_pulse

строб

1 такт по завершении (Т9)

err_start_empty и компания

строб

reg_if превращает в sticky-флаги

Здесь зашито важное разделение труда: engine сообщает об ошибках стробами и тут же о них забывает; помнит их (sticky, до явной очистки) регистровый интерфейс. Память об ошибках — это про общение с хостом, значит, территория reg_if.

Граница 5: spi_engine ↔ FIFO. Симметрична границе 2, но читает TX и пишет RX уже engine. Один нюанс, который выстрелит в главе 20: контракт чтения требует, чтобы rd_data был валиден в такте строба — комбинационное чтение. FIFO с регистровым выходом (данные на такт позже) формально «тоже FIFO», но контракт нарушает — и интеграция с engine ломается небанальным образом. Анонс оставляю; вернёмся с поличным.

Прослеживаем потоки через схему

Финальная проверка архитектуры — мысленный прогон сценария из ТЗ через блок-схему. Возьмём основной: «передать слово, получить ответ».

  1. Хост пишет CLK_DIV, WORD_LEN, CONTROL (конфигурация) → осели уровнями в spi_reg_if, протянуты проводами в engine. 

  2. Хост пишет слово в TX_DATA → reg_if декодирует адрес → строб wr_en в TX FIFO → слово на складе.

  3. Хост пишет START → reg_if выдаёт строб start → engine просыпается: читает слово из TX FIFO, опускает CS#, отрабатывает фронты по главе 4. 

  4. Набрав слово с MISO, engine толкает его стробом в RX FIFO и выдаёт done_pulse → reg_if взводит флаг done и, по маске, irq. 

  5. Хост читает STATUS (или ловит irq), затем читает RX_DATA → reg_if стробом вычитывает слово из RX FIFO → данные у хоста.

Каждый шаг сценария лёг на схему без натяжек: ни один сигнал не понадобился «в обход», ни одному модулю не пришлось лезть не в своё дело. Это и есть признак того, что декомпозиция выдержит реализацию. Прогоните так же сценарии burst и переполнения RX — они тоже проходят (проверьте себя: на каком шаге и кто взводит err_rx_overflow?).

Шпаргалка главы

  1. Декомпозиция — до кода. Метод: существительные ТЗ → группы с плотными внутренними связями → модули; потоки данных → интерфейсы. 

  2. Четыре модуля: spi_reg_if (общение с хостом), spi_fifo ×2 (склад), spi_engine (весь протокол), spi_master_top (только соединения).

  3. Границу проводят там, где интерфейс тоньше, а семантика сторон различнее.

  4. Контракт интерфейса = сигналы + кто драйвит + уровень или строб. Невыразимые в коде пункты («не менять конфиг при busy») записываются словами.

  5. Память об ошибках — у reg_if (sticky-флаги); engine только сообщает стробами.

  6. Проверка архитектуры — мысленный прогон сценариев ТЗ: всё должно ложиться без сигналов «в обход».

Скелет готов. Но один интерфейс мы пока описали лишь снаружи — тот, через который контроллер видит хост. Внутри него целая дисциплина: адреса, битовые поля, sticky-флаги, W1C, self-clearing биты. Глава 8 — про то, как проектировать карту регистров, чтобы за неё не было стыдно.

Глава 8. Проектирование карты регистров

Регистровая карта — это API

Сменим на минуту роль. До сих пор мы смотрели на контроллер глазами его автора; теперь посмотрим глазами пользователя — программиста, который будет писать драйвер. Для него весь наш проект — это и есть регистровая карта: набор адресов, по которым можно читать и писать. FSM, FIFO, синхронизаторы — невидимая начинка. Карта — публичный API, и у неё те же законы, что у любого API:

  • её неудобство будет мучить каждого пользователя каждый день;

  • её изменение после выхода в свет ломает чужой код;

  • её несоответствие документации хуже, чем отсутствие документации.

Поэтому карту мы проектируем целиком и документируем до реализации — а в главе 10 она станет первым написанным файлом проекта (spi_defs.vh), из которого будут читать одни и те же дефайны и RTL, и testbench.

Сначала договоримся о форме

Несколько решений, общих для всей карты — скучных, но именно на них держится предсказуемость API.

Ширина данных: 32 бита. Даже там, где полей на три бита. Почему не экономить: хост — это почти всегда 32-битный CPU или мост, для которого неполные слова — лишние мучения. Неиспользуемые биты читаются нулями и игнорируются при записи — это тоже часть контракта: софт получает право писать слово целиком, не выискивая reserved-биты.

Адрес: 4 бита, словная адресация. Адрес — это индекс регистра, не байтовый офсет. 16 регистров нам хватает (займём 11), а превратить индекс в байтовые адреса для конкретной шины — задача будущего моста (офсет = индекс × 4).

Каждому регистру — режим доступа: R/W, RO (read-only), WO (write-only). WO — не экзотика: писать в TX_DATA можно, а «прочитать обратно» бессмысленно — чтение вернуло бы не то, что вы записали, а голову очереди. Честнее запретить, чем путать.

Полная карта

Вот она — все одиннадцать регистров. Эта таблица без изменений переедет в spi_defs.vh, в README и в приложение A:

Адрес

Имя

Доступ

Назначение

0x0

CONTROL

R/W

enable, START, soft reset, CPOL/CPHA, LSB, IRQ enable

0x1

STATUS

RO

busy, done, состояние FIFO, sticky-ошибки

0x2

CLK_DIV

R/W

делитель SCLK

0x3

CS_SELECT

R/W

индекс активного CS

0x4

WORD_LEN

R/W

длина слова в битах (8/16/24/32)

0x5

TX_DATA

WO

запись = push в TX FIFO

0x6

RX_DATA

RO

чтение = pop из RX FIFO

0x7

DELAY_CFG

R/W

задержки CS-setup / CS-hold / inter-word

0x8

IRQ_STATUS

RO

сработавшие события (sticky)

0x9

IRQ_MASK

R/W

какие события поднимают irq

0xA

ERROR_CLR

WO

квитирование: сброс флагов + flush FIFO

Битовые поля двух главных регистров:

CONTROL register

Бит

Поле

Доступ

Поведение

Назначение

[0]

EN

R/W

уровень

Разрешение работы SPI-мастера

[1]

START

WO

self-clearing

Запуск транзакции

[2]

SOFT_RST

WO

self-clearing

Программный сброс блока

[3]

CPOL

R/W

уровень

Полярность SCLK в состоянии покоя

[4]

CPHA

R/W

уровень

Фаза выборки данных

[5]

LSB_FIRST

R/W

уровень

Порядок передачи битов

[6]

IRQ_EN

R/W

уровень

Разрешение прерываний

STATUS register - текущие состояния

Бит

Поле

Тип

Смысл

[0]

BUSY

live

Идет SPI-транзакция

[1]

DONE

sticky

Транзакция завершена

[2]

RX_VALID

live

В RX FIFO есть данные, ~rx_empty

[3]

TX_READY

live

В TX FIFO есть место, ~tx_full

[4]

ENABLED

live

Блок включен

[5]

TX_EMPTY

live

TX FIFO пуст

[6]

RX_FULL

live

RX FIFO заполнен

STATUS register - ошибки

Бит

Поле

Тип

Смысл

[8]

ERR_TX_OVF

sticky

Переполнение TX FIFO

[9]

ERR_RX_OVF

sticky

Переполнение RX FIFO

[10]

ERR_START

sticky

Некорректный запуск транзакции

[11]

ERR_WORD_LEN

sticky

Некорректная длина слова

В колонке «тип» — три слова, за каждым из которых стоит соглашение. Разберём все три: это и есть содержательная часть главы.

Соглашение 1: sticky-флаги — ошибка ждёт, пока её увидят

Представьте: во время burst переполнился RX FIFO. Ошибка длилась один такт — 20 наносекунд. Хост опрашивает STATUS раз в миллисекунду. Если флаг ошибки «живой» (просто отражает текущее состояние), вероятность, что хост его застанет, — двадцать миллионных. Ошибка была, но никто о ней не узнает. 

Поэтому все флаги ошибок — sticky («липкие»): аппаратура взводит флаг по событию, а снять его может только хост, явной операцией квитирования. Контракт читается так: «если флаг поднят — ошибка происходила хотя бы раз с момента последней очистки». Заметьте, как разделение труда из главы 7 ложится на код: engine сообщает об ошибке однотактным стробом и забывает, а помнит — регистровый интерфейс:

// reg_if: строб от engine -> липкий бит для хоста
if (eng_done_pulse)
   stat_done <= 1'b1;     // взводится аппаратурой...
                          // ...снимается только записью в ERROR_CLR

Sticky у нас не только ошибки, но и DONE: транзакция на SCLK 1 МГц длится микросекунды, и «живой» DONE хост ловил бы с тем же успехом. 

А вот флаги состояния (BUSY, RX_VALID, TX_READY) — живые, и это не непоследовательность: они отвечают на вопрос «что сейчас?», а не «что случалось?». Липкий RX_VALID был бы бессмыслицей. Правило выбора: флаг события — sticky, флаг состояния — живой.

Соглашение 2: W1C — квитирование без гонок

Как именно хост снимает sticky-флаги? Наивный вариант: «запиши 0 в бит» — то есть хост читает регистр, сбрасывает нужный бит, пишет слово обратно. Видите гонку? Между чтением и записью аппаратура могла взвести другой бит — и запись его затрёт. Событие потеряно, причём невоспроизводимо. 

Индустриальный ответ — W1C, write-1-to-clear: записываемое слово трактуется не как новое значение, а как маска квитирования — единица в бите означает «этот флаг я видел, снимай». Нетронутые биты не затрагиваются, гонка исчезает:

`SPI_ADDR_ERROR_CLR:
   irq_status <= irq_status & ~wr_data;   // снимаем только указанные

У нас W1C-семантика живёт в регистре ERROR_CLR, и он сделан «уборщиком широкого профиля» — одна запись в него: снимает указанные маской биты IRQ_STATUS, сбрасывает DONE и sticky-ошибки и заодно флашит оба FIFO (строб clr_errors, который мы видели на блок-схеме). Логика объединения: все эти действия — части одного жеста «я разобрался с ошибкой, начинаем с чистого листа». Восстановление после ошибки — одна запись, а не ритуал из пяти.

Соглашение 3: self-clearing START — команда, а не состояние

Бит START по природе отличается от соседнего CPOL: CPOL — это состояние конфигурации («каким быть клоку»), а START — команда («сделай транзакцию»). Если реализовать START обычным R/W-битом, получится классическая ловушка: хост записал 1, транзакция прошла, бит так и стоит... и любая следующая запись в CONTROL (например, захотели включить IRQ_EN) перезапишет CONTROL со всё ещё стоящей единицей START — и запустит транзакцию, которую никто не просил.

Поэтому START — self-clearing: аппаратура снимает его сама. Тонкость — когда снимать. Снять через такт — а вдруг engine в этот момент ещё дорабатывает прошлую транзакцию и строб не увидит? Команда потеряется. Наш контракт: START держится до тех пор, пока engine не подтвердит приём делом:

// START снимается, когда engine отреагировал (busy) --
// или мгновенно отчитался ошибкой/завершением (done).
  
if (eng_busy || eng_done_pulse)
   ctrl_start <= 1'b0;

Это рукопожатие выглядит мелочью, но именно на нём мы поймаем одну из гонок в главе 20 — запомните это место. И ещё одна продуманная мелочь из той же серии: START срабатывает, только если контроллер включён, причём включён может быть этой же записью — условие ctrl_en | wr_data[EN] позволяет драйверу включить и запустить контроллер одной транзакцией. Удобство API складывается из таких крупиц. 

По той же схеме self-clearing работает SOFT_RST. А при чтении CONTROL оба командных бита возвращают 0 — читать команду бессмысленно.

Соглашение 4: RX_DATA — чтение с побочным эффектом

Регистр RX_DATA нарушает святое правило хороших API — «чтение не меняет состояния»: каждое чтение выталкивает слово из RX FIFO. Альтернатива — пара «прочитай head / подтверди pop» двумя регистрами — чище теоретически, но удваивает число транзакций на каждое принятое слово. 

Мы выбираем side-effect — так делают практически все UART/SPI-контроллеры в индустрии, — но выбор имеет цену, и её надо знать: 

  • Отладчик-вредитель. Окно памяти в отладчике, периодически читающее RX_DATA, будет молча воровать данные у драйвера. Это не гипотеза — это стандартные грабли, о которых пишут в errata реальных чипов.

  • Дисциплина драйвера: сначала проверь RX_VALID, потом читай. Чтение пустого FIFO у нас безопасно (вернутся нули, underflow не взводится благодаря защите !rx_fifo_empty), но нули неотличимы от честного нулевого слова.

И симметричная договорённость на запись: TX_DATA при полном FIFO слово отбрасывает и взводит sticky ERR_TX_OVF. Молчаливое проглатывание без флага было бы худшим из миров — данные теряются, и никто не узнаёт.

8.8. Конфигурационные регистры: мелочи, которые не мелочи

CLK_DIV. Формула из реализации: SCLK-полупериод равен CLK_DIV + 1 тактам системного клока, то есть f_SCLK = 50 МГц / (2 × (CLK_DIV + 1)). Для XPT2046: CLK_DIV = 24 → 1 МГц. Обратите внимание на «+1» — оно гарантирует, что даже нулевой делитель не породит бесконечную частоту. А ещё запись нуля... просто игнорируется — защита от значения, способного заморозить генератор. Спорное решение (тихий отказ!), но задокументированное.

CS_SELECT — индекс, а не маска. Можно было сделать битовую маску «какие CS активировать» — гибче! Но гибкость тут вредна: маска позволяет активировать двоих, что для независимых slave — авария на шине (оба полезут драйвить MISO). Индекс делает ошибку непредставимой на уровне типа данных. Принцип API-дизайна в чистом виде: make illegal states unrepresentable.

WORD_LEN — биты, а не код. Длина задаётся числом бит (8/16/24/32), а не кодом 0/1/2/3. Расход — четыре лишних бита регистра; выгода — самодокументируемость дампов (в логе видно «24», а не загадочное «2») и готовность к произвольным длинам в будущем. Валидность проверяет engine на старте (ERR_WORD_LEN).

DELAY_CFG — три задержки в одном регистре:

[31:24]   [23:16]      [15:8]     [7:0]
reserved  INTER_WORD   CS_HOLD    CS_SETUP     (в тактах 50 МГц)

Почему упакованы вместе, а не три регистра? Они конфигурируются всегда одновременно (это профиль одного slave), 8 бит на поле хватает (255 тактов = 5.1 мкс — больше не просит ни один даташит), а адресное пространство у нас не резиновое. Узнаёте компромисс из ТЗ? Это требование Т6, дословно превратившееся в формат регистра.

Шина: почему не Avalon и не AXI сразу

Контроллер делается для Quartus — казалось бы, сам бог велел сразу дать ему Avalon-MM интерфейс и подключаться через Platform Designer. Мы сознательно этого не делаем; наш интерфейс — пять сигналов из главы 7 (wr_en/rd_en/addr/wr_data/rd_data + ready). Аргументы:

  1. Тестируемость без зависимостей. Avalon/AXI в testbench — это либо вендорские BFM (прощай, Icarus), либо сотни строк собственной модели шины. Наш интерфейс дёргается из testbench двумя task'ами по десять строк (увидите в главе 18). 

  2. Переносимость. Ядро без вендорских интерфейсов синтезируется чем угодно и переезжает куда угодно. Avalon приколачивает к экосистеме Intel.

  3. Мост — это дёшево. Наша шина спроектирована как «общий делитель» шинных протоколов: однотактная запись, комбинационное чтение, ready для медленных мостов (у нас он константная единица — но порт в контракте уже есть). Мост на Avalon-MM поверх такого — действительно вечер работы (глава 30).

  4. И главное для этой статьи: в автономной демо-прошивке хостом будет не CPU, а наши же FSM (demo_init и spi_probe, часть VI) — им простая шина и нужна.

Это решение — «ядро с нейтральным интерфейсом + тонкие мосты по вкусу» — стандартная архитектура серьёзных IP, и привыкать к ней лучше с первого проекта.

Шпаргалка главы

  1. Регистровая карта — публичный API; проектируется и документируется до реализации, живёт в одном header для RTL и тестов. 

  2. Флаг события — sticky, флаг состояния — живой. Sticky взводит железо, снимает только хост.

  3. Квитирование — W1C (запись маски), иначе гонка read-modify-write теряет события.

  4. Командные биты (START, SOFT_RST) — self-clearing, снимаются по подтверждению от исполнителя, читаются нулём.

  5. RX_DATA с pop-on-read — индустриальный стандарт, но помните про отладчик-вредитель и дисциплину «сначала RX_VALID».

  6. Делайте нелегальные состояния непредставимыми (CS_SELECT — индекс, не маска).

  7. Шина ядра — vendor-neutral; мосты на Avalon/AXI — отдельным тонким слоем потом.

Карта регистров готова и заморожена. Осталось обустроить рабочее место — структуру каталогов и Makefile, — и можно писать код. Глава 9, последняя перед реализацией.

Глава 9. Структура репозитория и Makefile

Зачем глава про каталоги

Признаюсь: глава о раскладывании файлов по папкам выглядит как наполнитель. Но есть причина, по которой она здесь, причём до первой строчки RTL: структура репозитория — это материализованная архитектура. Граница «синтезируемое / несинтезируемое» из главы 5, граница «ядро / плата», граница «исходники / артефакты сборки» — всё это либо закреплено на уровне файловой системы и работает само, либо живёт в голове и однажды нарушается. Десять минут на обустройство сейчас — или архeология «а этот файл вообще синтезируется?» через месяц.

Четыре каталога

2.SPI/
├── rtl/            # синтезируемый Verilog -- и ТОЛЬКО он
│   ├── spi_defs.vh
│   ├── spi_reset_sync.v
│   ├── spi_fifo.v
│   ├── spi_reg_if.v
│   ├── spi_engine.v
│   ├── spi_master_top.v
│   └── spi_master_fpga_top.v
├── sim/            # testbench'и и модели -- несинтезируемый мир
│   ├── tb_spi_slave_ref.v
│   └── tb_spi_master.v
├── quartus/        # проект Quartus: QPF, QSF, SDC + артефакты сборки
├── docs/           # документация (включая эту статью)
├── Makefile        # точка входа в симуляцию
└── README.md       # карта регистров, инструкции

Правила, ради которых всё затевалось:

  • rtl/ — территория синтеза. Каждый файл здесь обязан проходить синтез. Появился #delay или initial — файл живёт не в той папке (глава 5 объяснила, почему это не вкусовщина).

  • sim/ — всё остальное HDL-хозяйство: testbench'и, поведенческие модели устройств. Сюда синтезатор не заглядывает вообще — в QSF (глава 21) этот каталог просто не упомянут.

  • quartus/ — карантин для тулчейна. Quartus порождает при компиляции десятки файлов (db/, incremental_db/, отчёты, rbf...). Запертые в одном каталоге, они накрываются одним .gitignore, и исходники остаются исходниками. 

  • Имена файлов = имена модулей: в файле spi_fifo.v живёт модуль spi_fifo, один на файл. Банально? Да. Нарушается в половине чужих проектов, после чего grep и Quartus одинаково страдают? Тоже да. Префикс spi_ / lcd_ — пространство имён подсистемы, tb_ — метка testbench.

Файлы в rtl/ перечислены не случайно: это ровно те модули, что мы спроектировали в главе 7 (плюс два, о которых ниже), и в части III мы напишем их в порядке списка — от листьев дерева к корню.

Два имени требуют пояснения. spi_master_top — вершина ядра: всё, что переносимо. spi_master_fpga_top — обёртка уровня платы: в ней синхронизатор кнопки сброса и привязка к реальным портам FPGA. Разделение то же, что «ядро/мост» в главе 8: ядро не знает, на какой плате живёт. Когда в части VI появится LCD-подсистема, она въедет в rtl/lcd/ со своим префиксом — структура готова к росту заранее.

spi_defs.vh — единственный источник правды

В главе 8 мы заморозили карту регистров. Теперь — инженерный вопрос: где ей жить? Адреса и битовые поля нужны минимум в трёх местах: в RTL (декодер в spi_reg_if), в testbench (task'и чтения-записи) и в документации. Худшее решение — три копии: они обязательно разъедутся, и вы получите баг класса «тест пишет в 0x5, а железо слушает 0x6»,  причём тест при этом может даже проходить (писали и читали по одинаково неверному адресу!).

Решение — header-файл spi_defs.vh, который инклюдят и RTL, и testbench:

// spi_defs.vh -- фрагмент
  
`define SPI_ADDR_CONTROL   4'h0
`define SPI_ADDR_TX_DATA   4'h5
`define SPI_CTRL_START     1      // бит в CONTROL
  
// в rtl/spi_reg_if.v:                 // в sim/tb_spi_master.v:
`include "spi_defs.vh"                 `include "spi_defs.vh"
  
case (addr)                            reg_write(`SPI_ADDR_TX_DATA, word);
 `SPI_ADDR_TX_DATA: ...

Теперь переименование или переезд регистра — правка одной строки, и несоответствие RTL↔тест становится непредставимым (знакомый принцип из главы 8, применённый к собственному процессу). Третья копия — таблица в README — увы, остаётся ручной; компромисс честный, пока проект не оброс генератором документации.

Защитный ритуал любого хедера — include guard, страховка от двойного включения:

`ifndef SPI_DEFS_VH
`define SPI_DEFS_VH
//  ... содержимое ...
`endif

Makefile: кнопка «проверить всё»

Симуляция, которую неудобно запускать, не запускается. Вся методология части IV держится на том, что между «изменил строчку RTL» и «увидел PASS/FAIL» — одна команда и несколько секунд. Эту кнопку мы и делаем:

IVERILOG ?= iverilog
VVP      ?= vvp
GTKWAVE  ?= gtkwave

RTL_DIR := rtl
SIM_DIR := sim

SPI_RTL := \
   $(RTL_DIR)/spi_reset_sync.v \
   $(RTL_DIR)/spi_fifo.v       \
   $(RTL_DIR)/spi_reg_if.v     \
   $(RTL_DIR)/spi_engine.v     \
   $(RTL_DIR)/spi_master_top.v

SPI_TB := \
   $(SIM_DIR)/tb_spi_slave_ref.v \
   $(SIM_DIR)/tb_spi_master.v

IVERILOG_FLAGS := -g2005 -I$(RTL_DIR) -Wall

tb_spi_master.vvp: $(SPI_RTL) $(SPI_TB)
   $(IVERILOG) $(IVERILOG_FLAGS) -o $@ $(SPI_RTL) $(SPI_TB)

sim_spi: tb_spi_master.vvp
   $(VVP) $<

wave_spi: sim_spi
   $(GTKWAVE) tb_spi_master.vcd &

clean:
   rm -f *.vvp *.vcd

.PHONY: sim_spi wave_spi clean

Построчный разбор решений:

  • ?= вместо := для инструментов — переменные окружения могут их переопределить (IVERILOG=iverilog-v12 make sim_spi), не трогая файл.

  • Явный список файлов, не $(wildcard rtl/*.v). Wildcard молча подхватит забытый эксперимент spi_engine_old.v — и вы будете отлаживать призрак. Цена явного списка — одна строчка на новый модуль; это цена осознанности, платим с удовольствием.

  • -g2005 — диалект языка. RTL мы пишем на Verilog-2001, но testbench использует пару удобств 2005 года; для RTL-подмножества разницы нет.

  • -I$(RTL_DIR) — путь поиска для include "spi_defs.vh": testbench лежит в sim/, а хедер — в rtl/, и флаг -I их знакомит.

  • -Wall — все предупреждения. Договоримся на берегу: предупреждений в чистой сборке — ноль. Не «ноль важных», а ноль: только тогда новое предупреждение заметно. Это та же дисциплина, что будет с warnings Quartus в главе 24.

  • .vvp как настоящая цель с зависимостями — make пересобирает, только если исходники менялись. На наших объёмах экономия копеечная, но правильная форма Makefile с зависимостями — привычка, которая окупится на проектах, где компиляция минуты.

  • wave_spi — однокнопочный путь «прогнал и смотрю волны»: цель зависит от sim_spi, так что GTKWave всегда открывает свежий VCD.

К концу проекта Makefile обрастёт целями-близнецами sim_lcd и sim_int (интеграционный тест) и сводной целью sim, гоняющей всё, — но костяк останется ровно этим.

Контрольная точка: скелет дышит

Завершаем часть II ритуалом, который стоит проводить в каждой такой точке: убеждаемся, что инфраструктура работает, до того как в неё въедет содержимое. Создаём файлы-заглушки — каждый модуль из списка 9.2 объявляем с портами из контрактов главы 7, но с пустым телом; testbench — один initial с $display("hello"); $finish;. И: 

$ make sim_spi
iverilog -g2005 -Irtl -Wall -o tb_spi_master.vvp rtl/... sim/...
vvp tb_spi_master.vvp
hello

Что мы только что проверили одной командой: инструменты установлены и в PATH (глава 3); пути и -I сходятся; имена файлов и модулей совпадают; порты заглушек соответствуют тому, как их инстанцирует spi_master_top, — то есть контракты главы 7 уже проверяются компилятором. Любая ошибка в этом хозяйстве сейчас стоит две минуты, а обнаруженная посреди отладки engine — час, потому что искать её будут не там. 

Это последняя глава, в которой ничего не работает. Скелет собран, кнопка нажимается — переходим к части III и начинаем наполнять модули, в установленном порядке: от самого простого (spi_defs.vh — глава 10) к самому сложному (spi_engine — глава 14).

Шпаргалка главы

  1. Структура каталогов — материализованная архитектура: rtl/ — только синтезируемое, sim/ — модели и тесты, quartus/ — карантин артефактов под одним .gitignore. 

  2. Один файл = один модуль = одно имя; префиксы spi_/lcd_/tb_ как пространства имён.

  3. Карта регистров живёт в одном хедере (spi_defs.vh) для RTL и testbench — расхождение непредставимо. Include guard обязателен.

  4. Makefile: явные списки файлов (не wildcard), -Wall с политикой «ноль предупреждений», -I для хедеров, цели с зависимостями. 

  5. Однокнопочный цикл «правка → PASS/FAIL» — фундамент всей верификации. 

  6. Контрольная точка на заглушках: инфраструктура и контракты портов проверены до написания логики.

Часть III. Реализация — модуль за модулем

Глава 10. Шаг 1: spi_defs.vh — карта регистров в коде

Первый файл проекта

Часть III открывается, и с этого момента формат меняется: меньше рассуждений, больше кода. Каждая глава — один файл из списка в разделе 9.2, в порядке «от листьев к корню»: сначала модули без зависимостей, потом те, кто их использует. Каждая глава заканчивается контрольной точкой — проверяемым утверждением «что уже работает».

Первый файл — не модуль вообще. Это header spi_defs.vh: карта регистров из главы 8, переведённая с языка таблиц на язык препроцессора. Логики в нём ноль, но по важности он первый и по порядку, и по сути — все будущие модули и тесты будут говорить друг с другом его словами.

Почему define, а не parameter

В Verilog три способа завести именованную константу, и выбор между ними — не вкусовщина:

  • localparam — константа внутри модуля. Идеальна для состояний FSM (S_IDLE из главы 5): они — приватная кухня модуля, снаружи не нужны.

  • parameter — настройка модуля, переопределяемая при инстанцировании (FIFO_DEPTH). Это интерфейс конфигурации, а не просто константа.

  • `define — текстовая подстановка препроцессора, видимая во всех файлах после включения. Единственный из трёх, кто умеет пересекать границы модулей и — важнее — границу RTL/testbench.

Адрес регистра должен быть одинаково виден декодеру в spi_reg_if и task'ам теста — значит, define, и только он. Обратная сторона: дефайны живут в глобальном пространстве имён, отсюда железное правило — каждый дефайн проекта начинается с SPI_. Конфликт имён с чужим кодом при таком префиксе практически исключён.

Листинг

Файл целиком — он короткий (привожу без шапки-комментария):

`ifndef SPI_DEFS_VH
`define SPI_DEFS_VH

// --------------------------------------------------------------------------
// Адреса регистров (addr[3:0])
// --------------------------------------------------------------------------
`define SPI_ADDR_CONTROL      4'h0  // R/W
`define SPI_ADDR_STATUS       4'h1  // RO
`define SPI_ADDR_CLK_DIV      4'h2  // R/W
`define SPI_ADDR_CS_SELECT    4'h3  // R/W
`define SPI_ADDR_WORD_LEN     4'h4  // R/W
`define SPI_ADDR_TX_DATA      4'h5  // WO  (push TX FIFO)
`define SPI_ADDR_RX_DATA      4'h6  // RO  (pop  RX FIFO)
`define SPI_ADDR_DELAY_CFG    4'h7  // R/W
`define SPI_ADDR_IRQ_STATUS   4'h8  // RO
`define SPI_ADDR_IRQ_MASK     4'h9  // R/W
`define SPI_ADDR_ERROR_CLR    4'hA  // WO  (W1C + flush FIFO)

// --------------------------------------------------------------------------
// Биты регистра CONTROL
// --------------------------------------------------------------------------
`define SPI_CTRL_EN           0   // 1: контроллер включён
`define SPI_CTRL_START        1   // self-clearing строб
`define SPI_CTRL_SOFT_RST     2   // self-clearing soft reset
`define SPI_CTRL_CPOL         3
`define SPI_CTRL_CPHA         4
`define SPI_CTRL_LSB_FIRST    5   // 0: MSB-first (default)
`define SPI_CTRL_IRQ_EN       6

// --------------------------------------------------------------------------
// Биты регистра STATUS (ошибки -- sticky, снимаются через ERROR_CLR)
// --------------------------------------------------------------------------
`define SPI_STAT_BUSY         0
`define SPI_STAT_DONE         1   // sticky
`define SPI_STAT_RX_VALID     2   // ~rx_fifo_empty
`define SPI_STAT_TX_READY     3   // ~tx_fifo_full
`define SPI_STAT_ENABLED      4
`define SPI_STAT_TX_EMPTY     5
`define SPI_STAT_RX_FULL      6
`define SPI_STAT_ERR_TX_OVF   8   // sticky
`define SPI_STAT_ERR_RX_OVF   9   // sticky
`define SPI_STAT_ERR_START    10  // sticky: START при пустом TX FIFO
`define SPI_STAT_ERR_WORD_LEN 11  // sticky: неверная WORD_LEN на старте

// --------------------------------------------------------------------------
// Биты IRQ_STATUS / IRQ_MASK
// --------------------------------------------------------------------------
`define SPI_IRQ_DONE          0
`define SPI_IRQ_RX_VALID      1
`define SPI_IRQ_ERROR         2

// --------------------------------------------------------------------------
// Поля DELAY_CFG (единицы: такты clk)
// --------------------------------------------------------------------------
`define SPI_DELAY_CS_SETUP    7:0    // CS-low -> первый фронт SCLK
`define SPI_DELAY_CS_HOLD     15:8   // последний фронт SCLK -> CS-high
`define SPI_DELAY_INTER       23:16  // пауза между словами burst
//        биты [31:24] зарезервированы

`endif // SPI_DEFS_VH

Сверьте с таблицами главы 8 — это они и есть, символ в символ. Из неочевидного в листинге — три вещи.

Биты — числами, не масками. SPI_CTRL_START определён как 1 (номер бита), а не как 32'h0000_0002 (маска). Номер универсальнее: из него получается и селектор бита wr_data[SPI_CTRL_START], и маска (32'h1 << SPI_CTRL_START) — а вот из маски номер уже не достать.

Дырки в нумерации — осознанные. STATUS перепрыгивает с бита 6 на бит 8: ошибки начинаются с выровненной позиции, и между «состоянием» и «ошибками» оставлен резерв. Будущий бит состояния не заставит перенумеровывать ошибки — а перенумерация опубликованного регистра, как мы выяснили в главе 8, это слом чужих драйверов.

Поля DELAY_CFG — диапазонами. И вот тут зарыты грабли, заслуживающие отдельного раздела.

Грабли: дефайн-диапазон не работает в выражениях

Дефайн `SPI_DELAY_CS_HOLD раскрывается в текст 15:8. В part-select он великолепен:

wire [7:0] cs_hold = delay_cfg[`SPI_DELAY_CS_HOLD];   // = delay_cfg[15:8]

Читается как проза, опечатка в границах невозможна. Но помните: это текстовая подстановка. Попробуйте использовать то же имя там, где нужен номер позиции, — например, собирая слово для записи в регистр:

// ХОЧЕТСЯ: задвинуть значение в поле сдвигом
wdata = (hold_val << `SPI_DELAY_CS_HOLD);   // раскрывается в:
wdata = (hold_val << 15:8);                 // синтаксическая ошибка!

Препроцессор честно подставил 15:8 — и выражение рассыпалось. Причём ошибку вы получите не в хедере, а в файле-потребителе, с сообщением симулятора вида syntax error куда-нибудь в середину строки — иди догадайся, что виноват дефайн.

Это не выдуманный пример: ровно на эти грабли мы наступили в этом проекте — правда, в LCD-половине (testbench собирал значение регистра сдвигом, и iverilog ответил загадочным syntax error in task arguments). Лечение — соглашение: для каждого поля-диапазона, которым придётся пользоваться в выражениях, заводится дефайн-близнец с суффиксом _LSB:

`define LCD_CTRL_PATTERN      3:2   // для part-select
`define LCD_CTRL_PATTERN_LSB  2     // для сдвигов и арифметики

wdata = (pattern << `LCD_CTRL_PATTERN_LSB);          // работает
mode  = ctrl_reg[`LCD_CTRL_PATTERN];                 // тоже работает

В SPI-хедере близнецы пока не нужны (поля DELAY_CFG читаются только part-select'ом), поэтому их там нет — по принципу «не плодить сущностей впрок». Но правило держим в кармане: понадобился сдвиг — добавляем _LSB-дефайн, а не вписываем магическую цифру в выражение.

Контрольная точка

Файл без модулей не скомпилируешь сам по себе — поэтому проверка маленькая, но честная: включаем хедер в заглушку и убеждаемся, что препроцессор доволен, а include guard работает:

// sim/tb_smoke.v -- временная заглушка
`include "spi_defs.vh"
`include "spi_defs.vh"   // нарочно дважды: guard должен спасти

module tb_smoke;
   initial begin
       $display("CONTROL addr = %0d, START bit = %0d",
                `SPI_ADDR_CONTROL, `SPI_CTRL_START);
       $finish;
   end
endmodule

$ iverilog -g2005 -Irtl -o smoke.vvp sim/tb_smoke.v && vvp smoke.vvp
CONTROL addr = 0, START bit = 1

Двойной include не вызвал ругани на переопределение — guard на месте. Числа напечатались верные — дефайны живы. Заглушку можно удалять: со следующей главы хедер начнут включать настоящие жильцы.

Контрольная точка пройдена: карта регистров существует в виде кода, включается из rtl/ и sim/ без ошибок, и с этого момента — она единственный источник правды (глава 9.3). Любое изменение карты в статье дальше будет означать правку этого файла и только его.

Глава 11. Шаг 2: spi_reset_sync.v — самый короткий модуль проекта

Задача в одном абзаце

Теория уже отработана дважды: в главе 5 мы выбрали стратегию сброса «асинхронная установка, синхронное снятие», в главе 6 — познакомились с метастабильностью и 2-FF синхронизатором. Этот модуль — место, где обе линии сходятся. Вход: сигнал с кнопки платы, грязный и асинхронный. Выход: сброс, который устанавливается мгновенно (даже без клока), а снимается строго по фронту — одновременно для всех триггеров проекта.

Листинг

Полный файл. Да, вместе с шапкой и обвязкой он короче, чем рассуждения о нём, — и это нормально:

`default_nettype none

module spi_reset_sync #(
   parameter STAGES = 2          // ступеней синхронизатора (>= 2)
) (
   input  wire clk,
   input  wire rst_n_async,
   output wire rst_n_sync
);

   reg [STAGES-1:0] sync_chain;

   always @(posedge clk or negedge rst_n_async) begin
       if (!rst_n_async)
           sync_chain <= {STAGES{1'b0}};
       else
           sync_chain <= {sync_chain[STAGES-2:0], 1'b1};
   end

   assign rst_n_sync = sync_chain[STAGES-1];

endmodule

`default_nettype wire

Построчный разбор

`default_nettype none — новый идиом, объявляется впервые. По умолчанию Verilog обладает чертой, унаследованной из эпохи, когда экономили нажатия клавиш: необъявленный идентификатор молча становится однобитным wire. Опечатались в имени — rst_n_synс вместо rst_n_sync (заметили кириллическую «с»?) — и вместо ошибки компиляции получили новый провод, болтающийся в воздухе, и схему, которая «почти работает». Директива default_nettype none выключает это наследие: всякий неизвестный идентификатор — ошибка. В конце файла возвращаем wire — директива глобальна для потока компиляции, и невежливо ломать ею чужие файлы, написанные в расчёте на умолчание. Эта пара строк будет обрамлять каждый RTL-файл проекта; больше комментировать её не буду.

parameter STAGES = 2. Двух ступеней достаточно для наших 50 МГц (MTBF — астрономический), но параметр оставляет дверь открытой: на более быстром клоке или в параноидальном проекте ставят 3. Обратите внимание: код написан так, что работает при любом STAGES ≥ 2 — выражение sync_chain[STAGES-2:0] при STAGES=2 — это просто sync_chain[0:0].

Always-блок. Перед вами гибрид из главы 5 в чистом виде:

  • Асинхронная установка: negedge rst_n_async в списке чувствительности. Кнопка нажата → вся цепочка падает в ноль немедленно, клок не нужен. Выход rst_n_sync тоже падает сразу — потребители уходят в сброс без задержки.

  • Синхронное снятие: кнопка отпущена → по каждому фронту клока в цепочку вдвигается единица: {sync_chain[STAGES-2:0], 1'b1} — сдвиг влево с константой-единицей на входе. Через STAGES тактов единица доползает до старшего бита — и выход поднимается.

assign rst_n_sync = sync_chain[STAGES-1]. Выход — последняя ступень цепочки. Первая ступень (sync_chain[0]) — жертвенный триггер из главы 6: именно он рискует поймать метастабильность, если кнопку отпустили впритирку к фронту. До потребителей его дрожь не доходит — её гасят следующие ступени.

Нарисуем временную диаграмму снятия сброса (STAGES=2):

Сравните с диаграммой установки — там всё мгновенно:

Асимметрия — суть модуля: в сброс падаем как угодно быстро, из сброса выходим строем.

Почему десять строк заслуживают отдельного файла

Соблазн велик: «да я эти три строчки прямо в топе напишу». Аргументы против — те же, что были за декомпозицию в главе 7, просто в миниатюре: 

  1. По одному на домен. Правило из шапки файла: один инстанс на каждый тактовый домен, которому нужен этот сброс. Сегодня домен один, но модуль — это готовность к «а добавим-ка PLL» без копипасты. 

  2. Имя — это документация. Строка spi_reset_sync u_rst_sync (...) в топе говорит читателю всё. Те же три строки россыпью среди другой логики — загадка «зачем тут цепочка из двух триггеров».

  3. Synthesis-атрибуты лягут в одно место. Продвинутый ход (мы его делать не будем, но знать стоит): триггерам синхронизаторов полагаются атрибуты, запрещающие синтезатору их «оптимизировать» и помогающие STA их распознать. Когда синхронизатор — модуль, атрибуты пишутся один раз.

И методическое замечание о порядке работы: мы начинаем реализацию с самого простого модуля не для разминки пальцев. Первый настоящий модуль — это обкатка всего конвейера (файл → Makefile → компиляция →  смок-тест) на объекте, в котором нечему ломаться. Если что-то пойдёт не так — виноват конвейер, не код. Отладка по одной переменной за раз — лейтмотив этой статьи вплоть до главы 27.

Контрольная точка

Модуль уже в списке SPI_RTL Makefile (глава 9), так что компиляцию проверяет общая сборка. Для смок-теста — десятиминутный одноразовый testbench (потом его поглотит большой тест):

`timescale 1ns/1ps

module tb_reset_sync_smoke;
   reg  clk = 0, rst_n_async = 1;
   wire rst_n_sync;

   spi_reset_sync dut (.clk(clk), .rst_n_async(rst_n_async),
                       .rst_n_sync(rst_n_sync));

   always #10 clk = ~clk;                 // 50 МГц

   initial begin
       // Снятие сброса нарочно НЕ по фронту: +3 нс после него
       #23 rst_n_async = 0;               // нажали кнопку
       #17 rst_n_async = 1;               // отпустили асинхронно
       #100 $finish;
   end

   initial $monitor("t=%0t async=%b sync=%b",
                    $time, rst_n_async, rst_n_sync);
endmodule

Что проверяем глазами по выводу $monitor:

  • rst_n_sync упал в тот же момент, что и rst_n_async (асинхронная установка — между ними ноль времени);

  • поднялся — не сразу, а через два фронта клока после отпускания (синхронное снятие).

Контрольная точка пройдена: конвейер «файл → сборка → прогон» обкатан, первый синтезируемый модуль проекта работает. Следующий шаг серьёзнее — синхронный FIFO, первый модуль с настоящей логикой и первый, где проектное решение (комбинационное чтение) выстрелит аж в главе 20.

Глава 12. Шаг 3: spi_fifo.v — синхронный FIFO

Первый модуль с настоящей логикой

FIFO — очередь «первым вошёл, первым вышел» — мы уже назначили на роль склада между хостом и engine (глава 7): хост сваливает слова со своей скоростью, engine забирает со своей. Сегодня пишем этот склад. По дороге решим классическую задачку (как отличить «полон» от «пуст»), заведём два sticky-флага и примем решение, которое аукнется через восемь глав.

Сразу сужение задачи, экономящее половину сложности: наш FIFO — одноклоковый (synchronous). Обе стороны — и пишущая, и читающая — живут на одних 50 МГц (спасибо главе 6). Двухклоковые (asynchronous) FIFO с серыми кодами указателей — отдельная наука, которая нам не нужна: ещё один дивиденд решения «один домен».

Кольцо, два указателя и проблема на единичку

Внутри FIFO — обычный массив на DEPTH слов и два указателя: wr_ptr (куда писать следующее) и rd_ptr (откуда читать следующее). Оба только растут; адресом в массиве служит остаток от деления на DEPTH — массив замыкается в кольцо:

И вот классика жанра: как узнать, что очередь полна? «Указатели равны» — это пустая очередь. Но если записать DEPTH слов, указатели тоже сравняются (wr_ptr обошёл кольцо и догнал rd_ptr) — и «полная» неотличима от «пустой». Народных решений три: держать отдельный счётчик заполнения, жертвовать одной ячейкой («полон» = осталась одна), или — самое изящное — расширить указатели на один бит.

Мы берём третье. При DEPTH = 8 адрес требует 3 бита, а указатели делаем 4-битными. Младшие 3 бита — адрес в кольце; старший бит — «фазовый»: он переключается каждый раз, когда указатель завершает круг. Теперь: 

  • пусто: указатели равны полностью — оба на той же позиции того же круга;

  • полно: адресные биты равны, фазовые различаются — wr_ptr на той же позиции, но кругом впереди.

А разность указателей — беззнаковая, с естественным заворотом — всегда даёт точное число слов в очереди, и обе проверки сводятся к ней:

reg [ADDR_WIDTH:0] wr_ptr, rd_ptr;            // на бит шире адреса!

assign count = wr_ptr - rd_ptr;               // 0 .. DEPTH
assign full  = (count == DEPTH);
assign empty = (count == 0);

Три строки, ноль специальных случаев. Проверьте на пальцах: wr_ptr=8 (фаза 1, адрес 0), rd_ptr=0 (фаза 0, адрес 0) → count = 8 = DEPTH → full. Записать ещё одно — некуда, и логика записи это не позволит.

Листинг

`default_nettype none

module spi_fifo #(
   parameter DATA_WIDTH = 32,
   parameter ADDR_WIDTH = 3,                       // depth = 1 << ADDR_WIDTH
   parameter DEPTH      = (1 << ADDR_WIDTH)
) (
   input  wire                   clk,
   input  wire                   rst_n,
   input  wire                   flush,

   // Сторона записи
   input  wire                   wr_en,
   input  wire [DATA_WIDTH-1:0]  wr_data,
   output wire                   full,
   output wire                   overflow,       // sticky до flush/reset

   // Сторона чтения
   input  wire                   rd_en,
   output wire [DATA_WIDTH-1:0]  rd_data,
   output wire                   empty,
   output wire                   underflow,      // sticky до flush/reset

   output wire [ADDR_WIDTH:0]    count
);

   reg  [DATA_WIDTH-1:0] mem [0:DEPTH-1];
   reg  [ADDR_WIDTH:0]   wr_ptr;
   reg  [ADDR_WIDTH:0]   rd_ptr;
   reg                   ovf_flag;
   reg                   unf_flag;

   wire write_now = wr_en && !full;     // запись состоится
   wire read_now  = rd_en && !empty;    // чтение состоится

   assign rd_data   = mem[rd_ptr[ADDR_WIDTH-1:0]];   // комбинационно!
   assign count     = wr_ptr - rd_ptr;
   assign full      = (count == DEPTH[ADDR_WIDTH:0]);
   assign empty     = (count == { (ADDR_WIDTH+1){1'b0} });
   assign overflow  = ovf_flag;
   assign underflow = unf_flag;

   // --------------------------- указатель записи + sticky overflow --------
   always @(posedge clk or negedge rst_n) begin
       if (!rst_n) begin
           wr_ptr   <= {(ADDR_WIDTH+1){1'b0}};
           ovf_flag <= 1'b0;
       end else if (flush) begin
           wr_ptr   <= {(ADDR_WIDTH+1){1'b0}};
           ovf_flag <= 1'b0;
       end else begin
           if (write_now)
               wr_ptr <= wr_ptr + 1'b1;
           if (wr_en && full)
               ovf_flag <= 1'b1;
       end
   end

   // --------------------------- память (запись стробируется) --------------
   always @(posedge clk) begin
       if (write_now)
           mem[wr_ptr[ADDR_WIDTH-1:0]] <= wr_data;
   end

   // --------------------------- указатель чтения + sticky underflow -------
   always @(posedge clk or negedge rst_n) begin
       if (!rst_n) begin
           rd_ptr   <= {(ADDR_WIDTH+1){1'b0}};
           unf_flag <= 1'b0;
       end else if (flush) begin
           rd_ptr   <= {(ADDR_WIDTH+1){1'b0}};
           unf_flag <= 1'b0;
       end else begin
           if (read_now)
               rd_ptr <= rd_ptr + 1'b1;
           if (rd_en && empty)
               unf_flag <= 1'b1;
       end
   end

endmodule

`default_nettype wire

Пройдёмся по решениям.

write_now / read_now — защита на границе. Сырые wr_en/rd_en — это просьбы; write_now/read_now — просьбы, прошедшие проверку. Запись в полный FIFO не происходит (слово отбрасывается), чтение из пустого не двигает указатель. Сама просьба при этом фиксируется sticky-флагом — ровно та семантика «ошибка ждёт, пока её увидят», которую мы обещали в карте регистров (глава 8). Снимаются флаги по flush — который, вспомните, входит в «уборку» ERROR_CLR.

flush — не reset. Похож (обнуляет указатели и флаги), но семантически другой: это функциональная операция «выбросить содержимое», доступная во время работы. Заметьте: память при flush не очищается — старые слова физически остаются в mem, но указатели сравнялись → empty → прочитать их невозможно. Очередь определяется указателями, а не содержимым ячеек.

Память — без сброса. Третий always-блок единственный не имеет ни rst_n, ни flush: сбрасывать массив не нужно (см. выше — содержимое недостижимо при empty), а возможности у синтезатора нет: у блочной памяти FPGA нет входа сброса, и попытка описать «обнулить все ячейки разом» сделала бы память несинтезируемой в M9K. Привычка разделять «указатели со сбросом» и «память без сброса» — признак человека, который уже обжигался.

Два маленьких always вместо одного большого. Запись и чтение — независимые процессы с непересекающимися переменными; смешав их в один блок, мы бы только запутали читателя. Правило «один always — одна зона ответственности» масштабируется вниз так же хорошо, как декомпозиция на модули — вверх.

Решение с последствиями: комбинационный rd_data

Теперь строка, ради которой эта глава помечена флажком «вернёмся в главе 20»:

assign rd_data = mem[rd_ptr[ADDR_WIDTH-1:0]];

rd_data — комбинационный: на выходе всегда, в текущем такте, лежит голова очереди. Строб rd_en данные не «запрашивает», а подтверждает: «это слово я забрал, двигай указатель». Альтернатива — регистровый выход: по rd_en слово защёлкивается и появляется на выходе в следующем такте.

Чем они отличаются для потребителя — контрактом, и мы его уже зафиксировали в главе 7 (граница 5): «rd_data валиден в такте строба». Оба варианта — легальные FIFO, но они не взаимозаменяемы: FSM, написанный под комбинационное чтение, с регистровым FIFO молча начнёт получать данные с опозданием на такт — предыдущее слово вместо текущего. Симптом в наших тестах выглядел как rx=00 expected 5A — полный разбор этой детективной истории отложен до главы 20, но запомните ощущение: вид у бага был «SPI неправильно сдвигает биты», а причина сидела в контракте чтения FIFO.

У комбинационного выхода есть и цена, честно записанная в шапке файла: читаемая комбинационно память не ложится в блочные M9K (тем нужен регистровый адрес) — синтезатор соберёт хранилище из LE-регистров с мультиплексором. Для глубины 8×32 это ~256 регистров — приемлемо; для глубины 1024 — уже катастрофа. Поэтому в шапке модуля прямо написано: глубже 32 слов — переходи на синхронное чтение. Параметризованный компромисс, задокументированный на месте принятия, — будущий вы скажет спасибо.

Контрольная точка

Мини-тест — push/pop/flush и оба граничных случая:

// фрагмент tb: DEPTH = 4 для быстрого заполнения
initial begin
   // 1. push A,B,C,D -> full должен подняться
   // 2. пятый push при full -> слово отброшено, overflow = 1
   // 3. pop x4: получаем A,B,C,D в порядке записи -> empty
   // 4. pop при empty -> rd_ptr не сдвинулся, underflow = 1
   // 5. flush -> указатели в ноль, флаги сняты
   // 6. push E, pop -> E (FIFO жив после flush)
end

Каждый пункт — проверка конкретного решения из главы: 1–2 — фазовый бит и sticky overflow, 3 — порядок FIFO и комбинационное чтение (слово сверяем в такте строба!), 4 — защита указателя, 5–6 — семантика flush.

Прогон чистый, предупреждений нет.

Контрольная точка пройдена. В копилке проекта — первый переиспользуемый модуль: этот файл без единой правки встанет и в TX-тракт, и в RX-тракт, а после статьи — в любой ваш проект. Дальше — регистровый интерфейс: модуль, где карта регистров из главы 8 обретёт исполняемую плоть.

Глава 13. Шаг 4: spi_reg_if.v — регистровый интерфейс

Что мы реализуем

Карта регистров спроектирована (глава 8), её константы лежат в хедере (глава 10) — пришло время исполняемой плоти. spi_reg_if — это «дипломат» с блок-схемы: единственный модуль, видящий хоста. Его работа сводится к четырём занятиям:

  1. читать: по адресу отдать слово (mux);

  2. писать: по адресу обновить регистр или толкнуть FIFO;

  3. помнить: sticky-флаги событий и ошибок;

  4. сигналить: события × маска → провод irq.

Модуль крупнее FIFO (~270 строк), но структурно прост: один комбинационный always на чтение, один секвенциальный на всё остальное. Полный листинг есть в репозитории; здесь разберём каждый из четырёх пунктов по характерным фрагментам — и три тонких места, где закладываются будущие события глав 18 и 20.

Чтение: комбинационный mux

Контракт границы хоста (глава 7): rd_data валиден в том же такте, что и rd_en. Значит — чистая комбинационка, и мы пишем её строго по шаблону из главы 5 (default первой строкой, default: в case):

reg [31:0] rd_data_mux;

always @(*) begin
   rd_data_mux = 32'd0;
   if (rd_en) begin
       case (addr)
           `SPI_ADDR_CONTROL:    rd_data_mux = { 25'd0,
               ctrl_irq_en, ctrl_lsb_first, ctrl_cpha, ctrl_cpol,
               1'b0 /*SOFT_RST*/, 1'b0 /*START*/, ctrl_en };
           `SPI_ADDR_STATUS:     rd_data_mux = status_word;
           `SPI_ADDR_CLK_DIV:    rd_data_mux = {16'd0, reg_clk_div};
           `SPI_ADDR_RX_DATA:    rd_data_mux = rx_fifo_empty ? 32'd0
                                                             : rx_fifo_rdata;
           // ... остальные адреса ...
           default:              rd_data_mux = 32'd0;
       endcase
   end
end
  
assign rd_data = rd_data_mux;

Знакомые решения из главы 8 материализовались: командные биты CONTROL читаются константными нулями (self-clearing не имеет «текущего значения»); RX_DATA берёт голову FIFO — комбинационный rd_data FIFO из главы 12 здесь и пригодился; несуществующие адреса возвращают ноль, а не мусор.

Слово STATUS собирается отдельным assign — это просто конкатенация живых сигналов и sticky-битов в порядке, продиктованном хедером: 

assign status_word = {
   20'd0,
   eng_err_word_len, eng_err_start,            // [11:10] sticky (в engine)
   rx_fifo_overflow,                           // [9]  sticky (в FIFO)
   sticky_tx_ovf | tx_fifo_overflow,           // [8]  два источника!
   1'b0, rx_fifo_full, tx_fifo_empty, ctrl_en, // [7:4]
   ~tx_fifo_full, ~rx_fifo_empty,              // [3:2] TX_READY, RX_VALID
   stat_done, eng_busy                         // [1:0]
};

Любопытен бит 8: переполнение TX имеет два источника. FIFO сам ловит строб записи при full (его внутренний sticky), но наш reg_if запись в полный FIFO даже не отправляет (см. 13.4) — и фиксирует попытку собственным флагом sticky_tx_ovf. ИЛИ двух флагов даёт полную картину независимо от того, на каком этаже отбили запись.

Запись: секвенциальный блок и его default-строки

Вся запись — один always @(posedge clk), открывающийся блоком однотактных умолчаний (паттерн из шаблона FSM главы 5): 

end else begin
   // -------- однотактные умолчания --------
   tx_fifo_wr    <= 1'b0;
   rx_fifo_rd    <= 1'b0;
   ctrl_soft_rst <= 1'b0;
   clr_errors    <= 1'b0;
   ...
   if (wr_en) begin
       case (addr)
           ...
       endcase
   end
end

Эти четыре сигнала — стробы: каждый живёт ровно один такт после вызвавшей его записи, потому что в следующем такте default вернёт его в ноль, если case не взведёт снова. Один взгляд на шапку блока — и читатель знает все импульсные выходы модуля.

Сами ветви case — скучная, в хорошем смысле, материализация главы 8. Покажу две небанальные. CLK_DIV с защитой от нуля:

`SPI_ADDR_CLK_DIV:
   if (wr_data[15:0] != 16'd0)        // ноль заморозил бы SCLK
       reg_clk_div <= wr_data[15:0];  // -- молча игнорируем
  
И ERROR_CLR — «уборщик», в котором сходятся W1C и flush:

`SPI_ADDR_ERROR_CLR: begin
   stat_done     <= 1'b0;
   irq_status    <= irq_status & ~wr_data;   // W1C по маске
   sticky_tx_ovf <= 1'b0;
   clr_errors    <= 1'b1;    // строб наружу: FIFO flush + сброс ошибок engine
end

Три тонких места

Тонкость 1: жизненный цикл START. Реализация рукопожатия, спроектированного в главе 8.6, — две точки в коде. Постановка (внутри записи CONTROL):

if (wr_data[`SPI_CTRL_START] &&
   (ctrl_en | wr_data[`SPI_CTRL_EN]))   // включён -- возможно, этой же записью
   ctrl_start <= 1'b1;

И снятие — вне if (wr_en), потому что оно управляется не шиной, а ответом engine:

// START держится, пока engine не отреагирует делом:
if (eng_busy || eng_done_pulse)
   ctrl_start <= 1'b0;

Почему в условии два сигнала? eng_busy — нормальный путь: engine принял команду и работает. А eng_done_pulse ловит вырожденный случай: engine отказал мгновенно (START при пустом FIFO — ошибка ERR_START, транзакция не началась, busy не поднимался). Без второго условия START завис бы взведённым навсегда. Найден этот случай был, признаюсь, не умозрительно — а тестом таймаута в главе 18.

Тонкость 2: pop-on-read и его хронология. Чтение RX_DATA должно вытолкнуть слово (глава 8.7). Смотрите внимательно на времена:

// в комбинационном mux (такт N):       rd_data = голова FIFO
// в секвенциальном блоке (тот же такт N):
if (rd_en && (addr == `SPI_ADDR_RX_DATA) && !rx_fifo_empty)
   rx_fifo_rd <= 1'b1;                 // строб уйдёт в FIFO в такте N+1

Хост получает данные в такте N (комбинационно), а указатель FIFO сдвигается в такте N+1 — после того, как данные уже забраны. Порядок «сначала отдай, потом выбрось» обеспечен самой структурой «комбинационное чтение + регистровый строб»; никакой специальной логики не нужно. Но заметьте, как этот фокус зависит от комбинационности rd_data FIFO — с регистровым FIFO хронология рассыпается. Ружьё из главы 12 продолжает висеть на стене.

Тонкость 3: IRQ-конвейер и его задержка. Путь от события до ножки прерывания — три ступени:

// 1. события -> sticky-биты (взводит железо, снимает W1C)
if (irq_done_evt) irq_status[`SPI_IRQ_DONE]     <= 1'b1;
if (irq_rx_evt)   irq_status[`SPI_IRQ_RX_VALID] <= 1'b1;
if (irq_err_evt)  irq_status[`SPI_IRQ_ERROR]    <= 1'b1;

// 2. маска (комбинационно)
wire [31:0] irq_active = irq_status & irq_mask;

// 3. регистровый выход, под глобальным разрешением
irq_out <= ctrl_irq_en & (|irq_active);

Архитектура классическая: sticky-биты позволяют обработчику узнать, что именно случилось (читай IRQ_STATUS), маска — выбирать, что достойно прерывания, глобальный IRQ_EN — рубильник на всё. Выход irq_out — регистровый: на ножку CPU должен идти чистый, бесглитчевый сигнал, а не дрожащая комбинационка. 

Цена регистрового выхода — задержка: между событием (скажем, eng_done_pulse) и подъёмом irq_out лежит два фронта — один на взведение sticky-бита, один на перевзятие выхода. Поведение совершенно правильное... но в главе 20 вы увидите, как тест, написанный в наивном ожидании «done → irq в тот же такт», объявил исправный RTL сломанным. 

Запомните слово «задержка» — встретимся на разборе.

Контрольная точка

Смок-тест — пара task'ов (их полная версия станет ядром testbench в главе 18) и короткий сценарий:

task reg_write(input [3:0] a, input [31:0] d);
   @(posedge clk); addr <= a; wr_data <= d; wr_en <= 1'b1;
   @(posedge clk); wr_en <= 1'b0;
endtask

task reg_read(input [3:0] a, output [31:0] d);
   @(posedge clk); addr <= a; rd_en <= 1'b1;
   #1 d = rd_data;                       // комбинационно, тот же такт
   @(posedge clk); rd_en <= 1'b0;
endtask

Сценарий: записать CLK_DIV=24 → прочитать → 24 вернулось; записать CLK_DIV=0 → прочитать → осталось 24 (защита работает); записать CONTROL с EN+START → ctrl_start взведён; подать eng_busy=1 → ctrl_start упал через такт; записать маску в ERROR_CLR → строб clr_errors длиной один такт. FIFO и engine в этом тесте — четыре провода-заглушки: модуль честно тестируется в изоляции, как и обещала декомпозиция главы 7.

Контрольная точка пройдена: хост (пока в лице testbench) умеет разговаривать с контроллером. Осталось, чтобы контроллеру было чем заняться, — впереди самая большая и самая важная глава части: spi_engine, машина, которая двигает биты.

Глава 14. Шаг 5: spi_engine.v — сердце контроллера

Самая большая глава — и почему её не стоит бояться

Всё, что мы делали до сих пор, было подготовкой к этому модулю. Engine — единственное место проекта, где живёт протокол SPI: делитель частоты, автомат транзакции, фронты, биты, задержки, burst. Четыреста строк — больше, чем всё написанное в главах 11–13 вместе. 

И тем не менее модуль проще, чем кажется, — потому что мы будем собирать его не «сверху вниз по листингу», а вокруг трёх решений, каждое из которых схлопывает целый пласт потенциальной сложности: 

  1. счётчик фронтов вместо жонглирования «полутактами» — и вся CPOL/CPHA-семантика главы 4 сожмётся в одно сравнение;

  2. индексный доступ к битам вместо сдвигового регистра — и MSB/LSB-first перестанет быть источником багов; 

  3. FSM на восемь состояний, где у каждого состояния одна обязанность.

Разберём решения по очереди, потом пройдём состояния, потом — края (ошибки, сброс, синхронизатор).

Решение 1: считаем фронты, а не время

Вспомните выстраданное правило из главы 4: на каждый бит приходится два фронта SCLK — sample и shift; CPHA говорит, какой первый. Прямолинейная реализация считала бы биты и внутри каждого — отслеживала «первую» и «вторую» половину такта с ветвлениями на все четыре режима. Видел такие

engine: лестница вложенных if по CPOL/CPHA, четыре копии почти одинакового кода.

Вместо этого назначим главной осью времени счётчик фронтов edge_cnt: слово из N бит — это просто 2N фронтов SCLK, пронумерованных от 0 до 2N−1. И тогда вся семантика CPHA — это вопрос «чётный фронт или нечётный»:

wire do_sample = (edge_cnt[0] == cpha);   // CPHA=0: чётные фронты -- sample
wire do_shift  = (edge_cnt[0] != cpha);   // CPHA=1: чётные -- shift

Проверим против диаграмм главы 4. CPHA=0: первый фронт (№0, чётный) — sample, второй (№1) — shift... совпало. CPHA=1: первый фронт — shift, второй — sample... совпало. Два провода и один XOR — вместо лестницы if. Это не трюк ради краткости: меньше ветвей — меньше мест, где режимы могли бы разойтись поведением, то есть меньше тест-кейсов, способных сломаться независимо.

А что CPOL? Ему досталась ещё более скромная роль: уровень покоя. SCLK инициализируется значением cpol и просто переключается на каждом фронте — полярность получается сама собой. В датапуте CPOL не участвует вообще.

Сам SCLK генерируется счётчиком полупериода (знакомый приём clock enable из главы 6):

if (half_cnt >= clk_div) begin
   half_cnt <= 16'd0;
   sclk_r   <= ~sclk_r;            // фронт!
   edge_cnt <= edge_cnt + 7'd1;
   // ... здесь же sample или shift ...
end else begin
   half_cnt <= half_cnt + 16'd1;
end

Отсюда формула из главы 8: полупериод = clk_div + 1 тактов.

Решение 2: индекс вместо сдвига

Каноничный SPI-датапут — сдвиговый регистр: tx_shift <= {tx_shift[30:0],1'b0}, MOSI смотрит на старший бит. Для MSB-first — прекрасно. Но добавьте LSB-first — и придётся либо сдвигать в другую сторону, либо предварительно разворачивать слово; добавьте переменную длину — и выясняется, что слово 8 бит надо предварительно выровнять к старшему краю 32-битного регистра (для MSB) или не выравнивать (для LSB)... Каждая комбинация — своя подготовка данных, и любую из них можно перепутать. Это и есть «класс багов выравнивания», обещанный в главе 4.

Наше решение: слово не двигается вообще. Оно лежит в tx_word как записал хост, а индекс нужного бита вычисляется функцией от номера бита, длины слова и порядка:

function automatic [4:0] bit_pos;
   input [5:0] cnt;       // номер бита по порядку передачи (0, 1, 2...)
   input [5:0] total;     // длина слова
   input       lsb;
   begin
       if (lsb)
           bit_pos = cnt[4:0];                      // 0-й бит -- LSB
       else
           bit_pos = total[4:0] - 5'd1 - cnt[4:0];  // 0-й бит -- MSB
   end
endfunction

Использование симметрично в обе стороны:

spi_mosi <= tx_word[ bit_pos(bit_cnt, bits_total, lsb_first) ];   // передача
rx_word [ bit_pos(bit_cnt, bits_total, lsb_first) ] <= miso_synced; // приём

Что мы выиграли. Никакого выравнивания: для WORD_LEN=8 MSB-first функция сама выдаст индексы 7,6,...,0 — слово лежит в младшем байте, как и ожидает хост. Никакого разворота для LSB: те же данные, индексы 0,1,...,7. Приём и передача используют одну и ту же функцию — RX-слово собирается сразу в правильном порядке, и баг «TX по-новому, RX по-старому» непредставим. Узнаёте принцип из главы 8? Он работает и внутри датапута.

Это та самая синтезируемая function, которую мы легализовали в главе 5.7: чистая комбинационка с именем. В железе она превратится в мультиплексор 32→1 с пятибитным адресом — чуть дороже сдвигового регистра по LUT, копейки на фоне выигрыша в надёжности.

Финальный штрих датапута — упаковка принятого слова. Раз RX собирается по индексам «как есть», для коротких слов он занимает младшие биты, и хосту остаётся отдать его с обнулённым верхом:

function automatic [31:0] pack_rx;     // прижать к младшему краю
   ...
   case (total)
       6'd8:    pack_rx = {24'd0, raw[7:0]};
       6'd16:   pack_rx = {16'd0, raw[15:0]};
       ...

Решение 3: FSM, где у состояния одна работа

Скелет автомата — расширенная версия шаблона из главы 5 (один секвенциальный always, localparam-состояния, default-строки для стробов): 

Пройдём состояния, останавливаясь на неочевидном.

S_IDLE — покой и приёмка. Держит линии в уровнях покоя (SCLK=CPOL, CS#=все единицы, MOSI=0) и ждёт start. Здесь же — входной контроль с мгновенным отказом:

if (enable && start) begin
   if (!word_len_ok) begin
       err_word_len <= 1'b1;
       done_pulse   <= 1'b1;        // отказ: done без busy!
   end else if (tx_empty) begin
       err_start_empty <= 1'b1;
       done_pulse      <= 1'b1;
   end else begin
       busy <= 1'b1;
       ...
       state <= S_CS_SETUP;
   end
end

Вот и второй конец рукопожатия из главы 13: при отказе engine не поднимает busy, но даёт done_pulse — и reg_if снимает START именно по нему. Ошибка фиксируется sticky-битом, хост узнает причину из STATUS. 

Третья проверка — cs_select: индекс за пределами NUM_CS просто не активирует ни одного CS (транзакция уйдёт «в никуда» — но без аварии на шине).

S_CS_SETUP / S_CS_HOLD / S_INTER — три паузы, один счётчик. Все три задержки из DELAY_CFG отрабатываются одинаково: загрузили delay_cnt, декрементируем до нуля. Состояния разные, потому что разные выходы из них; счётчик один, потому что паузы никогда не пересекаются.

S_LOAD — подготовка слова. Защёлкивает голову TX FIFO в tx_word (комбинационный rd_data FIFO — слово доступно сразу), обнуляет счётчики и... выполняет обещание из главы 4: для CPHA=0 первый бит должен стоять на MOSI до первого фронта:

if (cpha == 1'b0)
   spi_mosi <= tx_data[first_bit_idx];   // преддрайв
else
   spi_mosi <= 1'b0;                     // CPHA=1: первый фронт сам выставит

Помните, в главе 4 мы пометили эту асимметрию как «заставит завести отдельное состояние FSM»? Вот оно. У CPHA=1 преддрайва нет — его первый бит выйдет на первом же фронте через обычный do_shift.

S_XFER — конвейер фронтов. Весь код состояния уже показан выше — это объединение делителя, классификатора фронтов и индексного датапута. Обратите внимание на страж bit_cnt < bits_total у shift: последний shift-фронт слова не должен лезть за следующим битом — его нет.

S_XFER_END — состояние на один такт, существующее из-за <=. Тонкое место. Последний sample-фронт записывает последний бит в rx_word — неблокирующим присваиванием. По семантике из главы 5.4 это значит: в текущем такте rx_word ещё старый, новое значение появится после фронта. Если бы мы пушили слово в RX FIFO в том же такте, что и последний sample, — ушло бы слово без последнего бита. Поэтому между «последний фронт» и «push в FIFO» вставлен такт-прокладка:

S_XFER_END: begin
   ...
   if (!rx_full) begin
       rx_wr_en <= 1'b1;
       rx_data  <= pack_rx(rx_word, bits_total);  // rx_word уже полный
   end else begin
       err_rx_overflow <= 1'b1;                   // слово теряется, но честно
   end
   tx_rd_en <= 1'b1;                              // pop отправленного слова
   ...

Запомните этот паттерн: «NBA-результат используется состоянием позже». Он встречается в каждом нетривиальном FSM, а его нарушение — в каждом втором студенческом.

Здесь же — третья ошибка ТЗ: RX FIFO полон → слово отбрасывается, sticky err_rx_overflow взводится, транзакция продолжается (мы обязаны дотикать фронты — slave-то уже работает).

И ещё деталь: TX FIFO покидается (tx_rd_en) только сейчас, после успешной передачи, а не в S_LOAD. Слово лежало в FIFO всю транзакцию — задел под будущий retry и просто аккуратная семантика «забрал = отдал в провод».

S_CS_HOLD — развилка burst. Отсчитав t_csh, заглядываем в TX FIFO:

if (!tx_empty) begin
   delay_cnt <= delay_cfg[`SPI_DELAY_INTER];
   state     <= S_INTER;          // CS остаётся низким!
end else begin
   spi_cs_n <= {NUM_CS{1'b1}};    // конец пакета
   state    <= S_DONE;
end

Это весь burst (требование Т4): пока на складе есть слова, цикл S_INTER → S_LOAD → S_XFER → S_XFER_END → S_CS_HOLD крутится, не отпуская CS. Никакого «режима burst» как отдельной сущности — он просто возникает из формы графа переходов. Хотите три отдельные транзакции — кладите слова по одному (глава 4.7 уже объясняла семантику со стороны хоста).

S_DONE — финал на один такт: done_pulse, busy вниз, в S_IDLE.

Края: сброс, soft reset, синхронизатор

MISO-синхронизатор — наш второй асинхронный вход (после кнопки сброса) и точная копия схемы из главы 6.4, на этот раз внутри модуля:

reg [MISO_SYNC_STAGES-1:0] miso_sync;

wire miso_synced = miso_sync[MISO_SYNC_STAGES-1];

always @(posedge clk or negedge rst_n)

   if (!rst_n) miso_sync <= {MISO_SYNC_STAGES{1'b0}};

   else        miso_sync <= {miso_sync[MISO_SYNC_STAGES-2:0], spi_miso};

Датапут читает только miso_synced; сырой spi_miso не видит никто. Задержку в два такта мы уже посчитали в главе 6.4 — при наших полупериодах в 25 тактов она безобидна.

soft_rst — средний путь между «ничего» и полным сбросом: возвращает FSM в S_IDLE и приводит линии в покой, но не трогает sticky-ошибки (их снимает только clr_errors) и конфигурацию. Сценарий: хост видит, что транзакция зависла (slave умер), и хочет аккуратно вернуть контроллер в строй, не теряя диагностику.

Полный сброс инициализирует всё, включая bits_total <= 8 — осмысленное значение на случай, если кто-то стартует, не настроив WORD_LEN (вернее, его отловит word_len_ok, но триггер без определённого значения после сброса — нарушение Н3).

Контрольная точка

Testbench'а ещё нет (он — глава 18), поэтому контрольная точка ручная, и это осознанный методический шаг: до написания самопроверяющихся тестов посмотреть на сигналы глазами. Одноразовая обвязка: engine + константная конфигурация (mode 0, 8 бит, CLK_DIV=4), start импульсом, MISO закорочен на MOSI (внешний loopback). Компилируем, дампим VCD, открываем GTKWave и проверяем по чек-листу главы 4:

  • CS# упал → пауза t_css → первый фронт SCLK;

  • фронтов ровно 16; SCLK вернулся в CPOL после последнего;

  • MOSI меняется по нечётным фронтам, первый бит стоял до фронта №0 (преддрайв CPHA=0 на месте);

  • rx_data == отправленному слову (loopback же), rx_wr_en — один такт, на такт позже последнего фронта (S_XFER_END работает);

  • busy поднялся с приёмкой start, упал в S_DONE, done_pulse — один такт.

Контрольная точка пройдена: сердце бьётся, один прогон глазами сошёлся с теорией. Чего эта проверка не даёт — уверенности во всех 4 режимах × 2 порядках × 4 длинах × burst × ошибках: 60+ комбинаций глазами не пересмотришь. Для этого существует часть IV. А пока — финальный аккорд части III: собрать всё в spi_master_top.

Глава 15. Шаг 6: spi_master_top.v — сборка

Модуль, в котором ничего не происходит

Все четыре кирпича готовы — остался цемент. spi_master_top мы ещё в главе 7 окрестили «клеем» и пообещали, что логики в нём не будет. Обещание сдержано почти буквально: на ~200 строк файла — три инстанса, пачка проводов и ровно четыре строки того, что можно назвать логикой. Но именно эти четыре строки — содержание главы: на интеграционном уровне принимаются интеграционные решения, и им положено быть видными как на ладони.

Шапка: параметры и $clog2

module spi_master_top #(
   parameter integer NUM_CS           = 4,
   parameter integer FIFO_DEPTH       = 8,    // степень двойки
   parameter integer ADDR_WIDTH       = 4,
   parameter integer MISO_SYNC_STAGES = 2
) (
   input  wire                  clk,
   input  wire                  rst_n,
   // регистровая шина: wr_en, rd_en, addr, wr_data, rd_data, ready, irq
   ...
   // SPI: spi_sclk, spi_mosi, spi_miso, spi_cs_n[NUM_CS-1:0]
   ...
);

   localparam integer FIFO_AW = $clog2(FIFO_DEPTH);

Параметры топа — это сводный прайс-лист конфигурации ядра: всё, что можно подкрутить, собрано в одном месте и пробрасывается вниз. Заметьте, наружу торчит FIFO_DEPTH (понятный человеку), а FIFO хочет ADDR_WIDTH (понятный железу) — перевод делает функция $clog2, двоичный логарифм с округлением вверх: $clog2(8) = 3. Стандартная идиома Verilog-2005 «из глубины — в ширину адреса»; до её появления писали самодельные функции-циклы, и в старом коде вы их ещё встретите.  Контракт «глубина — степень двойки» (на нём держится арифметика указателей из главы 12) параметр не проверяет — увы, Verilog-2001 не имеет штатного assert времени элаборации; защита тут конвенциональная, комментарием.

Провода и четыре строки логики

Межмодульные провода — просто перечисление контрактов из главы 7, переведённое в объявления (ctrl_* — конфигурация, tx_*/rx_* — FIFO-стороны, eng_* — статусы). А вот и вся логика модуля:

   wire irq_done_evt = eng_done;
   wire irq_rx_evt   = rx_wr;
   wire irq_err_evt  = eng_err_start | eng_err_rx_ovf
                     | eng_err_word_len | tx_overflow;

   assign fifo_flush = clr_errors | ctrl_soft_rst;

Четыре строки — три интеграционных решения, и каждое стоит абзаца.

Что считается IRQ-событием — решает топ. Модули-источники ничего не знают о прерываниях: engine выдаёт done_pulse, FIFO — overflow, а смысл «это достойно прерывания» назначается здесь. Самая интересная строка — вторая: событием RX назначен rx_wr — строб записи в RX FIFO от engine. То есть прерывание RX_VALID случается в момент прихода слова, на границе двух модулей, ни одному из которых это событие не принадлежит. Будь IRQ-логика зашита в engine — она не узнала бы про tx_overflow (это территория reg_if/FIFO); будь в reg_if — пришлось бы тянуть туда лишние провода. На стыке — самое место.

Свод ошибок в одно событие. irq_err_evt — ИЛИ всех четырёх источников ошибок проекта. Гранулярность не теряется: sticky-биты в STATUS различают, что именно случилось; IRQ лишь будит хоста.

Политика flush. FIFO чистятся в двух случаях: явная уборка (clr_errors из ERROR_CLR) и soft_rst. Решение неочевидное — а обязан ли soft reset выбрасывать данные? Наш ответ «да» опирается на сценарий из главы 14: soft_rst дёргают, когда транзакция зависла, и содержимое FIFO в этот момент — половина недопереданного пакета, доверять которой нельзя. Политика — интеграционная: ни FIFO, ни engine не могут решить её в одиночку, поэтому она записана здесь, одной строкой, на самом видном месте.

Инстансы: соединение по контрактам

Дальше — три инстанса, и единственное достоинство этого кода — занудная аккуратность (полный листинг — в репозитории):

   spi_fifo #(
       .DATA_WIDTH (32),
       .ADDR_WIDTH (FIFO_AW)
   ) u_tx_fifo (
       .clk       (clk),
       .rst_n     (rst_n),
       .flush     (fifo_flush),
       .wr_en     (tx_wr),        // пишет reg_if (хост)
       .wr_data   (tx_wdata),
       .full      (tx_full),
       .overflow  (tx_overflow),
       .rd_en     (tx_rd),        // читает engine
       .rd_data   (tx_rdata),
       .empty     (tx_empty),
       .underflow (),             // не используется -- осознанно
       .count     ()
   );

   // u_rx_fifo -- зеркально: пишет engine, читает reg_if
   // u_reg     -- spi_reg_if: все ctrl_*/reg_*/tx_*/rx_*/eng_* порты
   // u_eng     -- spi_engine: конфигурация, FIFO-стороны, SPI-пины

Правила, которые здесь соблюдены и которые стоит унести в привычки:

  • Только именованное связывание (.clk(clk)), никогда позиционное (u_fifo(clk, rst_n, ...)). Позиционное ломается молча при любом изменении порядка портов; именованное — громко и на компиляции.

  • Неиспользуемые выходы — явно пустыми скобками (.underflow ()), а не пропуском порта. Это разница между «я решил не подключать» и «я забыл подключить» — для читателя и для линтера.

  • Симметрия читается как документация: у TX FIFO wr_* смотрит в reg_if, rd_* — в engine; у RX — наоборот. Один взгляд на пару инстансов — и направление потоков данных (диаграмма главы 7) восстанавливается без схемы.

Кстати, про «занудную аккуратность» — она вознаграждается немедленно: при компиляции этого файла Verilog впервые сверит обе стороны каждого контракта из главы 7. До сих пор порты модулей существовали независимо; теперь несовпадение имени или ширины — ошибка элаборации. Контрольная точка 9 проверяла это на заглушках, сейчас — на настоящих модулях.

Об обёртке spi_master_fpga_top

В списке файлов главы 9 значится ещё один топ — spi_master_fpga_top. Это обёртка уровня платы на три десятка строк: инстанс spi_reset_sync (кнопка платы — асинхронный вход, глава 11) плюс инстанс ядра. Зачем отдельный файл, если можно вставить синхронизатор прямо сюда? Затем, что spi_master_top — переносимое ядро: его инстанцируют и testbench (который подаёт уже чистый сброс — синхронизатор там был бы лишним усложнением), и будущий комбинированный топ с LCD (часть VI), и гипотетический SoC, где сброс приходит от системного контроллера. Обработка платных реалий — забота того, кто знает про плату. Разбирать обёртку построчно не будем — после главы 11 в ней нет ни одной новой идеи.

Контрольная точка — и итог части III

Финальная проверка части — весь стек собирается одним вызовом:

$ make sim_spi   # пока без настоящего testbench -- хватит и заглушки
iverilog -g2005 -Irtl -Wall -o tb_spi_master.vvp \
   rtl/spi_reset_sync.v rtl/spi_fifo.v rtl/spi_reg_if.v \
   rtl/spi_engine.v rtl/spi_master_top.v sim/...

Ноль ошибок, ноль предупреждений (политика -Wall из главы 9 работает с первого дня). Элаборация прошла — значит, все контракты сошлись по именам и ширинам.

Подведём итог части III. Написано пять синтезируемых модулей и один хедер; каждый прошёл свою контрольную точку; ядро компилируется целиком. Чего у нас нет — доказательств, что оно работает: ручной прогон главы 14 покрыл один режим из шестидесяти с лишним комбинаций, а контрольные точки глав 12–13 — изолированные смоки, не интеграцию. Сейчас наш контроллер — это «компилируется и вроде шевелится». Вся часть IV — о том, как превратить «вроде» в число: 38 автоматических проверок. И начнём мы её с самой поучительной истории проекта — о том, как первый вариант верификации врал нам в лицо, показывая зелёные тесты на сломанном протоколе.

Часть IV. Верификация — половина всей работы

Глава 16. Философия: почему ваш testbench врёт вам в лицо

16.1. Признание

Начну часть о верификации с признания: первая версия этого проекта была верифицирована неправильно, и я этого не знал. Testbench существовал, тесты гонялись, все горели зелёным — а в контроллере жили как минимум два протокольных бага, один в CPHA=1, другой в LSB-first. Любой реальный чип, подключённый к тому «проверенному» мастеру в этих режимах, получил бы мусор.

Эта глава — о том, как такое возможно: как полный набор проходящих тестов сосуществует со сломанным протоколом. Механизм, который это устраивает, называется симметричным багом, и он — профессиональная болезнь всех, кто верифицирует свой код своими же моделями. История реальная, выводы оплачены временем; код в примерах упрощён до сути, но устройство ловушки — подлинное.

16.2. Как пишут slave-модель «по наивности»

Чтобы протестировать SPI master, нужен SPI slave — кто-то на другом конце проводов, кто примет слово и ответит своим. Реального чипа в симуляторе нет, пишем поведенческую модель. И вот тут развилка, незаметная в момент прохождения.

Вы только что закончили master. В голове — его устройство: системный клок, делитель, счётчики полутактов. И рука сама пишет slave в тех же терминах — тактируясь от системного клока и вычисляя, когда «должен быть» фронт SCLK:

// slave-модель, написанная "по образу и подобию master" -- НЕ ДЕЛАЙТЕ ТАК
always @(posedge clk) begin              // системный клок testbench!
   if (!cs_n) begin
       half_cnt <= half_cnt + 1;
       if (half_cnt == clk_div) begin   // модель ЗНАЕТ делитель мастера
           half_cnt <= 0;
           // ... "наступил фронт": сдвинуть, защёлкнуть ...
       end
   end
end

Выглядит разумно — это же почти готовый код из engine, проверенный! 

Но посмотрите, что произошло на уровне методологии: модель slave воспроизводит устройство мастера, а не слушает провода. Она знает делитель, разделяет с мастером представление о том, когда случаются фронты и что на них делать. Это не второе мнение — это второй экземпляр первого.

16.3. Анатомия симметричного бага

Теперь — сама ловушка, на нашем материале. В первой версии master семплирование для CPHA=1 было сдвинуто: данные защёлкивались не на том фронте из пары (перепутана чётность — вспомните do_sample из главы 14.2; в той версии вместо сравнения с cpha стояла константа). По стандарту из главы 4 — ошибка: реальный XPT2046 в mode 3 выставляет бит по одному фронту, а наш мастер читал по нему же, вместо противоположного.

Но slave-модель была написана зеркально от того же кода — с той же перепутанной чётностью. И в симуляции происходило вот что:

Две ошибки, равные по величине и противоположные по знаку, дали в сумме ноль. Тест сравнивал «что отправил slave» с «что принял master» — и они честно совпадали, потому что оба участника одинаково заблуждались насчёт протокола. То же с LSB-first: master выгребал биты с ошибкой индексации (сдвиговый регистр, та самая каша выравниваний из главы 14.3), модель — с той же самой, потому что копировала структуру. 

Палиндромные тестовые байты вроде 0xA5 (глава 4 предупреждала!) маскировали остатки.

У явления есть имя из теории измерений: отказ по общей моде (common-mode failure). Вы измеряете длину линейкой, которую отлили в той же форме, что и измеряемую деталь: дефект формы не обнаружим в принципе. Сравнение покажет ноль расхождений при любой величине дефекта.

И худшее свойство симметричного бага: его не видно изнутри системы. Сколько ни гоняй тесты, сколько ни добавляй кейсов — все они используют ту же кривую линейку. Зелёных тестов становится больше; правды в них — нет.

Как это вскрылось

Раскрытие пришло снаружи, при ревью кода — и сценарий поучителен сам по себе. Ревьюер не сравнивал master с моделью (они сходились!). Он сравнил волны с диаграммой из даташита: открыл VCD прогона mode 3, приложил к картинке из главы 4 — и увидел, что MISO семплируется на фронте, на котором стандарт предписывает shift.

Запомните этот ход: симметричный баг ловится только сверкой с внешним эталоном — даташитом, стандартом, чужой реализацией, железом. Любая внутренняя сверка («модуль A согласен с модулем B») проверяет согласованность, а не правильность. Согласованность тоже нужна — но она дешевле и слабее.

Принцип: модель пишется структурно иначе

Что делать? Радикальный вариант — купить/взять чужую verified модель — в нашем учебном проекте недоступен (и лишил бы нас урока). Наш вариант: переписать slave-модель так, чтобы у неё не было ни одной общей структурной идеи с master. Конкретно:

Аспект

Master (RTL)

Модель slave (новая)

Ось времени

системный клок + делитель

фронты SCLK на проводе

Стиль

синхронный FSM

событийный (@(posedge sclk))

Знает делитель?

да (он его создаёт)

нет — и не может

Источник правды

глава 14

глава 4 (диаграммы стандарта)

Новая модель слушает провода, как настоящий чип: ждёт negedge cs_n, реагирует на фронты sclk — какие пришли, такие пришли. Ей безразлично, каким делителем они сделаны и какой логикой; она не может «согласиться» с ошибкой мастера в генерации фронтов, потому что не разделяет с ним ни строчки представлений об их происхождении. Семантику sample/shift она берёт напрямую из диаграмм стандарта — у неё другой первоисточник.

Это и есть главный принцип главы: независимость модели — это независимость происхождения, а не авторства. Один человек может написать обе стороны — если заставит себя вывести их из разных

оснований и в разных стилях. Совпадение результатов двух структурно чужих реализаций — сильное свидетельство; совпадение двух копий — никакое. (Полностью эффект автора это не устраняет: неверно понятый стандарт отравит обе реализации. Абсолютной защиты нет — есть удорожание ошибки.)

Переписанная модель — tb_spi_slave_ref.v, и её устройство — тема главы 17. Спойлер: едва она встала на место старой, два «зелёных» режима стали красными. Лучшая новость за весь проект — сломалось не что-то, а ложь.

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

Раз уж глава о философии — зафиксируем и второй принцип части IV, прежде чем писать код. Testbench бывает двух пород: 

  • смотровой: гоняет сценарий, дампит волны; вердикт выносит человек, разглядывая GTKWave;

  • self-checking: знает ожидаемый результат каждой операции, сверяет сам и печатает PASS/FAIL со счётчиком ошибок.

Смотровой годится для знакомства с модулем (ручная контрольная точка главы 14 — ровно он). Но как регрессия он бесполезен: человек не пересмотрит 60 комбинаций после каждой правки, а пересмотрев — пропустит расхождение в один бит на 38-м слове. Наш testbench будет self-checking с первой строки: каждая транзакция завершается сверкой принятого с ожидаемым, каждая проверка инкрементирует счётчики, финал — одна строка вердикта. Цикл из главы 9 — «правка → make → PASS/FAIL за секунды» — работает только так.

Оба принципа — независимая модель и self-checking — складываются в формулу всей части IV: Тест = независимый эталон + автоматическая сверка. Убери первое — получишь согласованную ложь. Убери второе — правду, которую никто не прочитает.

Шпаргалка главы

  1. Зелёные тесты доказывают согласованность DUT с моделью, а не правильность. Если модель — структурная копия DUT, не доказывают ничего.

  2. Симметричный баг = одна ошибка в двух экземплярах; сумма ошибок — ноль, снаружи — идиллия. Изнутри системы не обнаружим в принципе.

  3. Ловится внешним эталоном: даташит, стандарт, железо, чужая реализация. Сверка волн с диаграммой стандарта — дёшево и сердито.

  4. Референсная модель пишется из другого первоисточника и в другом стиле: у нас — событийно от проводов, без знания о внутренностях DUT.

  5. Self-checking или ничего: вердикт выносит тест, не человек у осциллограммы.

  6. Худший день верификатора — не когда тесты упали, а когда узнал, почему они проходили.

Философия закрыта. Дальше — практика: глава 17, строим ту самую структурно независимую модель slave.

Глава 17. Шаг 7: tb_spi_slave_ref.v — независимая модель slave

Требования к модели — из выводов главы 16

Переводим философию в код. Модель должна:

  1. жить только от проводов — SCLK, CS#, MOSI; никакого системного клока, никакого знания о делителе;

  2. брать семантику режимов из стандарта (таблица главы 4), а не из главы 14;

  3. поддерживать все 4 режима, оба порядка бит, все длины слов и burst; 

  4. отдавать testbench'у принятое слово и ответ для master — чтобы сверку можно было автоматизировать (второй столп, self-checking).

И помним: это sim/, не rtl/. Здесь легальны integer, события от произвольных сигналов, initial — всё, что запрещала глава 5. Свобода несинтезируемого мира — законный инструмент: чем меньше модель похожа на RTL, тем лучше она выполняет пункт «структурно иначе».

Интерфейс: что модель знает и чего не знает

module tb_spi_slave_ref (
   input  wire        cpol,        // конфигурация теста --
   input  wire        cpha,        //   те же параметры, что задают DUT
   input  wire        lsb_first,
   input  wire [5:0]  word_len,

   input  wire        sclk,        // провода -- ЕДИНСТВЕННАЯ связь с DUT
   input  wire        cs_n,
   input  wire        mosi,
   output reg         miso,

   input  wire [31:0] tx_response, // что отвечать мастеру
   output reg  [31:0] rx_captured, // что услышали от мастера
   output reg         rx_valid     // слово принято (по подъёму CS#)
);

Пройдитесь по списку портов взглядом аудитора — это легальный способ проверить независимость: здесь нет clk, нет clk_div, нет ни одного сигнала из внутренностей spi_engine. Конфигурацию режима (cpol/cpha/...) модель получает — но это знание о тесте, а не о DUT: реальному чипу его режим тоже «сообщает» даташит. А tx_response / rx_captured — измерительные клеммы для testbench: через них пойдёт сверка «отправлено → принято» в обе стороны.

Семантика фронтов — выведенная заново

Ключевой пункт независимости: правило sample/shift модель не наследует от мастера, а выводит заново, из таблицы стандарта. Выпишем  таблицу (глава 4) ещё раз, но с вопросом «по какому фронту провода SCLK семплировать?»:

mode | CPOL | CPHA | sample на проводе  | случается на...
-----+------+------+--------------------+------------------
 0  |  0   |  0   | первый фронт, клок идёт 0->1 | posedge
 1  |  0   |  1   | второй фронт, клок идёт 1->0 | negedge
 2  |  1   |  0   | первый фронт, клок идёт 1->0 | negedge
 3  |  1   |  1   | второй фронт, клок идёт 0->1 | posedge

Смотрим в последнюю колонку: posedge, negedge, negedge, posedge… Семплируем по подъёму провода ровно когда CPOL и CPHA совпадают:

wire sample_on_posedge = (cpol == cpha);

Сравните с мастером: там — «sample на чётных или нечётных фронтах по счётчику» (edge_cnt[0] == cpha), здесь — «sample на podъёме или спаде провода» (cpol == cpha). Две разные формулы, выведенные из одной таблицы разными путями. Если в одной из них ошибка — данные не сойдутся, тест упадёт. Именно этого свойства была лишена старая зеркальная модель: формула там была одна на двоих, и ошибка в ней сокращалась.

Событийное ядро: четыре процесса

Дальше модель пишется почти сама — по процессу на каждое событие внешнего мира. Вот всё ядро:

// --- выбор устройства: подготовиться к слову --------------------------
always @(negedge cs_n) begin
   rx_captured <= 32'd0;
   rx_valid    <= 1'b0;
   next_rx     <= 0;
   if (cpha == 1'b0) begin
       // CPHA=0: первый бит -- на провод ДО первого фронта (глава 4!)
       miso    <= tx_response[bit_idx(0, word_len, lsb_first)];
       next_tx <= 1;
   end else begin
       miso    <= 1'b0;
       next_tx <= 0;
   end
end

// --- снятие выбора: слово готово, отпустить линию ---------------------
always @(posedge cs_n) begin
   rx_valid <= 1'b1;
   miso     <= 1'b0;     // реальный чип ушёл бы в Z; для теста хватит 0
end

// --- фронты SCLK: что пришло, на то и реагируем ------------------------
always @(posedge sclk) if (!cs_n)
   if (sample_on_posedge) do_sample; else do_shift;

always @(negedge sclk) if (!cs_n)
   if (sample_on_posedge) do_shift;  else do_sample;

Обратите внимание, насколько это похоже на описание чипа из даташита и непохоже на наш engine: нет состояний, нет делителя, нет счётчика полутактов. CS# упал — проснулись, преддрайвнули бит (если CPHA=0 — та самая асимметрия из главы 4, реализованная второй раз и вторым способом). Фронт пришёл — отработали. Когда придёт следующий и придёт ли вообще — не наша забота: мастер генерирует время, slave его потребляет. Модель автоматически верна для любого делителя, любых пауз, любого джиттера фронтов — она их просто не различает.

Сами do_sample/do_shift — task'и на четыре строки с уже знакомой (но написанной заново!) индексацией бит:

task automatic do_sample;
   begin
       rx_captured[bit_idx(next_rx, word_len, lsb_first)] <= mosi;
       if (next_rx + 1 >= word_len) next_rx <= 0;     // wrap!
       else                         next_rx <= next_rx + 1;
   end
endtask

Wrap счётчиков: как ведут себя настоящие чипы в burst

Строка if (next_rx + 1 >= word_len) next_rx <= 0 выглядит мелочью, но за ней — целая глава отладки (№3 в хронике главы 20), поэтому разберём осознанно.

Первая версия модели считала так: дошёл до word_len — стоп, слово принято, жди подъёма CS#. Для одиночных транзакций — безупречно. Новспомните burst из главы 14.4: мастер гонит несколько слов под одним CS#. Модель-«стопор» принимала первое слово и дальше глохла: счётчик упёрся, фронты второго слова падали в пустоту, тест burst — FAIL. Причём FAIL ложный: мастер был прав, врала модель. 4

Как должна вести себя модель — подсказывают реальные чипы. SPI-флешка в режиме чтения отдаёт байты бесконечно, пока CS# низкий: внутренний счётчик бит заворачивается по границе слова. Кодек — так же. Наш wrap — это их поведение: после N-го бита счётчик возвращается к нулю, и следующий фронт начинает следующее слово. rx_captured при этом содержит последнее полное слово пакета — ровно то, что проверяет testbench в burst-тесте.

Мораль шире частного случая: модель тоже код, и в ней тоже бывают баги — глава 16 не делает модель святой. Различие в другом: баг модели и баг DUT, как правило, не совпадают (структуры-то разные) — и проявляются красным тестом, который заставляет разбираться. Симметрия же прятала бы оба. Красный тест при правильном DUT — штатная плата за независимость; молчаливый зелёный при сломанном DUT — катастрофа.

Контрольная точка

Модель — инструмент измерения, и перед употреблением её калибруют. Мини-прогон без DUT: testbench руками (#delay + присваивания) рисует на виртуальных проводах эталонную транзакцию mode 0 из диаграммы главы 4 — и проверяет, что rx_captured совпал с нарисованным словом, а MISO выдал tx_response бит в бит. Потом то же для mode 3 и LSB-first. Это сверка модели с бумагой — внешним эталоном — в обход обоих наших Verilog-устройств.

Контрольная точка пройдена: линейка откалибрована. Теперь можно измерять: глава 18 — большой self-checking testbench, где модель и engine наконец встретятся на одной шине.

Глава 18. Шаг 8: tb_spi_master.v — self-checking тесты

Стенд в сборе

Все участники готовы — осталось поставить их на общий стол. Испытательный стенд устроен так: 

Testbench играет две роли одновременно. Со стороны шины он — хост: дёргает reg_write/reg_read, как это делал бы драйвер. Со стороны проводов он — лаборант при эталонном слейве: перед транзакцией кладёт в tx_response ответ, после — снимает с rx_captured показания. DUT зажат между двумя независимыми точками наблюдения, и каждая транзакция проверяется в обе стороны:

  • мастер принял то, что слейв отправлял (путь MISO);

  • слейв услышал то, что мастер должен был отправить (путь MOSI).

Полпути проверять бессмысленно: ошибка индексации в TX-датапуте не видна на RX и наоборот.

Инфраструктура: пять task'ов, на которых всё стоит

Чекеры — три близнеца check_eq8 / check_eq32 / check_bit:

task automatic check_eq8(input [255:0] name,
                        input [7:0] got, input [7:0] expected);
   begin
       if (got !== expected) begin
           $display("FAIL [%0s] got=%02h expected=%02h", name, got, expected);
           errors = errors + 1;
       end else
           $display("PASS [%0s] = %02h", name, got);
   end
endtask

Три детали, каждая выстрадана:

  • !== вместо !=. Четырёхзначная логика Verilog: обычное != с иксом в любом бите даёт x, то есть «не истину» — и ветка FAIL не выполнится. Неинициализированный сигнал прошёл бы тест! Операторы ===/!== сравнивают побитово, включая x/z, — в чекерах только они.

  • Имя в каждом вызове. Через полгода строка FAIL [mode3 RX] got=cd expected=cd... локализует проблему без раскопок. Безымянный «FAIL #23» — это минус полчаса на поиски.

  • Печатаются и PASS'ы — лог прогона одновременно служит протоколом покрытия: видно не только что упало, но и что вообще проверялось. 

Шинные задачи reg_write/reg_read — те самые из контрольной точки главы 13, по такту на транзакцию, как требует контракт шины.

wait_done — ожидание завершения, как его ждал бы драйвер: опросом STATUS, а не подглядыванием в провода DUT:

task automatic wait_done(input integer max_cycles);
   ...
   while (!ok && i < max_cycles) begin
       reg_read(`SPI_ADDR_STATUS, st);
       if (st[`SPI_STAT_DONE] && !st[`SPI_STAT_BUSY]) ok = 1'b1;
       else i = i + 1;
   end
   if (!ok) begin
       $display("FAIL: timeout waiting for DONE ...");
       errors = errors + 1;
   end
endtask

Лимит цикла — это тест на живость: зависший контроллер (FSM в тупике, потерянный START) проявится таймаутом, а не вечной симуляцией. Плюс второй рубеж — глобальный сторож в отдельном initial с #5_000_000 и принудительным $finish: даже если зависнет сам testbench, прогон завершится словом FAIL, а не съеденным диском VCD.

spi_xfer — однострочник теста: «настрой обе стороны, передай слово, верни принятое». Внутри — полный ритуал драйвера: ERROR_CLR (чистый лист!), CLK_DIV, CS_SELECT, WORD_LEN, DELAY_CFG, TX_DATA, CONTROL, CONTROL+START, wait_done, чтение RX_DATA. Благодаря ему тест режима — четыре строки:

slv_tx_resp = 32'hCD;
spi_xfer(1, 1, 0, 16'd4, 8'd0, 6'd8, 32'h77, rx);   // mode 3
check_eq8("mode3 RX",       rx[7:0],     8'hCD);
check_eq8("mode3 slave RX", slv_rx[7:0], 8'h77);

Матрица: 18 кейсов, 38 проверок

Полный список — он же оглавление файла:

Кейс

Проверяет требование

1

состояние после сброса

Н3 (известный reset-state)

2–5

режимы 0/1/2/3, 8 бит MSB

Т1 (CPOL/CPHA)

6–8

16/24/32 бита, mode 0

Т3 (длины слов)

9–10

LSB-first 8 и 16 бит

Т2 (порядок бит)

11

mode 1 + LSB-first

Т1×Т2 (взаимодействие!)

12

burst 3 слова под одним CS

Т4, Т5 (FIFO)

13

маршрутизация CS1, CS0 молчит

Т7 (multi-CS)

14

START при пустом FIFO

Т8 (ERR_START)

15

WORD_LEN = 12

Т8 (ERR_WORD_LEN)

16

10 записей в FIFO глубины 8

Т8 (ERR_TX_OVF)

17

IRQ по DONE: маска, линия, W1C

Т9 (прерывания)

18

SOFT_RST с очередью слов

Т10 (soft reset)

Это — таблица трассируемости из главы 2, доведённая до исполняемого вида: у каждого требования ТЗ есть номер теста, который его сторожит. Обратное тоже верно, и это полезная самопроверка: тест, не привязанный ни к какому требованию, либо проверяет недокументированное поведение (плохо — задокументируйте), либо не проверяет ничего. 

Несколько решений внутри матрицы, которые легко не заметить:

Тестовые данные и урок о палиндромах. Глава 4.4 предупреждала: байты, симметричные относительно разворота бит, не отличают MSB-режим от LSB. Теперь — упражнение на внимательность: посмотрите на данные LSB-тестов. Тест 9 гоняет 0xA5 и 0x5A... и оба — палиндромы (1010_0101, 0101_1010 — прочитайте их задом наперёд)! Сам по себе тест 9 пропустил бы сломанный LSB-режим. Реальную различающую силу даёт тест 10: 0x1234 (разворот 16 бит — 0x2C48) и 0xC0DE (разворот — 0x7B03) — числа, которые меняются от разворота, и подмена порядка бит гарантированно даст расхождение. Мораль: подбирая тестовые данные, проверяйте их различающую силу так же придирчиво, как сам тест, — красивый «случайный» паттерн вроде 0xA5 может оказаться слепым пятном.

Кейс 11 — комбинация фич. Режимы и порядок бит тестируются не только порознь: исторически именно сочетание CPHA=1 + LSB-first пряталось за симметричными багами. Перемножать все измерения матрицы не нужно (18 кейсов, а не 4×2×4×...), но по одному «диагональному» кейсу на пару взаимодействующих фич — обязательно.

Кейс 13 хитро устроен: на CS1 не висит никакой модели. Проверяется не обмен (его не с кем вести), а коммутация: во время транзакции CS1 активен, CS0 молчит, после — CS1 отпущен. Для проверки «не туда не ходим» слейв не нужен.

Кейсы 14–16 проверяют ошибки как поведение, а не как катастрофу: контроллер обязан отказать вежливо — sticky-флаг взведён, DONE отработал, следующая транзакция возможна. Каждый ошибочный кейс заканчивается тем же wait_done — зависание после ошибки тоже было бы багом.

Финальный аккорд и вердикт

Завершается файл сводкой:

if (errors == 0)
   $display("ALL TESTS PASSED (%0d cases)", test_no);
else
   $display("TESTS FAILED: %0d error(s) across %0d cases", errors, test_no);

Одна строка, по которой make sim_spi (и человек, и CI-скрипт) судит о здоровье проекта. Заметьте, чего в testbench нет: ни одного обращения к внутренним сигналам DUT — только шина и провода. Тест,

подглядывающий в dut.u_eng.state, ломается при каждом рефакторинге и, хуже, начинает проверять реализацию вместо контракта.

Контрольная точка у этой главы отсутствует намеренно: её роль играет вся следующая глава — первый запуск, чтение вывода и знакомство с тем, что делать, когда вместо ALL TESTS PASSED на экране совсем другие слова. Спойлер: именно это мы и увидим.

Глава 19. Запуск: iverilog, vvp, GTKWave

Что на самом деле делает make sim_spi

Команда одна, но под ней — два разных инструмента, и понимать их разделение полезно при чтении ошибок:

$ make sim_spi
iverilog -g2005 -Irtl -Wall -o tb_spi_master.vvp \
       rtl/spi_reset_sync.v rtl/spi_fifo.v rtl/spi_reg_if.v \
       rtl/spi_engine.v rtl/spi_master_top.v \
       sim/tb_spi_slave_ref.v sim/tb_spi_master.v
vvp tb_spi_master.vvp

iverilog — компилятор: парсит исходники, элаборирует иерархию (сверяет порты — помните контрольную точку 15.6?) и собирает всё в файл .vvp — байткод для симуляционной машины. Ошибки здесь — это ошибки языка и структуры: синтаксис, ширины, неподключённые порты, неизвестные модули (забыли файл в Makefile — глава про wildcard это предсказывала).

vvp — собственно симулятор: исполняет байткод, отрабатывая событийную семантику Verilog. Ошибки здесь — поведенческие: FAIL'ы чекеров, таймауты, иксы. Граница важна психологически: пока ругается iverilog, вы чините текст; когда ругается vvp — чините схему.

Читаем вывод прогона

Удачный прогон выглядит так (сокращено):

PASS [reset: BUSY=0] = 0
PASS [reset: TX_EMPTY=1] = 1
PASS [mode0 MSB 8-bit RX] = 5a
PASS [mode0 MSB 8-bit slave RX] = 3c
...
PASS [SOFT_RST: BUSY=0] = 0
========================================
ALL TESTS PASSED (18 cases)
========================================

Неудачный — почти так же, и в этом ловушка: после FAIL прогон продолжается (мы сознательно не останавливаем симуляцию на первой ошибке — пусть упадёт всё, что может, картина целиком информативнее). Поэтому привычка «глянуть хвост и успокоиться» опасна — хвост может быть зелёным при красной середине. Смотреть надо на сводку, а искать — по слову FAIL:

$ vvp tb_spi_master.vvp | grep -E "FAIL|====" -A1
FAIL [burst RX[1]] got=00 expected=11
...
TESTS FAILED: 2 error(s) across 18 cases

Две строки FAIL — это уже половина диагноза: имена чекеров говорят, какой тест и какая именно проверка не сошлась, got/expected дают характер расхождения (нуль вместо данных? сдвиг на бит? обмен nibble'ов?). По одному только got/expected опытный глаз часто называет класс бага: got=00 — данные не дошли вообще; got=2c expected=34 — сдвиг на один бит; got=b2 expected=4d — инверсия порядка бит. Но подтверждать гипотезу идём в волны.

GTKWave: обустраиваем рабочее место

$ make wave_spi      # прогон + открытие tb_spi_master.vcd

Слева — дерево иерархии (tb_spi_master → dut → u_eng...), из него сигналы перетаскиваются в окно волн. Пустое окно — худшее начало; вот стартовый набор, отработанный практикой, сверху вниз:

  1. test_no — наш счётчик кейсов из testbench. Это карта: значение 12 на курсоре = смотрим тест 12. Без него поиск нужного места в многомиллисекундном дампе — мучение.

  2. Шина хоста: wr_en, rd_en, addr, wr_data, rd_data — что приказывал «драйвер».

  3. Провода SPI: spi_sclk, spi_cs_n, spi_mosi, spi_miso — сердце картины, то, что можно сверять с диаграммами главы 4 один в один.

  4. Внутренности engine: dut.u_eng.state, edge_cnt, bit_cnt, tx_word, rx_word. Да, testbench'у внутренности запрещены (глава 18.4) — но глазам при отладке можно и нужно: волны не создают зависимости теста от реализации.

  5. Стробы рукопожатий: ctrl_start, busy, done_pulse, tx_rd_en, rx_wr_en.

Три настройки, экономящие часы. Шинам — формат Hex (правый клик → Data Format), state — ASCII не выйдет в чистом Verilog, но можно держать рядом шпаргалку кодов состояний из главы 14. Собранный набор сохраните в сессию (File → Write Save File, получится spi.gtkw) — следующий запуск восстановит раскладку одной командой gtkwave tb_spi_master.vcd spi.gtkw. И главный hotkey-набор: колесо — прокрутка, Ctrl+колесо — зум, средняя кнопка — маркер, Edit → Find Edge — прыжок по фронтам сигнала.

Методика: от FAIL до строчки кода

Поиск причины по волнам — не искусство, а процедура. Зафиксируем её, потому что в главе 20 она будет применяться пятикратно:

Шаг 1. Локализовать время. По test_no находим кейс, по стробам wr_en — конкретную транзакцию. Маркер — на начало.

Шаг 2. Сверить провода со стандартом. Берём диаграмму нужного режима из главы 4 и прикладываем к SCLK/MOSI/MISO/CS#. Уровень покоя SCLK = CPOL? Число фронтов = 2N? Первый бит на MOSI стоит до первого фронта (CPHA=0)? Это сверка с внешним эталоном — она отделяет «мастер неправ» от «мастер прав, неправы ожидания».

Шаг 3. Найти первое расхождение. Не последнее видимое следствие, а первый момент, где реальность разошлась с ожиданием. Данные испорчены в RX_DATA? Смотрим, какими они были в rx_word до pack. В rx_word? Смотрим моменты семплирования: что было на miso_synced в каждый do_sample-фронт. Двигаемся против потока данных, пока расхождение не исчезнет: первый исправный сигнал → виновата логика между ним и следующим.

Шаг 4. Поставить гипотезу — и проверить её дёшево. «Семплируем не на том фронте» проверяется подсчётом фронтов на экране; «теряем строб» — поиском по Find Edge. Только убедившись, открываем редактор. Двигаться помогает дисциплина одного вопроса: на каждом шаге формулируйте, что именно вы хотите увидеть («рукопожатие: ctrl_start должен упасть в первом же такте busy»), и проверяйте ровно это. Волны гипнотизируют — без вопроса можно скроллить их часами.

Первый настоящий прогон

Методика готова — применяем. Запускаю свежесобранный стек впервые (исторический момент: до этой секунды engine и reg_if никогда не работали вместе, а модель slave впервые видит настоящего мастера):

$ make sim_spi
...
PASS [reset: BUSY=0] = 0
PASS [reset: TX_EMPTY=1] = 1
PASS [reset: TX_READY=1] = 1
FAIL: timeout waiting for DONE (max_cycles=20000)
FAIL [mode0 MSB 8-bit RX] got=00 expected=5a
...

Первая же транзакция — таймаут: контроллер даже не завершил обмен. Хороший момент вспомнить главу 16: красный тест — это не плохая новость, это новость. Система проверки работает: она поймала

настоящую проблему в первые десять секунд использования — и ещё несколько за следующие часы. Какие именно проблемы, как каждая была выслежена по методике 19.4 и что в итоге оказалось багом RTL, багом модели, а что — багом самого теста, — этому посвящена следующая глава целиком: хроника всех пяти багов проекта, с волнами и моралью.

Глава 20. Хроника отладки — все найденные баги

Правила хроники

Эта глава — журнал реальной отладки, приведённый в порядок. Пять багов, в хронологическом порядке обнаружения, каждый по жёсткой схеме: симптом → гипотеза → эксперимент → причина → фикс → мораль. Схема — не литературный приём: это методика 19.4, и я настоятельно советую вести такой журнал письменно в собственных проектах. Запись «гипотеза: строб теряется; эксперимент: Find Edge по ctrl_start» дисциплинирует лучше любого самоконтроля — и превращает отладку из блужданий в последовательность опытов.

Важная оговорка о жанре: в статье баги выглядят аккуратной цепочкой, потому что так устроен пересказ. В реальности были и тупиковые гипотезы, и перепроверки очевидного, и перерыв на кофе как лучший отладочный инструмент. Это нормально. Цените не безошибочность маршрута, а его завершаемость.

Баг №1: таймаут на первой же транзакции

Симптом. Тот самый, из главы 19.5: FAIL: timeout waiting for DONE на тесте 2 — простейшая транзакция mode 0. Контроллер не завершает обмен. Все последующие тесты падают за компанию (нет RX-данных).

Гипотезы. Таймаут — симптом-молчун: «что-то не завершилось», без уточнений. Кандидаты, от дешёвых к дорогим: (а) тест неправильно ждёт; (б) транзакция не начинается; (в) начинается и виснет в середине. 

Разделяются одним взглядом на волны: есть ли вообще активность на SCLK?

Эксперимент. GTKWave, маркер на тест 2. SCLK — мёртвый ноль, CS# — мёртвая единица. Транзакция не начиналась: вариант (б). Сужаем: жив ли START? Find Edge по ctrl_start — строб есть, один такт, как положено. 

А dut.u_eng.state? Не менялся. S_IDLE до и после строба. Engine не увидел START, который был у него под носом. 

Кадр волн, с которого всё стало ясно (реконструкция):

Причина. В той версии engine условие приёма было написано как каскад: сначала по фронту проверялся enable, и только уже включённый автомат на следующем такте начинал слушать start (внутренний «арбитраж» через промежуточный регистр-защёлку start_seen). Строб длиной в один такт умирал ровно в той щели: EN и START пришли одновременно (помните удобство «включить и запустить одной записью» из главы 8.6?), engine потратил такт на «проснуться» — а строб уже кончился. Классический race между производителем и потребителем строба: обе стороны корректны поодиночке и несовместимы вместе.

Фикс — тот, что вы уже видели в главах 8.6 и 13.4, потому что в финальную версию он вошёл как родной: START перестал быть слепым однотактным стробом. reg_if держит его взведённым, пока engine не подтвердит приём делом (busy или done_pulse), а engine принимает enable && start в одном выражении, без промежуточных защёлок. Протокол вместо надежды на совпадение тактов.

Мораль. Однотактный строб через границу модулей — всегда вопрос: «а гарантировано ли, что потребитель слушает в этом такте?» Если гарантии нет — нужен handshake. И заметьте: контракт границы (глава 7.5) у нас был записан — «строб; детали рукопожатия — глава 13», — но первую реализацию написали небрежно к нему. Контракты работают, только если им следовать.

Баг №3: burst получает только первое слово

Симптом. Тесты 2–11 зелёные. Тест 12 (burst, три слова под одним CS): burst RX[1] got=00, burst RX[2] got=00, и slave last MOSI got=aa expected=cc — слейв запомнил первое слово, а не последнее. 

Гипотеза. Мастер прислал только первое слово? Или слейв принял только первое? Различаются взглядом на провода: если MOSI шевелится все 48 фронтов — мастер невиновен.

Эксперимент. Волны: CS# непрерывно низкий, фронтов ровно 48 (3×16), на MOSI отчётливо видны 0xAA, 0xBB, 0xCC. Мастер чист. Смотрим модель: next_rx дополз до 8 на первом слове и... встал. Остальные 32 фронта модель просмотрела в стену.

Причина. Баг модели, разобранный в 17.5: счётчики останавливались на word_len, модель не предполагала второго слова под тем же CS#. 

Фикс. Wrap счётчиков по границе слова — поведение реальных стриминговых слейвов.

Мораль. Красный тест ≠ виноват DUT. Эталон тоже код. Но обратите внимание на асимметрию доверия: проверку начали с DUT (взглядом на провода — сверка с внешним стандартом!) и лишь оправдав его, пошли в модель. Правильный порядок: сначала исключи подозреваемого, на которого указывает тест, потом допрашивай свидетеля.

Баг №4: IRQ поднимается... поздно?

Симптом. Тест 17: FAIL [IRQ asserted] got=0 expected=1. При этом IRQ_STATUS sticky bits — PASS: событие DONE зафиксировано, маска наложена... а линия irq в момент проверки — нуль.

Гипотеза. После трёх багов рука уже тянется чинить RTL. Стоп. Сформулируем точнее: «линия не поднялась» или «линия не успела подняться к моменту проверки»?

Эксперимент. Find Edge по irq: линия поднимается — на один такт позже того фронта, на котором тест её проверил. Расхождение — один такт, всегда один, стабильно воспроизводимо.

Причина. Перечитываем главу 13.4, тонкость 3, — она была написана заранее именно об этом: irq_out — регистровый выход, между событием и линией — такты конвейера «sticky-бит → перевзятие выхода». Это задокументированное, осознанное поведение RTL. Неправ — тест, ожидавший комбинационной реакции: он проверил линию в том же такте, в котором прочитал DONE из STATUS.

Фикс — в testbench, одна строка перед проверкой:

wait_done(20000);
@(posedge clk);          // irq_out -- регистровый: дать конвейеру дотечь
reg_read(`SPI_ADDR_IRQ_STATUS, irq_st);
check_bit("IRQ asserted", irq, 1'b1);

Мораль — про вопрос, который задаёт себе каждый верификатор по десять раз на дню: как отличить баг RTL от бага теста? Ответ всегда  один: по спецификации. Не по коду RTL (он может быть неправ), не по ощущениям теста (он тоже), а по контракту: что обещано? У нас обещание звучало как «irq — уровень, держится пока есть неквитированные события» (глава 7.5) — про задержку в такт контракт молчит, значит, тест не вправе её требовать. Если бы контракт гласил «в том же такте, что DONE» — чинили бы RTL. Нет контракта — нечем рассудить; вот зачем мы его писали.

Баг №5 (финальный босс): что вскрыла независимая модель

Хронологическое примечание: этот пункт — не «пятый по порядку», а фоновый: симметричные баги CPHA=1 и LSB-first жили в проекте с первой строчки старого engine и пережили бы все четыре исправления выше, если бы модель оставалась зеркальной. История их вскрытия рассказана в главе 16; здесь — техническое досье, чем именно они были.

Досье 1: семплирование CPHA=1. Старый engine отсчитывал «полутакты» вложенными условиями, и для CPHA=1 семплирующим оказался ведущий фронт (как у CPHA=0), а не замыкающий. Старая модель, скопированная с той же логики, выставляла данные тоже на полтакта раньше — и пара «ошибка+ошибка» давала зелёный тест. Новая модель семплирует по правилу cpol==cpha из таблицы стандарта — и тест 3 (mode 1) немедленно лёг: got=24 expected=12 — характерный сдвиг на один бит, данные «уехали» на полтакта. Фикс в новом engine — счётчик фронтов и do_sample = (edge_cnt[0] == cpha) (глава 14.2): чётность фронта выводится, а не отслеживается условиями, путать больше нечего.

Досье 2: индексация LSB-first. Старый датапут — сдвиговый регистр с предвыравниванием слова под MSB/LSB; для коротких слов LSB-ветка выгребала биты со сдвигом на 32 - word_len. Модель, разделявшая ту же схему выравнивания, грешила зеркально. Новая модель индексирует биты заново (bit_idx), и LSB-тесты упали с диагнозом «байт развёрнут не от того края». Фикс — bit_pos() и слово, которое никогда не двигается (глава 14).

Теперь видна полная цена зеркальной модели: четыре из пяти багов этой главы она бы тоже скрыла или исказила — она ведь разделяла с DUT не только протокольные формулы, но и контракты стробов, и представления о тайминге FIFO. Независимый эталон оказался не «ещё одним тестом», а условием, при котором остальные тесты вообще что-то значат.

Сводка и зелёный финал

Симптом

Виновник

Фикс

1

таймаут DONE

RTL (оба модуля)

handshake START

2

got=00, оба направления

RTL (FIFO)

комбинационный rd_data

3

burst: только 1-е слово

модель

wrap счётчиков

4

IRQ «не поднялся»

тест

+1 такт перед проверкой

5

(скрытые) CPHA=1, LSB

RTL + старая модель

edge_cnt, bit_pos

Распределение поучительно само по себе: из пяти багов — три в RTL, один в модели, один в тесте. Верификационный код ошибается с той же частотой, что и проверяемый — отнеситесь к нему с тем же уважением: ревью, контракты, аккуратность. И — момент, ради которого всё было:

$ make sim_spi
...
PASS [SOFT_RST: BUSY=0] = 0
========================================
ALL TESTS PASSED (18 cases)
========================================

Тридцать восемь проверок, ноль ошибок, прогон — четыре секунды. Это число — 38/38 — теперь регрессионный капитал проекта: любая будущая правка обязана его сохранить, и проверка стоит одну команду. 

Часть IV закрыта. У нас есть контроллер, в котором мы уверены настолько, насколько вообще можно быть уверенным до встречи с железом. Впереди — эта встреча: часть V, Quartus, синтез, пины и констрейнты. Там выяснится, что у железа есть способы удивлять, которых нет в симуляторе.

Часть V. Синтез в Quartus

Глава 21. Шаг 9: создаём проект — QPF, QSF, ревизии

Смена мира

До этой части наш Verilog жил в уютном мире симулятора, где «работает» означало «события отрабатываются по семантике языка». Теперь код поедет в мир, где у сигналов есть напряжение, у проводов — ёмкость, а у ошибок — запах палёного. Посредник между мирами — Quartus: он превратит RTL в конфигурацию конкретного кристалла EP4CE6F17C8 на конкретной плате.

Часть V — четыре шага, и каждый отвечает на свой вопрос:

  • проект (эта глава): что собирать и для чего;

  • пины (глава 22): куда на корпусе выходит каждый порт;

  • констрейнты (глава 23): как быстро всё должно работать;

  • компиляция (глава 24): собрать, прочитать отчёты, прошить.

Принципиальное решение на всю часть: проект Quartus у нас — это два текстовых файла, написанных руками и лежащих в git, а не нечто, наклацанное в визардах. GUI мы будем использовать как смотровую площадку (Pin Planner, отчёты, Programmer), но источник правды — текст. Причина та же, что у явных списков в Makefile (глава 9): текст можно ревьюить, диффать и чинить осознанно. Забегая в главу 27: баг, который съест больше всего времени на bring-up, будет жить именно в этом текстовом файле — и обнаружится именно диффом.

QPF: файл-визитка

quartus/spi_master.qpf — файл проекта, и он анекдотически мал:

QUARTUS_VERSION  = "25.1"
PROJECT_REVISION = "spi_master"

Вся его работа — сказать «проект существует, актуальная ревизия — такая-то». Ревизия — это именованный набор настроек компиляции; у каждой ревизии — свой QSF-файл с тем же именем. Открываете QPF в GUI (File → Open Project) — Quartus подхватывает ревизию и читает её QSF. 

У нас в каталоге quartus/ со временем поселятся два проекта: spi_master — чистое SPI-ядро, и spi_lcd — полная демо-прошивка с дисплеем (появится в части VI). Зачем держать оба? Это продолжение философии изоляции из части IV: маленький проект собирается быстрее и содержит меньше подозреваемых. Когда на bring-up возникнет вопрос «это SPI сломан или его окружение?», возможность собрать и прошить только ядро окажется тем же приёмом, что изоляционный смок-тест модуля, — только на уровне железа.

QSF: всё, что Quartus должен знать

spi_master.qsf — настоящий центр проекта. Разберём его по секциям — это тот же файл, который в главе 27 сыграет роль места преступления, так что знакомьтесь внимательно.

Секция 1: кристалл и топ.

set_global_assignment -name FAMILY                   "Cyclone IV E"
set_global_assignment -name DEVICE                   EP4CE6F17C8
set_global_assignment -name TOP_LEVEL_ENTITY         spi_master_fpga_top
set_global_assignment -name PROJECT_OUTPUT_DIRECTORY output_files
set_global_assignment -name MIN_CORE_JUNCTION_TEMP   0
set_global_assignment -name MAX_CORE_JUNCTION_TEMP   85

DEVICE — точное имя кристалла, до последней буквы (буква в конце — speed grade, от неё зависят все тайминги). TOP_LEVEL_ENTITY — имя модуля (не файла!), который станет вершиной иерархии: у нас это платная обёртка из главы 15.5. Температуры задают условия, для которых STA посчитает тайминг, — коммерческий диапазон 0–85°C.

Секция 2: исходники.

set_global_assignment -name SEARCH_PATH          "../rtl"
set_global_assignment -name VERILOG_INCLUDE_FILE ../rtl/spi_defs.vh

set_global_assignment -name VERILOG_FILE ../rtl/spi_reset_sync.v
set_global_assignment -name VERILOG_FILE ../rtl/spi_fifo.v
set_global_assignment -name VERILOG_FILE ../rtl/spi_reg_if.v
set_global_assignment -name VERILOG_FILE ../rtl/spi_engine.v
set_global_assignment -name VERILOG_FILE ../rtl/spi_master_top.v
set_global_assignment -name VERILOG_FILE ../rtl/spi_master_fpga_top.v

Узнаёте? Это список SPI_RTL из Makefile, переведённый на язык Quartus, и принцип тот же: явное перечисление, никаких wildcard. Пути — относительные от QSF (мы в quartus/, исходники в ../rtl/): проект переносим вместе с репозиторием. SEARCH_PATH решает ту же задачу, что -I у iverilog, — где искать spi_defs.vh по include. Файлов из sim/ здесь нет — синтезатор никогда не увидит testbench, граница каталогов из главы 9 работает.

Секция 3: констрейнты.

set_global_assignment -name SDC_FILE spi_master.sdc

Одна строка, подключающая файл, которому посвящена вся глава 23.

Секция 4: пины — её мы намеренно пропускаем до главы 22: там и строки set_location_assignment, и стандарты I/O, и подтяжки. Пока лишь зафиксируем факт: в первой версии файла эта секция была заполнена заглушками с честным комментарием PLACEHOLDERS — replace with values from your board. Запомните этот комментарий. Он ещё выстрелит.

Сборка: GUI и CLI

Запустить компиляцию можно двумя способами, и нужны оба.

GUI: File → Open Project → spi_master.qpf, затем Processing → Start Compilation (Ctrl+L). Плюс GUI — всё кликается и сразу видны отчёты; минус — «открыл, поменял галочку, забыл» (любая галочка — это молчаливая правка QSF, и завтра дифф в git напомнит, что именно вы наклацали; это, кстати, аргумент за хранение QSF в git, а не против GUI).

CLI — для воспроизводимых сборок:

quartus_sh --flow compile spi_master

Одна команда прогоняет весь конвейер (синтез → fitter → assembler → STA — подробности в главе 24). Чтобы не вспоминать синтаксис, в репозитории лежит обёртка build.sh:

./build.sh spi_master    # SPI-ядро
./build.sh               # spi_lcd по умолчанию (появится в части VI)

Внутри — set -euo pipefail, проверка имени ревизии и quartus_sh; двадцать строк, а порог входа «собрать проект» падает до одной команды — та же экономика, что у make sim_spi.

Гигиена: что породит Quartus и что не пускать в git

Первая же компиляция намусорит в quartus/ основательно: каталог db/ (внутренняя база синтезатора, сотни файлов), incremental_db/, output_files/ (отчёты, .sof), плюс россыпь .qws, .smsg и прочих кэшей. Всё это — производное: восстанавливается компиляцией, конфликтует при merge и распухает на мегабайты. В git ему не место, поэтому рядом лежит .gitignore:

db/
incremental_db/
output_files/*
!output_files/.gitkeep
*.qws
*.smsg
*.log
*.bak

Правило простое: в git живут QPF, QSF, SDC и build.sh — четыре рукописных файла. Всё остальное в quartus/ — мусор сборки. Карантин, обещанный в главе 9, обрёл стены.

Контрольная точка

Запускаем первую компиляцию — прямо с пиновыми заглушками (узнать, насколько они неправильные, — задача следующей главы; сейчас цель — убедиться, что проект синтезируется):

$ ./build.sh spi_master
==> Building revision: spi_master
...
Info: Quartus Prime Full Compilation was successful.

Что проверено: все файлы найдены (пути и SEARCH_PATH верны), весь наш Verilog принят синтезатором — это чуть строже, чем iverilog: Quartus по-своему смотрит на синтезируемость, инференс памяти, ширины — и появился output_files/spi_master.sof. Прошивать его рано: сигналы пока выходят на случайные ноги. Куда их направить и как не промахнуться — глава 22.

Контрольная точка пройдена: RTL, рождённый в симуляторе, впервые прожёван настоящим синтезатором до конца. 

Глава 22. Шаг 10: пины — Pin Planner и location assignments

Контракт с печатной платой

Распиновка — это последний контракт проекта, и заключается он с самым несговорчивым партнёром: печатной платой. Все предыдущие контракты (между модулями, между RTL и тестом) можно было править с обеих сторон; здесь вторая сторона отлита в стеклотекстолите. Дорожка от пада E1 идёт к генератору 50 МГц, от E9 — к ножке DOUT тач-контроллера, и никакие наши таланты этого не изменят. 

Либо QSF говорит правду о плате, либо прошивка общается с пустотой. Усугубляет дело то, как проявляется ошибка: не сообщением компилятора, а молчанием осциллографа через неделю. Промахнуться пином — значит заложить баг с самым длинным инкубационным периодом из возможных. Поэтому глава будет педантичной: распиновка — то место, где паранойя дешевле оптимизма.

Откуда берутся номера: два источника и перекрёстная сверка

Источник номеров пинов — схема платы (PIAX301V2) плюс схема модуля (AN430): сигнал SPI-тача проходит два листа — от ножки XPT2046 через разъём LCD-модуля до пада FPGA. Прослеживание такой цепочки — работа въедливая: имена сигналов на листах меняются (то, что у XPT2046 называется DOUT, на разъёме становится SPI_DOUT, а в нашем RTL — spi_miso), и перепутать DIN/DOUT при переименованиях — классика.

Поэтому второй источник — эталонное демо производителя: ALINX поставляет к плате примеры проектов, и в их QSF распиновка уже прожита чужим bring-up'ом. Метод перекрёстной сверки: выписываем цепочку по схемам сами → открываем QSF демо-проекта → сравниваем. Совпало — уверенность почти полная. Разошлось — разбираемся до конца, никаких «возьму из демо, там виднее»: демо может быть для другой ревизии платы.

Результат для нашего набора сигналов (фрагмент таблицы, полная — в приложении B):

Порт RTL

Пин

Цепь на схеме

Назначение

clk_50m

E1

CLK_50M

генератор

reset_n

N13

KEY1

кнопка, активный 0

spi_sclk

F8

SPI_DCLK

такт к XPT2046

spi_cs_n

E7

SPI_CS

выбор XPT2046

spi_mosi

D8

SPI_DIN

DIN тача = выход FPGA!

spi_miso

E9

SPI_DOUT

DOUT тача = вход FPGA

spi_busy

C8

SPI_BUSY

статус АЦП

spi_penirq

E8

PENIRQ

касание, активный 0

Обратите внимание на колонку «цепь»: имена на схеме даны со стороны тача (DIN/DOUT), и таблица — единственное место, где зафиксировано соответствие «их DIN = наш MOSI». Такая таблица в README обязана существовать в любом проекте: она дешевле одного-единственного перепутанного направления.

Анатомия назначений: четыре типа строк

В QSF каждый пин описывается до четырёх строками. Разберём на примере MISO:

set_location_assignment PIN_E9 -to spi_miso
set_instance_assignment -name IO_STANDARD "3.3-V LVTTL" -to spi_miso
set_instance_assignment -name WEAK_PULL_UP_RESISTOR ON  -to spi_miso
  
# (для выходов ещё бывает:)
set_instance_assignment -name CURRENT_STRENGTH_NEW 8MA  -to lcd_dclk

set_location_assignment — собственно привязка: порт топ-модуля → пад корпуса. Имя пина — из даташита корпуса (FBGA-256: буква-строка, цифра-столбец).

IO_STANDARD — электрический стандарт банка: уровни напряжения, пороги. У нас всё питается от 3.3 В — LVTTL. Несовпадение стандарта с физическим питанием банка — ошибка уровня «может и сжечь».

CURRENT_STRENGTH_NEW — сила выходного драйвера. Для длинного шлейфа к LCD выбрано 8 мА: достаточно для ёмкости панели, без звона на фронтах. Для SPI на 1 МГц хватает умолчания.

WEAK_PULL_UP_RESISTOR — встроенная слабая подтяжка к питанию. Копеечная строчка, а в нашем проекте — несущая: см. следующий раздел. 

И одна глобальная строка, о которой забывают:

set_global_assignment -name RESERVE_ALL_UNUSED_PINS_WEAK_PULLUP \
   "AS INPUT TRI-STATED"

— судьба неиспользуемых пинов. По умолчанию Quartus может загнать их в «выход, земля» — и если на плате такой пад куда-то подключён, получится тихий конфликт драйверов. «Вход с подтяжкой» — безопасное состояние для всего, что мы не используем осознанно.

Зачем подтяжки на MISO и PENIRQ

Две подтяжки в нашем QSF — не «на всякий случай», у каждой есть сюжетная роль.

MISO. Вопрос на засыпку: что прочитает мастер, если тач-модуль не подключён? Линия MISO повиснет в воздухе, и вход FPGA будет читать... что попало — наводки, соседние сигналы, фазу луны. С подтяжкой ответ детерминирован: все единицы, 0xFFFF. А если линия коротнула на землю — все нули. Эти два паттерна — «адреса отсутствия», и в части VI на них будет построено правило детекции устройства (rx != 0xFFFF && rx != 0x0000 — живой XPT2046 обязан вернуть что-то кроме крайностей). Подтяжка превращает «не подключено» из неопределённости в диагностируемое состояние.

PENIRQ. Выход PENIRQ у XPT2046 — open-drain: чип умеет тянуть линию вниз (касание), но не вверх; единицу обязан обеспечить резистор. На модуле подтяжка есть, но дублирующая внутренняя стоит копейки и спасает, если модуль отстыкован. Без неё «нет касания» выглядело бы как плавающий вход.

ВРЕЗКА-ПРЕДУПРЕЖДЕНИЕ: Quartus прощает молча

Теперь — самое важное место главы. Прочитайте дважды.

Строка -to spi_miso связывается с портом топ-модуля по текстовому имени. И если имя не совпало — порт переименовали, опечатка, неверная размерность — Quartus не остановит компиляцию. Он выдаст warning уровня «ignored assignment» (одну строчку среди сотен), выбросит назначение и сам расставит осиротевшие порты по свободным пинам. Сборка зелёная. .sof готов. 

Сигналы — на случайных ногах.

Особо коварный подвид — размерность. Шина и скаляр — разные имена: назначения на spi_cs_n[0]...spi_cs_n[3] не имеют никакого отношения к порту spi_cs_n без индекса. Перевели порт из шины в скаляр (как сделаем мы при интеграции с LCD — у тача один CS) — и все четыре строки QSF превратились в мусор, молча.

Почему так? Философия Quartus: назначения могут описывать ещё не написанный дизайн, поэтому «лишние» — не ошибка. Удобно для топ-даун-планирования, смертельно для невнимательных.

Если из всей части V вы запомните одно предложение, пусть будет это: изменил порты топ-модуля — перепроверь пиновую секцию QSF и отчёт Ignored Assignments. Глава 27 покажет, во что обходится забывчивость: наш собственный bring-up упёрся ровно в эти грабли — старые назначения-заглушки на шину spi_cs_n[*], новый скалярный порт, и дни поисков «куда делись сигналы».

 Pin Planner: смотровая площадка

Проверить, что назначения применились, можно не дожидаясь железа. После Analysis & Synthesis открываем Assignments → Pin Planner: внизу — таблица всех портов топа, у каждого — колонка Location. 

Чек-лист осмотра:

  • у каждого порта Location заполнен и совпадает с нашей таблицей 22.2 (пустая ячейка = порт будет расставлен фиттером куда попало);

  • нет «лишних» портов, которых мы не ожидали (признак расхождения RTL и наших представлений о нём); 

  • I/O standard у всех — 3.3-V LVTTL.

Второй пункт контроля — текстовый: output_files/*.pin, сгенерированный fitter'ом список «пин → сигнал» по всем 256 падам. Его удобно глазами сверить с таблицей из README (а в зрелом проекте — скриптом). И третий — отчёт компиляции, раздел Ignored Assignments: он обязан быть пустым.

Pin Planner, кстати, умеет и назначать (перетаскиванием), и иногда это удобно — но помните главу 21: всё, что вы накликали, молча дописывается в QSF. После любых упражнений в GUI — git diff по QSF, чтобы знать, что именно изменилось.

Контрольная точка 

Пересобираем с настоящими пинами и проходим тройной контроль: Pin Planner — все Location на местах; .pin-файл сходится с таблицей README; Ignored Assignments пуст. Электрическая часть контракта с платой задокументирована и, насколько возможно без паяльника, проверена.

Контрольная точка пройдена. Прошивать всё ещё рано: Quartus пока не знает, с какой скоростью должна жить наша схема, — и пока не знает, не гарантирует ничего. Тайминг-констрейнты — глава 23.

Глава 23. Шаг 11: SDC — таймнинг-констрейнты без страха

Зачем это вообще: STA на пальцах

SDC-файлы окружены, пожалуй, самым плотным ореолом страха из всего FPGA-стека — и совершенно зря: для дизайна вроде нашего весь файл — это дюжина строк, из которых половина — комментарии. Но сначала о том, зачем он.

Внутри кристалла сигнал распространяется не мгновенно: триггер → маршрут → LUT → маршрут → следующий триггер занимает единицы наносекунд, и для каждого пути — свои. Статический временной анализ (STA) — это проверка, что самый медленный путь между любой парой триггеров укладывается в период клока (setup), а самый быстрый — не настолько быстр, чтобы данные проскочили в тот же фронт (hold). «Статический» — потому что проверяются все пути сразу, по таблицам задержек, без симуляции.

Но у этой машинерии есть слепое пятно: она не знает ваших намерений. Какой период у клока? Этого нет в Verilog — always @(posedge clk) одинаков для 5 МГц и 500. Какие входы синхронны, а какие живут своей жизнью? Какие пути не должны проверяться вовсе? Всё это сообщаете вы — в SDC (Synopsys Design Constraints, индустриальный формат на базе Tcl).

Без SDC компилятор собирает схему «как получится» и ничего не гарантирует: сегодня повезло с разводкой — работает, завтра добавили модуль, фиттер разложил иначе — путь стал длиннее периода, и дизайн ловит сбои раз в час при нагреве. Это худший класс багов в профессии. Констрейнты — страховка от него: с ними нарушение тайминга — это красная строка в отчёте, а не сюрприз в полночь. 

Хорошая новость для нас: решение «один тактовый домен» из главы 6 обещало простой SDC — пришло время получать дивиденды.

Строка 1: системный клок

create_clock -name clk_50m -period 20.000 [get_ports {clk_50m}]

Перевод: «на порту clk_50m живёт клок с периодом 20 нс; проверь, что вся синхронная логика от него успевает». Эта одна строка накрывает STA все внутренние пути проекта — сотни триггеров reg_if, FIFO, engine. 

Вот и обещанный дивиденд: один домен — одна строка — полное покрытие внутренностей. Для сравнения, дизайн с доменом от SCLK потребовал бы описать второй клок, его остановки и все межДоменные пути — страница exception'ов, каждый из которых можно сформулировать неверно.

Синтаксис в квадратных скобках — Tcl: [get_ports ...] — вызов функции, возвращающей объект-порт. SDC — это программа, и в больших проектах этим пользуются (циклы, переменные); нам хватит прямолинейных вызовов.

SCLK: клок, которого «нет»

Тонкий момент. SCLK внутри кристалла — обычный регистр (глава 6), и для внутренней STA никакой не клок. Но снаружи, для XPT2046, он — самый настоящий тактовый сигнал, и данные MOSI/MISO живут относительно его фронтов. Чтобы STA смог проверить выходные и входные пути относительно SCLK, ему сообщают: «на этом порту — клок, производный от системного»:

create_generated_clock -name spi_sclk_gen \
   -source [get_ports {clk_50m}] \
   -divide_by 10 \
   [get_ports {spi_sclk}]

-divide_by 10 — здесь нужна честность с собой: наш делитель программируемый, а SDC статичен. Правило: констрейним худший случай — минимальный делитель, который реально программируется (самый быстрый SCLK = самые жёсткие требования). Будет запас — отлично; обманем себя большим делителем — получим непроверенные пути. Комментарий в файле прямо предписывает пересчитать значение при смене рабочей точки. (В проекте с LCD добавится второй такой же блок для lcd_dclk с -divide_by 6 — тот же приём без единой новой идеи.)

I/O-задержки: переводим даташит slave в числа

Самая «страшная» часть SDC — set_input_delay/set_output_delay — на деле просто арифметика по конвенции. Смысл: рассказать STA, сколько времени съедает внешний мир — тогда остаток периода становится бюджетом для нашей внутренней логики и пинов.

Выходы (MOSI, CS#). XPT2046 требует: данные стабильны за t_SU до фронта SCLK и держатся t_H после. Конвенция перевода:

#   -max = t_SU слейва: столько времени у фронта "отъедает" его setup
#   -min = -t_H слейва: знак минус -- особенность конвенции hold
set_output_delay -clock spi_sclk_gen -max  5.0 [get_ports {spi_mosi spi_cs_n}]
set_output_delay -clock spi_sclk_gen -min -2.0 [get_ports {spi_mosi spi_cs_n}]

Вход (MISO). Слейв выставляет данные через t_CO после своего фронта — столько MISO «опаздывает» относительно SCLK:

set_input_delay  -clock spi_sclk_gen -max 15.0 [get_ports {spi_miso}]
set_input_delay  -clock spi_sclk_gen -min  2.0 [get_ports {spi_miso}]

Откуда числа: из таблицы «AC characteristics» даташита XPT2046, с округлением в пессимистичную сторону (его t_CO — до ~100 нс при медленном SCLK; наши 15 нс на фоне периода SCLK 1.6 мкс — числа с огромным запасом, и комментарий в SDC честно называет их консервативными). 

Точность до наносекунды здесь не нужна — нужна пессимистичность: завышенная внешняя задержка может дать ложный FAIL тайминга (заметите и разберётесь), заниженная — пропустить настоящий (узнаете от железа). 

Главная мораль раздела прозаичнее формул: подавляющему большинству новичков эти констрейнты не понадобятся годами — на скоростях SPI в единицы МГц запасы огромны. Но строки должны существовать: они документируют намерение, и когда кто-то разгонит SCLK до 25 МГц, STA автоматически скажет, влезает ли дизайн. Констрейнты — это требования ТЗ (Т2: «частота программируется») в формате, который проверяет машина.

set_false_path: пути, которые не надо проверять

Остались сигналы, для которых временной анализ не имеет смысла, — и о них тоже надо сообщить, иначе STA честно попытается их проверить и либо завалит тайминг, либо заставит фиттер геройствовать впустую:

# Кнопка сброса: асинхронна по природе, дисциплинирована spi_reset_sync
set_false_path -from [get_ports {reset_n}] -to [all_registers]

# IRQ: внешний CPU прочитает, когда прочитает
set_false_path -from [all_registers] -to [get_ports {spi_irq}]

# (в LCD-проекте добавятся BUSY и PENIRQ -- уровни через 2-FF синхронизатор)
set_false_path -from [get_ports {spi_busy spi_penirq}] -to [all_registers]

Правило безопасности, оплаченное чужими слезами: set_false_path — это обещание, что путь обработан другим механизмом. Для reset_n механизм — spi_reset_sync (глава 11), для BUSY/PENIRQ — 2-FF синхронизаторы (глава 6.4), для уровневых сигналов вроде PENIRQ — ещё и сама их природа (уровень, не строб). false_path без синхронизатора на том конце — это не констрейнт, а сокрытие улик: красная строка из отчёта исчезнет, метастабильность — нет. Сначала схема, потом исключение.

Читаем отчёт: slack и «timing met»

После компиляции — Timing Analyzer (TimeQuest). Весь его многостраничный вывод сводится к одному слову на путь — slack: запас. Для setup: 

slack = (период) − (задержка данных по пути) − (поправки)

Slack положительный — путь успевает; отрицательный — нарушение. Отчёт группирует пути по клокам и показывает худшие. Что смотреть, по убыванию важности:

  1. Сводка Setup/Hold по clk_50m — наш единственный настоящий домен. У SPI-ядра slack уверенно положительный: логика короткая, кристалл скучает. (Финальная прошивка с LCD будет жить заметно теснее — реальные числа увидим в главе 24.)

  2. Unconstrained paths — список путей, не накрытых ни одним констрейнтом. В идеале — пуст; каждая строчка в нём — сигнал, о котором мы забыли рассказать (или забытый false_path). Это та же логика, что Ignored Assignments в главе 22: отчёт о дырах в наших декларациях.

  3. Clock transfers — перечень межклоковых передач. У нас их быть не должно (один домен!): непустой список — признак, что где-то завёлся незапланированный клок.

«Timing met» — это и есть итог: все констрейнты выполнены, slack везде положителен. С этого момента — повторим тезис из 23 главы — тайминг перестаёт быть везением и становится проверяемым свойством сборки.

Контрольная точка

Пересборка с SDC: timing met, slack по clk_50m положительный, unconstrained paths — пусто (после добавления false_path на reset и IRQ), clock transfers — пусто. Все три файла триады — QSF, пины, SDC  — готовы и лежат в git.

Контрольная точка пройдена. Осталось последнее на пути к железу: прогнать полный flow осознанно, прочитать отчёты не только про тайминг — и наконец нажать Program. 

Глава 24. Шаг 12: компиляция и чтение отчётов

24.1. Конвейер: четыре станции

quartus_sh --flow compile (или Ctrl+L в GUI) — это четыре последовательных инструмента, и у каждого своя зона ответственности. Знать границы полезно: по тому, на какой станции возникла проблема, сразу ясен её характер.

  1. Analysis & Synthesis — наш Verilog → нетлист из примитивов (LUT, триггеры). Здесь живут все языковые проблемы: ошибки синтаксиса, inferred latch, несинтезируемые конструкции, инференс памяти.

  2. Fitter (place & route) — нетлист раскладывается по конкретным ячейкам кристалла, сигналы разводятся по трассировочным ресурсам. Здесь — проблемы размещения: нехватка ресурсов, неразводимость, конфликты пинов. И именно фиттер исполняет (или молча игнорирует — глава 22) наши location assignments. 

  3. Assembler — размещённый дизайн → битстрим .sof. 

  4. Timing Analyzer — STA по нашему SDC (глава 23). Заметьте: тайминг проверяется после сборки — отрицательный slack не останавливает генерацию .sof. Прошить дизайн с проваленным таймингом вам никто не помешает; не прошивайте.

Fitter summary: числа нашего проекта

Главный отчёт для первого взгляда — сводка фиттера. Приведу настоящую — от финальной полной прошивки (SPI-ядро + LCD-подсистема + автономные FSM из части VI): числа из учебных статей обычно округлены до неузнаваемости, наши — из живого spi_lcd.fit.summary:

Fitter Status : Successful
Device        : EP4CE6F17C8
Total logic elements              : 1,743 / 6,272 ( 28 % )
   Total combinational functions : 1,525 / 6,272 ( 24 % )
   Dedicated logic registers     :   854 / 6,272 ( 14 % )
Total pins                        :    38 / 180   ( 21 % )
Total memory bits                 :     0 / 276,480 ( 0 % )
Embedded Multiplier 9-bit elements:     0 / 30      ( 0 % )
Total PLLs                        :     0 / 2       ( 0 % )

Как читать эти строки — построчно, с выводами: 1743 LE (28%). Logic element — атом Cyclone IV: один 4-входовый LUT плюс один триггер. Вся наша конструкция — два регистровых интерфейса, два FIFO, engine со всей протокольной машинерией, видеотракт LCD, шрифт, секвенсоры — заняла четверть самого маленького кристалла серии. 

Полезная калибровка интуиции: контроллерная логика дешева; дорогими бывают буферы данных и арифметика.

Соотношение 1525 комбинационных / 854 регистров — здоровый профиль управляющей логики (примерно 2:1). Резкий перекос в комбинационную сторону — повод поискать гигантские мультиплексоры или случайную арифметику; в регистровую — лишние конвейеры.

Memory bits: 0. Строка-ревизор: она подтверждает решение из главы 12.4 — FIFO легли в LE-регистры (комбинационное чтение), блочная память M9K не тронута. Если бы мы ждали M9K, а увидели ноль (или наоборот) — это сигнал, что синтезатор понял код не так, как мы задумывали. Сводка фиттера — дешёвый способ сверить намерения с реальностью.

PLLs: 0. Тоже подтверждение архитектуры (глава 6): никаких производных клоков, переносимость не нарушена по дороге.

38 пинов — сверьте с таблицей README: число должно сходиться с ожиданием до штуки. Лишний или недостающий пин — снова к главе 22.

Рядом — сводка STA (spi_lcd.sta.summary), тоже настоящая:

Setup 'clk_50m'        Slack : 1.032   TNS : 0.000
Setup 'lcd_dclk_gen'   Slack : 2.743   TNS : 0.000
Setup 'spi_sclk_gen'   Slack : 6.375   TNS : 0.000
Hold  'clk_50m'        Slack : 0.452   TNS : 0.000

Все slack'и положительные, TNS (total negative slack — сумма всех нарушений) — нулевой: timing met. Обратите внимание, насколько полная прошивка теснее голого ядра: worst setup slack по clk_50m — всего +1.03 нс. Запас есть, но эпоха «кристалл скучает» кончилась — LCD-тракт с его мультиплексорами глифов удлинил критический путь. Ещё пара таких подсистем — и пришлось бы заниматься оптимизацией всерьёз; пока достаточно знать, что сторож (STA) на посту.

Warnings: сортировка сигнала и шума

Реальная компиляция выдаёт сотни предупреждений, и стратегия «прочту все» умирает на втором прогоне. Нужна сортировка. Чёрный список — предупреждения, при виде которых работа останавливается: 

  • inferred latch — случайная защёлка (глава 5.5): где-то комбинационный always без полного присваивания. Всегда баг.

  • ignored assignment / Ignored Assignments — назначение из QSF не нашло адресата (глава 22.5). Пины уехали. Всегда разбирательство.

  • unconstrained clock / path — путь без констрейнта (глава 23.6): STA что-то не проверяет, и оно сломается молча.

  • truncated value на содержательной логике — присваивание с потерей старших бит: либо забытая разрядность, либо настоящая ошибка ширины.

  • multiple drivers / combinational loop — формально это error, но родственные warning'и про «net has no driver» туда же: схема не та, что задумана.

Шум (после однократного осмысленного прочтения): «feature X is unsupported in, «no clock transfers found» (у нас их и не должно быть), нотации про неиспользуемые входы стандартных ячеек.

Практика, которая делает список рабочим, — та же политика нуля, что в главе 9: после первой чистки новых предупреждений в сборке быть не должно. Сотня «привычных» строк, среди которых однажды появится сто первая важная, — это шумовая завеса, в которой и прячутся будущие главы-хроники. У нас списка «привычных» — около десятка, все осмотрены и поименованы.

Прошивка: Programmer и USB-Blaster

Финальный жест части V. Подключаем USB-Blaster (JTAG-кабель платы), Tools → Programmer: 

  1. Hardware Setup → USB-Blaster (если в списке пусто — на Linux почти всегда дело в udev-правах на USB-устройство). 

  2. Файл output_files/spi_master.sof обычно подхватывается сам; режим — JTAG.

  3. Галка Program/ConfigureStart → прогресс-бар → 100%.

Важная физика напоследок: .sof грузится в конфигурационную SRAM кристалла — энергозависимую. Снял питание — прошивки нет; для разработки это идеально (испортить нечего, итерация — секунды). Залить дизайн «навсегда» — значит прошить конфигурационную flash (EPCS) файлом .jic — нам в рамках статьи не понадобится.

После Start... ничего видимого не произойдёт — и это ожидаемо: у  SPI-ядра нет ни одного светодиода, а проверить SPI-выводы пока нечем, кроме осциллографа. Ощущение «прошил, и тишина» — не дефект, а постановка задачи следующей части: прежде чем что-то отлаживать на железе, надо научиться видеть, что происходит внутри кристалла.

Контрольная точка — и итог части V

Полный flow проходит без ошибок; в новых предупреждениях — пусто; fitter summary сходится с ожиданиями (LE — проценты, memory — ноль, пины — по списку); timing met; .sof заливается в плату без ошибок JTAG.

Подведём итог части V. Из текстового RTL мы получили воспроизводимым и проверяемым способом конфигурацию кристалла: проект в git (QPF + QSF + SDC + build.sh), сборка одной командой, распиновка задокументирована и трижды проверена, тайминг — под надзором STA, отчёты прочитаны

осознанно. Чего у нас нет — обратной связи от железа: работает ли всё это на самом деле, пока неизвестно. Часть VI — bring-up: сначала построим себе глаза (LCD как приборная панель), потом руки (автономные FSM вместо CPU), а потом переживём лучшую главу-хронику проекта — про то, как логический анализатор неделю не видел ни одного SPI-сигнала.

Часть VI. Bring-up на железе

Глава 25. Проблема наблюдаемости: как увидеть, что внутри ПЛИС

«Прошил и молюсь»

Часть V закончилась тем, что прошивка была залита и в ответ - тишина.Сформулируем проблему, с которой начинается любой bring-up. В симуляторе мы были всевидящими: любой из восьмисот триггеров — на ладони, время можно остановить и отмотать. В кристалле всё то же самое работает — но не видно ничего. Снаружи FPGA — это пины; всё, что не выведено на пин, наблюдению недоступно в принципе. Зависла FSM? Не приходит строб? CS живёт на другой ножке? Изнутри это неотличимо: симптом один — «не работает».

Новичок в этой точке делает типичную ошибку: начинает менять код наугад и перепрошивать, надеясь, что какая-то из правок «оживит» плату. Это худшая из стратегий — каждая итерация минуты, информации ноль, а слепые правки добавляют новые баги к старым. Правильный первый шаг bring-up — не отладка. Правильный первый шаг — строительство органов чувств: прежде чем спрашивать «почему не работает», надо обеспечить себе способ увидеть ответ.

Меню наблюдаемости

Инструментов несколько, и они не конкурируют — они дополняют друг друга на разных масштабах. Честный обзор с ценами: 

Светодиоды. Один бит на диод. Плюсы: ноль инфраструктуры, видно мгновенно. Минусы: бит — это очень мало («busy горит» — и что?). Идеальны для самого первого вопроса «жив ли клок» (мигалка от счётчика) — и быстро исчерпываются.

UART-лог. Печать в терминал, как $display, но из железа. Плюсы: неограниченная выразительность. Минусы: нужен UART-передатчик в дизайне, форматирование, приёмник на ПК; для нашего вопроса («работает ли SPI?») получается отладка одного непроверенного интерфейса через другой непроверенный.

SignalTap — фирменный встраиваемый логический анализатор Quartus: пишет внутренние сигналы в свободную блочную память и выгружает по JTAG. Плюсы: видно внутренности без пинов, триггеры как у настоящего LA. Минусы: ест ресурсы и (важно!) меняет разводку — дизайн с анализатором не тождественен дизайну без него; привязывает к JTAG и вендорскому GUI. Это самый мощный пункт меню, и в промышленной практике он обязателен; в нашей истории мы обойдёмся без него — и осознанно: статья учит строить наблюдаемость в самом дизайне, переносимую и вендоронезависимую. (Если bring-up у вас зайдёт в тупик глубже нашего — SignalTap первый кандидат на подключение.)

Внешний логический анализатор — щупы на физические пины. Плюс, который не заменить ничем: видит правду провода — то, что реально приходит к slave, после всех QSF, фиттеров и пайки. Минус: только пины, и нужно уметь его настраивать (глава 27 покажет, как неумение стоило нам дней).

Дисплей на плате. И вот наш туз: на PIAX301V2 подключён LCD-модуль 480×272. Дисплей — это тысячи бит наблюдаемости, обновляемых 60 раз в секунду, без ПК, без JTAG, без терминала: приборная панель, постоянно прикрученная к проекту. Цена — LCD-подсистему нужно построить (и проверить!) — но это разовая инвестиция, и она у нас уже сделана.

Стратегия проекта собирается из трёх слоёв: LCD — панель приборов (статус и числа, видимые всегда), логический анализатор — правда провода (когда нужно сверить выход с ожиданием), симуляция — истина последней инстанции о логике (часть IV никуда не делась: гипотезы с железа мы будем перепроверять в симуляторе).

LCD-подсистема с птичьего полёта

LCD-тракт — отдельная большая работа, проделанная по всем правилам этой статьи (свои контракты, свой testbench tb_lcd_top, свои контрольные точки), но протокол дисплея — не тема нашего лонгрида. Поэтому — обзор с птичьего полёта, ровно настолько, чтобы часть VI читалась; любопытным — исходники в rtl/lcd/, они комментированы не хуже SPI.

Панель AN430 ест параллельный RGB-поток: 24 бита цвета + DCLK + сигналы синхронизации HS/VS/DE — по сути, та же развёртка, что у VGA. Подсистема из шести модулей:

Модуль

Роль

lcd_timing

развёртка: счётчики x/y, HS/VS/DE — метроном

lcd_pattern

фоновые заливки (цветные полосы, градиент...)

lcd_seg7 + lcd_hex_display

рисуют hex-числа семисегментными цифрами

lcd_font5x7 + lcd_status_label

растровый шрифт и текстовая строка

lcd_reg_if

регистровая карта дисплея — близнец spi_reg_if

lcd_top

клей — близнец spi_master_top

Знакомая логика на каждом шагу — и это главное, что стоит вынести из таблицы. Тактирование — те же 50 МГц со стробом pix_en от делителя (приём из главы 6.5: DCLK — обычный регистр, никакого второго домена). Управление — та же vendor-neutral шина (глава 8.9): у LCD свои регистры (CONTROL, PATTERN, HEX0_VALUE, HEX1_VALUE, LABEL_CTRL...), и для хоста дисплей выглядит вторым периферийным устройством рядом с SPI — это станет важно в главе 26, где на шине появится мультиплексор двух устройств. Отрисовка — чистая комбинационка поверх развёртки: на каждый пиксель (x, y) приоритетный выбор «надпись? цифра? фон?» — никакого фреймбуфера, изображение генерируется на лету (и поэтому memory bits в сводке главы 24 — по-прежнему ноль).

Для bring-up нам из всего этого богатства нужны ровно два прибора:

  1. текстовая строка — lcd_status_label умеет писать на экране SPI: FOUND зелёным или SPI: NONE красным по одному входному биту;

  2. два hex-индикатора — 8 цифр каждый, пишутся в регистры HEX0_VALUE/HEX1_VALUE; в них поедут живые данные (сначала сырой ответ SPI, в главе 28 — координаты тача).

Принцип, ради которого написана эта глава

Сформулирую главный тезис части VI — он прост, но обходится дорого тем, кто узнаёт его опытным путём: Наблюдаемость проектируется до похода к железу — как часть дизайна, а не как реакция на проблему.

Сравните две хронологии. Реактивная: прошили → не работает → теперь начинаем встраивать диагностику → каждая попытка что-то увидеть требует правки RTL и пересборки → диагностика сама не проверена и врёт →  спираль. Проективная (наша): глаза построены и проверены в симуляции заранее → первая же прошивка сообщает о себе сама → каждый вопрос к железу — это запись в регистр дисплея, а не перестройка дизайна.

Есть и тонкий бонус, знакомый по части IV: средство наблюдения, проверенное независимо (LCD-тракт прошёл свой testbench и, забегая вперёд, заработал на железе с первого включения), становится точкой

опоры при локализации. «LCD живёт — значит, клок, сброс, питание и прошивка исправны» — это рассуждение в главе 27 одним махом снимет половину подозреваемых. Неработающий дисплей не смог бы снять ни одного.

Контрольная точка

Собираем и прошиваем spi_lcd — комбинированный проект (его QSF мы видели в главах 21–22). На экране: фоновый паттерн, оба hex-индикатора, статусная строка. Дисплей жив — приборная панель смонтирована и протестирована в железе, первый компонент проекта пересёк границу миров целиком.

Но панель пока показывает заглушки: на шине дисплея некому писать осмысленные данные — у нас вообще нет хоста. SPI-контроллер сконфигурировать тоже некому. Пора встроить в кристалл того, кто заменит CPU, — глава 26: ROM-секвенсор demo_init и FSM-опросчик spi_probe.

Глава 26. Шаг 13: автономный запуск — demo_init и spi_probe

Контроллеру нужен хост

Перечитайте сценарий из главы 7.6: «хост пишет CLK_DIV... хост пишет START... хост читает RX_DATA». Вся наша архитектура построена вокруг хоста — а его нет. В демо-прошивке нет CPU: Nios-процессор утащил бы статью в сторону на три главы, а внешний микроконтроллер — это ещё одна плата и ещё один источник ошибок на bring-up. 

Решение элегантно по-FPGA-шному: раз хост — это «кто-то, кто пишет и читает регистры по правилам», его можно синтезировать. Драйвер, отлитый в логике: две маленькие FSM, говорящие на нашей vendor-neutral шине (вот и четвёртый аргумент из главы 8.9 вышел на сцену). Разделение обязанностей — как у настоящего софта:

  • demo_init — «загрузчик»: один раз после сброса прописывает конфигурацию обеим подсистемам и навсегда умолкает;

  • spi_probe — «драйвер устройства»: бесконечный цикл опроса XPT2046 с публикацией результатов на дисплей.

Шина на двоих... точнее, на двоих с двух сторон

Прежде чем знакомиться с FSM, решим топологический вопрос: теперь у нас два устройства (SPI и LCD) и два хоста (init и probe). Шинная обвязка в spi_lcd_fpga_top решает обе задачи тривиальными средствами.  Выбор устройства — расширением адреса: к четырём битам регистра добавлены два бита «номера периферии»:

Декодер направляет wr_en тому, чей номер в старших битах, — это двухстрочный «системный интерконнект», младший брат Avalon-фабрики. 

Выбор хоста — без арбитра: хосты у нас не конкурируют, а сменяют друг друга навсегда. Пока init_done низкий, шиной владеет init; после — probe (его вход enable подключён к init_done). Простой мультиплексор по статическому признаку — never-fighting arbitration, лучший вид арбитража: тот, которого нет.

demo_init: прошивка, записанная в ROM

Конфигурационная последовательность — это данные, а не алгоритм. Логичная форма для них — таблица: ROM из пар «адрес → значение», которую крошечная FSM проигрывает по строке за такт. Сама таблица (сокращённо):

case (i)
   // ---- LCD: цвета, позиции hex-индикаторов, включение ----
   6'd0:  init_entry = {lcd_addr(`LCD_ADDR_BG_COLOR),  COL_BG};
   ...
   6'd6:  init_entry = {lcd_addr(`LCD_ADDR_CONTROL),   LCD_CTRL_VAL};
​
   // ---- SPI: делитель, выбор CS, длина слова, задержки, EN ----
   6'd7:  init_entry = {spi_addr(`SPI_ADDR_CLK_DIV),   32'd24};  // 1 МГц
   6'd8:  init_entry = {spi_addr(`SPI_ADDR_CS_SELECT), 32'd3};   // CS3 = тач
   6'd9:  init_entry = {spi_addr(`SPI_ADDR_WORD_LEN),  32'd8};
   6'd10: init_entry = {spi_addr(`SPI_ADDR_DELAY_CFG), 32'h0008_0808};
   6'd11: init_entry = {spi_addr(`SPI_ADDR_CONTROL),   SPI_CTRL_VAL};
endcase

Двенадцать строк — и обе подсистемы в рабочем состоянии. Сама FSM даже проще, чем обещало оглавление: счётчик idx бежит по таблице, выдавая по записи за такт (наша шина принимает запись каждый такт — ready всегда единица), а упёршись в конец — поднимает init_done и опускает wr_en навсегда.

Обратите внимание, что именно записано в SPI-строках — каждое число уже обосновано прошлыми главами: CLK_DIV=24 → SCLK = 50/(2·25) = 1 МГц — вдвое ниже потолка XPT2046 и комфортно для логического анализатора (глава 23 же предупреждала пересчитать SDC при смене точки — делитель 24

с запасом накрыт констрейнтом «worst case /8»); CS_SELECT=3 — тач висит на CS3; задержки 8 тактов — щедрость по даташиту. Конфигурация — это сумма решений, и теперь все они в одном файле, прямо напротив адресов. 

Паттерн «ROM-секвенсор» — кстати, один из самых переиспользуемых в FPGA-практике: инициализация SDRAM-контроллеров, конфигурация кодеков по I2C, загрузка коэффициентов фильтров — всё это та же таблица с шагающим индексом.

spi_probe: драйвер, отлитый в кремнии

Probe интереснее: он делает то, что в обычной жизни делает C-код драйвера, — и FSM читается как этот C-код, развёрнутый в состояния:

Пройдём цикл, узнавая знакомое:

  • S_CFG_WL дописывает WORD_LEN=24 (транзакция XPT2046 — 24 бита; подробный разбор протокола — в главе 28, сейчас достаточно «команда едет в старшем байте»).

  • S_PUSH кладёт в TX FIFO слово-команду: 0x00D0_0000 — запрос X-координаты, выровненный к старшему краю 24-битного слова. 

  • S_START пишет в CONTROL EN|START — одной записью, тем самым удобством из главы 8.

  • S_GAP — восемь тактов тишины перед опросом. Зачем? Вспомните баг №1 (глава 20): между START и подъёмом busy/DONE есть конвейерная задержка. Если опросить STATUS слишком рано, можно увидеть DONE… от прошлой транзакции. Probe знает уроки своей же статьи.

  • S_POLL крутит чтение STATUS до бита DONE — точная аппаратная копия task'а wait_done из главы 18. 

  • S_READ_RX забирает слово из RX_DATA (pop-on-read!), здесь же — правило детекции (раздел 26.5) и захват координаты.

  • S_ACK пишет ERROR_CLR — квитирует DONE и флаги, «чистый лист» перед следующей транзакцией: дисциплина драйвера из главы 8. Цикл повторяется для Y-канала (команда 0x0090_0000), затем S_LCD_X / S_LCD_Y публикуют обе координаты в HEX-регистры дисплея — два «обычных» шинных вызова, только адрес с префиксом 01.

  • S_DELAY — пауза ~84 мс (2²² тактов) до следующего скана: достаточно живо для глаз, необременительно для шины. Запомните эту цифру — у неё будет звёздная роль в главе 27. Там же объяснится и параметр CONTINUOUS, выкидывающий паузу вовсе.

Правило детекции: физика вместо магии

Самая содержательная строчка probe — критерий «устройство присутствует»: 

detected <= (p_rdata[15:0] != 16'hFFFF)
        && (p_rdata[15:0] != 16'h0000);

Это прямое следствие подтяжки из главы 22.4 — теперь все три случая различимы физически:

Что на линии MISO

Что прочитает мастер

Вердикт

Модуль отключён, линия в подтяжке

сплошные единицы (0xFFFF)

NONE

Линия коротнута / устройство мертво держит 0

сплошные нули

NONE

Живой XPT2046 отвечает

BUSY-бит + 12 бит ADC: что-то посередине

FOUND

Живой чип не может ответить ни сплошными единицами, ни сплошными нулями: в его 16 ответных битах всегда есть структура (нулевой BUSY-бит на фоне ненулевого ADC или наоборот). Детекция не требует ни специальной команды, ни регистра-ID, которого у XPT2046 и нет, — только понимания, как выглядит отсутствие ответа. Этот приём («подтяжка + проверка на крайние значения») работает с большинством простых SPI-устройств и стоит того, чтобы украсть его в личную коллекцию.

Замыкание петли — сначала в симуляции

Бит detected уезжает в lcd_top, и lcd_status_label рисует на экране зелёное SPI: FOUND или красное SPI: NONE. Петля наблюдаемости замкнулась: состояние SPI-тракта стало видно с другого конца комнаты, без единого прибора.

Но прежде чем бежать к плате — рефлекс, выработанный частью IV: вся автономная конструкция (init → демультиплексор → SPI → probe → LCD) обязана пройти симуляцию. Для этого в Makefile живёт третья цель — make sim_int с testbench'ем tb_spi_lcd_top: на провода SPI вешается поведенческая модель XPT2046, и тест проверяет последовательность инициализации, корректность 24-битных транзакций, работу правила детекции (с моделью — FOUND, без неё — NONE) и появление координат в HEX-регистрах. Интеграционный тест зелёный — значит, логика всего автономного стека верна, и любые проблемы на железе следует искать ниже логики: в пинах, проводах и приборах. Это разграничение — не педантизм; через страницу оно станет единственной твёрдой почвой под ногами.

Контрольная точка — с обрывом

Прошиваем плату. Ожидание: цветные полосы, два hex-числа, зелёное  SPI: FOUND. Реальность: цветные полосы — есть. Hex-числа — есть. Надпись — красная, SPI: NONE. Тач-модуль подключён, прошивка свежая, симуляция зелёная. Цепляем логический анализатор на SPI-пины, чтобы посмотреть на транзакции глазами. Запускаем захват.

На пинах — тишина. Ни SCLK, ни CS, ни единого фронта. Вообще ничего.

Симуляция говорит «всё работает». Железо говорит «сигналов нет». Оба правы — и выяснение того, как такое возможно, займёт следующую главу целиком. Это была лучшая школа отладки за весь проект.

Глава 27. Хроника отладки на железе: «логический анализатор ничего не видит»

Исходная картина и первый акт локализации

Зафиксируем улики на момент начала расследования:

  • симуляция (sim_spi, sim_lcd, sim_int) — вся зелёная;

  • LCD работает: полосы, hex-цифры, надпись (красная);

  • на экране SPI: NONE — probe не видит XPT2046;

  • логический анализатор на SPI-пинах не видит ничего: ни SCLK, ни CS, ни одного фронта за минуты захвата.

Первый ход — не гипотеза, а инвентаризация доказанного. Живой LCD — это не просто «приятно»: это работающий свидетель (глава 25 обещала, что он пригодится), и его показания снимают с подозрения целый этаж системы. Если дисплей рисует — значит: питание в норме, конфигурация загрузилась, 50 МГц тикают, сброс отпустил, demo_init отработал (картинка-то сконфигурирована — цвета и позиции из его таблицы!), шина и демультиплексор живы. Значит, и probe почти наверняка ходит по шине — он висит на той же инфраструктуре. 

Итого, проблема зажата в узком слое: между регистрами spi_engine и щупами анализатора. В этом слое живут трое подозреваемых: время (сигналы есть, но мы их не ловим), электрика (сигналы убиты физикой) и топология (сигналы не там, где мы смотрим). Допрашивали в этом порядке — и порядок, забегая вперёд, был неправильным. Но поучительным.

Подозреваемый №1: время. Скважность 0,012%

Посчитаем, как выглядит активность SPI во времени. Один скан probe — две транзакции по 24 бита. На SCLK 5 МГц (а в первой версии demo_init стоял CLK_DIV=4 — да, в главе 26 вы видели уже исправленное значение, это шрам как раз отсюда) транзакция — около 5 мкс, пара — порядка 10 мкс с паузами. Период скана — 84 мс. Доля времени, когда на шине хоть что-то происходит:

10 мкс / 84 мс ≈ 0.012%

Теперь — как работает дешёвый логический анализатор в режиме free-run: захватил буфер, отрисовал, захватил следующий. Буфер на типичных настройках — миллисекунды. Вероятность, что окно захвата накроет 10-микросекундный всплеск, — единицы процентов; глазами на живом экране это выглядит как идеально мёртвые линии. Сигнал может быть — а видно не будет.

Гипотеза уважаемая, и лечится она двумя путями сразу. Правильный путь — триггер: ставим запуск по спаду CS (начало транзакции) и ждём. Путь для верности — поднять скважность до небес: в spi_probe появился параметр CONTINUOUS, выкидывающий паузу S_DELAY — сканы идут впритык, шина активна почти 100% времени, и никакой триггер не нужен — мимо такого не промахнёшься.

Пересборка с CONTINUOUS=1, триггер по CS... тишина. По-прежнему ни фронта. Подозреваемый №1 не виновен — но допрос не был бесполезным: теперь мы знаем, что сигналов нет физически, а не статистически. (А параметр остался в проекте — он ещё пригодится любому будущему bring-up.)

Подозреваемый №2: электричество. 5 МГц против 2

Параллельно — смежная мысль: даташит XPT2046 разрешает SCLK до ~2 МГц, а мы гоним 5. Перебор частоты объяснил бы «NONE» (чип не понимает запросы)... хотя — стоп — не объяснил бы пустоту на анализаторе: SCLK-то генерируем мы, его бы видели в любом случае. Уже по этой логике подозреваемый №2 не мог быть главным — но раз пересборка всё равно нужна, прибрались и тут: CLK_DIV=24, SCLK = 1 МГц (то самое значение из главы 26), заодно комфортнее для семплирования анализатором. Пересборка. Тишина. Два подозреваемых оправданы, узел сужен до предела: сигналы либо не доходят до пинов, либо мы смотрим не на те пины.

Поворот: допрос уровней покоя

Тут следствие сделало ход, который надо было делать первым: вместо охоты за фронтами посмотреть на статические уровни — они-то видны без всякого триггера. Мультиметр или тот же LA в DC-режиме, все шесть линий:

SCLK = 0    ожидалось 0 (CPOL=0)      совпало
MOSI = 0    ожидалось 0 (покой)       совпало
CS   = 0    ожидалось 1 (активный 0!) ПРОТИВОРЕЧИЕ
MISO = 0    ожидалось 1 (подтяжка!)   ПРОТИВОРЕЧИЕ
BUSY = 0    допустимо                  --
PENIRQ = 1  ожидалось 1 (подтяжка)    совпало

Две улики кричат. CS# в нуле в покое — наш engine держит все CS в единице, когда не работает (S_IDLE, глава 14.4); ноль на этом паде означает, что этим падом управляет не engine. MISO в нуле — на входе с включённой подтяжкой (глава 22.4) обязана быть единица; ноль означает, что подтяжки на этом паде нет — то есть это не тот пад, которому мы её назначали... или назначение не применилось.

Обе улики показывают в одну сторону: топология. Мы смотрим на правильные точки платы — но сигналы FPGA живут не там.

Подозреваемый №3: QSF. Место преступления

Открываем spi_lcd.qsf — не Pin Planner, а сырой текст (глава 21 настаивала не зря) — и ищем все назначения SPI-сигналов: 

$ grep spi_ quartus/spi_lcd.qsf
set_location_assignment PIN_F8  -to spi_sclk
set_location_assignment PIN_D8  -to spi_mosi
set_location_assignment PIN_E9  -to spi_miso
set_location_assignment PIN_T11 -to spi_cs_n[0]   <-- ?!
set_location_assignment PIN_T12 -to spi_cs_n[1]   <-- ?!
set_location_assignment PIN_T13 -to spi_cs_n[2]   <-- ?!
set_location_assignment PIN_T14 -to spi_cs_n[3]   <-- ?!
set_location_assignment PIN_R12 -to spi_irq

Вот оно. Помните комментарий PLACEHOLDERS из главы 21 и врезку 22? Оба ружья выстрелили одновременно, и вторым зарядом:

Улика А: размерность. Назначения CS — на шину spi_cs_n[0..3]. Но в комбинированном топе порт давно стал скаляром spi_cs_n (у тача один CS) — все четыре строки не совпали с портом по имени, и Quartus молча выбросил их, а скалярный порт расставил на какой-то свободный пин по своему вкусу. Туда же отправились spi_busy и spi_penirq, которых в пиновой секции вообще не было (порты добавились позже файла). Вот и CS=0 на T11: этим падом владел вовсе не CS.

Улика Б (и причина, по которой даже «правильные» строки не спасли): часть назначений-заглушек осталась от другой ревизии распиновки — пины вроде бы заданы, но не те, что на актуальной схеме подключения модуля. Сверка колонки QSF с таблицей пинов из README (глава 22) дала расхождения и там. А warning? Был. Ignored assignments: 4 — честно лежал в отчёте фиттера среди трёх сотен соседей. Мы его не прочитали: политика «ноль новых предупреждений» из главы 24 была сформулирована после этой истории — собственно, ею.

Фикс — переписать пиновую секцию: скалярный spi_cs_n на E7, добавить spi_busy (C8) и spi_penirq (E8), сверить каждую строку с таблицей README, прогнать тройной контроль из главы 22.6 (Pin Planner → .pin-файл → Ignored Assignments = 0). Пересборка, прошивка… 

На анализаторе — пачки аккуратных 24-битных транзакций, SCLK 1 МГц, CS обрамляет каждую пару. На экране — зелёное SPI: FOUND. Неделя охоты. Десять строк в текстовом файле.

Инструмент, рождённый охотой: DIAG_PINWALK

Пока шло следствие, был построен инструмент, который жалко не показать — тем более что он закрывает класс задач «физика или логика?» одним экспериментом. Идея: вывести из подозрения всю логику разом, подав на SPI-пины простейшие сигналы мимо всего дизайна:

// spi_lcd_fpga_top, параметр DIAG_PINWALK
reg [25:0] diag_cnt;
always @(posedge clk_50m or negedge rst_n_sync)
   if (!rst_n_sync) diag_cnt <= 26'd0;
   else             diag_cnt <= diag_cnt + 26'd1;

assign spi_sclk = DIAG_PINWALK ? diag_cnt[4] : eng_sclk;  // ~1.5 МГц
assign spi_mosi = DIAG_PINWALK ? diag_cnt[6] : eng_mosi;  // ~390 кГц
assign spi_cs_n = DIAG_PINWALK ? diag_cnt[8] : eng_cs;    // ~98 кГц

Меандры с разных разрядов счётчика — у каждого пина своя частота, поэтому по картинке на анализаторе сразу видно, какой именно сигнал куда попал (а не просто «что-то моргает»). Интерпретация бинарна: 

  • меандры видны → пады, QSF, щупы и анализатор исправны → проблема в логике дизайна;

  • меандров нет → логика ни при чём → QSF/пайка/щупы/настройки LA.

Один параметр, одна пересборка — и пространство гипотез делится точно пополам. В нашей истории до системного применения pinwalk не дошло — улики уровней покоя успели раньше, — но он остался в топе проекта как штатный диагностический режим, и в чек-листе ниже у него почётное место.

Мораль

Три урока этой охоты, в порядке убывания цены:

Самая дешёвая проверка — первой. Уровни покоя (две минуты) закрывали дело сразу; мы же начали с гипотезы, потребовавшей пересборок и новых параметров. Соблазн «умной» гипотезы перед «тупой» проверкой — профессиональная слабость, тренируйте обратный рефлекс.

Зелёная симуляция — это алиби логики, и только логики. Она сузила поиск (мы ни разу не усомнились в FSM — и правильно), но не говорила ничего о слое между нетлистом и медью. Знайте границы своих доказательств.

Инструменты тоже под подозрением. Анализатор, который «ничего не видит», сам входит в список подозреваемых (пп. 5–7) — наравне с дизайном. Половина нашей недели ушла на сигналы, которых и не могло быть видно в free-run — мы отлаживали дизайн через прибор, которому ещё не научились доверять. Дальше — вознаграждение за неделю: устройство найдено, шина живёт, и можно наконец заняться тем, ради чего всё затевалось, — прочитать с тача настоящие координаты. Глава 28, последняя техническая.

Глава 28. Шаг 14: читаем координаты тача

Награда за неделю охоты

SPI: FOUND на экране означает скромную вещь: чип ответил чем-то, отличным от молчания. Пора заставить его отвечать чем-то осмысленным — координатами касания. Это последний технический шаг проекта, и он же — первый момент, когда вся вертикаль работает на полную: палец давит на плёнку резистивной панели, XPT2046 оцифровывает напряжение, наш SPI master вытаскивает биты по проводам, probe раскладывает их по регистрам, LCD рисует число. Семь глав реализации, пять глав верификации — ради того, чтобы ткнуть пальцем в экран и увидеть, как меняются цифры.

Приятная новость: ничего нового строить не придётся. Весь механизм — FSM spi_probe из главы 26 — уже умеет гонять транзакции и публиковать результаты. Осталось понять, что посылать и как читать ответ. То есть — наконец сесть и прочитать даташит XPT2046 не по диагонали.

Командный байт XPT2046: восемь бит, пять полей

XPT2046 (клон ADS7846 от Texas Instruments — даташит TI местами подробнее, читайте оба) — это 12-битный АЦП с аналоговым мультиплексором на входе. Каждая транзакция — это «приказ + ответ»: master посылает командный байт, чип в ответ выдаёт результат преобразования. Командный байт устроен так (MSB first):

Наши две команды раскладываются по полям так:

0xD0 = 1 101 0 0 00   ->  S=1, канал X, 12 бит, DFR, power-down

0x90 = 1 001 0 0 00   ->  S=1, канал Y, 12 бит, DFR, power-down

Три неочевидных выбора, каждый — со следствием:

  1. DFR (дифференциальный) режим, а не single-ended: в DFR опорой АЦП служат сами драйверы панели, поэтому результат не зависит от дрейфа внутреннего опорного напряжения. Для резистивного тача даташит прямо рекомендует DFR — single-ended оставьте измерению температуры и батарейки (у чипа есть и такие каналы — A2:A0 = 000/111). 

  2. 12 бит, не 8: полная точность, всё равно бесплатно — биты те же. 

  3. PD = 00: между преобразованиями чип засыпает, и — важный побочный эффект — в этом режиме активен выход PENIRQ. Выберите PD=11 («всегда включён») — и PENIRQ замолчит навсегда, а вы будете отлаживать «сломанный» провод. Классическая ловушка из errata-форумов.

Анатомия 24-битного кадра

Сколько тактов SCLK занимает «приказ + ответ»? Командный байт — 8. Дальше чипу нужен один такт на захват выборки — в этом такте он выставляет на MISO свой BUSY-бит. Потом 12 бит результата, MSB first. Итого 8 + 1 + 12 = 21 такт. Некруглое число; канонический кадр из даташита — 24 такта (три байта), хвост чип добивает нулями:

И вот ради чего строился контроллер с произвольной длиной слова (глава 8: WORD_LEN от 1 до 32): мы просто пишем WORD_LEN = 24 — и весь кадр, обе половины диалога, прокатывается одной транзакцией под одним CS. Никаких склеек из трёх байтовых обменов, никаких хитростей с удержанием CS между словами.

Теперь главный практический вопрос: где в принятом 32-битном слове лежит результат? Принятые биты въезжают в RX-регистр сдвигом, и после 24 тактов картина такая: старшие 8 бит (rx[23:16]) — мусор, принятый во время передачи команды (MISO в это время в высоком импедансе — с нашей подтяжкой это будут единицы); дальше BUSY; дальше данные; хвост: 

rx[23:16]  хлам времён командного байта (с pull-up читается 0xFF)
rx[15]     BUSY-бит
rx[14:3]   D11..D0 -- 12-битный результат АЦП
rx[2:0]    нули-добивка

Отсюда и строка, которую вы уже видели в главе 26, — теперь она расшифрована до бита:

touch_x  <= p_rdata[14:3];

И отсюда же — вторая жизнь правила детекции rx != 0xFFFF && rx != 0x0000 (глава 26): живой чип обязан притянуть BUSY и старшие нули результата вниз, мёртвая линия с подтяжкой даст сплошные единицы, короткое замыкание — сплошные нули. Правило не выдумано — оно прямо следует из анатомии кадра (глава 26 давала его «на веру», теперь вывод полный).

Двухканальный опрос: флаг chan вместо второй FSM

Координат две, а конвейер «push → start → poll → read» один. Дешёвое решение — не плодить состояния, а добавить один флаг chan (0 = X, 1 = Y) и прокрутить тот же конвейер дважды:

Флаг подменяет ровно две вещи. Какую команду класть в TX-FIFO:

           S_PUSH: begin
               p_wr_en = 1'b1;
               p_addr  = {2'b00, `SPI_ADDR_TX_DATA};
               p_wdata = chan ? CMD_Y : CMD_X;
           end

И в какой регистр складывать ответ:

               S_READ_RX: begin
                   last_raw <= p_rdata;
                   pen_down <= ~penirq_lvl;
                   if (chan == 1'b0) begin
                       // X channel: capture coordinate + run presence test.
                       touch_x  <= p_rdata[14:3];
                       detected <= (p_rdata[15:0] != 16'hFFFF)
                                && (p_rdata[15:0] != 16'h0000);
                   end else begin
                       // Y channel.
                       touch_y  <= p_rdata[14:3];
                   end
                   state <= S_ACK;
               end

Заметьте: тест присутствия гоняется только на X-канале — одного раза за скан достаточно, а Y-ветке остаётся чистая работа. Y-проход идёт сразу после X-прохода без перезаписи WORD_LEN (S_CFG_WL выполняется один раз при старте — длина у обоих кадров одинаковая) и с тем же страховочным S_GAP после START (его смысл разобран в главе 26.4).

Публикация — два последних штриха конвейера: X в HEX0, Y в HEX1 (адреса с префиксом 2'b01 — через демультиплексор шины в LCD, глава 26.2). Экран обновляется каждый скан, без всяких условий — живые цифры, и это осознанный выбор: фильтровать «только при касании» означало бы спрятать от себя самую полезную отладочную информацию (см. 28.5). Параллельно из PENIRQ защёлкивается pen_down — уровень уже прошёл 2FF-синхронизатор в топе (глава 6), здесь только инверсия активного нуля.

Проверка пальцем: читаем цифры как осциллограмму

Прошиваем, смотрим на экран. Протокол испытаний — три позы: 

  1. Покой (палец в воздухе). HEX0/HEX1 показывают значения у рельсов — около 000 или FFF, и заметно дрожат в младших разрядах. Это норма, а не баг: без нажатия резистивные слои не соприкасаются, измеряемый узел практически висит в воздухе, и АЦП честно оцифровывает наводки. Дрожащий мусор в покое — признак живого аналогового тракта. Вот почему мы не стали прятать цифры за pen_down: ровный замороженный ноль выглядел бы «чище», но не отличался бы от мёртвой системы. Дрожь — это сердцебиение.

  2. Нажатие в центре. Цифры мгновенно стабилизируются в середине диапазона — что-то около 0x800 ± 0x100 по обеим осям. Стабильность — ключевое: слои прижаты, цепь низкоомная, шум падает на порядок. 

  3. Обход углов. Ведём палец в каждый угол и записываем показания. Типичная картина для этой панели: одна ось ходит примерно от ~0x0E0 до ~0xF20, вторая — похоже, но в другую сторону (растёт влево или вниз). Инверсия осей, перестановка X↔Y, неиспользуемые поля по краям — всё это нормально: ориентация плёнки тача никак не обязана совпадать с разверткой LCD. Это не чинится в RTL — это чинится калибровкой.

Если вместо этой картины вы видите константу FFF без дрожи — MISO оторван (подтяжка рисует единицы); константу 000 — короткое на землю или мёртвый чип; осмысленный X при мусорном Y — обрыв одного из четырёх проводов панели (X+/X-/Y+/Y- — у резистивного тача каждая ось запитывается своей парой). Цифры на экране в этот момент — ваш осциллограф.

Чего здесь сознательно нет

Сырой АЦП — это не пиксели. Чтобы из 0x0E0..0xF20 получить 0..479, нужна калибровка: масштаб, смещение, возможно поворот — минимум аффинное преобразование по двум-трём опорным точкам. Нужна и фильтрация (медиана по 3–5 выборкам убивает выбросы на отрыве пальца), и debounce по PENIRQ. Всё это — арифметика поверх работающего канала, осознанно вынесенная за рамки проекта: наша задача была — SPI-канал, и он работает. Идеи развития, включая калибровку, ждут в главе 30.

Контрольная точка: проект работает

Финальная фотография системы. На плате: цветные полосы, надпись SPI: FOUND зелёным, два hex-числа, дрожащие в покое и послушно бегающие за пальцем. Внутри: demo_init отыграл конфигурацию с ROM,

spi_probe крутит вечный цикл из пары 24-битных транзакций, SPI master дёргает ножками строго по даташиту XPT2046, LCD-контроллер рисует всё это на 480×272. Снаружи: логический анализатор показывает учебно красивые кадры — CS вниз, 24 такта SCLK, команда на MOSI, через бит BUSY — ответ на MISO.

Путь от пустого каталога с spi_defs.vh до пальца на экране пройден полностью. Осталось оглянуться: посчитать, во что это обошлось кристаллу, честно перечислить ограничения и прикинуть, куда проект растёт дальше. Этим займётся заключительная часть.

Часть VII. Итоги и развитие

Глава 29. Что получилось: цифры и ограничения

Инвентаризация: что лежит в репозитории

Проект закончен — в том смысле, в каком бывают закончены инженерные проекты: он делает заявленное, и мы знаем, чего он не делает.

Прежде чем говорить об ограничениях и планах, проведём инвентаризацию.

Код. Около 3000 строк синтезируемого Verilog: ~1800 — SPI-ядро с обвязкой (spi_engine со всей протокольной машинерией, два spi_fifo, spi_reg_if, топы, demo_init, spi_probe) и ~1200 — LCD-подсистема (тайминг, паттерны, hex-индикатор, шрифт 5×7 со строкой статуса, регистровый интерфейс). Ещё ~1300 строк — тестбенчи, включая независимую референс-модель slave. Соотношение «тесты : дизайн» — примерно 1 : 2.3; для учебного проекта прилично, в индустрии верификация нередко превышает дизайн по объёму.

Тесты. 29 самопроверяющихся прогонов: 18 SPI-кейсов (38 проверок — матрица режимов × порядков бит × длин слова × burst × ошибки × IRQ, глава 18), 7 LCD-тестов (тайминг кадра, регистры, паттерны) и 4 интеграционных (полный автономный стек demo_init + spi_probe + обе подсистемы, глава 26.6). Всё гоняется одной командой make, полный прогон — секунды, лицензий не требует.

Документация. Карта регистров с контрактами поведения (глава 8), README с таблицей распиновки, SDC с комментариями-выводами из даташита. Не «бумага ради бумаги»: каждым из этих документов мы пользовались в ходе отладки — README-таблица, например, закрыла дело главы 27.

Цена в кремнии: повторим главное

Сводку фиттера мы подробно разобрали в главе 24.2; здесь — выжимка для итоговой картины. Полная прошивка (SPI-ядро + LCD + автономные FSM) на EP4CE6 — самом младшем кристалле семейства:

Total logic elements   : 1,743 / 6,272 ( 28 % )
Dedicated registers    :   854 / 6,272 ( 14 % )
Memory bits / PLLs     :     0  (всё в LE, клок один)
Worst setup slack      : +1.032 ns (clk_50m), timing met

Два вывода, ради которых эти числа стоит держать в голове. Первый: контроллерная логика дешева — полнофункциональный SPI master со всеми режимами, FIFO и регистровым интерфейсом — это лишь часть от четверти младшего кристалла; страх «не влезет» при проектировании управляющей логики почти всегда беспочвенен. Второй: нули в строках memory и PLL — это не пустота, а подпись под архитектурой: FIFO сознательно в LE (глава 12.4), клок сознательно один (глава 6). Сводка фиттера — последний пункт, где намерения сверяются с реальностью.

Демонстрация: что видно глазами

Если снимать проект на видео, кадр такой. Подача питания: мгновение чёрного экрана (LCD-тайминг стартует сразу, demo_init отрабатывает за микросекунды — для глаз это «сразу»), затем цветные полосы, надпись и два hex-числа. Надпись — зелёная SPI: FOUND; числа мелко дрожат в младших разрядах (глава 28.5 объяснила, почему это хороший знак). Палец на экран — числа замирают на средних значениях и плавно ездят за пальцем по обеим осям. Рядом на логическом анализаторе — пары 24-тактных кадров с паузой 84 мс: команда на MOSI, ответ с BUSY-битом на MISO, CS аккуратно обрамляет каждый кадр.

Отдельно стоит показать negative-тест, доступный без единой строчки кода: вытащить шлейф тача. Через один скан (≤ 84 мс) надпись становится красной SPI: NONE — детекция работает в обе стороны, в точности как в интеграционном тесте с «обрывом» из главы 26.7, только обрыв теперь настоящий.

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

Хорошая привычка — заканчивать проект списком того, чего он не умеет, пока память свежа. Наш список, с пояснением «почему так» у каждого пункта:

  • Только master. Slave-режим — отдельная задача с принципиально другой тактовой дисциплиной: SCLK приходит извне, и аккуратно переносить его в наш домен (или честно тактироваться от него) — та самая CDC-история, от которой глава 6 нас сознательно увела.

  • FIFO на LE, глубина 8. Для XPT2046 хватает с запасом; для потоковой работы с SPI-flash нужны сотни слов — это M9K и, возможно,  возврат к registered-чтению со всеми последствиями из главы 12.

  • Нет DMA. Каждый байт проходит через регистровый интерфейс. При 1–5 МГц SCLK это никого не беспокоит; при 25+ МГц и больших блоках хост станет узким местом.

  • Polling, а не прерывание от PENIRQ. spi_probe сканирует тач по таймеру даже когда панель никто не трогает. Для демо — неважно, для батарейного устройства — расточительно (и сам XPT2046 предлагает решение, см. главу 30).

  • Сырой ADC без калибровки и фильтрации. Координаты на экране — в попугаях АЦП, не в пикселях; выбросы при отрыве пальца не подавляются. Граница проведена сознательно (глава 28.6).

  • Vendor-neutral шина — и потому ничья. Подключение к NIOS II / Avalon-MM или к AXI-периферии требует моста-адаптера. Тонкого — но требует.

  • Один клок 50 МГц — и невысокий потолок SCLK. Формально при CLK_DIV=0 получается 25 МГц (полупериод = CLK_DIV+1 тактов), но двухтактная задержка синхронизатора MISO (глава 6.4) съедает на таких полупериодах весь запас по сэмплированию; практический потолок — единицы–десяток МГц. Цена переносимости и простоты SDC; для быстрых QSPI-flash пришлось бы пересматривать.

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

29.5. КОНТРОЛЬНАЯ ТОЧКА (финальная)

Система работает на столе и описана честно: 29 зелёных тестов, timing met, палец двигает цифры, выдернутый шлейф меняет надпись. Известны цена (28% младшего кристалла) и границы применимости (список выше). Осталось два разговора: куда этот проект может расти — и какие уроки из него стоит унести в следующий.

Глава 30. Куда расти

Список ограничений — это и есть roadmap

Глава 29 закончилась списком границ; эта глава — о том, что лежит за каждой из них и почём. Идеи отсортированы по соотношению «польза / трудоёмкость»: первые делаются за вечер, последние — материал на отдельный такой же проект. Для каждой укажем не только что делать, но и что в существующем коде уже к этому готово — потому что половина решений, принятых в частях II–III, принималась с прицелом именно сюда.

Вечер работы: мост на Avalon-MM / AXI4-Lite / Wishbone

Помните спор в главе 8.9 — почему шина vendor-neutral, а не сразу Avalon? Вот где этот вексель предъявляется к оплате. Наш интерфейс — addr / wr_en / wdata / rd_en / rdata / ready — семантически почти изоморфен любой простой системной шине, и мост пишется как тонкая комбинационная прослойка плюс пара регистров:

Avalon-MM (для NIOS II в Platform Designer): write → wr_en, read → rd_en, waitrequest = !ready. Десяток строк-обёртка плюс _hw.tcl-описание компонента — и контроллер появляется в каталоге Platform Designer как полноправная периферия.

AXI4-Lite — многословнее (пять каналов, два handshake на каждую операцию), но логика та же; FSM моста — состояний пять. Главная тонкость: AXI разрешает write-address и write-data приходить в разных тактах — их нужно собрать воедино перед нашим wr_en. 

Wishbone — самый прямой: stb & we → wr_en, ack = ready. Дешевизна — следствие дисциплины: контракты регистров (sticky, W1C, pop-on-read) живут за интерфейсом, в spi_reg_if, и ни один мост их не трогает. Прицепив мост, обязательно прогоните старые тестбенчи через него же — тест contract'ов бесплатно превращается в тест моста.

Вечер работы: гейтинг опроса по PENIRQ

Самое дешёвое улучшение по энергии и трафику шины. Сейчас spi_probe сканирует тач по таймеру всегда; но XPT2046 сам сообщает о касании проводом PENIRQ (он уже заведён, синхронизирован и даже отображается в pen_down — вся инфраструктура готова). Изменение — одно условие в FSM: из S_DELAY выходить не по таймеру, а по penirq_lvl == 0, и пока палец на панели — сканировать быстро (например, каждые 10 мс для плавного трекинга), а без касания — спать.

Одна ловушка, ради которой стоит перечитать даташит: во время собственно преобразования PENIRQ дребезжит (чип сам коммутирует обкладки панели), поэтому уровень нужно игнорировать, пока идёт транзакция, и пересэмплировать после паузы. Решается выдержкой в несколько сотен микросекунд после конца скана — ещё один счётчик.

Выходные: калибровка тача — из попугаев в пиксели

Глава 28.6 провела границу: сырой АЦП, не координаты экрана. Снятие этой границы — приятная, почти софтверная задача. Минимальная версия — линейная по каждой оси:

x_px = (x_adc - X_MIN) * 480 / (X_MAX - X_MIN)

X_MIN/X_MAX снимаются один раз обходом углов (глава 28 — мы их, по сути, уже измерили). Деление на константу синтезатор превратит в умножение со сдвигом — и вот зачем кристаллу 30 умножителей 9×9, которые до сих пор стояли нулём в сводке фиттера. Честная версия — аффинное преобразование по трём опорным точкам (учитывает и перекос осей), коэффициенты вычисляет хост или однократная FSM в режиме калибровки, а кресты-мишени на экране умеет рисовать уже существующий LCD-тракт. Сюда же — медианный фильтр по 3–5 выборкам против выбросов на отрыве пальца: пять регистров и сеть сравнений. Демо после этого становится законченным устройством: палец рисует точку под собой, а не число в углу.

Неделя: FIFO на M9K и DMA

Две границы из главы 29.4 снимаются вместе, потому что обе — про потоковые объёмы. M9K-FIFO: глубины в сотни слов на LE-регистрах  неподъёмны, а один блок M9K (9 кбит) даёт 256×32 почти даром. Цена известна из главы 12.4: блочная память читается регистрово, данные появляются тактом позже — это ломает контракт pop-on-read для RX_DATA. Канонический обход — «show-ahead»: FIFO заранее держит на выходе следующее слово (Altera-мегафункция scfifo это умеет ключом lpm_showahead), и для шины всё выглядит по-старому. Хороший повод сравнить свой рукописный FIFO с вендорским — и узнать, почему в продакшене обычно берут вендорский.

DMA имеет смысл только после M9K и моста: движок, который сам таскает блоки между системной памятью и FIFO, разгружая процессор. Это первый пункт списка, который тянет на новый модуль размером с engine — со своей FSM, дескрипторами и собственной главой про верификацию.

Месяц: slave-режим и QSPI

SPI slave — не «зеркальный master», а другая дисциплина: SCLK приходит извне, асинхронно, и весь аппарат главы 6 надо передумывать. Два честных пути: oversampling (тактируемся своим клоком, ловим фронты SCLK синхронизаторами — работает до SCLK ≈ clk/6..clk/8) или тактирование приёмного регистра самим SCLK с CDC-передачей готовых слов через двухклоковый FIFO. Второй путь — это наконец настоящая многоклоковая разработка: gray-коды указателей, set_max_delay в SDC… Отличный «проект №3» после этой статьи.

QSPI — те же четыре провода данных вместо одного, но протокольно это другой мир: команды с фазами (instruction / address / dummy / data), переключение направления шины посередине кадра, табличное описание команд флешки. Engine придётся учить понятию «фаза транзакции» — расширение честное, но глубокое.

Следующий уровень доверия: формальная верификация

Наши 29 тестов показывают примеры корректной работы; формальные методы доказывают свойства для всех входов сразу. Открытый стек — SymbiYosys + SVA-подмножество — позволяет бесплатно проверить именно те свойства, которые в этом проекте охранялись дисциплиной и ревью:

// CS никогда не активен, пока engine в IDLE
assert property (@(posedge clk) (state == S_IDLE) |-> &spi_cs_n);

// READY не зависает: после запроса ответ не позже N тактов
assert property (@(posedge clk) rd_en |-> ##[1:4] ready);

// FIFO не теряет и не дублирует (свойство сквозного счётчика)

Особенно благодарная цель — FIFO: маленький, замкнутый, с известными инвариантами (count = wr - rd; never full && empty) — классический первый формальный проект. Бонус для нашей истории: формальный инструмент структурно независим от тестбенча — это тот же принцип независимой модели из главы 16, доведённый до предела: симметричный баг с ним невозможен по построению.

Что выбрать первым

Если продолжение одно: мост + калибровка — за выходные проект превращается из демо в переиспользуемую периферию с законченным touch-вводом. Если цель — учёба: slave-режим, потому что он заставит пройти весь маршрут этой статьи заново, но с CDC — следующей большой темой, которую мы здесь сознательно обошли. А формальную верификацию FIFO можно вставить в любой план — она маленькая и меняет взгляд на верификацию сильнее, чем любой дополнительный тест.

Глава 31. Главные уроки проекта

Маршрут с высоты: девять станций

Тридцать глав назад всё началось с одной фразы: «контроллер SPI для Cyclone IV». Сложим теперь пройденный маршрут в карту — она пригодится для любого проекта «от идеи до железа», не только для SPI:

ТЗ как источник тестов (гл. 2) и инвентаризация окружения (гл. 3): каждая строка требований сформулирована проверяемо — и позже стала строкой в матрице тестов; инструменты и плата изучены до старта.

Теоретический минимум (гл. 4–6): протокол, синтезируемое подмножество языка, и одно большое архитектурное решение — один тактовый домен, — принятое до первой строчки кода. 

Декомпозиция и контракты (гл. 7): четыре модуля, у каждого одна работа, интерфейсы оговорены до реализации.

Карта регистров как API (гл. 8): sticky, W1C, self-clearing, pop-on-read — поведенческие контракты, переживающие любой рефакторинг внутренностей.

RTL снизу вверх (гл. 10–15): от двухстрочного синхронизатора к engine, каждый модуль — со своим разбором решений и последствий.

Независимая верификация (гл. 16–20): референс-модель, написанная структурно иначе, самопроверяющиеся тесты, хроника багов.

Синтез и констрейнты (гл. 21–24): QSF как контракт с платой, SDC как контракт со временем, отчёты как сверка намерений с реальностью.

Наблюдаемость до железа (гл. 25–26): дашборд на LCD и автономные FSM спроектированы раньше, чем прошивка впервые поехала в плату.

Систематический bring-up (гл. 27–28): локализация вместо паники, уровни покоя, бинарные эксперименты — и работающее устройство. Заметьте асимметрию, которую скрывают учебники: на «написать Verilog» пришлось шесть глав из тридцати одной. Остальное — то, что превращает Verilog в работающее железо.

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

Каждое правило ниже стоило конкретных часов — ссылка ведёт к месту, где счёт был выписан.

  1. Референсная модель пишется структурно иначе, чем DUT. Общая идея (сдвиговый регистр, счётчик фаз) в модели и дизайне порождает общие баги, и тесты зеленеют над сломанным протоколом. Наша модель на событиях @(posedge sclk) против счётчиков полупериодов в engine — единственная причина, по которой симметричные CPHA/LSB-баги вообще были найдены.

  2. Комбинационное чтение — или задокументированная задержка. Третьего не дано. Регистровый rd_data FIFO, не отражённый в контракте шины, дал «прошлогодние» данные при pop-on-read (гл. 12.4, баг №2). Любая задержка интерфейса — часть API, а не деталь реализации.

  3. Стробы, а не уровни; квитирование, а не надежда. Self-clearing START, W1C-флаги, ERROR_CLR перед каждой транзакцией — всё это машинная форма одной мысли: событие должно быть подтверждено получателем, а не предположено отправителем (гл. 8.5–8.6, баг №1).

  4. QSF сверяется grep'ом после каждого изменения портов топа. Quartus молча игнорирует назначения, не совпавшие с портом по имени или размерности, и молча раскидывает осиротевшие сигналы по свободным ножкам. Неделя с логическим анализатором против тридцати секунд с grep (гл. 27.5).

  5. Наблюдаемость проектируется до железа. Когда прошивка молчит, добавлять «что-нибудь для отладки» уже поздно: непонятно, работает ли сама отладка. Наш LCD-дашборд был готов и проверен в симуляции до первой прошивки — и в час Х стал единственным источником правды (гл. 25, 27.1).

  6. Скважность — первое число перед походом с анализатором. 10 мкс активности на 84 мс периода — это 0.012%; free-run такое не ловит никогда, и линия выглядит мёртвой при живом дизайне. Посчитать скважность — одна минута; неделя поиска «пропавших» сигналов — дороже (гл. 27.2).

  7. Один бинарный эксперимент лучше десяти гипотез. DIAG_PINWALK — меандры мимо всей логики — делит пространство «физика или логика» ровно пополам за одну пересборку. Хороший отладочный шаг отличается не умностью гипотезы, а тем, сколько гипотез он убивает (гл. 27.6).

  8. Самая дешёвая проверка — первой. Уровни покоя мультиметром (две минуты) закрывали дело главы 27 сразу — но мы начали с гипотез, требовавших пересборок. Сортируйте проверки по цене, а не по интеллектуальной привлекательности (гл. 27.8).

  9. Один тактовый домен — пока не доказано обратное. Решение главы 6 окупалось на каждом этаже: тривиальный SDC, нулевая CDC-зона, PLL-строка в отчёте фиттера как подпись под архитектурой. Второй домен заводится тогда, когда без него нельзя, — и тогда он заслуживает собственного проекта (гл. 30.6).

  10. Тестовые данные не должны быть палиндромами. 0xA5 выглядит «случайным», но читается одинаково в обе стороны — и MSB/LSB-баг сквозь него проходит незамеченным. Подбирая константы для теста, спросите: какая симметрия данных может замаскировать какую симметрию бага? (гл. 18.3).

Если сжать десять правил в одно: не доверяйте ничему, что не проверено способом, независимым от проверяемого. Модель — независима от DUT, grep — от Pin Planner'а, мультиметр — от анализатора, формальный инструмент — от тестбенча. Инженерия — это организованное недоверие.

Что унести в следующий проект

Помимо уроков, из репозитория стоит унести вполне материальные вещи — они проектировались переиспользуемыми:

  1. Скелет репозитория и Makefile (гл. 9): rtl/ sim/ quartus/ docs/, явные списки файлов, цели sim_* / wave_* / clean. Заводится новый проект копированием и вычёркиванием.

  2. Модули-кирпичи: spi_reset_sync (нужен буквально в каждом дизайне), spi_fifo (синхронный, с phase-bit и sticky-флагами), lcd_* целиком — дашборд для следующего bring-up уже готов. 

  3. Стиль тестбенча (гл. 18): check()-счётчики, task-обёртки шины, итоговая строка PASS/FAIL — каркас не зависит от того, что тестируем.

  4. Соглашения карты регистров (гл. 8): sticky / W1C / self-clearing / pop-on-read — готовый словарь контрактов для любой периферии.

  5. Чек-листы: «нет сигналов на пинах» (гл. 27.7) и маршрут из 31.1 — распечатать и положить рядом с платой.

Последняя контрольная точка

В начале статьи был пустой каталог и желание «сделать SPI». В конце — плата на столе: палец давит на стекло, число на экране послушно бежит следом, логический анализатор рисует учебно-красивые кадры, make за четыре секунды подтверждает 29 раз, что ничего не сломано. 

Между этими двумя точками не было ни одного волшебного шага — только последовательность маленьких, каждый из которых проверяем: контракт → код → тест → отчёт → плата. Именно поэтому маршрут воспроизводим: замените SPI на I2C, UART или собственный протокол — станции останутся теми же. 

Пустой каталог и желание «сделать X» теперь есть у вас. Маршрут — выше. Удачи в реализации в железе.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (1)


  1. mozg37
    23.07.2026 10:42

    Забыл когда в последний раз юзал внешний лог анализатор. Внутрисхемный - очень удобно. В quartus это signaltap. В gowin - gao. После отладки в gao на альтеровские плис смотреть нет никакого желания.