После успешной отладки на плате с Cyclone IV пришла пора перенести наработки на плату Zynq Mini c XC7Z020. В этой статье я опишу, каким образом можно организовать вывод нужной нам информации из PS-части Zynq на дисплей который подключен к EMIO на выводах PL. Сделаем обновленный модуль i2c_master_axi который добавляет сверху к уже разработанному ядру поддержку AXI4-Lite Slave, сделаем сборку проекта, подключим их к PS и проверим в bare-metal сценарии. После того как это будет все работать - переходить к Linux уже будет гораздо проще. Всем заинтересованным добро пожаловать под кат!

Важно! Перед началом повествования, хотелось бы заранее оговориться, что основная цель, которую я преследую при написании этой статьи - рассказать о своем опыте, с чего можно начать, при изучении отладочных плат на базе Zynq. Я не являюсь профессиональным разработчиком под ПЛИС и SoC Zynq, не являюсь системным программистом под Linux и могу допускать какие-либо ошибки в использовании терминологии, использовать не самые оптимальные пути решения задач, etc. Но отмечу, что любая конструктивная и аргументированная критика только приветствуется.
Цель статьи
Основная цель данной статьи - дать практически пошаговую инструкцию с подробным разбором «от пустого Vivado до картинки на OLED-дисплее».
Сформулируем скоп задач, которые будут стоять передо мной для достижения цели:
В программируемой логике (PL) сформировать доработанный Verilog-модуль
i2c_master_axi- это I²C-мастер, регистровая карта которого выставлена наружу как AXI4-Lite slave.Процессорная система Zynq (PS, ARM Cortex-A9) должна будет общаться с ним по AXI, инициализировать SSD1306-дисплей через I²C и рисовать тестовый паттерн.
ПО должно запускаться без операционной системы - программа на C загружается JTAG-ом прямо в OCM (256 КБ внутренней SRAM) и стартует.
Когда всё это будет работать - переходить к Linux будет несложно. Bare-metal - это “фундамент”: если он работает, значит и аппаратура корректна, и BSP/драйверы есть смысл собирать. Так же приложу готовые tcl-скрипты (в репозитории) для простоты воспроизведения полученного результата.
Что должно быть в наличии перед началом
№ |
Что нужно |
Зачем |
1. |
Xilinx Vivado 2025.2 (Standard/ML Edition - обе подходят; WebPACK подходит для Zynq-7000 бесплатно) |
Создаём проект, синтезируем PL, генерируем битстрим |
2. |
Xilinx Vitis 2025.2 (Unified IDE) - ставится вместе с Vivado. |
Собираем bare-metal программу для ARM, прошиваем по JTAG |
3. |
Cable Drivers - ставятся из инсталлятора Vivado: при первом запуске выйдет напоминание, или вручную:
|
Без них Vivado не увидит плату по USB-JTAG |
4. |
Плата ZYNQ MINI Rev B с XC7Z020-CLG400 (или XC7Z010 - у нас по умолчанию |
Цель прошивки |
5. |
OLED-модуль 128×64 на контроллере SSD1306, подключенный в режиме I²C к разъёму CAM1 платы: SDA на пин T20, SCL на пин P20, питание 3.3 В и общая земля |
Дисплей, на котором проверяем работу |
6. |
USB-кабель Type-C (JTAG/UART идут через один разъём - CH340E на плате) |
JTAG-прошивка и UART-терминал |
7. |
Папка репозитория - нужны файлы |
Готовый RTL и bare-metal код |
8. |
Эмулятор терминала: minicom, picocom, screen или PuTTY - с настройкой 115200 8N1 |
Смотрим |
Если у вас ZYNQ MINI с XC7Z010 - описанное ниже так же работает, нужно только выбрать xc7z010clg400-1 вместо xc7z020clg400-1. Битстрим у этих чипов несовместим, но шаги те же.
Важные понятия
Немного дам пояснения с чем мы будем иметь дело, “для самых маленьких”. Итак, что касается PS и PL. Чип XC7Z020 - это двухкомпонентный SoC:
PS (Processing System) - обычный двухъядерный ARM Cortex-A9 с собственными контроллерами DDR3, USB, Ethernet, UART, SD, QSPI и т.д. Всё это управляется MIO-пинами 0...53.
PL (Programmable Logic) - собственно FPGA: матрица из LUT/FF/BRAM, на которой мы описываем схему на Verilog/VHDL.
Между PS и PL идут шины:
AXI4 / AXI4-Lite - главная транспортная шина. PS - мастер, PL - slave (или наоборот).
FCLK_CLK0...3 - тактовые линии, выдаваемые из PS в PL (мы будем использовать
FCLK_CLK0 = 50 МГц).IRQ_F2P - линия прерываний от PL к контроллеру прерываний GIC внутри PS.
Следующее с чем мы будем иметь дело это Block Design - это графическое представление верхнего уровня дизайна, где IP-блоки (включая нашу Verilog-обёртку) соединяются как “кубики” проводами и шинами. Vivado автоматически генерирует под капотом нужный Verilog/VHDL.
Следующий пункт. IP-ядро (IP core) - это переиспользуемый блок. Бывают:
Из IP Catalog (поставляются Xilinx): Processing System 7, AXI Interconnect, Processor System Reset, Concat, Constant, Utility Buffer и т.д.
Module Reference: любой ваш Verilog-модуль, добавленный в проект, можно “вставить” в BD как IP-блок. Vivado сам прочитает порты модуля и предложит соединения. Имена сигналов вида
s_axi_*распознаются как AXI4-Lite slave автоматически.Packaged IP (свой собственный IP, упакованный через IP Packager) нам не нужен.
В этом проекте мы используем Module Reference для i2c_master_axi - это самый простой способ привязать IP-ядро Verilog к BD без упаковки.
Важный пункт: AXI4-Lite - простой подтип AXI4 для регистровых операций (32-битное чтение/запись по одному слову за раз). Наш i2c_master_axi выставляет на эту шину 7 регистров:
Адрес (от base) |
Имя |
Назначение |
0x00 |
CTRL |
EN, IEN |
0x04 |
STATUS |
TIP, RXACK, BUSY, AL |
0x08 |
CMD |
STA, STO, RD, WR, NACK |
0x0C |
TX_DATA |
байт для отправки |
0x10 |
RX_DATA |
принятый байт |
0x14 |
PRESCALE |
делитель: |
0x18 |
ISR |
Done IRQ / AL IRQ, write-1-to-clear |
Полная регкарта будет приведена далее. Базовый адрес мы зафиксируем равным 0x43C00000 - это “AXI GP0 user peripheral space” Zynq. Но об этом будет рассказано ниже.
Следующий пункт. IOBUF и tri-state. I²C - это open-drain шина: мастер может либо “отпустить” линию (на ней появится 1 благодаря подтяжке к VCC), либо “прижать” к земле (0). На стороне FPGA это реализуется трёхстабильным буфером IOBUF, у которого есть:
I - что мы хотим выдать (всегда 0 для open-drain);
T - tri-state enable: 1 = отпустить линию, 0 = тянуть I наружу;
O - что мы читаем с линии;
IO - собственно внешний контакт.
Наш i2c_master_axi выводит три сигнала на каждую линию:
_pad_o - мы зафиксируем = 0 внутри модуля;
_padoen_o - это T;
*_pad_i - это O.
Из BD они выходят наружу как обычные сигналы, а IOBUF мы поставим вручную в RTL-обёртке zynq_mini_oled_top.v - Block Design плохо работает с inout-портами напрямую.
Теперь про прерывания (IRQ_F2P). Контроллер прерываний Cortex-A9 (GIC) принимает с PL до 16 линий через шину IRQ_F2P. Нумерация в GIC: первый из них - Shared Peripheral Interrupt #61, второй - #62 и т.д. Для bare-metal-демо мы прерывание не используем (опрашиваем TIP спин-петлёй), но провод между i2c/irq_o и ps7/IRQ_F2P[0] всё равно проложим - на него после будет опираться Linux-драйвер.
И упомяну, что такое XSA. После того как PL собрана, нужно “передать” Vitis'у описание платформы: какой процессор, какая периферия, по каким адресам сидят IP, какие частоты. Это упаковано в файл *.xsa (Xilinx Support Archive). Внутри - XML с описанием и сам битстрим.
План работы
Создаем проект Vivado под xc7z020clg400-1;
Добавляем RTL i2c_master_core.v и i2c_master_axi.v;
Создать Block Design с названием system;
Добавить Zynq7 Processing System и настроить под плату;
Добавить i2c_master_axi как Module Reference;
Делаем внешними выводы SDA/SCL pad_i/o/oen + irq;
Запускаем Connection Automation AXI4 → подключит Interconnect и reset;
Подключить irq_o к IRQ_F2P;
В Address Editor для i2c/s_axi устанавливаем 0x43C00000;
Выполняем Validate Design;
Создаем HDL Wrapper и top-RTL с IOBUF;
После создаем XDC constraints: pin location;
Synthesis → Implementation→ Generate Bitstream;
Export Hardware → XSA
Vitis: создать Platform из XSA;
Vitis: создать Application oled_demo;
Добавить src/: i2c_master.c, ssd1306.c, main.c
Build ELF;
Program FPGA + Run ELF через JTAG;
OLED показывает нужный паттерн. Profit!
И в этой статье разберем каждый шаг подробно. Поехали!
Шаг 1. Запускаем Vivado и создаём проект
Запускаем среду разработки Vivado с предварительным импортом настроек и переменных:
source /opt/xilinx/2025.2/Vivado/settings64.sh vivado &
Откроется Welcome screen. В колонке Quick Start нажмите Create Project. Откроется мастер.

Project name: zynq_mini_oled (латиница, без пробелов и кириллицы - иначе TCL потом будет ругаться на пути).
Project location: короткий путь, например /home/user/fpga/.
✅ Create project subdirectory - должна быть включена.
Next.

⚫ RTL Project - это значит “проект для проектирования на Verilog/VHDL”.
✅ Do not specify sources at this time - оставляем включённым. Файлы добавим вручную позже.
Next.

Эта вкладка говорит Vivado, какой именно кристалл стоит на плате.
Сверху диалога переключитесь на вкладку Parts (НЕ Boards - нашей китайской платы в каталоге Vivado нет).
-
Раскройте Filters справа:
Family:
Zynq-7000Package:
clg400Speed grade:
-1(если на корпусе вашего чипа напечатано -2/-3 - поставьте ту же)$
В таблице ниже найдите
xc7z020clg400-1и кликните по строке.Next → Finish.
Хотя есть мнение о том, что на Zynq Mini rev.B стоит чип 2 speed grade.
Vivado создаст проект и откроет главное окно — Project Manager.
Если у вашей платы XC7Z010 - выбирайте xc7z010clg400-1. Дальнейшие шаги такие же; разные у этих чипов только bitstream и количество доступной логики, IP и MIO-пины одинаковы.

Шаг 2. Добавляем RTL-исходники
Теперь нужно взять исходники из проекта Quartus и интегрировать их в Vivado. А конкретно - необходим rtl/i2c_master_core.v - в нем низкоуровневый FSM шины, команды START/STOP, бит-уровень SDA/SCL как единый эталон проверенный и в симуляции, и на железе Cyclone.
И позже необходимо обернуть это ядро в вышестоящий модуль i2c_master_axi.v чтобы предоставить AXI4-Lite интерфейс: с точки зрения ARM в PS он выглядит как набор 32-битных регистров по шине.
Внутри модуля будет:
Интерфейс AXI4-Lite - стандартные порты
s_axi_*(aw, w, b, ar, r), тактированиеs_axi_aclk, сбросs_axi_aresetn. Именно префиксs_axi_позже заставит Vivado в Block Design распознать готовый AXI4-Lite slave и предложить Connection Automation к M_AXI_GP0.Регистровый файл и секвенсер - запись в CMD разворачивается в цепочку атомарных команд для ядра; чтения/записи идут по карте из заголовка файла.
Экземпляр i2c_master_core - всё “низкоуровневое” I²C сосредоточено в ядре; обёртка только подаёт
ena_i, команды и забирает статусы.Синхронизаторы на входах
scl_pad_i/sda_pad_i- асинхронные линии шины попадают в доменs_axi_aclkдвумя регистрами (метастабильность).Открытый коллектор наружу - не
inout, а три сигнала на линию:_pad_o(в коде всегда 0),_padoen_o(1 = отпустить линию / Hi-Z, 0 = тянуть вниз),*_pad_i(чтение линии). Для Zynq top-level с inout собирается уже в Verilog-обёртке с примитивами IOBUF.irq_o - линия к GIC (в мануале мы её позже ведём на
IRQ_F2P).
Поток данных в двух словах выглядит так: ARM в PS выполняет запись/чтение 32-битных слов по AXI - это попадает в регистры обёртки и (для CMD) запускает секвенсер. Секвенсер переводит “человеческую” команду (STA+WR+STO и т.д.) в последовательность обращений к i2c_master_core, а ядро, получая импульсы ena_i от прескалера, реально двигает SCL/SDA через open-drain. Обратный путь: ядро отдаёт RX-байт и биты статуса → они собираются в STATUS и RX_DATA при чтении AXI; события “передача закончена” и “арбитраж потерян” попадают в ISR и на irq_o.
Ниже я приведу логическое продолжение: в каком порядке вообще приходят к такому файлу при разработке под интеграцию в Zynq, затем приведу разбор исходника по фрагментам: после каждого блока кода из rtl/i2c_master_axi.v будет пояснение, что это делает и как связано с остальной логикой.
Переходим к наполнению проекта. Добавляем каждый файл.
Flow Navigator → Project Manager → Add Sources (или Alt+A).
Add or create design sources → Next.
Create Files → Verilog с именем
i2c_master_core.vCreate Files → Verilog с именем
i2c_master_axi.vFinish.

Далее нас попросят заполнить порты, пропустим этот шаг и сделаем это прямо в коде.
Необходимо вставить в содержимое файла i2c_master_core.v наши предыдущие наработки.

После этого перейдем к разработке i2c_master_axi.v и немного углубимся в суть AXI4-Lite шины. Общий флоу будет следующим:
Шаг |
Что делают |
Зачем это нужно |
1 |
Утверждаем интерфейс модуля |
Ядро - подключаемый общий слой, что для проектов Quartus, что для Vivado; |
2 |
Фиксируем регистровую карту периферии (адреса, R/W, смысл битов) - и дублируем её комментарием в заголовке |
ARM будет ходить по AXI в байтовых смещениях; |
3 |
Разделяем доступ по шине на два канала AXI4-Lite: запись (AW+W → B) и чтение (AR → R). Рисуем на бумаге рукопожатия: когда поднимать |
См. соответствующие always-блоки ниже; |
4 |
Реализуем хранение CTRL, TX_DATA, PRESCALE, полей CMD, флагов ISR в регистрах |
Регистры - это то, что «видит» процессор; стробы - связка между шиной и секвенсером. |
5 |
Вводим прескейлер ( |
Ядро шагает только при |
6 |
Пишем секвенсор (FSM |
Программа пишет “составную” команду; ядро принимает только атомарные команды - см. локальные параметры |
7 |
Добавляем синхронизаторы на линии с пинов и маскирование выходов при |
Метастабильность с внешней шины; безопасное поведение до |
8 |
Реализуем IRQ: задержка |
Согласование с GIC в BD; софт может опрашивать STATUS вместо IRQ и оба пути допустимы. |
9 |
Подключаем |
Точка сборки: после этого модуль - законченное RTL-целое. |
10 |
Прогоняем симуляцию (testbench на AXI master BFM + модели SDA/SCL), синтезируем в Vivado, правим тайминги/warnings. |
Только после этого имеет смысл подключать к Block Design и XDC. |
То есть сначала делают минимально живой AXI-slave: отвечает OKAY, читает/пишет пару “пустышек”, чтобы проверить адресное декодирование из PS. Затем подключают прескалер и смотрят на осциллографе/в симуляции, что ena_i щёлкает с нужным периодом. После этого вешают секвенсор и проверяют один сценарий (например только WRITE без STA), потом составные команды. Синхронизаторы на SCL/SDA вносят уже когда не страшно “уронить” шину не туда: до этого можно симулировать с идеальными входами. IRQ часто добавляют последними - bare-metal-демо в этом репозитории обходится опросом TIP, но линия IRQ в Block Design всё равно прокладывается “на вырост” под Linux. Шаг 10 обязателен: AXI легко написать “почти правильно”, но без симуляции/синтеза всплывут гонки на awready/wready или неучтённый wstrb. Флоу в целом понятен, перейдем к реализации.
AXI4-Lite и зачем он нам нужен?
AXI4-Lite - это упрощённый профиль протокола AXI4 из семейства шин AMBA (ARM). Он задаёт правила memory-mapped доступа: мастер выставляет адрес и (при записи) данные, slave принимает транзакцию и возвращает ответ или слово данных при чтении. Для программиста это выглядит как обычные store/load по фиксированным адресам - то же представление, что у регистров UART, таймера или GPIO в техническом описании процессора.
Для подробного объяснения предлагаю посмотреть обучающие ролики:
Может сделать подробное описание в своих статьях как-нибуль...
Если говорить про Zynq, то тут процессор в PS получает доступ к данным в PL через порты AXI GP (general-purpose master). Vivado собирает между PS и вашими IP цепочку AXI Interconnect / SmartConnect: на одном конце - мастер ARM, на другом - slave с интерфейсом вида s_axi_*. Именно Lite достаточно для типичной «регистровой» периферии: управление, статус, IRQ и без высокоскоростных потоковых burst.
Транзакции по шине разнесены по отдельным каналам для чтения и записи чтобы данные могли идти параллельно. У каждого канала свой поток …valid / …ready - это стандартное рукопожатие AXI.
Запись (write) состоит из трёх логических частей:
AW (address write) - адрес цели и тип доступа (в Lite формат упрощен).
W (write data) - данные и строб байтов wstrb (для 32-битного слова показывает, какие байты слова действительно записываются).
B (write response) - короткий ответ slave о том, принята ли запись (в простых регистровых блоках почти всегда OK).
Чтение (read) состоит из двух частей:
AR (address read) - адрес.
R (read data) - прочитанное слово и код ответа rresp (у успешной транзакции - OKAY). В Lite за одно чтение возвращается ровно одно слово данных.
На практике в slave-модуле должны быть порты вроде s_axi_awaddr, s_axi_wdata, s_axi_araddr, s_axi_rdata - это и есть эти каналы (конкретный набор зависит от того, как Vivado/IP описали интерфейс; наш i2c_master_axi использует минимальный набор без rlast, потому что поток данных всегда однотактный).
Передача по каналу считается состоявшейся в такте t, если в этом такте активны и valid, и ready. Пока ready от приёмника не выставлен, источник обязан удерживать стабильные адрес, данные и вспомогательные поля (wstrb и т.д.). Так slave может отвечать за один такт (как в минималистичных регистровых обёртках) или тратить несколько тактов на внутреннюю логику - протокол это допускает.
Модуль i2c_master_axi - это как раз slave AXI4-Lite: записи ARM в CTRL, CMD, TX_DATA попадают в регистры и запускают секвенсер; чтения STATUS, RX_DATA, ISR отдают состояние обратно по каналу R. На стороне софта это остаётся “запись по адресу base+смещение”; а семантика ниже - уже регистровая карта периферии, а не детали AXI.
Наш контроллер выставляет на шину семь 32-битных регистров (адресный шаг 4 байта), через которые будет организована механика взаимодействия с ядром модуля i2c_master_core. При разработке обёртки эту карту явно нужно зафиксировать (смещения, R/W, биты) и дублировать комментарием в заголовке, чтобы RTL, bare-metal и Linux-драйвер ссылались на один и тот же набор сигналов.
Представляю данную регистровую карту с пояснениями.
Сводная таблица (все адреса - смещение от базового адреса slave на GP0):
Смещение |
Имя |
Доступ |
Значение после сброса |
0x00 |
CTRL |
R/W |
|
0x04 |
STATUS |
R |
Только чтение |
0x08 |
CMD |
W |
Регистр по смыслу «запись-only» |
0x0C |
TX_DATA |
R/W |
|
0x10 |
RX_DATA |
R |
|
0x14 |
PRESCALE |
R/W |
Начальное значение задаётся параметром |
0x18 |
ISR |
R / W1C |
|
Для себя отметим важные замечания: PRESCALE меняем только при EN = 0. Переход EN из 1 в 0 обрывает текущую транзакцию сразу.
CTRL (0x00) - управление:
Биты |
Поле |
Сброс |
Описание |
31:2 |
- |
0 |
зарезервированы |
1 |
IEN |
0 |
Разрешение прерываний: при 1 линия |
0 |
EN |
0 |
Включение контроллера: при 0 ядро не двигает SCL/SDA, линии отпущены (как задаёт open-drain-логика) |
STATUS (0x04) - статус (только чтение):
Биты |
Поле |
Описание |
31:4 |
- |
нули |
3 |
AL |
потеря арбитража (арбитраж на шине) |
2 |
BUSY |
шина занята (между условиями START и STOP в логике ядра) |
1 |
RXACK |
последний принятый бит ACK/NACK: 0 = ACK от слейва, 1 = NACK |
0 |
TIP |
transfer in progress - секвенсор обрабатывает команду; |
CMD (0x08) - команда (запись слова; актуальны младшие 5 бит).
Несколько битов можно сочетать в одной записи - секвенсер развернет это в цепочку атомарных обращений к i2c_master_core:
Бит |
Поле |
Роль |
0 |
STA |
сгенерировать START; если шина уже занята, подаётся RESTART |
1 |
STO |
после передачи байта выполнить STOP |
2 |
RD |
принять байт в RX_DATA |
3 |
WR |
выдать байт из TX_DATA |
4 |
NACK |
при чтении на 9-м бите ответить NACK вместо ACK |
Типовые комбинации (как удобно задавать в софте):
Состав команды |
Hex |
Когда использовать |
STA + WR |
0x09 |
START и первый байт (часто 7-битный адрес с битом R/W) |
WR |
0x08 |
очередной байт записи |
WR + STO |
0x0A |
байт записи и завершение STOP |
RD |
0x04 |
чтение байта с ACK |
RD + NACK + STO |
0x16 |
последний байт чтения: NACK и STOP |
STO |
0x02 |
только STOP |
TX_DATA (0x0C) - байт для выдачи на шину ([7:0]), включая адресный байт в формате TX_DATA = {slave_addr[6:0], R/W}: R/W = 0 - режим записи мастера, R/W = 1 - режим чтения.
RX_DATA (0x10) - последний принятый байт ([7:0]), только чтение.
PRESCALE (0x14) - делитель такта ena_i для ядра ([15:0]).
Связь с частотой SCL:
где - это
s_axi_aclk у обёртки. Пример для:
Режим |
Целевая SCL |
PRESCALE |
Standard |
100 кГц |
249 ( |
Fast |
400 кГц |
62 ( |
Fast Mode Plus |
1 МГц |
24 ( |
ISR (0x18) и irq_o:
Биты |
Поле |
Доступ |
Описание |
31:2 |
- |
- |
зарезервированы |
1 |
AL_IRQ |
R/W1C |
событие по потере арбитража |
0 |
DONE_IRQ |
R/W1C |
событие завершения транзакции (после работы секвенсера) |
W1C: - это когда запись 1 в данный бит сбрасывает его; запись 0 не меняет остальные флаги. Активный уровень на irq_o (упрощённо): IEN & (DONE_IRQ | AL_IRQ) - см. точную реализацию в RTL.
Минимальный сценарий в софте (без привязки к конкретному API): выставить PRESCALE и EN, при необходимости IEN; для записи положить байт в TX_DATA, затем записать CMD; после крутиться в ожидании TIP = 0 и проверять RXACK; для чтения - CMD с RD, забрать байт из RX_DATA. Разбор того, как это сделано в обёртке - приведу ниже.
Пишем i2c_master_axi.v
Следующим шагом собираем исходник. Идём сверху вниз, как в файле и после каждого блока - я дам объяснение что он делает.
Первый шаг - заголовок файла и параметры модуля:
`timescale 1ns / 1ps // --------------------------------------------------------------------------- // I2C Master — AXI4-Lite slave wrapper // // Contains: register file, prescaler, command sequencer, interrupt logic, // 2-stage synchronisers for SDA/SCL inputs, i2c_master_core instance. // // Register map (32-bit data, byte-address step = 4): // 0x00 CTRL R/W [1:0] = {IEN, EN} // 0x04 STATUS R [3:0] = {AL, BUSY, RXACK, TIP} // 0x08 CMD W [4:0] = {NACK, WR, RD, STO, STA} // 0x0C TX_DATA R/W [7:0] // 0x10 RX_DATA R [7:0] // 0x14 PRESCALE R/W [15:0] SCL = clk / (4*(PRESCALE+1)) // 0x18 ISR R/W1C [1:0] = {AL_IRQ, DONE_IRQ} // --------------------------------------------------------------------------- module i2c_master_axi #( parameter C_S_AXI_DATA_WIDTH = 32, parameter C_S_AXI_ADDR_WIDTH = 5, parameter DEFAULT_PRESCALE = 16'd249 // 100 MHz → 100 kHz )(
Директива `timescale задаёт дискрет времени и точность задержек для симулятора (на поведение синтезированной схемы в FPGA не влияет). Блочный комментарий - это “контракт” с программистами: здесь перечислены смещения; софт и bare-metal в Vitis опираются на те же числа, иначе «сдвинутый на 4 байта» адрес даст тихую порчу регистров.
Параметр C_S_AXI_DATA_WIDTH=32 соответствует стандарту AXI4-Lite (одно слово за транзакцию). C
_S_AXI_ADDR_WIDTH=5: в Zynq на slave обычно приходят младшие биты полного адресного пространства GP - для нашей карты нужны адреса 0x00...0x18; 5 бит с запасом.
DEFAULT_PRESCALE: при сбросе регистр PRESCALE инициализируется этим значением; комментарий “100 MHz → 100 kHz” ориентирует на типичный s_axi_aclk из PL - при другой частоте пересчитайте PRESCALE, чтобы получить нужную SCL по формуле в шапке файла.
Далее объявляем порты AXI4-Lite, IRQ, I²C.
// AXI4-Lite slave interface input wire s_axi_aclk, input wire s_axi_aresetn, input wire [C_S_AXI_ADDR_WIDTH-1:0] s_axi_awaddr, input wire s_axi_awvalid, output reg s_axi_awready, input wire [C_S_AXI_DATA_WIDTH-1:0] s_axi_wdata, input wire [C_S_AXI_DATA_WIDTH/8-1:0] s_axi_wstrb, input wire s_axi_wvalid, output reg s_axi_wready, output reg [1:0] s_axi_bresp, output reg s_axi_bvalid, input wire s_axi_bready, input wire [C_S_AXI_ADDR_WIDTH-1:0] s_axi_araddr, input wire s_axi_arvalid, output reg s_axi_arready, output reg [C_S_AXI_DATA_WIDTH-1:0] s_axi_rdata, output reg [1:0] s_axi_rresp, output reg s_axi_rvalid, input wire s_axi_rready, // Interrupt (directly to GIC / concat) output wire irq_o, // I2C pads (active-low open-drain, directly to tri-state buffers) input wire scl_pad_i, output wire scl_pad_o, output wire scl_padoen_o, // 1=tristate, 0=drive low input wire sda_pad_i, output wire sda_pad_o, output wire sda_padoen_o // 1=tristate, 0=drive low );
Имена s_axi_* - это осознанный выбор: IP Integrator в Vivado распознаёт такой префикс и при добавлении Module Reference собирает из портов один интерфейс AXI4-Lite, что упрощает Run Connection Automation.
Запись: цепочка AW (адрес) + W (данные и wstrb) не обязана прийти в одном такте с точки зрения протокола; ответ B подтверждает приём. bresp=OKAY - единственный код, который мы реально выставляем (ошибки AXI здесь не кодируются).
Чтение: AR задаёт адрес, в том же или следующем такте выдаётся R с rdata; для Lite достаточно одной фазы данных на транзакцию.
wstrb: при записи 32-битного слова позволяет менять только младший байт (что важно для TX_DATA, CTRL, полей ISR) или оба байта PRESCALE по отдельности.
irq_o: уровень/событие для GIC; активность по ИТОГОВОМУ смыслу задаётся ниже через IEN и флаги ISR.
Пады I²C: наружу вынесены именно разобщённые «вход с пина», «выход данных» и «разрешение выхода» - так проще стыковать с IOBUF в top-level, чем тащить inout через Block Design.
После переходим к псевдонимам тактовых сигналов и сброса, объявляем “ноль” на выходе данных линии, и объявляем синхронизаторы SCL/SDA.
// --------------------------------------------------------------- // Local wires / aliases // --------------------------------------------------------------- wire clk = s_axi_aclk; wire rst_n = s_axi_aresetn; // Constant low output (open-drain drives 0 when enabled) assign scl_pad_o = 1'b0; assign sda_pad_o = 1'b0; // --------------------------------------------------------------- // 2-stage synchronisers for SDA and SCL inputs // --------------------------------------------------------------- reg [1:0] scl_sync_r; reg [1:0] sda_sync_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_sync_r <= 2'b11; sda_sync_r <= 2'b11; end else begin scl_sync_r <= {scl_sync_r[0], scl_pad_i}; sda_sync_r <= {sda_sync_r[0], sda_pad_i}; end end wire scl_sync = scl_sync_r[1]; wire sda_sync = sda_sync_r[1];
clk/rst_n - сокращения, чтобы ниже не затирать текст префиксом s_axi_. scl_pad_o/sda_pad_o всегда “ноль”: на реальной линии I²C “единица” по уровню получается не активным драйвером high, а высоким импедансом и внешним подтягивающим резистором; мы кодируем это как “тянуть вниз через буфер ИЛИ отпустить”. Поэтому когда padoen=0, выход тянет GND; когда padoen=1, выход отключён и на линии - логическая “1” из-за подтягивающего резистора.
Синхронизаторы. scl_pad_i и sda_pad_i приходят с «внешнего мира» и не обязаны меняться ровно на фронте clk; прямой ввод в большой конечный автомат без синхронизации рискует метастабильностью. Два регистра подряд - типовой компромисс: первый может поймать неопределённость, второй уже выдаёт устойчивый бит в домене clk. Сброс в 11 соответствует отпущенным линиям (idle на шине). scl_sync/sda_sync - это уже “чистый” вход для i2c_master_core.
Далее объявляем адреса регистров и регистровую память.
// --------------------------------------------------------------- // Register addresses // --------------------------------------------------------------- localparam [C_S_AXI_ADDR_WIDTH-1:0] ADDR_CTRL = 5'h00, ADDR_STATUS = 5'h04, ADDR_CMD = 5'h08, ADDR_TX_DATA = 5'h0C, ADDR_RX_DATA = 5'h10, ADDR_PRESCALE = 5'h14, ADDR_ISR = 5'h18; // --------------------------------------------------------------- // Software-writable registers // --------------------------------------------------------------- reg ctrl_en_r; // CTRL[0] reg ctrl_ien_r; // CTRL[1] reg [15:0] prescale_r; // PRESCALE reg [7:0] tx_data_r; // TX_DATA // Command register (latched on write, consumed by sequencer) reg cmd_sta_r, cmd_sto_r, cmd_rd_r, cmd_wr_r, cmd_nack_r; reg cmd_write_strobe; // Interrupt status (W1C) reg isr_done_r; // ISR[0] reg isr_al_r; // ISR[1]
localparam адресов совпадает с таблицей вверху файла: они сравниваются с aw_addr_r и с s_axi_araddr (при чтении). Важный момент: в AXI адрес байтовый, а в карте шаг 0x04 между соседними 32-битными словами и это нормальная арифметика Cortex-A9 при доступе к периферии.
Регистры ctrl_*, prescale_r, tx_data_r - то, что сохраняется между транзакциями. Поля cmd_*_r - не “живая шина CMD”, а защёлка последней записи в слово CMD из AXI; настоящую команду в нужный такт запускает строб cmd_write_strobe (он же подаётся в секвенсор). Так разделены хранение битов и событие “выполни команду”. isr_done_r/isr_al_r - липкие флаги устанавливающиеся до момента ручного программного сброса (W1C) или до обработки по правилам ниже.
Теперь объявим wire от ядра и маскирование padoen при выключенном EN.
// --------------------------------------------------------------- // Core outputs // --------------------------------------------------------------- wire [7:0] core_dout; wire core_rx_ack; wire core_ready; wire core_arb_lost; wire core_busy; wire core_scl_oen; wire core_sda_oen; assign scl_padoen_o = ctrl_en_r ? core_scl_oen : 1'b1; assign sda_padoen_o = ctrl_en_r ? core_sda_oen : 1'b1;
Здесь объявлены все наблюдаемые сигналы ядра, кроме тех что идут только внутрь (команда и т.д., см. инстанс). core_rx_ack попадёт в STATUS как RXACK; ready - busy/ready handshake атомарных команд; arb_lost/busy - арбитраж и занятость шины; oen - как ядро хочет отпускать или тянуть линии.
Маска на наружный padoen: пока ctrl_en_r=0, к пинам подмешивается 1 (“всегда Hi-Z на выходе драйвера”). Это защищает электрически соседей на шине в момент после сброса или до инициализации: I²C не “дергается” ядром, пока софт явно не выставил EN.
После добавляем блок с прескейлером (или прескалером, кому как удобно).
// --------------------------------------------------------------- // Prescaler — generate clock enable for core // --------------------------------------------------------------- reg [15:0] prescale_cnt_r; reg core_ena_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin prescale_cnt_r <= 16'd0; core_ena_r <= 1'b0; end else if (!ctrl_en_r) begin prescale_cnt_r <= prescale_r; core_ena_r <= 1'b0; end else if (prescale_cnt_r == 16'd0) begin prescale_cnt_r <= prescale_r; core_ena_r <= 1'b1; end else begin prescale_cnt_r <= prescale_cnt_r - 16'd1; core_ena_r <= 1'b0; end end
Ядро не тактируется отдельной “медленной” частотой: вся логика i2c_master_core синхронна clk, а “медленность” I²C достигается тем, что FSM ядра продвигается только когда ena_i=1. Этот блок генерирует core_ena_r: из циклов системного clk вырезается один такт раз в prescale_r+1 отсчётов счётчика перед обнулением.
Сброс / EN=0: счётчик не крутится для реального деления;
core_ena_r=0, чтобы ядро стояло.Работа:
prescale_cnt_rсчитает вниз; когда дошёл до нуля - на один такт выставляетсяcore_ena_r, счётчик перезагружается изprescale_r. Доля тактов сena=1определяет скорость, с которой ядро проходит четверть периода SCL, откуда и формула в заголовке файла.
Теперь перейдем к секвенсору, константам, регистрам FSM, сигналу core_arb_lost_clear, задержке core_ready.
localparam [2:0] CMD_C_NOP = 3'd0, CMD_C_START = 3'd1, CMD_C_WRITE = 3'd2, CMD_C_READ = 3'd3, CMD_C_STOP = 3'd4, CMD_C_RESTART = 3'd5; localparam [2:0] SEQ_IDLE = 3'd0, SEQ_START = 3'd1, SEQ_WRITE = 3'd2, SEQ_READ = 3'd3, SEQ_STOP = 3'd4; reg [2:0] seq_state_r; reg seq_sto_r, seq_wr_r, seq_rd_r, seq_nack_r; reg core_cmd_valid_r; reg [2:0] core_cmd_r; reg [7:0] core_din_r; reg tip_r; reg sub_cmd_sent_r; // 0=waiting acceptance, 1=waiting completion wire core_arb_lost_clear = cmd_write_strobe; // Track previous core_ready for edge detection (used for interrupt) reg core_ready_d_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) core_ready_d_r <= 1'b1; else core_ready_d_r <= core_ready; end
CMD_C_* - числовые коды команд, которые понимает i2c_master_core (см. его cmd_i): одна атомарная операция за раз. SEQ_* - состояния обёртки, которые превращают одну запись в регистр CMD (возможно с несколькими битами STA/WR/RD/STO) в цепочку таких атомарных шагов.
core_cmd_valid_r: “есть валидная команда для ядра”; протокол ядра обычно таков: поднял valid, ждём готовности приёма.core_cmd_r / core_din_r: что именно выполнить и с каким байтом/битом NACK для READ.tip_r: transfer in progress - держится высоким, пока секвенсер “крутит” составную операцию; используется в STATUS[3] и в логике IRQ (спад TIP = завершение).sub_cmd_sent_r: внутренняя фаза handshake с core_ready: сначала ждём момент, когда ядро приняло команду (ready падает), затем - когда ядро завершило атомарный шаг (ready снова 1). Именно поэтому в SEQ_START/SEQ_WRITE/… два уровня вложенности if.
core_arb_lost_clear = cmd_write_strobe: при новой записи в CMD мы одновременно просим ядро сбросить sticky-флаг потери арбитража (см. в предыдущих статьях) - это согласуется с тем, что софт «начал новую попытку».
core_ready_d_r: задержка ready на такт - в данной реализации используется в общей логике отслеживания (комментарий в RTL); основная детекция IRQ опирается на tip_r и core_arb_lost ниже.
Переходим к основному блоку always (FSM).
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin seq_state_r <= SEQ_IDLE; core_cmd_valid_r <= 1'b0; core_cmd_r <= CMD_C_NOP; core_din_r <= 8'd0; tip_r <= 1'b0; sub_cmd_sent_r <= 1'b0; seq_sto_r <= 1'b0; seq_wr_r <= 1'b0; seq_rd_r <= 1'b0; seq_nack_r <= 1'b0; end else if (!ctrl_en_r) begin seq_state_r <= SEQ_IDLE; core_cmd_valid_r <= 1'b0; tip_r <= 1'b0; sub_cmd_sent_r <= 1'b0; end else if (core_arb_lost) begin seq_state_r <= SEQ_IDLE; core_cmd_valid_r <= 1'b0; tip_r <= 1'b0; sub_cmd_sent_r <= 1'b0; end else begin case (seq_state_r) SEQ_IDLE: begin core_cmd_valid_r <= 1'b0; sub_cmd_sent_r <= 1'b0; if (cmd_write_strobe) begin tip_r <= 1'b1; seq_sto_r <= cmd_sto_r; seq_wr_r <= cmd_wr_r; seq_rd_r <= cmd_rd_r; seq_nack_r <= cmd_nack_r; if (cmd_sta_r) begin core_cmd_r <= core_busy ? CMD_C_RESTART : CMD_C_START; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_START; end else if (cmd_wr_r) begin core_cmd_r <= CMD_C_WRITE; core_din_r <= tx_data_r; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_WRITE; end else if (cmd_rd_r) begin core_cmd_r <= CMD_C_READ; core_din_r <= {7'd0, cmd_nack_r}; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_READ; end else if (cmd_sto_r) begin core_cmd_r <= CMD_C_STOP; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_STOP; end else begin tip_r <= 1'b0; end end end SEQ_START: begin if (!sub_cmd_sent_r) begin if (!core_ready) begin core_cmd_valid_r <= 1'b0; sub_cmd_sent_r <= 1'b1; end end else begin if (core_ready) begin sub_cmd_sent_r <= 1'b0; if (seq_wr_r) begin core_cmd_r <= CMD_C_WRITE; core_din_r <= tx_data_r; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_WRITE; end else if (seq_rd_r) begin core_cmd_r <= CMD_C_READ; core_din_r <= {7'd0, seq_nack_r}; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_READ; end else begin tip_r <= 1'b0; seq_state_r <= SEQ_IDLE; end end end end SEQ_WRITE: begin if (!sub_cmd_sent_r) begin if (!core_ready) begin core_cmd_valid_r <= 1'b0; sub_cmd_sent_r <= 1'b1; end end else begin if (core_ready) begin sub_cmd_sent_r <= 1'b0; if (seq_sto_r) begin core_cmd_r <= CMD_C_STOP; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_STOP; end else begin tip_r <= 1'b0; seq_state_r <= SEQ_IDLE; end end end end SEQ_READ: begin if (!sub_cmd_sent_r) begin if (!core_ready) begin core_cmd_valid_r <= 1'b0; sub_cmd_sent_r <= 1'b1; end end else begin if (core_ready) begin sub_cmd_sent_r <= 1'b0; if (seq_sto_r) begin core_cmd_r <= CMD_C_STOP; core_cmd_valid_r <= 1'b1; seq_state_r <= SEQ_STOP; end else begin tip_r <= 1'b0; seq_state_r <= SEQ_IDLE; end end end end SEQ_STOP: begin if (!sub_cmd_sent_r) begin if (!core_ready) begin core_cmd_valid_r <= 1'b0; sub_cmd_sent_r <= 1'b1; end end else begin if (core_ready) begin sub_cmd_sent_r <= 1'b0; tip_r <= 1'b0; seq_state_r <= SEQ_IDLE; end end end default: seq_state_r <= SEQ_IDLE; endcase end end
Сброс и «выключено»: при аппаратном сбросе или EN=0 автомат возвращается в IDLE, команды к ядру гасятся, TIP и internal state сбрасываются - иначе после i2cinit софт мог бы увидеть “зависший” TIP. То же при core_arb_lost: потеря арбитража - аварийная для текущей транзакции ситуация; FSM прекращает выдачу и ждёт новой команды от софта.
SEQ_IDLE + cmd_write_strobe: строб приходит из AXI-записи в ADDR_CMD (см. ниже). В этот момент в seq_* копируются задержанные ранее поля cmd_*_r (они обновились в том же такте записью в CMD) - дальше по ним строится сценарий. Поднимаем tip_r. Если запрошен STA и шина уже busy, подаётся RESTART, иначе START; если STA нет, можно сразу дать WRITE/READ/STOP в зависимости от битов. Пустая команда (все нули) - TIP сразу гасится (нечего делать).
SEQ_START: после START/RESTART автомат часто переходит к первому байту (WR/RD) - см. ветки seq_wr_r/seq_rd_r. Handshake через sub_cmd_sent_r повторяется в WRITE/READ/STOP: универсальная схема “команда принята → команда закончилась”.
SEQ_WRITE / SEQ_READ: по завершении байта, если в составе команды был STO, запускается STOP и состояние SEQ_STOP; иначе TIP опускается и возврат в IDLE (передача одного байта без сессии).
SEQ_STOP: после STOP снимаем TIP и в IDLE - шина в состоянии покоя с точки зрения нашего мастера.
Теперь что касается прерывания и irq_o.
// --------------------------------------------------------------- // Interrupt logic // --------------------------------------------------------------- // DONE fires on tip falling edge, AL fires on core_arb_lost rising edge reg tip_d_r; reg core_al_d_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tip_d_r <= 1'b0; core_al_d_r <= 1'b0; end else begin tip_d_r <= tip_r; core_al_d_r <= core_arb_lost; end end wire tip_fall = tip_d_r & ~tip_r; wire al_rise = core_arb_lost & ~core_al_d_r; assign irq_o = ctrl_ien_r & (isr_done_r | isr_al_r);
tip_d_r и core_al_d_r - классические задержки на один такт, чтобы в чисто комбинационной форме получить фронт/спад:
tip_fall= было_1 и стало_0 — «передача по составной команде закончилась»;al_rise— появилась потеря арбитража (ровно на этом такте).
assign irq_o: линия не дублирует “сырой” факт события - она активна только при ctrl_ien_r=1 (разрешение прерывания из CTRL). Сами флаги isr_done_r/isr_al_r поднимаются в блоке записи AXI при tip_fall/al_rise, поэтому даже при IEN=0 событие можно отследить опросом ISR позже (обработчик включил IEN и прочитал “что случилось”).
Теперь про AXI буферы для канала записи.
reg aw_done_r, w_done_r; reg [C_S_AXI_ADDR_WIDTH-1:0] aw_addr_r; reg [C_S_AXI_DATA_WIDTH-1:0] w_data_r; reg [C_S_AXI_DATA_WIDTH/8-1:0] w_strb_r;
Интерфейс записи AXI4-Lite разделяет адрес и данные: для одной транзакции нужны обе части. Регистры aw_done_r и w_done_r запоминают факт принятия соответственно AW и W; пока оба не «1», нельзя формировать ответ B и модифицировать регистры периферии по записи.
AXI канал записи выглядит следующим образом:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin s_axi_awready <= 1'b0; s_axi_wready <= 1'b0; s_axi_bvalid <= 1'b0; s_axi_bresp <= 2'b00; aw_done_r <= 1'b0; w_done_r <= 1'b0; aw_addr_r <= {C_S_AXI_ADDR_WIDTH{1'b0}}; w_data_r <= {C_S_AXI_DATA_WIDTH{1'b0}}; w_strb_r <= {(C_S_AXI_DATA_WIDTH/8){1'b0}}; ctrl_en_r <= 1'b0; ctrl_ien_r <= 1'b0; prescale_r <= DEFAULT_PRESCALE; tx_data_r <= 8'd0; cmd_sta_r <= 1'b0; cmd_sto_r <= 1'b0; cmd_rd_r <= 1'b0; cmd_wr_r <= 1'b0; cmd_nack_r <= 1'b0; cmd_write_strobe <= 1'b0; isr_done_r <= 1'b0; isr_al_r <= 1'b0; end else begin // Defaults s_axi_awready <= 1'b0; s_axi_wready <= 1'b0; cmd_write_strobe <= 1'b0; // ISR set events if (tip_fall) isr_done_r <= 1'b1; if (al_rise) isr_al_r <= 1'b1; // Accept write address if (s_axi_awvalid && !aw_done_r) begin s_axi_awready <= 1'b1; aw_addr_r <= s_axi_awaddr; aw_done_r <= 1'b1; end // Accept write data if (s_axi_wvalid && !w_done_r) begin s_axi_wready <= 1'b1; w_data_r <= s_axi_wdata; w_strb_r <= s_axi_wstrb; w_done_r <= 1'b1; end // Both address and data received — perform write if (aw_done_r && w_done_r && !s_axi_bvalid) begin s_axi_bvalid <= 1'b1; s_axi_bresp <= 2'b00; // OKAY aw_done_r <= 1'b0; w_done_r <= 1'b0; case (aw_addr_r) ADDR_CTRL: begin if (w_strb_r[0]) begin ctrl_en_r <= w_data_r[0]; ctrl_ien_r <= w_data_r[1]; end end ADDR_CMD: begin if (w_strb_r[0]) begin cmd_sta_r <= w_data_r[0]; cmd_sto_r <= w_data_r[1]; cmd_rd_r <= w_data_r[2]; cmd_wr_r <= w_data_r[3]; cmd_nack_r <= w_data_r[4]; cmd_write_strobe <= 1'b1; end end ADDR_TX_DATA: begin if (w_strb_r[0]) tx_data_r <= w_data_r[7:0]; end ADDR_PRESCALE: begin if (w_strb_r[0]) prescale_r[7:0] <= w_data_r[7:0]; if (w_strb_r[1]) prescale_r[15:8] <= w_data_r[15:8]; end ADDR_ISR: begin if (w_strb_r[0]) begin if (w_data_r[0]) isr_done_r <= 1'b0; if (w_data_r[1]) isr_al_r <= 1'b0; end end default: ; endcase end // Write response handshake if (s_axi_bvalid && s_axi_bready) s_axi_bvalid <= 1'b0; end end
Этап сброса: все выходы AXI и внутренние флаги переведены в известное состояние; PRESCALE берётся из DEFAULT_PRESCALE; периферия выключена (ctrl_en_r=0), команды и ISR - нули.
Каждый такт по умолчанию снимаются awready/wready и cmd_write_strobe. Так реализуется короткий импульс ready (один такт), что упрощает протокол относительно варианта “держать ready до снятия valid”.
ISR set events: если за этот такт был спад TIP или фронт AL, соответствующий бит ISR устанавливается в 1 (“липкий” до W1C или до маскировки внешней логикой).
Приём AW и W: классическая схема “если valid пришёл и ещё не защёлкнут соответствующий кусок”. Выполнение записи происходит только когда оба защёлкнуты и ещё не выставлен bvalid - тогда поднимается bvalid и одновременно в case(aw_addr_r) обновляются регистры.
CTRL: учитывается
wstrb[0]- только младший байт 32-битного слова (остальное игнорируется, что нормально для bare-metal).CMD: побайтово те же биты, что ожидает секвенсер;
cmd_write_strobeвыставляется здесь же - это единая точка, где “команда поехала”.TX_DATA: 8 бит в младшем байте слова.
PRESCALE: два байта независимо - удобно править только MSB или только LSB тулчейном.
ISR: запись 1 в бит
iвыполняет W1C для этого бита (сброс флага); запись 0 не трогает - стандартный приём статусных регистров.
Завершение транзакции: при bready снимается bvalid.
Теперь про AXI канал чтения. Выглядит он так:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin s_axi_arready <= 1'b0; s_axi_rvalid <= 1'b0; s_axi_rdata <= {C_S_AXI_DATA_WIDTH{1'b0}}; s_axi_rresp <= 2'b00; end else begin s_axi_arready <= 1'b0; if (s_axi_arvalid && !s_axi_rvalid) begin s_axi_arready <= 1'b1; s_axi_rvalid <= 1'b1; s_axi_rresp <= 2'b00; s_axi_rdata <= {C_S_AXI_DATA_WIDTH{1'b0}}; case (s_axi_araddr) ADDR_CTRL: s_axi_rdata[1:0] <= {ctrl_ien_r, ctrl_en_r}; ADDR_STATUS: s_axi_rdata[3:0] <= {core_arb_lost, core_busy, core_rx_ack, tip_r}; ADDR_CMD: s_axi_rdata <= {C_S_AXI_DATA_WIDTH{1'b0}}; ADDR_TX_DATA: s_axi_rdata[7:0] <= tx_data_r; ADDR_RX_DATA: s_axi_rdata[7:0] <= core_dout; ADDR_PRESCALE: s_axi_rdata[15:0] <= prescale_r; ADDR_ISR: s_axi_rdata[1:0] <= {isr_al_r, isr_done_r}; default: s_axi_rdata <= {C_S_AXI_DATA_WIDTH{1'b0}}; endcase end if (s_axi_rvalid && s_axi_rready) s_axi_rvalid <= 1'b0; end end
Реализация минималистична и AXI-Lite-типична: при валидном arvalid если ответ ещё не отдан (!rvalid) - в том же такте поднимаются arready и rvalid и заполняется rdata. Поэтому это не отдельная фаза ожидания для read channel, как в полном AXI4, - этого достаточно для регистровой периферии на GP-порту Zynq.
Сборка STATUS: {AL, BUSY, RXACK, TIP} - старшие три бита идут с ядра (core_arb_lost, core_busy, core_rx_ack), младший TIP - с секвенсера (tip_r). Так софт видит и низкоуровневую картину шины, и то, идёт ли ещё составная команда.
CMD при чтении нулится - регистр по смыслу записи-only; фактические биты команды всё равно лежат в cmd_*_r как тень последней записи (их не обязательно читать, обычно смотрят STATUS/TIP).
Снятие rvalid: по rready от мастера.
Ну и финалочка - подключение i2c_master_core и закрывающая конструкция endmodule.
i2c_master_core u_core ( .clk_i (clk), .rstn_i (rst_n), .ena_i (core_ena_r), .cmd_valid_i (core_cmd_valid_r), .cmd_i (core_cmd_r), .din_i (core_din_r), .dout_o (core_dout), .rx_ack_o (core_rx_ack), .ready_o (core_ready), .arb_lost_o (core_arb_lost), .arb_lost_clear_i (core_arb_lost_clear), .busy_o (core_busy), .scl_i (scl_sync), .scl_oen_o (core_scl_oen), .sda_i (sda_sync), .sda_oen_o (core_sda_oen) ); endmodule
Тактирование и сброс совпадают с обёрткой - единый домен с AXI. ena_i - разрешение микрошага от прескалера: без этого ядро не двигает состояние I²C, даже если команда «висит» на входе.
Интерфейс команд: cmd_valid_i + cmd_i + din_i стыкуются с секвенсером; ready_o закрывает внутренний протокол “команда принята/исполнена”.
Данные и статус: dout_o/rx_ack_o уходят в RX_DATA и STATUS при чтении AXI.
Арбитраж: arb_lost_o + arb_lost_clear_i, который мы завели от того же строба, что и новая CMD - новая попытка обмена сбрасывает «залипание».
Линии шины: к ядру идут уже синхронизированные scl_sync/sda_sync; выходы oen ядра проходят через маску EN на уровне присваиваний padoen выше.
endmodule закрывает обёртку - дальше в проекте Vivado этот модуль только подключают к PS и IOBUF.
После сохранения данных модулей будет простроена архитектура проекта:

При синтезе оба файла попадут в Design Sources и на этапе составления Block Design Vivado прочитает порты из уже проиндексированного i2c_master_axi.v и нарисует блок с интерфейсом AXI и отдельными пинами I²C/IRQ.
Проверка i2c_master_axi в симуляции
После добавления RTL в Vivado и разбора логики следующий обязательный шаг инженерного цикла - поведенческая проверка до добавления Block Design, синтеза и прочего. Цель проста: убедиться, что обёртка корректно общается по AXI4-Lite, секвенсор правильно разворачивает CMD, ядро формирует валидные START/STOP и биты данных на линиях SDA/SCL, а статусные биты (TIP, RXACK, ISR) согласованы с тем, что увидит ПО на Zynq.
В репозитории для этого уже есть self-checking тестбенч: он эмулирует процессор, модель слейва на шине и выдаёт в консоль PASS/FAIL по каждому сценарию.
Что ловит симуляция |
Почему это важно до интеграции |
Ошибки рукопожатия AXI (awready/wready/bvalid, arready/rvalid) |
На GP-порту PS ошибка в slave часто проявляется как “зависший” доступ или порча соседних регистров - отладка на железе долгая. |
Неверное декодирование адресов регистров |
Софт (bare-metal, Linux) опирается на смещения из описания; сдвиг на 4 байта даст “тихий” баг. |
Секвенсор CMD (STA+WR+STO, repeated START при чтении) |
Одна запись в CMD должна породить несколько атомарных шагов ядра - это главная логика i2c_master_axi.v. |
Форма SCL/SDA, ACK/NACK, open-drain |
Осциллограф на плате подключается значительно позже; в симуляции видно каждый бит и фазу FSM. |
TIP, RXACK, ISR, irq_o |
Проверяется связка “закончилась транзакция → можно читать RX / сбросить IRQ”. |
Симуляция не заменяет синтез и тайминг-анализ в Vivado, но отсекает функциональные ошибки RTL за минуты на ПК.
Состав тестового окружения будет выглядеть так:

Компонент |
Файл |
Роль |
DUT (верх) |
|
Обёртка с inout |
Обёртка (цель проверки) |
|
Регистры, AXI, секвенсер, IRQ, синхронизаторы. |
Ядро |
|
Низкоуровневый I²C; можно в waveform смотреть |
AXI master BFM |
|
Задачи |
Модель слейва |
|
Поведенческий EEPROM-like slave: 256 байт RAM, адрес 0x50, ACK/NACK, byte write/read. |
Тестбенч |
|
Часы, сброс, pull-up, сценарии, счётчики test_pass/test_fail, watchdog. |
В репозитории в файле tb/i2c_master_tb.sv заданы параметры, намеренно отличающиеся от боевых 100 кГц на плате - иначе одна транзакция заняла бы слишком много тактов симуляции:
Параметр |
Значение в TB |
Смысл |
|
10 нс |
100 МГц на |
|
4 |
Укороченный делитель: SCL в симуляции намного быстрее, чем 100 кГц, но та же формула |
|
|
Должен совпадать с I2C_ADDR в slave-модели. |
Watchdog |
50 мс сим-времени |
Если TIP не сбрасывается — $fatal (защита от вечного цикла). |
Подтяжки на шине обязательны:
pullup (sda); pullup (scl);
Без них open-drain линии “висели” бы в Z-состоянии и модель слейва/мастера работала бы неверно.
Основная проверка через Icarus Verilog
Нам потребуется Icarus Verilog ≥ 12.0 (iverilog, vvp), а для статической проверки - Verilator ≥ 5.0. Из корня репозитория:
cd /path/to/I2C_Master_Controller make sim-axi # Lint RTL (без симуляции таймингов) make lint-axi
Цель sim-axi компилирует:
rtl/i2c_master_core.vrtl/i2c_master_axi.vrtl/i2c_master_top.vtb/i2c_slave_model.sv, tb/axi_lite_master_bfm.sv, tb/i2c_master_tb.sv
и запускает vvp с дампом VCD в каталог sim/ (sim/i2c_master_tb.vcd).
Успешный прогон заканчивается примерно так:
============================================ TEST SUMMARY: PASS=10 FAIL=0 ============================================ All tests PASSED
Любой сбой печатает $error(...) с контекстом; при test_fail > 0 - $fatal(1, "Some tests FAILED").
Ручной запуск, если нет make,можно провести так:
mkdir -p sim iverilog -g2012 -Wall -o sim/i2c_master_tb.vvp \ rtl/i2c_master_core.v \ rtl/i2c_master_axi.v \ rtl/i2c_master_top.v \ tb/i2c_slave_model.sv \ tb/axi_lite_master_bfm.sv \ tb/i2c_master_tb.sv cd sim && vvp i2c_master_tb.vvp -vcd
Флаг -g2012 нужен из‑за SystemVerilog-конструкций в testbench (tasks, disable и т.д.).
Так же можно осуществить запуск в Questa / ModelSim. В каталоге sim/questa/ лежат скрипты compile.do, run_batch.do, run_gui.do и готовая раскладка сигналов wave.do (группы System, I2C Bus, AXI Write/Read, Core FSM, Sequencer, Slave).
make questa-gui # компиляция + GUI + wave.do # или make questa # batch без GUI
Пути в wave.do заточены под иерархию i2c_master_tb/dut/u_axi/... - удобно для отладки секвенсера и ядра после правок в i2c_master_axi.v.
Опишу сценарии TEST 0 - 7 и что именно проверяется. Тестбенч реализует восемь независимых проверок. Ниже - цель, шаги и какие сигналы имеет смысл смотреть при ручной отладке.
TEST 0 - read-back PRESCALE после сброса
Цель: AXI чтение и сбросовое значение DEFAULT_PRESCALE (в TB = 4).
Шаги: axi_read(REG_PRESCALE) → сравнение с PRESCALE_VAL.
Waveform: канал AR/R; внутри DUT - prescale_r.
Типичная ошибка: неверный case в read channel или сброс не обнуляет/не инициализирует prescale_r.
TEST 1 - запись одного байта и чтение (0xA5 @ mem 0x10)
Цель: сквозной путь AXI → CMD → I²C → slave → RX_DATA.
Шаги: CTRL = 0x03 (EN+IEN); задача i2c_write_byte(0x50, 0x10, 0xA5); затем i2c_read_byte с проверкой данных.
На шине: START → адрес 0xA0 (write) → байт указателя 0x10 → STOP; затем repeated START → 0xA1 (read) → один байт с NACK+STOP.
Waveform: группа I2C Bus; Sequencer seq_state_r; Core FSM; после чтения - RX_DATA по AXI.
Типичные ошибки: секвенсор не выдаёт STOP; TIP не падает; неверный порядок STA/WR при чтении.
TEST 2 - несколько байт подряд (0xDE @ 0x20, 0xAD @ 0x21)
Цель: несколько отдельных транзакций записи без “залипания” BUSY/TIP.
Критерий: оба адреса в памяти slave читаются обратно верно.
TEST 3 - NACK на неверный адрес слейва (0x3F)
Цель: реакция на отсутствующий slave: STATUS.RXACK = 1 (NACK).
Шаги: START+WR с адресом 0x3F; проверка rd_data[1] после REG_STATUS; затем CMD = STO для освобождения шины.
Waveform: на 9-м бите адресного байта slave не тянет SDA вниз → мастер видит NACK.
Типичная ошибка: RXACK не обновляется или не сбрасывается между транзакциями.
TEST 4 - флаги ISR (W1C) и DONE после записи
Цель: логика ISR, связь с завершением транзакции, линия irq_o (косвенно - через флаги при IEN=1).
Шаги: запись ISR = 0x03 (сброс обоих бит); проверка нулей; i2c_write_byte; чтение ISR[0] (DONE_IRQ).
Waveform: tip_r, спад TIP → установка isr_done_r; запись W1C в ISR.
TEST 5 - back-to-back write + read без длинной паузы
Цель: секвенсер и ядро корректно завершают одну транзакцию и сразу начинают следующую (0x55 @ 0x40).
TEST 6 - сброс s_axi_aresetn и восстановление
Цель: после аппаратного сброса контроллер снова принимает команды (повторная инициализация CTRL, запись/чтение 0xBB @ 0x50).
Waveform: rst_n в 0 на 10 тактов; регистры и FSM должны уйти в известное состояние.
TEST 7 - смена PRESCALE «на лету» (с EN=0)
Цель: правило из §1.4 — PRESCALE меняют при EN=0; после нового делителя обмен всё ещё работает (запись/чтение 0xCC @ 0x60).
Шаги: CTRL=0 → PRESCALE=2 → CTRL=0x03 → I²C транзакция.
Waveform: период импульсов core_ena_r / тактов SCL должен сократиться относительно TEST 1.
Вспомогательная задача wait_tip_clear (используется во всех I²C-сценариях): в цикле читает STATUS и ждёт TIP=0 с таймаутом 5000 итераций - это тот же паттерн, что в bare-metal (“опрос статуса”).
Теперь расскажу как читать осциллограмму для проверки “правильности сигналов”. После make sim-axi откройте дамп:
gtkwave sim/i2c_master_tb.vcd
Рекомендуемый порядок анализа одной транзакции записи байта:
AXI Write - пара транзакций: сначала запись в TX_DATA (awaddr=0x0C), затем в CMD (awaddr=0x08). Убедитесь, что для каждой записи завершились AW+W и пришёл BVALID (bresp=OKAY).
AXI Regs / Sequencer - после записи CMD растёт tip_r, seq_state_r проходит START → WRITE → (STOP при WR+STO), core_cmd_valid_r выдает импульс на каждую атомарную команду ядра.
Core FSM - state_r переходит IDLE→START→DATA→...; ena_i (через прескалер) двигает phase_r 0…3 на каждый бит.
I2C Bus - на SCL видны такты; SDA меняется только при низком SCL (кроме START/STOP); между транзакциями обе линии высокие (pull-up).
Slave - state слейва следует за адресным и data-байтами; mem_ptr указывает на ячейку EEPROM.
Завершение - tip_r падает; при включённом IEN можно увидеть isr_done_r и DONE в ISR (TEST 4).
Если используете Questa, те же группы уже собраны в sim/questa/wave.do - достаточно make questa-gui и прокрутить к метке времени из $display в логе ([%0t] I2C WRITE: ...).
Минимальный чеклист, который говорит о том, что с сигналами все ОК:
Сигнал / группа |
Ожидание |
|
Стабильный меандр 100 МГц в TB. |
|
Активный низкий сброс в начале и в TEST 6. |
|
Принимают запись BFM без зависания. |
|
Чтение STATUS/RX_DATA возвращает данные за 1–2 такта (как в RTL). |
|
1 на время секвенсера, 0 в idle между командами. |
|
Нет “залипшего” нуля на SCL при EN=1; STOP отпускает шину. |
|
Может кратковременно вспыхивать при DONE (при IEN=1). |
Перечислю, что делать при падении теста:
Симптом в логе |
Куда смотреть в RTL / Waveform |
PRESCALE mismatch |
Сброс, read mux для REG_PRESCALE, параметр DEFAULT_PRESCALE. |
TIMEOUT: TIP did not clear |
Секвенсер застрял в seq_state_r; ядро не даёт ready_o; нет ena_i (prescale/EN). |
NACK on slave address (не в TEST 3) |
Рассинхрон адреса TB и I2C_ADDR slave; бит R/W в TX_DATA. |
read != expected |
Цепочка READ+RESTART в i2c_read_byte; RX_DATA mux; slave mem. |
DONE interrupt not set |
Логика спада TIP → isr_done_r; маска IEN. |
WATCHDOG: simulation timeout |
Бесконечный цикл в FSM или AXI handshake. |
После правок в i2c_master_axi.v достаточно снова выполнить make sim-axi; Vivado-проект пересобирать не обязательно, пока вы не меняли только testbench.
Тут стоило бы написать отдельный материал о тестах и разборе waveform данного модуля, но оно обычно не очень интересно для читателей и я решил оставить это для самостоятельного изучения предоставив исходники testbench.
Настройка ZYNQ7 Processing System (PS)
Так, первые необходимые модули мы добавили. Теперь перейдем к настройке PS-части для платы Zynq Mini Rev.B, которую мы будем использовать для обкатки проекта. Для этого создаем Block Design:

И назовём его system:

Далее по шагам:
На холсте нажмите + (или Ctrl+I) - откроется окошко Add IP.
В строке поиска наберите Zynq7 Processing System.
Появится ZYNQ7 Processing System.
Двойной клик по нему - Vivado поставит блок на холст.
На холсте появится большой прямоугольник processing_system7_0 с кучей групп пинов (DDR, FIXED_IO, IRQ_F2P, M_AXI_GP0, FCLK_CLK0 …).
Переименуем блок для удобства правой кнопкой по блоку → Block Properties → Name: ps7 → Enter.
Сразу после добавления PS7 Vivado показывает зелёную полосу сверху:

Кликните по ссылке Run Block Automation.
Откроется диалог. Слева - список «правил автоматизации», нам нужен только один - processing_system7_0. Справа - параметры:
Make External: FIXED_IO, DDR ✅ (по умолчанию)
Apply Board Preset: ❌ (выберите пустоту - нашей платы в каталоге нет)
Cross Trigger In/Out: Disable
OK.
После этого:
Vivado создаст внешние интерфейсы FIXED_IO и DDR прямо на холсте - это будут “толстые” зелёные/жёлтые интерфейс-стрелки до края диаграммы. На синтезе их подключат к реальным выводам корпуса микросхемы.
Внутри PS7 Vivado настроит дефолт по группам, но для нашей платы дефолт не подходит - настройку сделаем дальше вручную.
Детально настраиваем PS7 под ZYNQ MINI Rev B. Двойной клик по блоку ps7. Откроется окно Re-customize IP с длинной вертикальной панелью разделов слева. Нам нужно настроить семь групп параметров: bank voltages, тактирование (PLL/clocks), DDR, MIO-пины периферии, FCLK_CLK0, AXI GP0, прерывания. Пробежимся быстро по настройкам.
Раздел Peripheral I/O Pins → MIO Configuration в верхней части ставит:
Bank 0 (MIO 0..15) Voltage: LVCMOS 3.3V
Bank 1 (MIO 16..53) Voltage: LVCMOS 1.8V

Далее устанавливаем входную частоту кварца для PS. Input Frequency (PS_CLK): 33.333333 МГц (это кварц X2 на плате).

В разделе Clock Configuration → Advanced Clocking будут видны вычисленные параметры PLL:
ARM PLL FBDIV: 40 → CPU PLL = 1333.333 МГц → CPU = 666.666 МГц
IO PLL FBDIV: 48 → IO PLL = 1600 МГц
DDR PLL FBDIV: 32 → DDR PLL = 1066.667 МГц → DDR clk = 533.333 МГц
Это значения для DDR3-1066. Они появятся автоматически после настройки DDR (следующий пункт).

Раздел DDR Configuration - заводские параметры под чип U1 MT41J256M16RE-125.
Параметр |
Значение |
Откуда |
DDR Controller |
✅ Enabled |
базовое |
DDR Memory Part |
MT41J256M16 RE-125 |
надпись на чипе U1 |
DDR Bus Width |
16 Bit |
single chip, 16 dq lines |
DRAM Width |
16 Bits |
один чип = 16 bit data |
Device Capacity |
4096 MBits |
512 MByte = 4096 Mbit |
Speed Bin |
DDR3_1066F |
-125 = 1066 MT/s |
CL (CAS Latency) |
7 |
datasheet MT41J256M16 |
CWL |
6 |
datasheet |
t_RCD |
7 нс |
datasheet |
t_RP |
7 нс |
datasheet |
t_RC |
48.91 нс |
datasheet |
t_RAS_min |
35.0 нс |
datasheet |
t_FAW |
40.0 нс |
datasheet |
ECC |
Disabled |
плата без ECC-чипа |
Internal VREF |
❌ (0) |
внешний VREF на плате |
Bank Addr Count |
3 |
datasheet |
Row Addr Count |
15 |
datasheet |
Col Addr Count |
10 |
datasheet |
DDR Memory High Addr |
0x1FFFFFFF |
512 МБ → последний адрес 0x1FFF_FFFF |
Если оставить дефолтные значения - DDR не инициализируется, FSBL зависнет ДО открытия UART, и в терминале не будет ничего.
Раздел Peripheral I/O Pins — большая таблица 0...53 пинов. Для нашей платы выставляем:
Периферия |
MIO-пины |
Пояснение |
QSPI (Quad SPI flash W25Q128) |
single-SS: MIO 1..6 + feedback на MIO 8 |
в строке «Quad SPI Flash» выбрать I/O: MIO 1..6, Single Slave Select; Feedback clock: MIO 8. Это режим single-SS. |
SD0 (microSD) |
MIO 40..45 |
в строке «SD0»: I/O = MIO 40..45; БЕЗ CD/WP/Power-enable (у нас этих линий нет) |
SD1 (microSD) |
MIO 10..15 |
в строке «SD1»: I/O = MIO 10..15; БЕЗ CD/WP/Power-enable (у нас этих линий нет) |
ENET 0 (RTL8211E PHY) |
MIO 16..27 + MDIO MIO 52..53 |
I/O = MIO 16..27; MDIO = MIO 52..53; speed 1000 Mbps; reset OFF |
USB 0 (USB3320C ULPI PHY) |
MIO 28..39 + reset MIO 7 |
I/O = MIO 28..39; Reset = MIO 7 |
UART 1 (CH340E USB-UART) |
MIO 48 = TX, MIO 49 = RX |
I/O = MIO 48..49; Baud = 115200 |
GPIO MIO |
enabled |
для прочих общих сигналов; EMIO GPIO выключен |
После выставления все эти строки покрасятся в зелёный/синий цвет в зависимости от bank-а (0 или 1).
Далее FCLK_CLK0 - тактирование PL.
Раздел Clock Configuration → PL Fabric Clocks:
✅ FCLK_CLK0 - Enable;
Frequency: 50 MHz ← это даст наш i2c_master_axi системный тактовый сигнал;
FCLK_CLK1...3: Disable;
Почему 50 МГц? Для I²C 100 кГц прескалер тогда = 50_000_000/(4·100_000) - 1 = 124. Это аккуратное круглое значение. Если поднимем FCLK0 до 100 МГц - придётся ставить PRESCALE = 249.

AXI Non Secure Enablement → GP Master AXI Interface.
Раздел PS-PL Configuration → AXI Non Secure Enablement → GP Master AXI Interface:
✅ M_AXI_GP0 interface - Enable.
После этого справа на блоке ps7 появится интерфейс M_AXI_GP0 - это та самая шина, через которую CPU будет ходить в наши регистры I²C.

Прерывания PL→PS
Раздел Interrupts → Fabric Interrupts:
✅ Enable Fabric Interrupts
В подкатегории PL-PS Interrupt Ports: ✅ IRQ_F2P (количество источников оставляем 1 - нам нужна одна линия от i2c_master_axi/irq_o).
После этого на левой стороне блока ps7 появится вход IRQ_F2P[0:0].

OK - диалог закроется, на холсте применятся все изменения. Vivado автоматически пересчитает PLL/clocks. Если в этот момент Vivado показывает красные значки около ps7 - откройте ps7 ещё раз и проверьте, что DDR-параметры и MIO-пины не наложились друг на друга (например, кто-то поставил USB на MIO 16..27, а Ethernet на тех же).
Добавляем модуль i2c_master_axi в Block Design
Кликаем в меню навигатора на модуль i2c_master_axi и выбираем Add Module to Block design:

Переименуйте его: левый клик по модулю → Name в окне свойств → i2c → OK.
Vivado, прочитав файл i2c_master_axi.v, распознал в нём префикс s_axi_* и сам сгруппировал эти сигналы в интерфейс s_axi типа AXI4-Lite slave. Вы это увидите по характерному «толстому» жёлтому коннектору-стрелке.
Настроим параметр DEFAULT_PRESCALE. В i2c_master_axi имеет parameter DEFAULT_PRESCALE - это значение, которое попадает в регистр PRESCALE после reset (до того, как CPU его перепишет программно). Удобно, чтобы при первом же обращении SCL уже была сконфигурирована правильно.
Двойной клик по блоку i2c.
В диалоге Re-customize IP - увидите три параметра: C_S_AXI_DATA_WIDTH, C_S_AXI_ADDR_WIDTH, DEFAULT_PRESCALE.
Поставьте DEFAULT_PRESCALE = 31 (для 50 МГц → 400 кГц).
OK.
Далее выводим SDA/SCL и IRQ наружу. В Block Design нельзя напрямую вывести inout-порт (требуется IOBUF, а IOBUF мы поставим в RTL-обёртке снаружи BD). Поэтому мы выведем наружу отдельно вход и выход, и логику open-drain соберём вручную в Top Level модуле zynq_mini_oled_top.v.
В блоке i2c есть пины:
scl_pad_i ← вход (мы туда подадим O от IOBUF) scl_pad_o → выход (всегда 0, но всё равно подключим) scl_padoen_o → tristate enable (это T для IOBUF) sda_pad_i sda_pad_o sda_padoen_o irq_o → прерывание для CPU
Делаем каждому Make External:
Правый клик по пину scl_pad_i → Make External (или сочетание Ctrl+T).
На холсте появится внешний порт в виде вытянутого шестигранника, соединённый с пином. Имя по умолчанию scl_pad_i_0.
Повторите для scl_pad_o, scl_padoen_o, sda_pad_i, sda_pad_o, sda_padoen_o, irq_o.
Получится 7 внешних портов. Имена с суффиксом _0 неудобны. Переименуем:
Кликните по порту scl_pad_i_0 → в правой панели External Port Properties → поле Name → введите scl_i_ext → Enter.
-
По аналогии:
scl_pad_o_0 → scl_o_ext
scl_padoen_o_0 → scl_oen_ext
sda_pad_i_0 → sda_i_ext
sda_pad_o_0 → sda_o_ext
sda_padoen_o_0 → sda_oen_ext
irq_o_0 → i2c_irq_ext
Эти имена потом появятся как порты на сгенерированном BD-wrapper'е и попадут в наш top-RTL.
После производим Connection Automation и соединяем AXI. В блоке i2c интерфейс s_axi пока висит без подключения. Внутри ps7 есть M_AXI_GP0 (мастер). Между ними должна быть AXI Interconnect и Processor System Reset.

Vivado сделает это сам:
Сверху появится зелёная полоса «Run Connection Automation» (если её нет - нажмите Window → Designer Assistance или просто кликните по интерфейсу i2c/s_axi, и Vivado снова покажет полосу).
Run Connection Automation → откроется диалог. Слева - список «требующих соединения» интерфейсов; нас интересует i2c/s_axi.
-
Параметры (справа):
Master: /ps7/M_AXI_GP0
Slave: /i2c/s_axi (уже выбран)
Clock master/slave/xbar: Auto
intc IP: New AXI Interconnect
OK.
Vivado автоматически:
Создаст блок axi_smc - это AXI SmartConnect, который перенаправляет транзакции от мастера к одному или нескольким слейвам.
Создаст блок rst_ps7_50M - это Processor System Reset, который из FCLK_RESET0_N от ps7 делает синхронный с FCLK0 сброс peripheral_aresetn для slave-устройств.
Свяжет тактовые линии: ps7/FCLK_CLK0 → AXI SmartConnect.ACLK → i2c/s_axi_aclk.
Свяжет ресет: ps7/FCLK_RESET0_N → rst_ps7_50M.ext_reset_in; rst_ps7_50M.peripheral_aresetn → i2c/s_axi_aresetn.
Диаграмма заметно усложнится - но не пугайтесь, это нормально. Vivado сделал всё за вас.
Подключаем прерывание i2c → IRQ_F2P. i2c/irq_o сейчас выведен наружу как i2c_irq_ext (нам это понадобится для индикации на LED), но также его надо завести в PS:
На холсте найдите пин i2c/irq_o (на правой грани блока i2c).
Зажмите ЛКМ и протащите линию до входа ps7/IRQ_F2P[0:0]. Когда курсор окажется на этом пине - отпустите. Линия нарисуется.
Если IRQ_F2P — векторный ([0:0] или шире), а наш irq_o - однобитный, Vivado может попросить вставить xlconcat для согласования. Соглашайтесь - Vivado сам поставит конкатенатор. В нашей конфигурации (1 источник) этого не нужно: [0:0] это тот же 1 бит и прямое соединение работает.
Получится следующее:

Далее открываем Address Editor. Слейв i2c/s_axi пока не имеет адреса в адресном пространстве PS - нужно его «прописать на карту».
Сверху над холстом найдите вкладку Address Editor (если её нет - Window → Address Editor).
В дереве будет узел ps7 (1 address space) → Data. Под ним - список всех слейвов; среди них i2c/s_axi.
Кликните правой кнопкой по i2c/s_axi (или на верхнем уровне) → Assign Address. Vivado автоматически назначит свободный адрес из диапазона GP0.
-
Проверьте/исправьте поля:
Offset Address: 0x43C00000
Range: 4K
Колонка Slave Segment должна показать что-то вроде i2c/s_axi/reg0.
Почему 0x43C00000? Это “GP0 General-Purpose Slave 0” в Zynq-7000 - кусок памяти 64 МБ, который AXI Interconnect маршрутизирует на M_AXI_GP0. По адресам 0x43C00000...0x7FFFFFFF живёт пользовательская периферия PL.
Нажмите F6 (или Window → Validate Design). Vivado проверит:
Все ли тактовые линии корректно проложены.
Совпадают ли разрядности шин.
Нет ли неподключенных интерфейсов.
Если всё хорошо - внизу появится диалог:

Warnings (жёлтые треугольники) могут быть - например, про несинхронизированные клоки между ps7 и FCLK_CLK0. Их можно игнорировать.
Теперь сохраняем BD и создаём wrapper.
File → Save Block Design (или Ctrl+S) — сохраняет system.bd в проекте.
В Sources правый клик по system.bd (он лежит в Design Sources → system.bd) → Create HDL Wrapper…
В диалоге выберите ⚫ Let Vivado manage wrapper and auto-update → OK.
Vivado сгенерирует system_wrapper.v - это Verilog-обёртка вокруг BD, со всеми портами, которые мы вывели наружу (DDR + FIXED_IO + наши scl_*_ext / sda_*_ext / i2c_irq_ext).
До этого момента top-уровнем в проекте был i2c_master_axi. После создания wrapper'а Vivado автоматически сделает top-уровнем system_wrapper. Дальше мы сделаем ещё один уровень - zynq_mini_oled_top.v - и он станет окончательным top level.
Теперь Top-RTL с IOBUF - соединяем BD с физическими пинами. system_wrapper.v выводит scl_i_ext (вход), scl_o_ext (выход - всегда 0), scl_oen_ext (tristate) и аналогично для SDA. Нам нужны физические inout-порты oled_sda_io, oled_scl_io - соберём их через явные IOBUF'ы.
Теперь создаём файл zynq_mini_oled_top.v
Flow Navigator → Add Sources → Add or create design sources → Next.
Create File → File type: Verilog, File name: zynq_mini_oled_top.v → OK → Finish.
В появившемся окне Define Module — НЕ заполняйте порты (это сэкономит время), просто OK → подтверждение «Do you want to use these values?» → Yes.
В Sources → Design Sources появится zynq_mini_oled_top. Двойной клик - откроется пустой редактор. В него переносим порты из system wrapper и заносим все что необходимо для вывода внешних сигналов:
`timescale 1ns / 1ps // Top-уровень: BD-обёртка + IOBUF + индикация состояния module zynq_mini_oled_top ( inout wire oled_sda_io, inout wire oled_scl_io, output wire [3:0] led_o, input wire key1_n_i, input wire key2_n_i, inout wire [14:0] DDR_addr, inout wire [2:0] DDR_ba, inout wire DDR_cas_n, inout wire DDR_ck_n, inout wire DDR_ck_p, inout wire DDR_cke, inout wire DDR_cs_n, inout wire [3:0] DDR_dm, inout wire [31:0] DDR_dq, inout wire [3:0] DDR_dqs_n, inout wire [3:0] DDR_dqs_p, inout wire DDR_odt, inout wire DDR_ras_n, inout wire DDR_reset_n, inout wire DDR_we_n, inout wire FIXED_IO_ddr_vrn, inout wire FIXED_IO_ddr_vrp, inout wire [53:0] FIXED_IO_mio, inout wire FIXED_IO_ps_clk, inout wire FIXED_IO_ps_porb, inout wire FIXED_IO_ps_srstb ); wire scl_i, scl_o, scl_oen; wire sda_i, sda_o, sda_oen; wire i2c_irq; IOBUF iobuf_scl (.O(scl_i), .IO(oled_scl_io), .I(scl_o), .T(scl_oen)); IOBUF iobuf_sda (.O(sda_i), .IO(oled_sda_io), .I(sda_o), .T(sda_oen)); system_wrapper u_bd ( .DDR_addr (DDR_addr), .DDR_ba (DDR_ba), .DDR_cas_n (DDR_cas_n), .DDR_ck_n (DDR_ck_n), .DDR_ck_p (DDR_ck_p), .DDR_cke (DDR_cke), .DDR_cs_n (DDR_cs_n), .DDR_dm (DDR_dm), .DDR_dq (DDR_dq), .DDR_dqs_n (DDR_dqs_n), .DDR_dqs_p (DDR_dqs_p), .DDR_odt (DDR_odt), .DDR_ras_n (DDR_ras_n), .DDR_reset_n (DDR_reset_n), .DDR_we_n (DDR_we_n), .FIXED_IO_ddr_vrn (FIXED_IO_ddr_vrn), .FIXED_IO_ddr_vrp (FIXED_IO_ddr_vrp), .FIXED_IO_mio (FIXED_IO_mio), .FIXED_IO_ps_clk (FIXED_IO_ps_clk), .FIXED_IO_ps_porb (FIXED_IO_ps_porb), .FIXED_IO_ps_srstb (FIXED_IO_ps_srstb), .scl_i_ext (scl_i), .scl_o_ext (scl_o), .scl_oen_ext (scl_oen), .sda_i_ext (sda_i), .sda_o_ext (sda_o), .sda_oen_ext (sda_oen), .i2c_irq_ext (i2c_irq) ); // LED-индикация: статус IRQ + кнопки assign led_o = {key2_n_i, key1_n_i, i2c_irq, ~i2c_irq}; endmodule
Делаем его модулем верхнего уровня. В Sources → Design Sources правый клик по zynq_mini_oled_top → Set as Top. Иконка должна стать “треугольник с шапкой” - это обозначение top-модуля. В дереве должна получиться такая иерархия:

Теперь необходимо сформировать XDC - констрейнты на физические пины. Создаём файл ограничений (тоже из репо: vivado/pins.xdc готов и проверен).
Flow Navigator → Add Sources → Add or create constraints → Next.
Сreate File → выберите pins.xdc → OK.
Finish.
Откройте pins.xdc (двойной клик в Constraints). Всем все необходимое:
# ----- I2C линии к внешнему SSD1306-модулю (через 40-pin GPIO-разъём CAM1) -- # SDA → T20 (FPGA_GPIO_15P_34), SCL → P20 (FPGA_GPIO_14N_34); BANK 34, 3.3 V set_property PACKAGE_PIN T20 [get_ports oled_sda_io] set_property PACKAGE_PIN P20 [get_ports oled_scl_io] set_property IOSTANDARD LVCMOS33 [get_ports oled_sda_io] set_property IOSTANDARD LVCMOS33 [get_ports oled_scl_io] set_property PULLUP TRUE [get_ports oled_sda_io] set_property PULLUP TRUE [get_ports oled_scl_io] # ----- Пользовательские LED (FPGA_PL_LED1..4, 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[*]}] # ----- Пользовательские кнопки (FPGA_PL_KEY1/2, BANK 35) -------------------- # Активный уровень — низкий (нажата = 0). 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}] # --------------------------------------------------------------------------- # Системный 50 МГц для PL — НЕ используется в PS+PL варианте: тактирование # логики идёт от FCLK_CLK0 процессорной системы. Если захотите подать # внешний 50 МГц в обход PS — раскомментируйте: # --------------------------------------------------------------------------- # set_property PACKAGE_PIN K17 [get_ports clk_50m_i] # set_property IOSTANDARD LVCMOS33 [get_ports clk_50m_i] # create_clock -name clk_50m -period 20.000 [get_ports clk_50m_i]
Заметьте, для DDR*/FIXED_IO* пинов мы НИЧЕГО не констрейнтим - это пины “жёсткой” части кристалла Zynq, привязанные к PS. Vivado знает их сам.
Опишу что делает каждая строка:
Конструкция |
Что значит |
set_property PACKAGE_PIN T20 [get_ports oled_sda_io] |
логический порт oled_sda_io сидит на физическом шарике T20 |
set_property IOSTANDARD LVCMOS33 [get_ports …] |
стандарт ввода-вывода 3,3 В CMOS - совпадает с напряжением банка 34 на нашей плате |
set_property PULLUP TRUE … |
включает внутреннюю подтяжку к VCC (резистор ~50 кОм). Для I²C полезно как страховка, хотя на модуле OLED обычно стоят свои 10 кОм |
Если в zynq_mini_oled_top.v вы вдруг переименуете порт oled_sda_io → i2c_sda_io, надо обновить и XDC. Имена должны совпадать буква-в-букву.
Синтез
Теперь переходим к первичной проверке и синтезу полученного результата. Синтез превращает Verilog в граф из примитивов FPGA (LUT, FF, IOBUF и т.д.).
Flow Navigator → Synthesis → Run Synthesis.
Диалог Launch Runs - оставьте всё по умолчанию (Number of jobs = столько ядер CPU, сколько у вас) → OK.
В правом верхнем углу появится бегущая полоса. Время - 1..3 минуты на современном CPU.
-
По окончании - диалог:

В Flow Navigator появится узел Synthesized Design. Открыли — теперь:
Reports → Report Utilization - сколько ресурсов использовано. Для нашей схемы: ~500 LUT, ~600 FF, 0 BRAM, 0 DSP. Это меньше 1% от XC7Z020.
Messages - внизу. Не должно быть красных ошибок. Жёлтые WARNING могут быть про unused pins или неоптимальные структуры - не страшно.
Что делать если синтез упал? Чаще всего появляются эти ошибки:
ERROR: [Synth 8-439] module 'i2c_master_axi' not found - забыли добавить i2c_master_axi.v (см. шаг 5).
ERROR: [Synth 8-2576] port 'oled_sda_io' not found - рассогласование имени между zynq_mini_oled_top.v и pins.xdc.
ERROR: [Place 30-574] - пин XDC ссылается на шарик, которого нет в выбранной упаковке (например, поставили xc7z010clg400, а в XDC пин в bank 35 на варианте xc7z020).
Implementation + Timing
Implementation - размещение и трассировка. Vivado физически кладёт LUT в конкретные ячейки кристалла и прокладывает между ними проводники.
Flow Navigator → Implementation → Run Implementation → OK.
Время - 2..5 минут.
По окончании - Open Implemented Design.
Откройте отчёт Reports → Timing → Report Timing Summary:
WNS (Worst Negative Slack): должен быть положительным (≥ 0 нс).
WHS (Worst Hold Slack): тоже ≥ 0.
Setup / Hold / Pulse Width violations: все по 0.

Для нашей схемы (50 МГц, минимум логики) WNS обычно > 10 нс - огромный запас. WNS - это “запас по времени”. Положительный - поезд приехал на станцию раньше расписания, всё ОК. Отрицательный - опоздал, сигнал не успел дойти, дизайн не работает.
Генерация bitstream
Bitstream (*.bit) - двоичный файл, который заливается в конфигурационную память FPGA.
Flow Navigator → Program and Debug → Generate Bitstream → OK. Vivado спросит, не хотите ли сначала пересинтезить - отвечайте «нет», у нас всё свежее.
Время - меньше минуты.
По окончании - Open Implemented Design (если ещё закрыто).
Файл лежит в vivado/proj/zynq_mini_oled.runs/impl_1/zynq_mini_oled_top.bit.
Export Hardware → XSA для Vitis
Чтобы Vitis узнал про нашу конфигурацию (DDR/UART/IRQ/i2c@0x43C00000) - экспортируем платформу.
File → Export → Export Hardware…
-
В диалоге:
⚫ Include bitstream - обязательно отметьте! Без битстрима внутри XSA Vitis потом не сможет автоматически прошить PL.
Export to: <repo>/vivado/zynq_mini_oled.xsa
OK.
Vivado создаст zynq_mini_oled.xsa (~5 МБ). Внутри - XML-описание PS + битстрим PL. Теперь можно переходить к Vitis.
Запускаем Vitis 2025.2 Unified IDE
В 2025.2 «классический» Vitis ушёл; есть только Vitis Unified IDE (наследник Eclipse-IDE, но переписанный с нуля). Команда запуска - vitis & после source $XILINX_ROOT/Vitis/settings64.sh.
Откройте Vitis Unified IDE.
На стартовой странице нажмите Open Workspace.
Создайте папку <repo>/vitis/workspace/ (можно прямо в диалоге), выберите её → Open.
Vitis запомнит workspace и откроет пустое рабочее пространство.
Создаём Platform Component из XSA. «Platform» в Vitis - это контейнер, который содержит описание hardware (XSA) и BSP - Board Support Package с драйверами под xparameters.h, xil_io.h, xil_printf.h.
В Vitis сверху: File → New Component → Platform.
-
В мастере:
Component Name: zynq_mini_oled_platform
Component Location: оставить Workspace
Next.
Flow: ⚫ Create platform from hardware specification (XSA).
Hardware Design: Browse → выберите <repo>/vivado/zynq_mini_oled.xsa.
-
Next. На следующей странице:
OS: standalone
CPU: ps7_cortexa9_0 (первое ядро ARM)
Compiler: gcc
✅ Generate Boot artifacts
Next → Finish.
Vitis создаст компонент zynq_mini_oled_platform. В Components (слева) появится узел и Vitis сгенерирует BSP-исходники, скомпилирует libxil.a, xilstandalone, xiltimer. Время - 30..60 секунд. После сборки появится файл <workspace>/zynq_mini_oled_platform/export/zynq_mini_oled_platform/zynq_mini_oled_platform.xpfm - это «expand» платформы для применения в приложениях.
Создаем Application Component oled_demo
Выходим на финишную прямую. Создадим основной программный компонент, который исполнит целевые действия на дисплее OLED.
File → New Component → Application.
Component Name: oled_demo. Next.
Platform: выберите zynq_mini_oled_platform (Vitis покажет уже встроенный путь до .xpfm).
Domain: standalone_ps7_cortexa9_0. Next.
Source Files: оставляем пустым
Next → Finish.
В Components появится oled_demo. Внутри будут:
src/ - пустая папка под исходники (шаблон Empty не добавляет helloworld.c).
UserConfig.cmake - список .c для сборки и путь к lscript.ld.
lscript.ld - дефолтный скрипт под DDR который после заменим на вариант под OCM.
Далее нам необходимо построить следующий проект по слоям:

Файл |
Назначение |
|
Куда линкер кладёт код (OCM, не DDR - для JTAG без FSBL) |
|
Смещения регистров и биты CMD/STATUS (контракт с RTL) |
|
Драйвер: init, write, read, опрос TIP |
|
Протокол SSD1306 поверх i2c_write |
|
main(): UART → I²C → OLED → цикл |
Порядок разработки: линкер → заголовок I²C → драйвер I²C → (проверка UART) → SSD1306 → main → сборка.
Линкер-скрипт lscript.ld (память OCM)
Шаблон Vitis по умолчанию кладёт .text в DDR. При Run on Hardware без полноценного FSBL DDR может быть не готов - программа будет «молчитать». Нам нужен On-Chip Memory (OCM) - 256 КБ SRAM в PS, доступная сразу после ps7_init.
В Components → oled_demo → src уже лежит файл lscript.ld от шаблона - откройте его и замените содержимое (или New File → lscript.ld, если пусто).
-
Минимальная идея, которую вы закладываете в скрипт:
MEMORY {ps7_ram_0 : ORIGIN = 0x00000000, LENGTH = 0x00030000 /* 192 KiB OCM */ps7_ram_1 : ORIGIN = 0xFFFF0000, LENGTH = 0x00010000 /* 64 KiB — стеки исключений */}ENTRY(_vector_table)SECTIONS {.text : { KEEP (*(.vectors)) (.text) (.text.*) } > ps7_ram_0.rodata : { (.rodata) (.rodata.*) } > ps7_ram_0.data : { (.data) (.data.*) } > ps7_ram_0.bss : { (.bss) (.bss.*) } > ps7_ram_0/* стеки, heap — в ps7_ram_1, как в типовом standalone */}
Полный скрипт с размерами стеков должен выглядеть следующим образом:
Скрытый текст
/* ---------------------------------------------------------------------------- * Linker script для bare-metal приложения на Zynq-7000 PS Cortex-A9. * * Размещает ВСЕ секции в On-Chip Memory (OCM, 256 KB), DDR не используется. * Это позволяет запускать ELF напрямую через xsdb 'dow', не настраивая DDR * контроллер — нужно только успешное ps7_init для MIO/clocks. * * Карта: * ps7_ram_0 : 0x00000000 .. 0x0002FFFF (192 KB, нижние 3/4 OCM) * ps7_ram_1 : 0xFFFF0000 .. 0xFFFFFFFF (64 KB, верхняя четверть OCM) * * (Старший 1/4 OCM по умолчанию замаплен в высокие адреса; здесь резервируем * под supervisor/abort/irq стеки, чтобы exception handlers работали даже * когда нижний регион переполнен.) * * ELF демо (oled_demo) занимает ~58 KB (text+data+bss) — спокойно умещается. * --------------------------------------------------------------------------*/ _STACK_SIZE = DEFINED(_STACK_SIZE) ? _STACK_SIZE : 0x2000; _HEAP_SIZE = DEFINED(_HEAP_SIZE) ? _HEAP_SIZE : 0x2000; _ABORT_STACK_SIZE = DEFINED(_ABORT_STACK_SIZE) ? _ABORT_STACK_SIZE : 1024; _SUPERVISOR_STACK_SIZE = DEFINED(_SUPERVISOR_STACK_SIZE) ? _SUPERVISOR_STACK_SIZE : 2048; _IRQ_STACK_SIZE = DEFINED(_IRQ_STACK_SIZE) ? _IRQ_STACK_SIZE : 1024; _FIQ_STACK_SIZE = DEFINED(_FIQ_STACK_SIZE) ? _FIQ_STACK_SIZE : 1024; _UNDEF_STACK_SIZE = DEFINED(_UNDEF_STACK_SIZE) ? _UNDEF_STACK_SIZE : 1024; MEMORY { ps7_ram_0 : ORIGIN = 0x00000000, LENGTH = 0x00030000 ps7_ram_1 : ORIGIN = 0xFFFF0000, LENGTH = 0x00010000 } ENTRY(_vector_table) SECTIONS { .text : { KEEP (*(.vectors)) *(.boot) *(.text) *(.text.*) *(.gnu.linkonce.t.*) *(.plt) *(.gnu_warning) *(.gcc_except_table) *(.glue_7) *(.glue_7t) *(.vfp11_veneer) *(.ARM.extab) *(.gnu.linkonce.armextab.*) } > ps7_ram_0 .init : { KEEP (*(.init)) } > ps7_ram_0 .fini : { KEEP (*(.fini)) } > ps7_ram_0 .interp : { KEEP (*(.interp)) } > ps7_ram_0 .note-ABI-tag : { KEEP (*(.note-ABI-tag)) } > ps7_ram_0 .rodata : { __rodata_start = .; *(.rodata) *(.rodata.*) *(.gnu.linkonce.r.*) __rodata_end = .; } > ps7_ram_0 .rodata1 : { __rodata1_start = .; *(.rodata1) *(.rodata1.*) __rodata1_end = .; } > ps7_ram_0 .sdata2 : { __sdata2_start = .; *(.sdata2) *(.sdata2.*) *(.gnu.linkonce.s2.*) __sdata2_end = .; } > ps7_ram_0 .sbss2 : { __sbss2_start = .; *(.sbss2) *(.sbss2.*) *(.gnu.linkonce.sb2.*) __sbss2_end = .; } > ps7_ram_0 .data : { __data_start = .; *(.data) *(.data.*) *(.gnu.linkonce.d.*) *(.jcr) *(.got) *(.got.plt) __data_end = .; } > ps7_ram_0 .data1 : { __data1_start = .; *(.data1) *(.data1.*) __data1_end = .; } > ps7_ram_0 .got : { *(.got) } > ps7_ram_0 .ctors : { __CTOR_LIST__ = .; ___CTORS_LIST___ = .; KEEP (*crtbegin.o(.ctors)) KEEP (*(EXCLUDE_FILE(*crtend.o) .ctors)) KEEP (*(SORT(.ctors.*))) KEEP (*(.ctors)) __CTOR_END__ = .; ___CTORS_END___ = .; } > ps7_ram_0 .dtors : { __DTOR_LIST__ = .; ___DTORS_LIST___ = .; KEEP (*crtbegin.o(.dtors)) KEEP (*(EXCLUDE_FILE(*crtend.o) .dtors)) KEEP (*(SORT(.dtors.*))) KEEP (*(.dtors)) __DTOR_END__ = .; ___DTORS_END___ = .; } > ps7_ram_0 .fixup : { __fixup_start = .; *(.fixup) __fixup_end = .; } > ps7_ram_0 .eh_frame : { *(.eh_frame) } > ps7_ram_0 .eh_framehdr : { __eh_framehdr_start = .; *(.eh_framehdr) __eh_framehdr_end = .; } > ps7_ram_0 .gcc_except_table : { *(.gcc_except_table) } > ps7_ram_0 .mmu_tbl (ALIGN(16384)) : { __mmu_tbl_start = .; *(.mmu_tbl) __mmu_tbl_end = .; } > ps7_ram_0 .ARM.exidx : { __exidx_start = .; *(.ARM.exidx*) *(.gnu.linkonce.armexidix.*.*) __exidx_end = .; } > ps7_ram_0 .preinit_array : { __preinit_array_start = .; KEEP (*(SORT(.preinit_array.*))) KEEP (*(.preinit_array)) __preinit_array_end = .; } > ps7_ram_0 .init_array : { __init_array_start = .; KEEP (*(SORT(.init_array.*))) KEEP (*(.init_array)) __init_array_end = .; } > ps7_ram_0 .fini_array : { __fini_array_start = .; KEEP (*(SORT(.fini_array.*))) KEEP (*(.fini_array)) __fini_array_end = .; } > ps7_ram_0 .ARM.attributes : { __ARM.attributes_start = .; *(.ARM.attributes) __ARM.attributes_end = .; } > ps7_ram_0 .sdata : { __sdata_start = .; *(.sdata) *(.sdata.*) *(.gnu.linkonce.s.*) __sdata_end = .; } > ps7_ram_0 .sbss (NOLOAD) : { __sbss_start = .; *(.sbss) *(.sbss.*) *(.gnu.linkonce.sb.*) __sbss_end = .; } > ps7_ram_0 .tdata : { __tdata_start = .; *(.tdata) *(.tdata.*) *(.gnu.linkonce.td.*) __tdata_end = .; } > ps7_ram_0 .tbss : { __tbss_start = .; *(.tbss) *(.tbss.*) *(.gnu.linkonce.tb.*) __tbss_end = .; } > ps7_ram_0 .bss (NOLOAD) : { . = ALIGN(4); _bss_start = .; __bss_start = .; __bss_start__ = .; *(.bss) *(.bss.*) *(.gnu.linkonce.b.*) *(COMMON) . = ALIGN(4); _bss_end = .; __bss_end = .; __bss_end__ = .; } > ps7_ram_0 _SDA_BASE_ = __sdata_start + ((__sbss_end - __sdata_start) / 2); _SDA2_BASE_ = __sdata2_start + ((__sbss2_end - __sdata2_start) / 2); /* Стек / куча в верхнем OCM-регионе */ .heap (NOLOAD) : { . = ALIGN(16); _heap = .; HeapBase = .; _heap_start = .; . += _HEAP_SIZE; _heap_end = .; HeapLimit = .; } > ps7_ram_1 .stack (NOLOAD) : { . = ALIGN(16); _system_stack_end = .; . += _STACK_SIZE; . = ALIGN(16); __stack = .; /* SYS mode stack — основной для main() */ _supervisor_stack_end = .; . += _SUPERVISOR_STACK_SIZE; . = ALIGN(16); __supervisor_stack = .; _abort_stack_end = .; . += _ABORT_STACK_SIZE; . = ALIGN(16); __abort_stack = .; _fiq_stack_end = .; . += _FIQ_STACK_SIZE; . = ALIGN(16); __fiq_stack = .; _undef_stack_end = .; . += _UNDEF_STACK_SIZE; . = ALIGN(16); __undef_stack = .; _irq_stack_end = .; . += _IRQ_STACK_SIZE; . = ALIGN(16); __irq_stack = .; } > ps7_ram_1 _end = .; }
Так же необходимо убедиться, что в UserConfig.cmake (в том же src/) есть строка (Vitis часто добавляет сам):
set(USER_LINKER_SCRIPT "${CMAKE_SOURCE_DIR}/lscript.ld")
Без неё сборка возьмёт DDR-скрипт из BSP.
Заголовок i2c_master.h - контракт с PL
Создаем src/i2c_master.h. Числа должны совпадать с комментарием в rtl/i2c_master_axi.v:
#ifndef I2C_MASTER_H_ #define I2C_MASTER_H_ #include <stdint.h> #include <stdbool.h> #define I2C_REG_CTRL 0x00 #define I2C_REG_STATUS 0x04 #define I2C_REG_CMD 0x08 #define I2C_REG_TX_DATA 0x0C #define I2C_REG_RX_DATA 0x10 #define I2C_REG_PRESCALE 0x14 #define I2C_REG_ISR 0x18 #define I2C_CTRL_EN (1u << 0) #define I2C_CTRL_IEN (1u << 1) #define I2C_STATUS_TIP (1u << 0) #define I2C_STATUS_RXACK (1u << 1) #define I2C_STATUS_BUSY (1u << 2) #define I2C_STATUS_AL (1u << 3) #define I2C_CMD_STA (1u << 0) #define I2C_CMD_STO (1u << 1) #define I2C_CMD_RD (1u << 2) #define I2C_CMD_WR (1u << 3) #define I2C_CMD_NACK (1u << 4) void i2c_init (uintptr_t base, uint16_t prescale); bool i2c_write (uintptr_t base, uint8_t slave_addr, const uint8_t *data, uint32_t len); bool i2c_read (uintptr_t base, uint8_t slave_addr, uint8_t *data, uint32_t len); void i2c_disable(uintptr_t base); #endif
В этом файле мы задаем имена смещений для Xil_Out32(base + off, val) - то же, что BFM пишет в tb/i2c_master_tb.sv.
i2c_master.c - доступ к регистрам и ожидание TIP
Создайте src/i2c_master.c. Подключите BSP и сделаем обёртки MMIO (одна строка чтения/записи):
#include "i2c_master.h" #include "xil_io.h" static inline uint32_t i2c_rd(uintptr_t base, uint32_t off) { return Xil_In32(base + off); } static inline void i2c_wr(uintptr_t base, uint32_t off, uint32_t v) { Xil_Out32(base + off, v); }
uintptr_t base - физический адрес slave на GP0, у нас 0x43C00000 (или макрос из xparameters.h после сборки platform).
Следующий шаг - объявим функцию wait_done: после каждой записи в CMD секвенсер поднимает TIP; софт крутится, пока TIP = 0 (как wait_tip_clear в тестбенче):
static bool wait_done(uintptr_t base) { // Опрашиваем TIP до сброса. Простой spin без таймера - для bare-metal достаточно; for (uint32_t i = 0; i < 1000000; i++) { uint32_t st = i2c_rd(base, I2C_REG_STATUS); if ((st & I2C_STATUS_TIP) == 0) { if (st & I2C_STATUS_AL) { // Arbitration lost — сбрасываем флаг через запись в CMD (NOP) i2c_wr(base, I2C_REG_CMD, 0); return false; } return true; } } return false; }
Добавляем функцию инициализации i2c_init с установкой PRESCALE, который можно менять только при EN=0:
void i2c_init(uintptr_t base, uint16_t prescale) { i2c_wr(base, I2C_REG_CTRL, 0); i2c_wr(base, I2C_REG_PRESCALE, prescale); i2c_wr(base, I2C_REG_ISR, 0x3); /* W1C: сброс DONE/AL */ i2c_wr(base, I2C_REG_CTRL, I2C_CTRL_EN); }
Логично добавить функцию i2c_disable: CTRL = 0 - линии I²C отпущены.
void i2c_disable(uintptr_t base) { i2c_wr(base, I2C_REG_CTRL, 0); }
Далее функция i2c_write: одна транзакция “запись N байт слейву” то, что вы уже видели в симуляции TEST 1:
TX_DATA ← {slave_addr[6:0], 0} (бит R/W = 0).
CMD ← STA | WR → wait_done.
Проверить STATUS.RXACK (бит 1): если 1 — слейв ответил NACK, отправить STO и вернуть false.
Для каждого байта данных: TX_DATA ← байт; CMD ← WR, на последнем байте добавить STO; каждый раз wait_done.
bool i2c_write(uintptr_t base, uint8_t slave_addr, const uint8_t *data, uint32_t len) { // 1. START + WRITE(addr<<1 | 0) i2c_wr(base, I2C_REG_TX_DATA, (uint32_t)(slave_addr << 1)); i2c_wr(base, I2C_REG_CMD, I2C_CMD_STA | I2C_CMD_WR); if (!wait_done(base)) return false; if (i2c_rd(base, I2C_REG_STATUS) & I2C_STATUS_RXACK) { // NACK — slave не ответил i2c_wr(base, I2C_REG_CMD, I2C_CMD_STO); wait_done(base); return false; } // 2. WRITE(data[i]) for (uint32_t i = 0; i < len; i++) { i2c_wr(base, I2C_REG_TX_DATA, data[i]); uint32_t cmd = I2C_CMD_WR; if (i == len - 1) cmd |= I2C_CMD_STO; // последний — со STOP i2c_wr(base, I2C_REG_CMD, cmd); if (!wait_done(base)) return false; if (i != len - 1 && (i2c_rd(base, I2C_REG_STATUS) & I2C_STATUS_RXACK)) { i2c_wr(base, I2C_REG_CMD, I2C_CMD_STO); wait_done(base); return false; } } return true; }
И функция i2c_read для чтения M байт:
START + адресный байт read: TX_DATA = (slave << 1) | 1, CMD = STA | WR.
Для каждого байта: CMD = RD; на последнем добавить NACK | STO;
После wait_done прочитать RX_DATA [7:0].
bool i2c_read(uintptr_t base, uint8_t slave_addr, uint8_t *data, uint32_t len) { if (len == 0) return true; // 1. START + WRITE(addr<<1 | 1) i2c_wr(base, I2C_REG_TX_DATA, (uint32_t)((slave_addr << 1) | 1)); i2c_wr(base, I2C_REG_CMD, I2C_CMD_STA | I2C_CMD_WR); if (!wait_done(base)) return false; if (i2c_rd(base, I2C_REG_STATUS) & I2C_STATUS_RXACK) { i2c_wr(base, I2C_REG_CMD, I2C_CMD_STO); wait_done(base); return false; } // 2. READ data for (uint32_t i = 0; i < len; i++) { uint32_t cmd = I2C_CMD_RD; if (i == len - 1) cmd |= I2C_CMD_NACK | I2C_CMD_STO; i2c_wr(base, I2C_REG_CMD, cmd); if (!wait_done(base)) return false; data[i] = (uint8_t)i2c_rd(base, I2C_REG_RX_DATA); } return true; }
Теперь проведем промежуточную проверку. Выведем сообщение в UART и выведем значение PRESCALER. Прежде чем писать SSD1306, убедимся, что базовый адрес и запись в регистры работают.
Временно создайте минимальный main.c:
#include "xil_printf.h" #include "xil_io.h" #include "i2c_master.h" #define I2C_BASE 0x43C00000u #define PRESCALE 124u int main(void) { xil_printf("\r\nI2C smoke test\r\n"); i2c_init(I2C_BASE, PRESCALE); xil_printf("PRESCALE read = %u\r\n", (unsigned)Xil_In32(I2C_BASE + I2C_REG_PRESCALE) & 0xFFFFu); xil_printf("OK\r\n"); for (;;) ; }
В Vitis Unified IDE прямо рядом с приложением есть Run/Debug configuration. Сделаем «Run As → On Hardware».

-
В дереве правый клик по oled_demo → Run As → Launch on Hardware (Single Application Debug)…
-
В диалоге убедитесь:
Platform: zynq_mini_oled_platform
✅ Program FPGA (Vitis возьмёт битстрим из XSA и зальёт)
✅ Initialize Processor / Run ps7_init (берётся из BSP)
Application: oled_demo (ELF)
Run.
-
Vitis под капотом сделает следующую последовательность (через xsdb/hw_server):
Соединится с JTAG-кабелем (connect).
Прошьёт PL: fpga -file zynq_mini_oled_top.bit.
Остановит CPU: targets -set -filter {name =~ "*Cortex-A9 #0"} → rst -processor.
Запустит ps7_init (инициализация DDR, PLL, UART, GIC).
Загрузит ELF: dow oled_demo.elf.
Стартует: con.
Если всё прошло — в терминале UART увидите:

А это означает что мы на верном пути. Остается сделать API OLED дисплея и сделать финальный main.c.
ssd1306.h - адрес и API дисплея
SSD1306 на модуле OLED обычно сидит на I²C 0x3C (иногда 0x3D - смотрите шелкографию модуля). Создайте ssd1306.h:
#ifndef SSD1306_H_ #define SSD1306_H_ #include <stdint.h> #include <stdbool.h> #define SSD1306_I2C_ADDR 0x3C // 0x3D на некоторых модулях bool ssd1306_init (uintptr_t i2c_base); bool ssd1306_clear (uintptr_t i2c_base); bool ssd1306_send_frame (uintptr_t i2c_base, const uint8_t fb[1024]); bool ssd1306_demo_pattern (uintptr_t i2c_base); #endif
Кадр 128х64 монохромный = 1024 байта (8 страниц x 128 столбцов).
ssd1306.c - протокол I²C для SSD1306
Далее составим основной код для дисплея. Код легко читается и в дополнительных пояснениях не нуждается:
// --------------------------------------------------------------------------- // SSD1306 128x64, режим I2C, через i2c_master_axi. // Init-последовательность взята из datasheet §8.5 «Power-ON Sequence». // Адреса I2C-фрейма: // <slave><Co=0,DC=0><cmd>... — команды (control byte 0x00) // <slave><Co=0,DC=1><data>... — данные (control byte 0x40) // --------------------------------------------------------------------------- #include "ssd1306.h" #include "i2c_master.h" #include <string.h> #define CTRL_CMD 0x00 #define CTRL_DATA 0x40 static const uint8_t ssd1306_init_seq[] = { CTRL_CMD, 0xAE, // Display OFF 0xD5, 0x80, // Set display clock divide ratio / oscillator freq 0xA8, 0x3F, // Multiplex ratio = 64 0xD3, 0x00, // Display offset = 0 0x40, // Start line = 0 0x8D, 0x14, // Charge pump enable 0x20, 0x00, // Memory addressing mode = horizontal 0xA1, // Segment remap (col 127 → SEG0) 0xC8, // COM scan direction (remapped) 0xDA, 0x12, // COM pins hardware config 0x81, 0xCF, // Contrast 0xD9, 0xF1, // Pre-charge period 0xDB, 0x40, // VCOMH deselect 0xA4, // Display follows RAM 0xA6, // Normal display (not inverted) 0x2E, // Deactivate scroll 0xAF // Display ON }; static bool send_cmd_block(uintptr_t base, const uint8_t *buf, uint32_t len) { return i2c_write(base, SSD1306_I2C_ADDR, buf, len); } bool ssd1306_init(uintptr_t base) { return send_cmd_block(base, ssd1306_init_seq, sizeof(ssd1306_init_seq)); } static bool set_window(uintptr_t base) { static const uint8_t win[] = { CTRL_CMD, 0x21, 0x00, 0x7F, // column addr 0..127 0x22, 0x00, 0x07 // page addr 0..7 }; return send_cmd_block(base, win, sizeof(win)); } bool ssd1306_send_frame(uintptr_t base, const uint8_t fb[1024]) { if (!set_window(base)) return false; // Передаём кадр по 16-байтовым блокам с control-байтом 0x40 uint8_t tx[17]; tx[0] = CTRL_DATA; for (uint32_t off = 0; off < 1024; off += 16) { memcpy(&tx[1], &fb[off], 16); if (!i2c_write(base, SSD1306_I2C_ADDR, tx, sizeof(tx))) return false; } return true; } bool ssd1306_clear(uintptr_t base) { static uint8_t fb[1024]; memset(fb, 0, sizeof(fb)); return ssd1306_send_frame(base, fb); } // «Шахматная доска» 8×8 — простая визуальная проверка bool ssd1306_demo_pattern(uintptr_t base) { static uint8_t fb[1024]; for (uint32_t y = 0; y < 8; y++) { // 8 страниц по 8 пикс for (uint32_t x = 0; x < 128; x++) { uint8_t cell_x = x / 8; uint8_t cell_y = y; fb[y * 128 + x] = ((cell_x ^ cell_y) & 1) ? 0xFF : 0x00; } } return ssd1306_send_frame(base, fb); }
main.c - сборка приложения
Создаем финальный main.c:
// --------------------------------------------------------------------------- // Bare-metal demo для ZYNQ MINI Rev B (PS Cortex-A9 + i2c_master_axi в PL). // Инициализирует SSD1306 (128x64) на разъёме J4 через PL I2C-мастер // и периодически пере-рисовывает шахматный паттерн. // --------------------------------------------------------------------------- #include "xparameters.h" #include "xil_printf.h" #include "sleep.h" #include "i2c_master.h" #include "ssd1306.h" // Базовый адрес ставится в build.tcl Vivado: 0x43C00000. // Для надёжности предпочитаем XPAR_*, если он сгенерирован BSP'ом, иначе // fallback на жёстко прописанный адрес. #ifdef XPAR_I2C_BASEADDR # define I2C_BASE XPAR_I2C_BASEADDR #else # define I2C_BASE 0x43C00000u #endif #define FCLK0_HZ 50000000u #define I2C_SCL_HZ 400000u #define I2C_PRESCALE ((FCLK0_HZ / (4u * I2C_SCL_HZ)) - 1u) // = 124 int main(void) { xil_printf("\r\n=== ZYNQ MINI OLED demo (PS+PL build) ===\r\n"); xil_printf("I2C base = 0x%08x, PRESCALE = %d\r\n", I2C_BASE, I2C_PRESCALE); i2c_init(I2C_BASE, I2C_PRESCALE); if (!ssd1306_init(I2C_BASE)) { xil_printf("ERROR: SSD1306 init failed (NACK?)\r\n"); return -1; } xil_printf("OLED init OK\r\n"); if (!ssd1306_clear(I2C_BASE)) { xil_printf("ERROR: OLED clear failed\r\n"); return -1; } while (1) { ssd1306_demo_pattern(I2C_BASE); sleep(1); ssd1306_clear(I2C_BASE); sleep(1); } return 0; }
Логика кода простая: сообщение в UART подтверждает старт → I²C на 400 кГц → длинная init-цепочка на дисплей → цикл «паттерн / пауза / чёрный экран».
Проверяем таким же образом еще раз и видим тестовый паттерн. УРА! Все заработало!

А что же дальше или Заключение
А дальше начнется самое интересное. Мы соберем с помощью buildroot все для запуска Linux, напишем драйвер для работы с данным дисплеем из userspace Linux, выведем на него свою информацию (например температуру Zynq), или вообще системную консоль.
Как оказалось все это сделать достаточно не сложно и по сути работа с периферийными устройствами из ПЛИС уже не кажется чем-то космически сложным и непонятным. Очень похоже что задумка, которая раньше мне казалась невыполнимой теперь выглядит как решаемый и вполне понятный кейс.
До встречи в следующих статьях, может заодно и HDMI выведу из Linux потом…
Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.
