Продолжаем разработку 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, который поднимал линк, но не пропускал пакеты.

  1. Цепочка загрузки: FSBL, U‑Boot, битстрим, ядро, rootfs.

  2. Ловушка чужого ps7_init от отладочной платы ZC702.

  3. Девять ошибок сборки и конфигурации образа.

  4. Device Tree: узлы, тактовые сигналы, зарезервированная память.

  5. Драйвер spi-zynq-fpga: probe, проверка паспорта, регрессии.

  6. Ethernet: адрес PHY и режим пинов против рабочей сети.

Часть VIII. Дисплей ST7789 на PL SPI (главы 32–35)

Дисплей как честный критерий успеха: он показывает то, что осциллограф показать не может. Инициализация панели, ориентация, смещение окна, и первые артефакты, которые оказались подсказками к будущей истории с DMA.

  1. Зачем нужен дисплей в этом эксперименте.

  2. Линии D/C, RST и подсветки через AXI GPIO.

  3. Инициализация панели, MADCTL и смещение по вертикали.

  4. Программа spi-clock: /dev/mem, unbind и перерисовка плитки.

Часть IX. Потоковый режим и AXI DMA (главы 36–43)

Ядро статьи. Почему FIFO глубины восемь физически не может переварить полный кадр, как устроен путь данных от DDR до пина, какие регистры для этого появились — и шесть тяжёлых ошибок: от ложного признака конца передачи до гонки в собственном FIFO и устаревшего кэша синтезатора, из‑за которого исправленный код существовал в git, но не в кристалле.

  1. Почему глубина FIFO 8 и штатный CS ломают полный кадр.

  2. Путь данных: DDR, порт HP0, DMA, поток, глубокий FIFO, удержание CS.

  3. Новые регистры STREAM_CTRL, STREAM_STATUS, FAST_DIV и версия IP.

  4. Режим TX_ONLY и почему без него длинный RAMWR обречён.

  5. Программные грабли spi-clock.

  6. Гонка в FIFO и лечение через almost_full.

  7. Устаревший out‑of‑context checkpoint: фикс в git, старое железо в кристалле.

  8. Загрузка по JTAG под живым Linux и правильный путь через FAT.

Часть X. Итоги (главы 44–46)

Что перенесли без изменений, что добавили, что осталось привязанным к вендору; какие архитектурные решения оказались верными, а за какие пришлось поплатиться; программа лабораторных для повторения пути и направления для развития проекта.

  1. Что перенесли, что добавили, что осталось vendor‑specific.

  2. Программа лабораторных: как повторить путь.

  3. Куда расти дальше.

Приложения

Приложение 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) с корневой ФС.

Файл

Кто создаёт

Где лежит

Зачем он нужен

boot.bin

U‑Boot SPL

FAT

FSBL: BootROM ищет его. Внутри ps7_init

u-boot.img

U‑Boot

FAT

Полный загрузчик, работает уже в DDR

boot.scr

mkimage из boot.cmd

FAT

Сценарий: сперва PL, потом ядро

system.bit

Vivado, кладёт post-image.sh

FAT

Конфигурация ПЛИС

zImage

ядро Linux 6.12.102

FAT

Собственно ядро

system.dtb

dtc из нашего DTS

FAT

Описание железа для ядра (гл. 29)

rootfs.ext4

Buildroot

2-й раздел

BusyBox, ssh, модуль, утилиты

sdcard.img

genimage

—

Всё перечисленное одним файлом под dd

Размеры из ../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

0xF8006004 (конфигурация памяти)

0x00001082

0x00001081

Различается не только количество записей — различается содержимое. Другая конфигурация 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. Вот что в нём есть и зачем.

Узел / свойство

Значение

Что оно включает

compatible

tzt,zynq-fpga-spi-1.0

Совпадает с of_device_id драйвера

reg

<0x40000000 0x1000>

Адрес из Address Editor (часть VI)

interrupts

<0 29 4>

IRQ_F2P[0], уровневое, active‑high

num-cs

<4>

Параметр синтеза NUM_CS

fifo-depth

<1024>

Глубокий FIFO DMA‑сборки; по умолчанию было бы 8

spidev@0

rohm,dh2228fv, 16 МГц

Даёт /dev/spidev* для userspace

reserved-memory

memory@1f000000, 1 МиБ

Буфер под DMA, no-map (часть IX)

&clkc

fclk-enable = <0x1>

Запрет гасить FCLK0

chosen/bootargs

clk_ignore_unused

Страховка того же назначения

gpio@40010000

status = "disabled"

AXI GPIO дисплея, до проверки FCLK0

dma@40400000

disabled, IRQ <0 30 4>

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 0×5350

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 или 0x53500200

30.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 = <&ethernet_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 (0x1)

W13

0 — команда, 1 — данные

отличает 0x2C от пикселя

RST

1 (0x2)

AA18

активный низкий

аппаратный сброс контроллера

BL

2 (0x4)

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 0×60, Y_OFF=34 и призраки старого кадра

34.1. Команды, окно и RAMWR

Полный набор команд ST7789 занимает несколько десятков позиций, но для того, чтобы получить картинку, нужна лишь горстка. Ниже те, которые встречаются в spi-clock.c; коды взяты прямо из кода, назначение — из общепринятой терминологии ST7789, отражённой в именах переменных того же файла.

Код

Имя

Что делает

0x01

SWRESET

программный сброс контроллера, требует паузы

0x11

SLPOUT

выход из спящего режима, запуск внутренних преобразователей

0x3A

COLMOD

формат пикселя; аргумент 0x55 — 16 бит, RGB565

0xB2

PORCTRL

тайминги гашения (порчи) внутренней развёртки

0xB7

GCTRL

напряжения затворов матрицы

0xBB

VCOMS

опорное напряжение VCOM

0xC2

VDVVRHEN

разрешение программной установки VRH и VDV

0xC3

VRHS

амплитуда напряжения питания матрицы

0xC4

VDVS

смещение VDV

0xC5

VCMOFSET

подстройка VCOM

0xD0

PWCTRL1

параметры внутреннего преобразователя питания

0x36

MADCTL

порядок обхода GRAM: ориентация и зеркала

0x21

INVON

включение инверсии цвета (нужна этим IPS‑модулям)

0x29

DISPON

включить вывод GRAM на стекло

0x2A

CASET

диапазон столбцов окна записи

0x2B

RASET

диапазон строк окна записи

0x2C

RAMWR

начать запись пикселей в текущее окно

Команды с 0xB2 по 0xD0 — это настройка аналоговой части: напряжений, которыми контроллер управляет жидкими кристаллами. Разбираться в их физическом смысле для нашей задачи не нужно: значения берутся из типовой последовательности производителя модуля, ровно как в драйвере fb_st7789v из ядра Linux, на который ссылается спецификация. Интересны нам три последние строки таблицы плюс MADCTL — именно они определяют, куда ляжет пиксель.

34.2. Инициализация панели по шагам

Ниже — последовательность из panel_init в том же порядке, что и в коде. Каждый шаг стоит понимать, а не копировать: половина расследований главы началась с попытки поменять один из них.

  1. Аппаратный сброс. Подсветка включается, линия RST удерживается в нуле 20 миллисекунд, затем поднимается, и мы ждём ещё 120 миллисекунд. Пауза нужна не «на всякий случай»: контроллер после снятия сброса поднимает внутренние генераторы напряжений, и до их стабилизации он команды не воспринимает.

  2. Настройка 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.

  3. SWRESET (0x01) и пауза 150 мс. Программный сброс поверх аппаратного приводит регистры контроллера в известное состояние. Это важно, если панель до нас уже кто‑то инициализировал — например, предыдущий запуск нашей же программы.

  4. SLPOUT (0x11) и пауза 120 мс. Выход из спящего режима. До этой команды контроллер экономит энергию и не обслуживает матрицу. Пауза снова обязательна: внутренним преобразователям нужно время выйти на режим.

  5. COLMOD (0x3A) с аргументом 0x55. Объявляем формат пикселя: 16 бит, RGB565. С этого момента контроллер считает, что каждые два байта после RAMWR — это один пиксель. Ошибка здесь превращает изображение в шум.

  6. Блок аналоговых настроек: 0xB2, 0xB7, 0xC2, 0xC3, 0xC4, 0xBB, 0xC5, 0xD0. Восемь команд с аргументами {05 05 00 33 33}, 0x75, {01 FF}, 0x13, 0x20, 0x22, 0x20, {A4 A1} соответственно. Это рецепт производителя модуля; менять эти числа наугад вредно — они задают напряжения на матрице.

  7. MADCTL (0x36) с аргументом 0x60. Ориентация. Ей посвящено следующее расследование целиком.

  8. INVON (0x21). Включение инверсии. У IPS‑модулей этого типа полярность такова, что без инверсии чёрный выглядит белым и наоборот. Если ваша заливка 0x0000 даёт белый экран — вы просто пропустили эту команду.

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

0x40000000

CONTROL … ID_VERSION, весь обмен с панелью

GPIO

0x40010000

три бита DC, RST, BL

AXI DMA Lite

0x40400000

канал MM2S, используется в части IX

Буфер DMA

0x1f000000

зарезервированная память, 1 МиБ, no-map

Первые два отображения программа делает всегда, вторые два — только при запуске с ключом --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 (spi_axi4lite)

0x40000000

64 КиБ окна, 64 байта апертуры

CPU через GP0

GPIO дисплея (DC/RST/BL)

0x40010000

64 КиБ

CPU через GP0

Регистры AXI DMA

0x40400000

64 КиБ

CPU через GP0

Буфер кадра в DDR

0x1f000000

1 МиБ, no-map

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% от 17 400 доступных под такую роль. Регистров 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, смещение 0×30

Бит

Имя

Семантика

0

STREAM_EN

Держать CS при пустой очереди TX до прихода EOT. Также разрешает TREADY на AXIS: при нуле мост не принимает данные вообще

1

TX_ONLY

Не писать принятые слова в RX FIFO (глава 39)

2

TX_FAST

Брать делитель из FAST_DIV вместо CLK_DIV; допускаются значения < 2

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 (0×34), FAST_DIV (0×38) и ID_VERSION (0×3C)

Регистр

Биты

Смысл

STREAM_STATUS

[0] BUSY

Движок занят (eng_busy напрямую из автомата)

STREAM_STATUS

[1] EOT

Мост принял порцию с TLAST; залипает до CLR_EOT

FAST_DIV

[15:0]

Полупериод SCLK = FAST_DIV + 1 тактов, когда включён TX_FAST

ID_VERSION

[31:16] / [15:0]

0x5350 (буквы «SP») и версия: 0x0100 = v1 PIO, 0x0200 = v2 stream/DMA

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

📊 Источник: Habr | Оригинал
← Предыдущая Как мы сделали бота-консультанта: нейросети и машинное обучение Следующая → Сверхпроводящий квантовый тепловой двигатель