Общение с ПЛИС

Помимо внутренней генерации данных на ПЛИС или их получения от периферийных компонентов платы, может возникнуть необходимость обмениваться данными напрямую с ПК. Для этого можно использовать шину PCIe. Для реализации этого взаимодействия на стороне устройства (ПЛИС) нужно настроить PCIe-контроллер, а на стороне хоста (ПК) необходимо реализовать драйвер и программу, которая будет управлять передачей данных.

PCIe (Peripheral Component Interconnect Express) — стандарт шины, предназначенный для подключения периферии к ПК. Это одновременно и протокол (набор правил, уровни, структура TLP), и физический интерфейс (разъемы). Поколения PCIe определяют максимальную скорость передачи данных по одной линии (линий может быть несколько).

Данные по шине передаются с помощью TLP (Transaction Layer Packet). Основных видов пакетов три: Memory Write (запись), Memory Read (запрос на чтение), Completion with Data (ответ на запрос). На уровне протокола TLP данные выравниваются по границе dword (4 байта), и длина любого PCIe-пакета измеряется в dword-ах.

Подробнее можно прочитать здесь, а дамп пакетов можно посмотреть здесь.

Когда линк (стабильное соединение) между устройством и хостом поднимается, начинается инициализация BAR (Base Address Register). Это регистр в конфигурационном пространстве PCIe-контроллера устройства. Он хранит базовый физический адрес пространства, которое ОС закрепляет за устройством. При обращении хоста по адресу внутри этого диапазона генерируется TLP-пакет, затем PCIe-контроллер сравнивает адрес TLP с диапазонами своих BAR и при совпадении обрабатывает запрос со смещением относительно базового адреса.

Далее пространство, за которое отвечает BAR, — окно.

Взаимодействие компонентов показано на схеме ниже. Зеленым цветом помечены компоненты, которые необходимо разрабатывать/дорабатывать самостоятельно, серым — готовые IP-ядра, предоставляемые вендором. Далее про каждый компонент будет рассказано подробнее.

pci.drawio

Конфигурация PCIe-контроллера

PCIe-контроллер использует модуль SerDes в качестве физического уровня для связи с хостом и внешними устройствами. Пользовательской логике (внутри ПЛИС) он выдает rx/tx порты с «сырыми» TLP-пакетами. Сгенерировать PCIe-контроллер и модуль SerDes можно при помощи SerDes IP: IP Core GeneratorSoft IP CoreSerDesSerDes. Далее в выпадающем списке Protocol требуется выбрать PCI Express Controller.

serdes ip core

Пробежимся по настройкам:

  1. Lane Width, Maximum Link Speed на вкладке PCIe Configuration и Max Payload Size на вкладке Core рекомендуем ставить максимальными. Они не влияют на пользовательский интерфейс контроллера, а реальные значения подберутся при установке соединения с хостом. Их можно будет увидеть с помощью команды sudo lspci -vvd 22c2:1100 (в строке LnkSta).

    1. Maximum Link Speed задает максимальную скорость соединения: Gen2 (5.0 GT/s) или Gen3 (8.0 GT/s). Скорость измеряется в гигатранзакциях в секунду.

    2. Lane Width показывает, сколько физических линий используется. Пиковая пропускная способность равняется Lane Width * Maximum Link Speed.

    3. Max Payload Size указывает максимально допустимый размер полезной нагрузки (данных) в одном TLP-пакете. Поддерживается размер до 4 КБ (чем больше пакет, тем меньше накладные расходы на заголовки).

  2. Частоту (TLP Clock Frequency) будем использовать 100 МГц, как и стоит по умолчанию.

  3. Vendor / Device ID позволяют однозначно идентифицировать плату, в нашем случае 22c2 и 1100.

  4. На вкладке BAR конфигурируются количество BAR-ов, разрядность базового адреса окна и размер окна. Всего доступно 6 BAR, но при выборе 64 бит следующий становится недоступным.

В нашем случае мы не будем напрямую взаимодействовать с «сырыми» rx/tx портами PCIe-контроллера, так как подключим их к другому IP-ядру — SGDMA. Пример работы с «сырыми» пакетами можно посмотреть в примере производителя (требуется разархивировать проект) или здесь. Там же можно позаимствовать файл описания сигналов (.cst) и логику отсчёта задержки для стабильного запуска.

Конфигурация SGDMA

DMA (Direct Memory Access) — механизм, позволяющий устройству читать и писать в оперативную память напрямую, минуя процессор. Scatter-Gather DMA — разновидность DMA, позволяющая передавать данные из разных участков памяти, используя список дескрипторов. Для выполнения этой операции хост выделяет буферное пространство в RAM и создает дескрипторы, описывающие атрибуты передачи (адреса и длины); DMA-контроллер на ПЛИС извлекает дескрипторы из памяти хоста и согласно им переносит данные[1].

Использование PCIe SGDMA IP избавляет нас от ручного разбора TLP-пакетов. Модуль подключается к PCIe-контроллеру и предоставляет пользователю два интерфейса AXI4-Stream (для чтения (H2C, Host to Card) и записи (C2H, Card to Host)). Путь для генерации: IP Core GeneratorSoft IP CoreInterface and InterconnectPCIe SGDMA. Для работы самого контроллера SGDMA используется BAR0 (через него хост настраивает передачу), а пользователю, помимо AXI-стримов, даётся доступ для управления собственной логикой через BAR2 (оба 64-битные, поэтому BAR1 и BAR3 недоступны).

В User Guide подробно описаны порты, архитектура и пайплайн работы контроллера.

Хост-программа

В хост-программе описывается весь пайплайн управления передачей данных. Сначала необходимо запросить у драйвера выделение памяти под буферы (H2C, C2H и дескрипторы для них), а затем отобразить физические адреса этих буферов и окон в виртуальное адресное пространство программы. Также требуется проверить установку линка и запустить контроллер, записав управляющие поля в BAR0-окно. Далее хост-программа формирует DMA-дескрипторы, а в BAR0-окно записывается физический адрес первого дескриптора, количество дескрипторов в первом блоке, адрес опроса для обратной связи и сигнал старта передачи. Потом необходимо в цикле считывать значение по адресу опроса (Poll Mode) и в конце отправить сигнал остановки.

При C2H-передаче в BAR0-окно необходимо дополнительно записать количество кредитов (один потраченный кредит — один обработанный дескриптор). Если дескрипторов больше, чем максимально возможное количество кредитов (1023), то можно в цикле опроса увеличивать их количество. Обратите внимание, что поле кредитов работает по принципу добавления, а не перезаписи.
С примером хост-программы можно ознакомиться здесь, а со структурой GowinBar0 здесь (порядок полей и их размеры фиксированы документацией SGDMA).

Размер каждого дескриптора составляет 32 байта, и он должен быть выровнен по 32-байтовой границе. На странице может уместиться до 128 дескрипторов (4 КБ / 32 Б). Блок дескрипторов представляет собой последовательность смежных дескрипторов. Несколько блоков дескрипторов образуют цепочку дескрипторов.

Так выглядит структура дескриптора:

typedef struct __attribute__((packed, aligned(32))) {
  uint32_t flags;       //* 0x00 - Stop[0], Eop[1], Completed[2], AdjDescNum[14:8]  (1)
  uint32_t length;      //* 0x04 - Data Length (bytes)
  uint32_t addr_src_lo; //* 0x08 - Source Low Address  (2)
  uint32_t addr_src_hi; //* 0x0C - Source High Address
  uint32_t addr_dst_lo; //* 0x10 - Destination Low Address  (3)
  uint32_t addr_dst_hi; //* 0x14 - Destination High Address
  uint32_t next_lo;     //* 0x18 - Next Descriptor Low Address
  uint32_t next_hi;     //* 0x1C - Next Descriptor High Address
} GowinDescriptor;
1 Бит Completed указывает SGDMA выполнить запись количества обработанных дескрипторов по адресу опроса в памяти хоста после обработки данного дескриптора. Рекомендуется ставить его на каждый последний дескриптор в блоке. Помимо данного флага, требуется указать либо количество дескрипторов в следующем блоке (AdjDescNum) и адрес первого дескриптора следующего блока (в next_*), либо флаг завершения работы (Stop).
2 При H2C-передаче указывается физический адрес памяти хоста, из которой SGDMA прочитает данные.
3 При C2H-передаче указывается физический адрес памяти хоста, в которую SGDMA запишет данные.
В текущей версии САПР (V1.9.12.02_SP2) при работе с IP-ядром SGDMA на хосте с порядком байтов Little Endian существует «особенность»: запись/чтение в окно работает, как ожидается, а для записи данных в RAM требуется использовать __builtin_bswap32.
Любая операция присваивания/чтения поля в структуре GowinBar0 — это генерация TLP-пакета со стороны хоста в сторону ПЛИС.

Драйвер

Без драйвера устройства хост-программа не сможет получить доступ к окну и не сможет выделить физически непрерывную память для DMA-буферов. В Linux такой драйвер реализуется в виде модуля ядра (с расширением .ko), который загружается и выгружается командами insmod и rmmod.

Для связи драйвера с устройством используется структура pci_driver. В ней содержится таблица pci_device_id, содержащая знакомые нам Vendor/Device ID, а также функция probe, которая вызывается при привязке (insmod). Данная функция отвечает за активацию устройства (pci_enable_device), предоставление ему права инициализировать транзакции на шине PCIe (pci_set_master), резервирует адресные пространства для BAR-окон и отображает их в виртуальное адресное пространство ядра (pcim_iomap_regions).

Также функция probe регистрирует символьное устройство в каталоге /dev/, связывая его со структурой file_operations. Она предоставляет пользовательской программе системные вызовы: open, close, mmap, ioctl. Например, ioctl_dma_mem_request, который вызывается перед mmap, выделяет память под DMA-буфер в ядре и возвращает её физический адрес.

На исходный код драйвера можно взглянуть здесь. Обратите внимание, что память для DMA выделяется как когерентная и отображается как некешируемая[2].
Модуль ядра нужно либо подписать, либо отключить Secure Boot в BIOS (подробнее здесь). Дополнительно может потребоваться настройка параметров в загрузчике. Основные рекомендации смотрите здесь и здесь.

Порядок действий

Шаги запуска проекта:

  1. Отключить Secure Boot в BIOS и настроить параметры GRUB на хосте.

  2. Подключить плату через USB-порт JTAG/UART к ноутбуку/ПК и загрузить .fs битстрим.

  3. Подключить прошитую плату в слот PCIe хоста и включить/перезагрузить хост.

  4. Собрать и загрузить модуль ядра. После успешной загрузки должны появиться подпись Kernel driver in use: в lspci, логи probe функции в сообщениях ядра (dmesg) и символьное устройство в каталоге /dev/.

  5. Собрать и запустить хост-программу. В случае успешной отработки программы, например в этом примере, выводится статус готовности BAR0, BAR2 и SGDMA-контроллера, отправляемые на плату и принятые с неё данные. Затем проверяется соответствие полученных данных ожидаемым, и программа завершается с кодом возврата 0.

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

Несколько замечаний из практики:

  1. Не подключать в слот PCIe непрошитую плату.

  2. После перепрошивки требуется выполнить перезагрузку хоста.

  3. После прошивки и установки платы в слот PCIe можно включать хост и отключать USB-кабель от ноутбука/ПК.

  4. Если устройство не появилось в lspci, то проблема может быть в настройках SerDes. Например, у авторов курса и в примере производителя на плате Tang MEGA 138K Pro наблюдается такая проблема при установке скорости Gen2 (5 GT/s) (несмотря на то, что при установке Gen3 (8 GT/s) в lspci отображается статус 5 GT/s (downgraded)).