Откройте любой учебный материал по 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 — встреча гипотезы с физическим миром, у которого есть паразитные ёмкости, подтягивающие резисторы и файлы конфигурации, которые вы забыли сохранить. Поэтому статья устроена так же, как устроена работа: четверть объёма — про то, как написать контроллер, и три четверти — про то, как убедиться, что он работает как надо, и что делать, когда он не работает.
Что вы будете уметь после прочтения
Прочитав статью целиком и повторив шаги руками, вы сможете:
Превратить размытую формулировку («нужно управлять SPI-устройствами») в техническое задание с измеримыми пунктами.
Разбить устройство на модули так, чтобы каждый можно было написать, протестировать и заменить независимо.
Спроектировать карту регистров, которой удобно пользоваться со стороны процессора, — с правильными соглашениями про sticky-биты, W1C и self-clearing стробы.
Написать синтезируемый Verilog без защёлок, гонок и прочей классики жанра.
Построить независимую верификацию: эталонную модель устройства, self-checking testbench, матрицу покрытия по режимам.
Пройти маршрут Quartus: проект, пины, временные констрейнты, чтение отчётов — и понять, какие предупреждения смертельны, а какие нет.
Системно отлаживать железо: локализовать проблему за минимальное число экспериментов, а не тыкать щупом наугад.
И, пожалуй, главное: у вас появится рабочий шаблон проекта — структура каталогов, 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-контроллера не придумать.
Шпаргалка главы
Уносим с собой шесть фактов — их хватит для всего проекта:
Четыре провода; SCLK и CS# всегда драйвит master; невыбранный slave отпускает MISO в Z.
SPI — это обмен: на каждое переданное слово приходит принятое.
На бит — два фронта: sample и shift. CPHA выбирает их порядок, CPOL — полярность клока. CPHA=0 требует выставить первый бит до первого фронта.
MSB-first почти везде; палиндромные байты маскируют ошибки порядка бит. Задержки CS-setup/hold и межсловные — не прихоть, а строки из даташитов; делаем их программируемыми.
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) работает с ней плохо; она чувствительна к глитчам комбинационной логики; и главное — вы её не просили. Случайная защёлка — это всегда баг, даже если сегодня он не стреляет.
Защита — два механических правила:
В комбинационном always первой строкой присваивайте default всем выходам блока (видно в примере из 5.3).
В каждом 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 и ещё не раз встретим.
Итог главы
Verilog — чертёж параллельно исполняемой комбинаторной логики в железе, а не программа.
Два вида always: @(posedge clk) с <= и @(*) с =. Не смешивать.
reg — не обязательно триггер; триггером его делает секвенциальный always.
Комбинационный блок без default-присваиваний = случайная защёлка = баг.
Сброс: асинхронная установка, синхронное снятие, модуль-синхронизатор.
#, initial, force — только в sim/. function — легальна и полезна.
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), и каждый — отдельная инженерная проблема:
Гонки и потерянные импульсы. Однотактный строб из быстрого домена медленный домен может просто не заметить — его фронт целиком уместится между двумя фронтами SCLK.
Временной анализ. Каждый клок нужно описать в SDC, каждый переход между доменами — снабдить constraint'ами. Для производного клока, который ещё и останавливается между транзакциями (SCLK не тикает, пока шина простаивает!), это упражнение нетривиально даже для опытных.
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). Если входной сигнал меняется ровно в этом окне, триггер может на какое-то время зависнуть между нулём и единицей — выходное напряжение болтается у порога, и схема за триггером читает не пойми что. Это метастабильность. Важно понимать три вещи:
Это не баг кода — это физика. Никакой Verilog не запретит slave переключить MISO в неудачный момент.
Метастабильное состояние рассасывается само — экспоненциально быстро. Подавляющее большинство случаев разрешается за доли наносекунды; нужно лишь дать триггеру время.
Опасна не сама метастабильность, а её распространение: если зависший выход триггера разойдётся по схеме (например, в 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 синхронизатор, и сырой сигнал не читает никто, кроме первого триггера. В нашем дизайне таких входов четыре, и у каждого будет свой синхронизатор:
Вход |
Откуда |
Где синхронизируется |
|
кнопка на плате |
spi_reset_sync (гл. 11) |
|
XPT2046 |
внутри spi_engine |
|
XPT2046 |
top-level |
|
XPT2046 |
top-level |
Однотактные стробы через 2-FF синхронизатор передавать нельзя (импульс может потеряться между фронтами) — но у нас, к счастью, все асинхронные входы — это уровни, так что хитрых схем с handshake не понадобится. Упоминаю, чтобы вы не применили рецепт за пределами его показаний.
А как же LCD с его 9 МГц?
В части про дисплей возникнет искушение номер два: пиксельный клок LCD — 9 МГц, «заведу-ка я PLL и отдельный клоковый домен под видеотракт». И это снова не нужно: применим тот же приём. Вся LCD-логика тактируется теми же 50 МГц, а пиксельные события задаёт строб pix_en от счётчика-делителя (50 / 5 = 10 МГц — в допуске панели). DCLK для дисплея, как и SCLK для SPI, — обычный выходной регистр. Один и тот же паттерн закрывает оба интерфейса проекта — это и есть признак правильно выбранной архитектуры.
Что мы только что купили
Подведём итог — что даёт проекту правило «один домен + стробы + 2-FF на входах»:
Корректность по построению. Внутри домена все переносы данных защищены статическим временным анализом — целый класс багов (самых злых: редких и невоспроизводимых) исключён архитектурно.
SDC из пяти строк вместо трактата о взаимоотношениях доменов. Меньше constraints — меньше шансов соврать инструменту.
Простая симуляция. Один клок в testbench, никаких искусственных рассинхронизаций. То, что мы проверим в части IV, будет соответствовать железу.
Переносимость. Никаких PLL и вендорских примитивов — дизайн переедет на любой FPGA, где есть 50 МГц.
Отлаживаемость. Когда в части VI мы будем смотреть сигналы логическим анализатором, у всех событий будет один временной базис.
Шпаргалка главы
Производный клок из логики (
always @(posedge sclk)) = второй домен, CDC, gated clock и боль. Не делаем.Один домен 50 МГц; медленные события — через clock enable (стробы от делителя). SCLK и DCLK — обычные выходные регистры.
Метастабильность — физика асинхронных входов; лечится временем на рассасывание.
Каждый асинхронный вход → 2-FF синхронизатор; сырой сигнал не читает никто. Для стробов этот рецепт не годится (у нас их и нет).
Задержку синхронизатора (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 — встреча гипотезы с физическим миром, у которого есть паразитные ёмкости, подтягивающие резисторы и файлы конфигурации, которые вы забыли сохранить. Поэтому статья устроена так же, как устроена работа: четверть объёма — про то, как написать контроллер, и три четверти — про то, как убедиться, что он работает как надо, и что делать, когда он не работает.
Что вы будете уметь после прочтения
Прочитав статью целиком и повторив шаги руками, вы сможете:
Превратить размытую формулировку («нужно управлять SPI-устройствами») в техническое задание с измеримыми пунктами.
Разбить устройство на модули так, чтобы каждый можно было написать, протестировать и заменить независимо.
Спроектировать карту регистров, которой удобно пользоваться со стороны процессора, — с правильными соглашениями про sticky-биты, W1C и self-clearing стробы.
Написать синтезируемый Verilog без защёлок, гонок и прочей классики жанра.
Построить независимую верификацию: эталонную модель устройства, self-checking testbench, матрицу покрытия по режимам.
Пройти маршрут Quartus: проект, пины, временные констрейнты, чтение отчётов — и понять, какие предупреждения смертельны, а какие нет.
Системно отлаживать железо: локализовать проблему за минимальное число экспериментов, а не тыкать щупом наугад.
И, пожалуй, главное: у вас появится рабочий шаблон проекта — структура каталогов, 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-контроллера не придумать.
Шпаргалка главы
Уносим с собой шесть фактов — их хватит для всего проекта:
Четыре провода; SCLK и CS# всегда драйвит master; невыбранный slave отпускает MISO в Z.
SPI — это обмен: на каждое переданное слово приходит принятое.
На бит — два фронта: sample и shift. CPHA выбирает их порядок, CPOL — полярность клока. CPHA=0 требует выставить первый бит до первого фронта.
MSB-first почти везде; палиндромные байты маскируют ошибки порядка бит. Задержки CS-setup/hold и межсловные — не прихоть, а строки из даташитов; делаем их программируемыми.
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) работает с ней плохо; она чувствительна к глитчам комбинационной логики; и главное — вы её не просили. Случайная защёлка — это всегда баг, даже если сегодня он не стреляет.
Защита — два механических правила:
В комбинационном always первой строкой присваивайте default всем выходам блока (видно в примере из 5.3).
В каждом 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 и ещё не раз встретим.
Итог главы
Verilog — чертёж параллельно исполняемой комбинаторной логики в железе, а не программа.
Два вида always: @(posedge clk) с <= и @(*) с =. Не смешивать.
reg — не обязательно триггер; триггером его делает секвенциальный always.
Комбинационный блок без default-присваиваний = случайная защёлка = баг.
Сброс: асинхронная установка, синхронное снятие, модуль-синхронизатор.
#, initial, force — только в sim/. function — легальна и полезна.
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), и каждый — отдельная инженерная проблема:
Гонки и потерянные импульсы. Однотактный строб из быстрого домена медленный домен может просто не заметить — его фронт целиком уместится между двумя фронтами SCLK.
Временной анализ. Каждый клок нужно описать в SDC, каждый переход между доменами — снабдить constraint'ами. Для производного клока, который ещё и останавливается между транзакциями (SCLK не тикает, пока шина простаивает!), это упражнение нетривиально даже для опытных.
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). Если входной сигнал меняется ровно в этом окне, триггер может на какое-то время зависнуть между нулём и единицей — выходное напряжение болтается у порога, и схема за триггером читает не пойми что. Это метастабильность. Важно понимать три вещи:
Это не баг кода — это физика. Никакой Verilog не запретит slave переключить MISO в неудачный момент.
Метастабильное состояние рассасывается само — экспоненциально быстро. Подавляющее большинство случаев разрешается за доли наносекунды; нужно лишь дать триггеру время.
Опасна не сама метастабильность, а её распространение: если зависший выход триггера разойдётся по схеме (например, в 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 синхронизатор, и сырой сигнал не читает никто, кроме первого триггера. В нашем дизайне таких входов четыре, и у каждого будет свой синхронизатор:
Вход |
Откуда |
Где синхронизируется |
|
кнопка на плате |
spi_reset_sync (гл. 11) |
|
XPT2046 |
внутри spi_engine |
|
XPT2046 |
top-level |
|
XPT2046 |
top-level |
Однотактные стробы через 2-FF синхронизатор передавать нельзя (импульс может потеряться между фронтами) — но у нас, к счастью, все асинхронные входы — это уровни, так что хитрых схем с handshake не понадобится. Упоминаю, чтобы вы не применили рецепт за пределами его показаний.
А как же LCD с его 9 МГц?
В части про дисплей возникнет искушение номер два: пиксельный клок LCD — 9 МГц, «заведу-ка я PLL и отдельный клоковый домен под видеотракт». И это снова не нужно: применим тот же приём. Вся LCD-логика тактируется теми же 50 МГц, а пиксельные события задаёт строб pix_en от счётчика-делителя (50 / 5 = 10 МГц — в допуске панели). DCLK для дисплея, как и SCLK для SPI, — обычный выходной регистр. Один и тот же паттерн закрывает оба интерфейса проекта — это и есть признак правильно выбранной архитектуры.
Что мы только что купили
Подведём итог — что даёт проекту правило «один домен + стробы + 2-FF на входах»:
Корректность по построению. Внутри домена все переносы данных защищены статическим временным анализом — целый класс багов (самых злых: редких и невоспроизводимых) исключён архитектурно.
SDC из пяти строк вместо трактата о взаимоотношениях доменов. Меньше constraints — меньше шансов соврать инструменту.
Простая симуляция. Один клок в testbench, никаких искусственных рассинхронизаций. То, что мы проверим в части IV, будет соответствовать железу.
Переносимость. Никаких PLL и вендорских примитивов — дизайн переедет на любой FPGA, где есть 50 МГц.
Отлаживаемость. Когда в части VI мы будем смотреть сигналы логическим анализатором, у всех событий будет один временной базис.
Шпаргалка главы
Производный клок из логики (
always @(posedge sclk)) = второй домен, CDC, gated clock и боль. Не делаем.Один домен 50 МГц; медленные события — через clock enable (стробы от делителя). SCLK и DCLK — обычные выходные регистры.
Метастабильность — физика асинхронных входов; лечится временем на рассасывание.
Каждый асинхронный вход → 2-FF синхронизатор; сырой сигнал не читает никто. Для стробов этот рецепт не годится (у нас их и нет).
Задержку синхронизатора (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», и для нашего ТЗ группировка получается почти безальтернативной:
Группа существительных |
Будущий модуль |
Регистры, флаги, прерывания |
|
Буфер (одинаковый для TX и RX!) |
|
Автомат, делитель, сдвиг, провода |
|
Соединить всё вместе |
|
Обратите внимание на 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 — «управляющая логика», и часть сигналов просто пробрасывается насквозь. Три причины не делать этого:
Тестируемость. Engine с уровневыми входами конфигурации можно дёргать в testbench напрямую, без эмуляции шинных транзакций. И наоборот: регистровый интерфейс проверяется на чтение/запись без единой SPI-транзакции. Ошибка локализуется до модуля самим фактом того, какой тест упал.
Замена шины. Сегодня у нас простая параллельная шина, завтра захочется Avalon-MM для Qsys или AXI-Lite. При слитой архитектуре это хирургия на живом FSM; при раздельной — замена одного модуля или вообще написание моста поверх (глава 30).
Два разных мира семантики. Регистры живут в мире «адрес-данные-такт», 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 ломается небанальным образом. Анонс оставляю; вернёмся с поличным.
Прослеживаем потоки через схему
Финальная проверка архитектуры — мысленный прогон сценария из ТЗ через блок-схему. Возьмём основной: «передать слово, получить ответ».
Хост пишет CLK_DIV, WORD_LEN, CONTROL (конфигурация) → осели уровнями в spi_reg_if, протянуты проводами в engine.
Хост пишет слово в TX_DATA → reg_if декодирует адрес → строб wr_en в TX FIFO → слово на складе.
Хост пишет START → reg_if выдаёт строб start → engine просыпается: читает слово из TX FIFO, опускает CS#, отрабатывает фронты по главе 4.
Набрав слово с MISO, engine толкает его стробом в RX FIFO и выдаёт done_pulse → reg_if взводит флаг done и, по маске, irq.
Хост читает STATUS (или ловит irq), затем читает RX_DATA → reg_if стробом вычитывает слово из RX FIFO → данные у хоста.
Каждый шаг сценария лёг на схему без натяжек: ни один сигнал не понадобился «в обход», ни одному модулю не пришлось лезть не в своё дело. Это и есть признак того, что декомпозиция выдержит реализацию. Прогоните так же сценарии burst и переполнения RX — они тоже проходят (проверьте себя: на каком шаге и кто взводит err_rx_overflow?).
Шпаргалка главы
Декомпозиция — до кода. Метод: существительные ТЗ → группы с плотными внутренними связями → модули; потоки данных → интерфейсы.
Четыре модуля: spi_reg_if (общение с хостом), spi_fifo ×2 (склад), spi_engine (весь протокол), spi_master_top (только соединения).
Границу проводят там, где интерфейс тоньше, а семантика сторон различнее.
Контракт интерфейса = сигналы + кто драйвит + уровень или строб. Невыразимые в коде пункты («не менять конфиг при busy») записываются словами.
Память об ошибках — у reg_if (sticky-флаги); engine только сообщает стробами.
Проверка архитектуры — мысленный прогон сценариев ТЗ: всё должно ложиться без сигналов «в обход».
Скелет готов. Но один интерфейс мы пока описали лишь снаружи — тот, через который контроллер видит хост. Внутри него целая дисциплина: адреса, битовые поля, 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). Аргументы:
Тестируемость без зависимостей. Avalon/AXI в testbench — это либо вендорские BFM (прощай, Icarus), либо сотни строк собственной модели шины. Наш интерфейс дёргается из testbench двумя task'ами по десять строк (увидите в главе 18).
Переносимость. Ядро без вендорских интерфейсов синтезируется чем угодно и переезжает куда угодно. Avalon приколачивает к экосистеме Intel.
Мост — это дёшево. Наша шина спроектирована как «общий делитель» шинных протоколов: однотактная запись, комбинационное чтение, ready для медленных мостов (у нас он константная единица — но порт в контракте уже есть). Мост на Avalon-MM поверх такого — действительно вечер работы (глава 30).
И главное для этой статьи: в автономной демо-прошивке хостом будет не CPU, а наши же FSM (demo_init и spi_probe, часть VI) — им простая шина и нужна.
Это решение — «ядро с нейтральным интерфейсом + тонкие мосты по вкусу» — стандартная архитектура серьёзных IP, и привыкать к ней лучше с первого проекта.
Шпаргалка главы
Регистровая карта — публичный API; проектируется и документируется до реализации, живёт в одном header для RTL и тестов.
Флаг события — sticky, флаг состояния — живой. Sticky взводит железо, снимает только хост.
Квитирование — W1C (запись маски), иначе гонка read-modify-write теряет события.
Командные биты (START, SOFT_RST) — self-clearing, снимаются по подтверждению от исполнителя, читаются нулём.
RX_DATA с pop-on-read — индустриальный стандарт, но помните про отладчик-вредитель и дисциплину «сначала RX_VALID».
Делайте нелегальные состояния непредставимыми (CS_SELECT — индекс, не маска).
Шина ядра — 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).
Шпаргалка главы
Структура каталогов — материализованная архитектура: rtl/ — только синтезируемое, sim/ — модели и тесты, quartus/ — карантин артефактов под одним .gitignore.
Один файл = один модуль = одно имя; префиксы spi_/lcd_/tb_ как пространства имён.
Карта регистров живёт в одном хедере (spi_defs.vh) для RTL и testbench — расхождение непредставимо. Include guard обязателен.
Makefile: явные списки файлов (не wildcard), -Wall с политикой «ноль предупреждений», -I для хедеров, цели с зависимостями.
Однокнопочный цикл «правка → PASS/FAIL» — фундамент всей верификации.
Контрольная точка на заглушках: инфраструктура и контракты портов проверены до написания логики.
Часть 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, просто в миниатюре:
По одному на домен. Правило из шапки файла: один инстанс на каждый тактовый домен, которому нужен этот сброс. Сегодня домен один, но модуль — это готовность к «а добавим-ка PLL» без копипасты.
Имя — это документация. Строка spi_reset_sync u_rst_sync (...) в топе говорит читателю всё. Те же три строки россыпью среди другой логики — загадка «зачем тут цепочка из двух триггеров».
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 — это «дипломат» с блок-схемы: единственный модуль, видящий хоста. Его работа сводится к четырём занятиям:
читать: по адресу отдать слово (mux);
писать: по адресу обновить регистр или толкнуть FIFO;
помнить: sticky-флаги событий и ошибок;
сигналить: события × маска → провод 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 вместе.
И тем не менее модуль проще, чем кажется, — потому что мы будем собирать его не «сверху вниз по листингу», а вокруг трёх решений, каждое из которых схлопывает целый пласт потенциальной сложности:
счётчик фронтов вместо жонглирования «полутактами» — и вся CPOL/CPHA-семантика главы 4 сожмётся в одно сравнение;
индексный доступ к битам вместо сдвигового регистра — и MSB/LSB-first перестанет быть источником багов;
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: Тест = независимый эталон + автоматическая сверка. Убери первое — получишь согласованную ложь. Убери второе — правду, которую никто не прочитает.
Шпаргалка главы
Зелёные тесты доказывают согласованность DUT с моделью, а не правильность. Если модель — структурная копия DUT, не доказывают ничего.
Симметричный баг = одна ошибка в двух экземплярах; сумма ошибок — ноль, снаружи — идиллия. Изнутри системы не обнаружим в принципе.
Ловится внешним эталоном: даташит, стандарт, железо, чужая реализация. Сверка волн с диаграммой стандарта — дёшево и сердито.
Референсная модель пишется из другого первоисточника и в другом стиле: у нас — событийно от проводов, без знания о внутренностях DUT.
Self-checking или ничего: вердикт выносит тест, не человек у осциллограммы.
Худший день верификатора — не когда тесты упали, а когда узнал, почему они проходили.
Философия закрыта. Дальше — практика: глава 17, строим ту самую структурно независимую модель slave.
Глава 17. Шаг 7: tb_spi_slave_ref.v — независимая модель slave
Требования к модели — из выводов главы 16
Переводим философию в код. Модель должна:
жить только от проводов — SCLK, CS#, MOSI; никакого системного клока, никакого знания о делителе;
брать семантику режимов из стандарта (таблица главы 4), а не из главы 14;
поддерживать все 4 режима, оба порядка бит, все длины слов и burst;
отдавать 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...), из него сигналы перетаскиваются в окно волн. Пустое окно — худшее начало; вот стартовый набор, отработанный практикой, сверху вниз:
test_no — наш счётчик кейсов из testbench. Это карта: значение 12 на курсоре = смотрим тест 12. Без него поиск нужного места в многомиллисекундном дампе — мучение.
Шина хоста: wr_en, rd_en, addr, wr_data, rd_data — что приказывал «драйвер».
Провода SPI: spi_sclk, spi_cs_n, spi_mosi, spi_miso — сердце картины, то, что можно сверять с диаграммами главы 4 один в один.
Внутренности engine: dut.u_eng.state, edge_cnt, bit_cnt, tx_word, rx_word. Да, testbench'у внутренности запрещены (глава 18.4) — но глазам при отладке можно и нужно: волны не создают зависимости теста от реализации.
Стробы рукопожатий: 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 положительный — путь успевает; отрицательный — нарушение. Отчёт группирует пути по клокам и показывает худшие. Что смотреть, по убыванию важности:
Сводка Setup/Hold по clk_50m — наш единственный настоящий домен. У SPI-ядра slack уверенно положительный: логика короткая, кристалл скучает. (Финальная прошивка с LCD будет жить заметно теснее — реальные числа увидим в главе 24.)
Unconstrained paths — список путей, не накрытых ни одним констрейнтом. В идеале — пуст; каждая строчка в нём — сигнал, о котором мы забыли рассказать (или забытый false_path). Это та же логика, что Ignored Assignments в главе 22: отчёт о дырах в наших декларациях.
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) — это четыре последовательных инструмента, и у каждого своя зона ответственности. Знать границы полезно: по тому, на какой станции возникла проблема, сразу ясен её характер.
Analysis & Synthesis — наш Verilog → нетлист из примитивов (LUT, триггеры). Здесь живут все языковые проблемы: ошибки синтаксиса, inferred latch, несинтезируемые конструкции, инференс памяти.
Fitter (place & route) — нетлист раскладывается по конкретным ячейкам кристалла, сигналы разводятся по трассировочным ресурсам. Здесь — проблемы размещения: нехватка ресурсов, неразводимость, конфликты пинов. И именно фиттер исполняет (или молча игнорирует — глава 22) наши location assignments.
Assembler — размещённый дизайн → битстрим .sof.
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:
Hardware Setup → USB-Blaster (если в списке пусто — на Linux почти всегда дело в udev-правах на USB-устройство).
Файл output_files/spi_master.sof обычно подхватывается сам; режим — JTAG.
Галка Program/Configure → Start → прогресс-бар → 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 нам из всего этого богатства нужны ровно два прибора:
текстовая строка — lcd_status_label умеет писать на экране SPI: FOUND зелёным или SPI: NONE красным по одному входному биту;
два 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
Три неочевидных выбора, каждый — со следствием:
DFR (дифференциальный) режим, а не single-ended: в DFR опорой АЦП служат сами драйверы панели, поэтому результат не зависит от дрейфа внутреннего опорного напряжения. Для резистивного тача даташит прямо рекомендует DFR — single-ended оставьте измерению температуры и батарейки (у чипа есть и такие каналы — A2:A0 = 000/111).
12 бит, не 8: полная точность, всё равно бесплатно — биты те же.
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), здесь только инверсия активного нуля.
Проверка пальцем: читаем цифры как осциллограмму
Прошиваем, смотрим на экран. Протокол испытаний — три позы:
Покой (палец в воздухе). HEX0/HEX1 показывают значения у рельсов — около 000 или FFF, и заметно дрожат в младших разрядах. Это норма, а не баг: без нажатия резистивные слои не соприкасаются, измеряемый узел практически висит в воздухе, и АЦП честно оцифровывает наводки. Дрожащий мусор в покое — признак живого аналогового тракта. Вот почему мы не стали прятать цифры за pen_down: ровный замороженный ноль выглядел бы «чище», но не отличался бы от мёртвой системы. Дрожь — это сердцебиение.
Нажатие в центре. Цифры мгновенно стабилизируются в середине диапазона — что-то около 0x800 ± 0x100 по обеим осям. Стабильность — ключевое: слои прижаты, цепь низкоомная, шум падает на порядок.
Обход углов. Ведём палец в каждый угол и записываем показания. Типичная картина для этой панели: одна ось ходит примерно от ~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 в работающее железо.
Десять правил, оплаченных часами отладки
Каждое правило ниже стоило конкретных часов — ссылка ведёт к месту, где счёт был выписан.
Референсная модель пишется структурно иначе, чем DUT. Общая идея (сдвиговый регистр, счётчик фаз) в модели и дизайне порождает общие баги, и тесты зеленеют над сломанным протоколом. Наша модель на событиях @(posedge sclk) против счётчиков полупериодов в engine — единственная причина, по которой симметричные CPHA/LSB-баги вообще были найдены.
Комбинационное чтение — или задокументированная задержка. Третьего не дано. Регистровый rd_data FIFO, не отражённый в контракте шины, дал «прошлогодние» данные при pop-on-read (гл. 12.4, баг №2). Любая задержка интерфейса — часть API, а не деталь реализации.
Стробы, а не уровни; квитирование, а не надежда. Self-clearing START, W1C-флаги, ERROR_CLR перед каждой транзакцией — всё это машинная форма одной мысли: событие должно быть подтверждено получателем, а не предположено отправителем (гл. 8.5–8.6, баг №1).
QSF сверяется grep'ом после каждого изменения портов топа. Quartus молча игнорирует назначения, не совпавшие с портом по имени или размерности, и молча раскидывает осиротевшие сигналы по свободным ножкам. Неделя с логическим анализатором против тридцати секунд с grep (гл. 27.5).
Наблюдаемость проектируется до железа. Когда прошивка молчит, добавлять «что-нибудь для отладки» уже поздно: непонятно, работает ли сама отладка. Наш LCD-дашборд был готов и проверен в симуляции до первой прошивки — и в час Х стал единственным источником правды (гл. 25, 27.1).
Скважность — первое число перед походом с анализатором. 10 мкс активности на 84 мс периода — это 0.012%; free-run такое не ловит никогда, и линия выглядит мёртвой при живом дизайне. Посчитать скважность — одна минута; неделя поиска «пропавших» сигналов — дороже (гл. 27.2).
Один бинарный эксперимент лучше десяти гипотез. DIAG_PINWALK — меандры мимо всей логики — делит пространство «физика или логика» ровно пополам за одну пересборку. Хороший отладочный шаг отличается не умностью гипотезы, а тем, сколько гипотез он убивает (гл. 27.6).
Самая дешёвая проверка — первой. Уровни покоя мультиметром (две минуты) закрывали дело главы 27 сразу — но мы начали с гипотез, требовавших пересборок. Сортируйте проверки по цене, а не по интеллектуальной привлекательности (гл. 27.8).
Один тактовый домен — пока не доказано обратное. Решение главы 6 окупалось на каждом этаже: тривиальный SDC, нулевая CDC-зона, PLL-строка в отчёте фиттера как подпись под архитектурой. Второй домен заводится тогда, когда без него нельзя, — и тогда он заслуживает собственного проекта (гл. 30.6).
Тестовые данные не должны быть палиндромами. 0xA5 выглядит «случайным», но читается одинаково в обе стороны — и MSB/LSB-баг сквозь него проходит незамеченным. Подбирая константы для теста, спросите: какая симметрия данных может замаскировать какую симметрию бага? (гл. 18.3).
Если сжать десять правил в одно: не доверяйте ничему, что не проверено способом, независимым от проверяемого. Модель — независима от DUT, grep — от Pin Planner'а, мультиметр — от анализатора, формальный инструмент — от тестбенча. Инженерия — это организованное недоверие.
Что унести в следующий проект
Помимо уроков, из репозитория стоит унести вполне материальные вещи — они проектировались переиспользуемыми:
Скелет репозитория и Makefile (гл. 9): rtl/ sim/ quartus/ docs/, явные списки файлов, цели sim_* / wave_* / clean. Заводится новый проект копированием и вычёркиванием.
Модули-кирпичи: spi_reset_sync (нужен буквально в каждом дизайне), spi_fifo (синхронный, с phase-bit и sticky-флагами), lcd_* целиком — дашборд для следующего bring-up уже готов.
Стиль тестбенча (гл. 18): check()-счётчики, task-обёртки шины, итоговая строка PASS/FAIL — каркас не зависит от того, что тестируем.
Соглашения карты регистров (гл. 8): sticky / W1C / self-clearing / pop-on-read — готовый словарь контрактов для любой периферии.
Чек-листы: «нет сигналов на пинах» (гл. 27.7) и маршрут из 31.1 — распечатать и положить рядом с платой.
Последняя контрольная точка
В начале статьи был пустой каталог и желание «сделать SPI». В конце — плата на столе: палец давит на стекло, число на экране послушно бежит следом, логический анализатор рисует учебно-красивые кадры, make за четыре секунды подтверждает 29 раз, что ничего не сломано.
Между этими двумя точками не было ни одного волшебного шага — только последовательность маленьких, каждый из которых проверяем: контракт → код → тест → отчёт → плата. Именно поэтому маршрут воспроизводим: замените SPI на I2C, UART или собственный протокол — станции останутся теми же.
Пустой каталог и желание «сделать X» теперь есть у вас. Маршрут — выше. Удачи в реализации в железе.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

mozg37
Забыл когда в последний раз юзал внешний лог анализатор. Внутрисхемный - очень удобно. В quartus это signaltap. В gowin - gao. После отладки в gao на альтеровские плис смотреть нет никакого желания.