Привет, Хабр! Меня зовут Владимир Заикин. Под руководством Семёна Григорьева @rsdpisuy я занимаюсь разработкой на ПЛИС в лаборатории YADRO в СПбГУ. Мой рассказ — о том, как мы реализовали общение по PCIe с ПЛИС на плате Sipeed Tang Mega 138K Pro с кристаллом GW5AST-138K от Gowin.

Устройство, реализованное на ПЛИС, должно было принимать данные по PCIe, сохранять их в DDR3 и обрабатывать пользовательской логикой. Чтобы реализовать такую функциональность, мы обратились к примеру взаимодействия с PCIe-контроллером от производителя платы, но тот оказался нерабочим. Пришлось разбираться самим. В итоге мы сделали рабочий стенд: Linux-драйвер, хост-программа на C и аппаратное описание на Verilog. Все это выложили в открытый доступ, чтобы поделиться своим опытом и помочь другим безболезненно разобраться с этой проблемой.

Введение: почему мы вообще этим занялись

Цель проекта — реализовать общение с вычислителем, основанным на модели Interaction Nets и написанным на Haskell с Clash. Но для начала нужно было научиться передавать данные по PCIe и складывать их в DDR3. Казалось бы, что может быть проще? Берешь пример от производителя и вперед. Но не тут-то было.

Производитель платы предоставил пример общения по PCIe, но он оказался нерабочим, что обсуждается в репозитории примера: хост-программа выводит статус успешного завершения передачи, но данные, получаемые по PCIe, всегда нулевые. К тому же после выхода специализированного PCIe SGDMA-контроллера от Gowin в начале 2026 года пример потерял актуальность, а нового производители не предоставили.

Мы решили использовать свежий PCIe SGDMA Gowin IP, который скрывает внутри себя всю маршрутизацию и работу с PCIe-пакетами, а на выходе предоставляет AXI4-Stream интерфейс. Но и тут нас ждали сюрпризы.

Немного теории: PCIe и DMA

PCIe — это не страшно

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

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

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

Gowin PCIe-контроллер использует модуль SerDes (Serializer/Deserializer) в качестве физического уровня для связи с хостом. Пользовательской логике внутри ПЛИС он выдает rx/tx-порты с сырыми TLP-пакетами. Эти порты мы подключили к PCIe SGDMA IP, а на выходе получили два интерфейса AXI4-Stream: для чтения — H2C, Host to Card, для записи — C2H, Card to Host. Для работы SGDMA-контроллера используется BAR0, через который хост настраивает передачу, а пользователю, помимо AXI4-Stream, дается доступ для управления собственной логикой через BAR2. Оба регистра 64-битные, поэтому BAR1 и BAR3 недоступны.

Вот так выглядит взаимодействие с BAR со стороны устройства. user_* — сигналы от PCIe SGDMA-контроллера, которые позволяют взаимодействовать с BAR2:

if (user_cs && user_rw) begin
  case (user_address)
    RegCtrl: begin
      if (user_wr_data[0]) begin // bit0: start pcie write descriptor
        m_axis_h2c_desc_valid <= 1'b1;
      end
      if (user_wr_data[1]) begin // bit1: stop  pcie write descriptor
        m_axis_h2c_desc_valid <= 1'b0;
      end
      if (user_wr_data[2]) begin // bit2: start pcie read  descriptor
        m_axis_c2h_desc_valid <= 1'b1;
      end
      if (user_wr_data[3]) begin // bit3: stop  pcie read  descriptor
        m_axis_c2h_desc_valid <= 1'b0;
      end
      if (user_wr_data[4]) begin // bit4: start logic adder
        lad_start_pulse <= 1'b1;
      end
      if (user_wr_data[5]) begin // bit5: stop  logic adder
        lad_stop_pulse <= 1'b1;
      end
    end

    RegAddrDDRh2c: begin
      m_axis_h2c_desc_addr <= user_wr_data[AXI_ADDR_WIDTH-1:0];
    end
    RegLengDDRh2c: begin
      m_axis_h2c_desc_len <= user_wr_data[AXI_LEN_WIDTH-1:0];
    end

    RegAddrDDRc2h: begin
      m_axis_c2h_desc_addr <= user_wr_data[AXI_ADDR_WIDTH-1:0];
    end
    RegLengDDRc2h: begin
      m_axis_c2h_desc_len <= user_wr_data[AXI_LEN_WIDTH-1:0];
    end

    ...

А так со стороны хоста:

GowinBar2 *gwbar2 = (GowinBar2 *)proc->gwbar2;
gwbar2->addr_ddr_h2c = PP_ADDR_LO(addr_ddr_h2c);
gwbar2->leng_ddr_h2c = size_data;
gwbar2->ctrl = BAR2_PCIE_WR_START;

А сама структура BAR2 выглядит так:

typedef struct attribute((packed, aligned(32))) {
  volatile uint32_t ctrl;      //* 0x000 - Control (RW)
  volatile uint32_t status;    //* 0x004 - Status (RO)
  volatile uint32_t rsv_08[2]; //* 0x008-0x00F - Reserved
  
  volatile uint32_t addr_ddr_h2c; //* 0x010 - PCIe Write Address (RW)
  volatile uint32_t leng_ddr_h2c; //* 0x014 - PCIe Write Length (RW)
  volatile uint32_t rsv_18[2];    //* 0x018-0x01F - Reserved

  volatile uint32_t addr_ddr_c2h; //* 0x020 - PCIe Read Address (RW)
  volatile uint32_t leng_ddr_c2h; //* 0x024 - PCIe Read Length (RW)
  volatile uint32_t rsv_28[2];    //* 0x028-0x02F - Reserved

  volatile uint32_t addr_lad_rd; //* 0x030 - Logic Adder Read  Address (RW)
  volatile uint32_t addr_lad_wr; //* 0x034 - Logic Adder Write Address (RW)
  volatile uint32_t leng_lad;    //* 0x038 - Logic Adder Length (RW)

  volatile uint32_t rsv_3c[497]; //* 0x03C-0x7FF - Reserved
} GowinBar2;

Подробнее про инициализацию BAR и общение по PCIe можно прочитать в статье Сергея Мирошниченко, руководителя группы системного программирования в YADRO.

DMA: чтобы не грузить процессор

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

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

Про Scatter-Gather DMA можно почитать здесь.

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

typedef struct attribute((packed, aligned(32))) {
  uint32_t flags;       //* 0x00 - Stop[0], Eop[1], Completed[2], AdjDescNum[14:8]
  uint32_t length;      //* 0x04 - Data Length (bytes)
  
  uint32_t addr_src_lo; //* 0x08 - Source Low Address
  uint32_t addr_src_hi; //* 0x0C - Source High Address
  
  uint32_t addr_dst_lo; //* 0x10 - Destination Low Address
  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;

После обработки дескрипторов бит Completed указывает SGDMA записать их количество в память хоста по адресу, используемому для опроса. Рекомендуется ставить этот бит на каждый последний дескриптор в блоке. Помимо данного флага, требуется указать либо количество дескрипторов в следующем блоке AdjDescNum и адрес первого дескриптора следующего блока в next_*, либо флаг завершения работы Stop. При H2C-передаче указывается физический адрес памяти хоста, из которой SGDMA читает данные. При C2H-передаче указывается физический адрес памяти хоста, куда SGDMA запишет данные.

Так выглядит создание дескрипторов на стороне хоста для передачи данных на ПЛИС:

  for (int i = 0; i < num_desc_adj; i++) {
    uint32_t flags = 0x0;
    uint64_t desc_next_a = 0x0;
    if (IS_LAST_DESC(i)) {
      uint32_t num_desc_adj_next = num_desc - (i + 1);
      flags = SET_FLAG_NUM_DESC(MODULE_DESC(num_desc_adj_next - 1));
      desc_next_a = proc->desc_src + (i + 1) * SIZE_DESC;
    };
  
    desc_h2c_p->flags = __builtin_bswap32(flags);
    desc_h2c_p->length = __builtin_bswap32(size_block);
    desc_h2c_p->addr_src_lo = __builtin_bswap32(PP_ADDR_LO(sa));
    desc_h2c_p->addr_src_hi = __builtin_bswap32(PP_ADDR_HI(sa));
    desc_h2c_p->addr_dst_lo = 0x0;
    desc_h2c_p->addr_dst_hi = 0x0;
    desc_h2c_p->next_lo = __builtin_bswap32(PP_ADDR_LO(desc_next_a));
    desc_h2c_p->next_hi = __builtin_bswap32(PP_ADDR_HI(desc_next_a));
  
    desc_h2c_p += 1;
    sa += size_block;
  }

  desc_h2c_p->flags = __builtin_bswap32(FLAG_LAST);
  desc_h2c_p->length = __builtin_bswap32(size_block);
  desc_h2c_p->addr_src_lo = __builtin_bswap32(PP_ADDR_LO(sa));
  desc_h2c_p->addr_src_hi = __builtin_bswap32(PP_ADDR_HI(sa));
  desc_h2c_p->addr_dst_lo = __builtin_bswap32(0x01234567);
  desc_h2c_p->addr_dst_hi = __builtin_bswap32(0x89ABCDEF);
  desc_h2c_p->next_lo = 0x0;
  desc_h2c_p->next_hi = 0x0;
  
  gwbar0->h2c[0].addr_desc_lo = PP_ADDR_LO(desc_h2c_a);
  gwbar0->h2c[0].addr_desc_hi = PP_ADDR_HI(desc_h2c_a);
  gwbar0->h2c[0].addr_poll_lo = PP_ADDR_LO(poll_h2c_a);
  gwbar0->h2c[0].addr_poll_hi = PP_ADDR_HI(poll_h2c_a);
  gwbar0->h2c[0].num_desc_adj = MODULE_DESC(num_desc_adj);

DDR3: куда складывать данные

Для взаимодействия с DDR необходимо сгенерировать и настроить IP-ядро DDR3 Memory Interface. Вендор дает настроить либо полноценный контроллер памяти (MC) вместе с физическим интерфейсом (PHY), либо отдельный PHY. В первом случае MC управляет PHY и предоставляет пользователю нативный или стандартный AXI4-интерфейс.

Такой интерфейс состоит из пяти независимых каналов: адрес/данные чтения, адрес/данные записи и канал подтверждения записи. Работать с ними напрямую трудоемко, поэтому мы воспользовались AXI DMA-контроллером. Он взаимодействует с DDR3-контроллером, выступая мастером на AXI4-шине. А пользовательской логике предоставляет два AXI4-Stream интерфейса, что позволяет ей работать с двумя потоками данных и дескрипторами, которые задают, откуда, куда и сколько нужно передать.

AXI DMA-контроллер работает с одним непрерывным буфером, поэтому дескриптор в этом случае — просто адрес начала и длина передачи: контроллер сам инкрементирует адрес и разбивает передачу на пакеты. Единица передачи данных в стандарте AXI — это beat: одно слово данных шириной в разрядность шины. Несколько последовательных beat могут объединяться в burst — группу данных, которая передается как одна непрерывная операция записи или чтения памяти DDR3. Размер burst, то есть количество входящих в него beat, задается настройками контроллера.

Например, при длине передачи 4096 байт и ширине шины 256 бит (32 байта) получится 4096 / 32 = 128 beat. Если размер burst — 8 beat, эти 128 beat объединятся в 128 / 8 = 16 burst: то есть контроллер выдаст DDR3 16 команд (со сменой адреса).

Драйвер: без него никак

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

Для связи драйвера с устройством используется структура pci_driver. В ней — таблица pci_device_id с Vendor/Device ID и функция probe, вызываемая при привязке драйвера к устройству.

static const struct pci_device_id gowin_tbl[] = {{PCI_DEVICE(0x22c2, 0x1100)}, {0}};

MODULE_DEVICE_TABLE(pci, gowin_tbl);

static struct pci_driver gowin_bar_driver = {
  .name = DRIVER_NAME,
  .id_table = gowin_tbl,
  .probe = gowin_bar_probe,
  .remove = gowin_bar_remove,
};

module_pci_driver(gowin_bar_driver);

Функция probe:

  • отвечает за активацию устройства (pci_enable_device);

  • отвечает за предоставление права инициализировать транзакции на шине (pci_set_master);

  • резервирует адресные пространства для BAR-окон и отображает их в виртуальное адресное пространство ядра (pcim_iomap_regions);

  • регистрирует символьное устройство в /dev/, связывая его со структурой file_operations, которое предоставляет системные вызовы: open, mmap, ioctl.

Например, ioctl_dma_mem_request, что вызывается перед mmap, выделяет память под DMA-буфер в ядре и возвращает ее физический адрес:

uint64_t request_mem(int fd, int index, uint32_t size) {
  struct gowin_ioctl_param param = {0};
  param.dma_idx = index; // номер буфера
  param.dma_size = size;
  param.dma_realloc = 1;

  if (ioctl(fd, GOWIN_REQUEST_DMA_MEM, &param)) {
    fprintf(stderr, "ioctl: %s\n", strerror(errno));
    return 0;
  }

  return param.dma_handle; // физический адрес
}

В самой функции ioctl_dma_mem_request выделяется когерентная память с помощью dma_alloc_coherent с флагом GFP_KERNEL. Функция gowin_bar_mmap, отвечающая за системный вызов mmap, обеспечивает отображение как выделенных DMA-буферов, так и BAR-окон платы в пространство хост-программы. Чтобы гарантировать согласованность данных при DMA, для этих областей отключается кеширование процессора pgprot_noncached. Связывание физических адресов с виртуальным адресным пространством пользовательского процесса выполняется функциями io_remap_pfn_range для BAR-окон или remap_pfn_range для DMA-буферов.

Проблемы, с которыми мы столкнулись

Драйвер и загрузчик

В туториале по запуску демопроекта рекомендуется использовать Ubuntu 20.04 и указать опцию iommu=pt в GRUB-загрузчике. На более новых версиях ядра сборка драйвера не работала из-за изменившегося API. Большинство таких мест компилятор указывает сам, и правки сводятся к сверке с документацией к новой версии ядра. А вот с опциями GRUB пришлось помучиться.

Сообщество уже сталкивалось с похожими проблемами, поэтому решение было в issue репозитория примера. Чтобы понять, что именно отсутствие опции pci=realloc=on являлось проблемой, понадобилось время. При этом эта опция требуется только в более новых версиях ядра — начиная с 6.17.

Демо от производителя не работает

Первая проблема: демоверсия от Sipeed не работала. В readme прямо сказано, что Gen2-проект изначально не работает, а Gen3 работает при передаче блока, описываемого дескриптором, с размером менее 4096 байт. На деле оказалось, что Gen3-проект можно запустить без исправлений только на версии ядра 5.15.

После того как удалось запустить Gen3-проект, оказалось, что программа либо зависает без какой-либо обратной связи, либо завершается мгновенно, информируя о том, что все данные были переданы. Реальная пропускная способность составила 1700% от максимальной. После изменений в хост-программе, выяснилось, что данные, которые приходят с платы, всегда нулевые, а пропускная способность на самом деле 0,6%.

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

При максимальной конфигурации SerDes IP в Gen3-проекте, 8 ГТ/с и 4 линии, линк устанавливается на более низких параметрах: Gen2, 5 ГТ/с и 2 линии. lspci помечает эти значения как downgraded — это обычная ситуация, которая работе не мешает. Однако при попытке задать в конфигурации IP-ядра значения Gen2 x2 по умолчанию устройство переставало обнаруживаться хостом. В readme демопроекта уточняется, что в Gen2-версии не всегда распознается плата. Учитывая, что единственное отличие Gen2- и Gen3-проектов — именно настройки SerDes IP, а остальной код идентичен, причину такого поведения установить не удалось.

Документация SGDMA и порядок байт

Добиться успеха с этим примером не удалось, поэтому мы переключились на работу с новым SGDMA IP. Gowin предоставляет неплохую документацию к IP-ядрам в виде руководства пользователя. К SGDMA IP вендор предоставил демопроект, но драйвер и хост-программа были уже скомпилированы в виде .exe-файлов.

Документация к SGDMA IP является по сути техническим требованием к хост-программе, и эти требования в некоторых местах недостаточно подробно описаны. Пришлось самостоятельно определять опции и порядок действий для корректного взаимодействия устройства с хостом. Например, было непонятно:

  • кто помечает дескриптор как последний (Eop) или обработанный (Completed),

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

  • в какие поля каналов в BAR0 требовалось писать, а какие содержали лишь информацию о статусе передачи.

Поскольку неверная трактовка флагов могла завершить передачу раньше времени, прояснить их окончательно удалось после того, как передача корректно завершилась. Но самой насущной оказалась проблема с порядком байт (big-endian/little-endian), о которой в руководстве пользователя не упоминалось.

Дескрипторы, как и сами передаваемые данные, лежат в RAM хоста. В нашем случае использовался хост с архитектурой x86 с порядком байт little-endian (младший байт записывается по младшему адресу). В итоге при попадании дескрипторов в SGDMA-контроллер их атрибуты неправильно интерпретировались, что приводило к некорректным обращениям к RAM и зависанию. Обнаружить это удалось с помощью GAO-анализатора и опыта работы с сырыми TLP-пакетами, который мы получили при разборе примера от производителя. А решением оказалось использование функции __builtin_bswap32 для всех данных, записываемых в RAM и передаваемых на ПЛИС.

В GAO-анализатор можно добавить сигналы, которые хочется отслеживать. Данные TLP-пакетов хранятся в pcie_tl_rx_data. После срабатывания триггера на начало запуска передачи фиксируются значения сигналов каждый такт. Зная порядок и адреса обращений к BAR из хост-программы, мы смогли обнаружить последний MWr пакет, за которым следовало чтение данных. Мы заметили, что пришел адрес 80323409, а запрос чтения пошел по адресу 80323408, то есть SGDMA-контроллер выровнял адрес. Хотя адреса уже должны были быть выровнены при выделении и с помощью атрибутов структуры в хост-программе. Получается, что SGDMA-контроллеру приходят некорректные данные. С помощью нескольких попыток мы выяснили, что это касается только чтения значений из RAM хоста.

Как это выглядело:

Первое слово pcie_tl_tx_data должно равняться первому слову pcie_tl_rx_data. К сожалению, здесь видны слова, начиная с 80343409
Первое слово pcie_tl_tx_data должно равняться первому слову pcie_tl_rx_data. К сожалению, здесь видны слова, начиная с 80343409

Запись в BAR-окно не требовала внимания к порядку байт, то есть адрес первого, управляющего дескриптора, записываемого в BAR0-окно, корректно считывался, затем отправлялся запрос на чтение из RAM хоста. Далее его атрибуты передавались обратно SGDMA-контроллеру. Тут-то и начинались проблемы.

Наше решение: как мы все собрали

Аппаратная часть

Самое важное в аппаратной части — организация обмена данными по стандарту AXI4. Базовые компоненты AXI4 мы заимствовали из открытых источников. Для взаимодействия с периферией и памятью платы использовали IP-ядра Gowin.

Зеленым цветом помечены компоненты, которые мы реализовали и доработали самостоятельно, серым — готовые IP-ядра вендора, голубым — AXI4-модули из открытых источников. Для упрощения на схеме не указаны CDC-синхронизаторы (Clock Domain Crossing),реализованные в виде асинхронных очередей (AXI4-Stream Async FIFO) между PCIe SGDMA и AXI DMA для H2C- и C2H-передач.

Взаимодействие компонентов на схеме выше:

  1. PCIe-контроллер общается с хостом через SerDes.

  2. PCIe SGDMA IP подключается к PCIe-контроллеру и предоставляет AXI4-Stream интерфейсы.

  3. AXI DMA-контроллер соединяет AXI4-Stream от SGDMA с AXI Interconnect.

  4. Пользовательская логика (в нашем случае — сумматор Adder) подключается через AXI4-Stream к еще одному AXI DMA-контроллеру, который присоединяется к AXI Interconnect.

  5. DDR3-контроллер подключается к AXI Interconnect как единственное slave-устройство.

Таким образом, мы получаем конвейер, в котором данные идут в обе стороны по симметричной схеме: хост → PCIe (SerDes) → SGDMA → AXI DMA → DDR3 → пользовательская логика.

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

Хост-программа (Host Application на схеме) описывает весь пайплайн управления передачей данных:

  1. Запросить у драйвера выделение памяти под буферы (H2C, C2H и дескрипторы) в RAM.

  2. Отобразить физические адреса буферов и BAR-окон в виртуальное адресное пространство.

  3. Проверить установку линка, прочитав регистр статуса соединения в конфигурационном пространстве PCIe, и запустить SGDMA-контроллер, записав в управляющее поле BAR0-окна (поле запуска контроллера).

  4. Сформировать DMA-дескрипторы.

  5. Записать в BAR0-окно физический адрес первого дескриптора, количество дескрипторов, адрес опроса и сигнал старта.

  6. В цикле считывать значение по адресу опроса (Poll Mode).

  7. Отправить сигнал остановки.

У  нашего архитектурного решения есть две особенности:

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

  • При C2H-передаче требуется указать количество кредитов — число дескрипторов, которые контроллер готов обработать. Максимальное значение, поддерживаемое SGDMA-контроллером, — 1023. Мы решили увеличивать это значение, когда включается режим опроса. При этом поле кредитов работает по принципу добавления, а не перезаписи.

Тестирование: что получилось

Промежуточные проверки проводились на Ubuntu 22.04 и 24.04 с ядрами 5.15, 6.8, 6.17. Итоговая версия проверялась на Ubuntu 22.04 (6.8), как на основном тестовом окружении, с параметрами GRUB: pci=realloc=on и iommu=pt.

Верификация

SerDes IP настроен на Gen3 (8 ГТ/с на линию) x4 с максимальной полезной нагрузкой 4096 байт. С помощью lspci для PCIe-устройства с ID 22c2:1100 мы узнали, что реальные параметры следующие:

  • Максимальный размер полезной нагрузки: 128 байт для записи, 4096 байт для ответа на запрос чтения.

  • Параметры PCIe-линка: Gen2 (5 ГТ/с на линию) x2 (downgraded).

Индикация

Светодиоды на плате показывают:

  • LED0 — работа ПЛИС. Мигает с постоянной частотой, используя циклический счетчик.

  • LED2 — готовность работы логики. Загорается спустя некоторое время после запуска ПЛИС.

  • LED3 — поднятие линка.

  • LED4 — успешная калибровка памяти DDR3.

  • LED5 — запуск H2C-передачи. Загорается после сигнала запуска H2C-передачи и без дополнительной нагрузки индикацию невозможно увидеть из-за скорости передачи.

Без дополнительной нагрузки индикацию передачи LED0 невозможно увидеть, потому что она выполняется слишком быстро. Для фотографии H2C-передача была подвешена. Больше всего индикация помогает понять, что передача зависла. Например, если в хост-программе отключен дамп статуса передачи.

Функциональность

Запуск нашего демо показал, что DMA-каналы и аппаратный сумматор Adder работают исправно. Заодно мы проверяли состояние системы, читая регистры BAR на разных этапах работы. Мы протестировали разные размеры буферов и варианты разбиения данных на блоки размером от 128 до 4096 байт с шагом по степеням двойки. Лучше всего работала конфигурация с буфером 2048 байт и блоками по 128 байт, описываемых дескрипторами.

Если учитывать только полностью успешные запуски программы, максимальный объем переданных данных составил 16 КБ. При этом сама H2C-передача, то есть до запуска сумматора, стабильно работает с буферами до 4 МБ включительно.

Известные ограничения

Проблему масштабируемости, а именно стабильности C2H-передачи, мы до сих пор не решили. Выделение дополнительных кредитов во время передачи не всегда работает, как и попытка разбить C2H-передачу на несколько независимых передач. Неисправность может крыться внутри SGDMA-контроллера, так как очень редко он справляется с C2H-передачей размером более 8 КБ. Мы продолжаем ее изучать, чтобы измерить максимальную пропускную способность. Если у кого-то из читателей есть опыт с работы с SGDMA IP или гипотезы насчет источника проблемы, то будем рады вашим рекомендациям в комментариях!

Что мы вынесли из этого опыта?

Версия ядра имеет значение. На новых версиях ядра (6.8, 6.17) потребовались исправления из issues. На версии 5.15, которую использовали разработчики демо, эти исправления не потребовались. После попыток исправить демопроект стоило бы попробовать воспроизвести точное окружение разработчиков, но мы решили, что проблема кроется в драйвере или хост-программе.

Надо использовать свежие IP-ядра. Gowin регулярно обновляет IDE и добавляет новые IP-ядра. Одним из таких оказался PCIe SGDMA IP. Он скрывает всю низкоуровневую работу с TLP и дает чистый AXI4-Stream интерфейс, что и требовалось в нашем случае. Сейчас у вендора мало IP-ядер с AXI4-интерфейсом, но, например, совсем недавно, в версии 1.9.12.03, добавили AXI_UART. Так что следите за обновлениями и не забывайте обновлять Gowin IDE до актуальной версии.

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

LLM — не враг, но хороший союзник. LLM не решили ни одной из описанных проблем напрямую, но были полезны как источник гипотез и способ проверить направление поиска. Модель один раз указала на проблему с порядком байт как на вероятную причину зависаний, но мы отклонили эту версию. Нам казалось маловероятным, что проблема именно в порядке байт, а не в драйвере, конфигурации GRUB и IP-ядрах или порядке обращения к регистрам контроллеров в хост-программе. Модель согласилась с нашими контраргументами про запись в BAR-окно и отступила от своей версии.

Всегда можно быть внимательнее. За полгода мы прошли путь от краша ПК и проблем из-за GRUB-опций до рабочего проекта, который уже помог нескольким людям. На каждом шаге причина оказывалась новой, хотя казалось, что уже все было перепробовано. Это же касается и документации: то, что не нашлось при первом чтении руководства пользователя или issues, может обнаружиться при повторном, а в нашем случае даже при пятом чтении. Потому что либо что-то пропустили, либо вышло новое обновление IP-ядра или новый issue.

Вместо заключения

Весь этот путь — от нуля до рабочего стенда с PCIe, DDR3 и пользовательской логикой — реально пройти за полгода без опыта работы с ПЛИС и драйверами Linux. Надеемся, наша статья сэкономит кому-нибудь несколько месяцев. ПЛИС от Gowin — отличная платформа для обучения и разработки, но, как и любое сложное устройство, требует терпения и внимания к деталям.

Если вы делаете что-то похожее — пользуйтесь нашим опытом. Весь код и схемы — в открытом доступе. Удачи!

Полезные ссылки

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