SPI-контроллер на FPGA: переносим на Zynq 7000. Часть 2

Продолжаем разработку SPI IP Core на Verilog. В первой части мы перенесли SPI-контроллер с Altera Cyclone IV на Zynq-7020, сохранили исходный регистровый интерфейс и добавили AXI4-Lite. Теперь задача меняется: нужно превратить работающий RTL в полноценное устройство под Linux.
Во второй части соберем Block Design с PS, настроим карту адресов и прерывания, подготовим загрузочную цепочку и Device Tree, а затем подключим контроллер к стандартной SPI subsystem Linux. Здесь уже недостаточно рабочего bitstream: ошибка в ps7_init, адресах или Device Tree может сделать исправное ядро недоступным для ОС. После обычного PIO-доступа добавим потоковый тракт для больших объемов данных. Для этого появятся AXI-Stream адаптер, глубокий FIFO, backpressure и режим TX-only. По ходу разберем ошибки, которые проявляются только под нагрузкой: потерю байтов на границе FIFO, проблемы запуска DMA и устаревший результат синтеза.
Финал этой части - плата самостоятельно загружает Linux, SPI работает через драйвер, а большие буферы передаются из DDR в PL через AXI DMA без побайтового участия Cortex-A9.
Всем, кому интересно продолжение - добро пожаловать под кат! =)

Дисклеймер. Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи — рассказать о своем опыте. Я не являюсь профессиональным разработчиком под ПЛИС на языке Verilog и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется. Что ж, поехали…
Репозиторий
Весь список материалов, исходный код для проектов и подробную документацию вы можете найти по ссылке в репозитории: https://github.com/megalloid/SPI-Master-Controller/tree/master/spi_xilinx
Содержание
Часть VII. Linux и Buildroot (главы 26–31)
Самая богатая ошибками часть. Цепочка загрузки от FSBL до корневой файловой системы, чужой ps7_init, тихо ломающий память, девять ошибок сборки образа, ловушки Device Tree, драйвер, проверяющий паспорт IP, и отдельная детективная история про Ethernet, который поднимал линк, но не пропускал пакеты.
Цепочка загрузки: FSBL, U-Boot, битстрим, ядро, rootfs.
Ловушка чужого
ps7_initот отладочной платы ZC702.Девять ошибок сборки и конфигурации образа.
Device Tree: узлы, тактовые сигналы, зарезервированная память.
Драйвер
spi-zynq-fpga: probe, проверка паспорта, регрессии.Ethernet: адрес PHY и режим пинов против рабочей сети.
Часть VIII. Дисплей ST7789 на PL SPI (главы 32–35)
Дисплей как честный критерий успеха: он показывает то, что осциллограф показать не может. Инициализация панели, ориентация, смещение окна, и первые артефакты, которые оказались подсказками к будущей истории с DMA.
Зачем нужен дисплей в этом эксперименте.
Линии D/C, RST и подсветки через AXI GPIO.
Инициализация панели, MADCTL и смещение по вертикали.
Программа
spi-clock:/dev/mem, unbind и перерисовка плитки.
Часть IX. Потоковый режим и AXI DMA (главы 36–43)
Ядро статьи. Почему FIFO глубины восемь физически не может переварить полный кадр, как устроен путь данных от DDR до пина, какие регистры для этого появились — и шесть тяжёлых ошибок: от ложного признака конца передачи до гонки в собственном FIFO и устаревшего кэша синтезатора, из-за которого исправленный код существовал в git, но не в кристалле.
Почему глубина FIFO 8 и штатный CS ломают полный кадр.
Путь данных: DDR, порт HP0, DMA, поток, глубокий FIFO, удержание CS.
Новые регистры STREAM_CTRL, STREAM_STATUS, FAST_DIV и версия IP.
Режим TX_ONLY и почему без него длинный RAMWR обречён.
Программные грабли
spi-clock.Гонка в FIFO и лечение через
almost_full.Устаревший out-of-context checkpoint: фикс в git, старое железо в кристалле.
Загрузка по JTAG под живым Linux и правильный путь через FAT.
Часть X. Итоги (главы 44–46)
Что перенесли без изменений, что добавили, что осталось привязанным к вендору; какие архитектурные решения оказались верными, а за какие пришлось поплатиться; программа лабораторных для повторения пути и направления для развития проекта.
Что перенесли, что добавили, что осталось vendor-specific.
Программа лабораторных: как повторить путь.
Куда расти дальше.
Приложения
Приложение A — глоссарий, сгруппированный по слоям системы.
Приложение B — карта регистров с примерами devmem и минимальной транзакцией на C.
Приложение C — шпаргалка команд Vivado, XSCT, Buildroot и работы с платой.
Приложение D — индекс всех разобранных дефектов со ссылками на главы.
Приложение E — честный разговор о ресурсах кристалла.
Часть VII. Linux и Buildroot
К этому месту серии железо уже отвечает. В части V мы убедились, что SPI-ядро живёт в ПЛИС само по себе, без процессора. В части VI рядом с ним появился Cortex-A9: block design, шина AXI, адрес 0x40000000, прерывание IRQ_F2P[0]. Через JTAG можно прочитать регистр и увидеть осмысленное число — но это победа лабораторная: каждый раз нужны ноутбук, кабель и Vivado. Теперь мы хотим включить плату в розетку, подождать десять секунд, зайти по ssh и набрать команду. Между этими двумя состояниями лежит операционная система, и её надо собрать целиком, из исходников, под конкретно эту плату.
Дальше будет много историй вида «мы сделали разумную вещь, и она молча не сработала». Это не признак плохой работы: в цепочке загрузки встраиваемой системы около двух десятков независимых компонентов, и каждый имеет право промолчать. Ценность части не в финальном defconfig — его можно скопировать за минуту, — а в том, как выглядит каждая ошибка изнутри, до того как вы знаете ответ.
Отдельно про честность. В ../buildroot.md таблица «что осталось неподтверждённым» и в ../driver.md чеклист из двадцати пунктов зафиксированы до первого включения платы. Часть историй ниже описана уже по симптомам с живой платы и осела в комментариях к zynq-rk7020-spi.dts и boot.cmd. Где источник молчит — мы говорим «не проверено», а не додумываем.
Глава 26. Boot chain: FSBL → U-Boot → system.bit → kernel
26.1. Что вообще значит «загрузиться»
Процессор после подачи питания умеет ровно одно: взять инструкцию по фиксированному адресу и выполнить её. Он не знает, что такое файл, диск, SD-карта, Linux. Всё, что мы называем «загрузкой», — это цепочка программ, где каждая следующая больше и умнее предыдущей, а каждая предыдущая существует только ради того, чтобы следующая смогла запуститься. Программа, задача которой — подготовить условия и передать управление дальше, называется загрузчиком (bootloader). Аналогия: большой дизель заводят маленьким пусковым двигателем, а пусковой — стартером от аккумулятора; крутить дизель напрямую от аккумулятора никто не пытается.
Какие условия надо подготовить на Zynq-7000? Три, и все критичны. Во-первых, оперативная память: микросхема DDR3 после включения — не память, а набор выводов, потому что контроллер DDR внутри чипа не настроен, тайминги не заданы, калибровка не проведена. Во-вторых, выводы корпуса: ножек у чипа меньше, чем периферии внутри, поэтому в Zynq есть блок MIO (Multiplexed I/O) — 54 вывода, каждый из которых может быть UART, SD, Ethernet или просто GPIO в зависимости от содержимого регистра мультиплексора. До настройки MIO у платы нет ни консоли, ни карты, ни сети. В-третьих, тактовые частоты: PLL не запрограммированы.
Программа, которая всё это делает, не может лежать в DDR — DDR ещё нет. Поэтому внутри кристалла есть 256 КБ статической памяти OCM (On-Chip Memory), и первый загрузчик живёт там. Он называется FSBL, First Stage Boot Loader; размер его жёстко ограничен размером OCM, поэтому он умеет только самое необходимое.
26.2. Кто кого будит на Zynq-7000
Самая первая программа зашита в кремний и называется BootROM. Её нельзя изменить, обновить или сломать. BootROM смотрит на состояние нескольких выводов платы (boot mode straps, перемычки режима загрузки), понимает, откуда грузиться — SD-карта, QSPI-флэш или JTAG, — находит на первом FAT-разделе карты файл со строго определённым именем boot.bin, копирует его в OCM и передаёт управление. Имя boot.bin и требование «FAT-раздел первым» — не наш выбор и не выбор Buildroot: так устроен кремний, и наш genimage.cfg просто подчиняется. Дальше цепочка выглядит так.

Каждая стрелка здесь — это место, где всё может встать намертво, и в следующих главах мы разберём почти каждую.
26.3. Почему FSBL — это U-Boot SPL, а не отдельный FSBL из Vitis
У Xilinx есть свой FSBL: он генерируется в Vitis и представляет собой отдельный проект на C, который потом склеивают с U-Boot утилитой bootgen. Мы этого не делаем, потому что у U-Boot есть собственная «первая ступень» — SPL (Secondary Program Loader). Это урезанный U-Boot, помещающийся в OCM и умеющий ровно то, что нужно FSBL: поднять PS и загрузить полный U-Boot в DDR. На Zynq-7000 выход SPL — файл spl/boot.bin, то есть в точности тот, который ищет BootROM. В defconfig это выражено двумя строками: BR2_TARGET_UBOOT_SPL=y и BR2_TARGET_UBOOT_SPL_NAME="spl/boot.bin".
Обоснование — правило «один механизм на одну задачу» (раздел 80 задания). Два FSBL — это два места, где программируется DDR, два места, где настраивается MIO, и два набора версий, обязанных совпадать; расходятся они молча. Цена решения тоже есть: мы теряем инструменты Xilinx, привязанные к их FSBL, и обязаны сами подсунуть в U-Boot правильный ps7_init — чем и займёмся в главе 27.
26.4. Почему битстрим грузит U-Boot из FAT, а не JTAG
Битстрим — файл конфигурации ПЛИС: миллионы бит, определяющих, во что превратится программируемая логика. Пока он не загружен, «фабрика» (PL, Programmable Logic) пуста и по адресу 0x40000000 нет ничего. Загрузить его можно тремя способами: по JTAG с компьютера, из U-Boot до старта ядра или из самого Linux через подсистему FPGA Manager. Мы выбрали средний вариант, и делает это boot.cmd:
fatload mmc 0:1 ${kernel_addr_r} system.bit
fpga loadb 0 ${kernel_addr_r} ${filesize}
mw.l 0xf8000008 0xdf0d # разблокировать SLCR
mw.l 0xf8000170 0x00300600 # делители FCLK0 из нашего block design
mw.l 0xf8000900 0xf # level shifters PS <-> PL
mw.l 0xf8000240 0x0 # снять сброс с фабрики
mw.l 0xf8000004 0x767b # заблокировать SLCR обратноПорядок принципиален: PL конфигурируется до старта ядра. В ядре включены встроенные драйверы axi-gpio и axi-dma, и при загрузке они лезут по адресам 0x40010000 и 0x40400000. Если фабрика пуста, эти адреса не отвечают, AXI-транзакция не завершается, и процессор зависает на первом же обращении — консоль замолкает сразу после того, как поднялся ttyPS0, без единого сообщения об ошибке. Именно этот сценарий описан в шапке boot.cmd как последствие неудачной загрузки битстрима.
Раз PL программируется из U-Boot, механизм загрузки из Linux должен быть выключен — иначе получаются те самые «два механизма». В linux.config CONFIG_FPGA и CONFIG_FPGA_MGR_ZYNQ_FPGA намеренно закомментированы, и там же записано условие обратного перехода: включать их можно, только одновременно убрав шаг из boot.cmd.
Почему не JTAG? Он удобен на bring-up: перезалил битстрим за секунды, не трогая карту. Но у него есть побочный эффект, стоивший нам отдельного расследования в части IX: реконфигурация PL по JTAG под работающим Linux регулярно убивает Ethernet — ssh обрывается, сеть не поднимается до перезагрузки (дефект ETH-3). Поэтому рабочий цикл такой: system.bit кладётся на FAT, плата перезагружается, всё предсказуемо. JTAG остаётся для случаев, когда сеть не нужна.
Мелкая, но дорогая деталь оттуда же. В boot.cmd стоит fpga loadb, а не fpga load, потому что system.bit — файл Vivado с заголовком, а не голый поток бит, и load залил бы заголовок как конфигурацию. Адрес указан как ${kernel_addr_r}, а не ${loadaddr}: xilinx_zynq_virt задаёт CONFIG_SYS_LOAD_ADDR=0, и fpga load с нулевым адресом отказывается работать с сообщением Zero fpga_data address, оставляя PL непрограммированной. Дальше — тот самый молчаливый зависон на MMIO. Правило: если U-Boot ругается коротко и загадочно, следующий симптом вы увидите уже в ядре и совсем в другом месте.
26.5. Что в итоге лежит на карте
Сборка Buildroot выдаёт набор артефактов, и полезно один раз проговорить, кто из них кому нужен. genimage собирает образ SD-карты: первый раздел — FAT (тип 0xC, bootable), второй — ext4 (тип 0x83) с корневой ФС.
Файл | Кто создаёт | Где лежит | Зачем он нужен |
|---|---|---|---|
| U-Boot SPL | FAT | FSBL: BootROM ищет его. Внутри |
| U-Boot | FAT | Полный загрузчик, работает уже в DDR |
|
| FAT | Сценарий: сперва PL, потом ядро |
| Vivado, кладёт | FAT | Конфигурация ПЛИС |
| ядро Linux 6.12.102 | FAT | Собственно ядро |
|
| FAT | Описание железа для ядра (гл. 29) |
| Buildroot | 2-й раздел | BusyBox, ssh, модуль, утилиты |
|
| — | Всё перечисленное одним файлом под |
Размеры из ../buildroot.md по факту успешной сборки: boot.bin — 130 328 байт, u-boot.img — 1.3 МБ, zImage — 11.9 МБ, system.dtb — 16 КБ, rootfs.ext4 — 120 МБ; текущий genimage.cfg отводит под FAT 48 МиБ на шесть файлов, включая битстрим.
Корневая файловая система (rootfs) — это то, что вы видите как / после загрузки. Ядро без неё загрузится и тут же запаникует: ему некуда идти за первой программой. У нас её собирает Buildroot из BusyBox (один бинарник, притворяющийся сотней утилит), OpenSSH, ethtool, kmod и двух проектных пакетов — модуля ядра и тестовых утилит.
26.6. Почему Buildroot, а не Yocto и не готовый Petalinux-образ
Задача — получить систему, которая собирается одной командой из зафиксированных версий и в которой можно объяснить каждую строку. Buildroot даёт ровно это: один читаемый defconfig, полная сборка «с нуля» (включая компилятор) и механизм BR2_EXTERNAL, позволяющий не трогать дерево Buildroot вообще — всё проектное живёт в buildroot-external/, как требует раздел 72.
Yocto мощнее и в промышленной разработке чаще правильный выбор: слои, рецепты, готовый механизм обновлений. Но входной билет — недели на освоение bitbake, а нам нужно объяснить загрузку, а не систему сборки. Готовый образ от производителя или Petalinux избавляет от сборки вовсе, но ровно на этом и ломается: образ приходит чёрным ящиком, и когда что-то не работает (а в этой части не работает почти всё и по очереди), внутрь заглянуть нечем.
Цена Buildroot честная. На целевой системе нет пакетного менеджера: чтобы добавить утилиту, надо пересобрать образ. Инкрементальность частичная — смена toolchain означает make clean и несколько часов. И «собралось» не значит «правильно сконфигурировано»: Kconfig умеет молча выбрасывать непонятные ему опции, и вся глава 28 — про это.
26.7. Лестница уровней: правило локализации
Система, которую мы собираем, — башня из десятка слоёв, и симптом почти никогда не возникает на том слое, где живёт причина. Поэтому в ../linux_bringup.md зафиксировано правило: при любом отказе сначала назвать уровень, и только потом менять код.
Buildroot -> Toolchain -> U-Boot -> Linux kernel -> Device Tree ->
Platform driver -> SPI subsystem -> AXI -> FPGA registers -> SPI RTL -> I/OНе чинить Verilog из-за опечатки в compatible; не переписывать драйвер, потому что на выводе нет сигналов. Для каждого дефекта фиксировать шесть пунктов: уровень, симптом, подтверждающие данные, корневую причину, исправление и способ проверки. Дальше часть построена именно так.
Глава 27. Ловушка ps7_init от ZC702
27.1. Что такое ps7_init и почему он привязан к плате, а не к чипу
Вернёмся к трём условиям из раздела 26.1: DDR, MIO, клоки. Функция, которая их выполняет, называется ps7_init(), и её не пишут руками — её генерирует Vivado из block design. Вы указываете в интерфейсе, какая микросхема памяти стоит на плате, с какими таймингами, какой вывод MIO куда идёт, какая частота у опорного генератора, — и получаете файл на C, почти целиком состоящий из записей в регистры. Вот Tcl-версия того же самого, из ps7_init.tcl нашей платы:
mask_write 0XF8006004 0x0007FFFF 0x00001082
mask_write 0XF8000740 0x00003FFF 0x00001202Первая строка — конфигурация контроллера памяти. Вторая — настройка вывода MIO16. Таких строк в файле сотни, и каждая описывает конкретную плату.
Ключевое место, о которое разбиваются все: ps7_init привязан к плате, а не к номеру чипа. Две платы могут нести один и тот же xc7z020 и при этом иметь разную микросхему DDR, разную разводку MIO, разный опорный генератор. Одинаковый part number гарантирует только то, что у них одинаковый кремний. Всё остальное — вопрос схемы.
27.2. Как чужая инициализация попала в наш образ
Симптома в привычном смысле здесь не было — и это худший вид ошибки. Сборка проходила успешно, boot.bin создавался, компилятор не выдавал ни одного предупреждения. Ошибку нашли не по логу, а чтением: при разборе того, как именно U-Boot выбирает файл инициализации.
Механизм такой. В board/xilinx/zynq/Makefile есть цепочка с запасным вариантом: сначала U-Boot смотрит на CONFIG_XILINX_PS_INIT_FILE, а если опция пуста — берёт $(DEVICE_TREE)/ps7_init_gpl.c. То есть имя device tree определяет, чей ps7_init попадёт в загрузчик. Проект на тот момент собирался с DEVICE_TREE=zynq-zc702, потому что базовым описанием платы был взят zc702 (глава 29), и U-Boot послушно взял ps7_init_gpl.c из каталога zc702 — инициализацию отладочной платы Xilinx.

Гипотеза, казавшаяся совершенно логичной и продержавшаяся дольше всего: «ZC702 — тоже Zynq-7020, чип тот же, значит инициализация подойдёт». Она звучит разумно ровно до момента, когда вы сравниваете файлы построчно.
Блок инициализации | RK-ZYNQ7020-F | ZC702 |
|---|---|---|
DDR | 80 регистров | 81 |
MIO | 71 регистр | 73 |
Clock | 14 регистров | 17 |
|
|
|
Различается не только количество записей — различается содержимое. Другая конфигурация DRAM означает, что контроллер памяти настроен под чип, которого на плате нет: в лучшем случае DDR не поднимется совсем и SPL умрёт, не дойдя до U-Boot; в худшем — поднимется «почти», и вы получите редкие невоспроизводимые повреждения данных, которые будете месяц ловить в драйвере. Другое мультиплексирование MIO означает, что выводы разведутся под чужую периферию: консоль на другом UART, карта на другом контроллере. Это и есть silent brick — плата не подаёт признаков жизни и не сообщает почему, потому что консоль настраивает ровно тот код, который настроен неправильно.
Проверяли двумя способами. Первый — чтение Makefile U-Boot и поиск, кто именно выбирает файл; это дало механизм. Второй — прямое сравнение двух ps7_init_gpl.c, нашего заводского и стокового zc702, по количеству и значениям регистровых записей; это дало таблицу выше. Уликой, перевернувшей картину, стала пара 0x1082 против 0x1081 по адресу 0xF8006004: одно значение регистра конфигурации памяти, отличающееся на один бит, — но это бит, описывающий саму микросхему DRAM.
Корневая причина дефекта ETH-1: файл инициализации PS выбирается по имени device tree, а device tree мы честно взяли чужой; связь «DT → ps7_init» нигде не объявлена явно и не логируется.
Правило: для любой платы-клона «похожего» Zynq-7020 первым делом подменяйте ps7_init, и проверяйте результат в двоичном файле, а не в логе компиляции.
27.3. Чем заменили и почему именно этим файлом
Нужен ps7_init_gpl.c, сгенерированный для нашей платы. Он нашёлся в штатном проекте производителя, который поставляется вместе с платой:
RK-ZYNQ7020-F/5. Factory Image/factory/vivado/image_7020/image_7020.gen/
sources_1/bd/design_1/ip/design_1_processing_system7_0_0/ps7_init_gpl.cЭтот файл авторитетен: он описывает ту конфигурацию PS, с которой плата физически отгружается с завода. Вариант с суффиксом _gpl Xilinx выпускает под GPL-2.0+ именно для линковки в U-Boot, и он покрывает ревизии кремния 1.0, 2.0 и 3.0 — нужная таблица выбирается во время выполнения.
Файл лежит в ../../linux/uboot/ps7_init_gpl.c, а подставляет его в дерево U-Boot пакет rk7020-uboot-ps7. Фрагмент конфигурации задаёт путь относительный:
CONFIG_XILINX_PS_INIT_FILE="board/xilinx/zynq/custom_hw_platform/ps7_init_gpl.c"Причина прозаична: CONFIG_XILINX_PS_INIT_FILE разрешается через readlink -f, поэтому абсолютный путь тоже сработал бы — и навсегда вписал бы в коммит расположение конкретно этого рабочего каталога.
Одна правка в сгенерированном файле всё-таки понадобилась: объявления вида int ps7_init() в стиле K&R заменены на int ps7_init(void). U-Boot собирается с -Werror=strict-prototypes; подавление этого предупреждения в U-Boot есть, но привязано к цели ps7_init_gpl.o, а файл, поданный через CONFIG_XILINX_PS_INIT_FILE, компилируется в ps_init_gpl.o и подавления не получает. Правка совпадает с практикой upstream: собственный заголовок U-Boot объявляет int ps7_init(void);. Условие зафиксировано комментарием в начале файла, потому что при перегенерации из Vivado его придётся повторить.
Ещё одна ловушка того же пакета — из категории «сами наступили, сами записали». Хук, устанавливающий файл в дерево U-Boot, повешен на два события: pre-configure и pre-build. Естественное место — pre-configure, там применяется kconfig-фрагмент. Но make uboot-rebuild выполняет только шаг сборки, поэтому хук на одном configure оставлял в дереве старую копию, и правка ps7_init_gpl.c выглядела как не давшая эффекта. Ровно это однажды и произошло; двойной хук делает копирование идемпотентным. Правило: если правка исходника «не действует», проверьте, попадает ли она в дерево, которое реально компилируется.
27.4. Как доказали, что в образ попал нужный FSBL
Компиляция ничего не доказывает — мы только что видели, что она успешно собирала чужую инициализацию. Поэтому проверка двоичная: искать различающее значение регистра прямо в готовом файле.
boot.bin (130 328 байт)
значение этой платы (0x1082) : найдено 1 раз
значение ZC702 (0x1081) : не найдено
sdcard.img
boot.bin присутствует дословно : да
значение этой платы : найдено
значение ZC702 : не найденоЗаодно проверен заголовок, который читает BootROM (UG585, таблица 6-5): по смещению 0x20 лежит 0xAA995566 (шаблон определения ширины шины), по 0x24 — 0x584C4E58, то есть ASCII "XLNX", по 0x28 — нули (образ не зашифрован), по 0x40 — адрес исполнения 0x0001FD18, который попадает в OCM, как и положено SPL.
Что здесь не доказано, и в отчёте это сказано прямо: фактический подъём DDR на плате. Инициализация взята из рабочего дизайна производителя и попала в образ доказуемо, но на момент записи отчёта запуска не было. Разница между «правильный файл в правильном месте» и «DDR работает» — это разница между документом и осциллографом.
В том же фрагменте U-Boot включён ранний вывод: CONFIG_DEBUG_UART=y, CONFIG_DEBUG_UART_ZYNQ=y, база 0xE0000000, частота 100000000. Без него SPL молчит, пока не поднимется драйвер последовательного порта, а с чужим DT этот драйвер целился в UART1, которого на нашей плате нет. Функция board_debug_uart_init() в arch/arm/mach-zynq/spl.c вызывает ps7_init() раньше, поэтому к первому символу порт уже живой. Правило: ранняя консоль — это не удобство, а единственный способ отличить «зависло» от «не начиналось».
Глава 28. Девять багов сборки образа
Полная сборка Buildroot 2026.02.3 с Linux 6.12.102 и U-Boot 2026.01 была проведена от начала до конца, включая toolchain с нуля, и завершилась с кодом возврата 0. Собранный компилятор: GCC 14.3.0, glibc 2.42, binutils 2.44, триплет arm-buildroot-linux-gnueabihf. Ядро выбрано не случайно: драйверу нужно не ниже 6.8, потому что он использует devm_spi_alloc_host().
Главная ценность прогона не в артефактах, а в том, что он нашёл девять ошибок конфигурации, которые статическая вычитка defconfig найти не могла. Пять из них падают громко, и с ними легко. Четыре — молчаливые: система собирается, выглядит правильной и врёт о себе.
Механизм молчаливых ошибок стоит объяснить один раз. Buildroot конфигурируется через Kconfig — ту же систему, что и ядро Linux. Когда вы пишете в defconfig строку с несуществующим символом или с символом, чьи зависимости не выполнены, Kconfig не считает это ошибкой: он выбрасывает строку и продолжает. Логика разумная — конфигурации переносят между версиями, и падать на каждой устаревшей опции было бы невыносимо. Последствие для нас: defconfig — не приказ, а пожелание, и что из него сбылось, надо проверять отдельно.
28.1. Первый: FPU, которого нет в списке
Небольшое отступление для тех, кто не сталкивался. У ARM-процессора есть отдельный блок вычислений с плавающей точкой — VFP, — и его расширение для векторных операций, NEON: одна инструкция обрабатывает сразу несколько чисел, что важно для обработки сигналов и графики. Компилятору надо сказать, какой именно блок есть в вашем ядре, иначе он либо не воспользуется им вовсе (и всё будет считаться медленно, программно), либо сгенерирует инструкции, которых процессор не понимает. Более того, выбор влияет на ABI — двоичное соглашение о том, как передаются аргументы: триплет нашего toolchain arm-buildroot-linux-gnueabihf заканчивается на hf, hard float, то есть вещественные аргументы едут в регистрах FPU.
В defconfig за это отвечают три независимых переключателя: BR2_ARM_ENABLE_VFP, BR2_ARM_ENABLE_NEON и собственно выбор модели FPU. Мы честно попросили третий строкой BR2_ARM_FPU_NEON_VFPV3=y. Имя выглядит правдоподобно: NEON поверх VFPv3 — ровно то, что стоит в Cortex-A9, и такие имена действительно встречаются в старых конфигурациях и в интернете.
Симптом: никакого. Сборка проходит, бинарники получаются, всё работает. Заметить можно только косвенно — по флагам компилятора в логе (-mfpu=) или по тому, что итоговый .config не содержит ни этой строки, ни её замены.
Гипотеза «опция сработала» держится на естественном допущении: если я написал строку и никто не возразил, значит строка принята. Проверка, которая это опровергает, тривиальна и должна войти в привычку: открыть arch/Config.in.arm в дереве Buildroot своей версии и посмотреть, какие символы там объявлены. В Buildroot 2026.02.3 символа BR2_ARM_FPU_NEON_VFPV3 не существует; для ядра с NEON объявлен просто BR2_ARM_FPU_NEON.
Корневая причина: имя символа перенесено из чужого примера и не сверено с исходниками конкретной версии. Исправление в одну строку, цена ошибки невелика — потеря векторной оптимизации, — но механизм ровно тот же, что у следующей ошибки, где цена уже серьёзная.
Правило: имена символов Kconfig проверяйте в Config.in своей версии, а не по памяти и не по чужому конфигу.
28.2. Второй: glibc, который молча стал uClibc
Та же ошибка, но с последствиями. Пояснение для тех, кто раньше не задумывался: стандартная библиотека C (libc) — это код между вашей программой и ядром: printf, malloc, сокеты, потоки. Она не одна. glibc — большая и полная, стандарт для десктопа и серверов; uClibc — компактная, для систем, где важен каждый мегабайт, но с урезанной поддержкой части возможностей (локали, отдельные куски POSIX, детали поведения потоков). Программа, собранная против одной libc, не запустится с другой: они несовместимы на уровне двоичного интерфейса.
Мы явно попросили glibc: BR2_TOOLCHAIN_BUILDROOT_GLIBC=y. А в готовом .config оказалось BR2_TOOLCHAIN_BUILDROOT_LIBC="uclibc".
Симптом: снова никакого. Система собирается, ssh работает, BusyBox запускается. Обнаружить можно только при чтении итогового .config или при попытке запустить на плате бинарник, собранный где-то ещё.
Гипотеза «я же явно выбрал glibc, значит будет glibc» настолько естественна, что искать здесь ошибку никто не станет. Пришлось идти по цепочке зависимостей, и там обнаружилась логичная и абсолютно неочевидная конструкция. glibc в Buildroot зависит от BR2_PACKAGE_GLIBC_SUPPORTS, а тот требует заголовков ядра версии не ниже 3.2. Заголовки ядра берутся по опции BR2_KERNEL_HEADERS_AS_KERNEL — «те же, что у собираемого ядра». Ядро у нас задано строкой BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.12.102". И вот ключ: резолвер Kconfig не умеет читать версию из строковой опции. Для него значение BR2_TOOLCHAIN_HEADERS_AT_LEAST остаётся равным "2.6", условие «не ниже 3.2» не выполняется, glibc становится невыбираемым, и Buildroot молча откатывается на uClibc.
Улика, перевернувшая картину, — строка HEADERS_AT_LEAST со значением 2.6 в сгенерированном .config при ядре 6.12. Число «2.6» в проекте, где ядро 2.6 нигде не упоминается, ни с чем не спутаешь.
Исправление — одна строка BR2_PACKAGE_HOST_LINUX_HEADERS_CUSTOM_6_12=y, сообщающая серию заголовков явным символом, а не строкой. Правило описано в собственном руководстве Buildroot (docs/manual/adding-board-support.adoc): при пользовательской версии ядра задавайте и серию пользовательских заголовков. Две строки теперь обязаны меняться вместе, и в defconfig про это написан комментарий на десяток строк — через год никто не вспомнит.
Правило: после make <defconfig> открывайте сгенерированный .config и проверяйте, что критичные решения — libc, архитектура, версии — в нём действительно те, о которых вы просили.
28.3. Третий: kmod, отброшенный за ненадобностью
Модуль ядра — это кусок кода, который можно загрузить в работающее ядро и выгрузить обратно, не перезагружаясь; наш драйвер собирается именно модулем. Загружают модули утилитами insmod (по имени файла) и modprobe (по имени модуля, с разрешением зависимостей); вторая удобнее и требует базы, которую строит depmod. Всё это входит в пакет kmod, и мы его запросили: BR2_PACKAGE_KMOD_TOOLS=y.
Симптом молчаливый по той же схеме: строка выброшена, на плате есть только встроенные в BusyBox insmod и modprobe. Заметно это стало бы уже на плате — когда modprobe по имени модуля не находит его, хотя файл лежит на месте.
Логичная гипотеза: «пакет есть в Buildroot, я его выбрал, значит он соберётся». Проверка — снова чтение Config.in: BR2_PACKAGE_KMOD_TOOLS зависит от BR2_PACKAGE_BUSYBOX_SHOW_OTHERS. Смысл зависимости разумен: по умолчанию Buildroot прячет пакеты, функциональность которых уже даёт BusyBox, чтобы не плодить дубликаты в крошечной системе. Пока «показывать альтернативы BusyBox» выключено, запрос на настоящий kmod отбрасывается без единого слова.
Исправление: BR2_PACKAGE_BUSYBOX_SHOW_OTHERS=y рядом с BR2_PACKAGE_KMOD=y и BR2_PACKAGE_KMOD_TOOLS=y.
Стоит объяснить, почему нас не устроили встроенные апплеты BusyBox — они ведь есть и формально делают то же самое. Разница в depmod. Эта утилита обходит установленные модули, читает их символы и зависимости и строит файл modules.dep; именно по нему modprobe умеет находить модуль по имени и подтягивать то, что тому требуется. Без корректной базы modprobe по имени превращается в лотерею, и остаётся insmod с полным путём к файлу. А наш init-скрипт из главы 30 вызывает ровно modprobe spi-zynq-fpga — по имени. То есть молчаливо отброшенная строка в defconfig проявилась бы не при сборке и даже не при первой ручной проверке, а при первой автоматической загрузке модуля на плате, где отлаживать дороже всего.
Проверяется это тем же способом, что и предыдущие два случая: после make <defconfig> посмотреть в сгенерированный .config, есть ли там запрошенные символы. Три ошибки подряд с одним и тем же способом обнаружения — достаточный повод сделать этот взгляд обязательным шагом, а не жестом паранойи.
Правило: если пакет «выбран, но не собрался» — ищите не опечатку в имени, а невыполненную зависимость, которая его скрывает.
28.4. Четвёртый: devmem2, которого больше нет
Здесь ошибка наконец громкая. devmem2 — крошечная классическая утилита, позволяющая прочитать или записать физический адрес из userspace через /dev/mem. Для bring-up она бесценна: это способ пощупать регистр IP раньше, чем существует драйвер, и именно с неё начинается стадия 2 в ../linux_bringup.md.
Симптом: сборка падает сразу, с сообщением про «legacy configuration». Buildroot не просто отбросил опцию — он специально сохранил запись о том, что пакет удалён, и остановил сборку. Это отличный пример того, как надо: изменение, которое молча поменяло бы поведение, ловится явной ошибкой.
Гипотеза «утилита есть везде, значит есть и здесь» рассыпается о чтение самой legacy-записи, которая указывает замену: applet devmem из BusyBox. Он уже включён в конфигурацию BusyBox по умолчанию (CONFIG_DEVMEM=y), так что дополнительный пакет не нужен вовсе.
Нюанс, который ломает копипасту из старых инструкций: у них разный порядок аргументов.
devmem2 0x4000003c w # удалённый пакет: ширина буквой
devmem 0x4000003c 32 # BusyBox: ширина числом, третьим аргументомВся документация проекта синхронизирована под вариант BusyBox. Ошибиться здесь дорого: devmem с лишним аргументом означает запись. У человека, скопировавшего команду из старой инструкции, «ширина w» превращается в значение, которое уедет в регистр.
А писать в незнакомый регистр во время bring-up запрещено разделом 90 задания, и причина не формальная. В нашем IP есть регистры с побочным эффектом чтения и записи: RX_DATA по смещению 0x18 при каждом чтении извлекает слово из приёмного FIFO, а запись в ERROR_CLR по 0x28 флашит оба FIFO. Случайная запись не просто «ничего не даст» — она уничтожит состояние, которое вы собирались диагностировать, и следующая попытка покажет другую картину. Поэтому процедура из ../linux_bringup.md начинается с одного-единственного разрешённого действия: прочитать регистр ID по 0x3C и сравнить с ожидаемым. Всё остальное — после того, как появится драйвер и станет понятно, кто владеет железом.
Правило: когда инструмент заменён на «эквивалентный», сверьте синтаксис, а не только наличие.
28.5. Пятый: u-boot.itb, которого не бывает на Zynq-7000
U-Boot умеет упаковываться в разные форматы. Формат .itb (FIT image) — современный контейнер, в который складывают сразу несколько образов и подписи к ним; он используется во флоу ZynqMP (64-битные Zynq). Мы попросили именно его и получили громкое и предельно понятное No rule to make target 'u-boot.itb'.
Гипотеза выглядела обоснованной: xilinx_zynq_virt действительно включает CONFIG_SPL_LOAD_FIT, то есть SPL умеет грузить FIT — раз умеет грузить, наверное, умеет и собирать. Проверка состояла в том, чтобы посмотреть, как этот же SoC собирают в самом Buildroot: есть zynq_microzed_defconfig, штатная конфигурация для платы на том же Zynq-7000, и в ней стоит BR2_TARGET_UBOOT_FORMAT_IMG.
Корневая причина: цель .itb для 32-битных Zynq-7000 просто не определена, она относится к ZynqMP-флоу, а CONFIG_SPL_LOAD_FIT описывает возможность загрузки, а не сборки. Исправление: BR2_TARGET_UBOOT_FORMAT_IMG=y.
Полезно понимать, что именно меняется от этой строки, а что не меняется вовсе. Формат касается только второй ступени — того файла, который SPL загружает в DDR. Первая ступень, boot.bin, производится тем же BR2_TARGET_UBOOT_SPL независимо от формата и остаётся ровно такой же: BootROM про FIT ничего не знает и знать не должен. То есть выбор между u-boot.img и u-boot.itb — это выбор упаковки одного файла на FAT-разделе, а не смена схемы загрузки. Именно поэтому ошибка и выглядит так безобидно: она валит сборку на последнем шаге, когда всё существенное уже собрано.
Отдельно стоит отметить сам метод проверки, потому что он универсален. Вместо того чтобы читать документацию U-Boot про поддержку FIT (которая верна, но отвечает на другой вопрос), мы посмотрели на работающий пример для того же кремния внутри того же Buildroot. Штатные defconfig'и в дереве — это проверенные CI конфигурации, и они отвечают на вопрос «что здесь реально работает», а не «что теоретически возможно».
Правило: когда сомневаетесь в опции для своего SoC, посмотрите, как собран штатный defconfig для платы на том же SoC.
28.6. Шестой: конфигурация, которая принципиально не угадывает
xilinx_zynq_virt — generic-конфигурация U-Boot: она собирается под то описание платы, которое ей назовут, и отказывается угадывать. Мы не назвали, и сборка потребовала явного указания.
Формально это даже не баг, а дизайн, но последствия оказались самыми серьёзными во всей главе 27: именно через DEVICE_TREE U-Boot выбирает ps7_init. Строка, которую мы поначалу воспринимали как «формальность, чтобы сборка не ругалась», на деле определяла, чья DDR-инициализация попадёт в boot.bin.
Сначала было проставлено DEVICE_TREE=zynq-zc702 — то же, что у ядра, и это выглядело последовательно. Сейчас в defconfig стоит BR2_TARGET_UBOOT_CUSTOM_MAKEOPTS="DEVICE_TREE=zynq-rk7020", а сам zynq-rk7020.dts устанавливается в дерево U-Boot тем же пакетом rk7020-uboot-ps7, который кладёт туда ps7_init_gpl.c. Пакет дополнительно правит arch/arm/dts/Makefile, добавляя zynq-rk7020.dtb в список собираемых, — без этого dtc до нашего файла не дойдёт.
Здесь важно уложить в голове неочевидное: device tree в этой системе два, и они разные. Один описывает плату для U-Boot (linux/uboot/zynq-rk7020.dts), второй — для ядра (linux/dts/zynq-rk7020-spi.dts). Они собираются разными деревьями исходников, живут по разным путям и содержат разные вещи: узел SPI в PL нужен только ядру, а U-Boot про фабрику не знает вовсе. Но описывать они обязаны одну и ту же плату, иначе получается ситуация, в которой загрузчик настроил периферию под одно, а ядро ждёт другого.
И третья, самая тонкая часть этой истории — то, что через DEVICE_TREE проходит не только выбор DTS, но и выбор ps7_init. Одна строка makeopts управляет двумя разными вещами: описанием платы для DM-драйверов U-Boot и инициализацией PS в SPL. Это не задокументировано нигде на видном месте; это выясняется чтением Makefile. Именно поэтому в uboot.config ps7_init теперь задан явно, через CONFIG_XILINX_PS_INIT_FILE, а не через побочный эффект имени device tree.
Правило: параметр, который «просто чтобы собралось», стоит один раз проследить до конца — иногда он определяет содержимое загрузчика.
28.7. Седьмой: DTS, переехавший в подкаталог
В ядре Linux 6.12 файлы device tree для ARM разложены по подкаталогам производителей: не arch/arm/boot/dts/zynq-zc702.dts, а arch/arm/boot/dts/xilinx/zynq-zc702.dts. Разложили их сравнительно недавно, поэтому вся накопленная в интернете документация ссылается на старые пути. Симптом громкий: сборка не находит файл. Исправление — префикс xilinx/, и в нашем zynq-rk7020-spi.dts он стоит в первой же строке включения:
#include "xilinx/zynq-zc702.dts"
#include "zynq-rk7020-fpga-spi.dtsi"А вот тонкость, ради которой эту мелочь стоило упоминать. В linux/uboot/zynq-rk7020.dts — файле для U-Boot, а не для ядра — стоит #include "zynq-zc702.dts" без префикса. Не потому что мы забыли, а потому что в дереве U-Boot каталог arch/arm/dts/ плоский. Два почти одинаковых файла, два разных правила, и оба правильные.
Вторая строка включения тоже не бесплатна: Buildroot копирует каждый файл из BR2_LINUX_KERNEL_CUSTOM_DTS_PATH в arch/arm/boot/dts/ и компилирует каждый найденный там .dts. Поэтому в списке путей указаны оба файла — и .dts, и .dtsi. Фрагмент сам по себе в DTB не превращается, он едет следом только для того, чтобы включение из .dts разрешилось по простому имени, без всякого пути. Забыть его в списке — получить ошибку компиляции «файл не найден», и это тот редкий приятный случай, когда ошибка громкая.
Обратите внимание, как две соседние мелочи дают противоположные требования к одной и той же строке #include. Базовый DTS ядра требует префикс xilinx/, потому что дерево ядра разложено по вендорам. Наш фрагмент требует отсутствия пути, потому что он физически лежит рядом, скопированный Buildroot'ом. Обе строки стоят в одном файле друг под другом и выглядят непоследовательно ровно до того момента, пока не знаешь механику копирования.
Правило: путь включения зависит от дерева, в которое вы включаете, а не от имени файла.
28.8. Восьмой: OpenSSL, до которого не дотянуться
Симптом: сборка падает на загрузке исходников OpenSSL после трёх попыток. Хост github.com, откуда Buildroot его тянет, из используемой среды недоступен.
Небольшое пояснение, откуда в сборке встраиваемого Linux вообще берётся криптографическая библиотека. Buildroot различает пакеты target (едут на плату) и host (нужны только машине, которая собирает). host-libopenssl относится ко вторым: его тянут инструменты, умеющие подписывать образы. Мы не просили ни того, ни другого явно — OpenSSL приехал транзитивно, как зависимость чужих настроек, и его отсутствие в сети остановило всё.
Первая реакция на такое всегда одна: «проблема сети, надо настроить прокси или зеркало». Реакция правильная по форме и неправильная по существу, потому что стоит задать другой вопрос: а зачем нам вообще OpenSSL?
Проверка — кто в графе зависимостей его требует. Нашлось два потребителя, и оба оказались лишними. Первый: xilinx_zynq_virt включает CONFIG_FIT_SIGNATURE — проверку криптографических подписей FIT-образов. Это функция verified boot, а у нас её нет по построению: битстрим, ядро и rootfs лежат на одной незашифрованной SD-карте и ничем не подписаны. Второй: изначально была прописана BR2_LINUX_KERNEL_NEEDS_HOST_OPENSSL, скопированная из zynq_microzed_defconfig. Там она нужна по-настоящему, потому что конфигурация ядра Xilinx включает функции, требующие OpenSSL. Наш multi_v7 не включает ни подпись модулей, ни доверенную связку ключей — это проверено прямым grep по defconfig.
Исправление: снять FIT_SIGNATURE фрагментом uboot.config и убрать NEEDS_HOST_OPENSSL. CONFIG_SPL_LOAD_FIT при этом остаётся включённым и работает: он отвечает за загрузку FIT, а FIT_SIGNATURE — только за проверку подписи. Оба файла содержат инструкцию, как вернуть verified boot обратно.
Заодно закрывается вторая причина держать подпись модулей выключенной, уже не связанная с сетью: включённая подпись отвергла бы наш неподписанный out-of-tree модуль — тот самый, ради которого вся система и существует.
Правило: недоступную зависимость сначала попробуйте убрать, а не достать. Часто она тянется за функцией, которая вам не нужна.
28.9. Девятый: красивый образ без узла SPI внутри
Самая опасная ошибка главы и лучшая иллюстрация того, что значит «молча».
Симптом отсутствовал полностью. Сборка проходит, образ пишется на карту, плата грузится, модуль загружается — lsmod его показывает. И ничего не работает: в /sys/class/spi_master/ пусто, /dev/spidev* нет, драйвер живёт в памяти и не привязан ни к какому устройству. В dmesg ни одной ошибки, потому что с точки зрения ядра ничего плохого не произошло: модуль загружен, устройств для него не нашлось, ситуация законная.
Чтобы понять почему, нужно знать, как драйвер находит железо (подробнее — в главе 29). Ядро не сканирует адресное пространство: оно читает device tree — двоичное описание того, что есть на плате, — и для каждого узла ищет драйвер с совпадающей строкой compatible. Нет узла в DTB — нет устройства — привязываться не к чему.
Сначала мы указали ядру in-tree имя device tree, то есть штатный zynq-zc702 из дерева ядра. Гипотеза была: базовое описание платы возьмём готовое, а свой узел SPI добавим фрагментом. Разумная мысль с одним изъяном: фрагмент .dtsi сам по себе не собирается. Он не дерево, его нечем компилировать; чтобы его содержимое попало в DTB, его должен включить какой-то компилируемый .dts. Мы же велели ядру собирать стоковый zynq-zc702.dts, и он честно собирался, ничего не зная о нашем фрагменте.
Улика: dtc -I dtb -O dts по собранному DTB и grep по строке tzt,zynq-fpga-spi не находят ничего. Полминуты работы — и всё очевидно; проблема в том, что до этой команды надо догадаться, а естественнее в такой ситуации подозревать драйвер.
Исправление: свой zynq-rk7020-spi.dts, включающий базу и фрагмент, и указание Buildroot собирать именно его через BR2_LINUX_KERNEL_CUSTOM_DTS_PATH.
Но интереснее не исправление, а защита от повтора. В post-image.sh добавлена проверка, которая отказывается собирать образ, если в system.dtb нет нужного узла:
post-image: system.dtb contains no tzt,zynq-fpga-spi node
post-image: the driver would load and never bind; refusing to build the imageА при успехе печатает post-image: verified SPI node present in system.dtb. Там же вторая мера предосторожности того же класса: DTB для образа выбирается по явному имени zynq-rk7020-spi.dtb, а не как «первый найденный .dtb». В каталоге может остаться блоб от предыдущей конфигурации, порядок обхода не определён, и «случайно правильный» выбор однажды станет неправильным.
Правило: молчаливую ошибку надо превращать в громкую — добавьте в сборку проверку, которая падает вместо вас.
28.10. Дополнение: &amba_pl, шина-призрак
Ещё один дефект того же семейства, найденный в самом device tree. Наш фрагмент подключал узлы к шине &amba_pl — это имя постоянно встречается в файлах, сгенерированных инструментами Xilinx, и выглядит совершенно законно: «шина периферии в PL», логичнее не придумаешь. Но в mainline-ядре такого узла нет; это артефакт генератора, живущий в ядре Xilinx и отсутствующий в ванильном 6.12. Правильная шина — &amba, объявленная в zynq-7000.dtsi как simple-bus.
Симптом при этом относится к худшей категории. Ссылка на несуществующую метку — это ошибка компиляции, то есть громкая, и в нашем случае так и вышло. Но родственный вариант той же ошибки — когда узел объявлен под неправильным, но существующим родителем — компилируется прекрасно и даёт DTB, в котором узел есть, а драйвер к нему всё равно не привязывается, потому что родитель не simple-bus и ядро не создаёт из его детей platform-устройства. Свойство compatible = "simple-bus" — это ровно обещание «мои дети живут по прямым адресам, создавай для них устройства». Отсюда практический совет: убедившись, что узел в DTB есть, посмотрите заодно, под кем он лежит.
Замечание для читателя, который будет сверяться с ../linux_bringup.md: там в командах проверки фигурирует путь /proc/device-tree/amba_pl/spi@43c00000/. Оба значения устарели вместе: адрес изменился на 0x40000000 (история путаницы — в части VI), шина — на &amba. Точное имя каталога в /proc/device-tree зависит от базового DTS, поэтому на живой плате его стоит посмотреть через ls /proc/device-tree/, а не набирать по памяти.
Правило: имена узлов из vendor-дерева не переносятся в mainline автоматически — сверяйтесь с .dtsi того ядра, которое собираете.
28.11. Десятая история: сломанный rsync в песочнице
Эта ошибка не в Buildroot и не в нашей конфигурации, но она стоила времени и иллюстрирует отдельный жанр — когда виновата среда.
Симптом: сборка обоих проектных пакетов зависает намертво. Не падает — именно зависает. В используемой песочнице rsync не завершается никогда, даже при копировании каталога из трёх файлов, даже под timeout, и процессы не снимаются сигналом.
Связь с Buildroot неочевидна, пока не знаешь, как устроены локальные пакеты. Наши объявлены с SITE_METHOD = local, то есть «исходники лежат вот в этом каталоге на диске», и Buildroot копирует такие исходники в дерево сборки через rsync. Дальше зависание.
Гипотеза «пакеты написаны неправильно» проверялась первой и была отвергнута: SITE_METHOD = local — идиоматичный способ собирать из локального каталога, и в нормальном окружении он работает. Искажать пакеты в обход сломанного инструмента было бы худшим решением: обходной путь навсегда остался бы в поставляемом дереве.
Решение: на время сборки в начало PATH подставлялся шим — скрипт с именем rsync, реализующий нужный Buildroot вызов через cp -a. Он лежит в каталоге сборки (build-buildroot/rsync-shim/rsync), исключённом из git, и в поставляемое BR2_EXTERNAL-дерево попасть не может. Если ваша среда не страдает этим дефектом, шим не нужен, и сборка запускается командами из раздела 28.12 как есть.
Почему эта история вообще попала в статью, хотя ни Buildroot, ни наш код в ней не виноваты. Потому что диагностический путь здесь ровно тот же, что и в настоящих ошибках, и потому что искушение «починить» не то место здесь максимально: зависание проявляется на нашем пакете, значит виноват наш пакет — так рассуждать проще всего. Правильный ход — отделить «что зависло» от «где живёт причина», ровно как требует лестница уровней из 26.7: зависает rsync, а не Buildroot и не пакет. И второй урок — про место, куда кладут обходное решение. Костыль, встроенный в пакет, поедет ко всем пользователям и переживёт причину, по которой был написан; костыль в PATH конкретного прогона исчезает вместе с этим прогоном.
Правило: обход дефекта среды должен жить в среде, а не в исходниках проекта.
28.12. Как это собирается сейчас
Реальная последовательность, приводящая к образу на карте:
git clone --branch 2026.02.3 https://gitlab.com/buildroot.org/buildroot.git
cd buildroot
make BR2_EXTERNAL=/path/to/spi_xilinx/buildroot-external \
tzt_rk_zynq7020_f_v11_defconfig
make
sudo dd if=output/images/sdcard.img of=/dev/sdX bs=4M conv=fsync status=progress
syncПолная пересборка после правки одного файла нужна редко: есть make fpga-spi-driver-rebuild для драйвера, make fpga-spi-test-rebuild для утилиты, make linux-rebuild и make linux-reconfigure для ядра; make clean (удаляет output/, кроме dl/) — при смене архитектуры или toolchain, make distclean — при полном сбросе. Но после стабилизации чистая сборка с нуля обязательна: она и есть проверка воспроизводимости, и именно она нашла все девять ошибок выше.
Глава 29. Device Tree: compatible, spidev, reserved-memory, клоки
29.1. Зачем ядру отдельное описание железа
На обычном компьютере ядро Linux узнаёт о железе, опрашивая шины: PCI умеет сказать «я такое-то устройство по такому-то адресу». На встраиваемой плате шин с самоописанием нет: регистр контроллера SPI просто лежит по какому-то физическому адресу, и никто, кроме разработчика платы, не знает, по какому именно и какое прерывание к нему подведено. Раньше это зашивали прямо в ядро — на каждую плату свой файл на C, — и кончилось тысячами почти одинаковых файлов. На ARM перешли на Device Tree: отдельное декларативное описание железа, которое компилируется в двоичный блоб (DTB, Device Tree Blob) и передаётся ядру загрузчиком. Исходный текст — .dts, включаемые фрагменты — .dtsi, компилятор — dtc.
Аналогия: DTB — это план-схема здания, которую вручают пожарным. Если на схеме нет комнаты — пожарные в неё не пойдут, даже если она есть в реальности. Ровно это произошло в разделе 28.9.
Устроен DTS как дерево узлов со свойствами. Ключевое свойство — compatible: строка-идентификатор вроде tzt,zynq-fpga-spi-1.0. Ядро перебирает узлы, сравнивает их compatible с таблицами of_device_id зарегистрированных драйверов и при совпадении вызывает probe() этого драйвера. Никакой магии: совпадение строк, ничего больше. Опечатка в одной букве — и устройства для драйвера не существует.
29.2. База от zc702 — честный костыль
Наш zynq-rk7020-spi.dts начинается с включения xilinx/zynq-zc702.dts — описания отладочной платы Xilinx. Это не заявление, что наша плата и есть ZC702, а осознанный костыль, и в шапке файла он назван своим именем.
Логика такая. Нам нужно рабочее описание PS для Zynq-7020: DDR, UART, SD, Ethernet, контроллер прерываний. Написать его с нуля из схемы — работа на несколько дней, а проверяем мы в этой серии не PS, а свою логику в PL. Стоковый zc702 даёт достаточно, чтобы загрузиться, а всё, что на нашей плате отличается, чинится поверх: консоль на UART0 вместо UART1, card detect на другом MIO, другой Ethernet PHY, снятые pinctrl, удалённая таблица OPP.
Цена костыля — вся глава 31. Каждое «поверх чинится» — это отдельный вечер отладки, потому что чужое описание не отсутствует, а активно врёт: оно перепрограммирует то, что уже было настроено правильно. Для продакшена нужен DTS, выведенный из штатного проекта производителя; секцию PL из нашего файла можно перенести туда без изменений.
29.3. Узел SPI и его соседи
Всё, что относится к нашей фабрике, вынесено в отдельный фрагмент zynq-rk7020-fpga-spi.dtsi. Вот что в нём есть и зачем.
Узел / свойство | Значение | Что оно включает |
|---|---|---|
|
| Совпадает с |
|
| Адрес из Address Editor (часть VI) |
|
|
|
|
| Параметр синтеза |
|
| Глубокий FIFO DMA-сборки; по умолчанию было бы 8 |
|
| Даёт |
|
| Буфер под DMA, |
|
| Запрет гасить FCLK0 |
|
| Страховка того же назначения |
|
| AXI GPIO дисплея, до проверки FCLK0 |
|
| AXI DMA MM2S, включается в ч. IX |
Про spidev стоит сказать отдельно, потому что имя rohm,dh2228fv выглядит странно. spidev — универсальный драйвер, дающий userspace прямой доступ к SPI-шине через /dev/spidevX.Y и ioctl. Ядро намеренно не позволяет привязать его к произвольному узлу — иначе любой объявил бы compatible = "spidev" и обошёл нормальную модель драйверов. Поэтому разрешён список конкретных устройств, и rohm,dh2228fv — общепринятый выбор для «просто дайте мне шину». Через этот узел работает spi-test.
Два узла — GPIO дисплея и DMA — стоят disabled, и это не забывчивость. Встроенные драйверы gpio-xilinx и xilinx-dma пробуются очень рано, сразу после pinctrl; если в этот момент с фабрикой что-то не так, обращение по MMIO вешает процессор и консоль замолкает без сообщений. Включаются они после того, как подтверждено, что FCLK0 живёт и битстрим загружен.
29.4. История: ядро зависает сразу после pinctrl
Симптом: ядро стартует, печатает обычные строки инициализации, доходит до сообщения zynq pinctrl initialized — и замолкает. Не паника, не oops, не стек вызовов, просто тишина в консоли. Плата не перезагружается, watchdog не срабатывает.
Первая гипотеза была очевидной и неправильной: «опять ps7_init, опять DDR». После главы 27 подозрение падает туда автоматически — типичная ловушка мышления, когда только что найденная ошибка кажется причиной всего последующего. Вторая гипотеза, чуть лучше: «сломалась консоль». Она частично верна в соседнем случае (см. 31.4), но здесь не объясняла, почему система не отвечает и по сети.
Проверка шла от того, что происходит в этот момент времени. Сразу после pinctrl пробуются platform-драйверы, среди них — встроенные axi-gpio и axi-dma, у которых в нашем DT есть узлы; оба при probe() читают регистр своего устройства. Стоит поставить этим узлам status = "disabled", и зависание исчезает. Значит, место смерти — MMIO по адресам PL, а не DDR.
Дальше вопрос сузился: почему обращение к PL вешает процессор, если битстрим загружен? Ответ — в тактовой частоте. Шина AXI в фабрике тактуется от FCLK_CLK0, клока, который PS отдаёт в PL. Если FCLK0 стоит, AXI-транзакция не может завершиться: адрес выставлен, а отвечать нечем, и процессор ждёт ответа шины вечно.
Кто мог остановить FCLK0? Улика — в том, как Linux относится к клокам вообще. Ядро ведёт учёт потребителей, и если клок никто не запросил через clk_prepare_enable(), на поздней стадии загрузки clk_disable_unused() его гасит: нормальная энергосберегающая политика. А в стоковом zynq-7000.dtsi стоит fclk-enable = <0>, и драйвер zynq-clkc трактует регистр FPGA*_THR_CTRL как вентиль с семантикой «CLK_GATE_SET_TO_DISABLE». Итог: U-Boot запрограммировал FCLK0, Linux увидел, что клок никому не нужен, и выключил его.
Хуже того, «правильное» решение делает только хуже. Естественная мысль — добавить PL-узлам clocks = <&clkc 15> (индекс 15 — это fclk0), чтобы у клока появился потребитель. Но тогда clk_prepare_enable() проходит через тот же вентиль и может остановить уже работающий FCLK0 на время переподготовки: попытка честно объявить зависимость приводит к тому же зависанию, только раньше.

Корневая причина: клок PL программируется в U-Boot, а Linux считает себя вправе управлять им по своим правилам, не зная, что от него зависит жизнеспособность целой шины.
Исправление тройное, и все три части нужны. fclk-enable = <0x1> в узле &clkc — прямое указание держать FCLK0 включённым. clk_ignore_unused в bootargs — глобальная страховка на случай, если первого не хватит. И не объявлять clocks у PL-узлов, то есть намеренно не делать то, что кажется правильным. Плюс boot.cmd ещё раз прописывает делители FCLK0 и снимает сброс с фабрики.
Правило: на Zynq клок PL, запрограммированный загрузчиком, — священная корова; device tree не должен «переподготавливать» его вслепую.
29.5. Почему reserved-memory, и чем за это платим
reserved-memory — это способ сказать ядру: «вот этот кусок физической памяти не трогай». В нашем случае:
spi_dma_buf: memory@1f000000 {
compatible = "shared-dma-pool";
reg = <0x1f000000 0x00100000>;
no-map;
};Зачем. Аппаратный DMA, который в части IX будет качать кадры дисплея из памяти в SPI, работает с физическими адресами и требует непрерывного буфера: он не умеет ходить по таблицам страниц. Обычная память процесса физически разбросана по страницам, а её адреса виртуальные. Классический способ решить это в Linux — dma_alloc_coherent или CMA, но оба варианта требуют драйвера ядра, управляющего DMA, а наш путь для длинных кадров — userspace-приложение spi-clock через /dev/mem (обоснование в 30.2). Ему нужен фиксированный физический адрес, известный заранее. Флаг no-map означает, что ядро не создаёт для региона обычного отображения: память не считается системной и не кэшируется как обычная — то есть не будет ситуации, когда процессор записал данные в кэш, а DMA прочитал из DDR старое содержимое.
Цена. Мебибайт DDR вычеркнут из системы навсегда, работает дисплей или нет. Адрес зашит в трёх местах — в DTS, в приложении и в документации — и обязан совпадать; расхождение даст не ошибку, а тихо неправильные данные. Доступ через некэшируемое отображение медленнее обычной памяти, что при заполнении кадра заметно. И решение не масштабируется: два приложения, желающие DMA-буфер, подерутся за один регион, и разнимать их некому. Мы принимаем всё это ради того, чтобы путь дисплея оставался объяснимым и не требовал писать второй драйвер ядра.
29.6. Проверка, что DTB действительно тот
Проверки, которые дёшево делать и дорого не делать:
# На хосте, сразу после сборки: узел вообще попал в блоб?
dtc -I dtb -O dts output/images/system.dtb | grep -A12 "tzt,zynq-fpga-spi"
# На плате: какие шины и узлы видит ядро
ls /proc/device-tree/
# На плате: драйвер привязался к устройству?
ls -l /sys/bus/platform/drivers/spi-zynq-fpga/
ls /sys/class/spi_master/
# На плате: прерывание зарегистрировано и не растёт в покое?
grep spi-zynq /proc/interrupts; sleep 10; grep spi-zynq /proc/interruptsПоследняя пара команд заслуживает пояснения: число прерываний, растущее в покое, означает interrupt storm — линия осталась активной, потому что нарушен порядок «сначала вычитать RX, потом квитировать» (ограничение F-2 из части VI). Драйвер устроен так, чтобы этого не происходило, но проверить дешевле, чем поверить.
Глава 30. Драйвер spi-zynq-fpga и ID 0x5350
30.1. Что такое драйвер и почему он не создаёт /dev/custom_spi
Драйвер — это код внутри ядра, который знает, как разговаривать с конкретным железом, и даёт остальной системе стандартный интерфейс. Наш собирается загружаемым модулем — файл .ko, который можно вставить в работающее ядро и вынуть обратно.
Первое проектное решение — какого класса драйвер писать. Соблазн велик: сделать символьное устройство /dev/custom_spi со своими ioctl и писать в него из своей программы. Это работает, это просто и это почти всегда неправильно. Наш IP — полноценный SPI-мастер для произвольных ведомых устройств, а в Linux для таких есть готовая подсистема. Поэтому выбран «Вариант A»: драйвер регистрирует struct spi_controller, а userspace ходит через spidev или через обычные драйверы SPI-периферии. Что мы получаем: сотни готовых драйверов датчиков, дисплеев и флэшек начинают работать с нашим контроллером без единой строки кода, а частота, режим и разрядность слова конфигурируются стандартно. Что теряем: свободу выразить то, чего в модели Linux SPI нет.
И здесь железо диктует условия. Три ограничения из ../hw_sw_contract.md прямо определили форму драйвера. F-1: сигнал выбора кристалла (CS) намертво связан с опустошением FIFO, регистра «держать CS» не существует. Значит, честный set_cs() невозможен, и сообщение с нативным CS обязано укладываться в один burst; проверка вынесена в prepare_message(), который видит сообщение целиком и отказывает с внятной диагностикой вместо того, чтобы сформировать неверную осциллограмму. F-2: единственный способ квитировать прерывание заодно флашит оба FIFO, поэтому обработчик снимает уровневую линию маскированием, а настоящее квитирование происходит после вычитывания RX. F-3: прерывания по порогу TX нет, поэтому дозаливка FIFO на ходу не реализуется вовсе. Подробный разбор — в части VI; здесь важно, что архитектура драйвера выведена из RTL, а не выбрана из вкуса.
30.2. Почему в системе одновременно kernel-драйвер и путь через /dev/mem
Второе решение выглядит как противоречие. В системе есть и полноценный драйвер ядра, и userspace-приложение spi-clock, которое лезет к тем же регистрам через /dev/mem. Разве не сказано, что у железа должен быть один владелец?
Сказано, и это соблюдается — но во времени, а не одновременно. Причина двойного пути в F-1 и F-3: кадр дисплея ST7789 — около 110 КиБ, которые обязаны уйти под одним непрерывным CS, а модель Linux SPI на нашем железе так не умеет — сообщение нарезается на burst'ы, между которыми CS падает. Для этого в v2 IP появились регистры STREAM и путь через AXI DMA, и управляет ими приложение, которому нужен прямой доступ к MMIO.
Договорённость такая: перед работой spi-clock отвязывает kernel-драйвер от устройства через sysfs, работает и привязывает обратно.
echo 40000000.spi > /sys/bus/platform/drivers/spi-zynq-fpga/unbind
./spi-clock --dma
echo 40000000.spi > /sys/bus/platform/drivers/spi-zynq-fpga/bindЦена решения серьёзная, и её надо называть вслух. В ядре включён CONFIG_DEVMEM и выключен CONFIG_STRICT_DEVMEM: любой процесс с правами root может читать и писать любую физическую память — на лабораторной плате приемлемо, в продукте нет. Дисциплина unbind/bind держится на человеке: забыли отвязать — два владельца одного железа и невоспроизводимые ошибки. И часть логики (STREAM, DMA) живёт вне ядра и не переиспользуется другими драйверами. Альтернатива — полноценный DMA-путь внутри драйвера — честнее, но это отдельный проект. UIO намеренно не включён: раздел 35 разрешает его только как диагностику bring-up.
30.3. Магический идентификатор и почему его читают в probe()
По смещению 0x3C в IP лежит регистр ID_VERSION (полная карта регистров — в ../register_map.md). Старшие 16 бит — константа 0x5350, то есть ASCII "SP"; младшие — версия: 0x53500100 означает v1.0 (классический PIO), 0x53500200 — v2.0 (STREAM и DMA).
Первое, что делает probe() после отображения регистров и получения клока, — читает этот регистр (spi-zynq-fpga.c):
if (ZFSPI_ID_GET_ID(id) != ZFSPI_ID_MAGIC) {
dev_err(zs->dev, "no compatible IP found: ID register reads 0x%08x, "
"expected ID field 0x%04x\n", id, ZFSPI_ID_MAGIC);
if (!id)
dev_err(zs->dev, "a zero ID usually means the loaded bitstream "
"predates the identification register, or the reg "
"address in the device tree is wrong\n");
return -ENODEV;
}Зачем это нужно, если device tree уже сказал compatible? Затем, что device tree — обещание, а битстрим — реальность, и меняются они независимо: оба лежат на FAT-разделе, но собираются разными инструментами в разное время. Ситуация «новый DTB, старый битстрим» возникает совершенно естественно.
Альтернатива, которую мы отвергли: сверять версии по файлу. Такой файл есть — post-build.sh пишет /etc/zynq-spi-version с полями ip_id_expected=0x5350, ip_version_expected=1.0, spi_max_sclk_hz=8333333, spi_min_clk_div=2 и git-коммитом сборки. Он отвечает на вопрос «из каких исходников собран образ», но описывает rootfs, а битстрим лежит на другом разделе и мог быть заменён отдельно. Регистр описывает то, что реально загружено в кремний прямо сейчас, поэтому проверка аппаратная, а файл справочный.
Драйвер поддерживает major-версии с 1 по 2: несовпадение major — отказ, различие minor — предупреждение и продолжение. Цена строгости: на старом битстриме, собранном до появления регистра ID, драйвер не загрузится (там читается ноль). Это не побочный эффект, а требование раздела 80 — драйвер не должен успешно привязываться к несовместимому железу.
Первая ручная проверка того же самого, ещё до всякого драйвера:
devmem 0x4000003c 32 # ожидание 0x53500100 или 0x5350020030.4. История: модуль есть, устройства нет
Это самый частый отказ во всём проекте, и у него несколько разных причин — но выглядят они одинаково.
Симптом: insmod или modprobe проходит без ошибок, модуль виден в /proc/modules, а в /sys/class/spi_master/ пусто и /dev/spidev* нет. Иногда в dmesg есть строка от драйвера, иногда нет вообще ничего.
Первая гипотеза почти всегда «сломан драйвер», и она почти всегда неверна. Правильный первый вопрос другой: дошло ли дело до probe() вообще? Ответ виден по наличию сообщений драйвера в dmesg. Нет сообщений — probe() не вызывался, устройство не найдено, проблема на уровне device tree. Есть сообщения — probe() начался и упал, и он сам говорит где.
Разбор по сообщениям, каждое из которых указывает на свой уровень лестницы из 26.7. Тишина при загруженном модуле — уровень DT: compatible не совпадает (у драйвера tzt,zynq-fpga-spi-1.0) или у узла стоит status = "disabled". Строка no compatible IP found: ID register reads 0x00000000 — уровень FPGA или DT: битстрим не загружен, загружен старый или reg указывает не туда; драйвер сам печатает вторую строку-подсказку про обе причины. incompatible IP major version — битстрим и драйвер из разных ревизий. failed to get AXI clock или AXI clock rate is zero — уровень DT: не разрешается узел клока либо у fixed-clock не задана частота. Нет /dev/spidev* при живом контроллере — нет дочернего узла или выключен CONFIG_SPI_SPIDEV.

Улика, быстрее всего разделяющая случаи, — та самая ручная проверка devmem 0x4000003c 32. Вернулось 0x53500100 или 0x53500200 — AXI работает, адрес верен, битстрим тот, значит проблема выше, в DT или драйвере. Ноль — виноват битстрим или адрес. Чтение вешает систему — адреса нет в карте вообще, и надо возвращаться в Address Editor.
Корневая причина у всей группы одна по структуре: device tree, битстрим и драйвер — три независимых артефакта, обязанных описывать одно и то же железо, но ничего друг о друге не знающих. Отсюда три независимые страховки: проверка узла в post-image.sh (28.9), проверка ID в probe() (30.3) и файл метаданных сборки.
Правило: при «драйвер не работает» сначала выясните, вызывался ли probe(); это делит пространство поиска пополам одним взглядом в dmesg.
Как выглядит успех, для сравнения (пример из ../linux_bringup.md):
spi-zynq-fpga 43c00000.spi: SPI IP v1.0
spi-zynq-fpga 43c00000.spi: registered: AXI clock 50000000 Hz,
SCLK 382..8333333 Hz, 4 CS, 8-word FIFO, interrupt drivenДве оговорки к этому логу, чтобы вы не искали несоответствие. Адрес в имени устройства (43c00000.spi) относится к более ранней карте адресов; текущая — 0x40000000. И «8-word FIFO» соответствует значению по умолчанию, тогда как нынешний .dtsi задаёт fifo-depth = <1024> для DMA-сборки. Числа в строке берутся из железа и DT, поэтому меняются вместе с ними.
30.5. История: modalias есть, а автозагрузки нет
Симптом мягкий: всё работает, но только после ручного modprobe. После перезагрузки контроллера снова нет. Такие дефекты особенно легко не заметить на bring-up: пока вы сидите в консоли и всё равно набираете команды руками, разница между «загрузилось само» и «загрузил я» стирается. Обнаруживается она в тот день, когда плата должна подняться без вас.
Логичная гипотеза: «модуль собран неправильно, у него нет modalias, поэтому ядро его не находит». Modalias — строка, которую ядро выводит из таблицы of_device_id драйвера; по ней система понимает, какой модуль подходит к появившемуся устройству. Проверка простая — посмотреть метаданные модуля:
ELF 32-bit LSB relocatable, ARM, EABI5
vermagic: 6.12.102 SMP mod_unload ARMv7 p2v8
alias: of:N*T*Ctzt,zynq-fpga-spi-1.0Улика опровергла гипотезу: alias есть и в точности соответствует compatible из device tree. Сопоставление сработало бы — не хватает того, кто его запустит.
Корневая причина оказалась не в модуле, а в системе. Автозагрузка по modalias работает не сама по себе: событие о появлении устройства получает менеджер устройств (udev или mdev), смотрит modalias и вызывает modprobe. В минимальном rootfs на BusyBox правила, которое делало бы это для platform-устройств, просто нет, а сами platform-устройства из device tree создаются очень рано — задолго до запуска менеджера устройств.
Исправление честное и скучное: init-скрипт S60spi, который делает modprobe spi-zynq-fpga при загрузке, а потом печатает, что получилось: какие контроллеры появились в /sys/class/spi_master/ и какие узлы /dev/spidev*. Так по логу загрузки сразу видно, привязался драйвер или нет. Альтернатива — собрать драйвер прямо в ядро; мы не стали, потому что модуль позволяет цикл «поправил — перезагрузил» без пересборки ядра.
Показательный побочный сюжет в этом же скрипте: имя модуля в /proc/modules пишется через подчёркивания (spi_zynq_fpga), а файл — через дефисы. Естественное решение, подстановка ${var//-/_}, работает в bash и не работает в ash BusyBox и в dash: обе отвечают Bad substitution. Поэтому оба написания заданы буквально, с комментарием почему. Правило: скрипт для BusyBox — не «bash попроще», а другой язык с меньшим набором конструкций.
30.6. Что доказано, а что нет
Ещё одна привычка, которую стоит перенять: разделять «собралось» и «работает». В ../driver.md это зафиксировано явно. Компиляция под x86_64 против ядер 6.14, 6.17 и 7.0 — успех, ноль предупреждений с -Wall -Wextra. Кросс-сборка под ARM против 6.12.102 компилятором GCC 14.3.0 из собранного toolchain — то же самое. checkpatch.pl --strict — ноль errors, ноль warnings, ноль checks. Модуль установлен в rootfs как /lib/modules/6.12.102/updates/spi-zynq-fpga.ko. А вот загрузка модуля, probe() и передача данных на момент записи отчёта были не проверены, и в чеклисте из двадцати пунктов так и написано.
Сборка сразу против трёх версий ядра — не педантизм: она нашла настоящий дефект. Макрос FIELD_PREP требует заголовка <linux/bitfield.h>, который в 6.14 и 6.17 подтягивался транзитивно через другие включения, а в 7.0 — уже нет. Одной версии для проверки переносимости недостаточно. Компиляция под x86_64 законна потому, что драйвер не использует ни одного Zynq-специфичного API (в Kconfig стоит depends on ARCH_ZYNQ || COMPILE_TEST); она проверяет работу с API ядра и ничего не говорит о работе на железе — успешная компиляция намеренно не считается подтверждением функциональности.
30.7. Быстрый цикл разработки
Рабочий цикл, ради которого стоит один раз настроить сеть на плате: полная пересборка Buildroot после правки одной строки в драйвере не нужна.
# 1. Проверка работы с API ядра за секунды, вообще без ARM-цели:
make -C spi_xilinx/linux/driver host-check
# 2. Кросс-сборка только модуля:
make fpga-spi-driver-rebuild
# 3. На плату по сети (быстрее, чем перезаписывать карту):
scp output/target/lib/modules/*/extra/spi-zynq-fpga.ko root@board:/tmp/
# 4. На плате:
rmmod spi-zynq-fpga; insmod /tmp/spi-zynq-fpga.ko; dmesg | tail
spi-smoke-testШаг 1 ловит большинство ошибок работы с API ядра и стоит секунды. Шаг 4 опирается на то, что remove() намеренно приводит IP в состояние покоя: без этого перезагруженный драйвер нашёл бы движок включённым и, возможно, посреди передачи. После стабилизации обязательна чистая сборка Buildroot с нуля — она и есть проверка воспроизводимости.
Глава 31. Ethernet-детектив: PHY@7 vs RTL8211F@1, HSTL vs LVCMOS18
Это лучшая история всего проекта, потому что она про самое неприятное состояние железа — «почти работает». Не «не работает»: то было бы честно и быстро. Именно почти.
31.1. Из чего состоит Ethernet на плате
Контроллер Ethernet внутри Zynq называется GEM (Gigabit Ethernet MAC), в device tree — узел gem0. Он умеет формировать кадры, но не умеет работать с медью напрямую. Между ним и разъёмом RJ-45 стоит отдельная микросхема — PHY (Physical Layer): она превращает цифровые данные в сигналы в кабеле и обратно, договаривается с другой стороной о скорости (автосогласование) и следит за наличием линка. На нашей плате это RTL8211F-CG, на схеме U9.
Между MAC и PHY идут две независимые группы проводов, и это ключ ко всей истории. Первая — RGMII: собственно данные, четыре бита в каждую сторону плюс тактовые сигналы, быстрая шина. Вторая — MDIO: двухпроводная медленная служебная шина, по которой MAC читает и пишет регистры PHY. По MDIO узнают, поднялся ли линк и на какой скорости; по RGMII ходят пакеты. Сломайте одну, оставив другую, — получите ровно то, что получили мы. У PHY на шине MDIO есть адрес — число от 0 до 31, задаваемое подтяжками на плате; в device tree он записан как reg в дочернем узле.
31.2. Первая половина: PHY по адресу 7, которого нет
Симптом появился ещё в U-Boot, до всякого Linux:
Could not get PHY for eth0Стоковый zynq-zc702 описывает ethernet-phy@7 — и это правда для ZC702, где стоит PHY Marvell по адресу 7. Мы унаследовали описание вместе со всем остальным. Первая гипотеза — «плохой контакт, не подключён кабель, PHY не вышел из сброса»; все три правдоподобны, все три проверяются шевелением железа и все три оказались ни при чём.
Что означает это сообщение механически, полезно понимать. MAC при старте пробует прочитать по MDIO пару стандартных регистров PHY с того адреса, который ему назвали. Шина MDIO — открытый коллектор с подтяжкой: если по запрошенному адресу никто не отвечает, линия остаётся в единице, и мастер читает 0xffff. Такой ответ не отличим от «устройства нет», поэтому загрузчик честно говорит, что PHY не найден, и не может сказать почему — адрес неверный, микросхема в сбросе или её вообще нет на плате.
Улика нашлась в схеме платы: U9 — это RTL8211F-CG, и на его выводах конфигурации записано PHY1 ADDR 001, то есть MDIO-адрес 1, а не 7. MAC всё это время честно опрашивал адрес 7 и получал тишину. Стоит подчеркнуть, насколько дешёвой оказалась проверка по сравнению с гипотезами: посмотреть две строки в схеме платы быстрее, чем один раз переобжать кабель.
Корневая причина: адрес PHY взят из описания чужой платы. Исправление в обоих деревьях (U-Boot и Linux) одинаковое: удалить узел ethernet-phy@7, добавить ethernet-phy@1 с reg = <1> и указать phy-mode = "rgmii-id" — такой режим стоит в заводском pcw.dtsi производителя. Суффикс -id означает, что задержку между данными и тактовым сигналом вносит сам PHY; это свойство разводки платы, а не вкусовое предпочтение.
В дереве U-Boot пришлось снять ещё одно свойство, и это отдельная мина. zc702 объявляет сброс PHY через MIO11 (phy-reset-gpio), а на нашей плате MIO11 — это RX консольного UART0: попытка сбросить PHY дёргала бы линию приёма консоли. Свойство удалено, сброс PHY оставлен на POR и ps7_init.
Правило: PHY-адрес читается по схеме платы, а не наследуется вместе с базовым DTS.
31.3. Вторая половина: Link Up, а ping не проходит
Адрес PHY исправлен. Плата загружается, ethtool показывает Link detected: yes, светодиоды на разъёме мигают, скорость согласована. И ничего не работает: DHCP не получает адрес, ping не проходит, а счётчики ifconfig наливаются RX errors и frame errors.
Гипотезы, которые казались логичными, и почему каждая отпадала. «Неверный phy-handle» — но линк-то определяется, значит MAC с PHY разговаривает. «Кабель или коммутатор» — заменили, не помогло, да и автосогласование проходит. «Файрвол, DHCP-сервер, сеть» — но счётчик ошибок приёма растёт на самом интерфейсе, до всякой маршрутизации. «Драйвер MACB» — стоковый, используется тысячами устройств.
Улика лежала в том, что MDIO и RGMII — разные линии. Была бы проблема в PHY или в согласовании — не было бы линка. Была бы выше уровнем — не росли бы ошибки кадров. Ошибки кадров при живом MDIO означают ровно одно: служебная шина работает, а шина данных принимает мусор. Линии электрически разные, значит, различаться должно что-то электрическое.
И оно различалось. У каждого вывода MIO в Zynq есть не только функция, но и электрический стандарт — какими напряжениями кодируются ноль и единица и каким приёмником сигнал воспринимается. ps7_init нашей платы программирует выводы RGMII (MIO16…27) как LVCMOS18; вот строка для MIO16 (регистр 0xF8000740, поле IO_Type в битах 11:9 равно 001, то есть LVCMOS18):
mask_write 0XF8000740 0x00003FFF 0x00001202А pinctrl от zc702, который Linux применяет к gem0 при загрузке, форсирует на тех же выводах HSTL — другой стандарт, с другим опорным напряжением. Выводы перепрограммируются уже после того, как ps7_init всё сделал правильно.
Почему при этом линк поднимается? Потому что MDIO живёт на других выводах и остаётся в LVCMOS18, а автосогласование — это разговор по MDIO. Он проходит, PHY рапортует «линк есть, гигабит», ethtool это пересказывает. Приёмники же RGMII, переведённые в HSTL, интерпретируют приходящие уровни неправильно, и в MAC приезжает мусор, не проходящий проверку контрольной суммы. Отсюда RX errors и frame errors при идеальном линке.
Корневая причина дефекта ETH-2 двойная, и обе части нужны для полной картины: чужой адрес PHY (31.2) и чужой электрический стандарт выводов, навязанный поверх правильного ps7_init. Исправление в zynq-rk7020-spi.dts:
&gem0 {
status = "okay";
phy-mode = "rgmii-id";
phy-handle = <ðernet_phy>;
/delete-property/ pinctrl-names;
/delete-property/ pinctrl-0;
/delete-node/ ethernet-phy@7;
ethernet_phy: ethernet-phy@1 {
reg = <1>;
device_type = "ethernet-phy";
};
};Обратите внимание на приём /delete-property/. Мы не переписываем pinctrl правильными значениями — мы удаляем его целиком, оставляя выводы такими, какими их настроил ps7_init: он сгенерирован из заводского дизайна и по определению прав, а попытка воспроизвести его в pinctrl была бы вторым источником истины.

Правило: Link Up доказывает работоспособность MDIO, а не тракта данных. Проверяйте счётчики ошибок интерфейса, а не только состояние линка.
Инструменты для этого в образе есть заранее: BR2_PACKAGE_ETHTOOL=y, BR2_SYSTEM_DHCP="eth0", в ядре CONFIG_MACB=y и CONFIG_REALTEK_PHY=y — последний нужен потому, что PHY у нас Realtek, и без его драйвера работал бы только generic-режим.
31.4. Три кузена той же ошибки
Ethernet — не исключение, а третий случай одного класса. В zynq-rk7020-spi.dts сняты ещё три чужих настройки, и каждая стоила своего вечера.
Консоль, замолкающая после pinctrl. Симптом: ядро печатает zynq pinctrl initialized и умолкает навсегда. Выглядит как зависание — и это отдельная беда, потому что настоящее зависание из 29.4 выглядит точно так же. Гипотеза «ядро упало» опровергается тем, что система продолжает работать (позже подтвердилось по сети). Улика: в zc702 узел gpio0 имеет pinctrl, перемультиплексирующий MIO10 и MIO11 в GPIO (gpio0_10/11_grp), а на нашей плате MIO10/MIO11 — это UART0, то есть сама консоль. Как только пробуется gpio-zynq, выводы консоли перестают быть выводами консоли. Исправление: удалить pinctrl у gpio0. Заодно удалён узел gpio-keys, ссылавшийся на несуществующие на этой плате кнопки.
Карта, которая не находится. Симптом: Waiting for root device mmcblk0p2 бесконечно, при том что U-Boot только что читал файлы с этой же карты из этого же слота — из-за чего гипотеза «карта или файловая система» отпадает сразу. Улика: pinctrl от zc702 кладёт card detect на MIO0, а write protect на MIO15; ps7_init нашей платы разводит SDIO0_CD на MIO9 (WP на MIO55). Драйвер sdhci смотрит не туда, сигнал «карта вставлена» никогда не приходит, и контроллер честно ждёт. Рецепт тот же: удалить pinctrl плюс на время bring-up добавить broken-cd — «не верь детектору, считай, что карта всегда есть».
Паника вместо загрузки. Симптом: BUG() и паника сразу после pinctrl. Улика: в стоковой конфигурации Zynq есть таблица OPP (operating performance points) — пары «частота/напряжение», между которыми cpufreq переключает процессор, здесь 667 и 333 МГц. С состоянием клоков, которое оставляет ps7_init этой платы, clk_set_rate() возвращает -EBUSY, а cpufreq_online() на такой случай не рассчитан и вызывает BUG(). Исправление двойное: из DTS удалены operating-points и clock-latency у &cpu0, а в linux.config отключён CONFIG_CPU_FREQ целиком — чтобы будущая ошибка в DTS не смогла оживить подсистему.
Общий знаменатель всех четырёх историй: pinctrl от zc702 врёт поверх правильного ps7_init. Он не «не настраивает» — он настраивает неверно, поверх верного. Отсюда вывод для любого, кто поднимает клон чужой платы на чужом DTS: первым делом пройтись по узлам UART, SD, Ethernet и GPIO и снять с них pinctrl-*, оставив выводы загрузчику. Настроить их правильно можно потом, когда система грузится и есть чем отлаживать.
Итог части VII. Работающий образ Linux — это не «ядро плюс rootfs», а сумма согласованных артефактов, каждый из которых может молча разойтись с остальными: ps7_init от этой платы, а не от похожей; DTB, в котором доказуемо есть узел SPI; FCLK0, который никто не потушил из лучших побуждений; драйвер, читающий 0x5350 и отказывающийся работать с чужим битстримом; Ethernet, с которого сняли чужой электрический стандарт. Ни одно из этих условий не проверяется само собой, и почти каждое при нарушении молчит.
Если запоминать из части одну вещь — пусть это будет привычка превращать молчаливую ошибку в громкую. Проверка узла в post-image.sh, проверка ID в probe(), поиск константы 0x1082 в boot.bin стоят по десять минут и каждая экономит день.
Дальше — критерий, который видно глазами. В части VIII к плате подключается дисплей ST7789, и «работает» перестаёт быть строкой в dmesg. В части IX выяснится, что кадр целиком через эту архитектуру не проходит, и появятся STREAM и AXI DMA — вместе с самым длинным списком ошибок в серии. Итоги — в части X; справочник команд, глоссарий и каталог дефектов (ETH-1, ETH-2, ETH-3) — в приложениях.
Часть VIII. Дисплей ST7789
До сих пор слово «работает» в этой статье означало нечто довольно скромное. В части V мы радовались тому, что на выводе MOSI есть импульсы правильной формы, а байт, отправленный в петлю, вернулся обратно неискажённым. В части VII успехом считалась строчка в dmesg о том, что драйвер привязался к устройству. Всё это честные победы, но у них есть общая слабость: они локальные. Они проверяют один стык за раз и почти ничего не говорят о том, складываются ли эти стыки в работающую систему.
С этой главы критерий меняется. Теперь успех — это осмысленная картинка на экране. Не «пиксели зажглись», а именно осмысленная: цифры часов стоят ровно, не перевёрнуты, не сдвинуты, не двоятся и не мигают остатками предыдущего кадра. Такой критерий нельзя подделать. Он одновременно проверяет протокол SPI, порядок байт, работу вспомогательных линий, адресацию памяти внутри контроллера дисплея, поведение сигнала выбора кристалла на длинной посылке и то, что операционная система не мешает нашей программе. Любая ошибка в этой цепочке немедленно видна глазами — и обычно видна характерным, узнаваемым образом.
Дальше будет разбор четырёх тем и шести расследований: что происходит внутри модуля дисплея, откуда взялись три линии управления, которых в SPI нет, почему появившаяся картинка оказалась перевёрнутой, сдвинутой и с призраками и как устроена программа spi-clock, рисующая часы напрямую через физическую память в обход всей графической подсистемы Linux.
Глава 32. LCD как критерий успеха
32.1. Что вообще делает контроллер дисплея
Прежде чем говорить об ошибках, надо понять, с чем мы разговариваем. Внутри модуля дисплея находится не просто стекло с пикселями, а отдельная микросхема — контроллер ST7789V, маленький специализированный процессор с собственной памятью. Именно с ним, а не с пикселями, общается наш SPI.
Память эта называется GRAM — graphics RAM, графическое ОЗУ. Проще всего представить её как лист миллиметровой бумаги, лежащий внутри микросхемы: в каждой клетке записан цвет одной точки. Контроллер сам, без нашего участия, десятки раз в секунду обходит этот лист и подсвечивает соответствующие точки на стекле. Наша задача сводится к одному: писать в клетки нужные цвета. Дальше контроллер справится сам. Отсюда следует важное свойство, которое позже породит целое расследование: GRAM — это память, и она помнит то, что мы записали в неё раньше. Никто её не чистит при смене настроек, при перезапуске программы или при переключении ориентации.
Общение с контроллером состоит из команд и данных. Команда — это один байт, который говорит «сейчас будет настройка яркости», «поверни порядок обхода памяти» или «начинай запись пикселей». Данные — это байты аргументов команды или сами пиксели. Проблема в том, что по SPI и то и другое идёт по одному и тому же проводу MOSI одинаковыми восьмибитными посылками, и контроллер не может по содержимому байта догадаться, что перед ним: 0x2A — одновременно валидная команда и валидный оттенок цвета.
Поэтому у контроллера есть отдельный физический провод, который отвечает ровно на этот вопрос. Он называется D/C — data/command, у нас в схеме и в коде он обозначен DC. Ноль означает «то, что сейчас едет по SPI, — команда», единица означает «данные». Аналогия: лектор диктует текст и время от времени поднимает табличку «это заголовок, а не текст». Табличка — отдельный канал, никак не смешанный с речью, и слушатель обязан смотреть на неё, чтобы правильно разобрать диктовку. Ровно так же контроллер смотрит на уровень DC в момент прихода байта.
Три команды из всего набора нужно запомнить сразу, потому что вокруг них крутится вся глава 34. CASET (0x2A) задаёт диапазон столбцов, RASET (0x2B) — диапазон строк. Вместе они очерчивают прямоугольное окно на нашем листе миллиметровки: «пиши только сюда». RAMWR (0x2C) означает «начинай записывать пиксели в это окно». После RAMWR каждые два байта, приходящие с DC=1, ложатся в очередную клетку окна: слева направо, дойдя до правого края — на следующую строку окна, и так до заполнения. Курсор двигает сам контроллер, нам его положение сообщать не нужно.
Два байта на пиксель — это формат RGB565. Шестнадцать бит делятся на пять бит красного, шесть зелёного и пять синего; зелёному досталось на бит больше, потому что человеческий глаз к нему чувствительнее, и экономить лучше на других каналах. Белый цвет в этом формате — 0xFFFF, чёрный — 0x0000, а тёмно-серый фон наших часов — 0x1082. Старший байт уходит первым: в spi-clock.c это видно в функции lcd_pixels, которая разбирает шестнадцатибитное слово на pix >> 8 и pix & 0xff именно в таком порядке. Перепутанный порядок байт даст не «немного другие цвета», а полностью инопланетную палитру — ещё один симптом, который дисплей показывает мгновенно.
32.2. Почему «мигнул MOSI» уже недостаточно
Осциллограф и логический анализатор отвечают на вопрос «есть ли на проводе импульсы правильной формы». Это необходимое условие, но крайне слабое. Петля MOSI–MISO, которой мы пользовались на bring-up, отвечает на вопрос «дошёл ли байт обратно» — тоже полезно, но она проверяет только наш собственный контроллер, замкнутый сам на себя. Ни один из этих инструментов не отвечает на вопросы, от которых зависит картинка.
Понимает ли контроллер наши команды? Держится ли сигнал выбора кристалла на всей длине посылки пикселей или рвётся посередине? Совпадает ли окно, которое мы задали командами CASET и RASET, с той областью стекла, которую видит глаз? Не перепутан ли порядок обхода памяти? Верен ли порядок байт в пикселе? Каждый из этих вопросов — про согласованность двух сторон, а не про качество сигнала. И на каждый из них экран отвечает за долю секунды, без единого прибора.
Экран — интегральный тест в буквальном смысле слова: он проверяет всю цепочку сразу и падает, если сломано хоть одно звено. Один неверный байт в инициализации, один слишком ранний подъём CS, одна ошибка в смещении окна — и вы получаете чёрный прямоугольник, полосы, призраки старого кадра или сплющенный шрифт. Причём разные поломки дают разные картинки, и это главная ценность: экран не просто говорит «плохо», он показывает как плохо, а по характеру искажения опытный человек угадывает причину раньше, чем откроет исходники.
32.3. Что дисплей показывает лучше осциллографа — и что прячет
Раз мы делаем дисплей главным критерием, честно сказать и об обратной стороне: выбор инструмента верификации — архитектурное решение, у него есть цена.
Сильные стороны очевидны. Дисплей ничего не стоит: он уже распаян на плате, его не надо подключать, настраивать развёртку и искать щупом землю. Он проверяет всю систему целиком, от пользовательской программы до физического вывода микросхемы, и даёт обратную связь мгновенно — можно менять константу, пересобирать программу и смотреть, что изменилось, десятки раз подряд. И он проверяет то, что осциллограф увидеть в принципе не может: семантику. Осциллограф покажет идеальный по форме байт 0x36, но не скажет, что аргумент к нему выбран неверно и картинка от этого встанет вверх ногами.
Слабые стороны не менее важны. Во-первых, дисплей даёт булев ответ без координаты отказа: чёрный экран одинаково выглядит и при отсутствии питания подсветки, и при удерживаемом сбросе, и при неверном режиме SPI, и при том, что программа вообще не запустилась. Во-вторых, у этого модуля нет линии MISO, поэтому дисплей физически не способен проверить приёмную половину нашего контроллера — именно поэтому в части IV он был отвергнут как основная цель bring-up. В-третьих, дисплей не показывает запас по времени: картинка может быть идеальной, а sticky-бит ошибки в STATUS — взведённым, и система, работающая на этой плате при этой температуре, сломается на следующей.
Отсюда практическое правило, которым мы пользовались дальше: дисплей — приёмочный тест, осциллограф — диагностический. Сначала смотрим на экран, а как только он показал «неправильно», берём приборы и регистры.
32.4. Панель на этой плате
На TZT RK-ZYNQ7020-F распаян IPS-модуль на контроллере ST7789V. Физически матрица имеет 172 столбца и 320 строк — вытянутый прямоугольник, который логичнее всего использовать горизонтально. Интерфейс — SPI в режиме 3 (тактовый сигнал в покое высокий, данные защёлкиваются по второму фронту), пиксели RGB565. Панель принципиально «неполноразмерная»: ST7789 рассчитан на матрицы до 240×320, а у нас видимых столбцов только 172. Разница в 68 клеток никуда не девается — память под них в GRAM есть, стекла над ними нет, и это станет причиной второго расследования главы 34.

32.5. Лестница видимых признаков
В лаборатории удобно идти к картинке не одним прыжком, а по ступеням, каждая из которых что-то доказывает. Первая ступень — загорается подсветка: значит, блок AXI GPIO отвечает на запись, битстрим в самом деле загружен, уровни на выводах банка 33 доходят до модуля и питание в порядке. Никакого SPI тут ещё не нужно.
Вторая ступень — после подачи сброса экран показывает не случайный шум, а предсказуемое состояние: импульс сброса дошёл до контроллера, инициализация принята, контроллер вышел из спящего режима и включил вывод на стекло. Третья ступень — сплошная цветная заливка или простые полосы. Здесь впервые проверяется весь конвейер записи в GRAM: команда RAMWR, окно и порядок байт в пикселе. Заливка одним цветом при этом милосердна: она скрывает потерю отдельных байт, потому что все байты одинаковые.
Четвёртая ступень — текст или часы без двоения и перекоса. Она самая жёсткая, потому что мелкий шрифт превращает любую потерю байта в видимый сдвиг строки, а любой разрыв посылки — в двоение. Если у вас есть третья ступень, но нет четвёртой, проблема почти наверняка не в проводке и не в инициализации, а в удержании сигнала выбора кристалла на длинной посылке или в ориентации. Это прямой мост к главе 34 и дальше к части IX.
Глава 33. AXI GPIO, DC/RST/BL и MISO «в воздух»
33.1. Три линии, которых нет у «чистого» SPI
Наше IP-ядро умеет ровно то, что описано в его карте регистров: формировать SCLK, гнать биты в MOSI, принимать их из MISO и опускать CS на время посылки. Больше ничего. Дисплею этого мало: ему нужны ещё три обычных выхода общего назначения, которые не имеют к протоколу SPI никакого отношения, но без которых модуль не оживёт.
Сигнал | Бит GPIO | Вывод FPGA | Полярность | Роль |
|---|---|---|---|---|
DC | 0 ( | W13 | 0 — команда, 1 — данные | отличает |
RST | 1 ( | AA18 | активный низкий | аппаратный сброс контроллера |
BL | 2 ( | Y13 | активный высокий | подсветка панели |
Сами линии SPI при этом уходят на выводы, разведённые заводом под дисплей: SCLK на V18, MOSI на U19, CS на AA13. Все шесть выводов сидят в банке 33 со стандартом LVCMOS33 — числа взяты из constraints/ps_axi_pins.xdc и из спецификации на ST7789, где эта раскладка была зафиксирована ещё до сборки битстрима.
Три бита живут в отдельном блоке axi_gpio из каталога Xilinx, подключённом вторым ведомым к той же межсоединительной шине AXI, что и наш SPI. Его базовый адрес — 0x40010000, и это тот адрес, который программа открывает вторым отображением. Все три бита сконфигурированы как выходы прямо в блок-дизайне (xlnx,all-outputs = <1> в linux/dts/zynq-rk7020-fpga-spi.dtsi), поэтому регистр направления трогать не нужно: достаточно писать данные по смещению 0x00.
33.2. Почему GPIO из каталога, а не три регистра в нашем IP
Соблазн был очевидный: добавить в spi_axi4lite ещё один регистр с тремя битами и управлять дисплеем «изнутри», без второго блока. Мы этого не сделали, и это осознанное архитектурное решение с понятной ценой.
Главный аргумент — чистота контракта. Карта регистров нашего IP описана в ../register_map.md и в ../hw_sw_contract.md, она опубликована, к ней написан драйвер, по ней составлены тесты. Как только в неё попадает бит LCD_BACKLIGHT, универсальный SPI-мастер перестаёт быть универсальным SPI-мастером и становится «контроллером вот этого дисплея на вот этой плате». Следующий проект, который захочет взять наше ядро, унаследует бессмысленный бит и вопрос «а что будет, если его выставить, когда дисплея нет». Аппаратный интерфейс — публичный API, и засорять его удобствами конкретного приложения так же плохо, как класть в библиотеку сортировки функцию печати накладной.
Второй аргумент — стоимость проверки. Любое изменение RTL означает новую симуляцию, прогон синтеза, проверку временных ограничений и риск регрессии в том, что уже работало. axi_gpio от Xilinx — готовый, верифицированный вендором блок с давно написанным драйвером в ядре и стандартным описанием в Device Tree (compatible = "xlnx,axi-gpio-2.0"): ноль строк нашего RTL и ноль риска.
Третий вариант, управлять этими линиями напрямую из процессорной системы через MIO, отпал по физике: выводы W13, AA18 и Y13 находятся в банке 33 программируемой логики, а MIO — это выводы процессорной части. Провода между ними нет.
Заплатили мы за такое разделение двумя вещами. Первая — лишнее адресное окно на межсоединительной шине: в блок-дизайне пришлось поставить NUM_MI = 2 и отдать блоку GPIO окно 0x40010000 (подробности сборки — в части VI). Это дёшево, но это ещё одна вещь, которую надо не забыть в Device Tree и в скрипте сборки. Вторая цена серьёзнее: между уровнем DC и байтом на проводе нет никакой аппаратной связи. Это два независимых ведомых устройства, и порядок их операций гарантирует только программа. Если переключить DC раньше, чем последний бит предыдущего байта ушёл с провода, контроллер дисплея запишет этот байт не в ту категорию. Именно поэтому в коде функция lcd_cmd после каждого байта вызывает spi_wait_done и только потом трогает GPIO — это не перестраховка, а обязательное следствие выбранной архитектуры.
33.3. Как это выглядит в софте: одно слово на три линии
Отправка команды с аргументами выглядит в spi-clock.c предельно просто, и в этой простоте спрятана ловушка, о которой стоит сказать заранее. Функция gpio_set пишет в блок GPIO целое слово с маской трёх младших бит: wr(gpio, 0x00, v & 7). Отдельных адресов «только DC» или «только подсветка» у блока нет. Каждая запись задаёт состояние всех трёх линий одновременно.
Поэтому lcd_cmd перед командным байтом пишет G_RST | G_LED, то есть 0b110 — DC опущен, сброс снят, подсветка включена, — а перед аргументами пишет G_DC | G_RST | G_LED, то есть 0b111. Биты сброса и подсветки повторяются в каждой записи не для красоты: если их не повторить, они обнулятся.

33.4. Порядок включения, который стоит запомнить наизусть
BL = 1 подсветка: экран хотя бы светится
RST = 0, пауза 20 мс сброс удерживается
RST = 1, пауза 120 мс сброс снят, контроллер поднимается
настройка SPI CLK_DIV, WORD_LEN, CS_SELECT, DELAY_CFG, режим 3
init-последовательность DC=0 для команд, DC=1 для аргументов
MADCTL, CASET, RASET ориентация и окно
RAMWR (0x2C), DC=1 непрерывный поток RGB565В panel_init шаги 1–3 занимают четыре строки: gpio_set(G_LED) оставляет бит сброса нулевым при уже включённой подсветке, usleep(20000) выдерживает паузу, gpio_set(G_RST | G_LED) снимает сброс, usleep(120000) даёт контроллеру время подняться. Без явного импульса сброса система попадает в самый неприятный класс отказов — «иногда работает»: после холодного старта контроллер может подняться сам, а после перезагрузки платы без снятия питания — нет. Соседняя группа в лаборатории получает картинку, вы с тем же битстримом нет, и уходит вечер.
33.5. Ошибка: экран молчит, хотя SCLK и MOSI живы
Что видели. Подсветка горит ровно, значит панель запитана и блок GPIO отвечает. Осциллограф на выводах V18 и U19 показывает аккуратные пачки импульсов нужной частоты и правильной формы, CS на AA13 послушно опускается и поднимается вокруг каждой пачки. Регистр ID_VERSION по адресу 0x4000003C читается и возвращает 0x53500200, то есть в логику загружен именно тот битстрим, который мы собирали. И при всём этом на стекле не появляется вообще ничего: ровная светящаяся поверхность без единого изменения, сколько ни гоняй заливку.
Что казалось логичным. Первая гипотеза была самая обидная: панель мёртвая. Вторая, чуть более конструктивная, — перепутан режим SPI. Обе выглядели правдоподобно: если контроллер защёлкивает биты не по тому фронту, он получит мусор вместо команд и, разумеется, ничего не покажет, а с виду сигналы при этом останутся идеальными. Третья гипотеза — что кто-то ещё пишет в те же регистры; её мы проверили и вынесли в отдельное расследование главы 35.
Как проверяли. Начали с самого дешёвого разделения. Подсветка управляется битом 2 того же блока GPIO, что и сброс, и она работала — значит адрес 0x40010000 верный, запись доходит, уровни на выводах банка 33 живые. Дальше мы стали смотреть не на форму сигналов, а на их взаимное расположение во времени, и подняли на анализаторе четвёртый канал — вывод AA18, то есть RST. Картина оказалась неожиданной: линия сброса стояла в нуле почти всё время, коротко поднимаясь в единицу и снова падая с периодичностью, подозрительно похожей на период отправки байт.
Улика. Совпадение периода. Сброс падал не случайно и не от помех, а ровно в такт нашим собственным записям в блок GPIO. То есть его ронял наш же код.
Корневая причина. У блока AXI GPIO нет трёх независимых адресов для трёх линий. Есть один регистр данных по смещению 0x00, и каждая запись в него задаёт состояние всех битов сразу. Ранняя версия кода, переключая DC перед командным байтом, писала в этот регистр просто 0 — «опустить DC». Вместе с DC она опускала бит 1, то есть загоняла контроллер дисплея в аппаратный сброс, и бит 2, то есть гасила подсветку на время посылки (глаз этого не замечал из-за инерции). Контроллер, удерживаемый в сбросе, добросовестно игнорировал всю инициализацию: команды приходили на вход микросхемы, которая в этот момент была выключена. Осциллограф был прав — байты действительно уходили. Просто их некому было слушать.
Исправление. Каждая запись в GPIO должна формировать полное слово. В коде это выражено явно: перед командой пишется G_RST | G_LED, перед данными — G_DC | G_RST | G_LED, и нигде нет «частичных» записей. Единственное место, где бит сброса намеренно опущен, — это первая строка panel_init, где мы этот сброс и хотим удержать.
Правило. У параллельного порта нет отдельных пинов — есть одно слово. Любая запись задаёт все линии сразу, поэтому состояние всех линий надо держать в переменной и писать её целиком.
33.6. MISO привязан к единице — и это не бесплатно
У встроенного дисплея нет линии MISO. Совсем: провода нет ни на плате, ни в разъёме модуля, и назначать под него вывод FPGA не на что. Но наше ядро — полнодуплексный SPI-мастер: оно на каждом такте не только выдаёт бит в MOSI, но и защёлкивает бит с MISO. Оставить вход висящим в воздухе нельзя — он будет ловить наводки, а в симуляции даст неопределённое состояние, — поэтому в блок-дизайне spi_miso подключён к константе единица, что и записано в спецификации строкой «tied high in BD (no pad)».
Прямые следствия просты. Любое «чтение» с шины теперь возвращает 0xFF — это не данные дисплея, а наша собственная константа. Петля MOSI–MISO, на которой держалась методика bring-up из части V, к выводам дисплея неприменима: замыкать нечего. А самое неприятное вылезло позже, на длинных посылках.
33.7. Ошибка LCD-4: приёмник, которого мы не просили
Что видели. Пока мы гоняли короткую инициализацию, всё было в порядке. Проблема вылезла на первой полноэкранной заливке. Картинка появлялась, но с дефектом: часть кадра оказывалась сдвинутой, будто из потока выпали несколько байт, а в конце заливки программа обнаруживала взведённый флаг ошибки, который до того ни разу не встречался. Иногда следующий кадр рисовался поверх сдвинутого и получалась каша.
Что казалось логичным. Рассуждение было такое: мы же только пишем. Приёмная половина контроллера нам не нужна, значит она нам и не мешает. В худшем случае RX FIFO заполнится единицами и перестанет принимать — данные там всё равно мусорные, никто их читать не собирается. Гипотеза выглядела настолько самоочевидной, что приёмник вообще не рассматривался как подозреваемый: искали ошибку в скорости, в задержках DELAY_CFG, в темпе, с которым процессор подкладывает байты.
Как проверяли. Сначала сузили условия: короткая посылка на несколько десятков байт проходила чисто, длинная на десятки килобайт — нет. Порог был подозрительно близок к глубине FIFO. Затем прочитали STATUS сразу после сбойного кадра и получили обескураживающий результат: бит 9, тот самый ERR_RX_OVF, был нулевым. По регистру состояния переполнения приёмника не было. Спасло то, что этот дефект уже был описан: в ../hw_sw_contract.md он числится под номером A-5 — бит 9 не выставляется никогда, это унаследованная от Altera-версии болячка, и единственный работающий способ узнать о переполнении приёма — бит 2 регистра IRQ_STATUS. Прочитали его — бит взведён.
Улика. Переполнение приёмного FIFO на посылке, в которой мы ничего не принимаем. Это звучит абсурдно ровно до того момента, пока не вспомнишь, что принимаем мы не «ничего», а константу единица, подключённую в блок-дизайне: на каждый отправленный байт движок исправно кладёт в RX FIFO очередное 0xFF.
Корневая причина. В spi_engine.v запись в приёмное FIFO выполняется безусловно, если не поднят сигнал tx_only. Движок не знает и не может знать, что подключённое устройство однонаправленное. Кадр 320×172 в RGB565 — это 110 080 байт, то есть 110 080 бесполезных слов, которые пытаются пролезть в FIFO глубиной 1024. Переполнение наступает практически сразу и поднимает sticky-флаг ошибки. Дальше срабатывает вторая мина: единственный способ снять sticky-флаг — запись в ERROR_CLR, а она, согласно ограничению F-2 из контракта, флашит оба FIFO разом. Попытка «прибраться» посреди кадра выбрасывает из передающего FIFO ещё не отправленные пиксели — вот откуда взялись пропавшие байты и сдвиг картинки.
Исправление. В обёртке v2 есть бит TX_ONLY (бит 1 регистра STREAM_CTRL по адресу 0x30), который заставляет движок выбрасывать принятое слово, не трогая RX FIFO. В коде он выставляется в stream_arm вместе с потоковым режимом и быстрым делителем: STREAM_EN | STREAM_TX_ONLY | STREAM_TX_FAST. После этого длинные кадры перестают порождать переполнение приёма, а ERROR_CLR в середине кадра становится не нужен.
Правило. Полнодуплексный движок принимает всегда, даже когда принимать нечего. Однонаправленная нагрузка требует явного режима «только передача» — иначе вы получите переполнение приёмника, невидимое в регистре состояния.
Подробный разбор потокового режима, FAST_DIV и того, как всё это уживается с AXI DMA, ждёт в части IX; дефект LCD-4 занесён в каталог в приложениях.
Глава 34. MADCTL 0x60, Y_OFF=34 и призраки старого кадра
34.1. Команды, окно и RAMWR
Полный набор команд ST7789 занимает несколько десятков позиций, но для того, чтобы получить картинку, нужна лишь горстка. Ниже те, которые встречаются в spi-clock.c; коды взяты прямо из кода, назначение — из общепринятой терминологии ST7789, отражённой в именах переменных того же файла.
Код | Имя | Что делает |
|---|---|---|
| SWRESET | программный сброс контроллера, требует паузы |
| SLPOUT | выход из спящего режима, запуск внутренних преобразователей |
| COLMOD | формат пикселя; аргумент |
| PORCTRL | тайминги гашения (порчи) внутренней развёртки |
| GCTRL | напряжения затворов матрицы |
| VCOMS | опорное напряжение VCOM |
| VDVVRHEN | разрешение программной установки VRH и VDV |
| VRHS | амплитуда напряжения питания матрицы |
| VDVS | смещение VDV |
| VCMOFSET | подстройка VCOM |
| PWCTRL1 | параметры внутреннего преобразователя питания |
| MADCTL | порядок обхода GRAM: ориентация и зеркала |
| INVON | включение инверсии цвета (нужна этим IPS-модулям) |
| DISPON | включить вывод GRAM на стекло |
| CASET | диапазон столбцов окна записи |
| RASET | диапазон строк окна записи |
| RAMWR | начать запись пикселей в текущее окно |
Команды с 0xB2 по 0xD0 — это настройка аналоговой части: напряжений, которыми контроллер управляет жидкими кристаллами. Разбираться в их физическом смысле для нашей задачи не нужно: значения берутся из типовой последовательности производителя модуля, ровно как в драйвере fb_st7789v из ядра Linux, на который ссылается спецификация. Интересны нам три последние строки таблицы плюс MADCTL — именно они определяют, куда ляжет пиксель.
34.2. Инициализация панели по шагам
Ниже — последовательность из panel_init в том же порядке, что и в коде. Каждый шаг стоит понимать, а не копировать: половина расследований главы началась с попытки поменять один из них.
Аппаратный сброс. Подсветка включается, линия RST удерживается в нуле 20 миллисекунд, затем поднимается, и мы ждём ещё 120 миллисекунд. Пауза нужна не «на всякий случай»: контроллер после снятия сброса поднимает внутренние генераторы напряжений, и до их стабилизации он команды не воспринимает.
Настройка SPI под инициализацию. Потоковый режим выключается (
STREAM_CTRL = 0), делительCLK_DIVставится в 5, длина слова — 8 бит, выбирается нулевой CS,DELAY_CFG = 0x00080808даёт по восемь тактов на установку CS, на удержание и на паузу между словами. Делитель 5 означает полупериод в шесть тактов, то есть частотуf_clk / 12: при FCLK_CLK0 в 100 МГц это около 8,3 МГц — заведомо безопасно для панели. Биты CPOL и CPHA вместе с битом разрешения дают третий режим SPI.SWRESET(0x01) и пауза 150 мс. Программный сброс поверх аппаратного приводит регистры контроллера в известное состояние. Это важно, если панель до нас уже кто-то инициализировал — например, предыдущий запуск нашей же программы.SLPOUT(0x11) и пауза 120 мс. Выход из спящего режима. До этой команды контроллер экономит энергию и не обслуживает матрицу. Пауза снова обязательна: внутренним преобразователям нужно время выйти на режим.COLMOD(0x3A) с аргументом0x55. Объявляем формат пикселя: 16 бит, RGB565. С этого момента контроллер считает, что каждые два байта послеRAMWR— это один пиксель. Ошибка здесь превращает изображение в шум.Блок аналоговых настроек:
0xB2,0xB7,0xC2,0xC3,0xC4,0xBB,0xC5,0xD0. Восемь команд с аргументами{05 05 00 33 33},0x75,{01 FF},0x13,0x20,0x22,0x20,{A4 A1}соответственно. Это рецепт производителя модуля; менять эти числа наугад вредно — они задают напряжения на матрице.MADCTL(0x36) с аргументом0x60. Ориентация. Ей посвящено следующее расследование целиком.INVON(0x21). Включение инверсии. У IPS-модулей этого типа полярность такова, что без инверсии чёрный выглядит белым и наоборот. Если ваша заливка0x0000даёт белый экран — вы просто пропустили эту команду.DISPON(0x29). Разрешить вывод GRAM на стекло. До неё вся работа идёт «в стол»: пиксели пишутся в память, но не показываются.
После этого main выполняет ещё два действия, которые формально не относятся к инициализации панели, но без них картинка будет неправильной: полная заливка чёрным lcd_fill(0x0000) и следом заливка фоном lcd_fill(0x1082). Зачем нужны именно две — в разделе 34.5.
34.3. Ошибка LCD-3: картинка перевёрнута и зеркальна
Что видели. Сплошная заливка проходила безупречно: экран послушно становился красным, потом синим, потом серым. Как только на этом фоне появился текст, стало ясно, что праздновать рано. Цифры читались справа налево, каждая была зеркальной, а вся строка стояла не в центре, а прижималась к противоположному краю и была перевёрнута сверху вниз. Часы выглядели как отражение в зеркале, которое вдобавок положили на бок.
Что казалось логичным. Первая гипотеза, и она действительно выглядела разумной: ошибка в отрисовке шрифта. Шрифт 5×7 в коде хранится упакованными строками, и выборка бита выглядит как g[row] & (0x10 >> col) — обход столбцов идёт от старшего бита к младшему. Перепутать здесь направление проще простого: одна опечатка 0x01 << col вместо 0x10 >> col даёт ровно зеркальные символы. Гипотеза объясняла зеркальность, и мы полчаса потратили на её проверку, что было разумным ходом, но неверным.
Как проверяли. Правильный эксперимент оказался проще, чем чтение кода. Шрифт и адресация панели — два независимых подозреваемых, и разделить их можно одним тестом: нарисовать не текст, а заведомо асимметричную фигуру, положение которой задаётся не шрифтом, а окном. Мы задали окно размером десять на десять пикселей в углу (0, 0) командами CASET и RASET и залили его белым. Если бы виноват был шрифт, квадрат появился бы в левом верхнем углу — там, где мы его просили. Квадрат появился в противоположном углу.
Улика. Белый квадрат в правом нижнем углу при запросе левого верхнего. Шрифт здесь ни при чём: в этом тесте от него не зависело вообще ничего. Значит, неверно интерпретируются сами координаты, а за это отвечает ровно одна настройка.
Корневая причина. Команда MADCTL — memory data access control — управляет не «поворотом картинки», как хочется думать, а порядком, в котором контроллер обходит ячейки GRAM при записи. У неё три интересных бита. Бит 7, MY, меняет направление обхода строк на противоположное. Бит 6, MX, делает то же со столбцами. Бит 5, MV, меняет строки и столбцы местами — именно он превращает портретную панель в ландшафтную. Аналогия: вы диктуете человеку, как заполнять клетчатый лист. MV — это «поверни лист на бок», MX и MY — «начинай не с левого края, а с правого» и «не с верхней строки, а с нижней». Диктовка одна и та же, результат разный.
Для ландшафта обязателен MV, но одного MV мало: остаются два варианта разворота на 180 градусов друг относительно друга. Экспериментальное значение 0xA0 — это MY вместе с MV, и оно даёт ландшафт, повёрнутый «не тем концом» относительно того, как модуль стоит на плате. Рабочее значение — 0x60, то есть MX вместе с MV. Обе комбинации законны, обе дают горизонтальную картинку, и выбор между ними определяется не логикой, а тем, с какой стороны у модуля выведен шлейф.
Исправление. В коде это одна константа: #define MADCTL_LANDSCAPE 0x60, передаваемая аргументом команды 0x36 в panel_init. Комментарий рядом с ней фиксирует и историю: MX|MV → landscape, 180° from MY|MV.
Правило.
MADCTLне поворачивает изображение — он меняет порядок обхода памяти. Сначала выбирайте ориентацию, и только потом считайте всё остальное: размеры, смещения и координаты центра зависят от неё.
34.4. Ошибка LCD-3, продолжение: картинка сдвинута на 34 строки
Что видели. После правки MADCTL текст встал в правильном направлении, и это было облегчение ровно на минуту. Сплошная заливка теперь покрывала не весь экран: сверху оставалась полоса, которая не закрашивалась и показывала мусор, а нижняя часть изображения будто уезжала за край стекла. Строка часов, которую мы честно центрировали формулой (HEIGHT - tile_h) / 2, оказывалась заметно ниже середины.
Что казалось логичным. Гипотеза номер один: мы неправильно определили разрешение. Контроллер ST7789 обычно ставят на панели 240×320, а мы объявили 172 — возможно, панель на самом деле полноразмерная, а мы рисуем только её часть и оттого видим полосу. Гипотеза номер два, более экзотическая: заливка не успевает и верхние строки просто не дописываются. Обе объясняли наблюдаемое, обе были неверны.
Как проверяли. Вторую гипотезу отбросили быстро: непрокрашенная полоса всегда была сверху и всегда одной и той же ширины, а недописанный кадр давал бы пропуск снизу, там, где заканчивается поток. Первую проверили честным экспериментом: задали окно на всю логическую высоту и нарисовали не заливку, а рамку в один пиксель по границам окна, то есть по строкам y = 0 и y = 171. Верхняя линия рамки на стекле не появилась вовсе. Нижняя появилась, но не у нижнего края, а с заметным отступом. Затем мы сдвинули всё окно вниз на несколько десятков строк и стали смотреть, при каком сдвиге верхняя линия выползет из-за края.
Улика. Верхняя граница окна физически существовала, но находилась за пределами видимой области. То есть память под неё была, а стекла над ней не было. Дальше сработала арифметика: у контроллера в этой оси 240 ячеек, у панели видимых 172, разница составляет 68. Если бы видимая полоса была прижата к одному краю памяти, смещение равнялось бы нулю или 68. Наблюдаемое значение попадало ровно в середину — 34, то есть половина от 68. Панель отцентрована внутри GRAM.
Корневая причина. GRAM у контроллера больше видимой области. Команды CASET и RASET задают окно в координатах памяти, а не в координатах стекла, и контроллер не имеет ни малейшего понятия о том, какая часть его памяти действительно накрыта матрицей. Для нашего модуля видимая полоса шириной 172 начинается со смещения 34. В ландшафтной ориентации, которую задал MADCTL, узкая ось стала осью строк, поэтому смещение попадает в аргументы RASET, а по столбцам смещения нет.
Исправление. Две константы и четыре сложения. В коде объявлено X_OFF 0 и Y_OFF 34, а функция lcd_window прибавляет их к обеим границам окна: xs = x0 + X_OFF, xe = x1 + X_OFF, ys = y0 + Y_OFF, ye = y1 + Y_OFF. Прибавлять надо именно к обеим границам — прибавив только к началу, вы получите окно неправильного размера и «сжатую» картинку, что маскируется под совсем другую ошибку. После этого всё приложение живёт в удобных логических координатах 320×172 и о существовании смещения не подозревает.

Правило. Видимое стекло — подмножество памяти контроллера. Окно задаётся в координатах GRAM, поэтому смещение прибавляется к обеим границам окна, а не к одной.
34.5. Ошибка LCD-1 и LCD-2: призраки и двоение
Что видели. Симптом был пёстрый и оттого особенно неприятный. После смены ориентации на экране жили остатки прошлой картинки: горизонтальные полосы, обрубки старого шрифта, местами второй, полупрозрачный по ощущению циферблат. Новое изображение рисовалось поверх старого, не затирая фон. Отдельно наблюдалось второе: когда мы заливали экран построчно, закрашивалась только верхняя полоса высотой примерно в одну строку, а всё остальное оставалось таким, каким было.
Что казалось логичным. Для призраков гипотеза была такая: мы переключили MADCTL, контроллер должен сам перестроить свой адресный указатель, значит достаточно начать новую запись — и всё перерисуется. Для построчной заливки гипотеза была ещё естественнее и звучала как хорошая инженерная практика: между строками надо аккуратно завершать транзакцию, сбрасывать CONTROL в ноль, чтобы не оставлять движок в неопределённом состоянии. Обе гипотезы исходили из молчаливого предположения, что запись в GRAM — это набор независимых операций.
Как проверяли. Сначала развели два симптома. Заменили построчную заливку на одну непрерывную: собрали весь кадр в буфер на 110 080 байт и вытолкнули его единым потоком, без единого касания регистра CONTROL в середине. Экран закрасился полностью. Затем вернули построчный вариант — снова закрасилась только первая полоса. Это надёжно локализовало второй симптом в границах транзакции, а не в данных. Дальше посмотрели анализатором на CS: при построчной заливке линия исправно поднималась между строками, потому что запись CONTROL=0 и опустевшее передающее FIFO завершают посылку. Наконец, для призраков сделали самый примитивный тест: две полные заливки подряд, сначала 0x0000, потом 0x1082. Призраки исчезли и больше не возвращались.
Улика. Их две, и они относятся к разным причинам. Первая: подъём CS между строками совпадал с границей закрашенной области — контроллер прекращал приём пикселей ровно там, где заканчивалась первая непрерывная посылка. Вторая: полная заливка убирала призраков, а смена MADCTL без заливки — нет.
Корневая причина, часть первая. GRAM — это память, и она не самоочищается. Команда MADCTL не стирает ни байта: она меняет только правило, по которому координаты превращаются в адреса ячеек. Старые пиксели остаются на своих местах, но читаются в другом порядке, поэтому вылезают на стекло перемешанными, повёрнутыми, обрезанными — это и есть «двойной циферблат». Пока мы рисуем только плитку с цифрами, остальное поле экрана продолжает показывать наследство предыдущей ориентации.
Корневая причина, часть вторая. Сигнал выбора кристалла у нашего движка жёстко связан с наличием данных в передающем FIFO — это ограничение F-1 из контракта. CS опускается на старте и поднимается, когда FIFO опустело. Для ST7789 подъём CS посреди потока пикселей означает конец записи в GRAM: следующая порция байт уже не продолжает кадр. По наблюдаемому поведению нашего модуля граница транзакции между командным байтом 0x2C и последующим потоком пикселей переживается нормально — контроллер помнит текущую команду. А вот разрыв внутри потока пикселей губителен, и в кадр попадает только то, что успело уехать до первого подъёма CS.
Исправление. Оба лечения записаны прямо в коде. После инициализации выполняются две полные заливки подряд: сначала 0x0000, чтобы гарантированно затереть всю GRAM в новой системе координат, затем 0x1082 как рабочий фон. Функция lcd_fill формирует весь кадр целиком в оперативной памяти и отдаёт его одним вызовом stream_pio — никаких построчных циклов и никаких записей CONTROL=0 между строками. Комментарий в исходнике фиксирует историю дословно: построчная версия роняла CS между строками, поэтому в GRAM оставалась только первая строка, а остальное выглядело как двоение поверх старых данных.

Правило. Смена ориентации — это смена системы координат поверх старых байт, поэтому после неё обязательна полная перерисовка. И один
RAMWR— это одна непрерывная посылка: разрыв внутри потока пикселей заканчивает кадр.
Здесь же прячется тизер к следующей части. Держать CS опущенным на протяжении 110 080 байт при FIFO глубиной восемь слов физически невозможно: процессор под Linux не успеет подкладывать данные, FIFO опустеет, CS поднимется. Именно из этого противоречия выросли потоковый режим STREAM_EN, глубокое FIFO и передача через AXI DMA — вся часть IX.
Глава 35. spi-clock: /dev/mem, unbind и перерисовка плитками
35.1. Что такое /dev/mem и почему мы начали с него
Под Linux пользовательская программа не видит физических адресов. Она работает в виртуальном адресном пространстве, а блок управления памятью процессора переводит её адреса в настоящие. Обратиться напрямую по адресу 0x40000000 из обычной программы нельзя — там для неё просто ничего нет.
Файл /dev/mem — это лазейка, специально оставленная в ядре. Он выглядит как обычный файл, но содержимым его является вся физическая память машины: смещение в файле равно физическому адресу. Если открыть этот файл и вызвать mmap, попросив отобразить кусок начиная со смещения 0x40000000, ядро настроит таблицы страниц так, что обращения к полученному указателю будут попадать прямо на регистры нашего SPI-контроллера. Именно это делает функция map_phys в spi-clock.c: открывает /dev/mem с флагами O_RDWR | O_SYNC и отображает 64 килобайта. Флаг O_SYNC здесь не про запись на диск: он просит некэшируемое отображение, без него процессор мог бы придержать запись в кэше, и регистр получил бы её с непредсказуемой задержкой или не в том порядке.
Почему мы пошли этим путём, а не написали честный драйвер кадрового буфера (fbdev) или графический драйвер DRM, — вопрос архитектурный, и ответ у него двойной. Первая причина педагогическая и она же главная для этой статьи: цель — увидеть контракт регистров своими глазами. В программе на 600 строк видно всё: какой бит в какой регистр пишется, в каком порядке, с какими паузами. Драйвер DRM спрятал бы ровно то, ради чего проект затевался; читатель, открывший spi-clock.c, может сопоставить каждую строку с таблицей из ../register_map.md, а в драйвере ему пришлось бы продираться через слой абстракций подсистемы.
Вторая причина практическая: скорость итерации. Каждый эксперимент с ориентацией, смещением или порядком байт — это правка одной константы, пересборка за секунду и запуск. В ядре тот же цикл означал бы пересборку модуля, перезапись SD-карты, перезагрузку платы и чтение dmesg вместо printf. При шести расследованиях этой части разница между секундой и парой минут на итерацию превращается в разницу между вечером и неделей. Чем мы за это платим — в разделе 35.5.
35.2. Карта, которую открывает программа
Ресурс | Физический адрес | Зачем |
|---|---|---|
SPI |
|
|
GPIO |
| три бита DC, RST, BL |
AXI DMA Lite |
| канал MM2S, используется в части IX |
Буфер DMA |
| зарезервированная память, 1 МиБ, |
Первые два отображения программа делает всегда, вторые два — только при запуске с ключом --dma. Числа не выдуманы: они заданы в скрипте сборки блок-дизайна, продублированы в ../hw_sw_contract.md и описаны в Device Tree (linux/dts/zynq-rk7020-fpga-spi.dtsi), где буфер под DMA объявлен узлом reserved-memory с атрибутом no-map, чтобы ядро не считало эту область своей.
Сразу после отображения программа делает то, что должен делать любой софт, работающий с программируемой логикой: проверяет, тот ли битстрим загружен. Она читает ID_VERSION по смещению 0x3C и убеждается, что старшая половина слова равна 0x5350. Для потоковой сборки полное значение — 0x53500200; версия без потокового режима вернула бы 0x53500100, а собранный до появления регистра битстрим — ноль. Ту же проверку можно сделать руками из консоли:
devmem 0x4000003C 32 # ожидание для v2: 0x53500200Это дешёвая страховка от самой обидной категории ошибок: программа корректна, а в логике живёт вчерашний битстрим.

35.3. Ошибка: часы идут несколько кадров и замирают
Что видели. Программа стартовала нормально, печатала свой идентификатор, проводила инициализацию, заливала фон. Часы начинали идти — и через несколько секунд картинка застывала. Иногда вместо застывания ломалась сама инициализация: панель не отвечала на команды, хотя пять минут назад тот же бинарник на той же плате отработал безупречно. Никакой закономерности по времени не было, воспроизводилось «примерно каждый третий запуск». В dmesg при этом обнаруживалась активность драйвера spi-zynq-fpga.
Что казалось логичным. Рассуждение звучало так: мы пишем в /dev/mem, это прямое обращение к железу, ядро в этом не участвует и мешать не может. Значит, зависание — наша собственная гонка: где-то не дождались флага DONE, где-то не хватило паузы, где-то переполнилось FIFO. Мы добавляли задержки, усиливали проверки статуса, увеличивали таймауты в циклах ожидания. Симптом слегка менялся, но не уходил — классический признак того, что лечится следствие.
Как проверяли. Переломный момент наступил, когда мы посмотрели не на свой код, а на систему вокруг. Каталог /sys/bus/platform/drivers/spi-zynq-fpga/ содержал символическую ссылку 40000000.spi — это стандартный способ Linux показать, что драйвер привязан к устройству и считает его своим. Заглянув в Device Tree, мы нашли и вторую половину картины: у узла spi@40000000 объявлен дочерний узел spidev@0, то есть ядро регистрирует на нашей шине устройство и готово в любой момент провести с ним обмен. Дальше стало интересно: мы запустили devmem параллельно работе приложения и увидели, как sticky-биты в STATUS меняются без всякого участия нашей программы.
Улика. Изменение состояния регистров SPI в моменты, когда наша программа к ним не обращалась. Владелец у железа оказался не один.
Корневая причина. Платформенный драйвер spi-zynq-fpga, написанный в части VII, владеет тем же самым окном MMIO. Он настраивает делитель под свою частоту, пишет в CONTROL, обрабатывает прерывания и квитирует их записью в ERROR_CLR. И вот здесь безобидное сосуществование становится разрушительным: запись в ERROR_CLR, согласно ограничению F-2 из контракта, флашит оба FIFO. Если это происходит в середине нашего кадра, из передающего FIFO бесследно исчезают ещё не отправленные пиксели, FIFO пустеет, CS поднимается, и запись в GRAM обрывается — со всеми последствиями из раздела 34.5. Аналогично ведёт себя чтение RX_DATA: у него есть побочный эффект, каждое чтение извлекает слово из очереди. Два владельца одного устройства с деструктивными побочными эффектами — это не «иногда мешают друг другу», это гарантированная порча состояния, вопрос только во времени.
Исправление. Функция unbind_spi_driver вызывается первой строкой main, до всяких отображений памяти. Она проверяет, привязан ли драйвер (по наличию 40000000.spi или spi@40000000 в каталоге драйвера), и если да — пишет строку 40000000.spi в файл /sys/bus/platform/drivers/spi-zynq-fpga/unbind. Это штатный механизм Linux: запись имени устройства в unbind отвязывает от него драйвер, драйвер освобождает ресурсы, устройство остаётся в системе без хозяина. Вернуть драйвер обратно можно записью того же имени в соседний файл bind или просто перезагрузкой.
Правило. У блока MMIO должен быть ровно один владелец. Либо драйвер ядра, либо программа через
/dev/mem— никогда оба сразу, особенно если у регистров есть побочные эффекты вроде сброса очередей.
О чём это говорит с точки зрения дизайна системы — тема неприятная, но полезная. Необходимость отвязывать драйвер означает, что у нашего IP нет механизма арбитража: ни семафора, ни регистра «занято», ни разделения регистров на «системные» и «прикладные». В нормальной системе такого выбора не стоит, потому что доступ к железу монополизирует драйвер, а приложения разговаривают с ним через файл устройства. Мы же построили конструкцию, где два равноправных клиента дерутся за одну апертуру, и разрешили конфликт административно — выключением одного из них. Для лаборатории допустимо, для изделия нет; куда двигаться дальше, обсуждается в части X.
35.4. Почему перерисовывается плитка, а не весь кадр
Полный кадр в логическом разрешении 320×172 по два байта на пиксель — это 55 040 пикселей и 110 080 байт, чуть меньше 108 килобайт. Плитка с часами гораздо скромнее: строка HH:MM:SS.mmm — двенадцать знакомест шириной 5 SCALE + 2 = 17 пикселей при SCALE = 3, то есть 204 пикселя в ширину и 7 SCALE + 4 = 25 в высоту. Это 5100 пикселей и 10 200 байт — почти в одиннадцать раз меньше полного кадра.
Разница напрямую переходит в частоту обновления. При потоковой передаче с FAST_DIV = 1 полупериод SCLK равен двум тактам, то есть частота обмена составляет четверть тактовой; если FCLK_CLK0 равна 100 МГц, это 25 МГц, и один байт занимает примерно 320 наносекунд. Полный кадр в этих условиях требует около 35 миллисекунд — меньше 30 обновлений в секунду. Плитка требует около трёх миллисекунд, то есть теоретический потолок поднимается примерно до трёх сотен обновлений в секунду. Для часов с миллисекундами это принципиально: показывать каждое значение поля .mmm не выйдет в любом случае, но при полном кадре миллисекунды превратились бы в неразличимое мельтешение через раз, а при плитке они хотя бы бегут осмысленно. Цикл в main это учитывает: он перерисовывает экран только когда отформатированная строка отличается от предыдущей, а между проверками спит по 0,2 миллисекунды, чтобы не сжигать процессор в холостом опросе.
Есть и вторая, менее очевидная причина, диагностическая. Сплошная заливка — милосердный тест: все байты в ней одинаковы, поэтому потеря нескольких штук незаметна глазу. Мелкий шрифт беспощаден: пропажа одного байта сдвигает всю оставшуюся часть плитки на половину пикселя, и цифры становятся рваными. Поэтому у двух режимов отрисовки разная специализация: полноэкранная заливка выявляет грубые проблемы — ранний подъём CS, неверный MADCTL, забытое смещение, отсутствие режима «только передача», — а плитка с текстом выявляет тонкие, вроде потери байт в FIFO и ошибок инициализации DMA. В коде это осознанное решение: lcd_fill намеренно идёт через обычный PIO даже в режиме --dma, потому что первый после программирования DMA-обмен легче отследить на тексте, чем на однотонном поле.
Практический вывод для лаборатории: если заливка цветом идеальна, а цифры выглядят сплющенными или наклонными, смотрите не на окно дисплея и не на MADCTL — они уже доказали свою правильность заливкой. Смотрите на путь данных, то есть на DMA и FIFO, и открывайте часть IX.
35.5. Чем мы платим за userspace
Мы платим отсутствием арбитража — целиком раздел 35.3. Мы платим требованием прав суперпользователя: /dev/mem открывается только root, и это правильно, потому что программа с таким доступом может писать в любую физическую память, включая ядро. Мы платим отказом от кэш-когерентности: буфер под DMA приходится объявлять зарезервированным с атрибутом no-map, отображать с O_SYNC и на всякий случай ещё и явно сбрасывать кэш вызовом __ARM_NR_cacheflush, что видно в функции dma_buf_flush; драйвер получил бы всё это бесплатно через DMA API.
Мы платим отсутствием композиции: дисплей не становится /dev/fb0, и никакая другая программа на нём ничего не нарисует — ни консоли, ни логотипа при загрузке. Мы платим отсутствием управления питанием и восстановления после ошибок: в ядре есть понятие «драйвер обнаружил сбой и переинициализировал устройство», у нас есть только fprintf в поток ошибок. И наконец, адреса 0x40000000 и 0x40010000 зашиты в исходник константами: драйвер взял бы их из Device Tree, а наша программа при переезде адресов молча уйдёт писать в пустоту.
Всё это — сознательный обмен: мы отдали свойства продукта, чтобы купить скорость обучения и прозрачность. Пока цель состоит в том, чтобы понять контракт железа, обмен выгодный. Как только цель сменится на «изделие», путь известен: драйвер кадрового буфера или tinydrm поверх нашего же контроллера, с единственным владельцем регистров и адресами из Device Tree.
35.6. Мини-чеклист перед частью IX
ID_VERSIONпо адресу0x4000003Cчитается как0x53500200Подсветка и сброс управляются из GPIO
0x40010000, запись — полным словомMADCTL = 0x60,Y_OFF = 34, логическое разрешение 320×172После смены ориентации выполнен полный wipe двумя заливками
Драйвер
spi-zynq-fpgaотвязан от40000000.spiРежим
TX_ONLYвключён — иначе MISO, подтянутый в единицу, переполнит приёмОдин непрерывный
RAMWRна кадр, без записейCONTROL = 0между строками
Когда этот список зелёный, а полноэкранная заливка всё равно рвётся, вы дошли до границы возможностей классического PIO. Дальше начинается арифметика, которую не обойти уговорами: FIFO глубиной восемь слов не может прокормить кадр в 110 080 байт, сколько бы приоритета вы ни выдали процессу. Об этом — глава 36 в части IX.
Что читать рядом: каталог дефектов LCD-1 … LCD-4 — в приложениях; описание STREAM_CTRL — в ../register_map.md; ограничения F-1 … F-3, из которых растут почти все ошибки этой части, — в ../hw_sw_contract.md; исходные постановки задач — в спецификациях ST7789 на PL SPI и Linux-часов.
Часть IX. Stream и AXI DMA
Здесь максимум ловушек: от FIFO=8 до «fix в git, а на плате старый кремний» из-за устаревшего файла-заготовки синтеза. Каждая история этой части — реальный час, а иногда и день отладки. Читайте медленно: именно эта часть отделяет «собрал bit» от «вижу кадр».
К началу главы 36 у нас на руках следующее. В программируемой логике Zynq живёт наш SPI-мастер, перенесённый с Cyclone IV почти без изменений (часть I). Он обёрнут в шину AXI4-Lite и виден процессору по адресу 0x40000000 (часть III и часть VI). Плата грузит Linux из Buildroot (часть VII). К SPI подключён дисплей ST7789 — логически 320×172 точки в ландшафтной ориентации, MADCTL = 0x60, смещение строк Y_OFF = 34 (часть VIII). Часы рисует userspace- программа spi-clock, которая отображает регистры через /dev/mem.
И всё это упирается в одну стену: полноэкранный кадр нарисовать нельзя. Не «медленно», не «с артефактами», а физически нельзя — по контракту железа. Глава 36 объясняет, почему; главы 37–39 строят обход; главы 40–43 — список того, что мы сломали по дороге.
Чтобы читать дальше, вам не нужно ничего знать про DMA, потоковые шины и кэш процессора. Все эти слова объясняются по ходу, при первом появлении, простыми словами и с аналогией. Если какое-то определение покажется слишком подробным — пропускайте абзац; если наоборот, загляните в глоссарий в приложениях.
Глава 36. Почему FIFO=8 и native CS ломают fullscreen
36.1. Сначала арифметика, потом гипотезы
Кадр нашего дисплея — это 320 × 172 точек, и каждая точка в формате RGB565 занимает два байта. Перемножаем: 320 × 172 × 2 = 110 080 байт. Это примерно 107.5 KiB, или «сто десять килобайт» в разговорном десятичном счёте. Именно столько байтов должно уехать по одной-единственной линии MOSI, чтобы экран целиком перекрасился один раз.
Теперь вторая половина арифметики: сколько времени занимает один байт. Ответ полностью определяется конечным автоматом в ../../rtl/spi_engine.v и двумя регистрами — делителем частоты и упакованными задержками DELAY_CFG. Движок для каждого слова проходит цепочку состояний: загрузка слова, генерация фронтов SCLK, завершение слова, выдержка CS, межсловная пауза. Полупериод SCLK равен CLK_DIV + 1 тактов системной частоты, а на восьмибитное слово нужно 2 × 8 = 16 фронтов.
Подставим настройки инициализации панели из ../../userspace/spi-clock/spi-clock.c: CLK_DIV = 5 и DELAY_CFG = 0x00080808 (по 8 тактов на setup, hold и межсловную паузу). Системная частота в сборке с DMA — 100 МГц, она задана в ../../scripts/build_ps_axi.tcl как PCW_FPGA0_PERIPHERAL_FREQMHZ = 100. Тогда 16 фронтов по 6 тактов дают 96 тактов, плюс такт на загрузку, такт на завершение, девять тактов выдержки CS и девять тактов паузы — итого около 116 тактов, то есть 1.16 мкс на байт.
Умножаем на размер кадра: 110 080 × 1.16 мкс ≈ 128 мс. Это восемь кадров в секунду в идеальном мире — уже негусто, но жить можно. Проблема не в этом числе. Проблема в другом: все эти 128 миллисекунд поток байтов не должен прерываться дольше, чем на 9 микросекунд.
Откуда 9 микросекунд? Это ёмкость очереди. Классическое ядро собрано с FIFO_DEPTH = 8: восемь слов, восемь байт. Восемь байт по 1.16 мкс — это 9.28 мкс запаса. Если процессор не успел долить девятый байт за это время, очередь опустела. А что происходит при опустевшей очереди — вопрос не удачи, а контракта.
36.2. Кто на самом деле снимает CS
CS (chip select) — это линия «я говорю с тобой». Пока она прижата к нулю, дисплей считает, что идёт одна непрерывная транзакция. Как только линия поднялась в единицу, ST7789 считает команду записи в видеопамять (RAMWR, код 0x2C) законченной. Следующие байты для него — уже начало новой команды, а не продолжение кадра. Это мы подробно разбирали в главе 34.
Кто решает, когда поднять CS? Не программа. Автомат движка. Вот ровно то место в ../../rtl/spi_engine.v, где принимается решение:
// rtl/spi_engine.v, состояние S_CS_HOLD
S_CS_HOLD: begin
busy <= 1'b1;
if (delay_cnt == 16'd0) begin
if (!tx_empty) begin
// ещё есть слова в этом burst
delay_cnt <= {8'd0, delay_cfg[`SPI_DELAY_INTER]};
state <= S_INTER;
end else if (stream_en && !stream_eot) begin
// пауза DMA: держим CS, ждём новых данных
state <= S_WAIT_TX;
end else begin
// конец burst (классика) или конец потока
spi_cs_n <= {NUM_CS{1'b1}};
state <= S_DONE;
endПрочитайте среднюю ветку ещё раз, мысленно вычеркнув stream_en — именно так выглядел код до этой части статьи. Остаётся: очередь пуста → CS вверх → всё. Никакого «подожди, сейчас долью» в классическом контракте нет и не было.
Это ограничение задокументировано в ../hw_sw_contract.md под номером F-1: CS жёстко связан с burst'ом FIFO, отдельного бита «активировать CS и держать» не существует. Рядом живёт F-3: у IP нет прерывания «в TX FIFO осталось мало». То есть даже если бы вы захотели дозаливать очередь по событию — события нет, остаётся только опрос в цикле.

36.3. Ошибка DMA-1: «сейчас я оптимизирую цикл записи»
Что мы видели глазами. Программа честно проходила по всему буферу и не жаловалась. На панели верхняя часть окрашивалась правильно, а дальше начиналась каша: обрывки предыдущего изображения, полосы, иногда — сдвинутая по цвету нижняя половина. При повторном запуске картина менялась: граница «хорошего» и «плохого» гуляла вверх-вниз от запуска к запуску. Логический анализатор показывал вещь, которую невозможно списать на дисплей: линия CS за время одного «кадра» поднималась и опускалась сотни раз, а суммарная длина каждого низкого участка была микросекундной, тогда как весь кадр должен занимать десятки миллисекунд.
Какая гипотеза казалась логичной. Первая мысль всегда одна и та же: «мы не успеваем, надо ускорить программу». Она соблазнительна, потому что подтверждена опытом — граница артефактов действительно гуляет от запуска к запуску, значит дело в тайминге, значит надо убрать лишнее из цикла, поднять приоритет, попросить у ядра SCHED_FIFO, прибить процесс к ядру процессора, отключить вывод в консоль. Всё это разумные слова, и каждое из них можно потратить час на реализацию. Мы честно попробовали несколько.
Как проверяли. Спасла нас не оптимизация, а измерение. Мы взяли длительность низкого уровня CS с анализатора и сравнили с расчётной длительностью кадра. Если бы это была гонка «CPU против SPI», то при ускорении программы длина CS-low росла бы — пусть не до конца, но заметно. Она не росла. Она оставалась примерно равной времени передачи восьми байт. Затем мы сделали второй, ещё более дешёвый опыт: намеренно записали в буфер вдвое меньше данных и посмотрели, где кончится «правильная» часть картинки. Она кончилась ровно там же, где и раньше. Третий опыт — сравнение с построчной отправкой из главы 34, где CONTROL = 0 между строками ставится явно: картинка получилась точно такая же.
Улика, перевернувшая картину. Вот она, третья проверка. Если код, который специально снимает CS между строками, и код, который старается его не снимать, дают идентичный результат — значит наши старания ни на что не влияют. CS снимается не потому, что мы плохо стараемся. Он снимается потому, что так устроен автомат. Мы боролись не с производительностью, а с контрактом.
Корневая причина. spi_engine.v, состояние S_CS_HOLD: при пустом tx_empty автомат безусловно уходит в S_DONE и поднимает spi_cs_n. Глубина восемь означает, что «пусто» наступает через 9.3 мкс после последнего долива. Linux общего назначения не даёт гарантий на таком масштабе: одно прерывание, один вытесняющий процесс, одна страница памяти, которую надо подкачать, — и пауза выходит за окно. Дело не в том, что «Linux медленный»: дело в том, что мы построили систему, требующую жёсткого реального времени с окном 9 мкс, и не имели права этого делать.
Исправление. Двухчастное, и обе части нужны. Первая — новый режим в самом движке: бит STREAM_EN заставляет автомат при пустой очереди уходить не в S_DONE, а в новое состояние S_WAIT_TX, где CS остаётся прижатым, а движок просто ждёт данных или явного признака конца потока. Опустевшая очередь перестаёт быть аварией и становится обычной паузой. Вторая часть — углубление очереди с 8 до 1024 слов, чтобы пауз было меньше и они были короче. Обе части подробно разобраны в главах 37 и 38.
Правило на будущее. Если протокол требует «один CS на весь буфер», это должно быть свойством железа — явный stream-режим или отдельная линия CS под управлением софта. Надежда на скорость процессора контрактом не является.
36.4. Почему нельзя просто «сделать FIFO побольше» и на этом закончить
Соблазн номер два: раз не хватает восьми байт, поставим тысячу. Посчитаем, что это даёт. Тысяча двадцать четыре байта по 1.16 мкс — это 1.19 мс запаса вместо 9.3 мкс. Улучшение в 128 раз, и Linux почти наверняка уложится. Но слово «почти» здесь стоит дорого: при опустошении очереди CS всё равно поднимется, и кадр всё равно порвётся — просто теперь это будет случаться раз в минуту, а не постоянно. Ошибка станет плавающей, а плавающие ошибки дороже стабильных.
Поэтому порядок исправлений именно такой: сначала STREAM_EN, который делает опустошение безвредным, и только потом глубокий FIFO, который делает его редким. Глубокий FIFO без stream-режима — это ловушка, глубокий FIFO вместе с ним — оптимизация.
Глава 37. Архитектура: DDR → HP0 → axi_dma MM2S → AXIS → deep TX FIFO → stream CS_HOLD
37.1. Пять слов, которые надо понять до схемы
DMA расшифровывается как direct memory access, прямой доступ к памяти. Это отдельный блок железа, который умеет сам читать память и сам отдавать прочитанное куда-то ещё, не занимая процессор. Аналогия: у вас есть кладовщик. Обычный режим — вы сами бегаете на склад за каждой коробкой. Режим DMA — вы говорите кладовщику «возьми на полке номер такой-то сто тысяч коробок и вези их на погрузку», после чего занимаетесь своими делами, а он справляется сам. Процессор в этой схеме тратит несколько тактов на записку кладовщику и потом свободен.
Дескриптор — это и есть та самая записка: маленькая структура в памяти, описывающая «откуда брать, сколько брать, куда класть, что делать после». В сложных сценариях дескрипторы связывают в цепочку, и DMA идёт по ней сам, как курьер по маршрутному листу с несколькими адресами. В нашем проекте цепочка не нужна — есть один непрерывный буфер, — поэтому мы обошлись вообще без дескрипторов в памяти. Подробности в разделе 37.5.
AXI-Stream (в текстах — AXIS) — это шина без адресов. Обычная шина AXI похожа на почту: у каждого пакета есть адрес получателя. AXI-Stream похож на конвейерную ленту: данные просто едут от источника к приёмнику, и вопрос «по какому адресу» не имеет смысла. У ленты есть три ключевых сигнала. TVALID — «на ленте лежит настоящий груз». TREADY — «я готов его принять». Передача происходит в тот такт, когда оба сигнала подняты одновременно; это называется рукопожатием.
TLAST — четвёртый сигнал ленты и, пожалуй, самый важный для нас. Это пометка «последний вагон в составе». Данные едут порциями (их называют beat, «удар», или «такт передачи»), и на самой последней порции источник поднимает TLAST. Приёмник по этой пометке понимает, что состав закончился, и может закрывать шлагбаум. В нашем случае «закрыть шлагбаум» означает «поднять CS и завершить RAMWR».
Backpressure — обратное давление. Это способность приёмника сказать «стоп, я не успеваю» простым опусканием TREADY. Аналогия: турникет в метро. Пока турникет открыт, люди идут; как только он закрылся, очередь копится снаружи, но никто не теряется. Ключевое отличие backpressure от «просто уронить данные» в том, что при обратном давлении ничего не теряется — источник обязан ждать. Эта разница станет темой драматической главы 41: у нас был почти-backpressure, который всё-таки терял байты.
Есть и шестое понятие — кэш — но оно понадобится в разделе 37.7, там его и объясним.
37.2. Четыре варианта архитектуры и цена каждого
Прежде чем рисовать схему, честно перечислим, что мы рассматривали. Проектное решение ценно не тем, что оно хорошее, а тем, что понятно, от чего отказались.
Вариант «GPIO-CS». Не трогать движок вообще, а вывести CS отдельной ножкой и управлять ей из axi_gpio — благо блок GPIO у нас уже есть, он держит DC, RST и подсветку. Программа опускает CS, гоняет сколько угодно восьмибайтных burst'ов подряд, потом поднимает CS. Дисплей увидит одну непрерывную сессию. Схема рабочая, ядром Linux она даже рекомендована (cs-gpios в Device Tree, см. F-1 в ../hw_sw_contract.md). Цена: она не решает главную задачу. Пропускная способность остаётся прежней, процессор по-прежнему обязан вручную доливать по восемь байт сто тридцать семь тысяч раз за кадр, и по-прежнему нет события, по которому это делать (F-3). Мы получили бы целый кадр ценой ядра процессора, полностью занятого записью в один регистр. GPIO-CS — это лекарство от разрыва картинки, но не от нагрузки.
Вариант «огромный PIO-буфер». Углубить очередь до 1024 слов, добавить STREAM_EN — и кормить её по-прежнему процессором, через запись в TX_DATA. Это не гипотетический вариант: он реализован и работает прямо сейчас. Функция stream_pio() в ../../userspace/spi-clock/spi-clock.c делает ровно это, и именно ей рисуются заливки фона. Цена: одно ядро процессора занято на всё время кадра, потому что каждый байт стоит одного чтения STATUS и одной записи TX_DATA через шину. Для учебной задачи приемлемо, для «часов, которые тикают миллисекундами» — расточительно. Зато этот путь бесценен как эталон: когда DMA сломан, есть с чем сравнивать. Мы к этому вернёмся в главе 40.4.
Вариант «взять аппаратный SPI процессорной системы». У Zynq в PS есть два готовых контроллера SPI со своими FIFO. Соблазнительно: чужой отлаженный блок вместо своего. Два возражения, и оба закрывают вопрос. Первое — физическое: дисплей на этой плате разведён на выводы программируемой логики, а не на MIO процессорной системы. Посмотрите ../../constraints/ps_axi_pins.xdc: SCLK на V18, MOSI на U19, CS на AA13, линии DC/RST/подсветки на W13, AA18, Y13. Чтобы задействовать PS SPI, пришлось бы переразводить плату. Второе — смысловое: вся статья про то, как перенести и оживить своё IP. Заменить его чужим — это не решение задачи, а её отмена.
Вариант, который выбрали: DMA пишет в поток, а CS держит наше IP. Блок axi_dma читает буфер из оперативной памяти и выдаёт байты на конвейерную ленту AXI-Stream. Наше IP принимает ленту, складывает байты в глубокую очередь и удерживает CS до пометки TLAST. Процессор участвует ровно четырьмя записями в регистры на кадр. Цена: новый модуль-мост ../../rtl/spi_axis_tx.v, четыре новых регистра в обёртке, глубокая очередь (её стоимость посчитана в 37.6), блок axi_dma в дизайне и второй порт к памяти. И, как выяснилось, ещё восемь ошибок, которым посвящены главы 39–43.
Ключевая мысль, ради которой всё это перечислено: правильная граница ответственности проходит по CS. Данные умеет возить готовый чужой блок — пусть возит. А вот кто и когда поднимает CS — это семантика нашего протокола, и её нельзя отдавать наружу. Поэтому DMA ничего не знает про CS, а наше IP ничего не знает про адреса в памяти. Каждый занят своим.
37.3. Путь данных целиком

Пунктирные стрелки справа налево — это и есть backpressure. Когда очередь близка к заполнению, она сообщает об этом мосту; мост опускает TREADY; блок axi_dma перестаёт выдавать данные и приостанавливает чтение из памяти. Вся цепочка тормозится синхронно, никто ничего не теряет. По крайней мере, так это должно работать — глава 41 расскажет, как одна лишняя защёлка сломала это свойство.
Отдельно стоит проследить, что происходит с CS в новом режиме. Ниже — та часть автомата движка, ради которой всё затевалось:

Обратите внимание на две стрелки в S_WAIT_TX. Раньше их не было вовсе, и любое опустошение вело прямиком в S_DONE. Теперь единственный путь к подъёму CS лежит через stream_eot — тот самый флаг, который мост поднимает, увидев TLAST. Иными словами, право закончить кадр передано от очереди к источнику данных, и это правильно: только источник знает, где конец буфера.
37.4. Почему регистры на GP0, а данные через HP0
У процессорной системы Zynq есть несколько независимых «дверей» в программируемую логику. Мы используем две.
GP0 — general purpose, дверь общего назначения. Через неё процессор ходит в регистры периферии: короткие 32-битные обращения по конкретным адресам. Она устроена как обычная шина с адресами, оптимизирована на низкую задержку одиночного обращения и не рассчитана на поток. Через GP0 у нас идут три устройства: регистры SPI по 0x40000000, GPIO дисплея по 0x40010000 и регистры управления самим DMA по 0x40400000. Разводку этой ветки видно в ../../scripts/build_ps_axi.tcl: один axi_interconnect на три выхода.
HP0 — high performance, дверь для потока. Она ведёт напрямую к контроллеру памяти, имеет собственные буферы и рассчитана на длинные пакетные чтения. Через неё ходит только один мастер — канал MM2S нашего DMA. Название канала стоит расшифровать: MM2S это memory-map to stream, «из памяти в поток». Обратный канал называется S2MM, и его в нашем дизайне нет вообще (c_include_s2mm = 0) — читать с дисплея нечего.
Почему их не смешивать? Две причины, обе практические. Первая: пропускная способность. Кадр — это 110 080 байт, которые DMA читает пакетами по 16 слов (c_mm2s_burst_size = 16, то есть по 64 байта за пакет). Пустить эту реку через ту же дверь, куда процессор ходит за статусом, значит поставить чтение STREAM_STATUS в очередь за пакетом на 64 байта. Вторая, менее очевидная: диагностируемость. Когда регистры и данные разведены по разным путям, вопрос «поток встал или процессор не может прочитать регистр» решается одним обращением. При общей шине эти два отказа выглядят одинаково.
Формально это оформлено в конце build_ps_axi.tcl двумя разными пространствами адресов: ps7/Data (что видит процессор) и dma0/Data_MM2S (что видит DMA, ему выдан весь сегмент HP0_DDR_LOWOCM).
Ресурс | Физический адрес | Размер | Кто обращается |
|---|---|---|---|
Регистры SPI ( |
| 64 КиБ окна, 64 байта апертуры | CPU через GP0 |
GPIO дисплея (DC/RST/BL) |
| 64 КиБ | CPU через GP0 |
Регистры AXI DMA |
| 64 КиБ | CPU через GP0 |
Буфер кадра в DDR |
| 1 МиБ, | CPU (запись) и DMA (чтение через HP0) |
Апертура регистров SPI — 64 байта, шестнадцать 32-битных слов, декодируется по awaddr[5:2] (отклонение D-4 из ../register_map.md). Окно в 64 КиБ, выделенное в карте адресов, при этом просто зеркалит эти 64 байта — это нормально и никого не смущает.
37.5. Почему простой режим DMA, а не scatter-gather
У блока axi_dma два режима работы. В режиме scatter-gather вы строите в памяти цепочку дескрипторов — тех самых «маршрутных листов» — и говорите DMA только адрес начала цепочки. Дальше он сам вычитывает дескриптор, выполняет кусок передачи, переходит к следующему. Так возят сетевые пакеты, разбросанные по десяткам страниц памяти.
В простом режиме (direct register mode) дескрипторов нет вообще. Вы пишете в регистр SA физический адрес источника, в регистр LENGTH — количество байт, и передача стартует прямо от записи в LENGTH. Всё.
Мы выбрали простой режим, и в build_ps_axi.tcl это записано явно: c_include_sg = 0. Обоснование прямолинейное. Наш буфер физически непрерывен — это гарантировано узлом reserved-memory в Device Tree, — значит цепочка всегда состояла бы ровно из одного звена. Строить механизм обхода списка ради списка из одного элемента бессмысленно. Дополнительно мы избавляемся от целого класса проблем: дескрипторы сами лежат в памяти, их тоже надо согласовывать с кэшем, у SG-движка есть свой мастер на шине и свои прерывания. Каждая из этих сущностей — потенциальная глава в этой части статьи, и мы рады, что их здесь нет.
Цена решения: одна передача = один непрерывный физический буфер. Если однажды понадобится рисовать из обычной памяти пользователя, разбросанной по страницам, придётся возвращаться к scatter-gather. Мы это знаем и записали в «non-goals» спецификации ../superpowers/specs/2026-08-09-axi-dma-spi-design.md.
37.6. Почему FIFO углубили до 1024 и что это стоит
Глубина выбрана из арифметики раздела 36.1, только теперь с быстрыми настройками потока. В режиме TX_FAST с FAST_DIV = 1 полупериод SCLK равен двум тактам, то есть SCLK = 25 МГц при системных 100 МГц. Байт занимает 16 фронтов по 2 такта плюс шесть служебных тактов на загрузку, завершение, выдержку CS и межсловную паузу (DELAY_CFG = 0x00010101) — примерно 38 тактов, или 380 нс на байт. Кадр целиком: 110 080 × 380 нс ≈ 42 мс, около 24 полных кадров в секунду. Тайл с часами (204 × 25 точек, 10 200 байт) — около 3.9 мс.
Тысяча двадцать четыре байта по 380 нс дают 389 микросекунд запаса на паузу в потоке. Это на два с лишним порядка больше, чем типичная задержка арбитража на шине или пауза DMA между пакетами, и этого хватает с колоссальным запасом. Меньшая степень двойки (например 256) тоже, скорее всего, сработала бы; 1024 выбрана как «заведомо достаточно, и всё ещё дёшево».
Теперь честная цена, и здесь есть неожиданность. В заголовке ../../rtl/spi_fifo.v написано, что выход данных комбинационный:
// rtl/spi_fifo.v
assign rd_data = mem[rd_ptr[ADDR_WIDTH-1:0]];
assign count = wr_ptr - rd_ptr;
assign full = (count == DEPTH[ADDR_WIDTH:0]);Комбинационное чтение означает, что данные появляются на выходе в том же такте, в котором выставлен адрес, без промежуточного регистра. Блочная память (BRAM) семейства 7-й серии так не умеет — у неё чтение всегда регистровое. Значит синтезатор не сможет положить такую очередь в BRAM и построит её на распределённой памяти: на тех же таблицах истинности (LUT), из которых делается логика, только переключённых в режим маленького ОЗУ.
Так и вышло. Вот реальные цифры из ../../reports/ps_axi_utilization.rpt для собранного дизайна с DMA: 3319 LUT всего (6.24% кристалла), из них 1815 — логика и 1504 — память, причём 1410 LUT работают именно распределённым ОЗУ. Это 8.64% от 17400 доступных под такую роль. Регистров 2061 (1.94%). Блочной памяти в дизайне занят один тайл RAMB36 из 140 — и это не наша очередь, а внутренние буферы axi_dma. Для сравнения, отдельная сборка того же IP с мелким FIFO (../../reports/ooc_impl_utilization.rpt) занимала 405 LUT, из них 44 распределённого ОЗУ.
Есть и второй, менее приятный пункт счёта. Загляните в ../../rtl/spi_master_top.v: обе очереди, передающая и приёмная, создаются с одной и той же шириной адреса, выведенной из общего параметра:
// rtl/spi_master_top.v
localparam integer FIFO_AW = $clog2(FIFO_DEPTH);Это значит, что углубив передающую очередь до 1024, мы автоматически углубили и приёмную — которая в потоковом режиме вообще не используется, потому что TX_ONLY запрещает в неё писать. Примерно половина из 1410 LUT распределённой памяти лежит мёртвым грузом. Ошибкой это назвать нельзя (параметр общий по конструкции, и правка сломала бы байт-идентичность файла с Altera-оригиналом), но знать об этом полезно: если однажды ресурсы кончатся, первое, что стоит сделать — развести глубины TX и RX по разным параметрам.
Итоговая сборка при этом закрывается по времени с большим запасом: в ../../reports/ps_axi_timing.rpt записано WNS = 1.586 нс и WHS = 0.054 нс при периоде 10 нс, все ограничения выполнены. Глубокий FIFO на распределённой памяти частоту не убил.
37.7. Кэш, когерентность и почему буфер помечен no-map
Последнее понятие, обещанное в 37.1. Кэш — это маленькая быстрая память внутри процессора, куда он складывает копии недавно использованных данных. Аналогия: рабочий стол и архив в подвале. Вы пишете «в память», но на самом деле пишете в блокнот на столе, а в подвал запись уедет когда-нибудь потом, когда место на столе понадобится под другое.
Пока с памятью работает только процессор, это невидимо. Но у нас появился второй участник — DMA, который читает ту же память напрямую через HP0, минуя кэш процессора. Порт HP не когерентный: он не заглядывает в блокнот на столе. И если программа сформировала кадр, а данные ещё лежат в кэше, DMA прочитает из DDR старое содержимое. Это классическая проблема когерентности: два участника видят разные версии одних и тех же байт.
Есть три способа с этим жить, и мы применили два с половиной. Первый и основной: сделать буфер вообще некэшируемым. В Device Tree (../../linux/dts/zynq-rk7020-fpga-spi.dtsi) объявлен узел reserved-memory с адресом 0x1f000000, размером 1 МиБ и, главное, свойством no-map:
spi_dma_buf: memory@1f000000 {
compatible = "shared-dma-pool";
reg = <0x1f000000 0x00100000>;
no-map;
};no-map означает «ядро, не включай этот кусок в свою карту обычной памяти». Область остаётся физически существующей, но у ядра нет её кэшируемого отображения. Когда spi-clock открывает /dev/mem с флагом O_SYNC и делает mmap этого адреса, получается некэшируемое отображение: запись из программы идёт в DDR, а не на «рабочий стол».
Второй способ — явный сброс кэша перед передачей — оставлен как страховка. В spi-clock.c есть маленькая функция под ARM, дёргающая системный вызов __ARM_NR_cacheflush; комментарий над ней прямо говорит «belt-and-suspenders», то есть «ремень и подтяжки». Она не нужна при правильном no-map, но стоит копейки и однажды спасёт, если кто-то соберёт образ без нужного узла в Device Tree.
Третий способ — использовать когерентный порт ACP вместо HP — мы не применяли. Он проще для софта, но нагружает кэш второго уровня трафиком кадра и на Zynq имеет свои ограничения по пропускной способности. Для потока «только запись, ни одного повторного чтения» кэш бесполезен по определению — кэшировать данные, которые прочтут ровно один раз, незачем.
Глава 38. Новые регистры STREAM_CTRL / STREAM_STATUS / FAST_DIV / ID v2.0
38.1. Почему они живут в обёртке, а не в ядре
Все четыре новых регистра добавлены в ../../rtl/spi_axi4lite.v — в AXI-обёртку, — а не в spi_reg_if.v, где живут CONTROL, STATUS и остальные десять. Причина не техническая, а методологическая, и она проходит через всю статью: spi_reg_if.v байт-идентичен Altera-оригиналу, и это факт, которым мы доказываем корректность переноса. Как только мы допишем в него хоть одну строку, аргумент «ядро не менялось, значит вести себя обязано так же» рассыпается, и придётся заново обосновывать поведение всей регистровой части.
Обёртка же — новый код, написанный специально для Zynq. Дописать в неё регистры ничего не ломает. Технически это выглядит как перехват четырёх адресов до того, как обращение уйдёт в ядро:
// rtl/spi_axi4lite.v
localparam [3:0] ADDR_STREAM_CTRL = 4'hC, // 0x30
ADDR_STREAM_STATUS = 4'hD, // 0x34
ADDR_FAST_DIV = 4'hE, // 0x38
ADDR_ID = 4'hF; // 0x3CИндексы 0xC…0xF в ядре не заняты — spi_reg_if возвращает по ним ноль и игнорирует запись. Поэтому ничего, что работало раньше, изменения не заметит. Побочный, но важный вывод: PIO-сборка без DMA вернёт по этим адресам нули, и это нормальный способ отличить одну прошивку от другой.
38.2. STREAM_CTRL, смещение 0x30
Бит | Имя | Семантика |
|---|---|---|
0 | STREAM_EN | Держать CS при пустой очереди TX до прихода EOT. Также разрешает |
1 | TX_ONLY | Не писать принятые слова в RX FIFO (глава 39) |
2 | TX_FAST | Брать делитель из |
8 | CLR_EOT | Импульс: сбросить залипающий флаг EOT в мосте AXIS |
Первый бит делает две вещи сразу, и об этом легко забыть. Помимо удержания CS он управляет готовностью потоковой шины — посмотрите на строку из ../../rtl/spi_axis_tx.v:
// rtl/spi_axis_tx.v
assign s_axis_tready = enable && (state == ST_IDLE) && !tx_full;enable здесь — это и есть STREAM_EN. Пока он в нуле, TREADY не поднимется никогда, и любой запущенный DMA-перенос повиснет намертво до таймаута. Это не дефект, а осознанный предохранитель: он даёт программе способ гарантированно «заткнуть» ленту перед тем, как трогать DMA. Мы будем пользоваться этим свойством в главе 40.2 — и именно из-за его незнания получим одну из самых зрелищных ошибок.
Бит CLR_EOT работает как строб, а не как уровень: обёртка ловит запись единицы и выдаёт одиночный импульс clear_eot_pulse в мост. Записывать туда ноль не нужно, он самоочищается.
38.3. STREAM_STATUS (0x34), FAST_DIV (0x38) и ID_VERSION (0x3C)
Регистр | Биты | Смысл |
|---|---|---|
STREAM_STATUS | [0] BUSY | Движок занят ( |
STREAM_STATUS | [1] EOT | Мост принял порцию с |
FAST_DIV | [15:0] | Полупериод SCLK = |
ID_VERSION | [31:16] / [15:0] |
|
Признак конца кадра — это именно комбинация двух битов, а не один из них. BUSY = 0 в одиночку означает «движок ничего не делает», что верно и до старта. EOT = 1 в одиночку означает «источник сказал, что состав кончился», но последние байты могут ещё лежать в очереди и уходить на MOSI. Кадр закончен тогда и только тогда, когда BUSY = 0 и EOT = 1 одновременно. Именно так написана функция ожидания в spi-clock.c, и именно так проверяет тест ../../sim/tb_spi_stream.v.
38.4. Почему FAST_DIV — отдельный регистр, а не «разрешите писать 1 в CLK_DIV»
Здесь спрятан хороший урок про то, как оформлять исключения из правил.
В ../register_map.md записан унаследованный дефект A-3: при CLK_DIV = 1 приём портится — принятое слово оказывается сдвинуто на бит. Причина в синхронизаторе входа MISO: он делает внешний уровень видимым только через три такта, а мастер при таком делителе сэмплирует раньше. Отсюда жёсткое требование CLK_DIV >= 2 и максимальная рабочая частота f_clk / 6.
Но обратите внимание, что это ограничение на приём. В потоковом режиме мы не принимаем ничего: MISO у дисплея не подключён и в блок-дизайне подтянут к единице константой (miso_tie в build_ps_axi.tcl), а бит TX_ONLY вообще запрещает складывать принятое в очередь. Значит на передачу ограничение A-3 не распространяется, и делитель можно опустить.
Дальше — развилка. Можно было просто разрешить запись единицы в CLK_DIV. И это было бы плохо: тогда любая программа, работающая в обычном режиме, смогла бы молча выставить настройку, при которой приём даёт мусор, а никакого флага об этом нет. Мы выбрали второй путь: отдельный регистр FAST_DIV плюс отдельный бит TX_FAST, который его включает. Чтобы получить небезопасный делитель, нужно сделать два независимых действия и оба назвать своими именами. Обычный режим остаётся защищённым, а исключение видно в коде глазами.
В сборке проекта используется FAST_DIV = 1, то есть полупериод два такта и SCLK 25 МГц при системных 100 МГц. Значение 0 регистр тоже примет (в обёртке нет защиты, аналогичной A-4 для CLK_DIV) и даст SCLK 50 МГц — но смысла в этом мало. Разберём почему: при FAST_DIV = 1 из 38 тактов на байт полезными тактами SCLK заняты 32, то есть КПД 84%. При FAST_DIV = 0 байт займёт 22 такта, из которых полезны 16 — КПД падает до 73%, а служебные шесть тактов на слово никуда не денутся. Удвоение частоты даёт прирост не в два раза, а в 1.7, зато требования к целостности сигнала на шлейфе дисплея растут по-честному вдвое. Мы остановились на 25 МГц.
38.5. Зачем вообще версионировать IP
Регистр ID_VERSION по смещению 0x3C возвращает константу, зашитую параметрами обёртки:
// rtl/spi_axi4lite.v
parameter [15:0] IP_ID = 16'h5350, // "SP"
parameter [15:0] IP_VERSION = 16'h0200 // v2.0: DMA/streamФормально он нужен драйверу: в probe() драйвер читает эти четыре байта, и если старшая половина не 0x5350 — отказывается привязываться с -ENODEV, потому что по этому адресу либо не наш IP, либо вообще ничего (подробности в разделе 9 файла ../hw_sw_contract.md). Битстримы, собранные до появления регистра, вернут ноль, и это тоже трактуется как «несовместимое железо».
Практически же это первый инструмент отладки в лаборатории. Одна команда отвечает на вопрос, который иначе стоит получаса гаданий:
devmem 0x4000003C 32 # ожидаем 0x53500200Увидели 0x53500100 или ноль — вы гоняете не ту прошивку, и дальше отлаживать софт бессмысленно. Увидели 0x53500200 — железо как минимум того поколения, которое вы ожидаете.
И сразу — честная граница применимости, которая нам ещё аукнется в главе 42. Версия меняется только тогда, когда её меняет человек. Она отвечает на вопрос «то ли это поколение прошивки», но не отвечает на вопрос «собрана ли эта прошивка из сегодняшних исходников». В главе 42 мы попадём ровно в эту щель: ID честно показывал 0x53500200, а внутри работал RTL недельной давности.
38.6. Правильный порядок запуска потокового кадра
Порядок здесь не «рекомендация», а требование: почти каждый шаг обязан предшествовать следующему по причине, записанной в RTL. Последовательность собрана из функций stream_arm(), stream_start() и dma_send() в ../../userspace/spi-clock/spi-clock.c.
ERROR_CLR = 0xFFFFFFFF— снять залипшие ошибки и флашнуть обе очереди, чтобы в потоке не оказалось хвоста от прошлого кадра.STREAM_CTRL = CLR_EOT— погасить флаг конца потока от прошлого кадра. Если этого не сделать, движок увидит «поток уже закончен» на старте.STREAM_CTRL = STREAM_EN | TX_ONLY | TX_FAST— включить режим. Именно сейчас, доSTART, потому что приSTREAM_EN = 0старт с пустой очередью поднимет ошибкуERR_START, аTREADYостанется в нуле.FAST_DIV = 1,WORD_LEN = 8,CS_SELECT = 0,DELAY_CFG = 0x00010101— параметры передачи.CONTROL = EN | CPOL | CPHA— включить контроллер, но пока не стартовать.CONTROL |= START— CS уходит вниз, движок проходитS_CS_SETUPи встаёт вS_WAIT_TX, терпеливо ожидая данных с удержанным CS.DMASR = 0xFFFFFFFF,DMACR = RS,SA = 0x1f000000,LENGTH = n— запись вLENGTHи есть команда «поехали» для канала MM2S.Дождаться бита IOC в
DMASR(перенос завершён), затем вSTREAM_STATUSдождатьсяBUSY = 0 && EOT = 1(последний байт ушёл на MOSI).STREAM_CTRL = CLR_EOT, затемCONTROL = 0— закрыть сессию.
Между шагами 6 и 7 CS уже прижат, а тактов ещё нет: движок стоит в S_WAIT_TX. Для ST7789 это безобидно — он ждёт фронтов SCLK и молчит. Зато это даёт нам запас: DMA можно программировать не торопясь, окно не «уедет».
Глава 39. TX_ONLY: без него RX FIFO убивает длинный RAMWR
Что мы видели глазами. Инициализация панели проходила безупречно: подсветка загоралась, SWRESET, SLPOUT, настройки гаммы и питания — все два десятка коротких команд отрабатывали, панель просыпалась. Заливка небольшого окна тоже работала. А вот полноэкранная запись обрывалась: экран заполнялся частично, дальше шёл мусор, и в регистре IRQ_STATUS обнаруживался поднятый бит ERROR. Что особенно сбивало с толку — момент обрыва был подозрительно стабильным, не плавал от запуска к запуску, как это бывает у гонок.
Какая гипотеза казалась логичной. «Мы же только передаём. Приёмный тракт нас не касается — мы никогда не читаем RX_DATA, значит его как бы нет». Гипотеза соблазнительна потому, что она верна на уровне программы: в коде spi-clock слово «RX» действительно не встречается ни разу. Ошибка в том, что железо не знает о ваших намерениях. Оно делает то, что написано в его автомате, а не то, что вы собирались делать с результатом.
Как проверяли. Первым делом сравнили короткую и длинную запись. Команда с четырьмя байтами параметров проходила всегда; запись окна на несколько тысяч пикселей — иногда; полный кадр — никогда. Это уже подсказка: отказ зависит не от «что», а от «сколько». Затем посчитали, где именно граница, и цифра сошлась с глубиной очереди. Наконец, прочитали автомат движка и нашли виновника в явном виде — состояние S_XFER_END:
// rtl/spi_engine.v, состояние S_XFER_END
// Длинные DMA-кадры немедленно переполнили бы RX FIFO;
// в режиме TX-only принятое слово выбрасывается (MISO у LCD не нужен).
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Улика, перевернувшая картину. Движок кладёт принятое слово в приёмную очередь после каждого переданного слова. Не «если вы попросили», а всегда. SPI по своей природе дуплексный: пока один бит уезжает по MOSI, другой приезжает по MISO, и разделить эти два процесса невозможно — они происходят на одних и тех же фронтах. А MISO у нас, напомним, подтянут к постоянной единице в блок-дизайне, потому что у панели нет обратной линии. Значит на каждый отправленный байт кадра в приёмную очередь ложится 0xFF. Очередь глубиной 1024 переполняется на 1025-м байте кадра — из ста десяти тысяч.
Корневая причина. Дальше начинается цепная реакция, и она хуже, чем просто переполнение. Когда rx_full уже поднят, движок выставляет залипающий err_rx_overflow. Этот флаг, минуя регистр STATUS (в нём соответствующий бит 9 мёртв — дефект A-5), попадает в IRQ_STATUS как общий бит ERROR. И тут срабатывает выработанный рефлекс: «увидел ошибку — почисти её через ERROR_CLR». Смотрим, что делает эта запись, в ../../rtl/spi_master_top.v:
// rtl/spi_master_top.v
// Обе очереди флашатся по запросу очистки ошибок или по мягкому сбросу.
assign fifo_flush = clr_errors | ctrl_soft_rst;Одна запись в ERROR_CLR уничтожает содержимое обеих очередей, включая передающую. То есть попытка убрать сообщение об ошибке в середине кадра выбрасывает несколько сотен ещё не отправленных байт. Это ограничение F-2 из ../hw_sw_contract.md, и здесь оно проявилось в самой неприятной форме: диагностика ломает то, что диагностирует.
Исправление. Бит TX_ONLY в STREAM_CTRL. Он не «отключает приём» — фронты SCLK по-прежнему сэмплируют MISO, регистр rx_word по-прежнему набирается, — он запрещает запись результата в очередь. Переполняться становится нечему, ERROR не поднимается, рефлекс «почистить» не срабатывает, передача не рвётся. В spi-clock этот бит всегда идёт в связке: STREAM_EN | TX_ONLY | TX_FAST.
Заметьте изящество решения: одна строчка if (!tx_only) в состоянии S_XFER_END, никаких изменений в тракте данных, никакого влияния на классический дуплексный режим при нулевом бите.
Правило на будущее. В симплексной потоковой передаче TX-only должен быть явным битом в железе. Фраза «мы не читаем принятые данные» — это про намерения программы; железо продолжает их принимать и складывать, пока ему буквально не сказать «не складывай».
Глава 40. Программные грабли spi-clock
Четыре независимые ошибки, все — в трёхстах строках пользовательской программы, все — найдены после того, как RTL уже считался готовым. Общая черта: PIO-путь при этом работал. Это редкая удача для отладки, и мы советуем сохранять работающий медленный путь до самого конца — он играет роль эталона, с которым сравнивают сломанный быстрый.
40.1. Построчная отправка и падение CS между строками (LCD-2)
Что мы видели глазами. Изображение двоилось. Не «шумело» и не «сдвигалось», а именно двоилось: поверх свежих цифр просвечивали остатки предыдущего кадра, а в верхней части экрана лежала одна корректная полоса. При заливке однородным цветом верхняя полоса красилась правильно, ниже оставался прежний фон. Ширина правильной полосы соответствовала одной строке буфера.
Какая гипотеза казалась логичной. Мы писали код так, как пишут работу с фреймбуфером: цикл по строкам, в каждой итерации отправляем строку пикселей. Между строками — сброс CONTROL в ноль, «чтобы аккуратно закрыть транзакцию». Это выглядит как хороший стиль: не оставлять контроллер включённым, каждая операция самодостаточна, легко отлаживать построчно. Именно эта аккуратность и убивала кадр.
Как проверяли. Сравнили два варианта в лоб: цикл по строкам против одного вызова stream_pio() на весь буфер целиком. Второй вариант нарисовал кадр правильно с первой попытки. Затем посмотрели анализатором и убедились, что при построчном варианте CS честно поднимается между строками — ровно столько раз, сколько строк.
Улика. Правильной оставалась ровно первая строка. Не половина, не случайная часть — первая. Значит дисплей принимал начало записи и переставал её принимать сразу после первого разрыва.
Корневая причина. Для ST7789 подъём CS завершает команду RAMWR. Всё, что приходит после, контроллер панели интерпретирует как новую команду, а не как продолжение потока пикселей. Первая строка попадает в видеопамять, вторая трактуется как код команды с параметрами, третья — тоже, и так далее. Это поведение подробно разобрано в главе 34, и в ../../userspace/spi-clock/spi-clock.c над функцией lcd_fill() теперь висит комментарий-надгробие об этом.
Исправление. Один непрерывный RAMWR на весь кадр или на всю область окна: lcd_window() задаёт прямоугольник и выдаёт 0x2C, после чего все байты области уходят одним потоком без единого подъёма CS.
Правило на будущее. Модель «строка = одно SPI-сообщение» несовместима с непрерывной записью в видеопамять. Границу сообщения диктует не удобство кода, а протокол устройства.
40.2. Сброс DMA при включённом STREAM_EN: ложный TLAST и раздвоение (DMA-2)
Что мы видели глазами. Первый же кадр, отправленный через --dma, выглядел почти правильно — и это было хуже, чем если бы он выглядел как мусор. Глифы часов читались, но каждый был продублирован со смещением, как при плохой печати в два прогона. Программа при этом не сообщала ни о таймаутах, ни об ошибках DMA: функция ожидания честно дожидалась BUSY = 0 && EOT = 1 и возвращала успех — просто подозрительно быстро.
Какая гипотеза казалась логичной. Начали, разумеется, с дисплея: неверное окно, неверный MADCTL, забытое смещение Y_OFF. Гипотеза тем более соблазнительна, что в главе 34 мы уже ловили призраков после смены ориентации, и симптом внешне похож — «остатки поверх нового». Провели там пару часов, перепроверили CASET/RASET, сделали полный wipe перед кадром. Двоение осталось.
Как проверяли. Переломным был вопрос «а почему ожидание завершается так быстро?». Мы сняли время между записью в LENGTH и моментом, когда STREAM_STATUS показывает готовность. Для кадра в 42 миллисекунды получалось что-то около единиц миллисекунд. Значит EOT — флаг «состав кончился» — приходил задолго до конца состава. Дальше стало интересно: единственный источник EOT — это TLAST на потоковой шине, а TLAST выставляет DMA на последней порции. Откуда ему взяться в начале?
Улика. Мы посмотрели, что программа делает перед каждым кадром, и нашли ритуал: DMACR.Reset — мягкий сброс канала MM2S «для надёжности», перед каждой отправкой. При этом STREAM_EN в этот момент был уже поднят, то есть мост AXIS был активен и держал TREADY.
Корневая причина. Сброс DMA в момент, когда потоковая шина активна, не обязан оставлять её линии в спокойном состоянии. Наш мост в ../../rtl/spi_axis_tx.v защёлкивает признак последней порции в момент рукопожатия:
// rtl/spi_axis_tx.v, состояние ST_IDLE
if (enable && s_axis_tvalid && s_axis_tready) begin
data_r <= s_axis_tdata;
keep_r <= keep_eff;
last_r <= s_axis_tlast; // вот эта защёлка
idx <= 2'd0;
state <= ST_BYTE;
endЕсли в такте сброса TVALID и TLAST оказались подняты одновременно с нашим TREADY, мост честно решит, что состав кончился, и поднимет залипающий stream_eot. Дальше — цепочка: программа видит признак завершения, объявляет кадр законченным, пишет CONTROL = 0 и уходит готовить следующий, в то время как часть буфера ещё не отправлена. Остаток уезжает уже в следующей сессии, с начала окна, и ложится вторым слоем поверх свежей картинки. Отсюда двоение со смещением.
Исправление. Разделить сброс и работу по времени и по состоянию. Сброс MM2S выполняется один раз при инициализации, и обязательно при заглушенной потоковой шине: сначала STREAM_CTRL = 0 (мост перестаёт отдавать TREADY, ложное рукопожатие физически невозможно), затем ERROR_CLR, затем сам сброс с ожиданием, пока бит не снимется сам, и только потом RS. Именно так устроена функция dma_init(). А в dma_send() над телом функции стоит однострочный комментарий-предупреждение: «никогда не делать здесь DMACR.Reset — STREAM и AXIS уже взведены».
Правило на будущее. Не сбрасывайте DMA посреди потока. Если сброс всё же нужен, сначала гарантированно заглушите приёмную сторону — тогда никакая переходная комбинация на линиях не будет истолкована как настоящая передача.
40.3. Сброс на каждом кадре и «сжатый» шрифт (DMA-3)
Что мы видели глазами. Убрав сброс из середины потока, мы получили гораздо более здоровую картину: двоение исчезло, кадр рисовался целиком, EOT приходил вовремя. Осталась одна странность: цифры выглядели сжатыми по горизонтали, будто шрифт растянули по вертикали или сплющили по ширине. При внимательном взгляде было видно, что и наклонены — каждая следующая строка глифа сдвинута относительно предыдущей на постоянную величину. Тот же шрифт 5×7 через PIO рисовался идеально.
Какая гипотеза казалась логичной. «Значит, я неправильно считаю ширину тайла». Это очень соблазнительная гипотеза, потому что арифметика тайла в коде действительно нетривиальна: GLYPH_W = 5 * SCALE + 2 при SCALE = 3 даёт 17, двенадцать символов дают 204 точки ширины, высота — GLYPH_H + 4 = 25. Легко поверить, что где-то потерялся плюс-минус пиксель, и окно CASET/RASET не совпало с реальным размером буфера. Мы пересчитали это трижды.
Как проверяли. Спасло сравнение с PIO. Один и тот же буфер, одна и та же функция lcd_window(), один и тот же расчёт ширины — через stream_pio() рисуется правильно, через stream_dma() сжимается. Если бы ошибка была в арифметике окна, оба пути ломались бы одинаково. Значит виновата разница между путями, а разница ровно одна: DMA.
Улика. Наклон. Сжатие с постоянным наклоном строк — это подпись конкретного класса ошибок: буфер приходит короче, чем ожидает окно дисплея. Панель заполняет прямоугольник построчно с автоинкрементом; если байтов меньше, чем точек в окне, каждая следующая строка начинается «не там», и накопленный сдвиг рисует характерную косую сжатость. Не «пиксели неправильные», а «пикселей меньше, чем нужно».
Корневая причина. Сброс DMA мы убрали из середины потока, но оставили перед каждым кадром — теперь уже «безопасно», при STREAM_EN = 0. Оказалось, что безопасно от ложного TLAST, но не от потери начала. После DMACR.Reset канал MM2S должен пройти полную повторную инициализацию: дождаться самоснятия бита сброса, очистить статус, поднять RS и выйти из состояния Halted. Наша последовательность записывала SA и LENGTH практически сразу после RS, и первые порции передачи систематически терялись. Систематически — то есть одно и то же число байт каждый кадр, отчего и сжатие выглядело стабильным, а не мерцающим.
Исправление. Сброс — один раз на старте программы, в dma_init(). Между кадрами только два действия: очистить бит IOC в DMASR и записать новые SA и LENGTH. Ровно это и делает dma_send().
Правило на будущее. Сброс — это средство восстановления после ошибки, а не ритуал начала кадра. Каждый профилактический сброс в горячем пути — это окно, в которое утекают первые данные. Если тянет сбрасывать «на всякий случай», спросите себя, от какого именно случая, и проверьте, что после сброса вы дождались готовности, а не просто подождали.
40.4. «В PIO работает, в --dma ломается» (DMA-6)
Формально это не отдельная ошибка, а метод — но он стоил нам достаточно времени, чтобы получить собственный номер в каталоге дефектов.
Что мы видели глазами. Одна и та же программа, один и тот же буфер, разница в одном ключе командной строки. spi-clock без ключа рисует часы безупречно. spi-clock --dma рисует то двоящиеся, то сжатые цифры, а иногда упирается в таймаут ожидания. Психологически это очень неприятная ситуация: работающий вариант рядом создаёт иллюзию, что до рабочего DMA «полшага».
Какая гипотеза казалась логичной, и почему она вредна. Мозг инженера видит «картинка неправильная» и идёт туда, где картинка формируется: инициализация панели, MADCTL, смещения, окно, порядок байт в RGB565. Каждая из этих гипотез проверяется медленно (перепрошивка, перезапуск, разглядывание экрана) и каждая ложная. Соблазн усиливается тем, что в главе 34 мы действительно ловили ошибки именно там — свежий опыт тянет в знакомое место.
Как проверяли, и в чём была улика. Улика лежала на поверхности всё это время, и она чисто логическая: если бы дело было в инициализации дисплея, PIO-путь ломался бы тоже. Инициализация выполняется один раз, до всякого выбора режима; окно задаётся общей функцией; буфер пикселей формируется общим кодом. Всё, что отличает --dma от PIO — это способ доставки байтов из буфера в очередь передатчика. Значит корень обязан лежать в этом промежутке: кэш, программирование DMA, потоковая шина, обратное давление, глубина очереди, или содержимое самого кристалла.
Отсюда родился рабочий порядок проверок, который мы теперь применяем не думая:

Правило на будущее. Когда медленный путь работает, а быстрый — нет, корневая причина лежит строго в разнице между ними. Не начинайте с переписывания того, что у обоих путей общее. Это звучит банально ровно до того момента, когда вы третий час правите инициализацию дисплея.
И дополнительное правило, добытое здесь же: не выбрасывайте работающий медленный путь. В spi-clock заливка фона намеренно оставлена на PIO — не из лени, а потому что однотонная заливка плохо показывает мелкие потери данных, и именно на ней сломанный DMA заметить труднее всего. Соответствующий комментарий в lcd_fill() так и написан. Эталон должен быть заведомо исправным.
Глава 41. RTL-гонка FIFO: registered tx_wr, wr & ~full и тихая потеря байтов
Это самая тонкая ошибка проекта и единственная, которую нельзя было увидеть ни одним регистром статуса. Разберём её по тактам.
41.1. Что мы видели и почему это выглядело как рецидив
Что мы видели глазами. После исправления ритуала сбросов (40.3) кадр стал почти правильным. Почти. Цифры по-прежнему оставались слегка сжатыми и наклонёнными — тот же самый симптом, что и раньше, но существенно слабее. Заливка однотонным цветом при этом выглядела нормально. PIO-путь — идеально.
Какая гипотеза казалась логичной. «Значит, ритуал сброса поправлен не до конца». Это самая опасная гипотеза во всей главе, потому что она почти верна: симптом действительно тот же, класс причины действительно тот же (кадр приходит короче), и предыдущее исправление действительно улучшило картину. Мы потратили заметное время, вылизывая последовательность программирования DMA, добавляя ожидания, проверяя биты статуса. Каждая правка чуть-чуть меняла результат — что окончательно убеждало, что мы копаем в верном месте.
Как проверяли. Сработали три вещи. Первая: программная проверка переполнения. В stream_dma() есть контроль бита ERR_TX_OVF (STATUS[8]), который должен ловить «записали в полную очередь». Он молчал — и мы поначалу приняли это за доказательство, что потерь нет. Вторая: симуляция. Тест ../../sim/tb_spi_stream.v гоняет поток через мост при небольшой глубине очереди; если заставить источник не делать пауз между порциями, поведение у границы заполнения становится наблюдаемым по тактам. Третья, решающая: сравнение «заливка против текста». Однотонная заливка выглядела здоровой, а текст — нет. Ни одна ошибка адресации окна не умеет так избирательно портить именно текст.
Улика. Заливка и текст отличаются не форматом и не длиной, а только тем, насколько заметна потеря отдельного байта. В однотонном потоке пропажа байта меняет разве что оттенок остатка кадра; в тексте с резкими границами она сразу превращается в геометрию — сдвиг строк, наклон, сжатие. Значит теряются отдельные байты, а не пакеты и не начало передачи. А раз отдельные, и раз ERR_TX_OVF при этом молчит, то теряет их кто-то, кто умеет терять тихо.
41.2. Корневая причина, такт за тактом
Виновников двое, и по отдельности каждый из них безобиден.
Первый: мост ../../rtl/spi_axis_tx.v регистрирует сигнал записи. Решение «писать» принимается по состоянию очереди в такте N, а сама запись выставляется на шину в такте N+1 — потому что tx_wr это reg, а не провод:
// rtl/spi_axis_tx.v, состояние ST_BYTE
if (keep_bit(idx)) begin
/* Stall on full — do not skip this byte. */
if (!tx_full) begin
tx_wr <= 1'b1; // видно только в следующем такте
tx_wdata <= {24'd0, sel_byte(idx)};Второй: очередь в ../../rtl/spi_fifo.v принимает слово только при одновременном «пишут» и «не полна», а иначе тихо его отбрасывает:
// rtl/spi_fifo.v
wire write_now = wr_en && !full;
...
if (wr_en && full)
ovf_flag <= 1'b1;Теперь совместим их во времени. Пусть очередь заполнена до DEPTH-1, то есть одно место свободно.
такт N count = DEPTH-1 full = 0 мост решает: пишу tx_wr <= 1
такт N+1 count = DEPTH-1 full = 0 запись принимается (место было)
^ но указатель ещё не увеличился, мост снова видит full = 0
^ и снова решает: пишу tx_wr <= 1
такт N+2 count = DEPTH full = 1 запись ОТБРАСЫВАЕТСЯ
^ а индекс байта в мосте уже сдвинулся — байт потерян навсегдаСуть в одной фразе: мост видит состояние очереди на такт позже, чем оно меняется от его собственной записи. Он всегда «промахивается на один» и переполняет очередь ровно на одно слово каждый раз, когда доходит до потолка.
41.3. Почему PIO не доводил очередь до потолка, а DMA доводил всегда
Вот ключевой вопрос всей главы, и ответ на него — чистая арифметика скоростей.
Считаем потребителя. Движок съедает один байт за ~38 тактов (глава 37.6): 16 фронтов по 2 такта плюс шесть служебных. Это 0.026 байта за такт.
Считаем производителя в режиме DMA. Мост забирает 32-битную порцию за такт в состоянии ST_IDLE и затем выдаёт из неё четыре байта за четыре такта в ST_BYTE — то есть 4 байта за 5 тактов, 0.8 байта за такт. Это примерно в 30 раз быстрее потребителя. Очередь при таком соотношении не «иногда наполняется» — она заполняется до упора за первые же микросекунды и дальше живёт прижатой к потолку. Каждая последующая запись происходит ровно на границе заполнения, то есть в тех самых условиях, где живёт гонка. Не время от времени. Постоянно.
Считаем производителя в режиме PIO. Каждый байт стоит одного чтения STATUS и одной записи TX_DATA через шину GP0 из пользовательской программы. Одна только регистровая часть обёртки тратит на запись четыре такта (ST_IDLE → ST_WACC → ST_WPULSE → ST_WRESP) и шесть на чтение, а сверх этого идёт путь через межсоединение и процессорную систему; чтение вдобавок блокирующее — процессор стоит и ждёт ответа. В сумме байт обходится в десятки, а реально в сотни наносекунд — то есть того же порядка, что и 380 нс потребления. Заполненность очереди при близких скоростях не растёт, а гуляет около нуля: производитель и потребитель идут ноздря в ноздрю.
Отсюда и весь эффект. Гонка была в коде с самого первого дня — более того, регистровый интерфейс spi_reg_if.v устроен точно так же (проверяет full, выставляет tx_fifo_wr в следующем такте), так что PIO-путь подвержен ей ровно в той же мере. Просто PIO физически не способен разогнаться до потолка очереди, а DMA не способен от него отойти. DMA не создал ошибку. DMA сделал её достижимой.
41.4. Почему потеря была абсолютно тихой
Отдельно стоит объяснить, почему нас не спас ERR_TX_OVF. Загляните в ../../rtl/spi_master_top.v, в строку, объединяющую два источника записи:
// rtl/spi_master_top.v
// В TX могут писать и AXIS, и регистровый файл; никогда не писать в полную.
wire tx_wr_any = (tx_wr | ext_tx_wr) & ~tx_full;Сигнал записи, доходящий до очереди, уже погашен условием «не полна». Значит внутри spi_fifo.v условие wr_en && full не может выполниться никогда, и залипающий флаг переполнения этой очереди не поднимается ни при каких обстоятельствах. Регистр STATUS[8] в результате отражает только переполнения PIO-пути, которые ловятся раньше, на этапе декодирования записи в spi_reg_if.v.
Получается ловушка второго порядка: защитный вентиль & ~tx_full, поставленный чтобы «никогда не портить указатели очереди», заодно скрыл факт потери. Данные исчезали корректно, аккуратно и совершенно бесшумно. Это стоит запомнить как отдельный урок: любая защита, которая молча приводит систему в согласованное состояние, обязана иметь счётчик срабатываний. Иначе она превращается из предохранителя в глушитель. Счётчик отброшенных байт — очевидный кандидат в следующую версию IP.

41.5. Исправление: almost_full и стойло без потери байта
Лечение состоит из двух частей, и обе обязательны.
Часть первая — тормозить на два места раньше. В ../../rtl/spi_fifo.v появился новый выход, а к нему — комментарий, объясняющий, зачем он нужен, на случай если через год кто-то захочет «упростить»:
// rtl/spi_fifo.v
/*
* 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));Почему именно два места, а не одно? Пересчитаем по тактам. Производитель опаздывает на один такт, а значит в момент, когда он наконец увидит сигнал «тормози», у него уже может быть одна запись в полёте. Одно место запаса покрывает эту запись — но в тот же такт он успевает принять решение ещё об одной. Два места покрывают обе. Проверим на предельном случае: пусть в такте N count = DEPTH-3, признак не поднят, мост решает писать. В такте N+1 count всё ещё DEPTH-3 (указатель обновится по фронту), признак не поднят, мост решает писать снова. В такте N+2 первая запись уже учтена, count = DEPTH-2, признак поднимается — мост останавливается, но вторая запись, решённая в N+1, ещё придёт и доведёт счётчик до DEPTH-1. Максимум DEPTH-1, до полного не дошли, ни один байт не отброшен. Ровно два места — не больше и не меньше.
В ../../rtl/spi_master_top.v этот новый сигнал и выводится наружу как «полно» для внешнего производителя, тогда как регистровый интерфейс продолжает получать настоящий full:
// rtl/spi_master_top.v
/* External backpressure uses almost_full — see spi_fifo.v comment. */
assign tx_fifo_full = tx_almost_full;Часть вторая, без которой первая бесполезна — не двигать индекс при остановке. Мост разбирает 32-битную порцию по байтам, и у него есть счётчик idx, указывающий, какой байт сейчас выдаётся. Если при остановке этот счётчик всё-таки увеличить, «вежливое» обратное давление съест байт точно так же, как съедала гонка, — просто на такт позже. Поэтому в ../../rtl/spi_axis_tx.v увеличение idx спрятано внутрь ветки «запись состоялась», а не рядом с ней. Формулировка из заголовка файла: «Index advances only when a kept byte is actually issued, so a stall cannot skip payload bytes».
Оба исправления вместе превращают наш почти-backpressure в настоящий: при переполнении поток останавливается целиком — мост опускает TREADY, axi_dma приостанавливает чтение из памяти через HP0 — и возобновляется ровно с того байта, на котором остановился.
Правило на будущее. Если производитель регистрирует сигнал записи, обратное давление должно опережать реальную границу на глубину его конвейера. Сырой full годится только для комбинационного производителя. И шире, для любых очередей: проверять условие в такте N, а действовать в такте N+1 — это всегда гонка, даже если между ними «ничего не происходит».
Глава 42. Мета-ошибка сборки: synth_1 переиспользовал старый OOC system_spi0_0.dcp
Все предыдущие ошибки были в схеме, в RTL или в программе. Эта — в процессе. Она не относится ни к SPI, ни к DMA, ни к дисплею, и именно поэтому опаснее всех остальных: искать её будут в последнюю очередь.
42.1. Два термина, без которых история не читается
Out-of-context синтез (OOC, «синтез вне контекста») — это синтез отдельного блока изолированно, без окружения. Vivado делает так со всеми IP в блок-дизайне: каждый блок синтезируется сам по себе, результат складывается в файл, а при сборке верхнего уровня этот готовый результат просто подставляется, как собранный узел на конвейере. Выигрыш очевиден: поменяли что-то в одном месте — пересобирать надо только его.
Checkpoint, файл .dcp — это и есть «консерва» с результатом синтеза: готовая схема из вентилей и триггеров, сохранённая на диск. Ближайшая аналогия из мира программ — объектный файл .o. Компилятор один раз перевёл исходник в машинный код и положил рядом; если make решит, что исходник не менялся, компиляция не повторится и в программу попадёт старый .o.
Держите эту аналогию в голове: вся глава — про то, что наш make ошибся.
42.2. История
Что мы видели глазами. Исправление гонки из главы 41 написано, проверено симуляцией (make sim_stream зелёный), закоммичено. Битстрим пересобран штатным запуском синтеза и имплементации. Битстрим положен на плату. И картинка осталась точно такой же сжатой, как до исправления. Не «стало лучше», не «изменился характер артефакта» — буквально пиксель в пиксель то же самое.
Какая гипотеза казалась логичной. «Значит, исправление неверное». Это разумно: симуляция — не кристалл, и разница между ними — обычное дело. Дальнейшие мысли шли по накатанной: может, almost_full неправильно посчитан? может, двух мест мало и надо три? может, где-то ещё один регистр по дороге? Мы всерьёз обсуждали углубление запаса и переписывание моста. Каждая такая гипотеза — это пересборка на десятки минут и новая поездка к плате.
Как проверяли. Спас простой вопрос, который надо задавать раньше всех остальных: а точно ли на плате то, что мы собрали? Проверка версии тут не помогает — devmem 0x4000003C 32 честно показывал 0x53500200, потому что номер версии мы не меняли (в этом ограничение из раздела 38.5 и проявилось). Поэтому мы пошли двумя другими путями.
Путь первый — время файлов. Смотрим, когда в последний раз менялся исходник и когда собиралась «консерва» блока:
# точные пути зависят от каталога проекта Vivado
ls -l --time-style=full-iso rtl/spi_fifo.v
ls -l --time-style=full-iso build/ps_axi/ps_axi.runs/system_spi0_0_synth_1/*.dcpПуть второй — контрольная сумма самого битстрима до и после «пересборки»:
md5sum build/ps_axi/ps_axi.runs/impl_1/system_wrapper.bitУлика. Файл .dcp оказался старше правленого spi_fifo.v. А контрольная сумма битстрима после «пересборки» совпала с суммой до неё — байт в байт. Пересборка не пересобрала ничего. Дополнительное подтверждение: поиск имени almost_full в синтезированном списке цепей блока не давал ни одного совпадения. Сигнала, который мы добавили, в кристалле просто не было.
Корневая причина. Наш SPI попадает в блок-дизайн как «ссылка на модуль» (create_bd_cell -type module -reference spi_axi4lite spi0 в ../../scripts/build_ps_axi.tcl). Vivado оборачивает такую ссылку в IP и синтезирует её вне контекста, отдельным прогоном с именем system_spi0_0_synth_1, складывая результат в system_spi0_0.dcp. Синтез верхнего уровня (synth_1) этот файл не пересобирает — он его подставляет. И, что критично, сброс верхнего прогона не отменяет прогон блока: они независимы. Правка rtl/*.v меняет исходник, но ничего не сообщает готовой «консерве».
Итог — самое неприятное состояние, в каком может оказаться проект: в репозитории лежит исправление, симуляция его подтверждает, а в кристалле работает код недельной давности. Все ваши выводы о поведении железа при этом относятся к другой версии дизайна, и вы этого не знаете.
Исправление. Скрипт ../../scripts/rebuild_ps_axi_rtl.tcl, который делает всё принудительно и в правильном порядке. Его заголовок начинается прямо с предупреждения об этой ловушке. Порядок действий такой:
Убедиться, что правленые файлы вообще есть в наборе исходников проекта (скрипт добавляет их, если не нашёл).
Заставить блок-дизайн перечитать HDL:
validate_bd_design -force, затемsave_bd_designиgenerate_target all.Сбросить прогоны OOC-синтеза, относящиеся к нашему блоку:
reset_runдля всех*_synth_1, чьё имя содержитspi0илиspi_axi4lite.Запустить
system_spi0_0_synth_1явно и дождаться его окончания — именно здесь заново синтезируется правленый RTL.Только после этого
reset_run synth_1,launch_runs synth_1, затемreset_run impl_1иlaunch_runs impl_1 -to_step write_bitstream.Сверить время файла
.dcpи контрольную сумму нового.bit— скрипт печатаетREBUILD_SPI_DCPс временем файла иREBUILD_BITSTREAMс путём.
Запускается это одной строкой:
vivado -mode batch -source scripts/rebuild_ps_axi_rtl.tclВ графическом интерфейсе то же самое делается через Reset Runs с обязательным выбором system_spi0_0_synth_1, а не только synth_1.

Правило на будущее. Правка RTL, попадающего в блок-дизайн ссылкой на модуль, требует сброса OOC-прогона этого блока, а не только верхнего уровня. «Свежий bit» и «свежий .dcp» — разные утверждения, и второе надо проверять явно.
Прежде чем сказать «патч не помог», ответьте на вопрос: чем именно вы доказали, что патч оказался в кристалле? Приемлемые доказательства — время файла .dcp не старше исходника, изменившаяся контрольная сумма .bit, найденное имя нового сигнала в списке цепей, изменившийся номер версии в ID_VERSION. Неприемлемое доказательство — «я же запустил сборку». Заведите это в привычку: любой вывод о поведении железа стоит ровно столько, сколько стоит уверенность, что вы смотрите на своё железо.
Глава 43. JTAG load под живым Linux убивает Ethernet
Что мы видели глазами. Цикл отладки сложился естественным образом: собрал битстрим на рабочей машине, залил его на плату по JTAG командой fpga -f system.bit из XSCT, зашёл по ssh, запустил spi-clock. Быстро и удобно — никаких перезагрузок. И вот на очередной итерации ssh-сессия умерла в момент загрузки битстрима. Не «подвисла» — оборвалась. Переподключиться не удалось: плата не отвечала на ping, хотя по всем внешним признакам жила. Дисплей, кстати, показывал уже новое поведение: PL перепрограммировался успешно. Сеть при этом не возвращалась ни через минуту, ни через десять — только перезагрузка.
Какая гипотеза казалась логичной. «Я переконфигурирую только программируемую логику. Процессорная система, её контроллер Ethernet и драйвер сетевой карты живут отдельно и меня не касаются». Гипотеза очень убедительная, потому что концептуально верна: PL и PS — действительно разные части кристалла, и Ethernet физически висит на выводах MIO процессорной системы, а не на фабрике. Из этого кажется, что перепрошивка фабрики для сети безразлична.
Как проверяли. Провели самое дешёвое из возможных сравнений: тот же самый файл битстрима, но доставленный другим способом — скопирован на FAT-раздел карты памяти под именем system.bit и применён штатной перезагрузкой. Сеть после этого жила. Повторили несколько раз в обе стороны. Результат оказался воспроизводимым: через карту и перезагрузку — сеть работает всегда, через JTAG под работающей системой — не работает никогда.
Улика. Воспроизводимость и односторонность. Плавающие сбои сети — это обычно кабель, PHY или автосогласование; здесь же корреляция была стопроцентной и привязанной к способу доставки, а не к состоянию сети. Значит виновата сама процедура перепрограммирования.
Корневая причина. Загрузка PL через JTAG обходит штатный порядок, который на Zynq выполняется при нормальной загрузке: первичный загрузчик настраивает процессорную систему (ps7_init), затем прошивается фабрика, затем выполняется ps7_post_config, который приводит в согласованное состояние мосты между PS и PL и преобразователи уровней. Когда фабрику перепрограммируют под уже работающим ядром, эти мосты сбрасываются и поднимаются заново под системой, которая об этом не знает: у ядра есть активные отображения регистров PL (наш SPI, GPIO, DMA), могут быть незавершённые транзакции на шине, и часть состояния оказывается рассогласованной. На этой плате наблюдаемым следствием стабильно оказывается отказ Ethernet до перезагрузки.
Честная оговорка: мы не доводили разбор до конкретной строки в драйвере или конкретного регистра контроллера — задача статьи не в этом. Твёрдо установлены воспроизводимость эффекта и надёжность обходного пути; механизм описан на том уровне, на котором мы его подтвердили.
Исправление. Повседневный цикл отладки переведён на карту памяти. Порядок такой:
Скопировать
build/ps_axi/ps_axi.runs/impl_1/system_wrapper.bitна FAT-раздел карты под именемsystem.bit(переименование обязательно — загрузчик ищет именно это имя), рядом с согласованными FSBL, U-Boot иsystem.dtb.Выполнить
syncи корректно отмонтировать раздел, иначе на карте окажется половина файла.Перезагрузить плату и смотреть в UART. Консоль — единственный канал, который переживает любые эксперименты с фабрикой.
Узнать адрес:
ip addr. После перезагрузки DHCP вполне может выдать другой IP — это не поломка, а обычное поведение аренды адресов. Мы на этом спотыкались: плата загрузилась нормально, а ssh стучался по старому адресу и получал тишину, что легко принять за «опять сеть умерла».Проверить, что на плате действительно новая прошивка:
devmem 0x4000003C 32должен вернуть0x53500200, аmd5sumфайла на карте — совпасть с суммой на рабочей машине.
JTAG при этом никуда не девается и остаётся правильным инструментом — просто для другой фазы. Он незаменим на этапе bring-up до операционной системы (см. часть V), когда сети ещё нет и терять нечего.

Правило на будущее. Отладка под ssh несовместима с перепрошивкой фабрики на живой системе: кладите битстрим на карту и перезагружайтесь. JTAG — для тех фаз, когда сеть не нужна. И держите UART подключённым всегда: это единственный канал, который не зависит ни от фабрики, ни от DHCP.
Шпаргалка части IX
Таблица ниже — не замена главам, а способ вспомнить, с чего начать, когда симптом уже перед глазами. Полные разборы — выше, каталог дефектов с перекрёстными ссылками — в приложениях.
ID | Ловушка | Первый инструмент проверки |
|---|---|---|
DMA-1 | FIFO=8, CS падает посреди кадра | Длина низкого уровня CS на анализаторе против расчётной длительности кадра |
LCD-4 | Нет |
|
LCD-2 | Построчная отправка, CS между строками | Один непрерывный |
DMA-2 | Сброс DMA при | Время до |
DMA-3 | Сброс DMA на каждом кадре | Сжатый шрифт в |
DMA-6 | «PIO работает, DMA сломан» | Искать строго в разнице путей, не в общем коде |
DMA-4 | Гонка | Заливка против текста; симуляция у границы; |
DMA-5 | Устаревший OOC | Время файла |
ETH-3 | JTAG под живым Linux убивает Ethernet | Тот же bit с карты плюс перезагрузка |
Шаблон отчёта — тот же, что и во всей статье. Проговаривайте его вслух после каждой пойманной ошибки, письменно и целиком; половина ценности отладки теряется именно на этом шаге:
Симптом:
Что казалось логичным:
Как проверяли:
Корневая причина:
Исправление:
Правило на будущее:И одно наблюдение напоследок, ради которого стоило написать всю эту часть. Из девяти разобранных историй только одна (глава 41) относится к схемотехнике в узком смысле. Одна — к дисплею, четыре — к последовательности действий в программе, одна — к сборке проекта, одна — к способу доставки прошивки на плату, одна — к контракту железа, который мы не прочитали внимательно. Если из всей части вы запомните один вывод, пусть он будет таким: в проекте на стыке FPGA, Linux и периферии большая часть времени уходит не на логику, а на границы между подсистемами. Именно там никто не отвечает за корректность, и именно там стоит искать первым делом.
Что из этого получилось в итоге и сколько чего стоило — в части X.
Часть X. Итоги
Есть соблазн считать перенос законченным в момент, когда Vivado написал «write_bitstream Complete». Мы через этот соблазн прошли трижды и трижды убедились, что он врёт. Первый раз битстрим собрался задолго до того, как на плате появился хоть один фронт SCLK, потому что программируемая логика молчала, пока процессорная система не разрешила преобразователи уровней. Второй раз битстрим собрался, логика заработала, Linux загрузился — и Ethernet поднимал линк, но не пропускал ни одного пакета. Третий раз всё работало, картинка на дисплее была, но текст двоился, и исправление, лежавшее в git, физически отсутствовало в кристалле.
Поэтому в этой части мы подводим итог не по критерию «собралось», а по трём другим: что именно из старого проекта уцелело без изменений, что пришлось дописать и чем мы за это заплатили, и какие правила остались в голове после всех разобранных ошибок. Последнее, если честно, и есть главный результат: битстрим устареет с первой же сменой платы, а привычка не доверять собственной первой гипотезе останется.
Глава 44. Что перенесли, что добавили, что осталось привязанным к вендору
44.1. Ядро: перенос, которого почти не было
Начнём с той части итога, которая выглядит скучно, и именно поэтому важна. Четыре модуля, составляющие сам SPI-мастер — регистровый интерфейс spi_reg_if, очередь spi_fifo, движок протокола spi_engine и обвязка верхнего уровня spi_master_top — переехали с Cyclone IV на Zynq-7020 практически без правок. «Практически» здесь означает два атрибута ASYNC_REG на триггерах синхронизатора, добавленных для того, чтобы синтезатор Xilinx не разъединил цепочку и разместил её компактно. Всё остальное — тот же текст, который годом раньше собирался в Quartus.
Это стоит проговорить прямо, потому что ожидания у большинства ровно противоположные. Когда студенту говорят «портируем IP с Altera на Xilinx», он представляет себе переписывание кода: замену мегафункций, борьбу с примитивами, подгонку под другую архитектуру логических блоков. У нас ничего этого не было по одной причине: ядро с самого начала писалось поведенческим Verilog без единого инстанса вендорского примитива. Ни altpll, ни altsyncram, ни scfifo — ни одного. Память FIFO описана так, что синтезатор сам решает, чем её реализовать; тактовая частота приходит извне, а не из встроенного PLL; вся арифметика делителя SCLK — обычные счётчики. Аудит, которому посвящена часть I, закончился пустым результатом поиска, и этот пустой результат оказался самой ценной его находкой.
Вместе с кодом переехала и семантика регистров. Карта от CONTROL до ERROR_CLR осталась той же: те же биты, те же sticky-флаги, тот же побочный эффект чтения RX_DATA. Изменилась только адресация — вместо индексов слов на параллельной шине появились байтовые смещения AXI, то есть индекс, умноженный на четыре. Драйвер, написанный под старую карту, узнаёт эти регистры без переучивания.
Честности ради уточним, что означает «без правок» сегодня, а не в момент переноса. Тогда единственным изменением были те самые атрибуты синхронизатора. Потом пришёл потоковый режим из части IX, и вместе с ним изменились spi_fifo (порог almost_full), spi_engine (состояние ожидания данных, признаки потока и режима «только передача») и spi_master_top (второй источник записи в передающую очередь). Контрольные суммы совпадают сейчас только у spi_defs.vh и spi_reg_if.v — и это ровно тот файл, который мы не трогали намеренно, потому что на его неизменности держится весь аргумент об эквивалентности переноса.
Переехали, что важно, и дефекты. Мы сознательно не стали чинить найденные проблемы исходного ядра до того, как докажем, что перенос эквивалентен: иначе нельзя честно утверждать, что на Zynq работает то же самое, а не «что-то похожее, только лучше». Поэтому предел CLK_DIV >= 2 (дефект A-3), молчаливое игнорирование записи нуля в делитель (A-4), мёртвый флаг переполнения приёмного FIFO (A-5) и ломающиеся при граничных параметрах part-select (A-1 и A-2) уехали на новую платформу как есть — но уже с тестами, воспроизводящими каждый из них, и с честной записью в документации. Разбор каждого — в части II.
44.2. Обвязка: всё, что пришлось дописать
Теперь та половина итога, на которую ушло время. Чтобы четыре неизменных модуля превратились в устройство, работающее под Linux и рисующее кадр на дисплее, понадобилось следующее.
Появилась обёртка spi_axi4lite — тонкий переводчик с шины AXI4-Lite на внутреннюю параллельную шину регистров. Тонкий сознательно: в ней нет ни одной строчки, знающей про SPI, только конечный автомат каналов и декодирование адреса. Ценой этой простоты стали четыре осознанных отклонения от спецификации (D-1…D-4), каждое из которых потом отразилось в правилах для драйвера — от запрета байтовых обращений до зеркалирования регистров за пределами 64-байтовой апертуры. Подробности — в части III.
Появился потоковый путь: модуль spi_axis_tx, принимающий AXI-Stream от DMA, глубокий передающий FIFO на 1024 слова вместо восьми, признак almost_full, удержание CS в состоянии CS_HOLD и три новых бита управления (STREAM_EN, TX_ONLY, TX_FAST) вместе с отдельным быстрым делителем FAST_DIV. Версия IP поднялась с 0x53500100 до 0x53500200, и это версионирование оказалось не бюрократией, а рабочим инструментом: одна команда devmem 0x4000003C отвечает на вопрос «а тот ли битстрим сейчас в кристалле», который в этом проекте задавался чаще, чем хотелось бы.
Появился Block Design с процессорной системой: порт GP0 для регистров, порт HP0 для потока данных из DDR, блок axi_dma в простом режиме, AXI GPIO для линий дисплея и сборка прерываний через xlconcat в IRQ_F2P. Адреса перестали быть абстракцией: 0x40000000 для SPI, 0x40010000 для GPIO дисплея, 0x40400000 для DMA и 0x1f000000 под зарезервированный кадровый буфер — четыре числа, которые с этого момента приходится держать согласованными сразу в трёх местах: в Block Design, в Device Tree и в коде userspace.
Появился, наконец, весь программный слой, которого на «голой» ПЛИС не было вовсе: образ Buildroot, цепочка загрузки, дерево устройств, драйвер spi-zynq-fpga, проверяющий паспорт IP при инициализации, и программа spi-clock, которая рисует часы через /dev/mem. Именно этот слой дал проекту видимый критерий успеха и одновременно принёс большинство ошибок.
Сводка, которую удобно держать перед глазами:
Категория | Артефакт | Комментарий |
|---|---|---|
Перенесли |
| На момент переноса — только два атрибута |
Перенесли | Карта регистров CONTROL…ERROR_CLR | Семантика та же, смещения стали байтовыми (индекс × 4) |
Перенесли | Дефекты A-1…A-6 как знание | Не исправлены молча, а зафиксированы тестами и документацией |
Добавили |
| Мост с AXI4-Lite; отклонения D-1…D-4 |
Добавили |
| Непрерывная передача для дисплея |
Добавили | Глубокий TX FIFO на 1024 слова и | Нужен только для DMA-сборки; PIO живёт и на восьми |
Добавили | Block Design: GP0, HP0, | Интеграция с процессорной системой |
Добавили | Buildroot, Device Tree, драйвер | Всё, чего не бывает на «голой» ПЛИС |
Vendor-specific | XDC, банки ввода-вывода, VCCO | Аналог QSF и SDC, но другая семантика |
Vendor-specific |
| На Cyclone IV аналога не было в принципе |
Vendor-specific | Out-of-context синтез и файлы | Источник мета-ошибки из главы 42 |
Vendor-specific | IRQ_F2P, порты GP0 и HP0 | Особенности архитектуры Zynq |

44.3. Проверка сквозного тезиса
В части 0 мы сформулировали тезис, который тогда звучал как обещание: ядро почти vendor-neutral, а настоящая работа — в обвязке. Теперь это отчёт, и его можно проверить простым вопросом: перечислите ошибки, которые стоили больше часа. Предел CLK_DIV — контракт ядра, но выявлен он был ещё на Altera. Всё остальное лежит за пределами четырёх перенесённых файлов: банк ввода-вывода и напряжение VCCO, отсутствие тактового сигнала до ps7_init, чужой код инициализации от отладочной платы ZC702, адрес PHY и режим пинов Ethernet, ориентация панели и смещение окна, глубина FIFO против скорости кадра, последовательность запуска DMA, гонка в собственном FIFO при полном заполнении, устаревший файл-заготовка синтеза и способ доставки битстрима на плату.
Из этого списка только одна история — гонка в FIFO из главы 41 — про схемотехнику в узком смысле. Остальные про границы: между процессорной системой и логикой, между железом и драйвером, между инструментом сборки и репозиторием, между дисплеем и его контроллером. Границы никому не принадлежат, за их корректность никто не отвечает, и потому именно там стоит искать в первую очередь.
44.4. Честные ограничения того, что получилось
Проект работает, но было бы нечестно закончить на этом. Данные идут только в одну сторону: DMA настроен на передачу из памяти в поток, обратного канала нет, и длинное чтение — например, дампа флеш-памяти — по-прежнему выполняется словом за словом через процессор. Кадр рисует программа из пространства пользователя, которая отображает физические адреса через /dev/mem, а перед этим отвязывает штатный драйвер от устройства; для учёбы это идеальный микроскоп, для продукта — нет: ни изоляции, ни прав доступа, ни аккуратной работы с кэшем. DMA работает в простом режиме, без цепочки дескрипторов, поэтому частичное обновление экрана приходится собирать в один непрерывный буфер. Дисплейная часть написана под конкретную панель ST7789 с её ориентацией 0x60 и смещением 34 по вертикали, а не как универсальный драйвер панелей. И наконец, контракт с софтом опознаётся по нашему собственному идентификатору 0x53500200 — с чужим IP-ядром AXI SPI эта программа не заработает.
Про ресурсы кристалла скажем ровно то, что подтверждается отчётами в ../../reports/. Автономный out-of-context синтез самого IP занимает 405 LUT и 406 триггеров, не тратя ни одного тайла блочной памяти и ни одного DSP — это версия с мелким FIFO, без Block Design и без DMA. Полная сборка с процессорной системой, глубокой очередью на 1024 слова и блоком DMA стоит уже 3319 LUT, то есть чуть больше шести процентов кристалла: 1815 ячеек ушло на логику и 1504 на память, из них 1410 работают распределённым ОЗУ. Триггеров 2061. Для сравнения, полное демо на Cyclone IV занимало около 1.7 тысячи логических элементов, примерно 28 процентов кристалла EP4CE6.
В этих цифрах спрятан урок, который стоит вынести отдельно. Глубокая очередь легла не в блочную память, а в логические ячейки, переключённые в режим маленького ОЗУ: у нашего FIFO комбинационный выход чтения, а блочная память 7-й серии так не умеет — она отдаёт данные только через регистр. Единственный занятый тайл RAMB36 принадлежит не нам, а внутренним буферам axi_dma. И ещё одна честная деталь: глубина задана одним общим параметром на оба FIFO, поэтому вместе с передающей очередью до 1024 слов выросла и приёмная, которая в потоковом режиме не используется вовсе. Примерно половина распределённой памяти лежит мёртвым грузом — знать об этом полезно на случай, когда ресурсы кончатся.
При повторении проекта эти числа стоит не переписывать, а снимать со своей сборки: они зависят от глубины FIFO, наличия встроенного логического анализатора и состава блок-дизайна. Число, которое читатель не может воспроизвести, вреднее отсутствия числа.
Глава 45. Программа лабораторных: как повторить путь
Этот проект хорошо разбирается на занятия, и порядок здесь важнее скорости. Соблазн «сразу подключить дисплей» велик, но каждая пропущенная ступень возвращается втрое: отлаживать одновременно распиновку, шину, загрузку Linux и протокол панели практически невозможно, потому что симптом один — «ничего не видно», — а причин десяток.
Занятие первое: аудит и сборка без платы. Возьмите чужое (или своё старое) SPI-ядро и проведите аудит зависимостей от вендора так, как описано в части I: найдите или не найдите примитивы, зафиксируйте контрольные суммы файлов, соберите проект под новый кристалл в режиме out-of-context и прочитайте отчёты синтеза. Критерий успеха — не «собралось», а письменный ответ на вопрос, какие файлы вы имеете право называть перенесёнными без изменений. Типичная ошибка — уверенность вместо доказательства.
Занятие второе: карта регистров и модель. Разберите ядро по модулям, как в части II, и воспроизведите в симуляции два дефекта: предел делителя (A-3) и мёртвый флаг переполнения приёмного FIFO (A-5). Критерий успеха — вы можете объяснить, почему f_SCLK = f_clk / (2 · (DIV + 1)) и почему драйвер обязан заявлять максимальную частоту как одна шестая тактовой, а не одна четвёртая. Типичная ошибка — поверить документации исходного проекта, а не коду.
Занятие третье: шина. Напишите или разберите обёртку AXI4-Lite и объясните рукопожатие каналов, как в части III. Критерий успеха — рассказать по такту, что происходит при записи CONTROL и при чтении RX_DATA, и назвать все четыре отклонения вашей реализации от спецификации вместе с их последствиями для драйвера. Типичная ошибка — считать отклонения багами и броситься их «чинить», не поняв цены.
Занятие четвёртое: первое включение. Соберите автономный вариант без процессорной системы, проверьте распиновку по части IV, измерьте напряжение банка мультиметром до подачи питания на нагрузку и оживите логику по шагам из части V: светодиод на делителе тактовой частоты, самотест, петля MOSI–MISO, логический анализатор внутри кристалла. Критерий успеха — байт вернулся неискажённым, а CS обрамляет посылку целиком. Типичная ошибка — искать баг в конечном автомате, когда логика просто не получила тактового сигнала.
Занятие пятое: процессорная система и Linux. Соберите Block Design с адресами и прерываниями по части VI, затем поднимите образ по части VII: свой, а не чужой ps7_init, цепочка загрузки до консоли UART, дерево устройств с узлом SPI по адресу 0x40000000, работающая сеть, probe драйвера в dmesg. Критерий успеха — devmem 0x4000003C возвращает ожидаемый паспорт IP. Типичная ошибка — начинать отладку драйвера раньше, чем проверен идентификатор.
Занятие шестое: дисплей и поток. Оживите панель по части VIII, добейтесь корректной ориентации и окна, а затем переведите заливку на поток с DMA по части IX. Критерий успеха — полный кадр без разрывов и без «сжатого» шрифта, флаг переполнения передающего FIFO чист. Типичная ошибка — сбрасывать DMA на каждом кадре и удивляться артефактам.
Одно требование действует на всех занятиях без исключения. На каждую поломку заполняется короткий отчёт — тот же шаблон, который встречается в статье всюду:
Симптом:
Что казалось логичным:
Как проверяли:
Корневая причина:
Исправление:
Правило на будущее:Последняя строка — не формальность. Без неё занятие заканчивается фразой «у меня заработало», которая не переносится ни на другую плату, ни на другой проект, ни даже на следующую неделю.
Глава 46. Куда расти дальше
46.1. Цепочка дескрипторов вместо одной передачи
Сейчас канал MM2S работает в простом режиме: один буфер, одна длина, один запуск на кадр. Это удобно объяснять и трудно испортить, но за простоту приходится платить памятью и лишним копированием: чтобы обновить три цифры на часах, мы собираем непрерывный кусок и держим под него зарезервированную область в DDR. Режим scatter-gather позволяет описать передачу цепочкой дескрипторов и склеить несколько разрозненных плиток в один логический кадр без промежуточного копирования.
Учебная ценность шага в другом. Дескрипторы придётся связать с признаком последнего элемента так, чтобы он приходился ровно на границу кадра, и при этом не нарушить правило, выстраданное в главе 42 части IX: не сбрасывать DMA внутри активного потока. То есть это упражнение не столько на новый режим блока, сколько на аккуратную работу с уже понятым контрактом.
46.2. Ядро вместо /dev/mem
Программа, отображающая физические адреса напрямую, была правильным выбором для изучения железа: она короткая, ничего не скрывает и отлаживается обычным принтом. Как основа продукта она плоха по трём независимым причинам: любой процесс с достаточными правами получает доступ ко всей памяти, штатный драйвер приходится отвязывать от устройства, и следить за согласованностью кэша процессора с содержимым буфера тоже приходится вручную.
Взрослый путь — перенести потоковую часть в ядро: клиент подсистемы dmaengine внутри spi-zynq-fpga, аккуратно оформленный интерфейс для приложения и сохранённый контракт потоковых регистров. Тогда правило «не сбрасывать DMA внутри потока» превращается из строчки в статье в инвариант, который нельзя нарушить снаружи.
46.3. Дисплей как обычное устройство системы
Финальная цель для дисплейной части — перестать считать его нашей личной периферией. В графической подсистеме Linux для панелей с последовательным интерфейсом есть готовый слой, и часы тогда становятся обычным приложением, которое рисует в буфер, ничего не зная ни про физические адреса, ни про удержание CS.

Важная оговорка: этот шаг имеет смысл только после стабильной передачи через DMA и понятного контракта CS. Иначе графический слой не решит проблемы, а спрячет их на уровень глубже, где симптом «текст двоится» будет виден, а причина — нет.
46.4. Что стоит поправить в самом ядре
Есть короткий список долгов, который мы осознанно не закрывали, чтобы не ломать эквивалентность переноса. Флаг переполнения приёмного FIFO стоит оживить, чтобы регистр состояния не врал. Значения делителя меньше двух в классическом режиме стоит явно отвергать, а не принимать молча, раз мы знаем, что приём при них портится. Опциональный режим, в котором линией выбора управляет не наше ядро, а драйвер, сделал бы IP совместимым со стандартным способом описания транзакций в Linux. Обратный канал DMA открыл бы дорогу длинным чтениям. И, наконец, доступный из регистров уровень заполнения FIFO позволил бы отлаживать поток без встроенного логического анализатора — а именно его отсутствие в главе 41 стоило нам больше всего времени.
46.5. Правила, которые остались после всех детективов
Если из статьи придётся оставить одну страницу, пусть это будет эта. Ниже — мораль каждой истории в том порядке, в котором они встречались, без симптомов и без интриги: только правило и адрес, где искать подробности.
Максимальную частоту SPI выводят из задержки синхронизатора, а не из красивой формулы в даташите — отсюда предел делителя и честная одна шестая тактовой вместо одной четвёртой (A-3, часть II). Регистр состояния — не истина в последней инстанции: если бит объявлен в документации, это ещё не значит, что RTL его когда-нибудь поднимет, поэтому переполнение приёмного FIFO ищут в прерывании (A-5, часть II). Отклонение от спецификации шины не грех, но оно обязано быть записано и известно драйверу: игнорируемые байтовые стробы означают «только 32-битные обращения», а чтение регистра данных — не наблюдение, а изъятие слова из очереди (D-1…D-4, часть III).
Сигнал, который не тактирует ни один триггер внутри кристалла, не следует объявлять тактовым: описывать надо физику, а не собственные ожидания (C-2). Любой асинхронный вход проходит через два триггера, и это не перестраховка, а цена детерминированного поведения (C-3, часть IV). Напряжение банка ввода-вывода проверяют мультиметром до подачи питания, а не после появления запаха (глава 18). На Zynq «логика жива» и «битстрим загружен» — разные утверждения: сначала процессорная система разрешает границу, потом уже имеет смысл искать баг в конечном автомате (глава 19).
Ограничения железа определяют облик софта раньше, чем написана первая строка на C: если линию выбора нельзя удерживать отдельно от посылки, а квитирование прерывания вычищает очереди, то драйвер обязан читать данные до квитирования и не рассчитывать на дозаливку на ходу (F-1…F-3, часть VI). Код инициализации процессорной системы привязан к плате: взять его от чужой отладочной платы — значит настроить свою память чужими параметрами и получить сбой, который не выглядит как сбой конфигурации (ETH-1, часть VII). Поднявшийся линк Ethernet не означает работающую сеть: адрес трансивера на шине управления и электрический режим пинов проверяются отдельно от факта «горит лампочка» (ETH-2, часть VII).
Дисплей — самый честный отладчик и самый коварный: перевёрнутая или сдвинутая картинка почти всегда означает не ошибку передачи, а ошибку в регистре ориентации или в границах окна, и после смены ориентации память панели надо очищать целиком, иначе старое изображение остаётся призраком (LCD-1…LCD-3, часть VIII). Арифметику пропускной способности считают до, а не после: FIFO глубины восемь физически не может прокормить кадр, и никакая ловкость в программе этого не изменит (DMA-1, часть IX). Сброс — не безобидная операция: сброс DMA внутри активного потока рождает ложный признак конца передачи, а сброс на каждом кадре превращает шрифт в сжатый (DMA-2 и DMA-3, часть IX).
Фраза «в одном режиме работает, в другом ломается» — не совпадение, а улика: если передача словом за словом даёт чистую картинку, а поток нет, разница между ними и есть место поломки (DMA-6). Именно так нашлась гонка в собственном FIFO: зарегистрированный сигнал записи вместе с проверкой «пиши, если не полон» молча терял байты ровно в тот момент, когда очередь заполнялась до конца, а до конца её заполнял только поток (DMA-4, часть IX). И самое неприятное правило из всех: не доверяйте кэшу инструментов. Исправление может существовать в git и отсутствовать в кристалле, если верхний уровень пересобрался, а заготовка синтеза IP осталась старой; проверять надо время файла и контрольную сумму битстрима, а не собственную память о том, что «я же поправил» (DMA-5, часть IX). Наконец, способ доставки прошивки — тоже часть системы: загрузка по JTAG под работающей операционной системой стоила нам сети до перезагрузки, а обычный файл на разделе FAT и штатный перезапуск не стоили ничего (ETH-3, часть IX).
46.6. Что вы на самом деле сделали
Вы не «перенесли SPI на другую ПЛИС». Вы прошли всю цепочку, из которой состоит любое встраиваемое устройство, и на каждом стыке этой цепочки один раз ошиблись:
нейтральное ядро → шина процессорной системы → ограничения очередей
→ протокол дисплея → DMA и память → кэш инструментов сборки
→ жизнь под Linux и работающая сетьОглядываясь на весь путь, видно, что почти все дорогие ошибки были не ошибками знания, а ошибками доверия: мы доверяли документации исходного проекта, чужому файлу инициализации, ярлыку «link up», собственной памяти о внесённом исправлении и кэшу синтезатора. Знания добываются за вечер чтением даташита. Привычка проверять — за проект вроде этого.
Поэтому лучший результат серии измеряется не картинкой на панели, хотя часы, которые рисует DMA-поток без единого артефакта, выглядят приятно. Он измеряется тем, что в следующий раз, увидев двоящийся текст, вы не полезете сразу переписывать конечный автомат, а спросите себя: а тот ли битстрим сейчас в кристалле, и чем именно поток отличается от передачи словом за словом.
Дальше — приложения A–E: глоссарий, карта регистров с примерами, шпаргалка команд и индекс всех разобранных дефектов со ссылками на главы.
Приложения A–E
Основные части статьи написаны как рассказ: там важна причина, а не справка. Приложения устроены наоборот — это то, что вы держите открытым во втором окне, пока паяльник греется, а плата уже мигает светодиодом. Здесь можно и нужно пользоваться таблицами: карта регистров, каталог дефектов и шпаргалка команд — ровно те данные, которые в прозе искать неудобно.
Читать приложения целиком не обязательно. Приложение A пригодится, когда в тексте встретилось незнакомое сокращение. Приложение B — когда вы пишете код и нужно точное смещение. Приложение C — когда пальцы уже на клавиатуре. Приложение D — когда что-то сломалось и хочется быстро понять, встречали ли мы это раньше. Приложение E — когда преподаватель спрашивает про ресурсы кристалла.
Приложение A. Глоссарий
Аббревиатуры в мире SoC размножаются быстрее, чем их успевают объяснять. Ниже — короткие определения своими словами, сгруппированные по смыслу, а не по алфавиту: так проще увидеть, что термины из одной группы описывают один и тот же слой системы.
Шина, процессорная система и адреса
PS (Processing System) — «жёсткая» часть Zynq: два ядра ARM Cortex-A9, контроллер DDR, Ethernet, UART, загрузчик. Она существует физически и не занимает ресурсов программируемой логики.
PL (Programmable Logic) — собственно ПЛИС: конфигурируемые блоки, в которые попадает ваш Verilog. Наш SPI-мастер живёт здесь.
AXI4-Lite — упрощённый вариант шины AXI, рассчитанный на доступ к регистрам: один адрес, одно слово данных, никаких пакетных передач. Именно так процессор читает и пишет наши CONTROL, STATUS, TX_DATA.
AXI-Stream (AXIS) — шина без адресов вообще: поток данных с сигналами «данные готовы» и «получатель готов». Ею AXI DMA кормит наш spi_axis_tx.
MMIO (memory-mapped I/O) — приём, при котором регистры устройства выглядят для процессора как обычные адреса памяти. Запись по адресу 0x40000000 не попадает в DDR, а дёргает провода внутри ПЛИС.
GP0 / HP0 — порты AXI со стороны PS. GP (general purpose) рассчитан на редкие обращения к регистрам, HP (high performance) — на поток данных в DDR и из неё. У нас регистры на GP0, а DMA-трафик через HP0.
Address Editor — окно Vivado, где базовым адресам блоков присваиваются конкретные значения. Именно оттуда берётся «магический» 0x40000000, и именно поэтому он вовсе не магический.
IRQ_F2P — вход прерываний «из логики в процессор» (fabric to PS). Наши события DONE и ERROR доходят до Linux только через него.
xlconcat — служебный блок Vivado, склеивающий несколько однобитных линий прерываний в один вектор для IRQ_F2P.
Тактирование, сброс и синхронизация
FCLK — тактовый сигнал, который PS выдаёт в PL (FCLK_CLK0). Если PS его не включил, ваша логика стоит, и это выглядит как «мёртвый битстрим».
CDC (Clock Domain Crossing) — передача сигнала между двумя разными тактовыми доменами. Опасна тем, что приёмник может «застать» сигнал в момент переключения и уйти в метастабильное состояние.
Метастабильность — состояние триггера, когда он ещё не решил, ноль в нём или единица. Обычно рассасывается за наносекунды, но до этого читается непредсказуемо, и в разных местах схемы по-разному.
Синхронизатор 2FF — два последовательных триггера, через которые пропускают асинхронный вход, чтобы метастабильность успела рассосаться до попадания в логику. У нас так заведены spi_miso и reset_n.
ASYNC_REG — атрибут Xilinx, помечающий такие триггеры для синтезатора, чтобы он не «оптимизировал» цепочку и разместил их рядом. Единственное место, где вендор вообще просочился в наш RTL.
Generated clock — способ объявить в constraints, что сигнал является производным тактовым. Для нашего SCLK это неверно: он не тактирует ни один триггер внутри кристалла (см. часть IV).
Level shifters — преобразователи уровней между PS и PL внутри Zynq. Пока PS их не разрешил (ps7_init), сигналы через границу не проходят, каким бы правильным ни был битстрим.
FIFO, поток и удержание CS
FIFO — очередь «первым пришёл, первым ушёл». У нас две: TX (слова, которые надо отправить) и RX (слова, которые пришли от ведомого).
almost_full — признак «в очереди осталось меньше двух мест». Мы завели его, чтобы останавливать поток заранее, а не терять байты в момент, когда FIFO уже полон (см. часть IX).
Backpressure — обратное давление: приёмник говорит источнику «я не готов», и источник ждёт. В AXI-Stream это снятый TREADY.
TLAST — бит в AXI-Stream, помечающий последний элемент передачи. Как метка последнего вагона в составе: по ней получатель понимает, что поток закончился.
EOT (end of transfer) — наш sticky-флаг, который поднимается, когда пришёл beat с TLAST, и держится до явной очистки битом CLR_EOT.
CS_HOLD — состояние движка, в котором CS остаётся активным, хотя отправлять пока нечего. Без этого режима полный кадр дисплея развалился бы на куски.
STREAM_EN / TX_ONLY / TX_FAST — три бита, которые превращают наш обычный SPI-мастер в потоковый: держать CS, не писать в RX FIFO, тактироваться от отдельного быстрого делителя.
PIO (programmed I/O) — режим, в котором процессор сам, инструкция за инструкцией, пишет слова в TX_DATA. Просто, наглядно и катастрофически медленно для полного кадра.
DMA (Direct Memory Access) — блок, который переносит данные из памяти в периферию без участия процессора. У нас это axi_dma, канал MM2S.
MM2S — направление DMA «memory-mapped to stream»: из DDR в поток. Обратное направление (S2MM) в проекте не задействовано.
Scatter-gather — режим DMA с цепочкой дескрипторов в памяти. Мы его не используем: одна передача на кадр проще и достаточно быстра.
Дисплей ST7789
GRAM — внутренняя видеопамять контроллера дисплея. Мы не «рисуем на стекле», а пишем в неё, а панель обновляется сама.
RAMWR (0x2C) — команда «начинаю писать пиксели». После неё контроллер ждёт поток данных и сам двигает внутренний адрес.
CASET / RASET — команды, задающие окно столбцов и строк, куда попадут следующие пиксели. Ошибка в окне выглядит как сдвинутая или обрезанная картинка.
MADCTL — регистр ориентации: отражения по осям и перестановка строк со столбцами. Значение 0x60 — то, при котором наша панель встаёт в ландшафт.
RGB565 — формат пикселя в два байта: пять бит на красный, шесть на зелёный, пять на синий.
D/C (Data/Command) — отдельная линия, которой мы говорим дисплею, команда сейчас идёт по SPI или данные. У нас она выведена через AXI GPIO.
Инструменты, сборка и Linux
XDC — файл constraints Xilinx: назначение пинов, уровни, тайминги. Аналог QSF плюс SDC в мире Quartus.
IOSTANDARD / VCCO — электрический стандарт пина и напряжение питания банка ввода-вывода. Несовпадение стандарта и реального напряжения — самый быстрый способ испортить плату.
OOC (out-of-context) синтез — синтез отдельного IP независимо от верхнего уровня. Ускоряет сборку и однажды стоил нам целого вечера отладки.
DCP (Design Checkpoint) — файл-снимок результата синтеза. Именно устаревший system_spi0_0.dcp однажды заставил нас отлаживать код, которого в кристалле не было.
ILA (Integrated Logic Analyzer) — логический анализатор, встраиваемый в саму ПЛИС: показывает внутренние сигналы через JTAG.
JTAG / XSCT — интерфейс отладки и командная строка Xilinx для работы с ним: загрузить битстрим, остановить процессор, прочитать память.
FSBL (First Stage Boot Loader) — первая программа после включения Zynq: настраивает DDR и клоки, затем передаёт управление U-Boot.
ps7_init — сгенерированный код инициализации PS под конкретную плату. Взять его от чужой платы — значит настроить DDR чужими параметрами.
Device Tree (DTS/DTB) — описание железа для ядра Linux: какие устройства есть, по каким адресам, с какими прерываниями. Исходник — .dts, бинарник — .dtb.
reserved-memory — область DDR, которую ядро не отдаёт под общие нужды. Мы держим там кадровый буфер для DMA.
unbind — операция в sysfs, отвязывающая драйвер от устройства. Нужна, чтобы userspace-программа могла взять железо себе.
W1C (write one to clear) — приём, при котором флаг сбрасывается записью единицы в его бит. Так у нас работает ERROR_CLR.
Sticky-флаг — бит, который, однажды поднявшись, держится до явной очистки. Удобно для ошибок: событие короткое, а узнать о нём надо позже.
Приложение B. Карта регистров и devmem
База SPI в актуальном Block Design — 0x40000000. Рядом живут GPIO дисплея (0x40010000), AXI DMA (0x40400000) и зарезервированный под кадр буфер в DDR (0x1f000000). Все обращения к регистрам — 32-битные: wstrb мы игнорируем (отклонение D-1), поэтому байтовая запись затронет весь регистр целиком.
Offset | Имя | R/W | Сброс | Назначение |
|---|---|---|---|---|
| CONTROL | R/W |
| EN, START, SOFT_RST, CPOL/CPHA, LSB_FIRST, IRQ_EN |
| STATUS | RO |
| live-состояние и 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 по ошибкам и 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 |
| паспорт IP |
Апертура — 64 байта, декодируется по awaddr[5:2] (отклонение D-4). Если отобразить в Device Tree больший диапазон, регистры начнут зеркалиться: адрес 0x40000040 попадёт в тот же CONTROL. Обращение по незанятому смещению не даст ошибки шины — ответ всегда OKAY, чтение вернёт ноль (D-3).
Биты CONTROL: [0] EN, [1] START (самосбрасывающийся строб), [2] SOFT_RST (тоже строб), [3] CPOL, [4] CPHA, [5] LSB_FIRST, [6] IRQ_EN. Полезная особенность: одна запись может одновременно включить контроллер и запустить транзакцию — START принимается, если EN уже стоит или устанавливается этой же записью.
Биты STATUS: [0] BUSY, [1] DONE (sticky), [2] RX_VALID, [3] TX_READY, [4] ENABLED, [5] TX_EMPTY, [6] RX_FULL, [8] ERR_TX_OVF, [9] ERR_RX_OVF — мёртвый бит, дефект A-5, [10] ERR_START, [11] ERR_WORD_LEN. Знакомое значение 0x2A в покое означает, что TX FIFO пуст и готов принимать данные (биты 3 и 5); полную расшифровку значения сброса, подтверждённую измерением, смотрите в register_map.md.
Про бит 9 стоит помнить отдельно: переполнение приёмного FIFO существует, но в STATUS не видно никогда. Единственный работающий способ его заметить — бит ERROR в IRQ_STATUS. Драйвер, который опрашивает только STATUS, потерю данных не обнаружит. Подробный разбор — в части II.
Частота SCLK считается по формуле:
f_SCLK = f_clk / (2 · (DIV + 1)) # DIV = CLK_DIV, либо FAST_DIV при TX_FASTПри тактовой частоте PL 50 МГц это даёт знакомый набор значений: запись нуля молча игнорируется (дефект A-4, делитель сохраняет прежнее значение), единица формально принимается, но портит приём (дефект A-3), двойка даёт 8.33 МГц и является минимально допустимой, четвёрка — номинальные 5 МГц, значение 24 даёт 1 МГц и удобно для логического анализатора, 249 — сотню килогерц, а предельные 65535 опускают SCLK до 381 Гц.
Важная оговорка про источник такта: в автономной сборке из части V логика тактируется собственным осциллятором платы на 50 МГц (вывод W17), а в сборке с процессорной системой — сигналом FCLK_CLK0, который в текущем Block Design настроен на 100 МГц. Один и тот же делитель даёт там вдвое большую частоту: CLK_DIV = 24 — это 1 МГц в автономном варианте и 2 МГц в варианте с процессором, а честный потолок f_clk / 6 поднимается с 8.33 до 16.7 МГц. Поэтому драйвер обязан брать частоту через clk_get_rate(), а не хранить её константой.
DELAY_CFG упаковывает три задержки в такты системного клока: биты 7:0 — от падения CS до первого фронта SCLK, биты 15:8 — от последнего фронта до подъёма CS, биты 23:16 — пауза между словами внутри burst, во время которой CS остаётся активным. Прерывание собирается по простому правилу irq_out = CONTROL.IRQ_EN & |(IRQ_STATUS & IRQ_MASK), выход регистровый, то есть линия поднимается на такт позже самого события.
Про ERROR_CLR помните главное: запись в него не только сбрасывает sticky-флаги по маске, но и флашит оба FIFO. Сначала прочитайте данные, потом квитируйте — иначе непрочитанное слово исчезнет вместе с флагом.
Потоковые регистры существуют только в обёртке spi_axi4lite.v и только в DMA-сборке; ядро spi_reg_if о них не знает. В STREAM_CTRL бит 0 включает удержание CS при пустом TX FIFO, бит 1 запрещает запись в RX FIFO (без него длинный RAMWR обречён), бит 2 переключает тактирование на FAST_DIV, а бит 8 — импульс сброса sticky-флага EOT. В STREAM_STATUS бит 0 говорит, что движок занят, бит 1 — что принят beat с TLAST. Типовой конец кадра: дождаться !BUSY && EOT, затем записать CLR_EOT.
Проверка «живой ли IP и тот ли битстрим» из shell на плате выглядит так:
devmem 0x4000003C 32 # паспорт: ждём 0x53500200 на DMA-сборке
devmem 0x40000004 32 # STATUS: в покое обычно 0x2A
devmem 0x40000008 32 4 # CLK_DIV = 4 → 5 МГц; меньше двух не ставить
devmem 0x40010000 32 7 # GPIO дисплея: подсветка, сброс, D/C
devmem 0x40000030 32 7 # STREAM_EN | TX_ONLY | TX_FAST
devmem 0x40000030 32 0x100 # CLR_EOT после кадраОдно предостережение, которое стоило нам данных: devmem 0x40000018 — это не безобидное чтение, а pop из RX FIFO. Каждое «посмотрю-ка, что там» уносит одно принятое слово. По той же причине не наводите на этот адрес отладчик, который любит спекулятивные чтения.
Минимальная последовательность одной транзакции в режиме 0 на языке C выглядит так — это тот же порядок, которому следует и драйвер:
#define SPI_BASE 0x40000000u
#define SPI_CONTROL (SPI_BASE + 0x00)
#define SPI_STATUS (SPI_BASE + 0x04)
#define SPI_CLK_DIV (SPI_BASE + 0x08)
#define SPI_CS_SELECT (SPI_BASE + 0x0C)
#define SPI_WORD_LEN (SPI_BASE + 0x10)
#define SPI_TX_DATA (SPI_BASE + 0x14)
#define SPI_RX_DATA (SPI_BASE + 0x18)
#define SPI_DELAY_CFG (SPI_BASE + 0x1C)
#define SPI_ERROR_CLR (SPI_BASE + 0x28)
Xil_Out32(SPI_ERROR_CLR, 0xFFFFFFFFu); /* квитировать всё, очистить FIFO */
Xil_Out32(SPI_CLK_DIV, 4u); /* 5 МГц при 50 МГц; не меньше 2 */
Xil_Out32(SPI_CS_SELECT, 0u);
Xil_Out32(SPI_WORD_LEN, 8u);
Xil_Out32(SPI_DELAY_CFG, 0x00020202u); /* setup / hold / inter по 2 такта */
Xil_Out32(SPI_TX_DATA, 0xA5u);
Xil_Out32(SPI_CONTROL, (1u << 0) | (1u << 1)); /* EN и START одной записью */
while (1) {
u32 st = Xil_In32(SPI_STATUS);
if ((st & (1u << 1)) && !(st & (1u << 0))) /* DONE && !BUSY */
break;
}
u32 rx = Xil_In32(SPI_RX_DATA); /* сначала данные... */
Xil_Out32(SPI_ERROR_CLR, 0x1u); /* ...потом квитирование */Приложение C. Шпаргалка команд
Vivado
Обычная пересборка проекта с процессорной системой запускается одной командой, но после правки RTL этого недостаточно:
vivado -mode batch -source scripts/build_ps_axi.tcl
# после изменений в rtl/*.v — принудительный пересинтез IP:
vivado -mode batch -source scripts/rebuild_ps_axi_rtl.tclВторой скрипт существует именно потому, что мы однажды потеряли вечер на отладку кода, которого в кристалле не было: верхний уровень пересобрался, а out-of-context IP взялся из старого checkpoint. В графическом интерфейсе тот же эффект достигается через Flow Navigator → Design Runs → Reset Runs для system_spi0_0_synth_1, и только потом синтез, имплементация и генерация битстрима. Быстрая проверка, что пересборка действительно произошла: время модификации system_spi0_0.dcp должно быть не раньше времени правки соответствующего rtl/*.v.
XSCT и JTAG до Linux
connect
targets -set -filter {name =~ "ARM*#0"}
stop
source ps7_init.tcl
ps7_init; ps7_post_config
fpga -f system.bitПорядок здесь не декоративный: ps7_init настраивает клоки, DDR и разрешает level shifters, и без него загруженный битстрим будет вести себя как мёртвый. Обратная сторона: делать это на работающем Linux нельзя — мы так теряли Ethernet до перезагрузки. Для повседневной смены логики кладите system.bit на FAT-раздел карты и перезагружайтесь.
Buildroot и карта памяти
На FAT-разделе живут загрузочные артефакты: boot.bin с FSBL, U-Boot, system.bit и дерево устройств system.dtb. Корневая файловая система — на ext-разделе. Сборка образа выполняется штатным для Buildroot способом (имя defconfig берите из своего дерева):
make <defconfig>make -j$(nproc)Работа с платой по ssh
devmem 0x4000003C 32 # что за битстримecho 40000000.spi > /sys/bus/platform/drivers/spi-zynq-fpga/unbind # отдать железо userspace./spi-clock --dma # часы через DMAecho 40000000.spi > /sys/bus/platform/drivers/spi-zynq-fpga/bind # вернуть драйверуip addr # какой сейчас адресdmesg | grep -iE 'eth|mac|spi' # что говорило ядроПосле перезагрузки адрес, выданный по DHCP, может смениться — это не поломка, а нормальное поведение сети; смотрите UART или список аренд на роутере.
Приложение D. Каталог дефектов
Этот каталог — индекс, а не пересказ. Каждая строка отвечает на один вопрос: «мы это уже видели, и если да, в какой части читать разбор». Сами истории живут в основных частях, потому что в них важен не факт, а путь от ложной гипотезы к улике.
Если вы повторяете проект и поймали свою ошибку, запишите её в том же формате — это самая полезная привычка, которую можно унести из всей серии.
Дефекты, унаследованные от исходного Altera-ядра, разобраны в части II:
ID | Суть | Где читать |
|---|---|---|
A-1 |
| ч. II |
A-2 |
| ч. II |
A-3 |
| ч. II, ч. V |
A-4 |
| ч. II, прил. B |
A-5 |
| ч. II |
A-6 | Неполные пины в исходном QSF | ч. IV |
Осознанные отклонения обвязки и ограничения для софта — это не баги, а контракты, но нарушать их так же больно:
ID | Суть | Где читать |
|---|---|---|
C-2 | SCLK не объявляется generated clock | ч. IV |
C-3 | MISO асинхронен, 2FF плюс false path | ч. IV |
D-1…D-4 | Игнор | ч. III, прил. B |
F-1 | CS нельзя удержать отдельно от burst | ч. VI, ч. IX |
F-2 | Дозаливка FIFO без stream ненадёжна | ч. VI, ч. IX |
F-3 | Квитирование IRQ флашит FIFO | ч. VI |
Ошибки дисплея, потока и сети — самая дорогая часть проекта по потраченному времени:
ID | Суть | Где читать |
|---|---|---|
LCD-1 | Призраки после смены MADCTL без полной очистки | ч. VIII |
LCD-2 | Построчная отправка поднимает CS и рвёт RAMWR | ч. VIII, ч. IX |
LCD-3 | Неверный MADCTL или смещение Y | ч. VIII |
LCD-4 | Нет TX_ONLY при MISO, подтянутом в единицу | ч. VIII, ч. IX |
DMA-1 | FIFO глубины 8 роняет CS посреди кадра | ч. IX |
DMA-2 | Сброс DMA при активном STREAM_EN даёт ложный TLAST | ч. IX |
DMA-3 | Сброс на каждом кадре «сжимает» шрифт | ч. IX |
DMA-4 | Registered | ч. IX |
DMA-5 | Устаревший OOC | ч. IX |
DMA-6 | В PIO работает, в DMA ломается | ч. IX |
ETH-1 | Чужой | ч. VII |
ETH-2 | Адрес PHY и HSTL вместо LVCMOS18 | ч. VII |
ETH-3 | JTAG под живым Linux убивает сеть, DHCP меняет адрес | ч. IX |
Приложение E. Ресурсы Quartus и Vivado
Сравнивать «логические элементы» Cyclone IV с LUT в 7-series в лоб бессмысленно: это разные единицы с разной внутренней структурой. Честный вывод из цифр другой — он не про качество ядра, а про цену обвязки: сам SPI-мастер занимает считанные сотни ячеек, а всё, что делает из него устройство под Linux, стоит заметно дороже.
Платформа | Что известно | Оговорка |
|---|---|---|
Quartus, Cyclone IV | Полное demo около 1.7k LE, примерно 28% EP4CE6; блоков памяти и PLL в самом SPI-ядре нет | Точные строки — в Altera-серии, |
Vivado, OOC-синтез IP | 405 LUT (из них 44 как память) и 406 триггеров, ни одного тайла блочной памяти, ни одного DSP | Только IP с мелким FIFO, без Block Design и без DMA: |
Vivado, сборка с DMA | 3319 LUT (1815 логика, 1504 память, из них 1410 распределённого ОЗУ), 2061 триггер, один тайл RAMB36, 0 DSP | Снято с нашей сборки: |
Одна деталь из последней строки заслуживает внимания, потому что она контринтуитивна: передающая очередь на 1024 слова легла не в блочную память, а в распределённое ОЗУ на логических ячейках. Причина в том, что выход чтения нашего FIFO комбинационный, а блочная память 7-й серии так не умеет — у неё чтение всегда через регистр. Единственный занятый тайл RAMB36 принадлежит не нам, а внутренним буферам axi_dma. Подробный разбор — в части IX.
Последняя строка — не отговорка, а принцип. Числа, которые нельзя воспроизвести на своей сборке, в статье вреднее отсутствия чисел: читатель получит цифру, сверит со своей, не сойдётся и потеряет доверие ко всему остальному тексту. Сверять при повторении проекта стоит не utilization, а контракт регистров и осциллограммы CS и SCLK — они говорят о правильности гораздо больше.
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.