После успешной отладки на плате с 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: при первом запуске выйдет напоминание, или вручную:

sudo $XILINX_ROOT/Vivado/data/xicom/cable_drivers/lin64/install_script/install_drivers/install_drivers

Без них Vivado не увидит плату по USB-JTAG

4.

Плата ZYNQ MINI Rev B с XC7Z020-CLG400 (или XC7Z010 - у нас по умолчанию xc7z020clg400-1)

Цель прошивки

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/i2c_master_core.v, rtl/i2c_master_axi.v, vivado/pins.xdc и каталог vitis/workspace/oled_demo/src/

Готовый RTL и bare-metal код

8.

Эмулятор терминала: minicom, picocom, screen или PuTTY - с настройкой 115200 8N1

Смотрим xil_printf из bare-metal

Если у вас 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

делитель: f_SCL = f_clk / (4·(PRESCALE+1))

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 с описанием и сам битстрим.

План работы

  1. Создаем проект Vivado под xc7z020clg400-1;

  2. Добавляем RTL i2c_master_core.v и i2c_master_axi.v;

  3. Создать Block Design с названием system;

  4. Добавить Zynq7 Processing System и настроить под плату;

  5. Добавить i2c_master_axi как Module Reference;

  6. Делаем внешними выводы SDA/SCL pad_i/o/oen + irq;

  7. Запускаем Connection Automation AXI4 → подключит Interconnect и reset;

  8. Подключить irq_o к IRQ_F2P;

  9. В Address Editor для i2c/s_axi устанавливаем 0x43C00000;

  10. Выполняем Validate Design;

  11. Создаем HDL Wrapper и top-RTL с IOBUF;

  12. После создаем XDC constraints: pin location;

  13. Synthesis → Implementation→ Generate Bitstream;

  14. Export Hardware → XSA

  15. Vitis: создать Platform из XSA;

  16. Vitis: создать Application oled_demo;

  17. Добавить src/: i2c_master.c, ssd1306.c, main.c

  18. Build ELF;

  19. Program FPGA + Run ELF через JTAG;

  20. 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, какой именно кристалл стоит на плате.

  1. Сверху диалога переключитесь на вкладку Parts (НЕ Boards - нашей китайской платы в каталоге Vivado нет).

  2. Раскройте Filters справа:

    • Family: Zynq-7000

    • Package: clg400

    • Speed grade: -1 (если на корпусе вашего чипа напечатано -2/-3 - поставьте ту же)$

  3. В таблице ниже найдите xc7z020clg400-1 и кликните по строке.

  4. NextFinish.

Хотя есть мнение о том, что на 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-битных регистров по шине. 

Внутри модуля будет:

  1. Интерфейс 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.

  2. Регистровый файл и секвенсер - запись в CMD разворачивается в цепочку атомарных команд для ядра; чтения/записи идут по карте из заголовка файла.

  3. Экземпляр i2c_master_core - всё “низкоуровневое” I²C сосредоточено в ядре; обёртка только подаёт ena_i, команды и забирает статусы.

  4. Синхронизаторы на входах scl_pad_i / sda_pad_i - асинхронные линии шины попадают в домен s_axi_aclk двумя регистрами (метастабильность).

  5. Открытый коллектор наружу - не inout, а три сигнала на линию: _pad_o (в коде всегда 0), _padoen_o (1 = отпустить линию / Hi-Z, 0 = тянуть вниз), *_pad_i (чтение линии). Для Zynq top-level с inout собирается уже в Verilog-обёртке с примитивами IOBUF.

  6. 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 будет пояснение, что это делает и как связано с остальной логикой.

Переходим к наполнению проекта. Добавляем каждый файл. 

  1. Flow Navigator → Project Manager → Add Sources (или Alt+A).

  2. Add or create design sources → Next.

  3. Create Files → Verilog с именем i2c_master_core.v 

  4. Create Files → Verilog с именем i2c_master_axi.v

  5. Finish.

Далее нас попросят заполнить порты, пропустим этот шаг и сделаем это прямо в коде. 

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

После этого перейдем к разработке i2c_master_axi.v и немного углубимся в суть AXI4-Lite шины. Общий флоу будет следующим:

Шаг

Что делают

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

1

Утверждаем интерфейс модуля i2c_master_core: тактовый сигнал, сброс, ena_i, команды cmd_i, handshake cmd_valid_i/ready_o, биты шины, open-drain через *_oen_o.

Ядро - подключаемый общий слой, что для проектов Quartus, что для Vivado; 

2

Фиксируем регистровую карту периферии (адреса, R/W, смысл битов) - и дублируем её комментарием в заголовке rtl/i2c_master_axi.v.

ARM будет ходить по AXI в байтовых смещениях

3

Разделяем доступ по шине на два канала AXI4-Lite: запись (AW+W → B) и чтение (AR → R). Рисуем на бумаге рукопожатия: когда поднимать xready, когда bvalid/rvalid.

См. соответствующие always-блоки ниже;

4

Реализуем хранение CTRL, TX_DATA, PRESCALE, полей CMD, флагов ISR в регистрах *_r, плюс внутренние флаги aw_done_r/w_done_r, cmd_write_strobe.

Регистры - это то, что «видит» процессор; стробы - связка между шиной и секвенсером.

5

Вводим прескейлер (prescale_cnt_r, core_ena_r): счёт от prescale_r до нуля, один такт ena на ядро.

Ядро шагает только при ena_i; частота SCL задаётся по формуле из заголовка файла.

6

Пишем секвенсор (FSM seq_state_r): из одной записи в CMD строится цепочка START/RESTART/WRITE/READ/STOP.

Программа пишет “составную” команду; ядро принимает только атомарные команды - см. локальные параметры CMD_C_* и состояния SEQ_*.

7

Добавляем синхронизаторы на линии с пинов и маскирование выходов при !ctrl_en_r (линии в отпущенном состоянии, пока контроллер выключен).

Метастабильность с внешней шины; безопасное поведение до EN=1.

8

Реализуем IRQ: задержка tip_r/core_arb_lost для фронтов, флаги isr_*_r, assign irq_o, запись в ISR и W1C-сброс.

Согласование с GIC в BD; софт может опрашивать STATUS вместо IRQ и оба пути допустимы.

9

Подключаем i2c_master_core и связываем проводами все сигналы; задаем arb_lost_clear_i от строба записи в CMD.

Точка сборки: после этого модуль - законченное 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) состоит из трёх логических частей:

  1. AW (address write) - адрес цели и тип доступа (в Lite формат упрощен).

  2. W (write data) - данные и строб байтов wstrb (для 32-битного слова показывает, какие байты слова действительно записываются).

  3. B (write response) - короткий ответ slave о том, принята ли запись (в простых регистровых блоках почти всегда OK).

Чтение (read) состоит из двух частей:

  1. AR (address read) - адрес.

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

0x00000000

0x04

STATUS

R

Только чтение

0x08

CMD

W

Регистр по смыслу «запись-only»

0x0C

TX_DATA

R/W

0x00000000

0x10

RX_DATA

R

0x00000000

0x14

PRESCALE

R/W

Начальное значение задаётся параметром DEFAULT_PRESCALE в RTL (часто 249, т.е. 0x00F9, если так подобрано под целевую SCL)

0x18

ISR

R / W1C

0x00000000

Для себя отметим важные замечания: PRESCALE меняем только при EN = 0. Переход EN из 1 в 0 обрывает текущую транзакцию сразу.

CTRL (0x00) - управление:

Биты

Поле

Сброс

Описание

31:2

-

0

зарезервированы

1

IEN

0

Разрешение прерываний: при 1 линия irq_o может подниматься по флагам ISR

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 - секвенсор обрабатывает команду;
для поллинга обычно ждут TIP = 0

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:

f_{\mathrm{SCL}} = \frac{f_{\mathrm{clk}}}{4 \cdot (\mathrm{PRESCALE}+1)}

где f_{\mathrm{clk}} - это s_axi_aclk у обёртки. Пример для f_{\mathrm{clk}} = 100\,\mathrm{МГц}:

Режим

Целевая SCL

PRESCALE

Standard

100 кГц

249 (0x00F9)

Fast

400 кГц

62 (0x003E)

Fast Mode Plus

1 МГц

24 (0x0018)

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 (верх)

rtl/i2c_master_top.v

Обёртка с inout sda_io/scl_io (как на плате с IOBUF); внутри инстанс i2c_master_axi. В Vivado BD вы позже можете подключать i2c_master_axi напрямую - для симуляции удобнее top с tri-state.

Обёртка (цель проверки)

rtl/i2c_master_axi.v

Регистры, AXI, секвенсер, IRQ, синхронизаторы.

Ядро

rtl/i2c_master_core.v

Низкоуровневый I²C; можно в waveform смотреть state_r, phase_r, ena_i.

AXI master BFM

tb/axi_lite_master_bfm.sv

Задачи axi_write / axi_read - аналог записи/чтения регистров с ARM.

Модель слейва

tb/i2c_slave_model.sv

Поведенческий EEPROM-like slave: 256 байт RAM, адрес 0x50, ACK/NACK, byte write/read.

Тестбенч

tb/i2c_master_tb.sv

Часы, сброс, pull-up, сценарии, счётчики test_pass/test_fail, watchdog.

В репозитории в  файле tb/i2c_master_tb.sv заданы параметры, намеренно отличающиеся от боевых 100 кГц на плате - иначе одна транзакция заняла бы слишком много тактов симуляции:

Параметр

Значение в TB

Смысл

CLK_PERIOD

10 нс

100 МГц на s_axi_aclk (как типичный FCLK в PL).

PRESCALE_VAL / DEFAULT_PRESCALE

4

Укороченный делитель: SCL в симуляции намного быстрее, чем 100 кГц, но та же формула f_{\mathrm{SCL}} = f_{\mathrm{clk}} / (4(\mathrm{PRESCALE}+1)).

SLAVE_ADDR

7'h50

Должен совпадать с 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.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

и запускает 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

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

  1. AXI Write - пара транзакций: сначала запись в TX_DATA (awaddr=0x0C), затем в CMD (awaddr=0x08). Убедитесь, что для каждой записи завершились AW+W и пришёл BVALID (bresp=OKAY).

  2. AXI Regs / Sequencer - после записи CMD растёт tip_r, seq_state_r проходит START → WRITE → (STOP при WR+STO), core_cmd_valid_r выдает импульс на каждую атомарную команду ядра.

  3. Core FSM - state_r переходит IDLE→START→DATA→...; ena_i (через прескалер) двигает phase_r 0…3 на каждый бит.

  4. I2C Bus - на SCL видны такты; SDA меняется только при низком SCL (кроме START/STOP); между транзакциями обе линии высокие (pull-up).

  5. Slave - state слейва следует за адресным и data-байтами; mem_ptr указывает на ячейку EEPROM.

  6. Завершение - tip_r падает; при включённом IEN можно увидеть isr_done_r и DONE в ISR (TEST 4).

Если используете Questa, те же группы уже собраны в sim/questa/wave.do - достаточно make questa-gui и прокрутить к метке времени из $display в логе ([%0t] I2C WRITE: ...).

Минимальный чеклист, который говорит о том, что с сигналами все ОК:

Сигнал / группа

Ожидание

s_axi_aclk

Стабильный меандр 100 МГц в TB.

s_axi_aresetn

Активный низкий сброс в начале и в TEST 6.

awready/wready

Принимают запись BFM без зависания.

arready/rvalid

Чтение STATUS/RX_DATA возвращает данные за 1–2 такта (как в RTL).

tip_r

1 на время секвенсера, 0 в idle между командами.

SCL/SDA

Нет “залипшего” нуля на SCL при EN=1; STOP отпускает шину.

irq

Может кратковременно вспыхивать при 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:

Далее по шагам:

  1. На холсте нажмите + (или Ctrl+I) - откроется окошко Add IP.

  2. В строке поиска наберите Zynq7 Processing System. 

  3. Появится ZYNQ7 Processing System.

  4. Двойной клик по нему - 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 уже была сконфигурирована правильно.

  1. Двойной клик по блоку i2c.

  2. В диалоге Re-customize IP - увидите три параметра: C_S_AXI_DATA_WIDTH, C_S_AXI_ADDR_WIDTH, DEFAULT_PRESCALE.

  3. Поставьте DEFAULT_PRESCALE = 31 (для 50 МГц → 400 кГц).

  4. 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:

  1. Правый клик по пину scl_pad_i → Make External (или сочетание Ctrl+T).

  2. На холсте появится внешний порт в виде вытянутого шестигранника, соединённый с пином. Имя по умолчанию scl_pad_i_0.

  3. Повторите для scl_pad_o, scl_padoen_o, sda_pad_i, sda_pad_o, sda_padoen_o, irq_o.

Получится 7 внешних портов. Имена с суффиксом _0 неудобны. Переименуем:

  1. Кликните по порту scl_pad_i_0 → в правой панели External Port Properties → поле Name → введите scl_i_ext → Enter.

  2. По аналогии:

    • 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 сделает это сам:

  1. Сверху появится зелёная полоса «Run Connection Automation» (если её нет - нажмите Window → Designer Assistance или просто кликните по интерфейсу i2c/s_axi, и Vivado снова покажет полосу).

  2. Run Connection Automation → откроется диалог. Слева - список «требующих соединения» интерфейсов; нас интересует i2c/s_axi.

  3. Параметры (справа):

    • Master: /ps7/M_AXI_GP0

    • Slave: /i2c/s_axi (уже выбран)

    • Clock master/slave/xbar: Auto

    • intc IP: New AXI Interconnect

  4. 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:

  1. На холсте найдите пин i2c/irq_o (на правой грани блока i2c).

  2. Зажмите ЛКМ и протащите линию до входа ps7/IRQ_F2P[0:0]. Когда курсор окажется на этом пине - отпустите. Линия нарисуется.

Если IRQ_F2P — векторный ([0:0] или шире), а наш irq_o - однобитный, Vivado может попросить вставить xlconcat для согласования. Соглашайтесь - Vivado сам поставит конкатенатор. В нашей конфигурации (1 источник) этого не нужно: [0:0] это тот же 1 бит и прямое соединение работает.

Получится следующее:

Далее открываем Address Editor. Слейв i2c/s_axi пока не имеет адреса в адресном пространстве PS - нужно его «прописать на карту».

  1. Сверху над холстом найдите вкладку Address Editor (если её нет - Window → Address Editor).

  2. В дереве будет узел ps7 (1 address space) → Data. Под ним - список всех слейвов; среди них i2c/s_axi.

  3. Кликните правой кнопкой по i2c/s_axi (или на верхнем уровне) → Assign Address. Vivado автоматически назначит свободный адрес из диапазона GP0.

  4. Проверьте/исправьте поля:

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

  1. File → Save Block Design (или Ctrl+S) — сохраняет system.bd в проекте.

  2. В Sources правый клик по system.bd (он лежит в Design Sources → system.bd) → Create HDL Wrapper…

  3. В диалоге выберите ⚫ Let Vivado manage wrapper and auto-updateOK.

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

  1. Flow Navigator → Add Sources → Add or create design sourcesNext.

  2. Create FileFile type: Verilog, File name: zynq_mini_oled_top.v → OKFinish.

  3. В появившемся окне 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 готов и проверен).

  1. Flow Navigator → Add Sources → Add or create constraintsNext.

  2. Сreate File → выберите pins.xdc → OK

  3. 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 и т.д.).

  1. Flow Navigator → Synthesis → Run Synthesis.

  2. Диалог Launch Runs - оставьте всё по умолчанию (Number of jobs = столько ядер CPU, сколько у вас) → OK.

  3. В правом верхнем углу появится бегущая полоса. Время - 1..3 минуты на современном CPU.

  4. По окончании - диалог:

В 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 в конкретные ячейки кристалла и прокладывает между ними проводники.

  1. Flow Navigator → Implementation → Run ImplementationOK.

  2. Время - 2..5 минут.

  3. По окончании - 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.

  1. Flow Navigator → Program and Debug → Generate BitstreamOK. Vivado спросит, не хотите ли сначала пересинтезить - отвечайте «нет», у нас всё свежее.

  2. Время - меньше минуты.

  3. По окончании - 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) - экспортируем платформу.

  1. File → Export → Export Hardware…

  2. В диалоге:

    • Include bitstream - обязательно отметьте! Без битстрима внутри XSA Vitis потом не сможет автоматически прошить PL.

    • Export to: <repo>/vivado/zynq_mini_oled.xsa

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

  1. Откройте Vitis Unified IDE.

  2. На стартовой странице нажмите Open Workspace.

  3. Создайте папку <repo>/vitis/workspace/ (можно прямо в диалоге), выберите её → Open.

  4. Vitis запомнит workspace и откроет пустое рабочее пространство.

Создаём Platform Component из XSA. «Platform» в Vitis - это контейнер, который содержит описание hardware (XSA) и BSP - Board Support Package с драйверами под xparameters.h, xil_io.h, xil_printf.h.

  1. В Vitis сверху: File → New Component → Platform.

  2. В мастере:

    • Component Name: zynq_mini_oled_platform

    • Component Location: оставить Workspace

  3. Next.

  4. Flow:Create platform from hardware specification (XSA).

  5. Hardware Design: Browse → выберите <repo>/vivado/zynq_mini_oled.xsa.

  6. Next. На следующей странице:

    • OS: standalone

    • CPU: ps7_cortexa9_0 (первое ядро ARM)

    • Compiler: gcc

    • Generate Boot artifacts

  7. NextFinish.

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. 

  1. File → New Component → Application.

  2. Component Name: oled_demo. Next.

  3. Platform: выберите zynq_mini_oled_platform (Vitis покажет уже встроенный путь до .xpfm).

  4. Domain: standalone_ps7_cortexa9_0. Next.

  5. Source Files: оставляем пустым

  6. NextFinish.

В Components появится oled_demo. Внутри будут:

  • src/ - пустая папка под исходники (шаблон Empty не добавляет helloworld.c).

  • UserConfig.cmake - список .c для сборки и путь к lscript.ld.

  • lscript.ld - дефолтный скрипт под DDR который после заменим на вариант под OCM.

Далее нам необходимо построить следующий проект по слоям: 

Файл

Назначение

lscript.ld

Куда линкер кладёт код (OCM, не DDR - для JTAG без FSBL)

i2c_master.h

Смещения регистров и биты CMD/STATUS (контракт с RTL)

i2c_master.c

Драйвер: init, write, read, опрос TIP

ssd1306.h / ssd1306.c

Протокол SSD1306 поверх i2c_write

main.c

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.

  1. В Components → oled_demo → src уже лежит файл lscript.ld от шаблона - откройте его и замените содержимое (или New File → lscript.ld, если пусто).

  2. Минимальная идея, которую вы закладываете в скрипт:

    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:

  1. TX_DATA ← {slave_addr[6:0], 0} (бит R/W = 0).

  2. CMD ← STA | WR → wait_done.

  3. Проверить STATUS.RXACK (бит 1): если 1 — слейв ответил NACK, отправить STO и вернуть false.

  4. Для каждого байта данных: 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 байт:

  1. START + адресный байт read: TX_DATA = (slave << 1) | 1, CMD = STA | WR.

  2. Для каждого байта: CMD = RD; на последнем добавить NACK | STO; 

  3. После 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».

  1. В дереве правый клик по 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):

  1. Соединится с JTAG-кабелем (connect).

  2. Прошьёт PL: fpga -file zynq_mini_oled_top.bit.

  3. Остановит CPU: targets -set -filter {name =~ "*Cortex-A9 #0"} → rst -processor.

  4. Запустит ps7_init (инициализация DDR, PLL, UART, GIC).

  5. Загрузит ELF: dow oled_demo.elf.

  6. Стартует: 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% при первом пополнении.

Воспользоваться

Комментарии (0)