Раз уж пошла такая песня, я решил добить вопрос с выводом изображения по HDMI на плате Zynq Mini. В предыдущей статье мы уже разобрали как это сделать в baremetal-сценарии, теперь пришла пора дополнить дизайн для возможности вывода системной консоли Linux, при том, что у Zynq-7000 нет своего видеоконтроллера и весь видеотракт (DMA, генератор таймингов, TDMS-кодер) должен быть построен внутри FPGA, а затем быть проброшен в Linux. 

Всем, кому интересна данная тема - добро пожаловать под кат!

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

Идея и постановка задачи

Есть плата - Zynq Mini Rev.B на чипе xc7z020clg400-2 с 512 МиБ DDR3. На плате распаян HDMI-разъём. Хочется простую, понятную любому линуксоиду вещь: воткнуть монитор в этот разъём, подать питание - и увидеть на экране загрузку Linux и приглашение login:. Как на обычном ПК.

Казалось бы, что тут сложного? Подключаем монитор, включаем - и… ничего. Чёрный экран, «No signal».

Дело в том, что у Zynq-7000 нет видеовыхода. Вообще. Если посмотреть на схему платы, выяснится, что HDMI-разъём разведён напрямую на обычные пины программируемой логики (PL) - четыре дифференциальные пары и сигнал разрешения выходного буфера:

Сигнал

Пин FPGA

HDMI_CLK_P / N

H16 / H17

HDMI_DATA0_P / N

D19 / D20

HDMI_DATA1_P / N

C20 / B20

HDMI_DATA2_P / N

B19 / A20

HDMI_OUT_EN

H18

Никакого HDMI-трансмиттера на плате нет. Никакого видеоконтроллера в чипе нет. Эти пины умеют ровно то, что умеет любой пин FPGA: выдавать нули и единицы. Всё остальное - формирование TMDS-сигнала, генерацию видеотаймингов, выкачивание пикселей из памяти - нам предстоит построить самим внутри FPGA, а потом научить Linux всем этим пользоваться.

Именно этим задача и интересна. Она не решается «включением галочки» - она прошивает насквозь весь embedded-стек, слой за слоем:

  1. Схемотехника - прочитать схему платы и понять, куда и как разведён HDMI.

  2. FPGA-дизайн - собрать в Vivado видеоконвейер: DMA-движок, читающий кадр из DDR, генератор таймингов, TMDS-сериализатор.

  3. Загрузчик - собрать U-Boot SPL, который поднимет DDR и зальёт прошивку в FPGA ещё до старта ядра.

  4. Ядро Linux - описать наш самодельный «видеоадаптер» в Device Tree и включить framebuffer-консоль.

  5. Userspace - написать утилиту инициализации конвейера и встроить её в загрузку системы через Buildroot.

Пройдя этот путь один раз, вы будете понимать, как на Zynq устроено вообще всё: как PS разговаривает с PL, как грузится система, откуда ядро узнаёт о железе и почему на экране появляются буквы. По сути, мы соберём с нуля простейшую «видеокарту» и напишем к ней поддержку.

Конечный результат выглядит так: плата стартует с microSD-карты, на HDMI-мониторе бежит лог загрузки ядра, затем появляется приглашение zynq-mini-revb login:. Подключаем USB-клавиатуру - и работаем в консоли прямо на мониторе, без всяких UART-переходников. 

Весь проект - скрипты Vivado, Device Tree, конфигурация Buildroot, исходники утилиты инициализации - лежит в репозитории, и каждый шаг статьи воспроизводим: от пустого каталога до готового образа SD-карты.

Дисклеймер про «честность» HDMI. Строго говоря, мы будем формировать DVI-D-сигнал в HDMI-разъёме: без звука, без InfoFrames, без HDCP. Любой монитор с HDMI-входом понимает такой сигнал - для консоли этого более чем достаточно. Подробности - в теоретической части.

Что понадобится?

Аппаратура

Компонент

Зачем нужен

Zynq Mini Rev.B

Целевая плата (xc7z020clg400-2, 512 МиБ DDR3)

microSD-карта (≥ 512 МиБ)

Загрузка системы: загрузчик + ядро + rootfs

USB-UART кабель (на плате - через Type-C)

Отладочная консоль ttyPS0, 115200 8N1. Строго обязателен: пока HDMI не заработал, это единственные «глаза»

HDMI-монитор + кабель

Собственно цель: консоль tty0

USB-клавиатура (опционально)

Ввод прямо на HDMI-консоли

Ethernet-кабель (опционально)

SSH, перекидывание файлов

JTAG-программатор не обязателен: весь цикл построен вокруг загрузки с SD-карты.

Статья привязана к конкретной плате, но переносится на любую Zynq-7000-плату, где HDMI (или просто TMDS-пары) разведён на PL: Zybo, Arty Z7, PYNQ-Z1/Z2 и им подобные. Поменяются только пины в constraints и конфигурация PS под вашу память и периферию.

Программное обеспечение (хост - Linux x86_64)

Компонент

Версия в статье

Комментарий

Vivado + Vitis

2025.2, /opt/xilinx/2025.2

Синтез FPGA-дизайна, экспорт платформы, bootgen, XSCT. Подойдёт и 2022.2+

Buildroot

2024.02.7

Сборка U-Boot, ядра и rootfs; скачивается архивом по ходу статьи

git, wget, unzip

любые свежие

Клонирование Digilent IP, скачивание тарболов

Vivado - самый тяжёлый пункт: дистрибутив на десятки гигабайт и небыстрая установка. Если он у вас ещё не стоит - ставьте параллельно с чтением теоретической части. Для нашего чипа достаточно бесплатной Standard-редакции (xc7z020 не требует платной лицензии).

Зависимости Buildroot (Ubuntu/Debian):

sudo apt update
sudo apt install -y \
 build-essential gcc g++ make patch gzip bzip2 unzip bc \
 cpio rsync file wget curl git \
 libncurses-dev libssl-dev \
 python3 python3-setuptools

Кросс-компилятор отдельно ставить не нужно - Buildroot соберёт собственный тулчейн сам.

Что нужно знать читателю заранее

  • Уверенно работать в Linux-консоли: make, dd, монтирование разделов.

  • Представлять, что такое FPGA, хотя бы на уровне «конфигурируемая логика, прошивается bitstream'ом». Писать на Verilog не потребуется - дизайн собирается из готовых IP-блоков.

  • Чем Device Tree отличается от bitstream'а, знать не нужно - это мы разберём.

Вся специфика - TMDS, видеотайминги, AXI, VDMA, цепочка загрузки Zynq, устройство Buildroot - объясняется по ходу статьи с нуля.

Общая карта маршрута

Прежде чем нырять в детали, посмотрим на путь целиком - к этой схеме полезно возвращаться из любой главы, чтобы не терять ориентиров. 

Сборка состоит из двух больших, поначалу независимых веток - «железной» (Vivado) и «софтовой» (Buildroot), - которые сходятся в финальном загрузочном образе:

Из схемы видно главное правило пересборки, к которому мы ещё вернёмся: ветки сходятся дважды. Файлы ps7_init из XSA вшиваются в U-Boot SPL, а bitstream упаковывается в BOOT.BIN. Поэтому если изменили FPGA-дизайн, то пересобирайте обе точки стыковки; изменили только rootfs или Device Tree - достаточно софтовой ветки.

Маршрут по главам статьи:

  1. Теория (часть 1) - как картинка вообще попадает на HDMI: TMDS, видеотайминги, архитектура Zynq (PS + PL), видеоконвейер, framebuffer-консоль Linux и цепочка загрузки. Самая длинная «лекционная» часть; всё остальное - практика со ссылками сюда.

  2. Схема платы (часть 2) - читаем schematic, выписываем пины HDMI и периферии.

  3. Vivado (часть 3) - пошагово, через GUI, собираем блок-дизайн видеоконвейера, прогоняем синтез, получаем bitstream и файл аппаратной платформы .xsa.

  4. Мост между мирами (часть 4) - извлекаем из .xsa файлы ps7_init: параметры инициализации DDR и клоков, без которых загрузчик не оживит память. Маленькая глава про шаг, на котором спотыкаются чаще всего.

  5. Buildroot (часть 5) - собираем U-Boot SPL, ядро linux-xlnx и rootfs; разбираем построчно Device Tree, конфиг ядра и нашу утилиту hdmi-init, которая запускает видеоконвейер.

  6. BOOT.BIN (часть 6) - упаковываем SPL, bitstream и U-Boot в единый загрузочный файл с помощью bootgen.

  7. Запуск (часть 7) - пишем SD-карту, выставляем boot mode, читаем загрузочный лог и встречаем login: на мониторе.

  8. Сквозной разбор (часть 8) - прослеживаем путь одного символа от printk до свечения пикселя через все слои, которые мы построили.

  9. Troubleshooting (часть 9) - что делать, когда «No signal», и другие реальные грабли.

Время на полный проход: сборка FPGA - 30-90 минут машинного времени, первая сборка Buildroot - 1-3 часа (скачает и скомпилирует тулчейн, U-Boot, ядро и все пакеты). Запаситесь кофе - а пока всё компилируется, читается теория из части 1, к которой мы и переходим.

Часть 1. Теория: как вообще картинка попадает на HDMI

Это самая длинная «лекционная» глава статьи. В ней нет ни одной команды для запуска, но после неё каждый практический шаг будет осмысленным, а не магическим. Мы пройдём весь путь сигнала снизу вверх: от физики дифференциальных пар до того, как ядро Linux решает, куда печатать login:.

Главу можно читать подряд, а можно возвращаться к разделам из практических частей - ссылки там расставлены.

1.1. HDMI глазами электронщика: DVI и TMDS

Что на самом деле в разъёме

Отбросим маркетинг. Если посмотреть на HDMI-разъём как инженер-электронщик, мы увидим:

  • 4 дифференциальные пары: три канала данных (Data0, Data1, Data2) и канал тактовой частоты (Clock);

  • служебные линии: I²C-шину DDC (по ней монитор отдаёт EDID - список поддерживаемых режимов), Hot Plug Detect, питание +5 В;

  • землю и экраны.

Вся картинка передаётся по этим четырём парам. Физический уровень называется TMDS - Transition Minimized Differential Signaling. Это токовая дифференциальная передача: источник тянет ток через пару, приёмник на стороне монитора имеет подтяжки к +3.3 В через 50 Ом. Размах сигнала небольшой (сотни милливольт), зато частоты - сотни мегагерц на пару.

Ключевой факт для нас: TMDS-пары можно сформировать обычными пинами FPGA. В 7-й серии Xilinx для этого есть стандарт ввода-вывода TMDS_33 - псевдодифференциальный выход на банке с питанием 3.3 В. Именно поэтому на нашей плате HDMI-разъём разведён прямо на PL: внешний трансмиттер не нужен.

HDMI или DVI? Честный ответ

HDMI исторически вырос из DVI и на уровне видеосигнала полностью с ним совместим. Разница в том, что HDMI дополнительно умеет вставлять в интервалы гашения «островки данных» (data islands): звук, InfoFrames с описанием формата, HDCP-шифрование.

Мы будем формировать чистый DVI-D-сигнал: только видео и синхронизация, без звука и метаданных. Любой монитор с HDMI-входом обязан такой сигнал понимать - с точки зрения монитора это просто «HDMI без опций». Для текстовой консоли большего и не требуется.

Поэтому везде дальше, говоря «HDMI», мы имеем в виду «DVI-D-сигнал в HDMI-разъёме». IP-блок, который мы возьмём у Digilent, так и называется - rgb2dvi.

TMDS-кодирование: зачем 8 бит превращают в 10

Каждый из трёх каналов данных передаёт по одной цветовой компоненте: Data0 - синюю, Data1 - зелёную, Data2 - красную. По 8 бит на компоненту, итого 24 бита на пиксель (RGB888).

Но просто «выстрелить» 8 бит в линию нельзя. У длинного кабеля и приёмника есть требования:

  1. Минимум переключений. Каждый фронт сигнала - это излучение помех и джиттер. Хочется, чтобы соседние биты пореже отличались друг от друга.

  2. DC-баланс. Передача идёт через ёмкостную связь, и среднее значение сигнала должно держаться около нуля: нельзя слать «почти все единицы» долго.

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

TMDS решает всё это, кодируя каждые 8 бит данных в 10-битный символ в два этапа:

  • Этап 1 - минимизация переходов. Восемь бит прогоняются через цепочку XOR или XNOR (что даёт меньше переключений - выбирается на лету), девятый бит сообщает приёмнику, какой вариант был выбран.

  • Этап 2 - DC-баланс. Кодер ведёт счётчик «перекоса» (сколько единиц против нулей ушло в линию). Если очередной символ усугубляет перекос, он инвертируется, а десятый бит сообщает об инверсии.

В результате в линию уходит 10 бит на каждый байт. Отсюда главное соотношение TMDS: битовая скорость на каждой паре данных = 10 × пиксельная частота.

Для нашего режима с пиксельной частотой 40 МГц каждая пара данных работает на 400 Мбит/с. Канал Clock при этом передаёт просто пиксельную частоту (символ 1111100000 - пять тактов единицы, пять нули): по нему PLL монитора восстанавливает опору, а затем подстраивает выборку бит на каналах данных.

Видеопериоды и управляющие символы

Поток на паре данных состоит из периодов двух типов:

  • Video data period - активная часть строки: 10-битные символы пикселей.

  • Control period - интервалы гашения (между строками и кадрами): передаются 4 специальных 10-битных управляющих символа. Они подобраны так, что содержат много переключений и не встречаются среди видеосимволов - по ним приёмник выравнивает границы 10-битных слов.

В управляющих символах канала Data0 закодированы два бита - HSYNC и VSYNC. Так сигналы синхронизации доезжают до монитора, не занимая отдельных проводов. Каналы Data1 и Data2 в это время шлют «нулевой» управляющий символ.

Отсюда практический вывод, который нам ещё пригодится: интервалы гашения - не пустая трата времени, а обязательная часть протокола. Без них приёмник не сможет ни выровнять символы, ни увидеть синхронизацию.

Чем это сериализуется в FPGA

40 МГц пиксельной частоты - это 400 Мбит/с на пару. Логика FPGA на таких частотах напрямую не работает, но у каждого выходного пина Zynq 7-й серии есть аппаратный сериализатор OSERDESE2: он принимает параллельное слово на низкой частоте и выталкивает его в пин бит за битом в DDR-режиме (по обоим фронтам клока).

Схема такая: 10-битный TMDS-символ грузится на пиксельной частоте (40 МГц), а сериализатор тактируется частотой 5× = 200 МГц и за счёт DDR выдаёт 10 бит за один пиксельный такт. Два OSERDESE2 (master + slave) каскадируются для ширины 10 бит - всё это спрятано внутри IP-блока rgb2dvi, но соотношение клоков пиксельный × 5 = последовательный нам предстоит обеспечить самим, и к нему мы вернёмся в разделе 1.4 и в части 3.

Предел OSERDESE2 на HR-банках нашего чипа - около 950 Мбит/с, то есть честный потолок такой «самодельной» видеокарты - примерно 1280×720@60 (742.5 Мбит/с). Наши 400 Мбит/с - комфортный режим с запасом.

1.2. Видеотайминги VESA: что такое 800×600@60

Кадр больше, чем картинка

Наивное представление: «800×600 - значит, передаём 480 000 пикселей 60 раз в секунду». На самом деле передаваемый кадр больше видимого. Это наследие электронно-лучевых трубок: лучу нужно было время вернуться в начало строки (горизонтальный обратный ход) и в начало кадра (вертикальный). Аналоговые мониторы ушли в музеи, а структура сигнала осталась - теперь она, как мы видели в 1.1, используется для синхронизации и выравнивания приёмника.

Каждая строка состоит из четырёх участков:

│◄────────── активные пиксели ──────────►│◄─FP─►│◄─SYNC─►│◄──BP──►│
│                  800                   │  40  │  128   │   88   │
│◄──────────────────────── полная строка: 1056 ──────────────────►│
  • Active - видимые пиксели;

  • Front porch (FP) - пауза перед синхроимпульсом;

  • Sync - собственно импульс HSYNC;

  • Back porch (BP) - пауза после.

Вертикальная развёртка устроена так же, только единица измерения - строки.

Режим 800×600@60 по стандарту VESA DMT

Тайминги стандартизованы VESA в документе DMT (Display Monitor Timings). Для 800×600@60:

Параметр

Горизонталь (пиксели)

Вертикаль (строки)

Active

800

600

Front porch

40

1

Sync

128

4

Back porch

88

23

Итого (total)

1056

628

Полярность sync

положительная

положительная

Для конфигурации IP-блоков удобнее та же таблица в виде «координат» внутри строки/кадра (счёт от начала активной области):

Параметр

Значение

Смысл

H_ACTIVE

800

видимых пикселей в строке

H_SYNC_START

840

800 + 40 (front porch)

H_SYNC_END

968

840 + 128 (sync)

H_TOTAL

1056

968 + 88 (back porch)

V_ACTIVE

600

видимых строк

V_SYNC_START

601

600 + 1

V_SYNC_END

605

601 + 4

V_TOTAL

628

605 + 23

Запомните эту таблицу. Она встретится нам трижды: в конфигурации Video Timing Controller в Vivado (часть 3), в исходнике утилиты hdmi-init (часть 5) и неявно - в параметрах framebuffer в Device Tree. Все три места обязаны совпадать.

Считаем пиксельную частоту

Частота кадров 60 Гц задаёт всё остальное:

f_pixel = H_TOTAL × V_TOTAL × f_frame = 1056 × 628 × 60 ≈ 39.79 МГц

VESA округляет до ровно 40.000 МГц (фактическая частота кадров получается 60.32 Гц - мониторам всё равно, они подстраиваются по принятому клоку).

Почему именно 800×600, а не 1080p

Выбор режима для статьи - осознанный компромисс:

  1. Круглые 40 МГц. В PS Zynq мы возьмём опорные 100 МГц, умножим в MMCM на 10 (VCO = 1000 МГц - в допустимом диапазоне) и поделим: на 25 → 40 МГц пиксельных, на 5 → 200 МГц последовательных. Целые делители, никакой дробной магии, идеальная фазовая привязка обоих клоков - об этом в 1.4.

  2. Скромная полоса памяти. Конвейер будет непрерывно читать кадр из DDR: 800 × 600 × 3 байта × 60 Гц ≈ 86 МБ/с. Для памяти нашей платы (16-битная DDR3, теоретический потолок ~2.1 ГБ/с - подробнее в части 2) это около 4% - видеовывод гарантированно не задушит процессор. Full HD в RGB888 потребовал бы уже ~373 МБ/с и 742.5 Мбит/с на TMDS-пару - работоспособно, но без запаса.

  3. Совместимость. 800×600@60 - базовый режим VESA, его обязан понимать любой монитор и телевизор без чтения EDID. А EDID мы как раз читать не будем - наш конвейер выдаёт один фиксированный режим.

  4. Консоль выигрывает. Со шрифтом 8×16 на 800×600 помещается 100×37 символов - комфортная плотность текста для работы в консоли.

1.3. Архитектура Zynq-7000: PS + PL

Два мира в одном корпусе

Для тех, кто давно читает меня давно известно, что Zynq-7000 - это система на кристалле, в которой под одной крышкой живут два принципиально разных мира:

  • PS (Processing System) - «обычный» ARM-процессор: два ядра Cortex-A9, контроллер DDR3, кеши, прерывания и набор жёстких (hardwired) периферийных контроллеров: Gigabit Ethernet, USB, SD/SDIO, UART, SPI, I²C, QSPI. PS работает сразу после подачи питания, без всякой прошивки FPGA - это полноценный микропроцессор, способный загрузить Linux.

  • PL (Programmable Logic) - классическая FPGA поколения Artix-7. В нашем xc7z020: 85 тысяч логических ячеек, 140 блоков BRAM по 36 Кбит, 220 DSP-секций. PL после включения пуста и не делает ничего, пока в неё не загрузят bitstream.

Периферия PS выводится наружу через 54 пина MIO (Multiplexed I/O) - какой контроллер на какие ножки попадает, задаётся конфигурацией, а не прошивкой PL. На нашей плате через MIO работают UART-консоль, SD-карта, USB, Ethernet и QSPI. А вот HDMI к MIO не имеет отношения - он разведён на банки PL, и весь видеотракт мы будем строить в программируемой логике.

Мосты между мирами: AXI

Сами по себе PS и PL - соседи по кристаллу. Связывает их шинная инфраструктура AXI (Advanced eXtensible Interface) - стандарт ARM для внутрикристальных соединений. У AXI строгие роли: master инициирует транзакции (чтение/запись по адресу), slave на них отвечает.

Zynq предоставляет несколько портов между PS и PL, и для нашего проекта важно понять различие:

Порт

Кто master

Ширина

Назначение

M_AXI_GP0/GP1

PS

32 бита

Процессор обращается к регистрам IP-блоков в PL. Медленно, но удобно: «управляющий пульт»

S_AXI_HP0…HP3

PL

64 бита

Логика PL напрямую читает/пишет DDR-память, минуя процессор. Высокая пропускная способность

S_AXI_GP, S_AXI_ACP

PL

32/64

Прочие варианты (нам не понадобятся)

В нашем конвейере будут задействованы оба направления:

  • через M_AXI_GP0 процессор (точнее, утилита hdmi-init) запишет конфигурацию в регистры VDMA и VTC - несколько десятков транзакций один раз при старте;

  • через S_AXI_HP0 DMA-движок будет 60 раз в секунду выкачивать кадр из DDR - те самые 86 МБ/с, непрерывно и без участия процессора.

Это разделение - «медленное управление через GP, быстрые данные через HP» - типовой паттерн любого Zynq-дизайна, запомните его.

Кроме AXI, из PS в PL идут тактовые сигналы FCLK_CLK0…3 (четыре программируемых клока - PL не обязана иметь свой кварц), сигналы сброса FCLK_RESET, а в обратную сторону - линии прерываний IRQ_F2P, заведённые на контроллер прерываний GIC процессора.

Карта адресов: где живёт PL

С точки зрения процессора всё адресуется в едином 32-битном пространстве:

Диапазон

Что там

0x0000_0000 - 0x1FFF_FFFF

DDR3 (наши 512 МиБ)

0x4000_0000 - 0x7FFF_FFFF

окно M_AXI_GP0 → slave-устройства в PL

0x8000_0000 - 0xBFFF_FFFF

окно M_AXI_GP1 (не используем)

0xE000_0000 - …

жёсткая периферия PS (UART, SD, Ethernet…)

0xF800_0000 - …

системные регистры (SLCR, devcfg…)

Когда процессор читает или пишет адрес из окна GP0, транзакция уходит в PL - а уж какому блоку она достанется, решает логика interconnect, которую мы соберём в Vivado. Там же, в Address Editor, каждому slave-блоку назначается его адресный диапазон.

В нашем дизайне адреса выбраны так:

Блок

Базовый адрес

AXI I2C (задел на будущее, EDID)

0x43C0_0000

AXI VDMA (регистры DMA)

0x43C1_0000

VTC (генератор таймингов)

0x43C2_0000

Важная мысль, которая объясняет половину дальнейших действий: эти адреса - наша собственная договорённость. Железу всё равно; но эти же числа будут захардкожены в Device Tree и в исходнике hdmi-init. Поэтому в части 3 мы зафиксируем их в Address Editor вручную, а не доверимся автоназначению Vivado: пересоберёшь проект - а адреса «уехали», и софт молча пишет в пустоту.

И последнее: прерывания. Линии IRQ_F2P отображаются на входы GIC с фиксированными номерами; первая группа - interrupt ID 61-68. В Device Tree прерывания GIC записываются со смещением 32, поэтому первая PL-линия там фигурирует как 29 (61 − 32). Нашему конвейеру прерывания не критичны (инициализация - разовая), но мы их подключим: пригодятся, если когда-нибудь захочется написать настоящий драйвер.

1.4. Видеоконвейер в PL: из DDR в TMDS

Сквозная схема

Теперь соберём в голове весь тракт - то, что мы построим в Vivado в части 3:

Данные текут слева направо: пиксели лежат в DDR, DMA превращает их в поток, «стыковщик» накладывает поток на сетку таймингов, кодер превращает RGB в TMDS. Разберём каждый блок - именно в той логике, в которой потом будем выставлять его параметры.

Framebuffer: исходная точка

Framebuffer - это просто массив в DDR, где пиксели лежат построчно. Наш формат - RGB888, 3 байта на пиксель:

  • строка = 800 × 3 = 2400 байт (это число называется stride - шаг между началами соседних строк);

  • кадр = 2400 × 600 = 1 440 000 байт (~1.4 МиБ).

Мы разместим его по адресу 0x1E000000 - у самого конца 512-мегабайтной памяти - и попросим Linux эту область не трогать (как - увидим в 1.5 и части 5). Писать в буфер будет ядро Linux (рисуя консоль), читать - VDMA.

AXI VDMA: насос для пикселей

AXI VDMA (Video DMA, документация Xilinx PG020) - DMA-движок, заточенный под видео. Его работа: без участия процессора циклически читать кадр из памяти и превращать его в поток пикселей.

У VDMA два независимых канала:

  • MM2S (Memory-Mapped to Stream) - из памяти в поток: это вывод изображения, наш случай;

  • S2MM (Stream to Memory-Mapped) - из потока в память: захват видео, нам не нужен - отключим, сэкономив ресурсы PL.

Конфигурируется канал четырьмя главными регистрами (имена пригодятся в части 5, когда будем писать hdmi-init):

Регистр

Наше значение

Смысл

FRMBUF

0x1E000000

адрес начала кадра

HSIZE

2400

байт в строке

STRIDE

2400

шаг между строками (равен HSIZE - строки лежат вплотную)

VSIZE

600

строк в кадре; запись этого регистра запускает передачу

Дочитав кадр до конца, VDMA немедленно начинает читать его снова - 60 раз в секунду, в цикле. Режим, когда движок крутится по одному и тому же буферу, называется park (обычно VDMA умеет жонглировать несколькими кадровыми буферами - для double buffering, - но консоли достаточно одного, поэтому в Vivado поставим число framestores = 1).

На выходе MM2S - поток AXI4-Stream.

AXI4-Stream Video: поток, который знает о кадрах

Если memory-mapped AXI - это «чтение/запись по адресу», то AXI4-Stream - просто конвейер данных без адресов: source выставляет данные, sink забирает, рукопожатие tvalid/tready регулирует темп (нет готовности - поток притормаживает).

Для видео поверх AXI4-Stream принято соглашение (используется всеми видео-IP Xilinx):

  • tdata[23:0] - пиксель;

  • tuser - взводится на первом пикселе кадра (Start of Frame);

  • tlast - взводится на последнем пикселе строки (End of Line).

Этих двух меток достаточно, чтобы приёмник восстановил структуру кадра, ничего не зная о разрешении заранее, и мог обнаружить рассинхронизацию (строка оборвалась раньше времени → ошибка, а не молчаливый сдвиг всей картинки).

Тонкость, которая выстрелит дальше: порядок байт. В AXI4-Stream Video у Xilinx принято tdata[7:0] = красный... на самом деле компоненты упакованы в порядке, который не совпадает с порядком байт RGB888 в памяти Linux. Для текстовой консоли (чёрный фон, белые буквы, серые рамки - у всех трёх компонент равные значения) перестановка R↔B невидима, и мы сознательно на неё забьём. Но если вы потом выведете цветную картинку и удивитесь «синим помидорам» - вспомните этот абзац.

VTC: метроном развёртки

Поток пикселей из VDMA - это «что показывать». Но монитору нужно ещё и «когда»: та самая сетка из активных областей, porch'ей и синхроимпульсов из раздела 1.2.

Её генерирует VTC (Video Timing Controller, PG016) - программируемый счётчик развёртки. В него записывается таблица таймингов (наши 800/840/968/1056 и 600/601/605/628 - узнаёте?), и он непрерывно выдаёт сигналы: hsync, vsync, active_video. VTC умеет и обратное - детектировать тайминги входящего видео, - но эту половину мы отключим при конфигурации.

Заметьте: VTC не видит пикселей, а VDMA ничего не знает о таймингах. Эти два мира надо состыковать.

v_axi4s_vid_out: стыковщик

AXI4-Stream to Video Out (PG044) - блок, в котором сходятся обе линии:

  • со стороны VDMA - поток AXI4-Stream с метками SOF/EOL;

  • со стороны VTC - сетка таймингов.

Внутри - FIFO-буфер и автомат синхронизации. Логика работы: блок накапливает пиксели в FIFO, дожидается от VTC начала активной области и начинает выдавать параллельный RGB строго в темпе развёртки, сверяя метки кадра (tuser) с вертикальной синхронизацией. Пока синхронизация не поймана - на выходе пусто; поймана - взводится флаг locked.

Критически важная деталь, из-за которой у многих «почти работает»: выход vtg_ce (video timing generator clock enable). Этим сигналом vid_out тормозит VTC: если FIFO опустело (VDMA не успела подкачать данные - например, на старте), стыковщик приостанавливает генератор таймингов, чтобы не уехать развёрткой вперёд потока. Поэтому в блок-дизайне обязательна обратная связь vid_out.vtg_ce → vtc.gen_clken. Забудете - конвейер будет «иногда показывать, иногда нет», в зависимости от фазы луны.

На выходе стыковщика - уже «телевизионный» набор: 24 параллельных бита RGB + hsync + vsync + видимость (data enable), всё в темпе пиксельного клока.

rgb2dvi: кодер и сериализатор

Последний блок - rgb2dvi от Digilent (открытый IP из репозитория vivado-library). Внутри него - ровно то, что мы разобрали в части 1.1:

  • три TMDS-кодера 8b→10b (по одному на цветовой канал), в интервалах гашения подставляющие управляющие символы с HSYNC/VSYNC;

  • сериализаторы OSERDESE2 (10:1, DDR на 5× клоке);

  • выходные буферы TMDS_33 на четыре пары.

Снаружи остаётся подать пиксельный клок 40 МГц и последовательный 200 МГц. У блока есть параметр kGenerateSerialClk: если true, он сам умножит пиксельный клок внутренним MMCM. Мы поставим false и подадим оба клока от одного внешнего MMCM - почему, объясняю в следующем разделе.

Три клоковых домена

В дизайне сосуществуют три тактовые частоты, и понимать их границы - значит понимать что происходит в дизайне:

Домен

Частота

Откуда

Кто в нём живёт

AXI

50 МГц

FCLK_CLK0 из PS

регистровые интерфейсы VDMA/VTC, чтение из DDR (HP0), interconnect

Pixel

40 МГц

clk_wiz (MMCM)

потоковый выход VDMA, VTC, vid_out, параллельная часть rgb2dvi

Serial

200 МГц

clk_wiz, тот же MMCM

OSERDES внутри rgb2dvi

Откуда берутся 40 и 200: из PS заведём второй клок FCLK_CLK1 = 100 МГц как опору для блока clk_wiz (обёртка над MMCM - аппаратным умножителем/делителем частоты). MMCM умножит 100 МГц на 10 (внутренняя VCO = 1000 МГц) и поделит двумя выходами: ÷25 → 40 МГц, ÷5 → 200 МГц.

Здесь два неочевидных «почему», на которых ломаются самодельные дизайны:

Почему оба клока - из одного MMCM? Сериализатору OSERDES нужно, чтобы клок загрузки (40) и клок выдачи (200) были не просто кратны, а фазово выровнены - это гарантируется только когда оба сняты с одного VCO. Именно поэтому kGenerateSerialClk = false: вариант с внутренним MMCM блока rgb2dvi тоже корректен, но тратит второй MMCM впустую, когда у нас уже есть свой.

Как данные переходят между доменами 50 и 40 МГц? Граница проходит внутри VDMA: память она читает на 50 МГц, поток выдаёт на 40, а между ними - асинхронное FIFO (плюс ещё одно FIFO в vid_out). Пересечение клоковых доменов (CDC) - классический источник самых подлых багов FPGA; счастье в том, что в нашем конвейере все переходы спрятаны внутри проверенных IP. Наша обязанность - лишь честно сказать каждому блоку, в каком домене какой его порт, и подключить правильные клоки. Этим займёмся в части 3.

И последнее: каждому домену - свой сброс. Сигнал сброса должен сниматься синхронно с клоком своего домена, иначе часть логики выйдет из сброса на такт раньше соседей. В блок-дизайне за это отвечают блоки proc_sys_reset - по одному на домен AXI и pixel.

1.5. Как Linux рисует консоль: framebuffer и fbcon

Сменим оптику: вся «железная» часть построена, по адресу 0x1E000000 лежат пиксели, конвейер гонит их на монитор. Теперь нужно, чтобы Linux начал в эти пиксели писать.

Framebuffer-устройство

В Linux давно существует подсистема fbdev: ядро предоставляет устройство /dev/fb0, за которым стоит кусок видеопамяти и описание его геометрии (ширина, высота, формат пикселя, stride). Любая программа может открыть /dev/fb0, сделать mmap() и рисовать. Подсистема старая и простая - современный графический стек живёт на DRM/KMS, - но для консоли fbdev идеален.

Чтобы /dev/fb0 появился, нужен драйвер. И вот тут особенность нашего проекта: «нормальный» видеодрайвер управляет железом - переключает режимы, программирует PLL, ждёт прерываний. Под наш самодельный конвейер из VDMA + VTC + rgb2dvi готового драйвера в ядре нет.

simple-framebuffer: драйвер для «уже настроенного» железа

К счастью, в ядре есть драйвер ровно для нашей ситуации - simple-framebuffer (CONFIG_FB_SIMPLE). Его контракт честен до прямолинейности: кто-то другой уже настроил видеожелезо так, что оно непрерывно показывает память по известному адресу. Я не знаю и не хочу знать, как оно устроено. Просто скажите мне адрес, размеры и формат - я объявлю это framebuffer-устройством.

Описание передаётся через Device Tree (подробно о DT - в 1.6 и части 5), узлом вида:

framebuffer@1e000000 {
   compatible = "simple-framebuffer";
   reg = <0x1e000000 0x1c2000>;   /* адрес и размер */
   width  = <800>;
   height = <600>;
   stride = <2400>;
   format = "r8g8b8";
};

Сравните с параметрами VDMA из 1.4 - это описание той же самой памяти с двух сторон. VDMA читает оттуда, simple-framebuffer даёт ядру туда писать. Согласованность этих двух описаний - и есть весь «интерфейс» между нашим железом и Linux.

Чтобы ядро не отдало эту память под свои нужды (страничный кеш, кучу - что угодно), в Device Tree она дополнительно объявляется в reserved-memory с флагом no-map. Адрес 0x1E000000 (480-й мегабайт из 512) выбран в самом конце DDR, где не бывает ни ядра, ни загрузчика.

fbcon: из пикселей - в терминал

Framebuffer - это пиксели. Консоль - это символы, курсор, прокрутка. Между ними - fbcon (CONFIG_FRAMEBUFFER_CONSOLE), консольный драйвер ядра, который рендерит текст виртуальных терминалов в framebuffer: берёт встроенный в ядро растровый шрифт (классика - 8×16), рисует глифы, двигает курсор, прокручивает экран.

Цепочка подсистем целиком:

Остаётся два штриха, оба - конфигурация, а не код:

  • Лог ядра. Параметр загрузки console=tty0 направляет вывод ядра на виртуальный терминал (то есть через fbcon на монитор). Параметров console= может быть несколько - мы укажем и console=ttyPS0,115200 (UART): лог пойдёт в оба места. Удобный факт: «основной» консолью (на которую пишет /dev/console) становится последний указанный.

  • Приглашение login. Его печатает программа getty, которую init запускает на конкретных терминалах согласно /etc/inittab. Добавим строку для tty1 - и на мониторе появится login:.

Кто включает конвейер: главное архитектурное решение проекта

Теперь сведём концы с концами. Simple-framebuffer по своему контракту ничего не настраивает - а наши VDMA и VTC после загрузки FPGA стоят в сбросе и сами по себе делать ничего не начнут. Кто-то должен записать в их регистры конфигурацию (ту самую таблицу таймингов и адрес буфера) и нажать «пуск».

Варианты, по нарастанию «правильности»:

  1. Userspace-утилита. Маленькая программа на C открывает /dev/mem, отображает себе регистры VDMA/VTC (мы знаем адреса: 0x43C1_0000, 0x43C2_0000) и программирует их напрямую. Запускается init-скриптом при старте, до getty.

  2. Патчи Digilent. У Digilent есть out-of-tree драйверы под их IP; их можно перенести на наше ядро и получить честный /dev/fb0 с настройкой железа.

  3. Свой DRM-драйвер. Взрослый путь: модуль ядра, DRM/KMS, чтение EDID, переключение режимов.

Для этой статьи выбран вариант 1 - утилита hdmi-init (~250 строк C, разберём построчно в части 5). Аргументы за, в порядке важности:

  • Прозрачность. Вся инициализация - один читаемый файл. Для статьи, цель которой «понять каждый байт», это решающий довод: те же записи регистров внутри драйвера были бы размазаны по callback'ам фреймворка.

  • Независимость от версии ядра. Out-of-tree патчи имеют свойство не накладываться на следующий LTS.

  • Адекватность задаче. Режим один и фиксированный, переключать нечего, прерывания не нужны. Драйвер ради драйвера - оверинжиниринг.

Цена решения - отказ от «динамики» (EDID, смена разрешений) и использование /dev/mem, которое в продакшн-системах справедливо считают дурным тоном. Honest trade-off: для образовательного проекта и консоли - правильный выбор; пути развития в сторону DRM-драйвера обсудим в части 8.

Как грузится Zynq: цепочка загрузки

Остался последний теоретический блок: как вся эта конструкция - bitstream, загрузчик, ядро - вообще оказывается в работающем состоянии после подачи питания. Загрузка Zynq - эстафета, где каждый участник маленький, но обязательный.

Эстафета: BootROM → SPL → U-Boot → ядро

Разберём звенья.

BootROM - программа, зашитая в кристалл на заводе. После сброса она читает с ножек MIO[5:4] boot mode (на плате это переключатель: SD / QSPI / JTAG), находит на выбранном носителе загрузочный образ BOOT.BIN и копирует его первую секцию во внутреннюю память OCM (On-Chip Memory, 256 КиБ статической памяти на кристалле). Почему в OCM? Потому что DDR ещё не работает - её некому было настроить.

Первичный загрузчик - тот самый образ, который BootROM положил в OCM. Его задача - «разбудить» чип: настроить тактирование, инициализировать контроллер DDR, сконфигурировать MIO-мультиплексор. Исторически у Xilinx эту роль играет FSBL (First Stage Boot Loader, генерируется из их SDK), но в этот раз мы возьмём U-Boot SPL - урезанную до размеров OCM сборку U-Boot. Мотив прост: SPL собирается тем же Buildroot из тех же исходников, что и основной U-Boot - одна система сборки на всё, никакого отдельного Vitis-проекта.

U-Boot proper - полноценный загрузчик, уже в DDR: умеет работать с файловой системой FAT, переменные окружения, скрипты. Находит на загрузочном разделе ядро (uImage) и Device Tree (.dtb), грузит их по нужным адресам и передаёт управление.

Ядро - дальше обычная загрузка Linux: драйверы (включая simple-framebuffer - /dev/fb0 появляется здесь), монтирование rootfs с второго раздела SD, запуск init. Init отрабатывает скрипты: S05hdmi-init запускает наш конвейер - с этого момента монитор оживает и показывает уже накопившийся лог, - затем getty печатает login:.

ps7_init: место, где Vivado встречается с U-Boot

В схеме выше выделена строчка ps7_init - присмотримся, потому что это самый неочевидный стык всего проекта и причина №1 неработающих сборок.

Параметры инициализации PS - сотни регистровых записей: множители PLL, тайминги DDR-чипов (CAS latency, длины линий на плате!), назначение каждой MIO-ножки. Эти значения знает только Vivado - мы вводим их при конфигурации блока PS7 (часть 3), а Vivado складывает результат в файлы ps7_init_gpl.c/h внутри экспортируемого архива .xsa.

U-Boot SPL устроен так, что ожидает найти ps7_init_gpl.c среди исходников своей платы и вызывает его первым делом. Значит, между сборкой FPGA и сборкой загрузчика обязан существовать шаг: вытащить ps7_init из XSA и подложить в дерево U-Boot (этому посвящена короткая, но важная часть 4).

Если шаг пропустить, сборка U-Boot... не упадёт. Она молча возьмёт запасной ps7_init от референсной платы Xilinx ZC702 - с чужими таймингами DDR и чужой картой MIO. И получится, что SPL стартует, печатает пару строк в UART и виснет на инициализации памяти. Классика жанра, держите это в голове.

BOOT.BIN: контейнер для троих

BootROM понимает единственный формат загрузочного образа - BOOT.BIN: контейнер с заголовком и таблицей разделов, собираемый утилитой bootgen из состава Vivado. Наш BOOT.BIN (соберём в части 6) содержит три раздела:

#

Содержимое

Кто использует

1

U-Boot SPL (ELF)

BootROM грузит в OCM и запускает

2

system.bit - bitstream

SPL заливает в PL через интерфейс PCAP

3

u-boot.img - U-Boot proper

SPL копирует в DDR и передаёт управление

Обратите внимание на раздел 2: прошивку FPGA загружает не программатор и не Linux, а SPL, через внутренний порт конфигурации PCAP (Processor Configuration Access Port). К моменту старта ядра PL уже жива, конвейер собран и ждёт настройки регистров. Это снимает целый класс проблем «драйвер стартовал раньше, чем загрузили bitstream».

Device Tree: анкета железа

Последнее понятие, без которого не прочитать ни одну дальнейшую главу.

ARM-мир не имеет PCI-style автообнаружения устройств: ядро не может «просканировать» чип и узнать, что по адресу 0x1E000000 лежит framebuffer, а UART - на 0xE0001000. Эту информацию ядру вручают в виде Device Tree - текстового описания железа (.dts), скомпилированного в бинарный блоб (.dtb), который загрузчик кладёт в память рядом с ядром.

Структура дерева - вложенные узлы со свойствами:

amba_pl {
   framebuffer@1e000000 {
       compatible = "simple-framebuffer";   /* ← КЛЮЧЕВОЕ свойство */
       reg = <0x1e000000 0x1c2000>;
       width = <800>;
       ...
   };
};

Механика связывания проста и важна: каждый драйвер в ядре объявляет список строк compatible, которые он обслуживает. При загрузке ядро обходит дерево и для каждого узла ищет драйвер с совпадающей строкой. Совпало - драйвер получает узел со всеми свойствами (адресами, частотами, прерываниями) и инициализирует устройство. Не совпало ни с чем - узел молча игнорируется.

Отсюда следствия, с которыми мы будем работать в части 5:

  • Device Tree - это конфигурация, а не код. Поменять адрес framebuffer или включить UART - правка .dts и пересборка .dtb, ядро трогать не надо.

  • DTS нужен не только ядру. U-Boot (и даже SPL) использует то же описание для своих драйверов - поэтому некоторые узлы мы пометим специальными флагами «нужен на этапе SPL».

  • Ошибки в DTS не ломают сборку. Опечатка в compatible или адресе обнаружится только на железе - устройство просто не появится. Поэтому построчный разбор нашего zynq-mini-revb.dts в части 5 будет дотошным.

Итого: полная картина

Сведём всю теорию в один абзац.

После питания BootROM грузит из BOOT.BIN U-Boot SPL; тот инициализирует DDR по таблицам ps7_init из Vivado, заливает в PL bitstream с конвейером VDMA → vid_out (+VTC) → rgb2dvi и запускает U-Boot, который передаёт ядру uImage и Device Tree. Ядро по узлу simple-framebuffer создаёт /dev/fb0 над зарезервированной памятью 0x1E000000, fbcon начинает рисовать туда консоль, параметр console=tty0 направляет туда лог. Init запускает hdmi-init, который через /dev/mem программирует VTC (тайминги 800×600@60) и VDMA (адрес буфера, запуск) - конвейер начинает 60 раз в секунду читать кадр по S_AXI_HP0 и слать его в монитор: 8 бит каждой компоненты кодируются в 10-битные TMDS-символы и сериализуются на 200 МГц в четыре дифференциальные пары. Getty печатает login: - задача решена.

Каждое выделенное слово этого абзаца мы теперь построим руками. Начнём с железа - со схемы платы.

Часть 2. Аппаратная часть: изучаем плату

Теория из части 1 рассказала, что мы строим. Прежде чем открывать Vivado, нужно выяснить, на чём: какой именно чип стоит на плате, куда физически разведён HDMI, на каких ножках живёт периферия, нужная для загрузки. Все эти ответы лежат в одном документе - принципиальной схеме платы.

Эта глава - практикум по чтению схемы. Результатом станут три таблицы, которые мы потом дословно перенесём в Vivado: распиновка HDMI (→ constraints, часть 3.7), конфигурация MIO-периферии (→ настройка PS7, часть 3.3) и параметры памяти (→ туда же).

Где взять схему и как с ней работать

Схема Rev.B распространяется производителем в PDF (https://t.me/zynq7000/147); с платами с AliExpress её обычно выкладывают в облако продавца или присылают по запросу.

Совет: добудьте схему до покупки платы - её наличие и читаемость много говорят о том, намучаетесь ли вы с bring-up'ом.

Читать схему Zynq-платы проще, чем кажется, если знать, что искать. Нас интересуют ровно две вещи:

  1. Листы с PL-банками - найти цепи с именами вида HDMI_D0_P: от какого пина FPGA до какого контакта разъёма идёт каждая цепь.

  2. Лист с MIO - на какие из 54 MIO-ножек посажена жёсткая периферия. Это вопрос разводки платы, поэтому без схемы конфигурацию PS7 не заполнить.

Этим и займёмся.

2.2. Чтение схемы: HDMI-узел

Что видно на схеме

Находим на схеме HDMI-разъём и идём по цепям. Картина такая:

В схематике производителя выглядит это все так:

Главное наблюдение: четыре TMDS-пары идут с пинов FPGA на разъём напрямую, без трансмиттера, без буферов. Вся «интеллектуальная» часть HDMI, как мы выяснили в 1.1, будет жить внутри FPGA; плата лишь доносит дифференциальные пары до разъёма дорожками согласованной длины.

Девятый сигнал - HDMI_OUT_EN (пин H18) - управляет ключом, подающим +5 В на 18-й контакт разъёма. Зачем HDMI-разъёму +5 В от источника? Две причины из стандарта: по этой линии монитор детектирует подключение источника, и от неё же питается микросхема EDID в мониторе (чтобы читать EDID можно было даже с выключенного монитора). Вывод для дизайна: пока H18 не установлен в «1», монитор считает, что к нему ничего не подключено, и не пытается ловить сигнал. В нашем top-модуле этот пин будет жёстко прижат к единице.

Чего на разъёме не разведено в PL: линии DDC (I²C-канал чтения EDID) и Hot Plug Detect до FPGA на этой плате не доходят. Это удобно стыкуется с решением из 1.2: мы и не собирались читать EDID - выдаём фиксированный 800×600@60, который монитор обязан понять.

Сверяем пары с корпусом: понятие диффпары

Нельзя просто взять два соседних пина и назвать их дифференциальной парой. В корпусе CLG400 пины заранее объединены в пары P/N (в терминологии Xilinx - IO_L<n>P/IO_L<n>N), и выходной буфер TMDS можно повесить только на такую пару, причём положительный сигнал - строго на P-пин.

Поэтому при чтении схемы обязательна сверка с документацией на корпус (package file в Vivado или таблица в даташите): каждая наша пара должна оказаться парой и по схеме, и по корпусу, с правильной полярностью. Для нашей платы всё сходится:

Сигнал

Пины (P / N)

Банк FPGA

TMDS Clock

H16 / H17

35

TMDS Data0 (B)

D19 / D20

35

TMDS Data1 (G)

C20 / B20

35

TMDS Data2 (R)

B19 / A20

35

HDMI_OUT_EN

H18 (одиночный)

35

Если бы разводчик платы перепутал P и N местами (бывает!), это тоже лечится: TMDS - полярно-симметричный сигнал, и у IP-блока rgb2dvi на этот случай нет ручки, но инверсию можно сделать константной single-ended инверсией данных перед сериализатором. Нам повезло - не понадобится. А вот перепутать пары между собой (Data0 на месте Data2) - уже цветовая каша, которую видно сразу.

Требование к банку: почему именно 3.3 В

Стандарт TMDS_33, который мы укажем в constraints, доступен только на HR-банке с VCCO = 3.3 В. Смотрим по схеме питание банка 35 - действительно 3.3 В. Это не случайность: разводчик платы знал про TMDS. Если бы банк питался от 1.8 В, HDMI «из коробки» не получился бы вовсе - вот почему этот пункт стоит проверять по схеме до покупки платы под видеопроекты.

Итоговая таблица для constraints

Всё, что нужно знать файлу ограничений (XDC), который мы напишем в части 3.7, теперь собрано:

Порт дизайна

Пин

IOSTANDARD

hdmi_clk_p / hdmi_clk_n

H16 / H17

TMDS_33

hdmi_data_p[0] / hdmi_data_n[0]

D19 / D20

TMDS_33

hdmi_data_p[1] / hdmi_data_n[1]

C20 / B20

TMDS_33

hdmi_data_p[2] / hdmi_data_n[2]

B19 / A20

TMDS_33

hdmi_out_en_o

H18

LVCMOS33

Обратите внимание на порядок каналов: Data0 - синий, Data2 - красный (раздел 1.1). Перепутаете индексы при подключении rgb2dvi - получите тот самый «синий помидор».

2.3. Остальная периферия: всё, что нужно для загрузки

HDMI - цель, но чтобы до неё добраться, плата должна загрузиться: прочитать BOOT.BIN с SD-карты, отчитаться в UART, поднять память. Пройдёмся по схеме ещё раз - теперь по «жёсткой» периферии PS. Попутно соберём таблицу, которая в части 3.3 станет конфигурацией блока PS7.

MIO-банки и их питание

Сначала общий факт, который легко упустить и потом долго отлаживать. 54 ножки MIO разбиты на два банка с независимым питанием, и на нашей плате они запитаны по-разному:

Банк MIO

Ножки

VCCO на плате

Что там живёт

MIO Bank 0

MIO 0-15

3.3 В

QSPI, boot mode, USB-управление, SD1

MIO Bank 1

MIO 16-53

1.8 В

Ethernet, USB ULPI, SD0, UART

Уровни напряжения каждой ножки - часть конфигурации PS7 (и, как следствие, часть ps7_init!). Укажешь 3.3 В для банка, который физически запитан от 1.8 - периферия в лучшем случае не заработает. Источник истины - опять схема: смотрим, что подано на выводы VCC_MIO0/VCC_MIO1.

Загрузочная связка: boot mode, SD, UART

Boot mode. Напряжения на MIO[5:4] в момент снятия сброса сообщают BootROM, откуда грузиться (раздел 1.6). На плате они заведены на переключатель:

Режим

MIO[5:4]

Когда нужен

SD

11

наш основной режим - загрузка с TF1

QSPI

10

автономная прошивка во флеш

JTAG

00

отладка: BootROM ждёт, всё грузим через кабель

SD-карта. Слот TF1 подключён к контроллеру SD0 на MIO 40-45 (6 линий: CLK, CMD, DATA0-3 - 4-битный режим). Важная деталь со схемы: у слота не разведены линии Card Detect и Write Protect - контроллеру их не дали. Запишем; в части 5 это превратится в свойства broken-cd / disable-wp в Device Tree, без которых Linux будет вечно ждать «вставки карты». Второй слот (TF2, SD1 на MIO 10-15) включим в конфигурации для порядка, но использовать не будем.

UART. Отладочная консоль - контроллер UART1 на MIO 48-49, дальше через USB-UART мост на разъём Type-C. То, что это UART1, а не UART0 - факт разводки конкретной платы, и из него вырастет целая цепочка следствий в софте: serial0 = uart1 в Device Tree, console=ttyPS0 в bootargs (ttyPS0 - первый зарегистрированный PS-UART, у нас это и есть UART1). Параметры классические: 115200 8N1.

Сеть и USB

Ethernet. Контроллер GEM0 соединён с PHY RTL8211E интерфейсом RGMII по MIO 16-27. Управляющая шина MDIO - на MIO 52-53; через неё процессор общается с PHY (узнаёт link, настраивает скорость). Со схемы же берём адрес PHY на шине MDIO (задаётся подтяжками на ножках PHY при сбросе) - пригодится для Device Tree. Тонкость про RGMII: режим rgmii-id (internal delay - задержки CLK↔DATA создаёт сам PHY) - это договорённость между схемой, PHY и DTS, к ней вернёмся в части 5.

USB host. Контроллер USB0 + внешний PHY USB3320C, соединены 12-проводным интерфейсом ULPI на MIO 28-39. И два управляющих GPIO, найденных на схеме, без которых USB «почти работает» (root hub есть, устройства не находятся - коварный симптом):

GPIO

Цепь на схеме

Роль

MIO 7

USBPHY_nRESET

сброс PHY (активный низкий, держать в «1»)

MIO 9

USBCP_EN

включение ключа питания VBUS на разъёме USB-A

Хост обязан подать 5 В на VBUS, иначе подключённая клавиатура даже не включится. Оба сигнала запомним: MIO 7 пропишем как «USB reset» прямо в конфигурации PS7, а MIO 9 поднимем из Linux (gpio-hog в Device Tree, часть 5).

QSPI. Флеш W25Q128 на MIO 1-6 (+ feedback clock на MIO 8). Для нашего сценария загрузки не используется, но включим контроллер: пригодится для будущей автономной прошивки, а Linux получит /dev/mtd.

Память: самые ответственные параметры

Контроллеру DDR в PS нужно сообщить, какая именно микросхема к нему подключена. Со схемы (и с маркировки чипа на плате) читаем: MT41J256M16 RE-125 - DDR3, 256M×16, скоростной бин DDR3-1600. Подключение - 16-битная шина (один чип). В конфигураторе PS7 это два поля: DDR Bus Width = 16 Bit и Memory Part = MT41J256M16 RE-125; по ним Vivado сам подставит все тайминги из встроенной базы данных чипов.

Если вашего чипа нет в базе Vivado - выбирается ближайший совместимый по даташиту, а CAS-латентности и длины дорожек вводятся вручную. На «безымянных» платах это самый рискованный пункт всего bring-up: ошибка здесь = зависание SPL на инициализации DRAM, неотличимое от пропущенного ps7_init (часть 1.6).

Практическое следствие 16-битной шины: пиковая пропускная способность памяти - около 2.1 ГБ/с (DDR3-1066 × 2 байта). Наш видеопоток 86 МБ/с - примерно 4% полосы: по-прежнему пренебрежимо, консоль не отнимет память у системы.

Сводная таблица: задание на часть 3

Всё прочитанное со схемы - в одну таблицу. Это и есть «техническое задание» для конфигурации PS7:

Подсистема

Контроллер PS

Ножки

Заметки для софта

DDR3 512 МиБ

DDRC

выделенный банк

MT41J256M16 RE-125, шина 16 бит

Консоль UART

UART1

MIO 48-49

→ ttyPS0, 115200

SD-карта (TF1)

SD0

MIO 40-45

нет CD/WP → broken-cd

SD-карта (TF2)

SD1

MIO 10-15

не используется

Ethernet

GEM0

MIO 16-27, MDIO 52-53

RTL8211E, rgmii-id

USB host

USB0

MIO 28-39 (ULPI)

PHY USB3320C; reset = MIO 7

USB VBUS

GPIO

MIO 9

поднять в «1» при старте

QSPI-флеш

QSPI

MIO 1-6, 8

W25Q128, задел на будущее

Boot mode

-

MIO 4-5

переключатель SD/QSPI/JTAG

Опорный клок PS

-

PS_CLK

кварц 33.3333 МГц

MIO Bank 0 / 1

-

0-15 / 16-53

3.3 В / 1.8 В

HDMI (TMDS)

- (чистый PL)

банк 35, таблица из 2.2

весь конвейер - наш

Последняя строка - квинтэссенция статьи: единственная периферия в этой таблице, у которой в колонке «контроллер» стоит прочерк. Всю её обвязку - от DMA до сериализаторов - мы сейчас и построим.

Открываем Vivado.

Часть 3. FPGA: создаём дизайн в Vivado

Пришло время строить. В этой главе мы соберём в Vivado весь видеоконвейер из части 1.4 - от блока PS7 до TMDS-портов, - прогоним синтез и имплементацию и получим два артефакта, ради которых всё затевалось:

  • bitstream - прошивка PL;

  • zynq_mini_hdmi.xsa - архив «аппаратной платформы», из которого софтовая ветка (части 4-5) возьмёт и bitstream, и параметры инициализации PS.

Глава написана в GUI-стиле: каждый шаг - руками через интерфейс Vivado, со скриншотами. Это осознанно: один раз пройдя путь мышкой, вы будете понимать, что именно делает каждая строчка TCL-скриптов, которые мы покажем в финале главы (3.10) и которыми будете пользоваться при пересборках.

3.1. Создание проекта

Запускаем Vivado, на стартовом экране - Create Project.

Мастер по шагам:

  1. Project Name. Имя zynq_mini_hdmi, расположение - каталог fpga/vivado внутри проекта. Галочку «Create project subdirectory» - снять, если указали путь с именем проекта, либо оставить - главное, чтобы итоговый .xpr оказался в fpga/vivado/.

  2. Project Type. «RTL Project», галочку «Do not specify sources at this time» - поставить: исходники добавим позже осмысленно.

  3. Default Part. Самый ответственный экран. Вкладка Parts, в фильтр вбиваем xc7z020clg400-2 и выбираем ровно его - ту самую маркировку, которую мы расшифровали в части 2.1. Ошибка в speed grade или корпусе обнаружится в лучшем случае на этапе constraints, в худшем - платой, которая не стартует.

Finish - перед нами пустой проект.

Структура каталогов, о которой стоит договориться сразу

Vivado-проект - это куча генерируемых файлов, которые не стоит хранить в git. Удобная дисциплина (её придерживается и репозиторий статьи):

fpga/
├── constraints/   # XDC - рукописный, в git
├── src/           # Verilog top - рукописный, в git
├── ip/repo/       # клон Digilent vivado-library
├── scripts/       # TCL для воспроизводимой пересборки (3.10)
└── vivado/        # сам проект Vivado - генерируется, в .gitignore

Рукописного в FPGA-части у нас будет удивительно мало: один top-модуль, один XDC и блок-дизайн.

3.2. Подключаем Digilent IP (rgb2dvi)

TMDS-кодер rgb2dvi (раздел 1.4) не входит в поставку Vivado - это открытый IP от Digilent. Забираем всю их библиотеку:

cd fpga
mkdir -p ip/repo
git clone --depth 1 https://github.com/Digilent/vivado-library.git ip/repo/vivado-library

Теперь говорим Vivado, где искать дополнительные IP: Tools → Settings → Project Settings → IP → Repository, кнопка +, указываем fpga/ip/repo/vivado-library. Vivado просканирует каталог и отчитается, сколько IP нашёл.

Проверка: открываем IP Catalog (Window → IP Catalog), в поиске - rgb2dvi. Должен найтись RGB to DVI Video Encoder (Source) в группе Digilent. Если нет - путь указан не до того уровня (нужен корень репозитория или его подкаталог ip/).

В библиотеке Digilent рядом лежит и axi_dynclk - программируемый генератор пиксельного клока. Он нужен для динамической смены разрешений; в нашем дизайне с одним фиксированным режимом обойдёмся обычным clk_wiz (решение из части 1.2). Запомните, что axi_dynclk существует, - это первый кандидат на «уровень 2» проекта.

3.3. Block Design: Processing System 7

Создаём диаграмму и добавляем PS

IP Integrator → Create Block Design, имя - system (имя попадёт в названия генерируемых файлов, менять его потом муторно).

На пустой диаграмме - кнопка + (Add IP), ищем «ZYNQ7 Processing System», добавляем. Сверху появится зелёная полоска подсказки Run Block Automation - соглашаемся с настройками по умолчанию: автоматика выведет наружу интерфейсы DDR и FIXED_IO (физические ножки памяти и MIO). Галочку «Apply Board Preset» - снять, если предлагается: пресета нашей платы у Vivado нет, всю конфигурацию вводим сами.

Конфигурация PS7: переносим таблицу из части 2

Двойной клик по блоку - открывается Re-customize IP, гигантский конфигуратор PS. Идём по разделам; источник каждого значения - сводная таблица из части 2.3.

PS-PL Configuration:

  • HP Slave AXI Interface → S AXI HP0 interface - включить. Это 64-битная дверь в DDR для VDMA.

  • GP Master AXI Interface → M AXI GP0 interface - включена по умолчанию, проверяем. Это «пульт управления» регистрами.

Peripheral I/O Pins (или MIO Configuration → I/O Peripherals) - включаем периферию и сверяем ножки:

Что включаем

Параметр

Значение

Quad SPI

QSPI, Single SS, Feedback Clk

MIO 1-6, FBCLK MIO 8

Ethernet 0

+ MDIO

MIO 16-27, MDIO MIO 52-53

USB 0

+ Reset

MIO 28-39, Reset = MIO 7

SD 0

без CD/WP/Power

MIO 40-45

SD 1

без CD/WP/Power

MIO 10-15

UART 1

MIO 48-49

GPIO

GPIO MIO

(для MIO 9 - VBUS, часть 5)

Здесь же, в табличке MIO внизу, выставляем напряжения банков: Bank 0 (MIO 0-15) - LVCMOS 3.3V, Bank 1 (MIO 16-53) - LVCMOS 1.8V. Помните предупреждение из 2.3: перепутанное напряжение банка - неработающая периферия, и узнаете вы об этом только на плате.

DDR Configuration:

  • Memory Type: DDR3

  • Memory Part: MT41J256M16 RE-125

  • DDR Bus Width: 16 Bit

  • Effective DRAM Bus Width: остаётся 16 бит, ECC - Disabled.

Vivado подставит тайминги чипа из своей базы. Если бы наша плата требовала ручных параметров (длины дорожек, board delays) - вводили бы их здесь же, на вкладке Board Delay; для Zynq Mini работают значения по умолчанию.

Clock Configuration:

  • Input Frequency: 33.333333 МГц (кварц платы, часть 2.1);

  • CPU Clock Ratio: 6:2:1 (по умолчанию);

  • PL Fabric Clocks:

    • FCLK_CLK0 = 50 МГц - тактирование всей AXI-логики;

    • FCLK_CLK1 = 100 МГц - включить, это опора для нашего видео-MMCM (часть 1.4).

Interrupts: включаем Fabric Interrupts → PL-PS → IRQ_F2P[15:0] - линии прерываний из PL (пригодятся для будущих драйверов, раздел 1.3).

OK - Vivado пересчитает блок. На диаграмме у PS7 появятся порты S_AXI_HP0, FCLK_CLK0/1, IRQ_F2P.

Самое важное в этом разделе - невидимое: всё, что вы сейчас ввели (DDR-тайминги, MIO, PLL), Vivado превратит в ps7_init_gpl.c при экспорте платформы (3.9). Эти файлы потом оживят память в U-Boot SPL (части 1.6 и 4). Конфигурация PS - это не «настройка для симуляции», это будущий код вашего загрузчика.

3.4. Клоки и сбросы

clk_wiz: фабрика пиксельного и последовательного клоков

Add IP → Clocking Wizard (clk_wiz), имя экземпляра clk_wiz_pixel. Двойной клик, настраиваем:

Вкладка Clocking Options:

  • Primitive: MMCM;

  • Primary Input Clock: 100.000 МГц, Source: Global buffer (клок придёт проводом с FCLK_CLK1, а не с ножки).

Вкладка Output Clocks:

  • clk_out1 - 40.000 МГц (пиксельный);

  • clk_out2 - включить, 200.000 МГц (последовательный, 5× для OSERDES DDR - раздел 1.1);

  • внизу: Reset Type - Active Low (порт станет называться resetn), locked - включён.

Загляните на вкладку с результатами расчёта: MMCM возьмёт VCO = 100 × 10 = 1000 МГц и поделит на 25 и на 5. Целые коэффициенты, оба выхода с одного VCO - та самая фазовая привязка, ради которой мы отказались от внутреннего MMCM в rgb2dvi (часть 1.4).

Два proc_sys_reset: по сбросу на домен

Add IP → Processor System Reset, дважды:

  • rst_axi_50M - сброс домена AXI;

  • rst_pixel_40M - сброс пиксельного домена.

Конфигурировать в них нечего - вся суть в подключении (поможет таблица доменов из 1.4):

Подключение

Куда

FCLK_CLK0 →

rst_axi_50M/slowest_sync_clk

FCLK_RESET0_N →

rst_axi_50M/ext_reset_in

clk_wiz_pixel/clk_out1 →

rst_pixel_40M/slowest_sync_clk

FCLK_RESET0_N →

rst_pixel_40M/ext_reset_in

clk_wiz_pixel/locked →

rst_pixel_40M/dcm_locked

Подключение locked → dcm_locked - маленькая, но содержательная деталь: пиксельный домен будет удерживаться в сбросе, пока MMCM не захватил частоту. Без этого vid_out и rgb2dvi могли бы «проснуться» на ещё плавающем клоке.

Сам clk_wiz тоже надо запитать и сбросить:

  • FCLK_CLK1 → clk_wiz_pixel/clk_in1;

  • rst_axi_50M/peripheral_aresetn → clk_wiz_pixel/resetn.

Провода на диаграмме тянутся просто: навести курсор на ножку (появится карандаш), нажать и вести до цели. Подсказка для длинных цепей: правый клик по ножке → Make Connection - выбор цели из списка.

3.5. Видеоконвейер: VDMA → vid_out → VTC → rgb2dvi

Добавляем четыре блока конвейера. Для каждого - конфигурация с объяснением, почему именно так (вся логика - из части 1.4).

AXI VDMA

Add IP → AXI Video Direct Memory Access. Двойной клик:

Параметр

Значение

Почему

Enable Read Channel (MM2S)

вывод из памяти - наш случай

Enable Write Channel (S2MM)

захват видео не нужен; экономим ~половину блока

Frame Buffers

1

один буфер, режим park: консоли double buffering не нужен

Memory Map Data Width

64

по ширине HP-порта

Stream Data Width (MM2S)

24

RGB888 - пиксель целиком за такт

Read Burst Size

16

длина burst-чтений из DDR

GenLock

отключён (Master/None)

синхронизировать VDMA не с чем - темп задаёт vid_out обратным давлением tready

Video Timing Controller (VTC)

Add IP → Video Timing Controller:

  • вкладка Detection/Generation: Enable Generation - ✔, Enable Detection - ✘ (метроном, а не измеритель - 1.4);

  • Max Clocks Per Line / Max Lines - можно оставить дефолт;

  • вкладка Default/Constant Generator Timing - вводим нашу таблицу из 1.2:

Поле конфигуратора

Значение

Horizontal Active Size

800

Horizontal Frame Size

1056

Horizontal Sync Start / End

840 / 968

Vertical Active Size

600

Frame Size (F0/F1)

628

Vertical Sync Start / End

601 / 605

Эти же числа умеет записать в VTC и софт через AXI-регистры - наш hdmi-init в части 5 именно это и делает. Зачем тогда вводить их здесь? Чтобы у блока был корректный режим сразу после сброса: полезно при отладке (паттерн-тест без процессора) и страхует от «полудефолтного» состояния.

AXI4-Stream to Video Out

Add IP → AXI4-Stream to Video Out. Ключевые параметры:

  • Timing Mode: Slave - темп задаёт внешний VTC (vid_out лишь подстраивается и притормаживает его через vtg_ce);

  • Clock Mode: Independent; FIFO Depth - 1024 по умолчанию (буфер сглаживания между VDMA и развёрткой).

rgb2dvi

Add IP → RGB to DVI Video Encoder:

Параметр

Значение

Почему

TMDS clock range

< 80 МГц (kClkRange = 1)

наши 40 МГц

Generate SerialClk internally

✘ (false)

оба клока подаём от своего MMCM - часть 1.4

Reset active high

будем подавать aresetn от proc_sys_reset

Соединяем конвейер

Сначала - потоковые интерфейсы:

axi_vdma_0/M_AXIS_MM2S       ──►  v_axi4s_vid_out_0/video_in
v_tc_0/vtiming_out           ──►  v_axi4s_vid_out_0/vtiming_in
v_axi4s_vid_out_0/vid_io_out ──► rgb2dvi_0/RGB

Затем - критическая обратная связь, про которую предупреждала часть 1.4:

v_axi4s_vid_out_0/vtg_ce ──► v_tc_0/gen_clken

Без неё дизайн пройдёт все проверки и даже иногда будет показывать картинку - «иногда» здесь ключевое слово. Не пропустите.

Теперь клоки, по таблице доменов из 1.4. Домен AXI (FCLK_CLK0, 50 МГц):

  • axi_vdma_0/s_axi_lite_aclk (регистры) и axi_vdma_0/m_axi_mm2s_aclk (чтение DDR);

  • v_tc_0/s_axi_aclk;

  • processing_system7_0/M_AXI_GP0_ACLK и S_AXI_HP0_ACLK (клоки портов самого PS!).

Домен pixel (clk_out1, 40 МГц):

  • axi_vdma_0/m_axis_mm2s_aclk (потоковый выход - вот она, граница CDC внутри VDMA);

  • v_tc_0/clk, v_axi4s_vid_out_0/aclk, rgb2dvi_0/PixelClk.

И одиночный clk_wiz_pixel/clk_out2 (200 МГц) → rgb2dvi_0/SerialClk.

Сбросы: rst_axi_50M/peripheral_aresetn → axi_vdma_0/axi_resetn, v_tc_0/s_axi_aresetn; rst_pixel_40M/peripheral_aresetn → v_axi4s_vid_out_0/aresetn, v_tc_0/resetn, rgb2dvi_0/aRst_n.

Константы: статические входы

У vid_out и VTC есть входы разрешения, которые в полнофункциональных дизайнах дёргает логика, а у нас они должны быть просто «всегда включено». Add IP → Constant (xlconstant), значение 1, ширина 1 - и разводим с него: v_axi4s_vid_out_0/aclken, vid_io_out_ce, v_tc_0/clken, s_axi_aclken. Второй Constant со значением 0 - на вход fid (field id: признак поля чересстрочной развёртки; у нас прогрессивная, поэтому константный 0).

3.6. AXI-инфраструктура и адреса

Два SmartConnect

Конвейер собран, но он «висит в воздухе»: регистры блоков не достижимы для процессора, а VDMA не дотянется до памяти. Соединяем миры - Add IP → AXI SmartConnect, дважды:

  • axi_smc - управляющий: 1 slave-вход (S00_AXI ← M_AXI_GP0 процессора), 3 master-выхода: M00 → axi_vdma_0/S_AXI_LITE, M01 → v_tc_0/ctrl (третий выход - для опционального I2C-блока, см. врезку ниже; если его не добавляете, хватит NUM_MI = 2);

  • axi_smc_hp - потоковый: 1×1, axi_vdma_0/M_AXI_MM2S → processing_system7_0/S_AXI_HP0.

Зачем второй, отдельный SmartConnect на одну-единственную связь? Во-первых, разделение управляющего и потокового трафика - гигиена (конфигурационные транзакции не толкаются с видео-burst'ами). Во-вторых, SmartConnect прозрачно преобразует протоколы: HP-порт Zynq - это AXI3, VDMA говорит на AXI4 - конвертер достаётся бесплатно.

Клоки обоих SmartConnect - FCLK_CLK0, сбросы - от rst_axi_50M.

Почему не Run Connection Automation? Зелёная полоска Vivado охотно сделает всё это за вас - и SmartConnect'ы, и сбросы. Для обучения полезнее руками; к тому же автоматика любит развести один общий interconnect на всё, а нам важна топология. Но если что-то не сходится - автоматика как подсказка легальна: посмотрите, что она сделала, и поправьте.

Address Editor: прибиваем адреса гвоздями

Окно Address Editor (вкладка рядом с Diagram). Сейчас там «Unassigned» либо адреса, расставленные автоматикой. Делаем Assign All, а затем редактируем offset'ы вручную:

Сегмент

Offset

Range

axi_vdma_0/S_AXI_LITE

0x43C1_0000

64K

v_tc_0/ctrl

0x43C2_0000

64K

(опц.) i2c

0x43C0_0000

64K

S_AXI_HP0/HP0_DDR_LOWOCM

0x0000_0000

512M

Последняя строка - окно, через которое VDMA видит DDR: весь диапазон памяти, чтобы буфер по адресу 0x1E00_0000 был достижим.

Напомню мотивацию из части 1.3: эти offset'ы - контракт с софтом. 0x43C1_0000 и 0x43C2_0000 дословно появятся в Device Tree (часть 5.5) и в #define нашего hdmi-init.c (часть 5.7). Поэтому никакой автоматики - только явные значения. После любой пересборки BD стоит заглянуть сюда и проверить, что адреса не «переехали».

Прерывания

Add IP → Concat (xlconcat), 2 входа: In0 - свободен (или irq нашего I2C), In1 ← axi_vdma_0/mm2s_introut; выход dout → processing_system7_0/IRQ_F2P. Сейчас прерывания никто не обслуживает (hdmi-init работает поллингом одного-единственного запуска), но линия в железе должна существовать до того, как она понадобится драйверу: добавить её потом - это пересборка bitstream.

Врезка: опциональный AXI I2C. В репозитории проекта в BD добавлен ещё самодельный AXI-блок i2c_master_axi (адрес 0x43C0_0000, выведен на пины разъёма расширения T20/P20) - наследие соседнего OLED-проекта и задел под чтение EDID. На путь «консоль на HDMI» он не влияет; добавлять его или нет - на ваше усмотрение, дальше в статье он упоминаться почти не будет.

Validate Design

Кнопка с галочкой (или F6) - Validate Design. Vivado проверит связность, клоковые домены интерфейсов, адресацию. Валидные предупреждения на этом этапе: «input not connected» для неиспользуемых входов (если что-то забыли из констант - вылезет здесь). Ошибок быть не должно.

Сохраняем BD (Ctrl+S). Диаграмма готова - полюбуйтесь: Regenerate Layout разложит её красиво.

3.7. Внешние порты, top-модуль и constraints

Порты BD

Выход rgb2dvi должен покинуть диаграмму. Правый клик по ножке rgb2dvi_0/TMDS_Clk_p → Make External - на краю диаграммы появится порт. Повторяем для TMDS_Clk_n, TMDS_Data_p[2:0], TMDS_Data_n[2:0]. Переименуем порты по-человечески (клик по порту → properties): hdmi_clk_p, hdmi_clk_n, hdmi_data_p, hdmi_data_n.

Обёртка и наш собственный top

Правый клик по system.bd в Sources → Create HDL Wrapper → «Let Vivado manage». Получаем system_wrapper.v - автогенерируемый Verilog, инстанцирующий BD.

Можно было бы объявить wrapper топом и закончить. Но у платы есть мелочи, которым в BD неудобно: пин HDMI_OUT_EN, светодиоды, трёхстабильные I2C-пады. Поэтому пишем поверх свой маленький top - fpga/src/zynq_mini_hdmi_top.v (≈90 строк), который:

  • инстанцирует system_wrapper, пробрасывая DDR/FIXED_IO/HDMI;

  • прижимает hdmi_out_en_o к единице - тот самый ключ +5 В на разъёме из части 2.2: монитор должен видеть источник постоянно, управлять этим динамически незачем;

  • вешает IOBUF'ы на пады I2C (если оставили опциональный блок) и моргает светодиодами.

Ключевые строки:

module zynq_mini_hdmi_top (
   output wire        hdmi_clk_p,
   output wire        hdmi_clk_n,
   output wire [2:0]  hdmi_data_p,
   output wire [2:0]  hdmi_data_n,
   output wire        hdmi_out_en_o,
   /* ... DDR, FIXED_IO, LED ... */
);
   assign hdmi_out_en_o = 1'b1;   // +5V на HDMI-разъём: всегда включено
​
   system_wrapper u_bd (
       .hdmi_clk_p  (hdmi_clk_p),
       .hdmi_clk_n  (hdmi_clk_n),
       .hdmi_data_p (hdmi_data_p),
       .hdmi_data_n (hdmi_data_n),
       /* ... DDR, FIXED_IO ... */
   );
endmodule

Добавляем файл в проект (Add Sources → Add or create design sources) и назначаем топом: правый клик → Set as Top (Sources-дерево должно показать его жирным, с system_wrapper внутри).

Constraints: таблица из части 2 становится XDC

Add Sources → Add or create constraints, создаём zynq_mini_revb_hdmi.xdc. Переносим таблицу пинов из 2.2 - построчно:

# --- HDMI TMDS (банк 35) ---
set_property -dict {PACKAGE_PIN H16 IOSTANDARD TMDS_33} [get_ports hdmi_clk_p]
set_property -dict {PACKAGE_PIN H17 IOSTANDARD TMDS_33} [get_ports hdmi_clk_n]
set_property -dict {PACKAGE_PIN D19 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[0]}]
set_property -dict {PACKAGE_PIN D20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[0]}]
set_property -dict {PACKAGE_PIN C20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[1]}]
set_property -dict {PACKAGE_PIN B20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[1]}]
set_property -dict {PACKAGE_PIN B19 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[2]}]
set_property -dict {PACKAGE_PIN A20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[2]}]
​
# Ключ +5 В на HDMI-разъёме (активный высокий)
set_property -dict {PACKAGE_PIN H18 IOSTANDARD LVCMOS33} [get_ports hdmi_out_en_o]

Разбор каждой строки:

  • PACKAGE_PIN - физический шарик корпуса из таблицы 2.2. Имя порта в get_ports должно дословно совпадать с портом top-модуля - опечатка здесь даст ошибку «port not found» на имплементации (и это хорошо: ошибки constraints, которые не ловятся, гораздо хуже);

  • IOSTANDARD TMDS_33 - тот самый токовый стандарт из 1.1; назначается на P-пин и N-пин пары одинаково;

  • индексы hdmi_data_*[0..2] - порядок каналов B, G, R (часть 1.1): сверьтесь с таблицей, перепутанные индексы = перепутанные цвета.

Этого достаточно. Заметьте, чего в XDC нет: ни одного клокового constraint'а руками. Все клоки дизайна рождаются внутри (FCLK из PS, MMCM) - Vivado выводит их автоматически из конфигурации IP.

3.8. Синтез, имплементация, bitstream

Кнопка Generate Bitstream (Flow Navigator, внизу) - Vivado сам прогонит цепочку synthesis → implementation → write_bitstream. На нашем дизайне это 15-40 минут в зависимости от машины (предложит выбрать число потоков - дайте сколько не жалко).

Пока крутится - что проверять, когда закончится. Откройте Implemented Design, и первым делом:

Timing Summary

Reports → Timing → Report Timing Summary. Интересуют три числа сверху: WNS, WHS, WPWS - все должны быть положительными (нет нарушений setup/hold/pulse width). Самый нагруженный домен нашего дизайна - 200 МГц serial: если где-то и будут проблемы, то там.

Отрицательный WNS в нашем дизайне - почти всегда симптом ошибки сборки (не тот speed grade, лишняя логика в пиксельном домене), а не «не повезло с роутингом»: конвейер маленький и для xc7z020 тривиальный.

Utilization

Reports → Report Utilization. Ориентиры для понимания масштаба: весь дизайн занимает порядка 10-15% LUT кристалла и несколько BRAM (FIFO в VDMA и vid_out). Это к вопросу «потянет ли»: видеоконвейер - лёгкая ноша, на 7z020 рядом поместится ещё много всего.

Messages

Вкладка Messages, фильтр Critical Warnings. Допустимые в нашем потоке: предупреждения о незадействованных входах IP. Недопустимые и требующие разбирательства: всё со словами IOSTANDARD, unconstrained, multiple driver.

Артефакт этапа: fpga/vivado/zynq_mini_hdmi.runs/impl_1/zynq_mini_hdmi_top.bit. Но забирать его оттуда руками не придётся -

3.9. Экспорт аппаратной платформы: XSA

Финальный аккорд: File → Export → Export Hardware. В диалоге - обязательно выбрать «Include bitstream», имя файла - zynq_mini_hdmi, расположение - корень fpga/.

[Скриншот: диалог Export Hardware с Include bitstream]

Получаем fpga/zynq_mini_hdmi.xsa. Формально это ZIP; внутри - всё, что софтовому миру нужно знать о нашем железе:

Файл внутри XSA

Что это

Кому нужно

*.bit

bitstream

в BOOT.BIN (часть 6)

ps7_init_gpl.c/h

инициализация PS: клоки, DDR, MIO

U-Boot SPL (части 4-5)

ps7_init.tcl

то же для отладчика

JTAG-сценарии

*.hwh

описание BD: блоки, адреса

генераторы device tree, отладка

Вот и встретились две сущности из теории: конфигурация PS7, которую мы вводили в 3.3, теперь лежит сишным кодом в ps7_init_gpl.c, а адреса из Address Editor - в .hwh. XSA - это «свидетельство о рождении» нашей платформы, и весь следующий этап (часть 4) посвящён тому, чтобы аккуратно вскрыть этот архив.

3.10. Врезка: то же самое одним скриптом

Всё, что мы сделали мышкой, в репозитории проекта закодировано в TCL - родном скриптовом языке Vivado. Каждое действие GUI имеет TCL-эквивалент (Vivado даже пишет его в консоль Tcl при каждом клике - подглядывайте туда, это лучший учебник):

Что делали в GUI

TCL-эквивалент

Create Project (3.1)

create_project zynq_mini_hdmi ./vivado -part xc7z020clg400-2

IP Repository (3.2)

set_property ip_repo_paths {...} [current_project]

конфигурация PS7 (3.3)

set_property -dict [list CONFIG.PCW_... ] $ps7

провод на диаграмме

connect_bd_net / connect_bd_intf_net

Address Editor (3.6)

set_property offset 0x43C10000 [get_bd_addr_segs ...]

Generate Bitstream (3.8)

launch_runs impl_1 -to_step write_bitstream

Export Hardware (3.9)

write_hw_platform -fixed -include_bit -force -file ...

В репозитории это три файла и обвязка:

cd fpga/scripts
./fetch_digilent_ip.sh    # клонирует Digilent vivado-library (3.2)
./run_vivado.sh -mode batch -source create_project.tcl   # 3.1-3.7: проект + BD + constraints
./run_vivado.sh -mode batch -source build_bitstream.tcl  # 3.8-3.9: synth → impl → XSA
# или всё разом:
./build_all.sh

create_project.tcl - это дословно наша глава: те же CONFIG.* у блоков, те же соединения, тот же Address Editor (в скрипте адреса прибиваются циклом по get_bd_addr_segs - с комментарием, почему именно эти значения). build_bitstream.tcl запускает synth/impl и в конце делает write_hw_platform.

Зачем это нужно, если GUI уже всё сделал:

  1. Воспроизводимость. Каталог vivado/ можно стереть в любой момент - проект восстанавливается из ~250 строк TCL за минуты. В git хранится текст, а не гигабайты кеша.

  2. Пересборки. Дальше по статье будут циклы «поменял дизайн → пересобрал» (шпаргалка в части 9) - в batch-режиме это одна команда без кликов.

  3. Диффы. Изменение «VDMA: 1 framestore → 3» в TCL - это диф в одну строку на code review. В GUI-проекте - «что-то поменялось в недрах .xpr».

Рекомендованный workflow: учиться и экспериментировать - в GUI, фиксировать результат - в TCL (выгрузить BD в скрипт можно командой write_bd_tcl).

На этом «железная» ветка почти закончена: у нас есть zynq_mini_hdmi.xsa, внутри которого лежат bitstream и будущий код инициализации памяти. Прежде чем уходить в Buildroot, нужно этот архив вскрыть и переложить его содержимое туда, где его найдёт U-Boot. Этому - короткая, но критически важная часть 4.

Часть 4. Мост между мирами: достаём ps7_init из XSA

Это самая короткая глава статьи - и при этом глава про шаг, на котором ломается больше всего самостоятельных сборок. На выходе части 3 у нас есть архив zynq_mini_hdmi.xsa. На входе части 5 софтовая ветка ожидает найти файлы ps7_init_gpl.c/h в известном месте. Между ними - один маленький, легко забываемый шаг. Разберёмся, почему он так важен, прежде чем его сделать.

4.1. Зачем это нужно

Напоминание: кто будит память

Вернёмся к цепочке загрузки из части 1.6. BootROM кладёт U-Boot SPL во внутреннюю память OCM и запускает. В этот момент чип - «овощ»: PLL не настроены, контроллер DDR спит, MIO-ножки не назначены. Первое, что обязан сделать SPL, - вызвать функцию ps7_init(): выполнить несколько сотен записей в системные регистры, которые поднимут клоки, оживят DDR и раздадут ножки периферии.

Откуда берётся тело этой функции? Его сгенерировал Vivado из конфигурации PS7, которую мы вводили в части 3.3 - каждый выбранный пункт (чип памяти MT41J256M16, UART1 на MIO 48-49, напряжения банков…) превратился в конкретные регистровые записи. Результат лежит внутри XSA: файлы ps7_init_gpl.c и ps7_init_gpl.h.

Загляните внутрь ps7_init_gpl.c - это поучительное зрелище: двенадцать с лишним тысяч строк сгенерированного кода. Структурно - несколько массивов «опкодов» (запись регистра / запись по маске / ожидание бита):

unsigned long ps7_pll_init_data_3_0[] = { ... };    // настройка PLL
unsigned long ps7_clock_init_data_3_0[] = { ... };  // дерево клоков
unsigned long ps7_ddr_init_data_3_0[] = { ... };    // контроллер DDR3 ← наш MT41J256M16
unsigned long ps7_mio_init_data_3_0[] = { ... };    // 54 MIO-ножки ← таблица из части 2.3
unsigned long ps7_peripherals_init_data_3_0[] = { ... };

и интерпретатор ps7_config(), который их исполняет. Суффиксы 30 / 20 / 10 - версии кремния Zynq: код в рантайме определяет ревизию чипа и выбирает нужный набор. Файл существует в двух вариантах - ps7_init.c (лицензия Xilinx, для FSBL) и ps7_init_gpl.c (GPL - специально для встраивания в U-Boot). Нам нужен GPL-вариант.

Что будет, если шаг пропустить

Вот теперь - главный сюжетный поворот, ради которого глава существует.

U-Boot ожидает найти ps7_init_gpl.c среди файлов своей платы (каталог board/xilinx/zynq/<имя-платы>/). Если файла нет, сборка U-Boot не упадёт и даже не предупредит всерьёз: система сборки молча возьмёт запасной вариант - ps7_init от референсной платы Xilinx ZC702. У которой другая память (32-битная шина, два чипа), другая карта MIO и другие напряжения банков.

Симптомы на плате узнаваемы с одного взгляда (запомните их):

U-Boot SPL 2023.01 (...)
mmc boot
Please RESET the board

или просто тишина после первой строки SPL: загрузчик повис на инициализации чужой DDR. Никакого сообщения «ps7_init не найден, использую ZC702» в консоли не будет - диагностировать приходится по косвенным признакам. По опыту, это грабли №1 всего проекта: FPGA-ветка собрана, Linux-ветка собрана, а плата молчит - и виноват один не скопированный файл.

Почему так? Исторически у Xilinx «правильным» считался путь через их генераторы (FSBL из Vitis), где ps7_init попадает в загрузчик автоматически. Путь «U-Boot SPL + Buildroot» - более открытый и удобный, но этот стык в нём приходится обслуживать руками. Чем мы и займёмся - причём так, чтобы рука была не наша, а скрипта (хук в части 5.4 сделает копирование автоматическим; наша задача - лишь распаковать XSA в условленное место).

4.2. Извлекаем

XSA - это просто ZIP

Никакой магии: .xsa - обычный ZIP-архив с манифестом. Можно убедиться:

$ unzip -l fpga/zynq_mini_hdmi.xsa
  
 Length      Name
---------     ----
   35230     ps7_init.tcl        ← инициализация PS для JTAG-отладчика
  534322     ps7_init.c          ← вариант для FSBL (лицензия Xilinx)
  534926     ps7_init_gpl.c      ← ★ вариант для U-Boot (GPL) - наша цель
    4283     ps7_init_gpl.h      ← ★ заголовок к нему
 4045694     zynq_mini_hdmi.bit  ← ★ bitstream - пойдёт в BOOT.BIN (часть 6)
  374893     system.hwh          ← описание блок-дизайна (адреса IP и т.п.)
     ...

Здесь всё, что мы наработали в части 3, разложено по файлам: конфигурация PS7 стала тремя реинкарнациями ps7_init (C для FSBL, C-GPL для U-Boot, TCL для отладчика), блок-дизайн - файлами .hwh (загляните: внутри XML с нашими адресами 0x43C10000/0x43C20000), синтез - bitstream'ом.

В классическом флоу Xilinx для работы с XSA используется консоль XSCT и команда openhw - но для нашей задачи это стрельба из пушки: достаточно unzip.

Скрипт extract_xsa.sh

В репозитории распаковку оформляет скрипт fpga/scripts/xsct/extract_xsa.sh. По сути он делает три вещи:

# 1. Распаковать XSA в fpga/xsa_extracted/ (каталог пересоздаётся начисто)
rm -rf fpga/xsa_extracted && mkdir -p fpga/xsa_extracted
( cd fpga/xsa_extracted && unzip -q ../zynq_mini_hdmi.xsa )

# 2-3. Дать артефактам канонические имена, не зависящие от имени проекта
ln -sf zynq_mini_hdmi.bit fpga/xsa_extracted/system.bit

Зачем «канонические имена»: дальнейшие скрипты (сборка BOOT.BIN, JTAG-сценарии) ссылаются на system.bit и ps7_init.tcl, не зная, как называется ваш Vivado-проект. Симлинки развязывают эту зависимость - переименуете проект, ничего не сломается.

Запуск:

cd fpga/scripts/xsct
./extract_xsa.sh

Ожидаемый вывод:

OK:
 bitstream     : .../fpga/xsa_extracted/system.bit
 ps7_init.tcl  : .../fpga/xsa_extracted/ps7_init.tcl

Проверяем результат

Минутная проверка, экономящая часы отладки:

$ ls -lh fpga/xsa_extracted/system.bit \
        fpga/xsa_extracted/ps7_init_gpl.c \
        fpga/xsa_extracted/ps7_init_gpl.h

-rw-r--r-- 523K ps7_init_gpl.c
-rw-r--r-- 4.2K ps7_init_gpl.h

lrwxrwxrwx       system.bit -> .../zynq_mini_hdmi.bit

И содержательный штрих - убедиться, что это наш ps7_init, а не чей-то ещё. В части 3.3 мы задавали UART1 на MIO 48-49; проверим, что генератор это запомнил:

$ grep -c "0xF8000000" fpga/xsa_extracted/ps7_init_gpl.c   # записи в SLCR - их сотни
$ grep "MT41J256M16\|DATA_3_0" fpga/xsa_extracted/ps7_init.html | head

Кстати, про ps7_init.html: Vivado кладёт в XSA и человекочитаемый отчёт - таблицу всех регистров с расшифровкой, что и почему записывается. Если когда-нибудь захочется понять, чем именно отличается инициализация вашей платы от ZC702, - этот файл бесценен.

Куда файлы поедут дальше

Финальная схема главы - место этого шага в общем потоке:

Обратите внимание на порядок: extract_xsa.sh должен отработать до сборки U-Boot в части 5 - хук копирует файлы в момент сборки, и если каталог пуст, мы получим тот самый молчаливый fallback на ZC702. В шпаргалке пересборки (часть 9) этот шаг будет стоять сразу за любым изменением FPGA.

Мост наведён. Переходим на софтовый берег - впереди Buildroot.

Часть 5. Linux: собираем систему в Buildroot

Железная ветка готова: bitstream синтезирован, ps7_init извлечён. Теперь - софтовая: загрузчик, ядро и корневая файловая система. К концу главы у нас будет почти готовый образ SD-карты, а по дороге мы построчно разберём все конфигурационные файлы проекта - от defconfig до исходника утилиты, оживляющей видеоконвейер.

Это вторая по объёму глава статьи. Маршрут: знакомство с Buildroot (5.1-5.2) → главный defconfig (5.3) → учим U-Boot нашей плате (5.4) → Device Tree (5.5) → конфиг ядра (5.6) → утилита hdmi-init (5.7) → init-скрипты (5.8) → сборка (5.9).

5.1. Знакомство с Buildroot

Что это и почему он

Чтобы получить загружаемый Linux для платы, нужно собрать четыре вещи согласованным кросс-тулчейном: загрузчик, ядро, корневую ФС с утилитами и сам тулчейн. Делать это вручную - недели; для автоматизации существуют «сборщики дистрибутивов», и главных школ две:

  • Yocto/OpenEmbedded (и его Xilinx-обёртка PetaLinux) - индустриальный стандарт: слои, рецепты, мощно и универсально. Обратная сторона - крутая кривая входа и ощущение «магии»: между вашим намерением и итоговым образом лежит много непрозрачных слоёв.

  • Buildroot - набор Makefile'ов и Kconfig (тот же конфигуратор, что у ядра). Один конфиг, понятный граф сборки, каждый шаг можно проследить до конкретной команды. Меньше возможностей (нет пакетного менеджера, пересборки тотальны), но для одноплатного embedded-проекта это не недостатки.

Для статьи, девиз которой «понимать каждый байт», выбор очевиден - Buildroot 2024.02 (LTS-релиз). PetaLinux остаётся за кадром ещё и из принципа: наш путь не зависит от проприетарных генераторов - из инструментов Xilinx мы используем только Vivado и bootgen.

Минимум концепций

Весь Buildroot держится на пяти понятиях:

Понятие

Что это

У нас

defconfig

один файл с ответами на все вопросы конфигуратора

zynq_mini_revb_hdmi_defconfig (5.3)

пакет

рецепт сборки одной программы: Config.in + *.mk

hdmi-init (5.7)

rootfs overlay

дерево файлов, копируемое поверх готовой rootfs

init-скрипты (5.8)

post-build script

шелл-скрипт, правящий rootfs после сборки

правка inittab (5.8)

post-image script

скрипт после сборки образов

genimage → sdcard.img (5.9)

BR2_EXTERNAL: своё - отдельно

Наивный путь - класть свои файлы прямо в дерево Buildroot. Работает, но превращает обновление Buildroot в ад, а ваши наработки - в патчи. Правильный механизм - BR2_EXTERNAL: переменная окружения, указывающая на внешний каталог с вашими конфигами, пакетами и DTS. Buildroot подхватывает его содержимое, не требуя менять ни одного своего файла.

Наше внешнее дерево - linux/external/:

linux/external/
├── external.desc        # имя дерева: ZYNQ_MINI_HDMI
├── Config.in            # меню наших пакетов для menuconfig
├── external.mk          # make-логика: пакеты + хуки U-Boot (5.4!)
├── configs/ → ../configs/zynq_mini_revb_hdmi_defconfig
├── dts/
│   └── zynq-mini-revb.dts          # Device Tree (5.5)
├── board/zynq_mini_revb/
│   ├── linux.fragment              # конфиг ядра (5.6)
│   ├── uboot.fragment              # конфиг U-Boot (5.4)
│   ├── post-build.sh               # getty + раскладка файлов (5.8)
│   ├── uEnv.txt, extlinux.conf     # загрузочные конфиги (5.8)
│   └── rootfs_overlay/             # init-скрипты (5.8)
└── package/hdmi-init/              # наш пакет (5.7)

Файл external.desc задаёт имя дерева:

name: ZYNQ_MINI_HDMI

Из имени Buildroot сделает переменную BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH - абсолютный путь к дереву, которым мы будем пользоваться во всех конфигах вместо хрупких относительных путей. Запомните её: она прошита по всему defconfig.

5.2. Скелет: разворачиваем Buildroot

Скачиваем и распаковываем (никаких установок - Buildroot живёт в каталоге):

cd hdmi_over_pl/linux
wget https://buildroot.org/downloads/buildroot-2024.02.7.tar.gz
tar xf buildroot-2024.02.7.tar.gz

Проверим, что внешнее дерево подхватывается. Каноническое заклинание Buildroot с out-of-tree сборкой (артефакты - в linux/output, дерево Buildroot остаётся чистым):

make -C buildroot-2024.02.7 O=$PWD/output \
    BR2_EXTERNAL=$PWD/external \
    BR2_DEFCONFIG=$PWD/configs/zynq_mini_revb_hdmi_defconfig \
    defconfig

Если после этого make -C buildroot-2024.02.7 O=$PWD/output menuconfig показывает в разделе External options меню «ZYNQ MINI Rev B (HDMI + PL)» с пакетом hdmi-init - связка работает. (В репозитории эти команды завёрнуты в scripts/build_linux.sh - он появится в 5.9.)

5.3. Главный defconfig: разбор построчно

Файл linux/configs/zynq_mini_revb_hdmi_defconfig - «оглавление» всей софтовой сборки, 65 строк, определяющих всё. Идём блоками.

Архитектура и тулчейн

BR2_arm=y
BR2_cortex_a9=y
BR2_ARM_ENABLE_NEON=y
BR2_ARM_ENABLE_VFP=y
BR2_TOOLCHAIN_BUILDROOT_GLIBC=y
BR2_PACKAGE_HOST_LINUX_HEADERS_CUSTOM_6_1=y

Cortex-A9 с NEON и VFP - это наш PS (часть 1.3). Из этих строк Buildroot соберёт собственный кросс-тулчейн: gcc с правильными -mcpu=cortex-a9 -mfpu=neon, glibc, заголовки ядра 6.1 (под версию ядра ниже). Важный практический момент: ABI тулчейна (hard-float) должен совпадать у всего, что попадёт в rootfs, - поэтому «принести бинарник из другого проекта» в мире Buildroot плохая идея, и след этого правила мы ещё увидим в init-скрипте (5.8).

Куда подключается наше внешнее дерево

BR2_ROOTFS_OVERLAY="$(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/board/zynq_mini_revb/rootfs_overlay"
BR2_ROOTFS_POST_BUILD_SCRIPT="$(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/board/zynq_mini_revb/post-build.sh"
BR2_ROOTFS_POST_IMAGE_SCRIPT="board/zynq/post-image.sh"

Первые две - наши (разбор в 5.8). Третья - интересный трюк: путь без переменной, то есть скрипт из стандартной поставки Buildroot. board/zynq/post-image.sh - готовая логика «собрать sdcard.img для Zynq» (genimage, см. 5.9): зачем писать свою, когда апстрим уже всё сделал.

Ядро: linux-xlnx

BR2_LINUX_KERNEL=y
BR2_LINUX_KERNEL_CUSTOM_TARBALL=y
BR2_LINUX_KERNEL_CUSTOM_TARBALL_LOCATION="$(call github,Xilinx,linux-xlnx,xlnx_rebase_v6.1_LTS_2023.2)/xlnx_rebase_v6.1_LTS_2023.2.tar.gz"
BR2_LINUX_KERNEL_DEFCONFIG="xilinx_zynq"
BR2_LINUX_KERNEL_CONFIG_FRAGMENT_FILES="$(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/board/zynq_mini_revb/linux.fragment"
BR2_LINUX_KERNEL_DTS_SUPPORT=y
BR2_LINUX_KERNEL_CUSTOM_DTS_PATH="$(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/dts/zynq-mini-revb.dts"
BR2_LINUX_KERNEL_INTREE_DTS_NAME="zynq-mini-revb"
BR2_LINUX_KERNEL_UIMAGE=y
BR2_LINUX_KERNEL_UIMAGE_LOADADDR="0x8000"

Решения, зашитые в этот блок:

  • Чьё ядро? Не mainline, а linux-xlnx - форк Xilinx, ветка xlnx_rebase_v6.1_LTS (LTS 6.1 + все их драйверы). Честная оценка: для нашего набора периферии подошёл бы и mainline, но xlnx-форк гарантирует, что вся специфика Zynq (включая редкие углы вроде devcfg) на месте - на одну категорию неизвестных меньше. $(call github,...) - макрос Buildroot для скачивания тарбола с GitHub.

  • База + фрагмент. За основу берём xilinx_zynq_defconfig из самого ядра, а наши добавки лежат в fragment-файле (5.6): Buildroot наложит его поверх. Это лучший паттерн конфигурирования ядра: ваши изменения - это короткий читаемый дифф, а не 5000-строчный полный конфиг.

  • DTS снаружи. CUSTOM_DTS_PATH подкладывает наш zynq-mini-revb.dts в дерево ядра перед сборкой - получаем zynq-mini-revb.dtb без патчей к ядру.

  • uImage @ 0x8000 - формат с U-Boot-заголовком; адрес загрузки 0x8000 - стандарт для zImage ARM (32 КиБ от нуля памяти).

U-Boot: тоже от Xilinx, с SPL

BR2_TARGET_UBOOT=y
BR2_TARGET_UBOOT_CUSTOM_TARBALL_LOCATION="$(call github,Xilinx,u-boot-xlnx,xlnx_rebase_v2023.01_2023.2)/..."
BR2_TARGET_UBOOT_BOARD_DEFCONFIG="xilinx_zynq_virt"
BR2_TARGET_UBOOT_CONFIG_FRAGMENT_FILES="$(...)/board/zynq_mini_revb/uboot.fragment"
BR2_TARGET_UBOOT_CUSTOM_DTS_PATH="$(...)/dts/zynq-mini-revb.dts"
BR2_TARGET_UBOOT_CUSTOM_MAKEOPTS="DEVICE_TREE=zynq-mini-revb"
BR2_TARGET_UBOOT_FORMAT_IMG=y
BR2_TARGET_UBOOT_SPL=y
BR2_TARGET_UBOOT_SPL_NAME="spl/boot.bin"

Та же схема: форк u-boot-xlnx, базовый defconfig xilinx_zynq_virt («virt» = универсальная Zynq-плата, конфигурируемая через Device Tree) плюс наш фрагмент. Две строки заслуживают подчёркивания:

  • CUSTOM_DTS_PATH + DEVICE_TREE=zynq-mini-revb - U-Boot использует тот же DTS, что и ядро. Одно описание железа на всех - никакого дрейфа между загрузчиком и ядром.

  • BR2_TARGET_UBOOT_SPL=y - собираем и SPL, тот самый первичный загрузчик из части 1.6. Артефакт spl/boot.bin - SPL в формате, понятном BootROM (но без bitstream - это починит часть 6).

Rootfs и пакеты

BR2_TARGET_ROOTFS_EXT2=y / EXT2_4=y / EXT2_SIZE="256M"   # rootfs.ext4 на второй раздел
BR2_PACKAGE_HOST_GENIMAGE=y / HOST_DOSFSTOOLS=y / HOST_MTOOLS=y  # инструменты сборки sdcard.img
BR2_PACKAGE_DROPBEAR=y          # SSH-сервер (лёгкий)
BR2_PACKAGE_IPERF3=y / ETHTOOL=y
BR2_PACKAGE_I2C_TOOLS=y
BR2_PACKAGE_FBTEST=y / FBSET=y  # утилиты проверки framebuffer - пригодятся в части 7
BR2_PACKAGE_HDMI_INIT=y         # ★ наш пакет (5.7)

База rootfs - BusyBox (включён в Buildroot по умолчанию): один бинарник, прикидывающийся сотней утилит, плюс простой init из /etc/inittab и скриптов /etc/init.d/S* - на этой механике построен раздел 5.8.

5.4. U-Boot SPL: учим загрузчик нашей плате

Базовый xilinx_zynq_virt знает про Zynq всё - кроме нашей платы. «Знакомство» состоит из трёх частей: два хука в external.mk и фрагмент конфигурации.

Хук №1: регистрация нашего DTB

Buildroot подложил zynq-mini-revb.dts в arch/arm/dts/ U-Boot, но система сборки U-Boot собирает только те DTB, что перечислены в её Makefile. Вписываем себя - в external.mk:

define ZYNQ_MINI_HDMI_UBOOT_REGISTER_DTS
   if ! grep -q 'zynq-mini-revb.dtb' $(@D)/arch/arm/dts/Makefile; then \
      sed -i '1a dtb-$$(CONFIG_ARCH_ZYNQ) += zynq-mini-revb.dtb' \
         $(@D)/arch/arm/dts/Makefile; \
   fi
endef
           
UBOOT_PRE_BUILD_HOOKS += ZYNQ_MINI_HDMI_UBOOT_REGISTER_DTS

UBOOT_PRE_BUILD_HOOKS - официальный механизм Buildroot: «выполни это перед сборкой U-Boot». Внутри - идемпотентный sed (повторная сборка не добавит строку дважды). Немного скотча? Да. Но альтернатива - поддерживать форк U-Boot ради одной строки.

Хук №2: тот самый ps7_init

Развязка сюжета части 4 - код, закрывающий «грабли №1»:

ZYNQ_MINI_PS7_DIR := $(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/../../fpga/xsa_extracted
define ZYNQ_MINI_HDMI_UBOOT_INSTALL_PS7_INIT
   if [ -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.c ] && [ -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.h ]; then \
      mkdir -p $(@D)/board/xilinx/zynq/zynq-mini-revb; \
      cp -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.c $(@D)/board/xilinx/zynq/zynq-mini-revb/; \
      cp -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.h $(@D)/board/xilinx/zynq/zynq-mini-revb/; \
   else \
      echo "WARN: ps7_init_gpl.* не найдены - SPL будет использовать ZC702 ps7_init"; \
   fi
endef

UBOOT_PRE_BUILD_HOOKS += ZYNQ_MINI_HDMI_UBOOT_INSTALL_PS7_INIT

Механика на стороне U-Boot: его Makefile ищет board/xilinx/zynq/$(DEVICE_TREE)/ps7_init_gpl.c - каталог по имени Device Tree. Мы задали DEVICE_TREE=zynq-mini-revb в defconfig - значит, кладём файлы в zynq-mini-revb/, и SPL слинкуется с нашей инициализацией DDR. Заметьте: при отсутствии файлов хук печатает WARN - но сборка продолжается (поведение U-Boot, не наше). Прочитать это предупреждение в многоэкранном логе - отдельный навык; вы предупреждены.

uboot.fragment: построчно

CONFIG_OF_EMBED=y

DTB встраивается в бинарник U-Boot, а не прикладывается рядом. Альтернатива (OF_BOARD) ожидает, что адрес DTB передаст предыдущий загрузчик, - это ломается при загрузке U-Boot напрямую через JTAG. Встроенный DTB надёжнее для отладки ценой пары десятков килобайт.

CONFIG_USE_BOOTCOMMAND=y
CONFIG_BOOTDELAY=1
CONFIG_BOOTCOMMAND="if iminfo 0x03000000; then bootm 0x03000000 - 0x02A00000; fi; \
 mmc rescan; mmc dev 0; \
 if fatload mmc 0 0x100000 uEnv.txt 10000; then env import -t 0x100000 10000; \
   if test -n \"$uenvcmd\"; then run uenvcmd; fi; fi; \
 fatload mmc 0 0x3000000 uImage; fatload mmc 0 0x2A00000 zynq-mini-revb.dtb; \
 bootm 0x3000000 - 0x2A00000"

Автозагрузочная команда - маленький сценарий с тремя ветками по убыванию приоритета:

  1. RAM-boot: если по адресу 0x03000000 уже лежит валидный uImage (его туда положил JTAG-скрипт при отладке) - грузимся из памяти, SD не нужна;

  2. uEnv.txt: если на FAT-разделе есть uEnv.txt - импортировать его переменные и выполнить uenvcmd (пользовательский крючок: поменять параметры загрузки без пересборки U-Boot - см. 5.8);

  3. прямой путь: загрузить uImage на 0x3000000, DTB на 0x2A00000, передать управление (bootm <kernel> - <dtb>; прочерк - «без initrd»).

Адреса 0x3000000/0x2A00000 согласованы с резервированием памяти в DTS (см. 5.5) - ядро и DTB не наступают друг другу и framebuffer'у на пятки.

CONFIG_DEBUG_UART=y / CONFIG_DEBUG_UART_ZYNQ=y
CONFIG_DEBUG_UART_BASE=0xE0001000
CONFIG_DEBUG_UART_SKIP_INIT=y
CONFIG_SPL_DEBUG_UART=y

Аварийный канал связи: «сырая» печать в UART прямо из ассемблерного старта, до инициализации каких-либо драйверов. 0xE0001000 - физический адрес UART1 (карта адресов из 1.3 в действии), SKIP_INIT - не перенастраивать UART (его уже настроил ps7_init). Когда SPL виснет молча - эти четыре строки единственное, что отличает «отладку» от «гадания».

5.5. Device Tree: сердце конфигурации

linux/external/dts/zynq-mini-revb.dts - файл, где сходятся все нити проекта: адреса из Vivado, выводы со схемы, договорённости с hdmi-init. Идём сверху вниз.

Каркас и chosen

/dts-v1/;
#include "zynq-7000.dtsi"
​
/ {
   model = "ZYNQ MINI Rev B (HDMI console over PL)";
   compatible = "user,zynq-mini-revb", "xlnx,zynq-7000";

zynq-7000.dtsi - базовое описание чипа из дерева ядра: все контроллеры PS уже описаны там (со status = "disabled"), наша работа - включать нужное и описывать специфику платы. Это типовая структура любого DTS: «чип.dtsi + плата.dts».

   chosen {
       bootargs = "console=tty0 console=ttyPS0,115200 earlycon fbcon=font:VGA8x16,12x22 root=/dev/mmcblk0p2 rootwait rw";
       stdout-path = "serial0:115200n8";
   };

Командная строка ядра - каждое слово на вес золота:

Параметр

Зачем

console=tty0

лог ядра → виртуальный терминал → fbcon → HDMI (часть 1.5)

console=ttyPS0,115200

и одновременно в UART; последний в списке становится /dev/console - UART остаётся «главным», это правильно, пока HDMI не доказал надёжность

earlycon

печать самых ранних стадий ядра (до инициализации полноценного драйвера UART)

fbcon=font:VGA8x16,12x22

шрифт консоли; 8×16 даёт 100×37 символов (расчёт из 1.2)

root=/dev/mmcblk0p2 rootwait rw

rootfs - второй раздел SD; rootwait - дождаться инициализации контроллера (без него - паника «VFS: unable to mount root»: ядро обгоняет асинхронный probe MMC)

В реальном файле проекта в bootargs добавлены ещё отладочные ключи (initcall_debug, loglevel=7, printk.time=1…) - для статьи это «строительные леса», в финальной системе их стоит убрать: подробный лог заметно замедляет загрузку.

Память и резерв под framebuffer

   memory@0 {
       device_type = "memory";
       reg = <0x0 0x20000000>;        /* 512 МиБ */
   };
​
   reserved-memory {
       framebuffer_mem: framebuffer@1e000000 {
           reg = <0x1e000000 0x00200000>;   /* 2 МиБ */
           no-map;
       };
   };

Вот он, контракт из части 1.5, сторона первая. no-map - самый строгий режим резервирования: ядро даже не создаёт линейного отображения этих страниц, никакой аллокатор их не увидит. Размер 2 МиБ - наш кадр 1 440 000 байт, выровненный вверх.

Почему именно 0x1E000000 (480-й МиБ) - выбор с тремя ограничениями: (1) внутри 512 МиБ DDR, (2) выше всех «рабочих» адресов загрузки - ядро на 0x3000000, DTB на 0x2A00000, U-Boot relocates в верх памяти, но ниже нашего резерва, (3) круглое число, читаемое в логах. Конец памяти - традиционное место для таких резервов.

Метки для SPL

&uart1  { status = "okay"; bootph-all; };
&clkc   { bootph-all; };
&slcr   { bootph-all; };
&sdhci0 { bootph-all; };

Помните из 1.6: U-Boot (и SPL!) использует тот же DTS. Но SPL крошечный - он включает в свой урезанный DTB только узлы с меткой bootph-all («нужен на всех фазах загрузки»). Состав списка - буквально «что нужно SPL, чтобы загрузить U-Boot с SD и печатать в консоль»: UART, клоки, системные регистры, SD-контроллер. Забудете пометить sdhci0 - SPL соберётся, но не найдёт ни одного MMC-устройства.

Периферия PS: включаем по списку из части 2.3

&sdhci0 {
   status = "okay";
   bus-width = <4>;
   broken-cd;
   disable-wp;
};

Та самая пометка из чтения схемы: у слота TF1 нет линий Card Detect и Write Protect. broken-cd говорит драйверу «определяй карту поллингом», disable-wp - «не верь флагу защиты записи». Без них: вечное «ожидание карты» и read-only сюрпризы.

&gem0 {
   compatible = "xlnx,zynq-gem", "cdns,zynq-gem", "cdns,gem";
   status = "okay";
   phy-mode = "rgmii-id";
   phy-handle = <&ethernet_phy>;
   ethernet_phy: ethernet-phy@1 { reg = <1>; };
};

Ethernet, и здесь зарыты реальные грабли этого проекта. Казалось бы, compatible уже задан в zynq-7000.dtsi - зачем переопределять? Затем, что в ядре xlnx_rebase_v6.1 драйвер macb, сматчившись по generic-строке cdns,gem, не выставляет флаг гигабитного режима - и phylink отказывает RGMII-интерфейсу с криптичной ошибкой «Could not attach PHY (-22)». Лекарство - поставить xlnx,zynq-gem первым в списке. Мораль для читателя: порядок строк в compatible - значим; ядро берёт первый известный ему вариант.

phy-mode = "rgmii-id" - режим из части 2.3: задержки RX/TX-клоков создаёт сам PHY (RTL8211E умеет). ethernet-phy@1 - адрес 1 на шине MDIO, заданный подтяжками на плате.

&usb0 {
   status = "okay";
   dr_mode = "host";
   usb-phy = <&usb_phy0>;
};
  
&gpio0 {
   usb0_phy_reset { gpio-hog; gpios = <7 GPIO_ACTIVE_HIGH>; output-high; };
   usb0_vbus_en   { gpio-hog; gpios = <9 GPIO_ACTIVE_HIGH>; output-high; };
};

USB-хост и два управляющих GPIO со схемы (MIO 7 - сброс PHY, MIO 9 - ключ VBUS). gpio-hog - изящный механизм: «при инициализации GPIO-контроллера сразу захвати линию и выставь уровень». Никакого кода - клавиатура получает питание самим фактом загрузки DTB. (В 5.8 мы всё же подстелим shell-fallback - следствие реальной отладки, когда hog по таинственным причинам не отрабатывал на части ядер.)

&clkc {
   ps-clk-frequency = <33333333>;
   fclk-enable = <0xf>;
};

Две строки, вторая из которых спасла проекту немало часов. ps-clk-frequency - кварц платы (часть 2.1). А fclk-enable = <0xf> - битовая маска «держать включёнными все четыре FCLK». Дело в том, что драйвер клоков Zynq гасит FCLK, на которые никто не сослался в DTS, - энергосбережение. А на FCLK_CLK1 у нас висит MMCM видеоконвейера, о котором Linux ничего не знает! Без этой маски картинка живёт до момента инициализации clkc и гаснет вместе с «неиспользуемым» клоком. Классическая засада конвейеров «мимо ядра» - запомните её.

amba_pl: наша логика глазами Linux

   amba_pl: amba_pl@0 {
       compatible = "simple-bus";
​
       fb_hdmi: framebuffer@1e000000 {
           compatible = "simple-framebuffer";
           reg = <0x1e000000 0x001c2000>;
           width = <800>; height = <600>;
           stride = <2400>;
           format = "r8g8b8";
       };
   };

Контракт из 1.5, сторона вторая: тот же адрес, что в reserved-memory, геометрия из части 1.2, stride из расчёта VDMA (1.4). format = "r8g8b8" - 24 бита; в памяти порядок байт [B, G, R] - и здесь живёт обещанный нюанс: vid_out трактует байты слегка иначе, так что у «цветного» использования R и B окажутся переставлены. Для консоли - невидимо; способ вылечить по-честному - поменять местами подключение компонент перед rgb2dvi.

Рядом в реальном файле - узел нашего опционального AXI I2C (status = "disabled": драйвера в mainline нет, узел - документация и задел). Обратите внимание, чего в amba_pl нет: узлов VDMA и VTC. Сознательное решение из 1.5: ядро о них не знает, ими владеет userspace. Описывать их в DT «для порядка» было бы даже вредно - какой-нибудь xilinx-драйвер из xlnx-ядра мог бы их подхватить и начать конфликтовать с hdmi-init.

5.6. Kernel fragment: конфиг ядра

linux.fragment накладывается поверх xilinx_zynq_defconfig. Смысловые блоки:

# Консоль на HDMI - ядро главы 1.5 в трёх строках:
CONFIG_FB=y
CONFIG_FB_SIMPLE=y                  # драйвер simple-framebuffer
CONFIG_FRAMEBUFFER_CONSOLE=y        # fbcon
CONFIG_FRAMEBUFFER_CONSOLE_DETECT_PRIMARY=y
CONFIG_FONT_8x16=y                  # шрифты, на которые ссылается fbcon=font:
CONFIG_FONT_12x22=y
CONFIG_OF_RESERVED_MEM=y            # поддержка reserved-memory из DTS
  
# USB-клавиатура: контроллер → шина → класс → ввод
CONFIG_USB_CHIPIDEA=y / CHIPIDEA_HOST=y   # USB-контроллер Zynq - это ChipIdea
CONFIG_USB_ULPI=y / ULPI_BUS=y            # интерфейс к внешнему PHY (часть 2.3)
CONFIG_USB_EHCI_HCD=y                     # host controller
CONFIG_HID=y / HID_GENERIC=y / USB_HID=y  # клавиатуры/мыши
CONFIG_INPUT_EVDEV=y
CONFIG_REALTEK_PHY=y      # драйвер RTL8211E
CONFIG_MMC_SDHCI_ZYNQ=y   # SD-контроллер (база и так в xilinx_zynq, но фиксируем)
CONFIG_EXT4_FS=y          # rootfs
CONFIG_MTD_SPI_NOR=y / SPI_ZYNQ_QSPI=y   # QSPI-флеш

Каждая строка фрагмента прослеживается к строке таблицы из части 2.3 - приятное свойство дисциплины «сначала прочитай схему». Заметьте и чего здесь нет: ни DRM, ни Xilinx-видеодрайверов - весь видеостек ядра для нас исчерпывается FB_SIMPLE (решение из 1.5).

Проверить, что фрагмент применился, можно после первой сборки: grep FB_SIMPLE linux/output/build/linux-*/.config.

5.7. Пакет hdmi-init: оживляем конвейер из userspace

Кульминация софтовой части. Ядро смонтировало rootfs, /dev/fb0 существует, fbcon прилежно рисует консоль в память по 0x1E000000… которую никто не читает: VDMA и VTC после загрузки bitstream стоят в сбросе. Монитор чёрен. Выходим на сцену.

Анатомия Buildroot-пакета

Пакет в Buildroot - это каталог с двумя файлами. Декларация для меню (Config.in):

config BR2_PACKAGE_HDMI_INIT
   bool "hdmi-init (Zynq Mini HDMI VDMA/VTC bring-up)"
   help
    Userspace утилита инициализации HDMI pipeline...

и рецепт (hdmi-init.mk):

HDMI_INIT_VERSION = 1.0
HDMI_INIT_SITE = $(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/package/hdmi-init/src
HDMI_INIT_SITE_METHOD = local          # исходники рядом, не скачивать

define HDMI_INIT_BUILD_CMDS
   $(TARGET_CC) $(TARGET_CFLAGS) -Wall -Wextra -O2 \
      $(@D)/hdmi-init.c -o $(@D)/hdmi-init
endef

define HDMI_INIT_INSTALL_TARGET_CMDS
   $(INSTALL) -D -m 0755 $(@D)/hdmi-init $(TARGET_DIR)/usr/bin/hdmi-init
endef

$(eval $(generic-package))

generic-package - самый простой класс пакетов: вы пишете команды сборки и установки, Buildroot даёт кросс-окружение ($(TARGET_CC) - наш arm-gcc) и встраивает пакет в граф зависимостей. Для одного C-файла - идеальный размер церемонии. Подключение к меню - одна строка source в external/Config.in.

hdmi-init.c: построчный разбор

Исходник - ~260 строк; пройдём по логике (полный листинг - в приложении C).

Доступ к железу. Карта адресов из части 1.3 материализуется в #define:

#define PL_BASE   0x43C00000UL   /* окно PL */
#define PL_SIZE   0x00040000UL   /* 256К: накрывает VDMA и VTC разом */
#define VDMA_OFFS 0x00010000UL   /* 0x43C10000 - из Address Editor, часть 3.6 */
#define VTC_OFFS  0x00020000UL   /* 0x43C20000 */

#define FB_ADDR   0x1E000000UL   /* из reserved-memory, часть 5.5 */
#define FB_STRIDE (800 * 3)      /* 2400 - из части 1.4 */

Окно в физическую память открывает пара open("/dev/mem") + mmap():

int fd = open("/dev/mem", O_RDWR | O_SYNC);
pl_base = mmap(NULL, PL_SIZE, PROT_READ | PROT_WRITE, MAP_SHARED, fd, PL_BASE);

/dev/mem - устройство «вся физическая память»; O_SYNC отключает кеширование - для регистров устройств обязательно (запись должна уйти в железо немедленно и в порядке программы). После mmap запись в регистр - это просто запись в массив: pl_base[offs >> 2] = val. Да, любая ошибка адреса - и вы пишете в случайное место физической памяти; root-права и аккуратность прилагаются. Это цена выбора «без драйвера» из части 1.5.

Шаг 1: VTC. Функция vtc_init() - таблица из части 1.2, записанная в регистры:

reg_w(base + VTC_CTL, VTC_CTL_RESET);              /* программный сброс */
reg_w(base + VTC_GASIZE,  (600 << 16) | 800);      /* активная область */
reg_w(base + VTC_GPOL,    0x7C);                   /* полярности - все active high */
reg_w(base + VTC_GHSIZE,  1056);                   /* полная строка */
reg_w(base + VTC_GVSIZE,  628);                    /* полный кадр */
reg_w(base + VTC_GHSYNC,  (968 << 16) | 840);      /* HSYNC: start|end */
reg_w(base + VTC_GVSYNC,  (605 << 16) | 601);      /* VSYNC */
reg_w(base + VTC_CTL, VTC_CTL_SW | VTC_CTL_RU | VTC_CTL_GE);  /* пуск */

Три бита запуска: GE - включить генератор, SW - использовать программные значения (а не «зашитые» при синтезе), RU - «применить»: регистры VTC двухступенчатые, записи копятся в теневом банке и вступают в силу атомарно по Register Update - чтобы развёртка не увидела «полусмененных» таймингов. После запуска - контрольное чтение CTL: убедиться, что биты встали.

Шаг 2: VDMA. Функция vdma_mm2s_start() - регистры из части 1.4, в строгом порядке:

reg_w(base + VDMA_MM2S_CR, VDMA_CR_RESET_MASK);    /* 1. сброс... */
while (reg_r(base + VDMA_MM2S_CR) & VDMA_CR_RESET_MASK) ...  /* ...и дождаться */
reg_w(base + VDMA_PARKPTR,     0);                 /* 2. парковка на кадре 0 */
reg_w(base + VDMA_MM2S_FRMBUF, fb_addr);           /* 3. адрес кадра */
reg_w(base + VDMA_MM2S_STRIDE, FB_STRIDE);         /* 4. геометрия... */
reg_w(base + VDMA_MM2S_HSIZE,  FB_STRIDE);
reg_w(base + VDMA_MM2S_CR,  VDMA_CR_RUN_MASK);     /* 5. RUN */
reg_w(base + VDMA_MM2S_VSIZE, FB_HEIGHT);          /* 6. VSIZE - ТРИГГЕР */

Порядок - не прихоть: запись VSIZE запускает передачу (документированное поведение VDMA), поэтому она обязана быть последней - всё остальное к этому моменту должно быть сконфигурировано. После запуска - чтение статусного регистра: бит HALTED должен сняться, биты ошибок (0x0FF0 - DMA decode/slave errors) - молчать. Если HALTED остался - VDMA не получила clock или reset (проблема в части 3), если ошибки - скорее всего, адрес кадра вне окна HP (проблема адресации).

Шаг 3: косметика. fb_clear() - отображает сам framebuffer и заливает чёрным: между стартом VDMA и первой перерисовкой fbcon на экране был бы «мусор» прошлой жизни DDR.

Заметьте, чего в программе нет: ни ожиданий прерываний, ни циклов обновления - настроил, проверил, вышел. Конвейер дальше живёт сам (режим park из 1.4), а пиксели обновляет ядро. Вся «магия» HDMI-консоли - 30 регистровых записей.

Запасной парашют: hdmi-init.sh

В overlay рядом лежит hdmi-init.sh - те же записи регистров, но шеллом через busybox devmem:

DM="busybox devmem"
$DM 0x43c20060 w 0x02580320   # VTC GASIZE: 600<<16 | 800
  
# ... вся таблица ...
  
$DM 0x43c10050 w 0x00000258   # VDMA VSIZE = 600 → пуск

Родился он как страховка от ABI-несовпадений (C-бинарник со старой rootfs на карте - exit 127), но оказался ценнее как инструмент отладки: каждую строчку можно выполнить руками с UART-консоли и посмотреть на реакцию железа. Прямой потомок этих строк - диагностические команды devmem в частях 7 и 9.

5.8. Rootfs overlay и init-скрипты: собираем загрузку воедино

Как BusyBox запускает систему

После монтирования rootfs ядро запускает /sbin/init (BusyBox), тот читает /etc/inittab: сначала sysinit (скрипт /etc/init.d/rcS, который прогоняет все /etc/init.d/S??* по алфавиту), затем строки respawn - getty на консолях. Наши вмешательства - два S-скрипта через overlay и правка inittab через post-build.

S04usb-gpio и S05hdmi-init

Номера в именах - порядок исполнения, и он продуман: S04 (питание USB - пусть клавиатура enumeration начнёт пораньше) → S05 (HDMI - как можно раньше, чтобы пользователь увидел остаток загрузки на мониторе) → стандартные S20/S40 (syslog, сеть...) → getty.

S05hdmi-init - обвязка вокруг утилиты, с двухступенчатым запуском:

do_start() {
   if [ -x /usr/bin/hdmi-init ] && /usr/bin/hdmi-init -v 2>/dev/null; then
       echo "  C-binary OK"
   elif [ -x /usr/bin/hdmi-init.sh ]; then
       /usr/bin/hdmi-init.sh          # fallback: shell + devmem
   fi
   # добиваем fbcon: разбудить fb0 и привязать консоль
   echo 0 > /sys/class/graphics/fb0/blank
   for v in /sys/class/vtconsole/vtcon*; do
       grep -q "frame buffer device" "${v}/name" && echo 1 > "${v}/bind"
   done
}

Последний цикл - урок, выстраданный отладкой: если fbcon по какой-то причине не привязался к fb0 при загрузке (гонка инициализаций), его можно «прибиндить» через sysfs. Дёшево и устраняет целый класс «framebuffer есть, консоли нет».

S04usb-gpio вызывает usb-gpio-init.sh, который через sysfs выставляет MIO 7/9 в единицу - дубль gpio-hog'ов из DTS (5.5). Скрипт сам находит номер GPIO-чипа по label zynq_gpio - маленький образец того, как писать переносимые GPIO-скрипты без захардкоженных номеров.

post-build.sh: getty на HDMI

Overlay кладёт файлы, но изменять существующие (сгенерированный Buildroot'ом /etc/inittab) - работа post-build скрипта:

echo 'tty1::respawn:/sbin/getty -L tty1 0 vt100 # HDMI fbcon' >> "${TARGET_DIR}/etc/inittab"

Это та самая строка, ради которой всё затевалось: getty на tty1 - приглашение login: на мониторе. (Почему tty1, а не tty0: tty0 - это «текущая виртуальная консоль», алиас; getty вешают на конкретную - первую.) Getty на UART (ttyPS0) Buildroot добавляет сам из BR2_TARGET_GENERIC_GETTY.

Второй половиной post-build раскладывает по местам uEnv.txt и extlinux.conf - загрузочные конфиги, которые genimage положит на FAT-раздел:

  • uEnv.txt - крючок из BOOTCOMMAND (5.4): текстовый файл с переменными U-Boot. Меняете bootargs (например, убрать отладочные ключи или сменить root=) - правкой файла на карте, без единой пересборки;

  • extlinux.conf - альтернативный механизм (distro boot), лежит «на всякий случай» для совместимости со стандартным sysboot.

Сводная картина: кто кладёт файлы в rootfs

Файл в rootfs

Откуда

Механизм

/usr/bin/hdmi-init

пакет hdmi-init

generic-package (5.7)

/usr/bin/hdmi-init.sh, usb-gpio-init.sh

rootfs_overlay

копия поверх (5.8)

/etc/init.d/S04usb-gpio, S05hdmi-init

rootfs_overlay

копия поверх

строка getty tty1 в /etc/inittab

post-build.sh

правка после сборки

/boot/uEnv.txt

post-build.sh

установка

5.9. Сборка

Запускаем

Вся механика из 5.2 завёрнута в scripts/build_linux.sh:

export BR2_EXTERNAL="${ROOT}/linux/external"
make -C buildroot-2024.02.7 O=output BR2_DEFCONFIG=... defconfig
make -C buildroot-2024.02.7 O=output -j"${JOBS}"
cd hdmi_over_pl
./scripts/build_linux.sh          # JOBS=16 ./scripts/build_linux.sh - задать параллелизм

Первая сборка - 1-3 часа: скачаются тарболы (тулчейн-компоненты, linux-xlnx ~280 МиБ, u-boot-xlnx, пакеты), соберётся кросс-gcc, потом всё остальное. Повторные - минуты: пересобирается только изменённое. Идеальное время перечитать часть 1.

Если сборка упала - не паникуйте и не перезапускайте вслепую: Buildroot печатает имя пакета-виновника, его лог - в выводе выше. 90% падений первой сборки - нехватка host-зависимостей (список - в части 0.2).

Артефакты

linux/output/images/
├── uImage              # ядро с U-Boot-заголовком
├── zynq-mini-revb.dtb  # наш Device Tree, скомпилированный
├── u-boot.img          # U-Boot proper
├── boot.bin            # SPL - ВНИМАНИЕ: без bitstream! (см. ниже)
├── rootfs.ext4         # корневая ФС, 256 МиБ
├── boot.vfat           # готовый FAT-раздел
├── sdcard.img          # ★ полный образ SD-карты
├── uEnv.txt, extlinux.conf

sdcard.img собрал тот самый стандартный board/zynq/post-image.sh из 5.3 утилитой genimage: декларативный конфиг описывает «раздел 1 - FAT с такими-то файлами, раздел 2 - rootfs.ext4», genimage строит образ с таблицей разделов. Записал на карту - получил готовую разметку, никакого ручного fdisk.

Ложка дёгтя, переходящая в часть 6

Посмотрите ещё раз на boot.bin в листинге. Это SPL и только SPL - U-Boot-овский spl/boot.bin из 5.3. BootROM его загрузит, SPL поднимет DDR (с нашим ps7_init - спасибо части 4)… а bitstream в PL заливать неоткуда: в этом файле его нет, и на FAT-разделе его нет. Конвейер останется пустой логикой, HDMI - чёрным.

Собрать правильный BOOT.BIN - с SPL, bitstream'ом и U-Boot в одном контейнере - работа утилиты bootgen и тема следующей, короткой части 6.

Часть 6. BOOT.BIN: финальная сборка загрузчика

Две ветки проекта сошлись: у нас есть всё для загрузки - SPL и U-Boot из части 5, bitstream из частей 3-4. Осталось упаковать их в формат, который поймёт BootROM. Глава короткая: один формат, один инструмент, один скрипт - но без этого шага монитор останется чёрным, поэтому разберёмся не торопясь.

6.1. Почему boot.bin из Buildroot недостаточно

В конце части 5 мы оставили закладку: среди артефактов сборки лежит boot.bin, и это только SPL. Buildroot честно собрал то, что умеет U-Boot - «обёрнутый» для BootROM SPL без какого-либо знания о FPGA. Если записать sdcard.img как есть и загрузиться:

  • BootROM найдёт boot.bin, загрузит SPL - ✔;

  • SPL поднимет DDR нашим ps7_init - ✔ (часть 4 не зря);

  • SPL загрузит U-Boot, U-Boot - ядро, ядро - rootfs, hdmi-init запишет регистры... в пустую PL. Bitstream никто не загружал: в PL нет ни VDMA, ни VTC - по адресам 0x43C1_0000 нет никого, и хорошо ещё, если обращение завершится ошибкой шины, а не зависанием.

Вывод: в загрузочный образ обязан попасть bitstream, и попасть так, чтобы его загрузили до старта Linux.

Формат BOOT.BIN

Контейнер, который читает BootROM Zynq, устроен просто: заголовок (магические слова, контрольные суммы, адрес первого образа) + таблица разделов + сами разделы. Каждый раздел - это образ с атрибутами: куда грузить, кто потребитель (процессор или PL), с какого адреса исполнять.

BootROM из всей этой таблицы использует ровно одну строку - первую, помеченную как bootloader: загружает её в OCM и передаёт управление. Остальные разделы BootROM не трогает - они лежат в файле для следующего звена эстафеты. В классическом флоу Xilinx это звено - FSBL, в нашем - U-Boot SPL: он повторно открывает BOOT.BIN, находит в таблице раздел с bitstream и заливает его в PL через devcfg/PCAP (внутренний порт конфигурации - часть 1.6), затем находит и запускает раздел с U-Boot proper.

Собирает такие контейнеры утилита bootgen из состава Vivado/Vitis. На вход ей подают BIF-файл (Boot Image Format) - декларативное описание «что положить и в каком порядке».

Наш BIF

the_ROM_image:
{
   [bootloader] .../spl/u-boot-spl      ← ELF! (см. ниже)
   .../fpga/xsa_extracted/system.bit    ← bitstream
   .../linux/output/images/u-boot.img   ← U-Boot proper
}

Три строки - три раздела, разберём каждую:

Строка

Атрибут

Кто загружает

Куда

u-boot-spl

[bootloader]

BootROM

OCM

system.bit

(автоопределение по расширению)

SPL

PL через PCAP

u-boot.img

-

SPL

DDR

Две неочевидности:

  • SPL подаётся в формате ELF (spl/u-boot-spl), а не готовый boot.bin и не «сырой» .bin. Причина: bootgen сам формирует BootROM-заголовок, а адреса загрузки и точку входа он вычитывает из ELF-заголовков. Подать boot.bin - получить заголовок поверх заголовка; подать плоский .bin - потерять адреса.

  • Порядок строк важен: SPL ищет разделы по порядку, и bitstream должен быть загружен в PL до старта U-Boot - так PL гарантированно жива к моменту, когда до неё кому-либо будет дело (то самое свойство из части 1.6, снимающее класс проблем «драйвер раньше прошивки»).

А можно было классическим FSBL? Можно: сгенерировать FSBL в Vitis, и BIF выглядел бы так же (FSBL вместо SPL, плюс u-boot.elf). Работает одинаково; мы выбрали SPL, чтобы весь софт собирался одной системой сборки из исходников, без отдельного Vitis-проекта. Если у вас SPL почему-то не задался - FSBL остаётся честным запасным путём, и репозиторий проекта содержит для него заготовку (fpga/fsbl_workspace/).

6.2. Собираем

Скрипт make_boot_bin.sh

В репозитории логика оформлена скриптом scripts/make_boot_bin.sh. Что он делает, по шагам:

  1. Находит ингредиенты (и внятно ругается, если чего-то нет - каждая ошибка отсылает к нужной части статьи):

    • SPL ELF: linux/output/build/uboot-custom/spl/u-boot-spl;

    • bitstream: fpga/xsa_extracted/system.bit (наш симлинк из части 4 - вот зачем были «канонические имена»);

    • linux/output/images/u-boot.img.

  2. Генерирует BIF с абсолютными путями (bootgen не дружит с относительными).

  3. Запускает bootgen: bootgen -image boot.bif -arch zynq -o BOOT.BIN -w

-arch zynq - формат заголовков именно Zynq-7000 (у ZynqMP другой), -w - перезаписать существующий файл.

Запуск (bootgen лежит в составе Vivado, поэтому сначала окружение):

cd hdmi_over_pl
source /opt/xilinx/2025.2/Vivado/settings64.sh    # появляется bootgen в PATH
./scripts/make_boot_bin.sh

Ожидаемый вывод:

SPL_BIN  = .../linux/output/build/uboot-custom/spl/u-boot-spl
 UBOOT    = .../linux/output/images/u-boot.img
 BIT_FILE = .../fpga/xsa_extracted/system.bit
→ bootgen -image boot.bif -arch zynq -o BOOT.BIN

OK: linux/output/images/BOOT.BIN
-rw-r--r-- 1 user user 5.0M ... BOOT.BIN

Проверка на правдоподобие

Глубокая верификация - на плате (часть 7), но грубые ошибки ловятся прямо здесь, по размеру:

Размер BOOT.BIN

Диагноз

~5 МиБ

норма: bitstream (~4 МиБ) + U-Boot (~1 МиБ) + SPL

~1 МиБ

bitstream не попал внутрь - проверьте пути в выводе скрипта

~100 КиБ

внутри только SPL - BIF сломан

Любопытным: bootgen -arch zynq -read BOOT.BIN распечатает заголовки и таблицу разделов - можно своими глазами увидеть три раздела, их адреса и атрибуты.

Почему ядро не кладём туда же

Резонный вопрос: bootgen умеет класть в BOOT.BIN что угодно - почему бы не упаковать и uImage с DTB, получив один файл на всё? Технически можно, но мы сознательно держим границу: BOOT.BIN - «прошивка» (меняется при изменении железа или загрузчика), ядро и DTB - «система» (меняются на порядок чаще и обновляются простым копированием файла на FAT). Раздельность экономит массу времени в цикле разработки - пересобрали ядро, скопировали один файл, перезагрузились.

Всё готово. Берём SD-карту - впереди момент истины.

Часть 7. SD-карта и первый запуск

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

7.1. Запись образа

Находим устройство

Вставляем microSD в кардридер хоста и выясняем, каким блочным устройством она стала:

$ lsblk -d -o NAME,SIZE,MODEL,TRAN
NAME        SIZE MODEL            TRAN
sda       476.9G Samsung SSD ...  nvme
sdb        14.9G Mass Storage     usb    ← вот она (размер совпадает с картой)

Дальше в командах фигурирует /dev/sdX - замените X на вашу букву и перепроверьте дважды: dd не задаёт уточняющих вопросов, и опечатка здесь стоит содержимого системного диска. Хорошая привычка - сверять размер и колонку TRAN=usb. На некоторых хостах карта появляется как /dev/mmcblk0 - тогда разделы будут называться mmcblk0p1/p2 вместо sdX1/sdX2.

Пишем sdcard.img

sudo umount /dev/sdX* 2>/dev/null   # отмонтировать всё, что автомонтировалось
sudo dd if=linux/output/images/sdcard.img of=/dev/sdX \
       bs=4M conv=fsync status=progress
sync

Образ принесёт с собой и таблицу разделов, и оба раздела с содержимым - никакого ручного fdisk/mkfs (спасибо genimage из части 5.9).

Подменяем boot.bin на BOOT.BIN

Финальный штрих, подготовленный частью 6: на FAT-разделе образа лежит SPL-only boot.bin, а нужен полный BOOT.BIN:

sudo mount /dev/sdX1 /mnt
sudo cp linux/output/images/BOOT.BIN /mnt/
sudo umount /mnt
sync

Старый boot.bin можно не удалять: BootROM ищет файл строго по имени BOOT.BIN (регистронезависимо, но именно это имя), boot.bin рядом он не заметит... с одной оговоркой - именно потому, что наш файл называется иначе. Если у вас сборка, где SPL-файл тоже называется BOOT.BIN, - заменяйте.

Почему подмена руками, а не правка genimage-конфига? Можно и так (добавить BOOT.BIN в список файлов boot-раздела своим genimage.cfg) - но тогда сборка Linux начнёт зависеть от артефакта bootgen, который появляется после неё. Разорвать круг можно, мы предпочли явный шаг - он же удобен в разработке: BOOT.BIN меняется отдельно от образа.

Что в итоге лежит на карте

Раздел

ФС

Содержимое

Кто положил

p1, ~32 МиБ

FAT

BOOT.BIN

вы, только что

uImage, zynq-mini-revb.dtb (system.dtb)

genimage

uEnv.txt, extlinux.conf

genimage ← post-build (5.8)

boot.bin (мёртвый груз)

genimage

p2, 256 МиБ

ext4

rootfs целиком

genimage ← rootfs.ext4

7.2. Подключение и boot mode

Переключатель загрузки

Выставляем boot mode в SD (таблица из части 2.3: MIO[5:4] = 11, на плате - переключатель/перемычки, см. шелкографию). Карту - в слот TF1 (основной; второй слот TF2 висит на SD1 и для загрузки не годится - BootROM смотрит только на SD0).

Кабели

Порядок подключения - чтобы ничего не пропустить:

  1. USB-UART (Type-C) → хост. Сразу открываем терминал - лог нужен с первой секунды:

picocom -b 115200 /dev/ttyUSB0     # выход: Ctrl+A, Ctrl+X
# или: minicom -D /dev/ttyUSB0 -b 115200
  1. HDMI → монитор. Монитор - включить заранее (наш конвейер не читает EDID и не заметит «горячего» подключения дисплея - сигнал он шлёт всегда, но монитору нужно время поймать его).

  2. USB-клавиатура → разъём USB-A платы (host).

  3. Ethernet → свитч/роутер (опционально).

  4. Питание - последним.

7.3. Первая загрузка: читаем лог

Подаём питание и смотрим в UART-терминал. Ниже - аннотированный лог: что печатается, кто это печатает и какой части статьи соответствует. Сверяйте со своим выводом поэтапно - первый же «недоехавший» этап укажет виновника.

Этап 1: SPL (часть 1.6 + 4 + 5.4)

U-Boot SPL 2023.01 (...)
mmc boot
Trying to boot from MMC1

Сам факт появления этих строк - большая победа: BootROM нашёл BOOT.BIN, SPL стартовал, а раз он печатает - ps7_init отработал, клоки и UART живы. Между «Trying to boot...» и следующим этапом SPL молча делает две важные вещи: заливает bitstream в PL (несколько секунд паузы - это оно; PL уже жива!) и загружает U-Boot в DDR - значит, и память работает (наш MT41J256M16 из части 3.3 инициализировался).

Если вместо этого: тишина - BootROM не нашёл/не принял BOOT.BIN (boot mode? карта в TF1? имя файла?); строки SPL и зависание или «Please RESET the board» - грабли №1 из части 4: SPL собран без нашего ps7_init.

Этап 2: U-Boot proper (часть 5.4)

U-Boot 2023.01 (...)
CPU:   Zynq 7z020
DRAM:  ECC disabled 512 MiB
MMC:   mmc@e0100000: 0
...
Hit any key to stop autoboot:  1

U-Boot представился и показал, что видит: 512 МиБ DRAM (сходится с частью 2!), MMC-контроллер. Через секунду autoboot выполнит BOOTCOMMAND из 5.4: проверка RAM-образа (нет), uEnv.txt (есть - импорт), затем:

2208944 Bytes read: OK          ← uImage
24576 Bytes read: OK            ← zynq-mini-revb.dtb
## Booting kernel from Legacy Image at 03000000 ...
  Loading Kernel Image
Starting kernel ...

Этап 3: ядро (части 5.5-5.6)

Дальше - лавина сообщений ядра (она идёт и в UART, и - пока вслепую - в framebuffer: console=tty0 уже работает, просто конвейер ещё не запущен). Реперные точки, которые стоит выцепить глазами или grep'ом потом из dmesg:

Booting Linux on physical CPU 0x0
Machine model: ZYNQ MINI Rev B (HDMI console over PL)   ← наш DTS подхвачен
Reserved memory: created region at 0x1e000000 ...        ← резерв под FB (5.5)
simple-framebuffer 1e000000.framebuffer: framebuffer at 0x1e000000, 0x1c2000 bytes
Console: switching to colour frame buffer device 100x37  ← fbcon жив! 100×37 - расчёт из 1.2
macb e000b000.ethernet eth0: Cadence GEM rev ...          ← Ethernet (без «-22»!)
mmc0: new high speed SDHC card                            ← SD
EXT4-fs (mmcblk0p2): mounted filesystem                   ← rootfs
Run /sbin/init as init process

Этап 4: init - монитор оживает (часть 5.8)

Starting usb-gpio-init: OK              ← S04: VBUS подан, клавиатура получает питание
Starting HDMI pipeline:   VTC CTL=0x00000007 GTSTAT=...
 VDMA CR=0x00010001 SR=0x00010000
 C-binary OK
OK                                       ← S05: hdmi-init отработал

Вот он, момент: между этими строками VDMA сделала первое чтение кадра - и на мониторе вспыхивает консоль. Причём не пустая: fbcon рисовал в буфер с самого этапа 3, так что на экране - весь накопившийся лог ядра, как на обычном ПК.

usb 1-1: new low-speed USB device number 2    ← клавиатура нашлась (S04 не зря)
input: ... USB Keyboard as /devices/...
Welcome to Buildroot
zynq-mini-revb login:

Приглашение печатается дважды - на UART (ttyPS0) и на мониторе (tty1, та самая строка getty из post-build). Набираем root на USB-клавиатуре, глядя в монитор. Никакого хоста, никакого UART - самодостаточный компьютер. Задача статьи формально решена... но доверяй и проверяй.

7.4. Проверяем результат

Логинимся (на мониторе или по UART) и прогоняем чек по слоям - сверху вниз, от Linux к железу.

Framebuffer и консоль

# cat /proc/fb
0 simple                          ← fbdev №0, драйвер simple-framebuffer

# fbset -fb /dev/fb0
mode "800x600-0"
   geometry 800 600 800 600 24   ← геометрия и 24 bpp - как в DTS
   ...

# echo "Hello HDMI" > /dev/tty1   ← строка появляется на мониторе

Регистры конвейера (привет, hdmi-init)

Те же адреса, что мы прибивали в части 3.6, та же семантика, что в 5.7:

# busybox devmem 0x43c20000 32   ← VTC CTL
0x00000007                        ← SW|RU|GE - генератор работает (1.2/5.7)

# busybox devmem 0x43c10000 32   ← VDMA MM2S CR
0x00010001                        ← бит 0 = RUN

# busybox devmem 0x43c10004 32   ← VDMA MM2S SR
0x00010000                        ← бит 0 (halted) снят, биты ошибок 0xFF0 - нули

Эти три команды - главный диагностический инструмент проекта: они отличают «не настроили конвейер» от «конвейер настроен, проблема в другом» за десять секунд. Они же лягут в основу troubleshooting'а части 9.

Тест на «непрерывность»: пишем в память - видим на экране

Самая наглядная проверка всей цепочки DDR → VDMA → TMDS:

# cat /dev/urandom > /dev/fb0
cat: write error: No space left on device   ← это норма: буфер кончился

Экран должен мгновенно заполниться цветным шумом - значит, VDMA честно перечитывает память 60 раз в секунду. Вернуть консоль: echo 1 > /sys/class/vtconsole/vtcon1/bind или просто понажимать клавиши - fbcon перерисуется. (Кстати, если на шуме приглядеться к «цветным» артефактам - вспомните про R↔B swap из 5.5.)

Клавиатура, сеть, остальное

# ls /dev/input/                      ← event0 = клавиатура
# cat /sys/class/net/eth0/operstate   ← up (если кабель воткнут)
# udhcpc -i eth0                      ← получить адрес; дальше ssh root@<ip> (dropbear из 5.3)
# cat /proc/mtd                       ← QSPI-флеш видна

Если все пункты сошлись - поздравляю: вы собрали видеоподсистему с нуля, от диффпар до getty. Если какой-то пункт не сошёлся - прямая дорога в часть 9, где разобраны все известные способы сломать эту конструкцию.

А пока система загружается и работает, самое время для интеллектуального десерта: проследить путь одного символа через все слои, которые мы построили. Часть 8.

Часть 8. Сквозной разбор: путь одного символа

Система работает. В предыдущих главах мы собирали её по слоям - и каждый слой видели изнутри, но по отдельности. Финальное упражнение - увидеть всю вертикаль разом: проследить судьбу одного-единственного символа от системного вызова до свечения люминофора, с конкретными адресами, координатами и микросекундами.

Возьмём символ, который на нашем мониторе появляется последним и значит больше всех: букву z - первую букву строки zynq-mini-revb login:.

8.1. Слой софта: от printf до пикселей в DDR

Шаг 1. getty пишет в tty

После окончания init BusyBox запускает по нашей строке из inittab (часть 5.8):

tty1::respawn:/sbin/getty -L tty1 0 vt100

getty открывает /dev/tty1, читает /etc/issue и имя хоста и делает обыкновенный write(). С точки зрения программы - байт 0x7A (ASCII z) уехал в файловый дескриптор. Всё, что дальше, - забота ядра.

Шаг 2. Подсистема VT: байт становится символом на сетке

write() в tty проваливается в драйвер виртуальных терминалов (CONFIG_VT, часть 5.6). VT-слой ведёт для tty1 «модель экрана»: текущую позицию курсора, атрибуты цвета, буфер 100×37 символов - той самой сетки, которую мы вычислили в части 1.2 (800/8 × 600/16 при шрифте 8×16).

Наш z попадает в ячейку, скажем, (строка 36, колонка 0) - низ экрана, прокрутившийся лог. VT записывает символ в свой текстовый буфер и зовёт консольный драйвер: «перерисуй ячейку».

Шаг 3. fbcon: глиф → байты

fbcon (часть 1.5) превращает текст в пиксели:

  1. берёт код 0x7A и находит глиф во встроенном шрифте VGA8x16 (тот, что мы просили через fbcon=font: в bootargs) - 16 байт, по биту на пиксель;

  2. вычисляет пиксельный прямоугольник ячейки: x = 0×8 = 0, y = 36×16 = 576, размер 8×16;

  3. для каждой строки глифа разворачивает биты в пиксели: бит 1 → цвет текста (белый, 0xFFFFFF), бит 0 → фон (чёрный) - и пишет в framebuffer через драйвер simple-framebuffer.

Посчитаем адрес первого пикселя нашей буквы - арифметика из части 1.4 в действии:

addr = FB_BASE + y × stride + x × 3
    = 0x1E000000 + 576 × 2400 + 0
    = 0x1E000000 + 0x151800
    = 0x1E151800

По этому физическому адресу ядро (через no-map-резерв из части 5.5 ничего другого там оказаться не может) выкладывает тройки байт [B, G, R]. Для верхней строки глифа z (а у этого глифа верхние две строки пустые, рисунок начинается с третьей) - нули; для строки с «перекладиной» - что-то вроде:

0x1E152AC0: 00 00 00 | FF FF FF | FF FF FF | FF FF FF | FF FF FF | FF FF FF | 00 00 00 | 00 00 00
             чёрный     белый      белый      белый      белый      белый     чёрный     чёрный

На этом софт закончился. Ни одна строчка кода больше не выполнится ради нашей буквы - ни в ядре, ни в hdmi-init (он давно завершился, часть 5.7). Дальше работает только железо, которое мы собрали в части 3.

8.2. Слой железа: из DDR в монитор

Шаг 4. VDMA: очередное чтение вечного цикла

VDMA крутит свой park-цикл (часть 1.4) с момента, когда S05hdmi-init записал VSIZE: кадр за кадром, 60 раз в секунду, не зная и не интересуясь, что в памяти что-то изменилось. Через ≤16.7 мс после записи fbcon движок в очередной раз дойдёт до строки 576: выставит на порту M_AXI_MM2S burst-чтение по адресу 0x1E151800, транзакция уйдёт через SmartConnect axi_smc_hp (попутно превратившись из AXI4 в AXI3 - часть 3.6) в порт S_AXI_HP0 и далее в контроллер DDR.

Прочитанные байты VDMA переупаковывает из 64-битных слов памяти в 24-битные пиксели и проталкивает в AXI4-Stream (часть 1.4): наш белый пиксель едет как tdata = 0xFFFFFF, без меток (tuser был на пикселе (0,0) кадра, tlast будет на пикселе 799 этой строки). Здесь же поток пересекает границу клоковых доменов: читали из памяти на 50 МГц, выдаём в поток на 40 МГц - асинхронное FIFO внутри VDMA (часть 1.4) делает это незаметно.

Шаг 5. vid_out + VTC: пиксель ждёт своего часа

В FIFO блока v_axi4s_vid_out пиксель ненадолго задерживается: его время выхода определяет не он, а развёртка. VTC отсчитывает свою сетку 1056×628 (часть 1.2); когда счётчики доходят до позиции «строка 576+вертикальный back porch, пиксель 0+горизонтальный back porch» активной области, стыковщик выталкивает наш пиксель на параллельную шину: 24 бита RGB + hsync=0, vsync=0, de=1.

Посчитаем и время. Строка развёртки длится 1056 / 40 МГц = 26.4 мкс; наша строка 576 выходит на экран через 576 × 26.4 мкс ≈ 15.2 мс после начала кадра. А весь наш z (16 строк глифа) размазан по 16 проходам развёртки - но человеческий глаз соберёт его в букву.

Шаг 6. rgb2dvi: 24 бита → 30 бит → 4 пары

Внутри rgb2dvi (части 1.1, 3.5) наш белый пиксель разбирают на компоненты: B=0xFF → канал Data0, G=0xFF → Data1, R=0xFF → Data2. Каждый TMDS-кодер превращает свои 8 бит в 10-битный символ - для 0xFF это символ с минимумом переходов, а бит инверсии зависит от накопленного DC-баланса линии (тот самый счётчик перекоса из 1.1 - состояние кодера зависит от всей предыстории строки!).

Три OSERDESE2 выталкивают свои символы бит за битом на частоте 200 МГц × DDR = 400 Мбит/с (часть 1.1), четвёртая пара несёт пиксельный клок. Передача 10 бит занимает ровно один пиксельный такт - 25 нс. Выходные буферы TMDS_33 превращают биты в токовые перепады на парах банка 35 - пины D19/D20, C20/B20, B19/A20, H16/H17 из таблицы части 2.2.

Шаг 7. Монитор

Дальше - чужая территория, но из вежливости проводим букву до конца: приёмник монитора восстанавливает клок по паре Clock, выравнивает 10-битные символы по управляющим кодам из интервалов гашения (часть 1.1 - вот зачем porch'и!), декодирует обратно в RGB, буферизует кадр и через свои матричные драйверы зажигает субпиксели. Буква светится.

8.3. Вся вертикаль одним взглядом

Обратите внимание на выделенную границу: интерфейс между софтом и железом в этой системе - просто область памяти. Никаких ioctl, прерываний, командных очередей: ядро пишет байты, FPGA их читает. Вся сложность проекта ушла в то, чтобы обе стороны одинаково понимали эти байты - адрес (0x1E000000 в трёх местах: DTS, hdmi-init, Address Editor-окно HP), stride (2400 в DTS и в регистре VDMA), формат (r8g8b8 против tdata[23:0]). Каждый баг согласованности из части 9 - это расхождение двух сторон в одном из этих чисел.

Бюджет задержки: а если символ печатает человек?

Ради спортивного интереса - полный путь от нажатия клавиши до свечения (вход мы подробно не разбирали, но слои известны):

Этап

Время

USB low-speed: опрос interrupt endpoint клавиатуры

до 10 мс

EHCI → HID → input core → VT → getty echo → fbcon → запись в FB

< 1 мс

ожидание, пока VDMA дойдёт до нужной строки кадра

0-16.7 мс

развёртка до строки пикселя

уже учтено выше

внутренняя обработка монитора (буферизация кадра)

5-30 мс

Итого

~20-60 мс

Любопытный вывод: самые большие слагаемые - на краях (USB-опрос и электроника монитора), а вся наша самодельная середина - память, DMA, TMDS - укладывается в один период кадра. «Велосипед» получился не медленнее заводских.

8.4. Ретроспектива: где мы срезали углы

Статья обещала честность. Перечислим компромиссы конструкции - каждый был осознанным решением «для простоты и прозрачности», и у каждого есть «взрослый» путь развития. Это готовый список квестов для второй итерации проекта.

1. Инициализация из userspace вместо драйвера. /dev/mem + mmap (часть 5.7) - прозрачно, но небезопасно и не масштабируется: нет управления питанием, нет арбитража, fbcon слепо верит, что конвейер жив. Взрослый путь - DRM/KMS-драйвер: модуль ядра, регистрирующий CRTC (VDMA+VTC) и encoder (rgb2dvi), с честным /dev/dri/card0. Бонусом - работа Wayland/X из коробки. Это на порядок больше кода, но весь нужный опыт у вас теперь есть: регистры известны, семантика понятна.

2. Один фиксированный режим вместо EDID. Мы жёстко зашили 800×600@60 в трёх местах (VTC, hdmi-init, DTS). Взрослый путь: дочитать схему до конца - если DDC-линии разъёма доступны (на нашей плате - нет, часть 2.2), читать EDID по I²C, выбирать родной режим монитора и программировать пиксельный клок на лету через axi_dynclk (тот самый блок Digilent из части 3.2) вместо статического MMCM. Потребует и таблиц таймингов, и пересчёта делителей - VTC и VDMA перепрограммируются теми же регистрами, что мы уже знаем.

3. Один framebuffer вместо двух. Park-режим с единственным кадром означает: fbcon пишет в ту же память, которую VDMA читает - теоретически возможен tearing (на текстовой консоли - неразличим). Взрослый путь - double buffering: 3 framestores в VDMA (часть 3.5 - один параметр!), переключение по vsync-прерыванию (линия в xlconcat уже заведена - часть 3.6 не зря).

4. R↔B swap. Канал Data0/tdata-байты у нас «почти» согласованы (части 1.4, 5.5): для серой консоли невидимо, для цветной графики - синие помидоры. Лечится одним из: перестановкой проводов компонент перед rgb2dvi в BD, или format = "b8g8r8"... которого у simple-framebuffer нет - так что правильное место починки - железо. Одна пересборка bitstream.

5. UART остаётся главной консолью. console=ttyPS0 последним в bootargs (часть 5.5) - осознанная страховка. Когда HDMI-стек докажет надёжность (или обзаведётся драйвером с p.1), порядок можно поменять - и /dev/console переедет на монитор.

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

Осталось последнее: систематизировать всё, что может пойти не так, - и попрощаться. Части 9 и 10.

Часть 9. Если что-то пошло не так

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

Но сначала - метод.

9.0. Метод: диагностика по слоям

Система из части 8 - строгая вертикаль слоёв, и это её главный отладочный подарок: каждый слой можно проверить независимо, а неисправность всегда живёт между последним работающим слоем и первым неработающим. Не начинайте с перетыкания кабелей - начните с локализации слоя.

Три инструмента покрывают всё:

Инструмент

Что видит

Когда

UART-лог (115200)

SPL → U-Boot → ядро → init

всегда; подключайте до подачи питания

devmem-триада (часть 7.4)

состояние конвейера в PL

система загрузилась, но HDMI молчит

dmesg / sysfs

драйверы, framebuffer, USB, сеть

проблемы внутри Linux

Девиз главы: плата, которая печатает в UART, - почти исправная плата. Поэтому первая развилка любой отладки - есть ли что-нибудь в UART.

9.1. В UART тишина или SPL виснет

Совсем тишина

BootROM даже не запустил SPL. По убыванию вероятности:

  1. Boot mode не SD - проверить переключатель (MIO[5:4] = 11, часть 2.3).

  2. Карта не в TF1 - второй слот не загрузочный (часть 7.2).

  3. Нет файла BOOT.BIN на первом разделе (именно с таким именем; boot.bin BootROM не ищет - часть 7.1).

  4. FAT-раздел не первый / повреждён - перезаписать sdcard.img.

  5. Терминал не на том устройстве / не 115200.

«Please RESET the board» или зависание после первых строк SPL

Грабли №1 всего проекта (части 1.6 и 4): SPL собран с fallback ps7_init от ZC702 и не смог инициализировать нашу DDR. Проверка и лечение:

ls fpga/xsa_extracted/ps7_init_gpl.c                      # есть?
ls linux/output/build/uboot-custom/board/xilinx/zynq/zynq-mini-revb/
                                                         # скопировался ли хуком?
# если первого нет:        ./fpga/scripts/xsct/extract_xsa.sh
# затем пересобрать U-Boot: rm -rf linux/output/build/uboot-custom
#                            ./scripts/build_linux.sh

rm -rf .../uboot-custom важен: хук из части 5.4 - PRE_BUILD, при инкрементальной сборке уже распакованного U-Boot он не перезапустится.

Реже та же картина означает действительно «не ту» конфигурацию DDR (чип памяти не из базы Vivado, часть 2.3) - если ps7_init точно ваш, перепроверьте Memory Part в конфигураторе PS7.

SPL стартует, но «Trying to boot from MMC1» и стоп

SPL не нашёл следующий этап. Чаще всего: на карте лежит SPL-only boot.bin вместо полного BOOT.BIN (забыли шаг из части 7.1) - SPL загрузился, а раздела с u-boot.img в его файле нет.

9.2. Linux загрузился, монитор: «No signal»

«No signal» - это про полное отсутствие сигнала: монитор не видит даже клока TMDS. (Если монитор показывает чёрное, но из standby вышел - это сценарий 9.3, другой слой.)

Диагностика - devmem-триада с UART-консоли:

busybox devmem 0x43c20000 32    # VTC CTL:  ждём 0x00000007
busybox devmem 0x43c10000 32    # VDMA CR:  ждём бит 0 = 1 (RUN)
busybox devmem 0x43c10004 32    # VDMA SR:  бит 0 = 0, биты 0xFF0 = 0

Чтение зависает / kernel oops по обращению → по этим адресам никого нет:

  • bitstream не загружен - BOOT.BIN собран без него (проверка размера из части 6.2);

  • или загружен старый bitstream с другими адресами - Address Editor «уехал» при пересборке (часть 3.6), сверить с fpga/xsa_extracted/system.hwh.

Регистры читаются, но нули / RUN не встал → конвейер не настроен:

sh -x /etc/init.d/S05hdmi-init start    # прогнать init-скрипт с трассировкой
/usr/bin/hdmi-init -v                   # или утилиту напрямую, с verbose
/usr/bin/hdmi-init.sh                   # или шелл-версию построчно (часть 5.7)

VDMA SR показывает ошибки (биты 0xFF0) → DMA не достучалась до памяти:

  • decode error: адрес кадра вне окна HP - в Address Editor сегмент HP0 не покрывает 0x1E000000 (часть 3.6);

  • slave error: проблемы клока/сброса HP-домена - сверить подключения из части 3.5.

Триада идеальна, а сигнала нет → между VDMA и разъёмом:

  • fclk-enable = <0xf> в DTS на месте? Классика: Linux погасил FCLK_CLK1, MMCM встал - и сигнал умер ровно в момент инициализации clkc (часть 5.5). Симптом-маркер: картинка была первые секунды загрузки и пропала.

  • связь vtg_ce → gen_clken в BD (часть 3.5)? Симптом-маркер: работает «через раз», от перезагрузки к перезагрузке.

  • пин H18 (hdmi_out_en_o)? Без него монитор не знает о существовании источника (часть 2.2). Проверяется вольтметром: +5 В на 18-м контакте разъёма.

  • перепутанные пары/полярности в XDC - сверить с таблицей 2.2.

9.3. Сигнал есть, но на экране не то

Монитор проснулся, экран чёрный

Сигнал идёт (монитор не пишет «no signal»), но рисовать нечего - слой fbcon/getty:

cat /proc/fb                        # должен быть «0 simple»
dmesg | grep -i "frame buffer"      # «switching to colour frame buffer device 100x37»?
cat /sys/class/vtconsole/vtcon*/name
echo 1 > /sys/class/vtconsole/vtcon1/bind   # прибиндить fbcon руками (часть 5.8)
echo 0 > /sys/class/graphics/fb0/blank
cat /dev/urandom > /dev/fb0         # лакмус: шум на экране = конвейер жив
  • /proc/fb пуст → simple-framebuffer не создался: узел в DTS (5.5)? CONFIG_FB_SIMPLE в ядре (5.6)? Грузится ли вообще ваш DTB (строка «Machine model» в dmesg)?

  • fb есть, шум виден, консоли нет → fbcon не привязан (bind выше) или нет console=tty0 в bootargs;

  • консоль есть, login: нет → строка getty tty1 в /etc/inittab (post-build из 5.8 отработал? grep tty1 /etc/inittab).

«hdmi-init: not found» (exit 127) при существующем файле

Исторический экспонат проекта: шелл говорит «not found» про файл, который ls видит. Это означает «не найден интерпретатор/загрузчик» - бинарник собран под другую libc/ABI, чем rootfs на карте. Типовой сценарий: на SD старая rootfs, а бинарник из новой сборки (или наоборот). Правило из части 5.3 («ABI един для всей rootfs») нарушено единственным легальным способом - несинхронной перепрошивкой. Лечение: перезаписать sdcard.img целиком, не «обновлять файлики». До лечения работает fallback hdmi-init.sh - он и был добавлен после этого случая.

Картинка со сдвигом, рваная, «плывёт»

  • расхождение stride/width между DTS и hdmi-init (части 5.5/5.7) - сверить 2400/800/600 во всех местах;

  • тайминги VTC ≠ 800×600@60 из части 1.2 (если меняли режим - сверьте все три места из части 8.3);

  • цвета «инопланетные» на графике, но текст нормальный - это не баг, это R↔B swap (части 5.5, 8.4).

9.4. USB-клавиатура не работает

Слои сверху вниз:

dmesg | grep -i usb
# ожидаем: «usb 1-1: new low-speed USB device number 2»
  • Вообще нет строк про новое устройство → нет питания VBUS или PHY в сбросе: MIO 7/9 (часть 2.3). Проверить hog'и и fallback:

cat /sys/kernel/debug/gpio | grep -i usb    # hog'и из DTS видны?
/usr/bin/usb-gpio-init.sh                   # поднять руками (часть 5.8)

Если и это не помогло - PCW_USB0_RESET_IO = MIO 7 в конфигурации PS7 (часть 3.3)? ULPI-конфиг ядра (5.6)?

  • Устройство есть, input-события нет → ls /dev/input/ пуст: CONFIG_USB_HID / HID_GENERIC / INPUT_EVDEV (часть 5.6);

  • event0 есть, в консоли не печатает → клавиатура шлёт события не в тот VT: проверить, что активна tty1 (chvt 1).

9.5. Ethernet: «Could not attach PHY (-22)»

Знакомец из части 5.5. Драйвер macb сматчился по generic-строке compatible и не включил гигабитный режим - phylink отверг RGMII.

  • Проверить DTS: compatible = "xlnx,zynq-gem", ... - первым (часть 5.5);

  • если PHY вообще не виден (mdio пуст в dmesg) - MDIO на MIO 52-53 включён в PS7 (часть 3.3)? Это требует пересборки FPGA → extract_xsa → Linux: MIO-конфиг едет через ps7_init;

  • линк есть, адреса нет - udhcpc -i eth0 (DHCP-клиент в boot-скриптах по умолчанию не запущен).

9.6. SD и rootfs

  • Kernel panic: «VFS: unable to mount root fs» → нет rootwait в bootargs (часть 5.5) либо root= указывает не туда (mmcblk0p2);

  • mmc: «card never left busy state» / карта не определяется в Linux (при том что U-Boot её читал!) → broken-cd потерялся из DTS - драйвер ждёт сигнала Card Detect, которого на плате нет (части 2.3, 5.5);

  • rootfs read-only → disable-wp там же.

9.7. Шпаргалка пересборки

Финал справочника - «что трогать после того, как я поменял X». Следствие графа зависимостей из части 0.3; распечатайте и повесьте над столом.

Менял Block Design / конфигурацию PS7 / XDC:

cd fpga/scripts && ./build_all.sh          # или build_bitstream.tcl, если без create
./xsct/extract_xsa.sh                      # ← НЕ ЗАБЫТЬ (часть 4)
cd ../..
rm -rf linux/output/build/uboot-custom     # если менялся PS7-конфиг (новый ps7_init!)
./scripts/build_linux.sh
source /opt/xilinx/2025.2/Vivado/settings64.sh
./scripts/make_boot_bin.sh
# на SD: dd sdcard.img + cp BOOT.BIN  (часть 7.1)

Менял только логику PL (BD без PS7) :

cd fpga/scripts && ./run_vivado.sh -mode batch -source build_bitstream.tcl
./xsct/extract_xsa.sh
cd ../.. && ./scripts/make_boot_bin.sh
# на SD: достаточно заменить BOOT.BIN на FAT-разделе

Менял DTS / kernel fragment / uboot.fragment:

./scripts/build_linux.sh
# DTS/ядро: обновить uImage и *.dtb на FAT (или перезаписать sdcard.img)
# uboot.fragment: ещё и ./scripts/make_boot_bin.sh + BOOT.BIN

Менял hdmi-init / overlay / пакеты rootfs:

./scripts/build_linux.sh
# перезаписать sdcard.img (вспомните 9.3: «обновлять файлики» в rootfs - путь к exit 127)

Два вечных правила:

  1. Изменился PS7-конфиг → изменился ps7_init → U-Boot надо пересобрать с нуля (хук не перезапустится сам - см. 9.1).

  2. Bitstream живёт в BOOT.BIN, а не на разделе - пересборка FPGA без make_boot_bin.sh ничего не меняет на плате.

Если ваш случай не нашёлся в этой главе - у вас в руках всё равно есть полная карта системы (часть 8.3) и метод слоёв (9.0): локализуйте границу, и причина найдётся. А мы переходим к заключению.

Часть 10. Заключение

Что получилось

Мы начали с платы, у которой HDMI-разъём висел на голых пинах FPGA, и закончили компьютером, который грузит Linux и встречает пользователя приглашением login: на мониторе. По дороге был построен полный вертикальный срез embedded-стека:

  • прочитана схема и из неё выведены все constraints и конфигурация процессорной системы;

  • собран видеоконвейер в FPGA - DMA, генератор таймингов, TMDS-кодер, три клоковых домена;

  • наведён мост между мирами Vivado и U-Boot - ps7_init;

  • собрана система в Buildroot - загрузчик с SPL, ядро linux-xlnx, rootfs;

  • написана прослойка между ядром и самодельным железом - Device Tree с simple-framebuffer и 260 строк userspace-инициализации;

  • упакован BOOT.BIN и пройдена загрузка с разбором каждого этапа.

Отдельное удовольствие - посмотреть, сколько собственного кода потребовала задача. За вычетом генерируемого и скачиваемого: ~250 строк TCL на весь FPGA-дизайн, ~90 строк Verilog (top-модуль), ~200 строк DTS, ~150 строк конфигов Buildroot и фрагментов, ~260 строк C и ~100 строк shell. Меньше тысячи строк - всё остальное сделали правильно выбранные готовые блоки: IP-ядра, Buildroot, ядро Linux. Искусство встраиваемой разработки - в основном искусство правильной стыковки чужого кода, и эта статья по сути была учебником стыковки.

Чему научились

Если выпарить главное из десяти глав:

  1. Слоистость - это инструмент. Система спроектирована так, что каждый слой проверяется отдельно (часть 9.0) и заменяется отдельно (часть 8.4). Это не случайность, а результат явных границ: память как интерфейс, Device Tree как контракт, адреса как договорённость.

  2. Согласованность дороже кода. Почти все настоящие баги проекта - не ошибки в логике, а расхождения одного числа в двух местах: адрес, stride, порядок строк compatible, напряжение банка. Отсюда же главный приём профилактики: одна таблица - много потребителей (тайминги из части 1.2 мы переиспользовали четырежды).

  3. Загрузка - это эстафета, и каждое звено можно потрогать. BootROM, SPL, ps7_init, U-Boot, ядро, init - после этой статьи ни одно из этих слов не должно быть магией.

  4. «Невидимые» стыки - самые опасные. Молчаливый fallback ps7_init, гашение «неиспользуемых» FCLK, незапущенный PRE_BUILD-хук - все главные грабли были там, где система не сообщает об ошибке.

Куда развивать

Список из ретроспективы (часть 8.4) - в порядке возрастания амбиций:

Уровень

Задача

Что потребуется

починить R↔B swap

поменять подключение компонент в BD, пересобрать bitstream

убрать отладочные bootargs, ускорить загрузку

правка DTS/uEnv

✦✦

double buffering

3 framestores в VDMA + обработка vsync-IRQ

✦✦

другие фиксированные разрешения (720p)

новая таблица таймингов + пересчёт MMCM (помним про потолок OSERDES из 1.1)

✦✦✦

динамические режимы: EDID + axi_dynclk

I²C к DDC (если разведён), таблицы DMT, перепрограммирование клока на лету

✦✦✦

честный DRM/KMS-драйвер

модуль ядра: CRTC поверх VDMA+VTC, encoder поверх rgb2dvi; X/Wayland в награду

✦✦✦✦

звук: от DVI к настоящему HDMI

data islands, TERC4, пакетизация - отдельная статья размером с эту

И ортогональное направление: перенести всё на другую плату (Zybo, Arty Z7, PYNQ) - лучший способ проверить, что вы поняли метод, а не запомнили команды. По нашим прикидкам, изменения уложатся в XDC, конфигурацию PS7 и пару строк DTS.

Ссылки

Проект:

  • Репозиторий статьи: megalloid/hdmi_over_pl - все скрипты, конфиги и исходники глав

Документация Xilinx/AMD (вся - бесплатно на docs.amd.com):

  • UG585 - Zynq-7000 TRM: библия PS; главы Boot and Configuration, DDR Memory Controller, переписка с которой составила часть 1.6

  • PG020 - AXI VDMA: регистры из частей 5.7 и приложения B

  • PG016 - Video Timing Controller

  • PG044 - AXI4-Stream to Video Out (там же - спецификация AXI4-Stream Video)

  • UG933 - 7 Series SelectIO: TMDS_33, OSERDESE2

Прочее:

  • Digilent vivado-library - rgb2dvi и axi_dynclk, исходники открыты и читабельны: лучший способ доучить TMDS - прочитать rgb2dvi.vhd

  • Buildroot manual - особенно главы про BR2_EXTERNAL и generic-package

  • VESA DMT - таблицы таймингов всех стандартных режимов

Спасибо, что дочитали. Если по мотивам статьи у вас на мониторе появилось изображение - день прожит не зря.

Приложения

Приложение A. Распиновка HDMI и полный XDC

Сигнал

Пин (P/N)

Банк

IOSTANDARD

TMDS Clock

H16 / H17

35

TMDS_33

TMDS Data0 (B)

D19 / D20

35

TMDS_33

TMDS Data1 (G)

C20 / B20

35

TMDS_33

TMDS Data2 (R)

B19 / A20

35

TMDS_33

HDMI_OUT_EN (+5V key)

H18

35

LVCMOS33

Полный constraints-файл (fpga/constraints/zynq_mini_revb_hdmi.xdc), включая необязательную обвязку платы:

# --- HDMI TMDS (bank 35) ---
set_property -dict {PACKAGE_PIN H16 IOSTANDARD TMDS_33} [get_ports hdmi_clk_p]
set_property -dict {PACKAGE_PIN H17 IOSTANDARD TMDS_33} [get_ports hdmi_clk_n]
set_property -dict {PACKAGE_PIN D19 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[0]}]
set_property -dict {PACKAGE_PIN D20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[0]}]
set_property -dict {PACKAGE_PIN C20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[1]}]
set_property -dict {PACKAGE_PIN B20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[1]}]
set_property -dict {PACKAGE_PIN B19 IOSTANDARD TMDS_33} [get_ports {hdmi_data_p[2]}]
set_property -dict {PACKAGE_PIN A20 IOSTANDARD TMDS_33} [get_ports {hdmi_data_n[2]}]

# Ключ +5 В на HDMI-разъёме (активный высокий)
set_property -dict {PACKAGE_PIN H18 IOSTANDARD LVCMOS33} [get_ports hdmi_out_en_o]

# --- PL LED (bank 34) ---
set_property PACKAGE_PIN T12 [get_ports {led_o[0]}]
set_property PACKAGE_PIN U12 [get_ports {led_o[1]}]
set_property PACKAGE_PIN V12 [get_ports {led_o[2]}]
set_property PACKAGE_PIN W13 [get_ports {led_o[3]}]
set_property IOSTANDARD LVCMOS33 [get_ports {led_o[*]}]

# --- PL keys (bank 35), active low ---
set_property PACKAGE_PIN M20 [get_ports key1_n_i]
set_property PACKAGE_PIN M19 [get_ports key2_n_i]
set_property IOSTANDARD LVCMOS33 [get_ports {key1_n_i key2_n_i}]
set_property PULLUP TRUE [get_ports {key1_n_i key2_n_i}]

Приложение B. Регистры VDMA и VTC, используемые hdmi-init

Базовые адреса: VDMA 0x43C1_0000, VTC 0x43C2_0000 (Address Editor, часть 3.6).

AXI VDMA, канал MM2S (PG020)

Offset

Регистр

Наша запись

Смысл

0x00

MM2S_VDMACR

0x4 затем 0x1

бит 2 - reset, бит 0 - RUN

0x04

MM2S_VDMASR

(чтение)

бит 0 - halted; биты 4-11 - ошибки

0x28

PARK_PTR_REG

0x0

парковка на кадре 0

0x50

MM2S_VSIZE

600

строк в кадре; запись запускает DMA

0x54

MM2S_HSIZE

2400

байт в строке

0x58

MM2S_FRMDLY_STRIDE

2400

шаг между строками

0x5C

MM2S_START_ADDRESS1

0x1E000000

адрес framebuffer

Порядок программирования: reset → дождаться снятия → PARK → FRMBUF → STRIDE → HSIZE → RUN → VSIZE последним.

Video Timing Controller, генератор (PG016)

Offset

Регистр

Наша запись

Смысл

0x000

CTL

0x80000000 → 0x7

reset; затем SW(0)+RU(1)+GE(2)

0x060

GASIZE

600<<16 \| 800

активная область V/H

0x064

GTSTAT

(чтение)

статус генератора

0x06C

GPOL

0x7C

полярности - все active high

0x070

GHSIZE

1056

полная строка

0x074

GVSIZE

628

полный кадр

0x078

GHSYNC

968<<16 \| 840

HSYNC end/start

0x080

G0VSYNC

605<<16 \| 601

VSYNC end/start

Тайминги - VESA DMT 800×600@60, pixel clock 40 МГц (вывод - часть 1.2).

Приложение C. Ключевые файлы проекта

Короткие - целиком, длинные - справочно (полные тексты в репозитории).

Карта файлов

Файл

Строк

Разбор в части

fpga/scripts/create_project.tcl

~245

3.10

fpga/scripts/ps7_config.tcl

~230

3.3

fpga/src/zynq_mini_hdmi_top.v

~90

3.7

fpga/constraints/zynq_mini_revb_hdmi.xdc

~40

3.7 / прил. A

linux/configs/zynq_mini_revb_hdmi_defconfig

65

5.3

linux/external/external.mk

28

5.4

linux/external/dts/zynq-mini-revb.dts

~195

5.5

linux/external/board/.../linux.fragment

~90

5.6

linux/external/board/.../uboot.fragment

~40

5.4

linux/external/package/hdmi-init/src/hdmi-init.c

~265

5.7

linux/external/board/.../rootfs_overlay/...

~100

5.8

scripts/build_linux.sh

~30

5.9

scripts/make_boot_bin.sh

~95

6.2

external.mk - целиком (тот самый мост)

include $(sort $(wildcard $(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/package/*/*.mk))

# Хук 1: регистрируем нашу DTS в arch/arm/dts/Makefile U-Boot
define ZYNQ_MINI_HDMI_UBOOT_REGISTER_DTS
   if ! grep -q 'zynq-mini-revb.dtb' $(@D)/arch/arm/dts/Makefile; then \
      sed -i '1a dtb-$$(CONFIG_ARCH_ZYNQ) += zynq-mini-revb.dtb' $(@D)/arch/arm/dts/Makefile; \
   fi
endef
UBOOT_PRE_BUILD_HOOKS += ZYNQ_MINI_HDMI_UBOOT_REGISTER_DTS

# Хук 2: ps7_init_gpl из Vivado XSA → board/xilinx/zynq/zynq-mini-revb/
ZYNQ_MINI_PS7_DIR := $(BR2_EXTERNAL_ZYNQ_MINI_HDMI_PATH)/../../fpga/xsa_extracted
define ZYNQ_MINI_HDMI_UBOOT_INSTALL_PS7_INIT
   if [ -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.c ] && [ -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.h ]; then \
      mkdir -p $(@D)/board/xilinx/zynq/zynq-mini-revb; \
      cp -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.c $(@D)/board/xilinx/zynq/zynq-mini-revb/; \
      cp -f $(ZYNQ_MINI_PS7_DIR)/ps7_init_gpl.h $(@D)/board/xilinx/zynq/zynq-mini-revb/; \
   else \
      echo "WARN: ps7_init_gpl.* не найдены - SPL будет использовать ZC702 ps7_init"; \
   fi
endef
        
UBOOT_PRE_BUILD_HOOKS += ZYNQ_MINI_HDMI_UBOOT_INSTALL_PS7_INIT

boot.bif - целиком

the_ROM_image:
{
   [bootloader] linux/output/build/uboot-custom/spl/u-boot-spl
   fpga/xsa_extracted/system.bit
   linux/output/images/u-boot.img
}

Приложение D. Полный цикл сборки одним экраном

От пустого каталога до карты в слоте - конденсат частей 3-7:

## 0. Окружение (один раз)
sudo apt install -y build-essential gcc g++ make patch gzip bzip2 unzip bc \
 cpio rsync file wget curl git libncurses-dev libssl-dev python3 python3-setuptools
export XILINX_VIVADO=/opt/xilinx/2025.2/Vivado

## 1. FPGA: bitstream + XSA                                  [части 3, ~30-90 мин]
cd hdmi_over_pl/fpga/scripts
./build_all.sh            # = fetch_digilent_ip + create_project + build_bitstream

## 2. Извлечь ps7_init + bitstream из XSA                    [часть 4, секунды]
./xsct/extract_xsa.sh

## 3. Buildroot: U-Boot SPL + ядро + rootfs + sdcard.img     [часть 5, ~1-3 ч]
cd ../../linux
wget -nc https://buildroot.org/downloads/buildroot-2024.02.7.tar.gz
tar xf buildroot-2024.02.7.tar.gz
cd ..
./scripts/build_linux.sh

## 4. BOOT.BIN = SPL + bitstream + U-Boot                    [часть 6, секунды]
source ${XILINX_VIVADO}/settings64.sh
./scripts/make_boot_bin.sh

## 5. SD-карта                                                [часть 7]
lsblk                                           # найти /dev/sdX - ПРОВЕРИТЬ ДВАЖДЫ
sudo dd if=linux/output/images/sdcard.img of=/dev/sdX bs=4M conv=fsync status=progress
sudo mount /dev/sdX1 /mnt && sudo cp linux/output/images/BOOT.BIN /mnt/ && sudo umount /mnt
sync

## 6. Плата: boot mode = SD, карта в TF1, UART 115200, HDMI, питание.
picocom -b 115200 /dev/ttyUSB0
# → zynq-mini-revb login:  - и на мониторе тоже.

Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Комментарии (0)