Продолжаем разработку SPI IP Core на Verilog. Следующий логичный этап - перенести разработанный SPI IP Core на Verilog на Xilinx Zynq-7020, который установлен на плате TZT RK-ZYNQ7020-F rev.1.1. Рядом с программируемой логикой в Zynq живёт ARM-процессор, между ними проложена шина AXI, на процессоре крутится Linux, а на плате есть дисплей, которому можно скормить готовые кадры, с часами на дисплее ST7789, которые рисуются потоком через AXI DMA. Читать статью можно и без Altera-части, всё существенное мы повторим по дороге; но если есть лишний вечер, начните с неё — увидите, откуда взялись решения, за которые нам сейчас придётся платить.
Главная ценность статьи — не описание того, как «должно быть», а разбор того, почему с первого раза не получилось. Каждая нетривиальная ошибка рассказана целиком: что мы видели своими глазами, какая гипотеза казалась логичной, как проверяли, какая улика перевернула картину, где оказалась настоящая причина и какой вывод мы из этого сделали.
Всем, кому интересно продолжение - добро пожаловать под кат! =)
Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Репозиторий
Весь список материалов, исходный код для проектов и подробную документацию вы можете найти по ссылке в репозитории: https://github.com/megalloid/SPI-Master-Controller/tree/master/spi_xilinx
Содержание
Часть 0. Введение (главы 1–4)
Чем эта история вообще интересна, спросит уважаемый читатель. Чем «портировать RTL» отличается от «получить работающее устройство». Коротно напомню, что такое система на кристалле и почему на Zynq ваша логика превращается из самостоятельного устройства в периферийный блок с адресом в карте памяти. Здесь же — плата TZT RK-ZYNQ7020-F, набор инструментов и договорённость о том, как читать разборы ошибок.
О чём статья: от SPI на Cyclone IV к Zynq, Linux и дисплею.
Что уже было на Altera и что меняется при переходе к SoC.
Плата, Vivado, Buildroot, JTAG: карта инструментов.
Как читать: теория, лаборатория и кладбище ошибок.
Часть I. Зачем переносить и что такое миграция IP (главы 5–7)
Что такое vendor lock в языке описания аппаратуры, почему поведенческий Verilog переносится, а вендорские мегафункции нет, и как выглядит аудит зависимостей, когда поиск заканчивается пустым результатом — и почему этот пустой результат надо задокументировать.
Vendor-neutral RTL против вендорской обвязки.
Аудит зависимостей и доказательство эквивалентности.
Дерево модулей: что копируем, что добавляем, что выбрасываем.
Часть II. Архитектура ядра SPI (главы 8–11)
Устройство ядра с акцентом на то, что делает его переносимым: единый тактовый домен, SCLK как разрешающий сигнал, а не второй клок, дисциплина сброса и синхронизаторы. Затем — карта регистров и шесть унаследованных дефектов, каждый как отдельная детективная история.
Один тактовый домен и SCLK как enable.
Модули
spi_reg_if, FIFO,spi_engine,spi_master_top.Карта регистров от CONTROL до ERROR_CLR.
Дефекты A-1…A-6, в первую очередь предел
CLK_DIVи мёртвый флаг переполнения.
Часть III. От параллельной шины к AXI4-Lite (главы 12–15)
Почему процессору недостаточно «дёрнуть провод», как работает рукопожатие VALID/READY, как выглядит запись регистра по такту и какие четыре осознанных отклонения от спецификации мы допустили — вместе с описанием цены каждого.
Зачем системе на кристалле нужна шина.
AXI4-Lite за сорок минут.
Обёртка
spi_axi4liteи отклонения D-1…D-4.Два верхних уровня: автономный selftest и продуктовый Block Design.
Часть IV. Vivado, constraints, распиновка (главы 16–19)
Что переносится из QSF и SDC, а что пишется заново; почему SCLK нельзя объявлять производным тактовым сигналом; почему MISO идёт через два триггера; и самая физическая глава серии — напряжение банка ввода-вывода, которое легко превращает ошибку в дым.
Из Quartus в Vivado: QSF и SDC против XDC.
Дефекты C-2 и C-3: SCLK и MISO.
Распиновка, банк 13 и напряжение VCCO.
Преобразователи уровней: почему логика молчит до
ps7_init.
Часть V. Первый bring-up на железе до Linux (главы 20–22)
Лабораторная работа: как доказать, что SPI жив, не имея ни дисплея, ни операционной системы. Светодиоды, встроенный самотест, петля MOSI–MISO и логический анализатор внутри кристалла. И типичные провалы первого включения, которые почти всегда лежат снаружи ядра.
Шаги bring-up как лабораторная работа.
Selftest, светодиоды и ILA: доказательства без дисплея.
Типичные провалы первых измерений.
Часть VI. Block Design с процессорной системой (главы 23–25)
Откуда берутся адреса и почему в них нет магии; как прерывание из логики доходит до ядра Linux и почему по пути легко потеряться; и три ограничения железа, которые определяют облик драйвера ещё до первой строки на C.
Порт GP0, Address Editor и адрес
0x40000000.IRQ_F2P, xlconcat и «драйвер не видит прерывание».
Ограничения F-1…F-3 и что они запрещают софту.
Глава 1. О чём статья: от SPI на Cyclone IV к Zynq + Linux + дисплей
1.1. Продолжение истории
У этой серии есть предыстория, и без неё половина решений выглядит произвольной. В списке моих статей уже есть материал про то как с нуля был построен SPI master на Altera Cyclone IV — с техническим заданием, резкой устройства на модули, конечным автоматом протокола, охотой за пинами в Quartus и осциллографом в финале, где из абстрактного Verilog получились настоящие пачки импульсов на ножках кристалла. Если вы пришли мимо той серии, достаточно вспомнить, что SPI — простой последовательный интерфейс, которым микросхемы общаются между собой на плате: линия тактов SCLK, линия данных «туда» MOSI, линия «обратно» MISO и линия выбора устройства CS. Наш проект — ведущий (master), та сторона, которая дёргает такты и командует обменом.
Эта статья — следующий акт того же сюжета. Мы не пишем ещё один учебный SPI с нуля: мы берём уже работающее, отлаженное и проверенное тестами ядро и переносим его на систему-на-кристалле Xilinx Zynq-7020. Рядом с программируемой логикой там живёт настоящий ARM-процессор, между ними проложена шина AXI, на процессоре крутится Linux, а на плате есть дисплей, которому можно скормить готовый кадр. Финал истории — часы на дисплее ST7789, которые рисуются потоком через DMA и не рассыпаются на артефакты. Читать серию можно и без Altera-статьи, всё существенное мы повторим по дороге; но если есть лишний вечер, начните с неё — увидите, откуда взялись решения, за которые нам сейчас придётся платить.
1.2. Зачем переносить IP и чем это отличается от «переписать код»
Первый честный вопрос: а зачем? Ядро работает на Cyclone IV, тесты зелёные, осциллограф показывает то что нужно — зачем тащить его в чужую экосистему? Причин три, и все встречаются в работе чаще, чем хотелось бы. Кристалл кончился или подорожал: линейку сняли с производства, поставщик поднял цену, заказчик потребовал микросхему из другого списка — проект не переписывают, проект переносят. Задача выросла: пока хватало «голой» логики, всё было хорошо, а как только понадобились сеть, файловая система или человеческий интерфейс, дешевле взять кристалл со встроенным процессором, чем городить внешний микроконтроллер и мост к нему. И третья: вы не автор ядра — чужой IP-блок (IP, intellectual property core, — готовый кусок логики, который переиспользуют как библиотеку) приезжает к вам вместе с задачей «интегрировать до пятницы». Есть и четвёртая, педагогическая, ради которой написан этот текст: перенос — единственная ситуация, в которой видно, что в проекте было настоящей инженерией, а что случайно подошедшей особенностью инструмента. Пока код живёт на одной платформе, вы не отличаете «я так спроектировал» от «Quartus так умеет».
Дальше начинается главное недоразумение новичка. Когда студенту говорят «перенеси проект с Altera на Xilinx», он слышит «перепиши код»: сидишь и меняешь одни конструкции языка на другие, как при переводе с диалекта на диалект. Так вот, кода мы почти не тронем. RTL (register-transfer level — тот уровень описания, на котором вы пишете always @(posedge clk) и думаете регистрами и проводами) в нашем ядре оказался практически нейтральным к вендору: на момент переноса из шести файлов четыре переехали байт-в-байт, а в двух добавлен один-единственный атрибут.
А вот устройство при этом не заработало — ещё много недель, потому что «портировать RTL» и «получить работающее устройство» — две разные профессии, и вторая гораздо насыщеннее. Между ними лежит всё то, что обычно называют скучным словом «обвязка»: шина, по которой процессор доберётся до ваших регистров; файл ограничений, где написано, какой сигнал на какой физической ножке; конфигурация процессорной подсистемы; цепочка загрузки от подачи питания до приглашения командной строки; драйвер и описание железа для операционной системы; протокол дисплея; поток данных через DMA. И отдельным сортом боли — кэш самого синтезатора, который однажды честно соберёт вам старую версию логики и не скажет об этом ни слова.
1.3. Что такое SoC
Слово SoC (system on chip, система на кристалле) встретится в серии сотню раз, поэтому договоримся о значении сразу. Обычная ПЛИС — поле из десятков тысяч одинаковых кубиков: таблица истинности плюс триггер, и так тысячи раз. Когда вы пишете Verilog и нажимаете «собрать», инструмент решает, какой кубик что делает и какими проводами их соединить. Никакой программы внутри нет, нет процессора, нет понятия «выполнить инструкцию»: есть только схема, работающая вся сразу, каждый такт. Zynq устроен иначе — на одном кристалле уживаются две принципиально разные половины. Одна — та самая ПЛИС, поле кубиков; в терминологии Xilinx это PL, programmable logic. Вторая — полноценный компьютер, вылитый в кремнии на заводе и неизменяемый: два ядра ARM Cortex-A9, контроллер оперативной памяти DDR, Ethernet, USB, UART, контроллер SD-карты и логика начальной загрузки. Эта половина называется PS, processing system, и на ней запускается Linux — самая обычная операционная система, та же, что на ноутбуке, только собранная под ARM.
Бытовая аналогия довольно точная: раньше у вас был цех со станками (PL), где вы сами всё расставили и сами всё запускали. Теперь к цеху пристроен офис с менеджером и бухгалтерией (PS). Станки по-прежнему ваши, но у здания появился распорядок дня, пропускная система и человек, решающий, когда цех включается. Новая работа — не в станках, а в коридоре между цехом и офисом. Коридор называется AXI: стандартная шина, по которой процессор обращается к вашей логике так же, как к любой другой периферии, — пишет и читает по адресам. Нужная нам разновидность называется AXI4-Lite: упрощённый вариант без пакетных передач, ровно «прочитай 32 бита по адресу» и «запиши 32 бита по адресу». Ей посвящена часть III.
Отсюда следует смена роли разработчика — пожалуй, самая важная мысль главы. На Cyclone IV вы были одновременно всем: процессора не было, значит, писать в регистры приходилось вам самим (сначала руками через отладочную шину, потом крошечным автоматом-секвенсором из ПЗУ), а чтобы понять, что творится внутри кристалла, вы выводили состояние на LCD-панель, потому что больше некуда. Процессор, периферия, отладчик и интерфейс пользователя — всё это были вы. На Zynq процессор уже есть, и он главный: ваша логика перестаёт быть устройством и становится периферийным блоком, таким же, как встроенный UART или таймер. У блока появляется адрес в карте памяти процессора (у нас 0x4000_0000), линия прерывания и драйвер, который им управляет. Вы больше не дёргаете wr_en руками — вы описываете контракт, а дёргаете софт. Звучит как понижение в должности, но это повышение требований: пока вы были единственным хозяином кристалла, неточность в контракте регистров стоила вам вечера отладки, а теперь ваш контракт читает чужой код, написанный другим человеком по документации, и всякая неточность превращается в баг, который ловят на другом конце системы — в драйвере, в приложении, в картинке на экране. Отсюда и вся драма серии: она разворачивается не внутри SPI-движка, а на границе с платформой.
1.4. Сквозной тезис и почему он неочевиден
Теперь можно сформулировать тезис, который держит на себе все десять частей.
RTL ядра SPI почти vendor-neutral. Настоящая работа — AXI, PS, constraints, Linux boot chain, DMA и периферия платы.
В цифрах: четыре модуля — spi_reg_if, spi_fifo в двух экземплярах, протокольный движок spi_engine и интеграционный уровень spi_master_top — переехали без единой правки логики, а во всём существующем RTL на момент переноса изменились ровно два атрибута ASYNC_REG: по одному на цепочку синхронизатора в spi_engine.v и в spi_reset_sync.v. Две строки на весь перенос. Много инженерного времени ушло на то, что вокруг: обёртку spi_axi4lite, файл ограничений XDC, блок-дизайн процессорной системы, сборку Linux в Buildroot, драйвер, дисплей и потоковую передачу через DMA.
Почему тезис неочевиден? Он противоречит тому, как устроено обучение: в университете вас оценивают по коду, а код видимый и измеримый, его можно показать. Обвязка невидима — в ней нет красивых алгоритмов, её нельзя запустить в симуляторе как самостоятельную вещь, и в резюме про неё не напишешь, поэтому начинающий инженер планирует проект по объёму кода и промахивается в сроках втрое. Вторая причина: у обвязки нет собственной теории, которую учат один раз. Она состоит из сотен мелких договорённостей конкретной платформы — этот банк выводов питается отсюда, этот файл инициализации привязан к плате, а не к кристаллу, этот драйвер ищет вот такое имя в описании железа. Каждая по отдельности тривиальна, а нарушение любой даёт мёртвую плату и ноль подсказок.
1.5. Что в итоге получилось на плате
Целевое железо — плата TZT RK-ZYNQ7020-F v1.1 с кристаллом XC7Z020 (корпус clg484, speed grade −2), инструмент Vivado 2025.2, среда сборки Linux — Buildroot. На этой плате живёт тот же самый SPI master: все четыре режима CPOL/CPHA, оба порядка бит, слова 8/16/24/32 бита, два FIFO, sticky-флаги ошибок и прерывания, без единого изменения в поведении. Но добраться до него теперь можно тремя способами, и каждый — отдельная часть статьи. Из процессора по AXI4-Lite, обычными чтениями и записями по адресам (части III и VI). Из Linux — сначала утилитой devmem из командной строки, потом драйвером spi-zynq-fpga, который регистрируется в стандартной подсистеме SPI ядра (часть VII). И, наконец, потоком: процессор кладёт в память готовый кадр, говорит контроллеру прямого доступа к памяти «отправь вот это», и данные текут в SPI сами, без участия процессора (часть IX).
Финальный критерий успеха нарочно бытовой: на дисплее идут часы. Не «тест прошёл», не «запас по времени положительный», а цифры, которые видно глазами и которые обновляются без мусора, без двоения и без артефактов. Такой критерий невозможно подделать самообманом: пока картинка кривая, вы не закончили, сколько бы зелёных галочек ни было в тестах.
Кое-чего мы честно не измеряли, и лучше сказать это здесь, а не прятать в сноску. Мы не сравнивали площадь ядра на Cyclone IV и на Zynq «один к одному». Мы не фиксируем итоговое число LUT для полной сборки с DMA — оно зависит от глубины FIFO и от наличия встроенного анализатора, смотрите отчёт своей сборки. И мы не измеряли частоту кадров часов: она заведомо избыточна для задачи.
1.6. Паспорт IP: регистр по смещению 0x3C
Одна маленькая деталь заслуживает отдельного разговора: она объясняет саму логику работы с SoC. По смещению 0x3C в AXI-обёртке живёт регистр только для чтения, ID_VERSION. В классической PIO-сборке он читается как 0x53500100, в сборке с потоком и DMA — как 0x53500200. Старшая половина 0x5350 — два ASCII-символа, 'S' и 'P'; младшая — версия в формате «старшая.младшая», 01.00 и 02.00 соответственно.
Зачем логике паспорт? Затем, что ваш блок больше не единственный житель системы, а софт не может посмотреть на него глазами. Драйвер получает из описания железа адрес и должен убедиться, что по этому адресу действительно он, а не сосед, не пустое место и не прошлогодняя версия битстрима. Поэтому первое, что делает драйвер при запуске, — читает 0x3C. Если старшие 16 бит не равны 0x5350, он отказывается привязываться к устройству и возвращает ошибку -ENODEV: либо в описании железа неверный адрес, либо в кристалл залита не та логика. Совпала старшая версия, отличается младшая — предупреждение в журнале, но не отказ.
Отдельно ценно, что старые битстримы, собранные до появления этого регистра, вернут по адресу 0x3C ноль, и ноль — тоже ответ: «прошивка старая». Это спасает от потерь времени на дебаг, когда вы отлаживаете софт против логики, которой в кристалле давно нет; в части IX увидите, во что превращается отсутствие такой проверки. Практический вывод, который стоит унести сразу: первая команда после прошивки — прочитать идентификатор. Строчка devmem 0x4000003C 32 экономит часы, потому что отвечает на вопрос «а ту ли логику я вообще отлаживаю».
Глава 2. Что уже было на Altera и что меняется при SoC
2.1. Что умело ядро на Cyclone IV
Начнём с инвентаризации результатов работы прошлой части: перенос — это в первую очередь понимание того, что именно вы держите в руках. К моменту переноса SPI master на плате ALINX с Cyclone IV умел всё, что должен уметь «взрослый» периферийный блок. Он генерировал такты SCLK целочисленным делением системного клока, выдавал данные на MOSI, сэмплировал MISO, опускал и поднимал линию выбора устройства с программируемыми паузами до первого фронта, после последнего и между словами пакета. Он поддерживал все четыре сочетания полярности и фазы такта, оба порядка бит, слова длиной 8, 16, 24 и 32 бита — причём всё это переключалось на лету записью в регистр, а не параметром синтеза. Он буферизовал данные в двух FIFO по восемь слов, вёл sticky-флаги ошибок (флаги, которые взводятся и висят, пока хост их явно не квитирует) и поднимал прерывание по маске.
Внутри каждый модуль отвечал за своё: spi_reg_if разговаривал с хостом и не знал ничего про фронты, spi_fifo был универсальным складом слов и не знал ни про хост, ни про SPI, spi_engine держал в себе весь протокол, а spi_master_top только соединял их проводами. Эта дисциплина границ и оказалась главным подарком для переноса, хотя писалась совсем не ради него. Решающим же оказалось другое: в ядре не было ни одного вендорского примитива — ни готового PLL из библиотеки Altera, ни scfifo, ни блочной памяти через мегафункцию, ни специальных буферов ввода-вывода. FIFO написан руками как обычный массив регистров, делитель — как обычный счётчик, тактовый домен ровно один. Часть I посвящена аудиту, который это подтвердил, и там же объясняется, почему пустой результат поиска — хорошая новость, а не повод для скуки.
2.2. Параллельная шина: правильное решение и его цена
Теперь самое интересное — интерфейс с хостом. На Cyclone IV ядро управлялось собственной параллельной memory-mapped шиной. Memory-mapped означает буквально следующее: с точки зрения управляющей стороны контроллер выглядит как кусок памяти. Хочешь настроить делитель — «запиши» число по адресу 2, хочешь узнать состояние — «прочитай» адрес 1. Никаких команд и протокола: адрес, данные и строб. Строб здесь ключевое слово. Шина состояла из сигналов wr_en, rd_en, addr, wr_data и rd_data, и оба разрешающих сигнала были импульсами ровно на один такт. Чтение было комбинационным: данные появлялись на rd_data в тот же такт, без задержки. А чтение регистра принятых данных имело побочный эффект — слово извлекалось из FIFO насовсем.
Почему тогда это было правильным решением? Потому что вокруг не было системы, к которой стоило бы прибиваться. Взять стандартную шину Altera Avalon-MM значило намертво привязать ядро к экосистеме одного вендора ради удобства, которое в том проекте никому не было нужно: процессора-то не было. Расчёт был другой — интерфейс делаем нарочито простым и нейтральным, а мост на любую системную шину (Avalon, Wishbone, AXI) пишется тонкой прослойкой за вечер, когда понадобится. Ставка оправдалась полностью: цена моста на AXI4-Lite по итоговому отчёту размещения составила 12 таблиц истинности и 79 триггеров.
Но была и вторая, менее приятная сторона. Но при переходе на Zynq меняется способ доступа к регистрам. В исходном ядре spi_reg_if использует простой внутренний memory-mapped интерфейс: addr, wr_data, rd_data и однотактовые стробы wr_en и rd_en. Сам по себе этот интерфейс не привязан ни к Altera, ни к физическим выводам FPGA и поэтому сохраняется без изменений. На Zynq хостом становится Cortex-A9 в Processing System. Процессор не управляет внутренними сигналами PL напрямую: доступ к пользовательской периферии выполняется через системную шину AXI. Поэтому поверх существующего интерфейса появляется spi_axi4lite, который преобразует транзакции AXI4-Lite в те же обращения, которые раньше ожидал spi_reg_if. Здесь важно сохранить исходный контракт интерфейса. wr_en и rd_en должны быть импульсами ровно на один такт, rd_data формируется комбинационно, а чтение RX_DATA имеет побочный эффект и извлекает слово из RX FIFO. Если AXI-обертка, например, удержит rd_en два такта, произойдут два pop из FIFO вместо одного и одно принятое слово будет потеряно. Отдельно при аудите старого Quartus-проекта обнаружился дефект A-6: в одной из промежуточных ревизий top-level 65 портам был задан IO_STANDARD, но не был задан set_location_assignment. Эта ревизия не предназначалась для реальной аппаратной сборки, поэтому дефект не являлся причиной перехода на AXI, но показал, что незавершенные top-level и constraints нельзя принимать за валидную распиновку платы.
2.3. Наблюдаемость: зачем ядру был нужен экран
Осталась третья часть наследства, о которой обычно забывают, — способность видеть, что происходит. Кристалл непрозрачен: внутри работающей ПЛИС нет ни печати в консоль, ни отладчика с точками останова, есть только те сигналы, которые вы сами вывели наружу. На Altera проблему решал LCD-дашборд: панель 480×272 с параллельным RGB-интерфейсом, на которую выводились состояние контроллера, шестнадцатеричные значения регистров и живые координаты касания. Рядом жил автомат demo_init, проигрывавший начальную конфигурацию из ПЗУ вместо процессора, и модуль spi_probe, самостоятельно опрашивавший контроллер сенсора XPT2046 — настоящее внешнее SPI-устройство со своим даташитом и своим характером. Это была не декорация, а приборный щиток; без него отладка свелась бы к угадыванию. Запомните эту роль — сейчас её придётся восстанавливать другими средствами.
2.4. Три кучки: байт-в-байт, обёртка и то, что остаётся
Разложим наследство на три кучки. В первую попадает почти всё ядро: файл описаний spi_defs.vh с картой регистров и битовыми полями, буфер spi_fifo, регистровый интерфейс spi_reg_if и интеграционный уровень spi_master_top перенесены байт-в-байт. Это не фигура речи, а проверяемое утверждение: совпадение контрольных сумм проверяет отдельный скрипт. Движок spi_engine и синхронизатор сброса spi_reset_sync отличаются ровно одним атрибутом каждый.
Одна оговорка, чтобы вы не поймали статью на противоречии, сверив суммы самостоятельно. Всё сказанное описывает состояние на момент переноса. Позже, уже в части IX, потоковый режим и DMA всё-таки заставили нас изменить spi_fifo, spi_engine и spi_master_top, поэтому сегодня совпадают только spi_defs.vh и spi_reg_if. Это не провал доказательства, а его работа: скрипт сравнения честно показывает, где мы отошли от оригинала и по какой причине. Регистровый интерфейс мы при этом не тронули сознательно — на его неизменности держится весь аргумент об эквивалентности.
Про атрибут стоит сказать пару слов — он единственный представитель категории «пришлось изменить». ASYNC_REG — подсказка синтезатору Vivado: «эти два триггера образуют цепочку синхронизатора, не растаскивай их по разным углам кристалла, не переставляй местами и не превращай в сдвиговый регистр в памяти». Синхронизатор нужен там, где в вашу логику приходит сигнал, не связанный с вашим тактовым сигналом, — у нас это входная линия MISO и кнопка сброса. Такой сигнал может измениться ровно в момент защёлкивания, и триггер окажется в метастабильном состоянии: не ноль и не единица, а неопределённость, рассасывающаяся за непредсказуемое время. Два последовательных триггера дают ей время рассосаться, но только если стоят рядом. Без атрибута инструмент об их особой роли не догадывается: видит обычную пару регистров и вправе оптимизировать её как угодно. Вот и весь перенос ядра — две строки.
Вторая кучка — то, что остаётся внутри, но получает новую упаковку. Хост-интерфейс никуда не делся: spi_reg_if по-прежнему ждёт свои однотактовые стробы, четырёхбитный адрес и 32 бита данных, просто теперь их формирует не человек и не автомат из ПЗУ, а новый модуль spi_axi4lite — переводчик между шиной AXI4-Lite и внутренним контрактом ядра. Ключевое слово здесь «над»: обёртка добавлена поверх неизменённого spi_master_top, а не вместо него, и внутренний интерфейс сохранён до последнего провода — именно поэтому мы вправе говорить, что ядро то же самое. Обёртка не совсем тривиальна: в ней крошечный автомат, обслуживающий одну транзакцию за раз, чтения и записи не перекрываются. Это не лень, а способ сделать свойство «ровно один строб на одно обращение» доказуемым по построению. Она же приносит четыре осознанных отклонения от учебного AXI4-Lite, пронумерованные D-1…D-4: игнорируется побайтовое разрешение записи, чтение и запись не перекрываются, все ответы всегда «успешно», а окно адресов в 64 байта зеркалируется, если отобразить его на больший диапазон. Каждое обосновано контрактом регистров и проверяется тестами. Сюда же попадает файл ограничений: временные требования пришлось не переносить, а переосмыслить — например, объявление SCLK производным тактовым сигналом в XDC не переехало, оно семантически неверно, потому что коэффициент деления программируется во время работы, а сам SCLK не тактирует ни одного триггера. Подробности — в части IV.
Третья кучка. В перенос не вошли подсистема rtl/lcd/* из девяти модулей, автомат начальной инициализации demo_init, опросчик spi_probe и топ-уровень под панель 480×272. Причина предельно понятная — на целевой плате нет ни параллельной RGB-панели 480×272, ни сенсорного контроллера XPT2046, к которому spi_probe привязан жёстко. Переносить драйвер несуществующей панели так же не требовалось, на плате есть свой дисплей и устройство SPI для экспериментов.
А вот функцию этой подсистемы выбрасывать нельзя. Наблюдаемость восстановлена в три приёма по нарастающей: сначала внутренний мастер spi_selftest, который сам конфигурирует ядро и гоняет транзакции сразу после подачи питания, плюс два светодиода (один мигает от обычного счётчика и доказывает, что такт живой и сброс снят, второй загорается, когда на MISO пришло что-то осмысленное); затем ILA — логический анализатор, синтезируемый прямо внутрь кристалла и показывающий внутренние сигналы через JTAG; и наконец, в частях VIII–IX, дисплей ST7789 по SPI, та самая приборная панель, только на другом железе и другом протоколе. Правило отсюда: переносится не подсистема, а её роль. Ответ на вопрос «а как я увижу, что оно работает?» обязан существовать на каждом этапе, и придумывать его надо заранее, а не когда прижмёт.
2.5. Сначала перенос, потом bugfix
Осталось последнее правило переноса, неочевидное настолько, что его приходится обосновывать. При аудите исходного ядра нашлись шесть дефектов, помеченных A-1…A-6. Два проявляются только при экзотических значениях параметров, один непринципиальный (запись нуля в делитель молча игнорируется, и хост об этом не узнаёт), один касается неполной распиновки. А два действительно неприятные. A-3: при делителе, равном единице, приём портится — слово сдвинуто на бит, отправленный 0x3C читается как 0x1E, причём передача остаётся корректной. A-5: бит 9 регистра состояния, который по документации сообщает о переполнении приёмного FIFO, не выставляется никогда ни при каких условиях.
Ни один из шести дефектов не исправлен, и это сознательная инженерная позиция, а не халатность. Задача переноса — получить версию, про которую можно доказуемо сказать «она ведёт себя точно так же»: совпадением контрольных сумм, прогоном одних и тех же тестов, теми же осциллограммами. Как только вы одновременно с переносом «заодно» чините баг, это утверждение рассыпается: если новая версия ведёт себя иначе, вы уже не отличите «мы правильно исправили баг» от «мы что-то сломали при переносе». Два наложенных друг на друга изменения невозможно развести по последствиям — а именно умение разводить причины и следствия отличает инженерию от шаманства.
Поэтому дефекты не чинятся молча, а фиксируются, причём в самом жёстком виде: тест в симуляции утверждает дефектное значение как ожидаемое. Тест на A-3 требует, чтобы при делителе, равном единице, приём давал именно 0x1E. Выглядит дико, пока не поймёшь замысел: если кто-то завтра изменит глубину синхронизатора или момент сэмплирования, тест упадёт и потребует объяснений вместо того, чтобы молча дать PASS, пока поведение железа меняется незаметно для всех.
Практическое следствие: там, где документация исходного проекта говорит «делитель не меньше двух рекомендуется», правильное чтение — «не меньше двух обязательно». Это не совет по улучшению запаса, а граница корректности, и из неё прямо следует максимальная частота SPI: 8.33 МГц при системном такте 50 МГц, а не 12.5 и не 25. Разбор механизма — в части II.
2.6. Сводка: слой за слоем
Всё сказанное удобно держать перед глазами картинкой и таблицей. Картинка — про смену роли из главы 1.
Таблица ниже — справочная карточка, к которой стоит возвращаться из следующих частей.
Слой | Altera-проект | Zynq-проект | Что это означает |
|---|---|---|---|
Протокол SPI |
| тот же файл, | движок не переписываем вообще |
Регистры и FIFO |
| байт-идентичны на момент переноса | семантика регистров сохранена |
Хост-интерфейс | параллельная шина на выводах |
| главная архитектурная добавка |
Топ-уровень |
|
| отладочная шина ликвидирована |
Ограничения | QSF + SDC | XDC | распиновка пишется с нуля |
Софт | нет ОС, «голый» хост | FSBL, U-Boot, Linux, драйвер | появляется цепочка загрузки |
Наблюдаемость | RGB LCD + | selftest, LED, ILA, затем ST7789 | роль та же, средства другие |
Поток данных | только PIO через регистры | PIO и AXI DMA через AXI-Stream | новый класс задач и новый класс ошибок |
Короткий вывод главы: ядро пережило переезд практически без потерь, потому что раньше кто-то (то есть я) отказался от удобных вендорских мегафункций и написал FIFO руками. Всё, что было привязано к конкретной плате — шина на выводах, панель, опросчик сенсора, — переезд не пережило, и это нормально: переносится логика, а не окружение.
Глава 3. Плата TZT RK-ZYNQ7020-F, Vivado, Buildroot, JTAG
3.1. Паспорт железа и инструментов
Прежде чем говорить про архитектуру, зафиксируем, на чём всё это работает.
Параметр | Значение |
|---|---|
Плата | TZT RK-ZYNQ7020-F v1.1 |
SoC | Xilinx Zynq-7000, XC7Z020, корпус |
Такт программируемой логики | 50 МГц, осциллятор на выводе W17 (вход MRCC) |
Такт от процессорной системы |
|
Инструмент синтеза | Vivado 2025.2 |
Сборка Linux | Buildroot (ядро, U-Boot и корневая ФС собираются из исходников) |
Отладка логики | JTAG: Hardware Manager, XSCT, встроенный анализатор ILA |
Дисплей на плате | IPS-модуль на ST7789V, физически 172×320, интерфейс SPI |
Исходная платформа-референс | Altera Cyclone IV E EP4CE6F17C8, плата ALINX, Quartus |
Пара пояснений к тому, что здесь не очевидно. Speed grade −2 — «средний по скорости» кристалл, и наших 50 МГц ему хватает с огромным запасом: полная сборка показала запас по установке около 14.6 нс при периоде 20 нс, а предельная частота логики оказалась 258 МГц. Наиболее уязвимое место в этом проекте — Buildroot — система сборки, которая по одному конфигурационному файлу собирает из исходников всё сразу: компилятор для целевой архитектуры, загрузчик U-Boot, ядро Linux, минимальную корневую файловую систему, а на выходе выдаёт образ SD-карты. Альтернатива — взять чужой готовый образ, но тогда вы не управляете ни версией ядра, ни набором драйверов, а именно они нам и понадобятся. Цена — время: первая сборка занимает десятки минут.
3.2. PS и PL: два мира и граница между ними
Вернёмся к архитектуре Zynq подробнее — от неё зависит понимание всех последующих ошибок. PS (processing system) — готовая, вылитая в кремнии микропроцессорная система: два ядра Cortex-A9, кэши, контроллер DDR-памяти, контроллер прерываний, Ethernet, USB, SD, UART и десятки других блоков. Изменить в ней нельзя ничего, можно только сконфигурировать: какие выводы MIO кому отданы, на какой частоте работает память, какие такты уходят в логику. PL (programmable logic) — обычная ПЛИС семейства 7-series, в нашем случае с 53 200 таблицами истинности и 106 400 триггерами; SPI-ядро с AXI-обёрткой занимает около 405 и 406 соответственно, то есть меньше процента.
Между половинами проложено несколько мостов, и они не равноправны. Порты общего назначения GP — для управления: процессор обращается через них к регистрам блоков в логике. Порты высокой производительности HP — для данных: через них блок в логике сам ходит в оперативную память, минуя процессор. Прерывания идут в обратную сторону по шине IRQ_F2P, «fabric to processor». И наконец, процессорная система раздаёт в логику тактовые сигналы FCLK_CLK0…3. В финальном дизайне задействованы почти все мосты сразу: регистры SPI и GPIO на GP0, контроллер DMA читает кадр из памяти через HP0, прерывание уходит в IRQ_F2P, тактируется всё от FCLK_CLK0. Поэтому финальная часть серии и самая насыщенная ошибками: там впервые работают одновременно все каналы связи.
Ошибки на этой границе обидны по одной причине: обе стороны выглядят исправными. Возьмите сценарий из части VII. Ethernet на плате поднимается, ethtool радостно сообщает Link detected: yes, интерфейс в системе есть — а пинг не идёт и счётчик ошибок приёма растёт. Логика подсказывает искать в сети: кабель, настройки, адрес. На самом деле описание железа для Linux досталось от чужой платы и перепрограммирует электрический стандарт выводов процессорной системы поверх того, что уже правильно настроил загрузчик: служебный канал управления физическим уровнем продолжает работать, отсюда «Link Up», а канал данных превращается в мусор. Или сценарий из части IX: вы правите файл .v, собираете новый битстрим, заливаете и получаете ровно то же поведение, что до правки, при зелёной симуляции нового кода.
Общее у этих историй одно: сигнал об ошибке приходит очень далеко от места ошибки. Логика не знает, что процессор перепрограммировал выводы; софт не знает, что в кристалле старая логика; ни один инструмент не жалуется, потому что каждый по-своему прав. Единственная защита — привычка не доверять косвенным признакам и проверять контракт напрямую: прочитать идентификатор, сравнить время изменения файлов, посмотреть реальный электрический стандарт вывода, а не тот, который вы имели в виду.
3.3. Два режима жизни одного IP
Проект существует в двух ипостасях, и для новичка два разных верхних уровня выглядят избыточностью — разберём, почему это не так. Первый режим — автономный, без процессора: верхний уровень spi_zynq_top, и внутри него, кроме ядра, живёт spi_selftest — маленький аппаратный автомат, который сам пишет в регистры по той самой параллельной шине, настраивает делитель и длину слова, включает контроллер и непрерывно гоняет транзакции. Linux не нужен, софта нет никакого: загрузили битстрим — на ножках сразу появилась жизнь. Индикация двумя светодиодами, диагностика через ILA.
Второй режим — продуктовый. Здесь написанного руками верхнего уровня нет вовсе: есть блок-дизайн, схема из готовых блоков, где spi_axi4lite инициализируется как обычный модуль рядом с процессорной системой, контроллером DMA и блоком GPIO. Адрес регистров 0x4000_0000, прерывание заведено в IRQ_F2P, управление — сначала утилитой devmem, потом драйвером spi-zynq-fpga.
Общее у двух картинок — spi_master_top: один и тот же файл, те же провода, то же поведение. Меняется только то, кто исполняет роль хоста.
3.4. Почему два топа, а не один универсальный
Соблазн сделать один верхний уровень с переключателем режимов возникает немедленно, и аргумент за него понятен: один файл проще сопровождать, нет риска, что версии разъедутся. Аргументов против оказалось три, все практические.
Первый — независимость bring-up от софта. Автономный режим проверяет железо, когда всего остального ещё не существует: нет сконфигурированной процессорной системы, загрузчика, ядра Linux, драйвера. Если для проверки ножки SCLK нужно, чтобы сначала загрузился Linux, первая же ошибка в цепочке загрузки блокирует всю аппаратную отладку. Второй — чистота продуктового битстрима: spi_selftest пишет в те же регистры, что и процессор, и в продуктовой сборке он не просто не нужен, а опасен, потому что два источника записи в один регистровый файл — это гонка, которую придётся или арбитрировать, или запрещать параметром. Третий — ограничения физически разные: у автономного варианта свои выводы, свой осциллятор на W17, свой файл ограничений, а у варианта с процессорной системой такт приходит из PS, часть выводов уходит в MIO и часть ограничений генерируется блок-дизайном автоматически. Слить это в один файл значит написать в нём условную логику, а условная логика в ограничениях — верный способ однажды собрать кривой битстрим.
Цена названа честно: два маршрута сборки, обе надо периодически прогонять, и есть риск, что они разойдутся. Риск уменьшен тем, что расходиться почти нечему: оба инстанцируют один и тот же неизменённый spi_master_top, различие живёт в тонком слое над ним. Плюс скрипт build.tcl в автономном режиме отказывается генерировать битстрим, если в файле ограничений остался хотя бы один маркер TBD — незаполненное назначение вывода. Это сделано именно ошибкой, а не предупреждением: битстрим с неверной распиновкой способен физически повредить плату. В исходниках можете изучить это самостоятельно.
3.5. Первая ловушка: level shifters и ps7_init
А теперь затравка на будущее — история из частей IV и V, идеально иллюстрирующая всё сказанное про границу PS/PL. Автономный дизайн процессору логически не нужен: он тактируется от собственного осциллятора, ему не нужна ни память, ни сеть, ни загрузчик. Казалось бы, залил битстрим по JTAG — и работай. На Zynq-7000 это не совсем так: программируемая логика электрически зависит от процессорной системы. Между половинами кристалла стоят согласователи уровней (level shifters), и включаются они программно, кодом инициализации при штатной загрузке. Пока они выключены, ваша логика может честно работать внутри и не иметь связи с внешним миром.
Vivado предупреждает так, что предупреждение легко пропустить: проверка правил проектирования выдаёт единственное замечание ZPS7-1 Warning: PS7 block required — «в дизайне Zynq должен присутствовать блок PS7, иначе не будет правильной конфигурации по умолчанию». Для PL-only дизайна оно ожидаемо и дефектом переноса не является, но именно оно — главный подозреваемый в ситуации «загрузил битстрим, на плате мёртвая тишина». Варианты решения разобраны: дать плате штатно загрузиться с SD-карты и только потом залить логику по JTAG; выполнить ps7_init из XSCT перед загрузкой; либо сразу перейти к блок-дизайну с процессорной системой, где вопрос снимается по построению.
Ловушка простая, а ценна порядком подозрений, который задаёт. Когда после прошивки нет никаких признаков жизни, неправильный первый вопрос — «что не так в моём конечном автомате»; правильный — «а включена ли вообще та половина кристалла, в которой он живёт». Та же логика позже приведёт нас к ps7_init от чужой платы, к электрическому стандарту выводов Ethernet и к устаревшему кэшу синтезатора: одна привычка — куча сохраненного времени и нервов. Кстати, о физическом риске: в списке незакрытых вопросов проекта числится напряжение питания банка 13. Его 3.3 В — заводское значение по умолчанию, переключаемое перемычками на 1.8 или 2.5 В, и если объявить в файле ограничений стандарт LVCMOS33, а банк переключён, испортить можно уже не настроение, а железо. Это единственный пункт серии, где ошибка стоит денег, и в части IV останавливаемся на нём отдельно.
3.6. Порядок первого включения
Полная процедура из тринадцати шагов расписана в части bringup. Часть V проходит её с комментариями. Здесь только скелет, потому что его логика важнее деталей: шаги упорядочены по цене проверки, а не по интересности, и каждый следующий имеет смысл лишь после предыдущего.
Сверка распиновки до подачи питания. Собрать проект и посмотреть отчёт о размещении выводов: все ли назначены явно и совпадают ли с документом распиновки. Тридцать секунд здесь против недели с анализатором потом — именно на этом шаге исходный Altera-проект дал кучу проблем.
Такт и сброс. Загрузить битстрим и посмотреть на светодиод heartbeat: он мигает примерно 1.5 раза в секунду от обычного 25-разрядного счётчика. Это доказывает, что такт живой, сброс снят и логика в кристалле, — независимо от того, работает ли SPI.
Уровни покоя мультиметром. Две минуты и один прибор закрывают класс отказов «не тот вывод, нет питания банка, вывод занят кем-то ещё»: линия выбора в покое около 3.3 В,
SCLKпри нулевой полярности около нуля,MISOподтянут к 3.3 В.Осциллограф на
SCLK, затем наCSиMOSI. Пачки импульсов 1 МГц, линия выбора обрамляет каждый кадр, данные меняются от кадра к кадру.Петля
MOSI→MISOджампером. Единственная проверка, покрывающая весь тракт без внешнего оборудования: сдвиг наружу, выходной вывод, провод, входной вывод, синхронизатор, момент сэмплирования, сборка слова обратно.
Обратите внимание на приём из шага 4, который сам по себе стоит главы: у spi_selftest есть режим непрерывной работы, включённый по умолчанию. В периодическом режиме активность занимает около 10 мкс на каждые 84 мс — скважность 0.012 %, и анализатор в режиме свободного запуска не поймает её никогда: линия выглядит мёртвой при абсолютно живом дизайне. Это второй урок, за который исходный проект заплатил кучей времени.
3.7. Где что лежит
Наконец, карта репозитория — пригодится каждый раз, когда в тексте встретится путь.
SPI/ ├── docs/ ← статья про Altera (Cyclone IV / ALINX) ├── rtl/ ← исходное ядро; эталон для сравнения ├── sim/, quartus/ ← тесты и проект Altera-версии └── spi_xilinx/ ← всё, о чём эта серия ├── rtl/ ← ядро (перенос: 4 файла байт-в-байт) + │ spi_axi4lite, spi_axis_tx, │ spi_zynq_top, spi_selftest ├── constraints/ ← XDC: выводы платы и обоснованные исключения ├── sim/ ← 26 тестов AXI-обвязки, свип делителя ├── scripts/ ← build.tcl, проверка эквивалентности, сверка выводов ├── linux/ ← драйвер spi-zynq-fpga и Device Tree ├── buildroot-external/← конфигурация сборки образа ├── userspace/ ← утилиты, в том числе часы на ST7789 ├── docs/ ← инженерные отчёты (migration, bringup, …) └── docs/article/ ← эта серия (вы здесь)
Разделение на «статью» и «отчёты» намеренное, и им стоит пользоваться. Статья упрощает: объясняет, рассказывает, иногда округляет ради понятности. Отчёты фиксируют доказательства: контрольные суммы файлов, номера строк в исходниках, выводы инструментов, точные числа из Vivado. Если утверждение статьи покажется сомнительным — не спорьте с текстом, откройте migration.md или сам код в ../../rtl/spi_engine.v. Источник истины всегда RTL, а не документация; по этому правилу в проекте нашлось уже несколько расхождений, и все они оказались в пользу кода. Короткий вывод главы: плата и инструменты здесь не фон, а полноценный участник событий. Половина ошибок серии — ошибки не в логике, а в договорённостях платформы, и знание того, где проходит граница PS/PL и кто включает согласователи уровней, стоит дороже, чем ещё одна конструкция Verilog.
Глава 4. Как читать: теория / лаборатория / кладбище ошибок
4.1. Три жанра под одной обложкой
Текст серии намеренно смешан из трёх жанров, и полезно уметь их различать: от жанра зависит, как читать конкретный абзац.
Теория отвечает на вопрос «почему так устроено». Зачем во всём дизайне ровно один тактовый домен и что было бы, если бы их стало два; что такое рукопожатие на шине AXI; почему SCLK у нас не второй тактовый сигнал, а обычный выход триггера, и как это сделало перенос тривиальным. Теорию читают медленно и один раз, она не устаревает ни при каких условиях.
Лаборатория отвечает на вопрос «что делать руками»: собрать такой-то командой, прочитать идентификатор, поставить джампер сюда, повесить щуп туда, ожидать вот такой результат. Эти фрагменты стоит не читать, а выполнять — и записывать то, что получилось, даже если не получилось «как в тексте».
Кладбище ошибок отвечает на вопрос «а что, если не работает». Это истории о том, как всё пошло не так, изложенные без сокращений: с ложными гипотезами, потерянным временем и решениями, которые оказались неверными. Их в серии больше всего, и это не случайность. Без третьего жанра техническая статья незаметно превращается в рекламный буклет: «перенесли ядро, добавили AXI, собрали Linux, вывели картинку» — всё правда и всё бесполезно, потому что читатель получает список результатов и ни одного инструмента для получения своих.
4.2. Шаблон разбора ошибки и почему первая гипотеза врёт
Каждый нетривиальный разбор построен по одному каркасу из шести вопросов. Мы не оформляем его таблицей: таблица создаёт ложное ощущение бюрократической процедуры, тогда как это способ думать.
Начинается всё с симптома, и записать его надо буквально: что вы увидели глазами, в логах, на экране осциллографа. Не «DMA не работает», а «верхняя треть кадра цветная, ниже мусор, линия выбора поднимается через 40 мкс после начала кадра, хотя кадр длится миллисекунды». Первая формулировка — уже вывод, причём чужой; вторая — факт, с которым можно работать. Симптом, записанный как вывод, задаёт направление поиска раньше, чем поиск начался.
Второй вопрос — что казалось логичным: честно зафиксировать первую гипотезу вместе с её обоснованием. Зачем записывать заведомо неверную версию? Затем, что она почти никогда не бывает глупой, и это самое важное наблюдение главы. В примере с дебагом искаженного кадра логичной казалась мысль «процессор не успевает подкладывать данные, надо поднять приоритет процесса» — рассуждение разумное, любой сказал бы то же самое. Именно потому, что гипотеза разумна, на неё можно потратить неделю.
Третий вопрос — как проверяли, и он главный. Хороший эксперимент не подтверждает вашу версию, а делит пространство гипотез пополам: даёт разный результат в зависимости от того, кто прав. Пример из части V: в автономном режиме есть диагностическая сборка, подающая на выводы меандры прямо со счётчика, минуя всё SPI-ядро, причём три разные частоты на трёх выводах, чтобы опознать их одним взглядом. Меандры видны — физика в порядке, ищите в логике; не видны — отлаживать RTL бессмысленно, проблема в выводах, банке, щупе или питании. Одна пересборка, и половина версий отпала.
Четвёртый — корневая причина, сформулированная так, чтобы из неё следовало предсказание: «линия выбора привязана к условию „передающий буфер не пуст“, а его глубина восемь слов при кадре в 110 килобайт» — это причина, сразу видно, что ускорение процессора не поможет; «что-то не так с DMA» — нет.
Пятый — исправление, с указанием, что именно изменилось: строка в коде, в ограничениях, бит в регистре, абзац в документации.
И шестой, единственный обязательный, — правило на будущее: одна фраза, применимая в следующем проекте, где не будет ни этого IP, ни этой платы. «Регистрируемый производитель данных требует запаса по заполнению буфера, равного глубине конвейера, а не сырого признака „полон“» — вот это правило; «мы добавили almost_full» — запись в журнале.
Откуда берётся закономерность про врущую первую гипотезу, стоит сказать прямо, иначе шаблон выглядит суеверием. Гипотеза формируется из того, что вы недавно трогали, и из той части системы, которую понимаете лучше всего; оба источника искажения тянут в одну сторону. Правили драйвер — значит, ошибка в драйвере; хорошо знаете C и хуже знаете AXI DMA — значит, объяснение на стороне C. В результате гипотеза оказывается не самой вероятной, а самой доступной. Помогает приём: прежде чем проверять первую версию, вслух назовите вторую и третью из другой области системы. Ошибка в кадре может жить в приложении, драйвере, описании железа, блок-дизайне, RTL, ограничениях, кэше синтезатора или в самом дисплее — восемь этажей, и если все ваши версии с одного, вы ещё не начали расследование.
И последнее: записывайте улику, а не вывод. Улика — измеренное значение, снимок с анализатора, строка из журнала, время изменения файла; вывод — ваша интерпретация, и он меняется по три раза за вечер. К концу дня вы не вспомните, было ли «CS поднялся рано» измерением или предположением, а это ровно та разница, которая определяет, зря вы потратили день или нет.
4.3. Что за расследования вас ждут
Чтобы не читать серию как справочник, полезно знать заранее, где в ней интересные места. Ниже анонсы без спойлеров: симптом назван, разгадка нет.
В части II живут дефекты самого ядра, унаследованные с Altera. Главный: делитель, установленный в единицу, портит приём — отправляете 0x3C, получаете 0x1E, причём передача при этом идеальна. Второй: бит регистра состояния, по документации сообщающий о переполнении приёмного буфера, не выставляется никогда — и обнаружился он не тестом, а безобидным предупреждением синтезатора про неподключённый порт.
В части IV выясняется, что аккуратно перенесённое из Altera объявление SCLK производным тактовым сигналом делает временной анализ не пессимистичным, а оптимистичным, то есть хуже, чем его отсутствие; там же банк выводов, чьё напряжение зависит от выставленного значения.
Часть V — первое включение и весь букет проблем начинающего: дизайн, который «мёртв», потому что не включена половина кристалла; линия, выглядящая пустой из-за скважности в сотые доли процента; паттерн 0xA5, который кажется случайным, но читается одинаково в обе стороны и потому пропускает ошибку порядка бит насквозь.
Часть VII — самая длинная. Цепочка загрузки собиралась с кодом инициализации от отладочной платы ZC702: тот же кристалл xc7z020, но другая память и другие выводы, и обнаружилось это поиском конкретной константы в двоичном файле загрузчика. Там же Ethernet, который «Link Up», но не пингуется: описание железа от чужой платы ищет физический уровень по неправильному адресу на служебной шине и перепрограммирует выводы в другой электрический стандарт поверх правильной настройки загрузчика. И ядро Linux, зависающее сразу после инициализации выводов, потому что подсистема тактирования выключила «неиспользуемый» такт, на котором работает вся логика. Мой косяк.
Часть VIII — дисплей: панель физически 172 на 320 точек, а часы хочется в ландшафте; команда смены ориентации меняет систему координат, но не содержимое памяти панели, и от старого кадра остаются призраки, плюс смещение начала кадра по вертикали ровно на 34 точки — число, которое нельзя вывести, можно только узнать.
Часть IX — главное кладбище багов. Передающий буфер на восемь слов против кадра в 110 килобайт: линия выбора снимается посреди кадра, и ускорение программы этого не меняет, потому что решение принимает конечный автомат, а не процессор. Сброс контроллера DMA перед каждым кадром, при включённом потоковом режиме порождающий ложный признак конца передачи и двоящуюся картинку. Гонка на один такт между регистрируемым сигналом записи и признаком «буфер полон», из-за которой байты пропадают молча, а шрифт оказывается сжат по горизонтали. И под конец мета-ошибка, самая обидная в курсе: исправление уже в репозитории, новый битстрим собран, а на плате поведение прежнее, потому что синтезатор переиспользовал свой прошлый результат для этого блока. Общее у всех историй: ни одна не про Verilog. Это и есть тезис главы 1, рассказанный перечнем симптомов.
4.4. Что вы должны уметь после частей 0–III
Первые четыре файла — фундамент, и у него есть измеримый результат. Проверьте себя перед тем, как идти в Vivado.
Объяснить своими словами, почему перенос SPI на Zynq — это в первую очередь системная интеграция, а не переписывание конечного автомата.
Провести аудит проекта на вендорские примитивы и не растеряться, когда поиск не нашёл ничего.
Нарисовать по памяти дерево модулей: что копируется байт-в-байт, что оборачивается, что остаётся дома и почему.
Связать ограничение «делитель не меньше двух» с задержкой синхронизатора на входе
MISOи посчитать из него максимальную частоту SPI.Объяснить, зачем параллельная шина превращается в AXI4-Lite, какие четыре отклонения от учебного варианта мы себе позволили и чем за них платим.
Сказать, что делает драйвер, прочитав по смещению
0x3Cзначение0, и почему это правильное поведение.
Если хотя бы один пункт вызывает затруднение — соответствующая часть не прочитана, а пролистана. Дальше будет только плотнее.
4.5. Темп чтения для первокурсника
И последнее — про то, как распределить силы: серия длинная, и сдаться на середине проще, чем кажется. Если Verilog ещё вызывает напряжение, не идите строго по порядку: прочитайте части 0 и I полностью, часть II — до места, где начинаются номера строк исходников, а затем прыгайте в части VIII и IX. Там мало RTL и много зримых причинно-следственных связей, а мотивация от увиденной картинки на дисплее стоит дороже строгой последовательности; к AXI и блок-дизайну вернётесь, когда захочется понять, откуда берутся адреса. Если вы уже собирали Altera-версию, части I и II можно читать по диагонали, но разборы дефектов A-3 и A-5 прочитайте целиком: первый определяет максимальную частоту, которую вы вправе заявить в документации, второй — то, как драйвер обнаруживает потерю данных. Обе вещи всплывут в части VII, когда дойдёт до кода драйвера.
Не пропускайте mermaid-схемы: они сжимают страницу текста в одну картинку, которую удобно держать в голове, стоя у осциллографа. И держите открытой карту регистров: когда в тексте мелькает смещение 0x3C или девятый бит регистра состояния, она играет роль словаря. Наконец, полезная привычка на всю серию — после каждой части выписывать одно правило, которое унесёте с собой. Не конспект, не пересказ, а одну фразу, применимую в проекте, где не будет ни Zynq, ни SPI. К концу наберётся десяток таких фраз, и именно они, а не рабочий битстрим, окажутся главным результатом.
Часть I. Зачем переносить и что показал аудит
Глава 5. Vendor-neutral RTL vs vendor glue
5.1. Что мы хотели понять до того, как что-то трогать
Перед тем как открыть Vivado, нам нужно было ответить на один вопрос: сколько именно кода придётся переписать. Ответ «перепишем половину» и ответ «поменяем два атрибута» ведут к принципиально разным проектам. В первом случае мы делаем новое устройство и обязаны заново доказывать, что оно работает; во втором — переносим уже доказанное и обязаны доказать только то, что ничего не сломали. Разница вся в масштабе доказательства, а не в объёме.
Наивное ожидание звучит так: «перенести с Altera на Xilinx» — значит «переписать Verilog». Оно почти всегда неверно. Verilog — стандартизованный язык описания аппаратуры (IEEE 1364), и синтезаторы обоих вендоров понимают одно и то же его подмножество. Строчка always @(posedge clk) q <= d; означает «триггер, защёлкивающий d по фронту clk», и это верно на Cyclone IV, на Artix-7 и на бумаге. Переносить тут нечего: триггеры есть у всех (ха-ха).
Тогда откуда берётся vendor lock — привязка к конкретному производителю? Не из языка, а из того, что вы в этом языке позвали по имени. Как только вы пишете имя модуля, которого нет в стандарте, — вы позвали деталь из фирменного набора конкретного вендора. И вот эта деталь не переносится.
5.2. Два слоя проекта: чертёж и фирменные детали
Любой FPGA-проект состоит из двух слоёв. Первый — vendor-neutral RTL: поведенческое описание логики. Регистры, конечные автоматы на localparam, счётчики, массивы reg, арифметика, комбинационные выражения. Синтезатор читает это описание и сам решает, какими физическими ячейками кристалла его реализовать. Вы описали что должно происходить, инструмент выбрал чем. Такое описание переносится между вендорами практически даром: меняется только исполнитель.
Второй слой — vendor glue, «клей» к конкретной экосистеме. Сюда попадает всё, что имеет смысл только внутри одного инструмента или одной платы: назначения выводов, файлы временных ограничений, настройки проекта, интеграция с шиной процессорной системы, цепочка загрузки, драйвер. И сюда же — вендорские примитивы, если вы их использовали. Этот слой не переносится по определению: он описывает не логику, а её стыковку с внешним миром, а внешний мир у нас теперь другой.
Ядро нашего SPI почти целиком живёт в первом слое. На Altera второй слой был тонким: пины-заглушки в QSF, небольшой SDC, топ с LCD-дашбордом. На Zynq второй слой становится заметно толще — появляются AXI, Block Design процессорной системы, XDC с банками ввода-вывода, FSBL, Device Tree, Buildroot. Отсюда и сквозной тезис всей серии, заявленный в части 0: работа переноса — не «переделать SPI», а переклеить его к другой экосистеме.
5.3. Что такое вендорский примитив на живых примерах
Абстракция «фирменная деталь» станет понятнее на конкретных именах — все они встречались бы нам в исходном коде, пойди автор Altera-версии обычным путём.
altpll (в старых проектах — ALTPLL) — это PLL, схема фазовой автоподстройки частоты. Физически внутри кристалла есть отдельный аппаратный блок, который умеет из входных 50 МГц сделать 100, 125 или 33.3 МГц с нужной фазой. Вы не описываете его поведенчески — вы инстанцируете его, то есть создаёте экземпляр модуля с именем altpll и передаёте параметрами коэффициенты умножения и деления. На Xilinx аналогичный блок называется MMCM или PLLE2, у него другое имя, другие параметры, другие ограничения. Модуля altpll в библиотеке Vivado не существует — синтез просто скажет, что такого модуля нет.
Остальные имена устроены так же. altsyncram — блочная память (ячейки M9K у Cyclone IV, BRAM у 7-series). scfifo и dcfifo — готовые FIFO, одно- и двухтактовый, построенные поверх той же блочной памяти. Семейство lpm_* — библиотека параметризуемых счётчиков и умножителей, исторически «стандартная», а на практике реализованная у каждого вендора по-своему. altddio_in/altddio_out — регистры, защёлкивающие данные по обоим фронтам. alt_iobuf — явный буфер ввода-вывода с управлением третьим состоянием.
Есть ещё две категории, о которых новички забывают. Первая — атрибуты синтеза: пометки вида /* synthesis keep /, ( altera_attribute = "..." ), / synthesis translate_off */. Они не меняют логику, но управляют поведением инструмента, и каждый вендор понимает свой набор. Вторая — файлы IP-каталога: .qip в Quartus, .xci в Vivado, проекты Platform Designer / Qsys — сгенерированные описания настроенных блоков, привязанные к версии инструмента ещё крепче, чем к вендору.
Общее правило стоит запомнить дословно: поведенческий Verilog описывает намерение, вендорский примитив называет деталь. Намерение переводится на любую платформу, потому что триггер есть у всех. Название детали не переводится: у другого вендора она называется иначе, ведёт себя не совсем так и настраивается другими параметрами.
Мегафункция Altera | Что это | Аналог у Xilinx | Что делает наш проект |
|---|---|---|---|
| PLL, синтез частот | MMCM / PLLE2 | нет: один клок, деление счётчиком |
| блочная память M9K | BRAM (RAMB18/36) | нет: FIFO — массив |
| синхронный FIFO |
| написан руками |
| асинхронный FIFO |
| нет: второго домена нет |
| счётчики, умножители | инференс из выражений | нет: обычная арифметика |
| DDR-регистры I/O | IDDR / ODDR | нет: DDR I/O не нужен |
| буфер I/O | IBUF / OBUF / IOBUF | нет: буферы выводит синтезатор |
5.4. Инференс против инстанса: почему это архитектурное решение
Теперь самое важное понятие главы. У памяти внутри FPGA есть два способа появиться в вашем проекте. Первый — инстанс примитива: вы явно создаёте экземпляр altsyncram или RAMB36E1, задаёте разрядность, глубину, режим чтения, и получаете ровно ту аппаратную ячейку, которую заказали. Это как заказать деталь по каталожному номеру: вы точно знаете, что приедет, но приедет оно только с одного склада.
Второй — инференс (от английского inference, «вывод по признакам»): вы описываете память как обычный массив регистров, а синтезатор сам догадывается, что это память, и выбирает, чем её реализовать. Это как написать в чертеже «здесь нужна ёмкость на восемь слов по 32 бита» и позволить производству решить, собирать её из триггеров, из распределённой памяти в таблицах истинности или из блока памяти. Инференс работает по шаблонам: синтезатор умеет узнавать несколько канонических способов записать память на Verilog.
Наш FIFO написан ровно во втором стиле — вот его хранилище и чтение целиком (../../rtl/spi_fifo.v):
reg [DATA_WIDTH-1:0] mem [0:DEPTH-1]; reg [ADDR_WIDTH:0] wr_ptr; reg [ADDR_WIDTH:0] rd_ptr; 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} });
Ни одного вендорского имени. Массив, два указателя, разность указателей как счётчик занятости и комбинационное чтение: rd_data — просто провод от головы очереди, без промежуточного регистра. Указатели на один бит шире адреса затем, чтобы их разность отличала «пусто» от «полно» без дополнительного флага.
И вот самое поучительное место: один и тот же текст два инструмента реализуют разными ячейками. Quartus на Cyclone IV собрал хранилище из логических элементов и триггеров — сводка фиттера показывает Memory bits: 0. Vivado на 7-series увидел тот же шаблон и вывел распределённую память, LUTRAM: таблицы истинности, переключённые в режим маленького ОЗУ. В отчёте это 44 LUT категории «LUT as Distributed RAM» для сборки IP с AXI-обвязкой и 24 для автономной. Логика и поведение идентичны, физическая реализация разная, и для Xilinx она выгоднее.
Почему инференс — архитектурное решение, а не лень автора? У него есть и цена, и выигрыш, и их выбирают осознанно. Выигрыш: переносимость, отсутствие привязки к версии инструмента, читаемый код, который моделируется любым симулятором. Цена: вы не управляете точно тем, что получится. Если бы FIFO был глубиной в тысячи слов, разница между «блок памяти» и «тысячи триггеров» стала бы разницей между «помещается» и «не помещается». Именно поэтому в шапке spi_fifo.v стоит честная оговорка автора: для глубин от 32 записей стоит перейти на вариант с синхронным чтением, дружественный к блочной памяти, а для используемых 4–16 слов инференс в регистры уместен. Практическое правило: инференс хорош, пока вам всё равно, в какую из подходящих ячеек попадёт описание. Как только конкретная ячейка становится требованием, придётся либо переписать код под её шаблон, либо инстанцировать примитив и потерять переносимость.
5.5. CDC: единственное место, где вендор всё-таки просочился в код
Теперь про единственное изменение, которое перенос всё же внёс в существующий RTL. Чтобы понять, зачем оно, нужно разобраться с двумя понятиями.
Метастабильность. Триггер защёлкивает данные по фронту клока, но требует, чтобы вход был стабилен небольшое время до фронта (setup) и после него (hold). Если сигнал меняется ровно в этом окне, триггер попадает в неопределённое состояние: выход не 0 и не 1, а «где-то посередине», и он может оставаться там долго, прежде чем свалится в одну из сторон. Это физика бистабильной ячейки, и полностью устранить эффект нельзя.
CDC, Clock Domain Crossing — пересечение тактовых доменов, то есть передача сигнала из области, тактируемой одним клоком, в область, тактируемую другим. Если клоки не связаны, момент смены сигнала относительно фронта приёмника непредсказуем, и запретное окно рано или поздно будет задето. То же относится к любому асинхронному входу извне: он тоже меняется когда хочет.
Лекарство — синхронизатор из двух триггеров. Первый может уйти в метастабильное состояние, но у него есть целый такт, чтобы из него выйти; второй защёлкивает уже устоявшееся значение. Вероятность того, что метастабильность переживёт обе ступени, падает на порядки — до значений, при которых среднее время между сбоями измеряется годами. Так устроен spi_reset_sync:
(* ASYNC_REG = "TRUE" *) 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];
Сама цепочка — чистый vendor-neutral код: сдвиговый регистр из двух разрядов. А вот строка (* ASYNC_REG = "TRUE" *) — уже метаданные для Vivado, и именно она составляет всё изменение RTL при переносе. Что она обещает инструменту, записано в шапке файла: держать триггеры цепочки в одном слайсе (чем короче связь между ступенями, тем больше времени на разрешение метастабильности), не применять к ним ретайминг и не сворачивать их в сдвиговый регистр SRL, а также пометить цепочку для отчётов report_cdc и report_synchronizer, чтобы пересечение было распознано, а не отнесено к небезопасным путям.
Ключевое слово — «распознано». Vivado не обязан догадываться, что два триггера подряд образуют синхронизатор, а не случайную задержку. Без атрибута он вправе оптимизировать их как удобно и классифицировать пересечение как непроверенное. С атрибутом отчёт после трассировки даёт «All paths are Safely Timed». Quartus делал тот же вывод из самой структуры асинхронного сброса, поэтому исходнику атрибут не требовался.
Вариантов было три. Первый — не трогать код и надеяться, что Vivado догадается: отвергнут, «надеяться» — не инженерный аргумент, да и отчёт CDC остался бы не в нашу пользу. Второй — инстанцировать готовый макрос xpm_cdc_single из библиотеки Xilinx: он делает то же самое, но завёл бы в vendor-neutral ядро вендорское имя, то есть создал бы собственными руками тот самый lock-in, отсутствию которого мы только что радовались. Третий, выбранный, — добавить атрибут. Логика не меняется ни на бит: то же число триггеров, те же связи, то же поведение в симуляции.
Цена третьего варианта интереснее, чем кажется. Два файла — spi_engine.v и spi_reset_sync.v — перестают быть побайтово равными оригиналу. Значит, самое простое доказательство эквивалентности («файлы совпадают — значит ведут себя одинаково») для них не работает, и нужен второй уровень: показать, что в различиях нет ничего, кроме атрибута и комментариев. Именно поэтому в скрипте проверки из главы 6 есть отдельный слой ровно для этих двух файлов. Граница проходит здесь: логика остаётся vendor-neutral, метаданные становятся vendor-aware. Это нормальное состояние переносимого IP.
5.6. Чем мы заплатили за vendor-neutral стиль
Красивая история про «ядро переносится даром» была бы нечестной без второй половины: за переносимость платят, и счёт приходит позже.
Готовый scfifo из каталога Quartus или FIFO Generator из каталога Vivado — это не просто «массив с указателями». Из коробки там programmable full и programmable empty (пороги, при которых поднимаются флаги «скоро заполнится» и «скоро опустеет»), регистр занятости, защита от переполнения, выбор между распределённой и блочной памятью одним переключателем, готовый асинхронный вариант с корректными счётчиками Грея, часто и контроль чётности.
Написав FIFO руками, мы получили ровно то, что написали: full, empty, overflow, underflow, count. Ни одного порога. И когда проект дошёл до потоковой передачи через AXI DMA, выяснилось, что порога не хватает. Продюсер spi_axis_tx регистрирует сигнал записи: решение «пишем» принимается по состоянию full на такте N, а сама запись приходит на такте N+1. FIFO принимает слово только при wr_en && !full. У самой границы заполнения — а под DMA полноэкранная передача живёт именно у границы — открывается окно в один такт, в котором запись молча пропадает. На экране ST7789 это выглядело как горизонтально сжатая картинка: часть байтов не доехала.
Лечение — тот самый порог, которого не было:
/* * Producers that register wr_en (spi_axis_tx) sample full one cycle late. * Backpressure two entries early so a delayed write cannot land on "full" * and be discarded by the wr_en & ~full gate — that drop showed up as a * horizontally compressed ST7789 image under AXI DMA (FIFO stays near full). */ assign almost_full = (count >= (DEPTH - 2));
Полный разбор гонки — глава 41 в части IX; здесь она важна как иллюстрация цены. Если бы FIFO был взят из каталога, almost_full с настраиваемым порогом лежал бы в нём с самого начала, и целого детектива не случилось бы. Мы обменяли готовую функцию на переносимость и заплатили одним багом, найденным на живом железе.
Стоила ли сделка того? Да. Баг мы нашли, поняли и исправили одной строкой в собственном коде — его можно прочитать целиком за пять минут. Если бы каталожный FIFO повёл себя неожиданно, мы бы отлаживали чужой сгенерированный netlist. И главное: без vendor-neutral стиля этой статьи не было бы вовсе.
5.7. Вывод главы
Vendor lock живёт не в языке, а в именах, которые вы позвали. Чистый поведенческий Verilog переносится между вендорами, потому что описывает намерение; мегафункции не переносятся, потому что называют деталь. Инференс — осознанный выбор в пользу переносимости ценой контроля над реализацией, и его последствия видны в отчётах о ресурсах: тот же массив стал LE-регистрами у Quartus и LUTRAM у Vivado. Единственная щель, через которую вендор всё-таки попал в наш RTL, — атрибут ASYNC_REG на цепочках синхронизаторов; это метаданные, а не логика. А за отказ от каталога мы заплатили отсутствующим almost_full, счёт за который пришёл в части IX. Осталось проверить, что всё сказанное — правда, а не самоуверенность. Этим занимается аудит.
Глава 6. Аудит: поиск altpll / scfifo — пустой результат
6.1. Чего мы хотели от аудита и почему «я помню» не подходит
Задача аудита формулируется в одну строку: получить список всего, что придётся переделать, до того как начнётся переделка. Не «примерно понять объём», а именно список, который можно предъявить, оспорить и по которому потом отчитаться.
Соблазнительный вариант — положиться на память автора: «я точно не ставил PLL, FIFO писал руками, IP-каталогом не пользовался». Мы его отвергли, и не из недоверия. Память отвечает за то, что человек делал сознательно, и не отвечает за то, что просочилось само: за строку, добавленную год назад при отладке, за настройку, автоматически дописанную инструментом в файл проекта, за подключённый чужой модуль. Кроме того, память не оставляет следа: через полгода ответ «я помню, что dcfifo там нет» ничего не стоит, а строка в отчёте с шаблоном поиска и списком просмотренных файлов — стоит. Поэтому аудит у нас — механическая процедура с чек-листом, результат которой записывается независимо от того, нашлось что-то или нет. Второе важнее первого: отрицательный результат тоже надо зафиксировать, иначе он неотличим от непроведённой проверки.
6.2. Как именно искали
Поиск шёл по всем файлам с расширениями .v, .vh, .qsf, .qpf, .sdc, .tcl, *.qip — то есть не только по RTL, но и по файлам проекта Quartus, по временным ограничениям и по скриптам сборки. Это принципиально: вендорская зависимость может сидеть не в коде, а в настройке проекта. Шаблон поиска покрывал весь список искомых сущностей одним регулярным выражением:
alt(pll|syncram|ddio|_iobuf|era_)|scfifo|dcfifo|lpm_|cyclone|arria| megafunction|altera|synthesis (translate|keep|noprune)|DEVICE_FAMILY
Прочитаем его вслух — в нём видна логика чек-листа. Первая группа ловит семейство alt*: PLL, блочную память, DDR-регистры, буферы ввода-вывода и любое имя, начинающееся с altera_. Дальше оба FIFO из каталога и семейство lpm_*. Слова cyclone и arria ловят не примитивы, а привязку к устройству, где бы она ни лежала; megafunction и altera — широкий невод на комментарии, заголовки и настройки. Группа synthesis (translate|keep|noprune) ищет прагмы синтеза в стиле Quartus, а DEVICE_FAMILY — параметр, который Quartus подставляет в сгенерированные обёртки.
К автоматическому поиску добавилась ручная проверка того, что регуляркой не ловится: порты inout и присваивания 1'bz (признак логики с третьим состоянием внутри кристалла), файлы .qip, атрибуты (* altera_attribute ), проекты Platform Designer / Qsys (.qsys, *_hw.tcl) и сигналы чужих шин — waitrequest, readdatavalid, stb/cyc/ack, по которым опознаются Avalon и Wishbone.
6.3. Что нашлось: шесть попаданий, ноль примитивов
Регулярка сработала — но ни одно попадание не оказалось вендорским примитивом в синтезируемом коде. Всё найденное распадается на две категории: текст в комментариях и настройки уровня проекта Quartus.
Файл | Строка | Что найдено | Классификация |
|---|---|---|---|
| 4 |
| комментарий |
| 9 |
| комментарий |
| 4 |
| комментарий |
| 6 |
| настройка проекта |
| 21, 42 |
| настройка проекта |
| 4 |
| комментарий |
Пройдёмся по чек-листу словами, чтобы отрицательные результаты были названы явно. PLL, MMCM и любых генераторов частоты в дизайне нет вообще: тактовый домен один, деление выполняется счётчиком. Блочной памяти нет — хранилище FIFO описано массивом reg [31:0] mem [0:7]. Ни scfifo, ни dcfifo: FIFO написан руками. DDR-регистров ввода-вывода нет. Явных буферов IBUF/OBUF/IOBUF нет — их выводит синтезатор. Логики с третьим состоянием внутри кристалла нет: ни одного порта inout, ни одного присваивания 1'bz. Модулей lpm_*, файлов .qip, сериализаторов и десериализаторов нет. Атрибутов синтеза Quartus нет ни одного: из директив используются только стандартные default_nettype и include.
Есть и косвенное подтверждение, независимое от нашего поиска. Сводка фиттера Quartus показывает Memory bits: 0 и PLLs: 0. Ноль бит блочной памяти и ноль PLL означают, что мегафункцию было негде спрятать: стой где-то altsyncram или altpll, счётчики были бы ненулевыми. Подтверждение ценно тем, что приходит с другой стороны — не от чтения текста, а от результата компиляции.
6.4. Почему пустой результат — это факт
Здесь возникает странное чувство: потратили время, ничего не нашли, вроде бы зря. Ощущение мешает делать аудит как следует, поэтому разберём его.
Пустой результат поиска — полноценный инженерный факт, и у него есть цена. Он означает, что этап «заменить вендорские примитивы на аналоги» можно вычеркнуть из плана целиком. В типичном переносе это самая скучная и самая рискованная часть: у scfifo и xpm_fifo_sync разная семантика флагов, разная задержка чтения, разное поведение при одновременных чтении и записи, и подмена одного другим почти всегда тянет за собой правку логики вокруг. Мы этого не делаем — и знаем об этом заранее, а не выясняем на третьей неделе. Вторая ценность менее очевидна: записанный отрицательный результат защищает от повторной проверки. Проверка, не оставившая следа, считается непроведённой — это не бюрократия, а способ не делать одну работу дважды.
И третья, самая важная: пустой результат по примитивам не означает пустой список работ. Это разные множества. Вендорская зависимость нашего проекта жила не в ядре протокола, а в окружении — в пинах, в constraints, в способе доступа с хоста, в периферии платы. Об этом — следующий разбор.
6.5. Детектив первый: «пустой grep — это успех или провал?»
Симптом. Регулярное выражение по всему репозиторию не находит ни одного Altera-примитива в синтезируемом коде. Шесть попаданий, все — текст.
Что казалось логичным. Раз в коде нет ничего вендорского, перенос тривиален: копируем шесть файлов .v и .vh в проект Vivado, нажимаем «собрать», получаем битстрим. День работы, максимум два.
Как проверяли. Вместо того чтобы поверить в эту оценку, посмотрели на проект целиком, а не только на RTL-ядро. Открыли топ отладочной ревизии spi_master_fpga_top и пересчитали выводы: параллельная шина отладки занимает порядка семидесяти контактов. Открыли quartus/spi_master.qsf — файл сам называет свои назначения заглушками, а часть сигналов вообще не имеет set_location_assignment. Прочитали quartus/spi_master.sdc по одному ограничению: останется ли каждое осмысленным на другой платформе. Посмотрели, на чём держится наблюдаемость: на LCD-панели 480×272 и на опросе XPT2046 — двух устройствах, которых на новой плате нет.
Корневая причина. Мы искали vendor lock не там, где он был. Ядро протокола чистое, но проект — это не только ядро. Зависимость от платформы сосредоточилась в слое glue: в распиновке, во временных ограничениях, в способе общения с хостом и в наборе периферии. Пустой grep сказал правду о первом слое и ничего — о втором.
Исправление. План миграции построен вокруг шести обязательных работ M-1…M-6, и ни одна не называется «заменить scfifo на xpm_fifo». M-1 — атрибуты ASYNC_REG на синхронизаторы, единственная правка существующего RTL. M-2 — замена топа с отладочной шиной на два целевых: spi_axi4lite для процессорной системы и spi_zynq_top для автономной проверки. M-3 — исключение LCD-подсистемы. M-4 — XDC с нуля по фактической схеме платы. M-5 — осмысленная переработка временных ограничений. M-6 — сохранение контракта стробов регистрового интерфейса в новом AXI-автомате. Единственная работа с высоким риском — M-4: неверно назначенный вывод способен повредить железо.
Правило на будущее. Пустой поиск примитивов — хороший знак для ядра и плохой повод расслабиться. Аудируйте glue отдельным проходом и по отдельному чек-листу: пины, шина, временные ограничения, цепочка загрузки, периферия. Объём работы определяет обвязка.
6.6. Как доказывали, что перенос эквивалентен
Отдельный вопрос: чем подтверждается утверждение «мы перенесли то же самое»? Успешный синтез таким подтверждением не является — компилируется и неправильный дизайн. Мы рассматривали три варианта. Первый — заявить, что ничего не меняли, и сослаться на аккуратность: отвергнут, это не доказательство, а обещание. Второй — прогнать тесты на перенесённой версии и показать, что все зелёные: лучше, но недостаточно, потому что тест проверяет то, что в нём написано, и молчит обо всём остальном. Третий, выбранный, — построить доказательство слоями, от сильного к слабому, и автоматизировать проверку так, чтобы она падала при любом отклонении. Он реализован в ../../scripts/check_equivalence.sh.
Слой первый — идентичность исходников. Для каждого файла, который отчёт объявляет неизменным, сравниваются контрольные суммы MD5 оригинала из ../../../rtl/ и копии из ../../rtl/. Это самое сильное доказательство, какое возможно: идентичный текст не может вести себя по-разному, потому что это буквально один и тот же текст. На момент миграции слой давал четыре совпадения — spi_defs.vh (aa11188644468a63…), spi_fifo.v (90b2f8da0fad37b5…), spi_reg_if.v (8adb7e0203941592…) и spi_master_top.v (e268a993d4b702d4…).
Слой второй — объявленные различия. Для двух файлов, объявленных изменёнными, скрипт берёт diff, выбрасывает пустые строки, строки комментариев и строку самого атрибута ASYNC_REG — и требует, чтобы остаток был пустым. Если после фильтрации остаётся хоть одна строка, значит изменилось выражение, порт, параметр или состояние автомата, и заявление «изменение только атрибутивное» ложно. Доказательство слабее первого, но структурное: оно не зависит от того, насколько хороши наши тесты.
Слой третий — поведенческое сравнение. Эталонный тестбенч Altera гоняется против оригинального RTL, тестбенч AXI — против перенесённого, затем из обоих логов извлекаются вердикты PASS/FAIL по именам тестов, берётся пересечение имён и сравниваются вердикты. Скрипт специально проверяет, что пересечение не пустое: если общих тестов не нашлось, это не «всё сошлось», а ошибка разбора логов, и она считается провалом. Тонкость важная — она защищает от самого опасного вида зелёного результата, полученного из-за того, что проверка просто не выполнилась. Скрипт возвращает ненулевой код, если провалился любой слой, и вызывается одной командой make equiv. Ключевая мысль его шапки: проверка самополицейская — случайная правка «замороженного» файла превращается не в тихое расхождение, а в жёсткий отказ.
6.7. Как этот механизм сработал на нас самих
Проверим утверждения слоя 1 на текущем состоянии репозитория — не по отчёту, а пересчётом контрольных сумм. Результат поучителен: сегодня совпадают только spi_defs.vh и spi_reg_if.v, а spi_fifo.v, spi_master_top.v и spi_engine.v от оригинала отличаются.
Это не опровержение аудита и не ошибка в отчёте, который фиксирует состояние на момент миграции, и на тот момент четыре файла действительно были побайтово равны, а spi_engine.v отличался только атрибутом. Разошлись они позже и осознанно, в части IX, когда появился поток через AXI DMA: в spi_fifo.v добавился порог almost_full, в spi_master_top.v — сквозные сигналы потокового режима и второй источник записи в TX FIFO, в spi_engine.v — режимы stream_en и tx_only вместе с новым состоянием автомата S_WAIT_TX.
Именно так механизм и должен работать. Пока файл объявлен замороженным, любая правка в нём — случайная или намеренная — немедленно становится видимой: контрольная сумма расходится, слой 1 падает, и вопрос «а вы точно ничего не трогали?» получает честный ответ. Когда изменение осознанное, меняется не код проверки, а объявление: файл переходит из «неизменных» в «изменённые с доказанным перечнем отличий». Плохо не то, что файл изменился, — плохо, когда он изменился, а документ об этом молчит. Отсюда правило главы: доказательство сильнее уверенности, а автоматическое доказательство сильнее разового. Проверка, запускаемая одной командой, переживёт и вашу память, и вашу аккуратность.
6.8. Детектив второй: предупреждение, которое оказалось дефектом
Теперь обещанная история про то, почему «просто скопировать и собрать» не проходит с первого раза. Формально Vivado принял скопированные файлы: elaboration прошла с нулём ошибок и нулём critical warnings. Но по пути инструмент выдал предупреждения — 55 на синтезе и 52 на имплементации, — и одно из них оказалось не шумом.
Симптом. При elaboration Vivado сообщил:
WARNING: [Synth 8-7129] Port eng_err_rx_ovf in module spi_reg_if is eitherunconnected or has no load
Что казалось логичным. Не обращать внимания. Предупреждений этого же кода в сборке сорок шесть, и большинство безобидны: зарезервированные разряды delay_cfg[31:24], неиспользуемые по осознанным отклонениям awprot/arprot/wstrb (D-1 и D-3 в части III), младшие биты байтового адреса. Логика «предупреждение о неподключённом порте — косметика» выглядит разумно и в девяти случаях из десяти верна.
Как проверяли. Вместо того чтобы отмахнуться, прошли по цепочке от бита статуса до его источника — три звена, каждое проверяемо по исходнику. Первое: spi_reg_if.v:99 формирует девятый бит регистра STATUS (флаг ERR_RX_OVF) из сигнала rx_fifo_overflow — собственного флага FIFO, который выставляется по условию wr_en && full (spi_fifo.v:74). Второе: spi_engine.v:340-345 показывает, что при заполненном приёмном FIFO движок не поднимает rx_wr_en, а выставляет свой флаг err_rx_overflow. Значит условие wr_en && full со стороны движка невозможно и флаг FIFO недостижим. Третье: флаг движка приходит на порт spi_reg_if.eng_err_rx_ovf — и в теле модуля не читается ни одним выражением. Ровно про этот порт и говорило предупреждение.
Затем предположение проверили экспериментом. Тест 26 в sim/tb_spi_axi4lite.v устраивает настоящее переполнение: принимает шестнадцать слов в восьмисловный FIFO и не вычитывает их. Лог:
A-5 EVIDENCE: STATUS=0000007e RX_FULL=1 ERR_RX_OVF=0 IRQ_ERROR=1
Переполнение произошло — RX_FULL=1. Бит ERR_RX_OVF остался нулём. При этом условие видно через прерывание: IRQ_ERROR=1.
Корневая причина. Бит 9 регистра STATUS не может быть установлен ни при каких условиях: он подключён к источнику, недостижимому в этой схеме, а достижимый источник никуда не подключён. Это дефект A-5, и он структурный — живёт в неизменённых файлах spi_reg_if.v и spi_engine.v, значит присутствует в Altera-версии в том же виде. Мы его не привезли с собой, мы его нашли. Ошибка при этом не теряется полностью: spi_master_top.v:69-70 включает флаг движка в источник события ошибки, поэтому переполнение видно через IRQ_STATUS.ERROR. Недоступно оно именно через STATUS.
Исправление. Дефект не исправлен, и это осознанное решение. Лечится он одной строкой — заменить в spi_reg_if.v:99 источник бита 9 на rx_fifo_overflow | eng_err_rx_ovf. Но правка меняет наблюдаемое поведение регистра: там, где был ноль, появится единица, а это разрушает аргумент эквивалентности. Поэтому дефект задокументирован, зафиксирован тестом, описан в ../register_map.md с обходным путём (детектировать переполнение через IRQ_STATUS.ERROR) и вынесен в список необязательных улучшений под идентификатором O-1. Полный разбор — в части II.
Правило на будущее. Не глушите предупреждения массово. Проведите перепись: сгруппируйте по коду, посчитайте и по каждой группе напишите одну строку — почему она допустима. В нашей сборке из семи классов пять существуют исключительно из-за режима out-of-context (портов-падов в нём нет, поэтому и претензии к ним неуместны), один — намеренное следствие отклонения D-3 (константные коды ответа AXI), и ровно один потребовал внимания по существу. Именно он и привёл к находке. Стоимость переписи — час; стоимость пропущенного дефекта — драйвер, который не замечает потерю принятых данных.
6.9. Что ещё не переносится автоматически: constraints
Кроме кода есть временные ограничения, и с ними ситуация обратная: SDC от Quartus и XDC от Vivado похожи настолько, что провоцируют на механический перевод. Это ловушка: часть ограничений исходного проекта семантически неверна на новой платформе, а некоторые были сомнительны и на старой.
create_clock на входные 50 МГц переносится по смыслу — это объявление реального внешнего клока, осмысленное везде. А вот create_generated_clock на spi_sclk не переносится вовсе; отклонение зафиксировано под идентификатором C-2. Причин три. Коэффициент деления не константа: CLK_DIV — регистр, программируемый во время работы, а в SDC стоит фиксированное -divide_by 10. Сам spi_sclk не тактирует ни одного триггера — это обычный регистровый выход, и объявить его клоком значит создать фиктивный домен, а потом закрывать порождённые им межклоковые пути исключениями. Наконец, заявленный как пессимистичный делитель 10 на деле оптимистичен: при CLK_DIV = 2 реальный делитель равен шести.
set_input_delay на spi_miso заменён объявлением входа асинхронным — отклонение C-3. Исходный SDC трактует MISO как source-synchronous вход, привязанный к SCLK, но архитектура IP сознательно отказалась от этой привязки в пользу синхронизатора. Объявлять соотношение, которым дизайн не пользуется, — значит получать бессмысленные отчёты временного анализа. Цена честности названа прямо: анализатор не проверяет тракт MISO, и корректность приёма гарантируется правилом CLK_DIV >= 2, а не ограничением. Потому предел A-3 и зафиксирован тестом 25.
Общее правило: ни один false_path и ни один multicycle не копируется «потому что так было». Каждое исключение обязано заново заслужить право на существование, а необоснованное исключение опаснее его отсутствия — оно даёт ложное закрытие временных требований, то есть отчёт, в котором всё зелёное, а железо не работает. Подробный разбор перевода QSF и SDC в XDC — в части IV.
6.10. Вывод главы
Аудит — механическая процедура с записанным результатом, а не воспоминание. Он дал шесть попаданий, ни одно из которых не оказалось примитивом в синтезируемом коде, и это полноценный факт: целый этап работ вычеркнут заранее. Отрицательный результат по ядру ничего не говорит об обвязке, поэтому список работ M-1…M-6 вырос из разбора пинов, шины, ограничений и периферии. Эквивалентность доказана тремя слоями и механизм оказался достаточно строгим, чтобы позже честно показать: три файла перестали быть идентичными, и произошло это в части IX. Одно предупреждение из пятидесяти пяти привело к находке дефекта A-5 — достаточная причина никогда не глушить предупреждения.
Глава 7. Дерево модулей: что копируем, что добавляем
7.1. Что было в Altera-проекте
Исходное дерево ../../../rtl/ содержит десять файлов верхнего уровня плюс каталог lcd/ ещё из девяти. Не всё из этого — «ядро SPI»: проект жил в двух ревизиях Quartus. Ревизия spi_master — минимальная, только контроллер: топ отладочной платы, интеграционный модуль, регистровый интерфейс, движок протокола, FIFO в двух экземплярах, синхронизатор сброса и заголовок с картой регистров. Ревизия spi_lcd добавляла инициализатор demo_init, опросчик touch-контроллера spi_probe, топ spi_lcd_fpga_top и подсистему rtl/lcd/* — девять модулей вывода на RGB-панель (тайминги развёртки, знакогенератор 5×7, индикаторы, метки, генератор узоров).
Модуль Altera | Роль | Vendor-specific | Судьба при переносе |
|---|---|---|---|
| сборка reg_if + 2×FIFO + engine | нет | байт-в-байт |
| регистры, sticky-флаги, IRQ | нет | байт-в-байт |
| синхронный FIFO с флашем | нет | байт-в-байт |
| FSM протокола: SCLK, MOSI, CS, MISO | нет | + |
| 2FF: async assert, sync deassert | нет | + |
| карта битов и смещений | нет | байт-в-байт |
| топ платы + debug-шина ~70 выводов | нет | заменён (M-2) |
| дашборд 480×272, 9 модулей | нет | не переносится (M-3) |
| ROM-инициализация регистров | нет | идея → |
| опрос XPT2046, вывод координат | нет | идея → |
Колонка «Судьба» описывает состояние на момент переноса; о том, как три файла разошлись позже под поток и DMA, сказано в главе 6.7. А вот колонка «Vendor-specific» пустая вся: ни один модуль, включая LCD-подсистему, не содержит вендорских примитивов. Значит, ничто из исключённого не выброшено «потому что технически не переносится» — решения принимались по другим причинам, и их стоит упомянуть.
7.2. Что копируем и почему байт-в-байт — принципиально
Четыре файла ядра перенесены копированием без единой правки, ещё два — с добавлением одного атрибута и комментариев. Резонный вопрос: почему такая педантичность? Разве нельзя было заодно поправить мелочи — привести комментарии в порядок, исправить найденные дефекты, переименовать пару сигналов? Нельзя.
Утверждение «мы перенесли то же самое» — самое ценное, что даёт весь перенос, и его сила прямо зависит от того, насколько дёшево его можно проверить. Пока файл идентичен, проверка стоит одну команду и не требует доверия ни к кому. Как только появляется правка — любая, даже безусловно улучшающая — проверка перестаёт быть механической и превращается в рассуждение: «мы поменяли вот это, вот почему это не влияет на поведение, вот тесты». Рассуждение можно оспорить, в нём можно ошибиться, его надо перепроверять после каждого изменения.
Отсюда жёсткое правило: сначала доказуемо эквивалентная версия, потом отдельным шагом улучшения. Именно поэтому все найденные дефекты — A-1 и A-2 (недопустимые part-select при экзотических значениях параметров), A-3 (сдвиг принятого слова при CLK_DIV = 1), A-4 (молчаливый отказ записи нуля в делитель), A-5 (мёртвый бит переполнения) и A-6 (неполные назначения выводов в QSF) — описаны, зафиксированы тестами и не исправлены. Их исправления вынесены в отдельный список O-1…O-5 с пометкой, какое из них меняет наблюдаемое поведение. Смешать перенос с рефакторингом — значит потерять возможность сказать, что именно сломалось.
Второе решение того же ряда: интерфейс spi_master_top сохранён без изменений — те же wr_en, rd_en, addr, wr_data, rd_data, ready, irq. AXI строится над ним, а не вместо него. Datapath SPI не переписывался ради смены системной шины, и результат измерим: вся AXI-обвязка стоит 12 таблиц истинности и 79 триггеров против 405 и 406 у всего IP целиком. Тонкая прослойка — ровно как и задумывал, когда делал хост-интерфейс собственным.
7.3. Что добавляем: четыре новых модуля
Вся новизна переноса сосредоточена в четырёх файлах.
Новый модуль | Роль | Что заменил или добавил |
|---|---|---|
| AXI4-Lite slave → стробы parallel MM | заменил debug-шину как доступ с хоста |
| PL-only топ для bring-up | заменил |
| автономный «мини-хост» без софта | заменил |
| AXI4-Stream → слова TX FIFO | добавлен позже, для потока и DMA |
spi_axi4lite — единственная новая шинная логика во всём переносе. Задача звучит просто: принять транзакцию AXI4-Lite и превратить её в один такт строба для регистрового интерфейса. Сложность в слове «один». Регистровый интерфейс ядра — не универсальный файл регистров, у него три чувствительных к времени свойства, которые небрежный адаптер ломает молча: стробы wr_en и rd_en должны длиться ровно такт (удержание на всю длину рукопожатия AXI выполнит доступ несколько раз), rd_data комбинационный и действителен только в такт с поднятым rd_en, а чтение RX_DATA извлекает слово из FIFO. Автомат обёртки поэтому обслуживает одну транзакцию за раз и формирует стробы декодированием из состояния, так что свойство «ровно один строб на доступ» доказуемо по построению. Тесты 19–21 всё равно написаны. Это работа M-6, подробно — в части III.
spi_zynq_top — топ для первого включения: ни процессорной системы, ни AXI, ни единой зависимости от софта. Он опирается на три подтверждённых факта о плате. Свободно бегущий осциллятор 50 МГц на выводе W17, причём вывод clock-capable — поэтому Vivado сам вставит глобальный буфер, и ни MMCM, ни ручной BUFG не нужны. Частота ровно та же, что в референсе Altera, поэтому значения CLK_DIV из исходного проекта переносятся без пересчёта. И два светодиода PL — жёсткий предел платы, а не предпочтение. Отдельно перенесён режим DIAG_PINWALK, который гонит SCLK, MOSI и CS прямо от свободного счётчика в обход ядра. В Altera-проекте именно этот эксперимент закончил недельную охоту за «пропавшими» сигналами: если выводы шевелятся, физика в порядке (пины, питание банка, преобразователь уровней, место щупа) и виновата логика; если нет — виновата физика. Один пересбор делит пространство поиска пополам.
spi_selftest ведёт себя как программа, которой ещё нет: сначала прогоняет фазу конфигурации из ROM (как demo_init), потом уходит в бесконечный цикл транзакций (как spi_probe), но не с командами XPT2046, а с обобщённым бегущим узором — никакого конкретного слейва мы не предполагаем. Состояние выводится на светодиоды и на сигналы, удобные для внутреннего логического анализатора ILA: led[0] — heartbeat, доказывающий, что клок и сброс живы, led[1] — эвристический признак ответа на MISO. Эвристика намеренно слабая и честно названа слабой: без слейва MISO стоит на уровне подтягивающего резистора, поэтому принятый байт будет 0x00 или 0xFF, и признак «пришло что-то другое» свидетельствует об отвечающем устройстве, но не доказывает корректность данных. Важно, что spi_selftest работает через тот же самый параллельный интерфейс, которым пользовался хост на Altera: если под selftest ядро работает, а под AXI нет — виновата обёртка.
spi_axis_tx появился позже остальных: на момент миграции DMA был вынесен за рамки (пункт O-4 — при восьмисловных FIFO и SCLK не выше 8.33 МГц узкого места нет). Когда дошло до полноэкранной отрисовки на ST7789, узкое место появилось. Модуль принимает биты AXI4-Stream по 32 разряда, раскладывает на байты начиная с младшего, отправляет каждый в TX FIFO отдельным словом с нулевым расширением, а TLAST превращает в флаг конца буфера. Именно его регистрируемый сигнал записи породил гонку из главы 41 части IX, лечение которой потребовало almost_full.
7.4. Почему два топа, а не один универсальный
Развилка настоящая, и похожий выбор встретится вам в любом проекте с несколькими сценариями использования. Вариант первый — один топ с параметром вроде USE_AXI, переключающим источник обращений: либо внешняя шина, либо внутренний selftest; экономно, один файл и одна точка правки. Вариант второй — два независимых топа: spi_zynq_top для автономной проверки и spi_axi4lite как модуль внутри Block Design с процессорной системой. Выбрали его.
Причина в том, что это не два режима одного изделия, а два контекста проверки, и смысл каждого — в том, чего в нём нет. Ценность spi_zynq_top целиком в отсутствии AXI, процессорной системы и софта: когда после загрузки битстрима на плате тишина, вы хотите исключить из подозреваемых как можно больше, а не рассуждать, правильно ли встал параметр. Ценность spi_axi4lite — в том, что это чистый slave без единой строки отладочной логики. Параметр USE_AXI оставил бы в каждой сборке мёртвый код второй ветки: в автономной — неиспользуемые порты AXI, в продуктовой — генератор транзакций, который никто не запускает, но который синтезатор обязан обработать и который выстрелит при ошибке в условии.
Порядок работ получается естественный: сначала вы убеждаетесь, что клок жив, CS дёргается, SCLK есть, MOSI несёт данные, — и только потом добавляете к списку подозреваемых Block Design, карту адресов, IRQ_F2P, драйвер и Device Tree. Цена решения названа прямо: два топа надо поддерживать, и они могут разойтись. Если завтра в ядро добавится порт, подключать его придётся в обоих местах, и забывчивость обнаружится на сборке. Обмен приемлемый: риск расхождения виден при компиляции, а риск запутаться в режимах универсального топа — только на плате.
7.5. Что исключили и почему это не вкусовщина
Из переноса исключили всю подсистему, привязанную к оборудованию старой платы: spi_lcd_fpga_top, девять модулей rtl/lcd/*, demo_init и spi_probe. LCD-модули были предназначены для RGB-панели 480x272, а spi_probe работал с touch-контроллером XPT2046. На TZT RK-ZYNQ7020-F ни такой панели, ни XPT2046 нет, поэтому переносить этот код не имело смысла.
Для автономной проверки SPI вместо старой демонстрационной логики появился spi_selftest. Он сам настраивает контроллер и выполняет тестовые транзакции, а состояние можно наблюдать по светодиодам и через ILA. Позже для практической проверки передачи используется штатный дисплей ST7789, подключенный по SPI.
При этом ST7789 не подходит для полной проверки контроллера: у него нет линии MISO, поэтому через него можно проверить передачу по SCLK, MOSI и CS, но нельзя проверить прием данных. Поэтому для первоначального bring-up используется отдельная петля MOSI -> MISO.
7.6. Почему старый top не переносится
Симптом. Ядро уже было собрано в spi_master_fpga_top, поэтому сначала кажется, что достаточно добавить этот файл в проект Vivado и заменить QSF на XDC.
Что оказалось. Само ядро для этого действительно менять не требуется, но старый top описывает другой способ подключения хоста. Он выводит наружу внутренний параллельный регистровый интерфейс addr, wr_data, rd_data, wr_en, rd_en и ready. На Zynq этот интерфейс наружу не нужен: управляющим хостом становится Cortex-A9 в Processing System, а доступ из PS к пользовательской логике выполняется через AXI.
Поэтому spi_master_top сохраняется, а верхний уровень заменяется. В рабочей системе между PS и ядром появляется spi_axi4lite, преобразующий транзакции AXI4-Lite в исходные однотактовые обращения к регистровому интерфейсу. Для автономного bring-up ту же роль хоста выполняет spi_selftest.
При аудите старого проекта дополнительно обнаружился дефект A-6: в placeholder-QSF для 65 top-level сигналов был задан IO_STANDARD, но отсутствовал set_location_assignment. Quartus поэтому мог автоматически назначить им свободные выводы корпуса. Практического влияния дефект не имел, поскольку эта ревизия не предназначалась для загрузки на реальную плату, но использовать ее QSF как источник распиновки новой платы тем более нельзя.
Что сделали. spi_master_top и его внутренний регистровый интерфейс сохранили. Старый spi_master_fpga_top заменили двумя вариантами интеграции:
spi_axi4lite- управление от Cortex-A9 через AXI4-Lite;spi_selftest+ Zynq top - автономная проверка без PS и Linux.
Распиновка внешних сигналов SPI, такта, сброса и диагностики для новой платы сформирована заново.
Правило на будущее. При переносе FPGA IP следует разделять ядро и board-level top. Внутренний интерфейс IP можно сохранить без изменений, тогда как верхний уровень определяется архитектурой конкретной системы и способом подключения хоста.
7.7. Порядок работ при миграции IP: как повторить у себя
Соберём опыт этой части в последовательность, применимую к любому переносу между вендорами. Порядок не декоративный: каждый шаг опирается на результат предыдущего, и перестановка шагов — источник потерянного времени.
Зафиксируйте эталон. Исходное дерево лежит рядом и не трогается: всё доказательство эквивалентности строится на возможности сравнить с ним.
Проведите аудит зависимостей. Регулярка по всем файлам, включая проектные и constraints, плюс ручной чек-лист по тому, что регуляркой не ловится.
Классифицируйте каждое попадание — комментарий, настройка проекта, примитив — и запишите результат, даже если он отрицательный.
Отдельно аудируйте glue. Пины, шина к хосту, временные ограничения, периферия платы, цепочка загрузки. Именно здесь окажется объём работ.
Составьте план с идентификаторами и рисками. Всё, что меняет наблюдаемое поведение, выносите в необязательное — иначе оно разрушит эквивалентность.
Перенесите ядро копированием. Минимум правок, каждая объявленная: атрибуты синтеза допустимы, изменения логики — нет.
Постройте доказательство слоями: контрольные суммы, фильтрованный
diff, сравнение вердиктов тестов. Одна команда, ненулевой код при провале.Напишите обвязку и топ под новую плату — отдельно для bring-up, отдельно для интеграции.
Constraints — с нуля. Ни одного исключения по инерции; каждое обязано заново заслужить право на существование.
Проведите перепись предупреждений. По строке обоснования на класс: здесь находятся дефекты, которые не находят тесты.
Дальше — часть II: как устроено ядро изнутри, почему один тактовый домен упрощает всё, и почему CLK_DIV = 1 и мёртвый бит ERR_RX_OVF заслуживают отдельных детективов.
Часть II. Архитектура ядра SPI
В части I мы выяснили главное: ядро SPI почти целиком vendor-neutral, и весь перенос RTL уместился в две строки атрибутов. Вывод приятный, но ничего не объясняющий. Почему ядро оказалось таким переносимым? Что в нём устроено правильно, а что — унаследованно криво? Эта часть отвечает на оба вопроса изнутри: тактирование и сброс, четыре модуля и их контракты, карта регистров и, в финале, шесть дефектов, найденных при аудите. Читать стоит с открытым исходником в соседнем окне: всё, о чём мы говорим, лежит в ../../rtl/.
Глава 8. Один тактовый домен, SCLK как enable
8.1. Слово, которое решает судьбу переноса
Начнём с термина, без которого дальше нельзя. Тактовый домен (clock domain) — это множество триггеров, которые тактируются от одного и того же сигнала. Триггер (flip-flop) запоминает значение на входе в момент фронта тактового сигнала и держит его до следующего фронта. Если два триггера тактируются одним clk, они шагают в ногу: инструмент может посчитать, успевает ли сигнал добежать от одного к другому за период. Если источники разные, «успевает» становится бессмысленным словом — фронты плывут друг относительно друга, и никакой расчёт не спасает.
В нашем IP тактовый домен ровно один — системный clk. На Zynq он называется s_axi_aclk и приходит из PS как FCLK_CLK0: 50 МГц в PL-only сборке, 100 МГц в сборке с DMA. Все без исключения триггеры дизайна тактируются от него.
А что же spi_sclk — сигнал, который на разъёме выглядит как «часы SPI»? Он тоже живёт в этом домене, но не как клок, а как обычный регистровый выход. Посмотрите объявление и присваивание в ../../rtl/spi_engine.v:
output reg spi_sclk, ... always @(posedge clk or negedge rst_n) begin ... sclk_r <= ~sclk_r; spi_sclk <= ~sclk_r;
Присваивание стоит внутри always @(posedge clk), значит spi_sclk — выход триггера, переключаемый системным клоком. Для внешнего устройства он выглядит как тактовый сигнал; для временного анализа внутри кристалла он такой же сигнал данных, как spi_mosi или spi_cs_n.
Проверить это можно механически: поиск posedge sclk и negedge sclk по каталогу ../../rtl/ не находит ничего, совпадения есть только в ../../sim/tb_spi_slave_ref.v — в поведенческой модели слейва, которая в железо не попадает. Запомните фразу целиком: «ни один синтезируемый триггер не тактируется от spi_sclk». Она спасает от половины ошибок в constraints и от всех попыток «поставить BUFG на SCLK».
8.2. Метастабильность и CDC: почему одного домена мало не бывает
У триггера есть два требования по времени: сигнал на входе должен установиться за некоторое время до фронта клока (setup time) и не меняться некоторое время после фронта (hold time). Если оба выполнены, триггер надёжно защёлкивает ноль или единицу. Если сигнал переключился ровно в запретном окне, триггер попадает в метастабильное состояние: его выход зависает между логическим нулём и единицей и разрешается в одну из сторон через непредсказуемое время. Аналогия — подброшенная монета: почти всегда она ляжет орлом или решкой, но существует ненулевая вероятность, что она встанет на ребро и будет качаться. Запретить ей это нельзя, можно только подождать, пока упадёт, и не принимать решение раньше.
Внутри одного домена метастабильности не бывает: все сигналы меняются сразу после фронта и успевают устояться к следующему. Проблема возникает там, где сигнал приходит от источника, никак с нашим клоком не связанного. Такое пересечение называется CDC (Clock Domain Crossing — переход между тактовыми доменами), и лечится оно синхронизатором: цепочкой из двух триггеров подряд. Первый может уйти в метастабильность, но у него есть целый период, чтобы «упасть с ребра»; второй захватывает уже устоявшееся значение.
Ключевой вывод для переноса: внутри нашего IP пересечений доменов нет — ни между AXI и движком SPI, ни между «часами SPI» и логикой, потому что часов SPI внутри кристалла не существует. Это подтверждено полным поиском: нет pulse-CDC, нет многобитных шин между доменами, нет асинхронных FIFO, не нужны Gray-счётчики. Асинхронных сигналов всего два, и оба — входы, а не пересечения: spi_miso и reset_n (раздел 8.6).
8.3. Откуда тогда берётся частота SPI
Если SCLK — просто регистровый выход, кто-то должен решать, когда его переключать. Этим занят счётчик полупериода half_cnt в ../../rtl/spi_engine.v; логика умещается в несколько строк состояния S_XFER:
if (half_cnt >= clk_div) begin half_cnt <= 16'd0; sclk_r <= ~sclk_r; spi_sclk <= ~sclk_r; edge_cnt <= edge_cnt + 7'd1; ... end else begin half_cnt <= half_cnt + 16'd1; end
Прочитаем по тактам. После очередного фронта SCLK счётчик обнулён; дальше он сравнивается с clk_div и, пока меньше, инкрементируется. Как только сравнение выполнилось, происходит переключение: sclk_r инвертируется, edge_cnt увеличивается, счётчик снова обнуляется. При CLK_DIV = 2 картина по тактам системного клока такая:
такт 1 2 3 4 5 6 7 8 9 half_cnt 0 1 2 0 1 2 0 1 2 фронт . . X . . X . . X SCLK ____________/‾‾‾‾‾‾‾‾‾‾‾\___________ |<-- 3 такта -->|
Между двумя фронтами проходит CLK_DIV + 1 тактов — это и есть полупериод. Полный период SCLK вдвое длиннее, откуда формула, которую надо выучить:
f_SCLK = f_clk / (2 · (CLK_DIV + 1))
При 50 МГц и CLK_DIV = 2 получаем 8.33 МГц. При CLK_DIV = 4 — ровно 5 МГц. При CLK_DIV = 24 — 1 МГц, удобное значение для логического анализатора, и именно оно стоит по умолчанию в spi_selftest. Нижний край задаёт разрядность поля: clk_div шестнадцатибитный, значит максимум 65535, что при 50 МГц даёт около 381 Гц.
Обратите внимание на пару sclk_r / spi_sclk. Это не дублирование ради красоты: sclk_r — внутренняя копия уровня, по которой вычисляется следующий, а spi_sclk — то, что уходит на вывод. Оба обновляются в одном такте одним и тем же выражением, поэтому расхождения нет, зато выходной сигнал гарантированно снят с триггера и не имеет комбинационного хвоста. Из-за этого глитчи (короткие паразитные импульсы при переключении комбинационной логики) на SCLK невозможны по построению. То же верно для spi_cs_n и spi_mosi — все три объявлены как output reg.
8.4. Clock enable style: что выигрываем и чем платим
Приём, при котором вся логика сидит на быстром клоке, а «медленные» события разрешаются счётчиком, называется clock enable style. Альтернатива — сделать SCLK настоящим клоком: пропустить его через глобальный тактовый буфер и тактовать им часть триггеров. Второй путь выглядит естественнее для новичка («это же часы, пусть тактуют»), но на переносе он обходится дорого.
Что даёт нам первый путь. Не нужен глобальный буфер: на 7-series сигнал, попадающий на тактовые входы триггеров, обязан идти через BUFG/BUFR, иначе перекос по кристаллу окажется неконтролируемым. Наш SCLK на тактовые входы не попадает, и в отчёте report_clock_utilization клоковых примитивов, кроме буфера системного клока, нет вовсе. Не нужен второй домен, а значит не нужны ни синхронизаторы между «SPI clock» и AXI, ни асинхронные FIFO. Не нужен create_generated_clock в constraints — и это не мелочь: коэффициент деления программируемый, диапазон 1…65535, так что любое фиксированное -divide_by N было бы заведомо неверной моделью.
Чем платим. Расплата ровно одна, зато существенная: SCLK не может быть быстрее, чем позволяет системный клок и счётчик. Минимальный делитель, который регистровый интерфейс вообще принимает, равен единице (ноль отвергается — см. дефект A-4), значит физический потолок — f_clk / 4, то есть 12.5 МГц при 50 МГц. Но и его использовать нельзя: приём на делителе 1 испорчен из-за задержки синхронизатора MISO. Практический потолок — f_clk / 6, то есть 8.33 МГц. Это дефект A-3, главная история этой части, и разбирается он в главе 11.
Второй, менее очевидный платёж — разрешение по частоте. Делитель целочисленный, поэтому доступен только ряд f_clk / 6, f_clk / 8, f_clk / 10 и так далее. Драйвер обязан округлять делитель вверх (то есть частоту вниз): просить 4 МГц и получить 4.17 значит превысить запрошенную скорость, чего подсистема SPI в Linux не допускает.
8.5. Сброс: асинхронная установка, синхронное снятие
Сброс (reset) переводит все триггеры в известное состояние. В нашем IP он активен низким уровнем и называется rst_n — черта в имени по традиции означает инверсию. Тип сброса выбран такой: асинхронная установка, синхронное снятие (async assert / sync deassert). Асинхронная установка означает, что сброс срабатывает немедленно, не дожидаясь фронта клока; в Verilog это записывается добавлением сигнала в список чувствительности:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; spi_cs_n <= {NUM_CS{1'b1}};
Это полезно, когда клок ещё не запущен или пропал: схема всё равно окажется в предсказуемом состоянии. Проблема начинается при снятии сброса. Если rst_n отпустить в произвольный момент, часть триггеров успеет выйти из сброса по ближайшему фронту, а часть не успеет, и автомат стартует наполовину сброшенным. Формально это нарушение времён recovery/removal — тех же setup/hold, только для входа сброса.
Лечение стандартное и живёт в ../../rtl/spi_reset_sync.v целиком:
(* ASYNC_REG = "TRUE" *) 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];
Читается так. Пока внешний сброс активен, вся цепочка держится в нуле — асинхронно, мгновенно, независимо от клока. Как только сброс отпущен, по цепочке начинает продвигаться единица: один такт до первого триггера, ещё один до второго. Наружу выходит sync_chain[STAGES-1], то есть снятие сброса происходит строго по фронту клока и одновременно для всех потребителей. Экземпляр модуля нужен по одному на тактовый домен; у нас домен один.
Теперь про решение, которое мы не приняли. Xilinx рекомендует синхронный сброс: он лучше упаковывается в slice, потому что sync/async задаётся на весь slice целиком. Соблазн «сделать как рекомендует вендор» велик, особенно когда правишь чужой код. Мы отказались по двум причинам. Первая: перевод сброса в синхронный — это изменение поведения, а задача переноса состояла в том, чтобы получить доказуемо эквивалентную версию; любая правка семантики обнуляет аргумент эквивалентности. Вторая: выигрыш от упаковки здесь нулевой — дизайн занимает 0.76 % кристалла (405 LUT из 53 200 в OOC-сборке).
Опасение, что «на 7-series асинхронный сброс плохо ложится», проверено, а не принято на веру. Конструкция отображается на примитивы напрямую: FDCE (асинхронный сброс) для триггеров, сбрасываемых в ноль, и FDPE (асинхронная установка) для тех, кто сбрасывается в единицу. Смешивать их можно, потому что значение сброса задаётся на каждый триггер, а sync/async — на slice. Отчёт имплементации это подтверждает: 399 триггеров с асинхронным Reset и 7 с асинхронным Set — последние это spi_cs_n[3:0] и компания, ведь CS активен низким уровнем и в сбросе должен быть в единице.
8.6. Ровно два асинхронных входа
Асинхронных входов у IP два, и это исчерпывающий список: spi_miso и внешний reset_n. Почему именно два — видно из архитектуры. Все остальные входы приходят либо от AXI-обвязки, либо от spi_selftest, а оба источника сидят на том же s_axi_aclk. MISO приходит от внешней микросхемы со своим кварцем. Линия сброса вообще не имеет отношения к клоку.
Оба входа пропущены через двухступенчатые цепочки. Для MISO это miso_sync в ../../rtl/spi_engine.v, для сброса — sync_chain в spi_reset_sync.v. Оба массива помечены атрибутом:
(* ASYNC_REG = "TRUE" *) reg [MISO_SYNC_STAGES-1:0] miso_sync; wire miso_synced = miso_sync[MISO_SYNC_STAGES-1];
Атрибут ничего не меняет в логике — Icarus его вообще игнорирует. Он меняет обещание, которое RTL даёт инструменту: не разноси эти триггеры по разным slice, не применяй retiming, не сворачивай цепочку в сдвиговый регистр SRL и учти её как синхронизатор в отчётах. Vivado не обязан распознавать 2FF-цепочку по одной структуре, поэтому без атрибута report_cdc классифицировал бы путь иначе. С атрибутом отчёт после разводки говорит «All paths are Safely Timed». Это и есть весь перенос RTL: две строки атрибутов, ноль изменений логики.
И здесь же — цена честного синхронизатора. Он спасает от метастабильности, но съедает время: сигнал доходит от вывода до miso_synced за два такта, а до точки, где его можно защёлкнуть, за три. Пока полупериод SCLK длиннее этой задержки, всё в порядке; как только вы попробуете разогнать SPI до f_clk / 4, приём начнёт врать. Это дефект A-3 из главы 11.
Глава 9. Блоки: spi_reg_if / FIFO / spi_engine / spi_master_top
9.1. Почему четыре модуля, а не один
Контроллер можно было написать одним файлом на восемьсот строк. Он бы даже заработал — в этом и коварство. Исходный автор разделил его на четыре модуля, и это решение стоит разобрать, потому что именно оно сделало перенос недельной, а не месячной работой.
Граница проводится там, где интерфейс тоньше, а семантика по сторонам — различнее. spi_reg_if живёт в мире «адрес — данные — такт»: он разговаривает с хостом, декодирует адреса, помнит sticky-флаги. spi_engine живёт в мире «фронты — фазы — задержки»: он не знает, по какому адресу лежит CONTROL, ему на входы приходят готовые уровни cpol, cpha, clk_div и одиночный строб start. Между ними проходит десяток проводов конфигурации и три строба — тоньше в этом дизайне не бывает. spi_fifo не знает ни про хоста, ни про SPI, только про свои указатели, и поэтому один и тот же файл встаёт и в TX-, и в RX-тракт без единой правки. spi_master_top не содержит логики вовсе.
Что дало бы объединение? Экономию нескольких строк в топе — и потерю трёх вещей сразу. Первое: тестируемости. Движок с уровневыми входами гоняется в тестбенче напрямую, без эмуляции шинных транзакций, а регистровый файл проверяется чтением-записью без единой SPI-передачи; когда падает тест, вы сразу знаете, в каком из миров искать. Второе: возможности заменить шину. Именно на этой границе выросла AXI-обвязка (часть III) — она надета поверх spi_master_top, а не вместо него, и внутренний datapath не переписывался вообще. Третье: локальности сложности. Отлаживая CPHA, вам не нужно держать в голове декодирование адресов.
Цифры реальной имплементации подтверждают, что «клей» действительно тонкий. По иерархии spi_engine — 203 LUT и 165 триггеров, spi_reg_if — 102 LUT и 142 триггера, RX FIFO — 56 LUT, 22 LUTRAM и 9 триггеров, а собственная логика AXI-обвязки поверх всего этого занимает 12 LUT и 79 триггеров. Двенадцать LUT — вот сколько стоит переезд с параллельной шины на AXI4-Lite, когда архитектура нарезана правильно.
9.2. spi_reg_if — дипломат
Этот модуль — единственный, кто разговаривает с хостом. Он хранит конфигурационные регистры, собирает статус со всего контроллера в одно слово и превращает события в прерывание. Про SPI он не знает ничего. Приёмы, которые здесь используются, типовые для любого регистрового файла, и их стоит узнать в лицо.
Комбинационное чтение. Мультиплексор чтения собран в блоке always @(*), то есть значение появляется на rd_data в том же такте, когда поднят rd_en, без единого триггера на пути. Это даёт нулевую задержку чтения для процессора. Плата — жёсткое требование к мосту: данные надо захватить именно в том такте, когда строб активен. Если rd_en не поднят, rd_data равен нулю, а не последнему прочитанному значению.
Строб длиной ровно в один такт. И wr_en, и rd_en поднимаются на один такт на одно обращение. Строб (strobe) — импульс-событие, в отличие от уровня, который держится. Разница принципиальна: уровень можно подержать «на всякий случай», строб — нельзя, потому что каждый такт активного строба считается отдельным обращением.
Sticky-флаг. Флаг называется sticky («липкий»), если он взводится событием и держится до явной очистки, даже если само событие давно прошло. Аналогия — сработавший автомат в электрощитке: ток уже не течёт, но рычажок остался внизу. Так устроены stat_done, sticky_tx_ovf и все биты irq_status. Без sticky однотактовое событие вроде done_pulse хост просто не увидит: он читает регистры раз в тысячи тактов.
W1C. Способ очистки sticky-флагов: write-1-to-clear, «запиши единицу, чтобы сбросить». Хост пишет в регистр маску, и те биты, где стоит единица, сбрасываются, а остальные не трогаются. В нашем IP это ERROR_CLR:
`SPI_ADDR_ERROR_CLR: begin stat_done <= 1'b0; irq_status <= irq_status & ~wr_data; sticky_tx_ovf <= 1'b0; clr_errors <= 1'b1; end
Почему не обычная запись нуля? Потому что между чтением статуса и записью нуля может прийти новое событие, и оно потеряется. W1C сбрасывает ровно то, что хост уже видел.
Self-clearing строб. Бит START в CONTROL не хранится: хост пишет единицу, а сбрасывается бит сам, когда движок событие заметил:
if (eng_busy || eng_done_pulse) ctrl_start <= 1'b0;
Строб держится, пока движок либо поднимет busy, либо выдаст done (второе случается, когда START отвергнут из-за ошибки — например, при пустом TX FIFO). Никакой защёлки start_seen внутри движка нет: однократность обеспечивает регистровый интерфейс. Отсюда приятная мелочь: одна запись в CONTROL может одновременно включить контроллер и запустить транзакцию, потому что START принимается, если EN уже стоит или устанавливается этой же записью.
Прерывание. Линия irq — регистровая, то есть поднимается на такт позже события:
irq_out <= ctrl_irq_en & (|irq_active);
где irq_active = irq_status & irq_mask. Три источника: DONE, RX_VALID и ERROR. Семантика уровневая, а не импульсная: линия держится, пока есть неквитированные разрешённые события. Для драйвера это важно — уровневое прерывание, которое нельзя быстро квитировать, требует маскирования прямо в обработчике, иначе получится interrupt storm (../hw_sw_contract.md, F-2).
9.3. spi_fifo — склад
FIFO (First In, First Out) — очередь: слова выходят в том же порядке, в котором вошли. Наш вариант однотактовый (запись и чтение в одном домене), глубина задаётся параметром ADDR_WIDTH и всегда есть степень двойки: FIFO_DEPTH = 8 в PIO-сборке и обычно 1024 в сборке с DMA (часть IX).
Устройство простое до изящества. Есть массив mem, указатель записи wr_ptr и указатель чтения rd_ptr, и оба они на один бит шире, чем нужно для адресации массива. Этот лишний бит — весь секрет:
assign count = wr_ptr - rd_ptr; assign full = (count == DEPTH[ADDR_WIDTH:0]); assign empty = (count == { (ADDR_WIDTH+1){1'b0} });
Если бы указатели были обычной ширины, состояния «пусто» и «полно» выглядели бы одинаково: wr_ptr == rd_ptr. Лишний разряд позволяет их различить — разность указателей даёт настоящее количество слов, от нуля до DEPTH.
Выход данных комбинационный: rd_data = mem[rd_ptr], то есть на выходе всегда стоит голова очереди, без строба и без задержки. Это часть контракта, а не деталь реализации: движок в состоянии S_LOAD защёлкивает tx_data напрямую, ожидая, что там уже лежит нужное слово. FIFO с регистровым выходом формально тоже FIFO, но с этим движком не соединится. Побочный эффект идиомы: Vivado отображает такое хранилище на распределённую память LUTRAM (44 LUT в OOC-сборке), тогда как Quartus выводил обычные регистры; RTL идентичен, отличается только инференс.
Флаги ошибок тоже sticky: overflow взводится при попытке записи в полный буфер, underflow — при чтении из пустого, и оба держатся до flush или сброса. Сама запись при этом подавлена гейтом write_now = wr_en && !full: слово теряется, но очередь не портится. Отдельного упоминания заслуживает almost_full:
assign almost_full = (count >= (DEPTH - 2));
Это не украшение, а заплатка на реальную гонку. Производитель, который регистрирует свой wr_en (так делает AXIS-адаптер spi_axis_tx), видит флаг full на такт позже, чем тот стал истиной, и его отложенная запись приходит в уже полный буфер, где её отбрасывает тот же гейт. Внешне это выглядело как горизонтально сжатая картинка на ST7789 при работе через AXI DMA. Лечение — тормозить внешнего производителя на две позиции раньше, поэтому spi_master_top выводит наружу именно almost_full. История целиком — в части IX.
9.4. spi_engine — работяга
Здесь живёт весь протокол. Разберём модуль по слоям: данные, классификация фронтов, автомат.
Как хранится слово. Обычно SPI-мастер пишут на сдвиговом регистре: слово сдвигается влево, наружу уходит старший бит. Наш движок держит слово неподвижно и адресует нужный бит функцией:
function automatic [4:0] bit_pos; input [5:0] cnt; input [5:0] total; input lsb; begin if (lsb) bit_pos = cnt[4:0]; else bit_pos = total[4:0] - 5'd1 - cnt[4:0]; end endfunction
Для MSB-first нулевой по счёту бит — старший (total-1), для LSB-first — младший. MOSI берётся как tx_word[bit_pos(bit_cnt, ...)], принятый бит кладётся в rx_word[bit_pos(bit_cnt, ...)]. Выигрыш в том, что исчезает классическая беда сдвиговых конструкций — рассогласование выравнивания при смене порядка бит и при слове короче регистра. Приём дополнительно выравнивается вправо функцией pack_rx. Допустимые длины слова — только 8, 16, 24 и 32; проверка записана явным перечислением в word_len_ok, и попытка задать иное поднимает sticky-флаг ERR_WORD_LEN вместо выдачи мусора в эфир.
Как классифицируются фронты. Счётчик edge_cnt считает фронты SCLK внутри слова: их ровно 2 · WORD_LEN. Дальше — самая изящная строчка модуля:
wire do_sample = (edge_cnt[0] == cpha); wire do_shift = (edge_cnt[0] != cpha);
Чётность номера фронта прямо кодирует, ведущий это фронт (leading — уход от уровня покоя CPOL) или замыкающий (trailing — возврат к нему). А выбор между «сэмплировать» и «выдвигать» по стандарту SPI зависит ровно от CPHA. Поэтому вся таблица четырёх режимов сворачивается в одно сравнение, без case и дублирования веток. Как это выглядит во времени в режиме 0 (CPOL = 0, CPHA = 0), 8 бит, MSB-first:
edge_cnt 0 1 2 3 14 15 SCLK _____/‾‾‾‾‾\_____/‾‾‾‾‾\_____ ... _____/‾‾‾‾‾\_____ MOSI < b7 >< b6 >< b5 ... >< b0 > sample ^ ^ ^ b7 b6 b0
Первый бит MOSI выставлен до первого фронта — в S_LOAD, строкой spi_mosi <= tx_data[first_bit_idx]. Так и должен вести себя CPHA = 0: слейв защёлкнет данные по ведущему фронту, значит к этому моменту они обязаны быть готовы. Режим 1 (CPOL = 0, CPHA = 1) отличается сдвигом на полпериода:
edge_cnt 0 1 2 3 SCLK _____/‾‾‾‾‾\_____/‾‾‾‾‾\_____ MOSI ------< b7 >< b6 > sample ^ ^ b7 b6
Здесь в S_LOAD MOSI выставляется в ноль, первый бит выдвигается по первому ведущему фронту, а сэмплирование идёт по замыкающим. CPOL меняет только уровень покоя: при CPOL = 1 картинка та же вверх ногами. Независимо от режима слово заканчивается после последнего замыкающего фронта, и SCLK возвращается к уровню CPOL. Между словами в burst он тоже стоит на CPOL — непрерывного клока IP не выдаёт.
Мелочь, объясняющая одну строчку с виду избыточного кода:
if (do_shift && (bit_cnt < bits_total)) begin
На последнем замыкающем фронте все биты уже сосчитаны, bit_cnt == bits_total, и выдвигать нечего; без охраны функция bit_pos посчитала бы позицию за границей слова. Такие условия — признак кода, который писали, глядя на волны.
Автомат. Девять состояний, четырёхбитное кодирование, обязательная ветка default, возвращающая в S_IDLE.
Пройдём обмен по шагам. В S_IDLE движок держит холостые уровни: SCLK на CPOL, все CS в единице, busy снят. Приходит start при поднятом enable — и сразу выполняются две проверки. Если длина слова недопустима, взводится err_word_len и выдаётся done_pulse; передачи не будет. Если TX FIFO пуст (и это не потоковый режим), взводится err_start_empty и снова done_pulse. Оба отказа дают завершение — именно поэтому строб START снимается и по done, а не только по busy. Если проверки пройдены, движок поднимает busy, запоминает длину слова, активирует выбранный CS (если индекс в диапазоне) и уходит в S_CS_SETUP.
В S_CS_SETUP досчитывается пауза «CS опущен → первый фронт SCLK», её величина берётся из младшего байта DELAY_CFG в тактах системного клока. Затем S_LOAD: слово из головы TX FIFO защёлкивается в tx_word, обнуляются счётчики, при CPHA = 0 предвыставляется первый бит MOSI. Дальше S_XFER — цикл, разобранный выше по тактам. Выход из него — по последнему фронту.
S_XFER_END — состояние на один такт, но самое насыщенное:
if (!tx_only) begin if (!rx_full) begin rx_wr_en <= 1'b1; rx_data <= pack_rx(rx_word, bits_total); end else begin err_rx_overflow <= 1'b1; end end tx_rd_en <= 1'b1;
Здесь принятое слово выравнивается и толкается в RX FIFO, а отправленное выталкивается из TX FIFO. Отдельное состояние нужно потому, что последний сэмпл присваивается неблокирующим оператором и становится виден только в следующем такте. Запомните ветку else с err_rx_overflow — из неё вырастет дефект A-5. Флаг tx_only просто выключает приём: писать в восьмисловный FIFO сотни тысяч пикселей кадра всё равно бессмысленно.
Дальше S_CS_HOLD выдерживает паузу «последний фронт → снятие CS» и решает судьбу транзакции: если в TX FIFO ещё что-то есть, CS не снимается, и через S_INTER (межсловная пауза) движок уходит на новое слово. Если пусто — CS поднимается, и S_DONE выдаёт однотактовый done_pulse. Состояние S_WAIT_TX относится только к потоковому режиму: оно удерживает CS низким, пока DMA не подвезёт следующую порцию или не придёт признак конца кадра.
Про мягкий сброс. Ветка soft_rst возвращает автомат в S_IDLE, ставит SCLK на CPOL, снимает CS и обнуляет счётчики — но не трогает sticky-ошибки. Мягкий сброс означает «прекрати и вернись в исходное», а не «забудь, что было»; забывание — это отдельный импульс clr_errors из ERROR_CLR.
9.5. spi_master_top — клей, в котором спрятаны три важные строки
Модуль не содержит собственной логики, но три выражения в нём стоят того, чтобы прочитать их глазами.
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; wire tx_wr_any = (tx_wr | ext_tx_wr) & ~tx_full;
Первое собирает все ошибки движка в одно событие прерывания. Заметьте: eng_err_rx_ovf здесь есть. Именно поэтому переполнение RX всё-таки видно хосту — но через IRQ, а не через STATUS. Это половина дефекта A-5.
Второе объединяет два источника очистки FIFO. Из-за него запись в ERROR_CLR не просто квитирует ошибки, а ещё и уничтожает непрочитанные данные в обоих буферах. Для драйвера это жёсткое ограничение порядка операций: сначала вычитать RX_DATA, только потом квитировать (../hw_sw_contract.md, F-2).
Третье разрешает писать в TX FIFO двум источникам сразу — регистровому интерфейсу и AXIS-адаптеру — и никогда не пишет в полный буфер. Рядом живёт eng_clk_div = use_clk_override ? clk_div_override : reg_clk_div: побочный вход, позволяющий потоковому режиму задать делитель мимо регистра. Обе конструкции появились ради DMA и в PIO-сборке подвязаны к нулю.
9.6. Контракт параллельной шины, который нельзя сломать мостом
Ядро наружу смотрит собственным memory-mapped интерфейсом: wr_en, rd_en, четырёхбитный addr, wr_data, rd_data, ready, irq. Ни Avalon, ни Wishbone, ни AXI — сознательный выбор автора исходного IP ради переносимости, и именно им воспользовался наш перенос. Но у простоты есть обратная сторона: контракт короткий, зато его пункты неочевидны. Стробы wr_en и rd_en активны ровно один такт; мост, который держит rd_en два такта, дважды вытолкнет слово из RX FIFO, и второе чтение потеряется молча. Адрес — индекс слова, четыре бита, а не байтовый адрес AXI; преобразование offset = index · 4 делает обвязка. Выход rd_data комбинационный и действителен только в такте активного строба, в остальное время там ноль. Чтение по адресу RX_DATA имеет побочный эффект: оно вытаскивает слово из очереди, поэтому отладчик со спекулятивными чтениями по этому адресу незаметно испортит вам приём. Сигнал ready — константная единица, он существует только для мостов, которым нужен waitrequest. И наконец irq регистровый, то есть виден на линии на такт позже, чем в статусе.
Из-за этих пунктов AXI-обвязка получила отдельные тесты: 19 — «ровно один pop на чтение», 20 — «ровно один push на запись», 21 — чтение всех регистров. Риск M-6 в ../migration.md описан именно так: ядро не тронуто, но небрежный адаптер способен сломать его снаружи. Как этот контракт переложен на AXI4-Lite и какие четыре отклонения при этом приняты — в части III.
Глава 10. Карта регистров CONTROL…ERROR_CLR
10.1. Что такое memory-mapped регистр
Управление периферией в SoC устроено так: блок занимает кусок адресного пространства процессора, и обращение по адресу из этого куска попадает не в память, а в железо. Записали слово по адресу «база + 0x14» — оно ушло в TX FIFO. Прочитали «база + 0x04» — получили состояние контроллера. Специальных инструкций не нужно, обычные load и store. Наш IP занимает 64 байта, данные 32-битные, регистров одиннадцать плюс четыре в обвязке. Внутри ядра адрес — индекс слова (те самые четыре бита addr), на шине AXI — байтовое смещение, связаны они умножением на четыре. Семантика регистров при переносе не менялась: spi_reg_if.v байт-идентичен Altera-оригиналу, что подтверждено контрольной суммой в ../verification.md.
10.2. Карта
Смещение | Регистр | Доступ | Назначение |
|---|---|---|---|
| CONTROL | R/W | EN, START, SOFT_RST, CPOL, CPHA, LSB_FIRST, IRQ_EN |
| STATUS | RO | BUSY, DONE, флаги FIFO, sticky-ошибки |
| CLK_DIV | R/W | Полупериод SCLK = |
| CS_SELECT | R/W | Индекс активного CS, не маска; сброс |
| WORD_LEN | R/W | Длина слова: только 8, 16, 24, 32; сброс |
| TX_DATA | WO | Запись = push в TX FIFO |
| RX_DATA | RO | Чтение = pop из RX FIFO (побочный эффект) |
| DELAY_CFG | R/W | CS_SETUP / CS_HOLD / INTER, в тактах |
| IRQ_STATUS | RO | Sticky-причины: DONE, RX_VALID, ERROR |
| IRQ_MASK | R/W | Маска разрешённых причин |
| ERROR_CLR | WO | W1C по IRQ_STATUS + сброс DONE/TX_OVF + flush FIFO |
| — | — | Не занят, читается нулём |
| STREAM_CTRL | R/W | v2: STREAM_EN, TX_ONLY, TX_FAST, CLR_EOT |
| STREAM_STATUS | RO | v2: BUSY, EOT |
| FAST_DIV | R/W | v2: делитель при TX_FAST |
| ID_VERSION | RO |
|
Смещения 0x30…0x3C живут не в ядре, а в AXI-обвязке spi_axi4lite.v; spi_reg_if про них не знает. В PIO-сборке их может не быть вовсе, и тогда чтение вернёт ноль. Полная версия таблицы со всеми битовыми полями — в ../register_map.md; здесь мы разбираем только то, что нужно понять архитектуру.
10.3. CONTROL: три обычных бита и два необычных
Биты 0, 3, 4, 5 и 6 — обычные хранимые: EN разрешает контроллер, CPOL и CPHA задают режим SPI, LSB_FIRST меняет порядок бит, IRQ_EN глобально разрешает прерывание; записали — прочитали то же самое. Биты 1 (START) и 2 (SOFT_RST) устроены иначе, это self-clearing стробы. Записанная единица порождает однотактовый импульс и не сохраняется, поэтому чтение CONTROL всегда возвращает в этих позициях ноль. Отсюда безопасность приёма «прочитал — изменил — записал»: START вы случайно не повторите, он читается нулём, а EN, CPOL и остальные сохранятся правильно. И ещё одна деталь из кода: START принимается, если EN уже установлен или устанавливается той же записью, поэтому запуск одной транзакцией — законный приём.
10.4. STATUS: живые биты, sticky-биты и один мёртвый
Бит | Имя | Тип | Смысл |
|---|---|---|---|
0 | BUSY | живой | Транзакция выполняется |
1 | DONE | sticky | Транзакция завершена; квитируется через ERROR_CLR |
2 | RX_VALID | живой | RX FIFO не пуст |
3 | TX_READY | живой | TX FIFO не полон |
4 | ENABLED | живой | Зеркало CONTROL.EN |
5 | TX_EMPTY | живой | TX FIFO пуст |
6 | RX_FULL | живой | RX FIFO полон |
8 | ERR_TX_OVF | sticky | Запись TX_DATA при полном FIFO |
9 | ERR_RX_OVF | sticky | Не выставляется никогда — дефект A-5 |
10 | ERR_START | sticky | START при пустом TX FIFO |
11 | ERR_WORD_LEN | sticky | Недопустимый WORD_LEN на момент START |
Разница между «живым» и sticky-битом принципиальна для того, как вы пишете опрос. Живой бит отражает текущее состояние: увидели BUSY = 0, значит прямо сейчас передачи нет. Sticky-бит отражает историю: DONE, однажды взведённый, останется единицей до квитирования, даже если после прошло десять транзакций. Поэтому классическое условие завершения — не «DONE = 1», а «DONE = 1 и BUSY = 0», и именно так написан цикл ожидания в ../../sim/tb_clkdiv_golden.v.
Бит 9 заслуживает отдельного предупреждения, и повторяется оно в трёх местах проекта не случайно. ERR_RX_OVF описан в документации исходного проекта как рабочий sticky-флаг переполнения приёмного буфера. Он не работает — не «иногда не срабатывает», а не выставляется ни при каких условиях, и до исправления единственный способ узнать о потере данных — бит ERROR в IRQ_STATUS (дефект A-5, глава 11).
10.5. CLK_DIV и два подводных камня
Регистр шестнадцатибитный, значение по умолчанию после сброса — 4, то есть 5 МГц при системных 50 МГц. Соответствие делителя и частоты стоит держать перед глазами:
CLK_DIV | f_SCLK при 50 МГц | Пригодность |
|---|---|---|
0 | — | Запись игнорируется молча (дефект A-4) |
1 | 12.5 МГц | Не использовать: приём сдвинут на бит (дефект A-3) |
2 | 8.33 МГц | Минимально допустимое, оно же максимальная рабочая частота |
4 | 5 МГц | Номинал, значение после сброса |
24 | 1 МГц | Удобно для логического анализатора |
249 | 100 кГц | Медленный край |
65535 | ≈ 381 Гц | Абсолютный минимум |
Первый камень: запись нуля отвергается, и никакой обратной связи об этом нет — ни флага, ни ошибки на шине. Хост уверен, что настроил делитель, а работает предыдущее значение. Второй: единица принимается, хотя она нерабочая. Оба разбираются в главе 11 (A-4 и A-3 соответственно). Практический вывод для любого кода, который трогает этот регистр: писать не меньше двух и читать обратно после записи.
10.6. TX_DATA, RX_DATA и побочный эффект чтения
Два регистра данных выглядят симметрично, но ведут себя по-разному. TX_DATA — только на запись, каждая запись это push в очередь; данные выравниваются вправо, то есть для восьмибитного слова значащими будут младшие восемь бит. Запись в полный буфер поднимает sticky ERR_TX_OVF, и слово теряется.
RX_DATA — только на чтение, и это чтение изменяет состояние железа: слово извлекается из очереди. Чтение пустого буфера возвращает ноль и pop не выполняет, тут RTL аккуратен, но повторное чтение непустого буфера вытащит следующее слово, а не то же самое. Отсюда правило, которое стоит написать на стене лаборатории: по адресу RX_DATA не читают «на всякий случай» и не ставят точку наблюдения в отладчике.
10.7. DELAY_CFG, IRQ и ERROR_CLR
DELAY_CFG упаковывает три задержки в одно слово: биты 7:0 — пауза между опусканием CS и первым фронтом SCLK, биты 15:8 — между последним фронтом и подъёмом CS, биты 23:16 — межсловная пауза внутри burst. Единица везде одна: такт системного клока, не такт SPI. Старший байт зарезервирован и читается нулём; кстати, именно он даёт часть безобидных предупреждений «port has no load» при синтезе. IRQ_STATUS хранит три sticky-причины, IRQ_MASK разрешает нужные, итоговая линия равна IRQ_EN & |(IRQ_STATUS & IRQ_MASK); замаскированное событие всё равно фиксируется.
ERROR_CLR — самый опасный регистр карты, потому что делает четыре вещи сразу: сбрасывает по маске биты IRQ_STATUS, сбрасывает sticky DONE, сбрасывает sticky TX overflow и флашит оба FIFO. Последнее не следует ни из названия, ни из семантики регистра; узнать об этом можно только из RTL или из ../register_map.md. Следствие: квитирование прерывания уничтожает непрочитанные данные, поэтому порядок «сначала прочитать RX, потом квитировать» — не рекомендация, а требование.
10.8. ID_VERSION: зачем железу паспорт
Регистр 0x3C возвращает 0x53500100 или 0x53500200. Старшие 16 бит — 0x5350, это ASCII-коды букв «S» и «P». Дальше major и minor: 01.00 — классическая PIO-сборка, 02.00 — сборка со stream и AXI DMA.
Смысл регистра в том, что программа не должна доверять адресу. Драйвер в probe() читает 0x3C первым делом: если там не 0x5350, значит либо в FPGA залит не тот битстрим, либо в Device Tree указан не тот адрес, и правильная реакция — отказаться привязываться, а не пытаться работать. Битстримы, собранные до появления регистра, вернут ноль и трактуются так же (../hw_sw_contract.md, раздел 9; сам драйвер — в части VII).
10.9. Типовая последовательность одного обмена
Соберём всё вместе. Порядок обращений тот же, что в тестбенче ../../sim/tb_clkdiv_golden.v и в примере на C из ../register_map.md.
Записать
0xFFFFFFFFвERROR_CLR— снять все sticky-флаги и очистить оба FIFO перед началом работы.Настроить
CLK_DIV(не меньше 2),CS_SELECT,WORD_LENиDELAY_CFG.Записать слово в
TX_DATA— оно ложится в TX FIFO. Для burst записать несколько слов подряд, но не больше глубины FIFO.Записать в
CONTROLединицы в биты EN и START — одной транзакцией.Ждать в цикле, пока в
STATUSне окажется DONE = 1 при BUSY = 0.Прочитать
RX_DATAстолько раз, сколько слов было отправлено.Только теперь квитировать: записать в
ERROR_CLRмаску обработанных событий. Раньше — нельзя, иначе шаг 6 останется без данных.
Глава 11. Дефекты A-1…A-6 как мини-детективы
Прежде чем открывать дела, зафиксируем политику, без которой они выглядят странно. Ни один из шести дефектов в этом переносе не исправлен. Это не лень и не небрежность, а прямое следствие задачи: сначала нужно получить доказуемо эквивалентную версию ядра, и только потом чинить. Любая правка логики обнуляет аргумент эквивалентности — а он у нас построен на контрольных суммах и на построчном сравнении вердиктов тестов (../verification.md). Поэтому дефекты задокументированы, покрыты тестами, а исправления вынесены в отдельный список O-1…O-3 в ../migration.md.
Более того, два теста в ../../sim/tb_spi_axi4lite.v утверждают дефектное поведение как ожидаемое. Приём звучит дико («тест проверяет, что баг на месте»), но он единственный честный: если кто-то поправит синхронизатор или источник бита STATUS, тест упадёт и потребует объяснений, вместо того чтобы молча позеленеть и оставить документацию врущей. Все шесть историй ниже идут в одном формате: что видели, какая версия казалась убедительной, чем проверяли, какая улика перевернула картину, где корень, что сделали и какое правило из этого следует. Две — A-3 и A-5 — стоит выучить наизусть: первая определяет заявленную частоту SPI, вторая — как писать драйвер.
A-1. MISO_SYNC_STAGES = 1 ломает part-select
История начинается там, где обычно и начинаются такие истории: в README, где написано, как сделать лучше. Параметр MISO_SYNC_STAGES управляет глубиной цепочки синхронизации входа MISO, по умолчанию он равен двум. Комментарий у объявления порта в ../../rtl/spi_engine.v гласит FF chain on spi_miso (>= 1), а README исходного проекта идёт дальше и прямо предлагает поставить единицу «для ускорения». Соблазн огромен, особенно после знакомства с дефектом A-3: раз задержка синхронизатора ограничивает частоту SPI, самое очевидное решение — укоротить синхронизатор. Логика безупречна, документация разрешает, параметр существует именно для этого.
Что мы увидели, попробовав. Elaboration падает: не работает медленнее, не теряет биты, а просто не проходит стадию разбора исходника, и ошибка указывает на выражение сдвига цепочки. Как проверяли: подставили MISO_SYNC_STAGES = 1 в неизменённый исходник и посмотрели, во что превращается строка:
miso_sync <= { miso_sync[MISO_SYNC_STAGES-2:0], spi_miso };
При единице MISO_SYNC_STAGES-2 равно минус единице, и part-select превращается в miso_sync[-1:0]. Такого диапазона в Verilog не существует, ни один инструмент его не примет. При двойке выражение даёт корректный однобитный срез miso_sync[0:0], при большем значении всё тоже законно. Настоящий минимум параметра — два, а не один.
Улика, которая расставила всё по местам, нашлась в соседнем файле. В ../../rtl/spi_reset_sync.v живёт точно такая же цепочка с точно таким же выражением sync_chain[STAGES-2:0] — и там комментарий у параметра честно говорит number of synchroniser stages (>= 2). Один и тот же автор, один и тот же приём, два разных комментария. Значит дело не в коде, который «не поддерживает» единицу; дело в том, что в одном месте документация написана по факту, а в другом — по намерению.
Корневая причина: выражение сдвига в ../../rtl/spi_engine.v, массив miso_sync, написано в предположении минимум двух ступеней, а комментарий порта и README заявляют минимум одну. Врёт документация, код честен.
Что сделали: ничего. Значение по умолчанию — два, перенос его не меняет, на эксплуатацию дефект не влияет вовсе, проявляется только при попытке последовать совету README. Правильное исправление вынесено в список необязательных (O-3) и состоит из выбора: либо привести комментарий и README в соответствие с реальностью, либо добавить generate-ветку для одной ступени. Первое дешевле и ничего не меняет в железе. Второе, кстати, было бы вредно по существу: одна ступень синхронизации — это отказ от защиты от метастабильности ради одного такта, и выигрыш в частоте SPI не окупает такой размен.
Правило на будущее: параметр «для ускорения», описанный в README, проверяйте синтезом на граничных значениях, а не доверием к тексту. И если один и тот же приём в проекте прокомментирован по-разному — верьте более строгому комментарию.
A-2. NUM_CS = 1 ломает part-select
Второе дело — близнец первого, но с другой мотивацией. Параметр NUM_CS задаёт количество линий выбора кристалла, по умолчанию их четыре. Ситуация, в которой хочется поставить единицу, возникает постоянно: к контроллеру подключено ровно одно устройство. Дисплей ST7789 в нашем проекте — как раз такой случай. Зачем держать четыре линии, если нужна одна? Экономия выглядит бесплатной.
Что увидели: то же самое, что в A-1 — отказ на стадии разбора исходника.
Как проверяли. Смотрели на строку активации CS в ../../rtl/spi_engine.v:
if (cs_select < NUM_CS) spi_cs_n[ cs_select[$clog2(NUM_CS)-1:0] ] <= 1'b0;
Функция $clog2(N) возвращает количество бит, необходимое для адресации N значений. Для четырёх это два, для двух — один, а для единицы — ноль, потому что для адресации одного элемента адрес не нужен вовсе. Формально всё верно, но $clog2(1)-1 равно минус единице, и мы снова получаем cs_select[-1:0]. При NUM_CS = 2 выражение даёт cs_select[0:0] и работает. То есть дефект — точечный, ровно в одном значении параметра.
Улика, объясняющая, почему это не заметили раньше: дефолт проекта равен четырём, и ни одна собираемая конфигурация единицу не использует. Более того, в целевой системе на Zynq потребность в единственном CS решается вообще иначе — через CS_SELECT, выставленный вне диапазона. Регистр CS_SELECT хранит индекс, а не маску, и если индекс больше или равен NUM_CS, охранное условие cs_select < NUM_CS не выполняется, ни одна нативная линия не активируется, а транзакция всё равно идёт: SCLK и MOSI работают. Ровно этим пользуется драйвер Linux, когда CS управляется через GPIO: он программирует CS_SELECT = NUM_CS и отдаёт удержание CS ядру ОС (../hw_sw_contract.md, ограничение F-1). Получается, что «сэкономить на NUM_CS» не нужно даже практически.
Корневая причина: ширина индекса в ../../rtl/spi_engine.v посчитана формулой $clog2(NUM_CS), которая вырождается при NUM_CS = 1. Классический случай формулы, корректной на всём диапазоне кроме граничной точки.
Что сделали: не трогали. Для всех рабочих конфигураций (два и больше) код безопасен, дефолт четыре, влияние на перенос нулевое. Исправление — та же generate-ветка или явно задокументированный минимум — лежит в O-3.
Правило на будущее: любая формула вида $clog2(N) в объявлении ширины обязана иметь либо ветку для N == 1, либо защитный localparam, берущий максимум из результата и единицы. Проверяйте параметризованные модули на границах диапазона параметров, а не только на дефолтах — граница почти всегда там, где величина обращается в ноль или в единицу.
A-3. Предел CLK_DIV >= 2: когда передача идеальна, а приём врёт
Главное дело части: как слово «recommended» в README оказалось замаскированным «required».
Что видели. Тест гоняет один байт в режиме 0: мастер отправляет 0xC3, слейв отвечает 0x3C. При делителе 2 и выше принятое слово равно 0x3C, при делителе 1 — 0x1E. Присмотритесь: 0x3C это 0011_1100, а 0x1E это 0001_1110. То же самое слово, сдвинутое вправо на бит. Не искажённое, не случайное — сдвинутое. Передача при этом безупречна, слейв видит ровно 0xC3. Симптом коварен именно этим: на осциллографе MOSI и SCLK красивые, CS опускается и поднимается когда надо, а софт уже читает мусор.
Что казалось логичным. Версий было четыре. Первая: «делитель 1 законен, RTL отвергает только ноль — значит единица обязана работать»; возразить трудно, она опирается на код. Вторая: «README пишет recommended ≥ 2, значит на единице просто меньше запаса». Третья: «сломался MOSI, или выставлен не тот режим, или петля соединена не так» — самая соблазнительная, потому что предлагает искать там, где удобно. Четвёртая, самая опасная: «это баг переноса», то есть мы сами что-то сломали.
Как проверяли. Сначала разделили передачу и приём. Тестбенч выводит не только принятое мастером слово, но и то, что зафиксировал слейв на линии MOSI, — колонка mosi_seen. Она оставалась равной 0xC3 при всех делителях, значит тракт передачи цел и ломается только сэмплирование MISO.
Потом проверили, наш ли это грех. Тестбенч ../../sim/tb_clkdiv_golden.v не касается ничего портированного: он инстанцирует неизменённое Altera-ядро из исходного дерева и общается с ним через родную параллельную шину, без AXI-обвязки. Прогон make sim_clkdiv даёт свип:
GOLDEN_RESULT: CLK_DIV_req=0 CLK_DIV_eff=4 rx=3c mosi_seen=c3 GOLDEN_RESULT: CLK_DIV_req=1 CLK_DIV_eff=1 rx=1e mosi_seen=c3 <-- дефект GOLDEN_RESULT: CLK_DIV_req=2 CLK_DIV_eff=2 rx=3c mosi_seen=c3 GOLDEN_RESULT: CLK_DIV_req=3 CLK_DIV_eff=3 rx=3c mosi_seen=c3
Дефект воспроизвёлся на оригинале байт в байт, и версия «это баг переноса» закрылась. Строка с req=0 — не аномалия: запись нуля отвергается регистровым интерфейсом, и эффективным остаётся прежнее значение 4, что показывает колонка CLK_DIV_eff. Молчаливый отказ при нуле — уже дефект A-4, следующее дело.
Третий шаг — вывести механизм из RTL, а не угадать. Проследим путь принятого бита по тактам системного клока. Пусть на такте N движок формирует замыкающий фронт SCLK; так как spi_sclk снимается с триггера, на выводе он меняется сразу после этого фронта, и слейв, увидев его, обновляет MISO. На такте N+1 новое значение защёлкивается первой ступенью miso_sync[0], на такте N+2 переходит во вторую, и только теперь miso_synced равен новому биту. Значит самый ранний фронт, на котором движок может прочитать бит в rx_word, — такт N+3. А сэмплирует он через полупериод, то есть через CLK_DIV + 1 тактов после предыдущего фронта:
CLK_DIV + 1 >= 3 ⇒ CLK_DIV >= 2
При CLK_DIV = 1 сэмпл приходит на такте N+2, когда miso_synced ещё держит предыдущий бит. Отсюда и сдвиг ровно на одну позицию — не порча, а запаздывание на бит.
Корневая причина. Двухступенчатый синхронизатор miso_sync в ../../rtl/spi_engine.v корректно защищает от метастабильности, но тратит на это время, а счётчик half_cnt в том же файле позволяет задать полупериод короче этой задержки. Ни одна из частей не ошибочна по отдельности; ошибочно отсутствие связи между ними. Документация же классифицировала жёсткое требование как совет, а RTL проверяет только CLK_DIV != 0 и молча принимает опасную единицу.
Что это значит для софта. Формула f_SCLK = f_clk / (2 · (CLK_DIV + 1)) при делителе 2 даёт f_clk / 6. Именно /6, а не /4, который получился бы при делителе 1, и тем более не /2 при нуле. При системных 50 МГц честный максимум SPI — 8.33 МГц. Драйвер обязан объявлять max_speed_hz = f_clk / 6 и брать f_clk из clk_get_rate(), а не из константы: в сборке с DMA системный клок часто 100 МГц, и потолок поднимается до 16.7 МГц (../hw_sw_contract.md, раздел 6).
Что сделали. Поведение сохранили. Тест 25 в ../../sim/tb_spi_axi4lite.v ожидает 0x1E при делителе 1 — чтобы случайное «улучшение» синхронизатора не прошло незамеченным. Исправление (O-2) — запретить запись CLK_DIV < 2 по аналогии с запретом нуля: одна строка, но она меняет наблюдаемое поведение, поэтому в перенос не вошла. Есть у дефекта и след в constraints: раз схема не полагается на соотношение SCLK и MISO, в XDC стоит set_false_path на вход MISO, и корректность приёма гарантирует правило CLK_DIV >= 2, а не STA (../migration.md, обоснование C-3).
Правило на будущее. Синхронизатор на входе данных проверяйте свипом частоты, а не одним удачным прогоном. Слово «recommended» в чужом README считайте подозреваемым: чаще всего это замаскированное «required». Максимальную частоту выводите из задержки CDC, а не из формулы делителя. И запомните признак: если передача идеальна, а приём сдвинут, первым подозреваемым делайте путь сэмплирования.
A-4. Запись CLK_DIV = 0 уходит в никуда
Дело маленькое, но поучительное — из тех, что съедают вечер отладки не сложностью, а неожиданностью.
Что видели. Хост записывает в CLK_DIV ноль, читает регистр обратно и получает старое значение. Ошибки нет: шина ответила OKAY, прерывание не взведено, ни один sticky-флаг не изменился. Впервые мы заметили это не в отладке, а в логе свипа из дела A-3: строка CLK_DIV_req=0 CLK_DIV_eff=4 показывает запрошенный ноль и эффективную четвёрку — значение, оставшееся с прошлой итерации.
Что казалось логичным. Первая мысль всегда одна: «запись потерялась». Дальше версия ветвится по вкусу — не тот адрес, не сработал wstrb, мост проглотил транзакцию, кэш не сбросился. Все разумны, все ведут в сторону от истины. Вторая мысль, чуть более изощрённая: «регистр read-only» — тоже мимо.
Как проверяли. Ключевое наблюдение сделано без единого инструмента: в той же последовательности записей CS_SELECT, WORD_LEN и DELAY_CFG установились нормально. Значит шина исправна, адрес верен, мост работает — проблема касается одного регистра при одном конкретном значении. Такое сужение почти всегда означает намеренную проверку в коде, и она нашлась в ../../rtl/spi_reg_if.v:
`SPI_ADDR_CLK_DIV: begin // Refuse divisor of zero (would freeze SCLK gen). if (wr_data[15:0] != 16'd0) reg_clk_div <= wr_data[15:0]; end
Вот и разгадка: запись нуля просто не выполняется. Никакого флага, никакого ответа SLVERR — обвязка отвечает OKAY на всё, это её задокументированное отклонение D-3.
Мотивировку в комментарии стоит прочитать критически. Он утверждает, что ноль «заморозил бы генерацию SCLK». Но если следовать тексту счётчика, условие half_cnt >= clk_div при нуле выполняется каждый такт, то есть SCLK переключался бы на каждом фронте системного клока — не замирал, а разгонялся до f_clk / 2. Практической разницы нет: и то и другое одинаково непригодно, ведь даже вдвое меньшая частота уже нарушает предел A-3. Но факт полезен как напоминание: комментарий — это мнение автора о коде, а не сам код.
Корневая причина. Защитная проверка в ../../rtl/spi_reg_if.v, ветка записи CLK_DIV, реализована как молчаливый отказ: без sticky-флага, без отражения в STATUS, без ошибки на шине. Строго говоря, это не баг: молчаливый отказ безопаснее заведомо неработающей конфигурации. Баг в том, что механизм не был описан в карте регистров и поэтому выглядел как потеря записи.
Что сделали. Поведение оставили, а недостающее звено — документацию — добавили в ../register_map.md. Дополнительно дефект оказался практически недостижим: драйвер по правилу A-3 никогда не пишет значение меньше двух, так что до нуля дело не доходит. Опциональный sticky-флаг «отвергнутая запись» в перенос не входит.
Правило на будущее. Любой молчаливый отказ железа обязан быть в карте регистров крупным шрифтом, иначе он превращается в час отладки для каждого следующего человека. А со стороны софта правило универсальное: после записи важного конфигурационного регистра читайте его обратно и сверяйте.
A-5. Мёртвый бит: STATUS.ERR_RX_OVF не выставляется никогда
Второе большое дело. Это история о том, как одно предупреждение синтеза оказалось важнее красивой строки в документации, и почему драйвер, честно опрашивающий регистр состояния, не заметит потерю данных.
Что видели. Приёмный буфер переполнен: STATUS.RX_FULL равен единице, принятые слова действительно потеряны. А бит 9 того же регистра, ERR_RX_OVF, описанный в документации исходного проекта как sticky-флаг переполнения приёма, остаётся нулём. Хост, читающий STATUS, получает картину «буфер полон, ошибок нет» — и делает из неё неверный вывод: раз ошибок нет, ничего не потеряно.
Что казалось логичным. Первая версия — самая естественная: «переполнения не было, иначе бит бы встал». Вторая: «флаг sticky, наверное, его кто-то сбросил — надо квитировать ERROR_CLR и перечитать». Третья: «баг в AXI-обвязке или в Linux-драйвере, ядро-то не трогали». Четвёртая, и вот она опаснее всех: «предупреждение Synth 8-7129 про неподключённый порт можно игнорировать, их там десятки».
Как проверяли. Отправной точкой стало именно то предупреждение, которое хотелось проигнорировать. Vivado при elaboration выдал:
WARNING: [Synth 8-7129] Port eng_err_rx_ovf in module spi_reg_if is either unconnected or has no load
Всего таких предупреждений в сборке 46. Сорок пять из них безобидны: это зарезервированные биты delay_cfg[31:24], неиспользуемые по отклонениям D-1 и D-3 сигналы awprot, arprot, wstrb, младшие биты байтового адреса. Одно — про сигнал с именем, в котором стоит слово err. Порт ошибки, у которого нет нагрузки, — это не стилистика, это функциональный риск, и разбирать его надо первым.
Дальше — чтение исходников по цепочке из трёх звеньев. Звено первое: ../../rtl/spi_reg_if.v, сборка слова STATUS. Бит 9 берётся из сигнала rx_fifo_overflow — то есть из флага самого FIFO. Звено второе: ../../rtl/spi_fifo.v, где этот флаг взводится условием wr_en && full. Звено третье, решающее: ../../rtl/spi_engine.v, состояние S_XFER_END. При полном приёмном буфере движок не поднимает rx_wr_en, а вместо этого выставляет собственный флаг:
if (!rx_full) begin rx_wr_en <= 1'b1; rx_data <= pack_rx(rx_word, bits_total); end else begin err_rx_overflow <= 1'b1; end
Вот и вся ловушка. Условие wr_en && full со стороны движка недостижимо в принципе: движок никогда не пишет в полный буфер, он вежливо воздерживается. Значит флаг FIFO от переполнения движком не встанет никогда, а бит 9 STATUS собран именно из него. Собственный же флаг движка приходит на порт eng_err_rx_ovf модуля spi_reg_if — и там не используется ни в одном выражении. Порт объявлен, подключён сверху, и на этом всё. Отсюда и предупреждение синтезатора.
Улику предъявили экспериментом. Тест 26 в ../../sim/tb_spi_axi4lite.v создаёт настоящее переполнение: шестнадцать принятых слов при глубине буфера восемь, без единого чтения со стороны хоста. Результат:
A-5 EVIDENCE: STATUS=0000007e RX_FULL=1 ERR_RX_OVF=0 IRQ_ERROR=1
Переполнение состоялось, RX_FULL поднят, бит 9 — ноль, а вот IRQ_ERROR единица. Последнее и есть спасительная деталь.
Корневая причина. Три звена, каждое проверяемо по исходнику: STATUS[9] питается от флага FIFO (../../rtl/spi_reg_if.v, сборка status_word); условие этого флага недостижимо со стороны движка (../../rtl/spi_fifo.v, ovf_flag); собственный флаг движка (../../rtl/spi_engine.v, err_rx_overflow) до STATUS не доходит. Ошибка не в одном месте, а в стыке трёх — поэтому её и не видно при чтении отдельного файла.
Что это значит для драйвера. Ошибка не теряется полностью: spi_master_top включает eng_err_rx_ovf в irq_err_evt, поэтому событие попадает в IRQ_STATUS.ERROR, бит 2. Пропадает именно удобный бит STATUS — тот самый, на который естественнее всего сядет и драйвер, и лабораторный скрипт с devmem. Следствие сформулировано в ../hw_sw_contract.md, раздел 8, и оно категорично: до исправления переполнение приёма детектируется только через IRQ_STATUS.ERROR, опрос STATUS[9] бесполезен. Драйвер, который живёт опросом и не читает IRQ_STATUS, будет молча терять данные и рапортовать об успехе.
Что сделали. Не исправили: исправление меняет наблюдаемое поведение регистра, а это выходит за рамки переноса (O-1 в ../migration.md). Сам фикс тривиален — источником бита 9 сделать rx_fifo_overflow | eng_err_rx_ovf. Вместо фикса тест 26, который утверждает нулевое значение бита как ожидаемое, и предупреждение в трёх документах.
Правило на будущее. Предупреждение «port has no load» на сигнале с именем, содержащим err, считайте задачей высшего приоритета, пока не доказано обратное. Именно поэтому глушить предупреждения пачками запрещено — сорок пять безобидных штук в этой сб