Меня зовут Сергей Мирошниченко. Я руковожу группой системного программирования в YADRO. Мой рассказ будет о том, как мы решили запустить AMD GPU на ПЛИС с RISC-V и Linux.
Началось все с задачи проверить, насколько хорошо работает PCIe-подсистема нашей СнК. Для этого мы взяли гарантированно рабочие NVMe и видеоускоритель с полным комплектом ПО для них (драйвера в ванильном ядре Linux, двоичные файлы прошивок).
Когда мы говорим о высокоскоростной периферии, то, как правило, речь идет о серверных платформах и ПК, а значит, чаще всего это архитектура x86. Но её внутреннее устройство, особенности, условия работы могут отличаться (и отличаются!) от тех вариантов архитектуры СнК, с которыми мы работаем. Все рассмотренные тезисы применимы не только к RISC-V, но и к любой другой архитектуре.
Все познается в сравнении
Начнем с теории, а именно с краткого экскурса в PCI-Express и драйверы. PCI-Express, или PCIe (Peripheral Component Interconnect Express) — это неотъемлемая часть всех серверных платформ. Согласно новой спецификации профилей RISC-V машина не может называться серверной, если в ней нет PCIe. Потому мы решили узнать, как и насколько хорошо архитектура RISC-V в общем и наши СнК в частности совместимы с известными нам картами при подключении через PCIe.
PCIe мы используем, когда нам нужно подключить внешнюю периферию, имеющую б��льшие возможности, чем внутренние блоки системы на кристалле. Это может быть высокоскоростной Ethernet с поддержкой InfiniBand и RDMA или какой-нибудь накопитель с более широкими возможностями, чем простая SPI-флешка с прошивкой или моднейший eMMC. Нам может, например, понадобиться либо объёмный шпиндельный диск, либо NVMe, и даже не один, а целая их корзина.


PCIe завоевал сердца людей за счет своей быстроты и удобства. Под удобством можно подразумевать разные вещи: все познается в сравнении.
Возьмем, например, простую и многим знакомую шину i2c. В API ядра Linux операция чтения из устройства по i2c состоит из двух связанных сообщений: обращение к устройству по его адресу с одновременным запросом на чтение, затем чтение отправленных в ответ данных. Сообщения эти представлены в виде массива — в данном случае из двух элементов ([0], [1]) — и заполняем мы их адресом самой шины, адресом устройства на ней, флагами, адресами и длинами буферов.
Структура программного чтения у i2c:
// linux/drivers/net/phy/sfp.c
int sfp_i2c_read()
{
msgs[0].addr = bus_addr;
msgs[0].flags = 0;
msgs[0].len = 1;
msgs[0].buf = &dev_addr;
msgs[1].addr = bus_addr;
msgs[1].flags = I2C_M_RD;
msgs[1].len = len;
msgs[1].buf = buf;
i2c_transfer(sfp->i2c, msgs, ARRAY_SIZE(msgs));
}
С устройствами на шине i2c мы взаимодействуем не напрямую, а посредством набортного i2c контроллера: передаем данные из структуры по одному байту и записываем их в FIFO. Характерная скорость i2c — ~100 кбит/с. Основную часть тактов процессора мы тратим на механическое заполнение полей этой структуры, чтобы потом драйвер i2c-контроллера разобрал их и отправил побайтно в шину. То есть каждый раз, когда хотим прочесть 4 байта, нужно двухэлементную структуру заполнить и пропустить через слои абстракции, затрачивая на это ресурсы процессорного ядра.
FIFO i2c — это регистр внутри i2c-контроллера, который для программиста выглядит как память. Он занимает какой-то адрес в физическом адресном пространстве, который намертво прибит еще на этапе RTL-дизайна. Мы помогаем драйверам Linux его узнать специальными средствами описания аппаратуры (через Device Tree или ACPI).
Общение с устройством происходит через семейство функций read*()
и write*()
, которые раскрываются в единственную ассемблерную инструкцию обращения к памяти: либо load byte
в примере, либо store byte
(плюс барьеры при необходимости):
// drivers/i2c/busses/i2c-imx.c
imx_i2c_read_reg(i2c_imx, reg)
{
return readb(i2c_imx-base + (reg << i2c_imx>regshift));
}
i2c_imx_read(i2c_imx, msgs)
{
msgs->buf[i] = imx_i2c_read_reg(i2c_imx, IMX_I2C_I2DR);
}
Чем же лучше наш PCIe?
В отличие от i2c у PCIe есть три способа взаимодействия с устройствами.
У первого способа (простого) есть много общего с i2c: мы прописываем разную информацию о подключенном устройстве в похожих на i2c структурах.
У каждого PCIe-устройства, а также у самого слота, куда мы вставляем это устройство, всегда есть набор конфигурационных регистров. Почти все они задокументированы в основной спецификации. Часть их обязательна, остальные используют в зависимости от наличия поддержки расширений, и ещё немного выделено под специфику от производителя. Из обязательных регистров можно прочесть статус линка (поднят/не поднят), его ширину и поколение, наличие ошибок, команды для управления питанием (опционально) и прочее. Кроме того, обязательные регистры Vendor ID и Device ID позволяют сопоставить подключенную карту с нужным драйвером.

У каждого устройства есть свой номер на шине. В случае i2c на ней помещается 127 устройств. Утилита i2cdetect
в Linux показывает по какому адресу есть что-то живое: она поочередно отправляет команду записи нулевой длины на какой-то адрес, и, если на той стороне есть кто-то «живой», в ответ придет acknowledge.
PCIe может делать примерно то же самое: при нумерации на старте он может опросить 256 возможных номеров на шине и запросить у них чтение нулевого регистра с Vendor ID и Device ID. А если при запросе на чтение нулевого регистра никто не отвечает, то придут единичные биты: устройство не определено — драйвер не подключен.
Как можно догадаться, этот способ прост в использовании, похож на i2c, крайне медленный и используется только для инициализации, онлайн-диагностики, управления питанием, отслеживания возникающих ошибок, lane margining, но не для передачи больших объёмов данных.
Что такое lane margining и как оценить качество PCIe-соединения без дополнительной аппаратуры, читайте в статье.
Подключение NVMe через PCIe выглядит так:
// drivers/nvme/host/pci.c
nvme_pci_enable(dev)
{
if (readl(dev->bar + NVME_REG_CSTS) = -1)
{
result = -ENODEV;
goto disable;
}
____
pci_read_config_word(dev, PCI_STATUS, &pci_status);
Второй способ поинтереснее: он похож на прямое общение с набортной периферией. Внутренние регистры устройства (статусный NVME_REG_CSTS
в примере) проецируются прямо в адресное пространство машины. Здесь драйвер NVMe в начале работы читает один из регистров NVMe-контроллера. В случае проблем — например, если пропадает линк или диск выходит из строя, — подсистема PCIe возвращает все единичные биты, 0xffffffff
, отсюда сравнение с -1.
Функция readl()
разворачивается в единственную машинную инструкцию обращения к памяти. То есть этот способ взаимодействия PCIe с устройствами подразумевает, что нам не требуется особенных машинных конструкций и многослойных API. Если мы хотим прочитать какой-либо регистр или память PCIe-устройства, мы обращаемся напрямую к ним теми же средствами, что и к основной памяти.
У каждого PCIe-устройства может быть до шести таких участков памяти/регистров. Для простоты их называют BAR — base address register. Этот регистр содержит адрес, размер и требуемое выравнивание этого участка памяти. В отличие от внутренней периферии СнК, адреса устройства при подключении через PCIe будут динамическими и появятся при подключении. Мы можем подключить карточку или NVMe, они появятся в runtime, и Linux назначит их BAR’ам адреса «на ходу».

Адрес BAR в Linux — виртуальный, как и остальные адреса, используемые ядром ОС и драйверами. Аппаратный MMU тран��лирует их в физические. Разработчики СнК располагают в 56-битном (в случае RISC-V) адресном пространстве физических адресов отдельный регион, в котором ОС будет программно назначать BAR’ы устройств — чаще даже два таких региона, но об этом позже. Регистры самого PCIe-контроллера (он же Root Complex) живут отдельно, обычно рядом с другими контроллерами СнК, такими как USB, SPI, i2c и пр. И с их помощью, например, происходит обращение к конфигурационным регистрам подключенного устройства.
Когда процессорное ядро обращается «наружу» к основной памяти, к встроенной периферии СнК или к BAR’ам внешних устройств, системная шина (Interconnect) с «зашитой» в нее картой памяти маршрутизирует обращение по физическому адресу: откуда взять и куда доставить команды/данные. Это позволяет взаимодействовать с устройствами ровно теми же машинными инструкциями load
и store
, как и с памятью, без необходимости дополнительно обозначать номер устройства на шине (BDF) и прочие аргументы. Не требуется ничего кроме адреса — именно поэтому PCIe настолько удобен в использовании.
BAR’ы Radeon и путь опрометчивых решений
Когда мы подключаем устройство, PCIe-подсистема ядра Linux выбирает ему адреса BAR’ов в пространстве машины и сообщает этот адрес обратно в устройство. Устройство знает, где оно находится относительно других и где находится основная память машины (например, DDR), чтобы в нее обращаться.
У видеокарты Radeon есть несколько видов BAR. Мы начнем с видеопамяти (VRAM). Стандартный механизм Resizable BARs позволяет выбирать размер BAR, и если он не помещается в выделенный регион адресного пространства машины целиком, то можно его ограничить. Стандарт VESA требует минимальный размер видеопамяти 256 МиБ, иначе драйвер Linux amdgpu
откажет в инициализации.
Первым устройством, которое мы подключили к PCIe, был не ускоритель AMD GPU, а совершенно непривередливый и непрожорливый NVMe. Ему хватало BAR размером в ~16 килобайт. Этот объем не зависит от объема накопителя — он может быть и сотни мегабайт, и десятки терабайт.

/proc/iomem
) видно, где находится оперативная память машины System RAM
. Обратите внимание, что начало оперативной памяти в нашем случае не влезает в 32 бита, ее начало лежит за пределами первых четырех гигабайтПо карте физических адресов этого стенда можно увидеть, что под BAR PCIe-устройств отведено 240 МиБ, чего с головой хватило для NVMe, но не хватило для AMD GPU с «минимальными» 256 МиБ. Можно было этот регион расширить, но последствия нас не устраивали: надо было бы подвинуть всю периферию спереди и сзади этих адресов. Если бы мы вдруг захотели воспользоваться фичей Resizable BARs, то надо было бы спроецировать все 4 ГиБ (а иногда и 24ГиБ!) памяти видеокарты.
Мы попросили RTL-инженера перенести все BAR’ы в 64-битное пространство, где было возможно организовать тот объем памяти, который нам нужен. VRAM видеокарты туда спокойно влезла, но возникла новая сложность.
Только что мы рассмотрели кешируемый (prefetchable) тип BAR, который работает как память. Второй тип BAR, который есть у AMD GPU, — это некешируемый тип (non-prefetchable). Зачастую такой тип используется для mmio, в котором располагаются регистры. Взять, к примеру, такой вид регистров как FIFO — каждое чтение/запись из него имеет побочные эффекты: эти операции не только копи��уют данные в эти регистры, но и меняют внутренний счетчик устройства. С такими регистрами мы не можем применять кеширующие техники и прочий read-ahead.
Еще при создании предшественника PCIe, PCI, решили, что адреса некешируемых BAR’ов будут ограничены 32 битами. А ведь мы только что переместили весь регион за пределы первых 4 ГиБ физических адресов, и такие BAR’ы невозможно туда поместить.
Для обхода этой проблемы RTL-инженер «добавил второй AXI Slave», вернул нам место для 32-битных BAR’ов в первых четырех гигабайтах и оставил очень много места для огромных 64-битных BAR’ов. Все три типа BAR’ов (в том числе и Doorbell, речь о котором пойдет позже) у нас влезли!


Когда мы находимся в ядре процессора, в hart’е если говорить в терминологии RISC-V, мы преобразуем виртуальный адрес в физический и передаем эту транзакцию в системную шину, где объединены все остальные компоненты СнК. Именно системная шина решает для кого была транзакция: для PCIe, для памяти или для i2c-контроллера. Эта маршрутизация производится по адресу, к которому обратился драйвер, — он и есть точка назначения. Допустим, шина видит, что это адрес BAR, значит его надо отправить в PCIe-контроллер, он же Root Complex, а дальше он сам как-нибудь разберется.
Из Root Complex мы выходим в физику, в страну lane’ов, дифференциальных пар и коммутаторов. Общение с физикой (PCIe fabric) пакетное — посредством TLP (transactional layer packet), пакетов наподобие Ethernet или Wi-Fi. Физика PCIe понимает, для кого транзакция, все по тому же адресу, полученному из драйвера.
Важное уточнение: мы рассматриваем простую ситуацию, когда нет блока IOMMU, единственное предназначение которого — испортить весь вышеописанный процесс: добавить ещё одну прослойку виртуальных адресов, изолировать пакеты и сделать все безопасным.
Семантика пакета TLP здесь — чтение из памяти, или memory read (MRd). Запрос передается из Root Complex и доходит до условного NVMe в примере ниже.
В ответ нам приходит тоже TLP, но уже новый, другого типа: completion with data (CplD) или ответ с данными. Возвращается ответ по тому же маршруту, по которому шел запрос. Когда ответ получен и записан в регистр общего назначения процессора (механика инструкции load), операция заканчивается.
Помните, что у PCIe три способа общения с устройствами? Вот и третий способ — когда не мы ходим в устройство как в память, а само устройство ходит напрямую в память машины (DMA — direct memory access)!

Все как в прошлой операции, когда ядро процессора обращалось к адресу NVMe и получало ответ от него. TLP типа memory read (MRd) передаются в память, а в «железку» приходит ответ c данными: тип TLP преобразуется в completion with data (CplD).
Приручаем кольцевые буферы и очередь команд
Процессор использует кольцевые буферы, чтобы давать команды устройствам. В них машина накладывает свои команды, а подключенное устройство по завершении всех команд накладывает туда статусы. Например, прошла ли операция успешно, а если нет, то почему именно: неверно сформированная команда, запись в область только для чтения, несовпадение контрольной суммы и другие причины.
О формате такого общения еще нужно договориться, и для каждого устройства есть свой собственный «договор». У NVMe есть спецификация, в которой подробно описано, как происходит общение между устройством и CPU, в том числе по байтам: как раскладывается очередь команд, какой код операции отвечает за запись блока, а какой за чтение, каким образом формировать статус и что означает число статуса. Под кольцевые буферы мы выделяем место в DDR. После инициализации устройства, мы записываем в нужные регистры адреса начала этих буферов в памяти и их размер.

Примерно то же самое происходит с видео- и с сетевой картой. «Железки» знают, куда им ходить за командами.
Мы запускаем, например, приложение dd для «сырой» записи в накопитель, ядро Linux и драйвер NVMe разбивают поток данных на порции, для каждой порции дописывается команда в кольцевой буфер, а когда в этот буфер от накопителя приходят ответы со статусами, то об этом сообщается приложению.
Тут мы вспоминаем про BAR Doorbell. Когда мы заполнили кольцевые буферы командами и данными приложения, мы просим PCIe-устройство прийти, как бы «звоним ему в дверь» записью в этот BAR. После Doorbell-звонка, «железка» идет по DMA в кольцевой буфер, минуя основной процессор. Она читает команды, выполняет input/output, обновляет статусы. Затем надо попросить CPU прочитать статусы и убедиться в том, что команды были выполнены.

Doorbell нужен, чтобы сообщить карте о необходимости перечитать буфер команд, но есть и обратный механизм — MSI (Message Signaled Interrupts), чтобы сообщить машине, что пора перечитать буфер статусов. «Железка» отправляет TLP MemWr с адресом в таблице прерываний в Root Complex по особенному адресу, а это позволяет машине очень быстро понять, какому драйверу на каком процессорном ядре это прерывание предназначено (как правило тому же, которое отправило команду).

В самом начале у нас был простой PCIe-контроллер с готовым «ванильным» драйвером из основного репозитория Linux. Мы подключили NVMe, Linux его увидел, и все было хорошо, пока мы не запустили программу fio
. NVMe тут же отвалился с таймаутами.
Что произошло? Утилита сгенерировала поток данных, а Linux с драйверами нарезали этот поток на куски и для каждого куска заполнили команды, отправили их NVMe, позвонив в Doorbell. Он все принял, выполнил все команды, а потом начал отдельно отчитываться по каждой. У нашего PCIe-контроллера FIFO событий было ограничено 8 сообщениями (MSI), потому в один момент он порвался.
За счет очереди команд PCIe позволяет устройству выполнять несколько команд параллельно. Кроме того, несколько процессорных ядер могут независимо друг от друга отправлять устройству свои команды, то есть дописывать их в буфер. У каждого устройства есть глубина очереди — сколько параллельных запросов оно может выполнять. Эту очередь мы сократили с 1024 до 8, дабы наш контроллер так не надрывался.
Также этот PCIe-контроллер не умел восстанавливаться от такой ошибки без сброса. И готовый Linux-драйвер не фиксировал и не обрабатывал ошибки: в регистрах аппаратурой выставлялись биты ошибок, но их никто не читал.
А ещё обработчик прерываний в этом драйвере извлекал события из FIFO по одному, то есть реакция на каждый MSI начиналась с первичного trap_handler()
, что ещё более замедляло ввод/вывод и без того не очень быстрой ПЛИС.
Нет кеша — нет проблем
Теперь, когда подсистема Linux PCIe посчитала карту достаточно готовой к передаче драйверу amdgpu
, мы столкнулись с менее очевидными проблемами, их уже пришлось отлаживать всерьёз.
Проблема раз. У нас была память DDR, в ней мы могли взять буфер, заполнить его и попросить устройство за ним прийти. Этот буфер существовал в двух копиях: помимо памяти он еще лежал в кеше процессора, уже в актуальной обновленной форме (при записи, а при чтении — наоборот). Задача — обеспечить синхронизацию этих двух копий: чтобы то, что лежало в кеше, вовремя передавалось в память, когда туда приходит «железка».
Проблема два. Процессор мог вычитать память в кеш до того, как в эту память запишет устройство. То есть драйвер пребывал в цикле ожидания ответа, а в это время процессор мог применить свои спекулятивные технологии, например, технику read-ahead, потому что заметил, что драйвер дальше читает память. Поэтому CPU услужливо загружал эту память в кеш до того, как это записывала в DDR «железка».

Решений было тоже два.
Первое — на аппаратном уровне с использованием сущности под названием «когерентный порт». Он автоматически отслеживал несогласованность между памятью и кешем, самостоятельно без явного указания драйвером/ядром ОС это исправлял — обновлял устаревшие данные.
Второе: на уровне драйвера аккуратно вручную заворачивать каждое обращение к устройству в Linux DMA API при каждом обращении. То есть мы должны были сделать cache flush перед отправкой любых данных в устройство и cache invalidate после принятия любых данных из устройства. При должной аккуратности можно в обоих случаях обойтись только cache flush, который для чистой линии кеша эквивалентен инвалидации. А одновременная запись в одну и ту же область памяти и машиной, и картой — это логическая ошибка.
// https://lkml.org/lkml/2018/5/18/979
dma_map_single(, dir)
dma_sync_single_for_cpu(, dir)
dma_sync_single_for_device(, dir)
dma_unmap_single(, dir)
/*
* map for_cpu for_device unmap
* TO_DEV writeback none writeback none
* TO_CPU invalidate invalidate* invalidate invalidate*
* BIDIR writeback invalidate writeback invalidate
*/
Один и тот же драйвер одновременно использует оба способа. Драйвер видеокарты вызывает специальное Linux API dma_alloc_attrs()
, которое должно вернуть нам когерентную память — такую, где синхронизация между «железкой», DDR и кешем обеспечивается либо когерентным портом, либо не нужна по другим причинам.
// linux/drivers/gpu/drm/ttm/ttm_pool.c
static struct page *ttm_pool_alloc_page(..., unsigned int order)
{
vaddr = dma_alloc_attrs((1ULL << order) * PAGE_SIZE,
&dma->addr, gfp_flags, attr);
Когда у процессора нет когерентного порта, мы вынуждены избавляться от кеширования таких буферов. Это можно сделать двумя способами:
Если наш процессор может динамически менять атрибуты, мы выделим нужный нам буфер памяти, а затем снимем с него атрибут кешируемости.
На запуске мы обозначаем какой-то кусок памяти как некешируемый.
Мы выбрали второй способ, так как он проще и поддерживался нашей системой. Более того, у Linux под это есть готовая инфраструктура shared-dma-pool
, куда мы передаем все атрибуты в Device Tree через OpenSBI, где происходит настройка регионов памяти и physical memory protection. И с помощью свойства mmio
мы даем знак о том, что в выделенном куске памяти кешей быть не должно.
// Device Tree
reserved-memory {
linux,dma@0 {
compatible = "shared-dma-pool";
reg = <UPPER32(BASE) LOWER32(BASE)
UPPER32(SIZE) LOWER32(SIZE)>;
linux,dma-default;
no-map;
}
}
opensbi-domains {
compatible = "opensbi,domain,config";
dma_mem: dma_mem {
compatible = "opensbi,domain,memregion";
base = <UPPER32(BASE) LOWER32(BASE)>;
order = <LOG2_DMA_SIZE>;
mmio;
};
Для NVMe этого было достаточно, с его подключением к PCIe больше проблем не возникло. Но AMD GPU оказалась значительно требовательней…
Негодные адреса
После dma_alloc_attrs()
идет крайне интересный комментарий:
// linux/drivers/gpu/drm/ttm/ttm_pool.c
static struct page *ttm_pool_alloc_page(..., unsigned int order)
{
vaddr = dma_alloc_attrs((1ULL << order) * PAGE_SIZE,
&dma->addr, gfp_flags, attr);
/* TODO: This is an illegal abuse of the DMA API, but we need to rework
* TTM page fault handling and extend the DMA API to clean this up.
*/
struct page *p;
if (is_vmalloc_addr(vaddr))
p = vmalloc_to_page(vaddr);
else
p = virt_to_page(vaddr);
Этот код был добавлен в 2021 году и актуален до сих пор. За пару лет до этого один из драйверов, который использовал такую же модель поведения, мейнтейнер DMA отметил как broken, и он был «проклят» до тех пор, пока его не починили. Также его нельзя было просто так включить в меню конфигурации Linux:
https://lore.kernel.org/linux-mm/20190614134726.3827-13-hch@lst.de/
Subject: [PATCH 12/16] staging/comedi: mark as brokencomedi_buf.c abuse the DMA API in gravely broken ways, as it assumes it
can call virt_to_page on the result, and the just remap it as uncached
using vmap. Disable the driver until this API abuse has been fixed.Signed-off-by: Christoph Hellwig
Правильным путем обхода этой проблемы было бы починить этот большой графический стек, но для этого нужен был линуксоид-«графист» и время. У нас не было ни того, ни другого, поэтому мы начали искать более быстрое решение. Качество уровня «костыль» нас бы устроило для целей проверки нашей аппаратуры.
Проблемный код желал получить структуру struct page
(64-байтная структура с метаданными для страницы размером 4КиБ или больше) для адреса, полученного из Linux DMA API. Это хорошо работает на x86, потому что там вся память когерентная, и DMA API возвращает такой же адрес, который мог бы прийти из обычного аллокатора.
А если такой аппаратной возможности нет и используется shared-dma-pool
, то адрес возвращается после ioremap()
. Он виртуальный, но без соответствующей struct page
. И virt_to_page()
для такого адреса возвращает что-то похожее на виртуальный адрес, если внимательно не вглядываться. На самом деле он ведет совсем не туда, куда надо. Нам повезло это вовремя заметить, потому что адрес указывал за пределы ядерных диапазонов и работа драйвера останавливалась исключением о некорректном адресе.
Лечили мы эту проблему разновидностью shared-dma-pool
драйвера — CMA (Continuous Memory Allocation). Он тоже резервировал в нашей оперативной памяти место, которое отведено только под DMA, и мы все так же просили OpenSBI снять атрибут кешируемости с этого региона, но теперь возвращаемые адреса имели struct page
.
// Device Tree
reserved-memory {
linux,dma@0 {
compatible = "shared-dma-pool";
reg = <UPPER32(BASE) LOWER32(BASE)
UPPER32(SIZE) LOWER32(SIZE)>;
linux,cma-default;
reusable;
};
}
Графическая система Linux (GPU/DRM) запрашивала у ПЛИС по одной странице когерентной памяти размером 4 КиБ. В ванильном виде драйвер CMA просто отказывался это делать. «Не для этого я был создан», — говорил CMA, потому что он был рассчитан на бо́льшие куски памяти. Нам пришлось немножко напарить Linux. Именно это помогло нам запустить нашу карточку и демо OpenGL на ней, но об этом ниже.
diff --git a/kernel/dma/contiguous.c b/kernel/dma/contiguous.c
index 055da410ac71..4d1f636d165a 100644
--- a/kernel/dma/contiguous.c
+++ b/kernel/dma/contiguous.c
@@ -360,7 +360,7 @@ struct page *dma_alloc_contiguous(dev, size, gfp)
if (dev->cma_area)
return cma_alloc_aligned(dev->cma_area, size, gfp);
if (size <= PAGE_SIZE)
- return NULL;
+ size = PAGE_SIZE;
Пока мы делали отладку CMA, все перестало загружаться, причем довольно серьезно. Эту проблему можно было довольно легко избежать, если бы мы использовали ядро поновее. Когда мы приступили к работе, мы взяли не самое свежее ядро, но со всеми нужными нам кодами и драйверами.
Есть люди, которые намеренно избегают обновлений ядра, чтобы не сталкиваться с новыми багами, но тем самым они упускают релизы с фиксами и тратят время на отладку уже решенных проблем.
Мы уяснили: под рукой лучше держать несколько версий ядра Linux. На случай если вдруг что-то не будет работать в старой версии, можно подтянуть свой код на новую версию — может повезти и там уже все будет исправлено.
И вот ещё одно ружье выстрелило: вы помните, что адрес начала оперативной памяти в нашем стенде не влезает в 32 бита и ее начало лежит за пределами первых четырех гигабайт? А ведь при этом находятся места в драйверах, требующие с помощью флага GFP_DMA32
выделение памяти именно в первых 4ГиБ. Есть такое место и в драйвере amdgpu
— о нем рассказывал наш коллега Александр Разинков в своей статье про похожую видеокарту на похожей ПЛИС. Но нам повезло: наша видеокарта поддерживала 40-битные физические адреса, поэтому флаг можно было безболезненно убрать:
--- a/drivers/gpu/drm/ttm/ttm_device.c
+++ b/drivers/gpu/drm/ttm/ttm_device.c
@@ -96,7 +96,7 @@ static int ttm_global_init(void)
ttm_pool_mgr_init(num_pages);
ttm_tt_mgr_init(num_pages, num_dma32);
- glob->dummy_read_page = alloc_page(__GFP_ZERO | GFP_DMA32 |
+ glob->dummy_read_page = alloc_page(__GFP_ZERO |
__GFP_NOWARN);
Драйвера пишутся для популярных платформ, как правило, для x86, поэтому вызываемые методы в них могут спрашивать одно, а драйвера будут отвечать совсем другое. Наш AMD драйвер использовал графическую функцию ttm_device_init()
, в которой задавалось два вопроса:
Вам DMA-когерентные адреса подавать или обычного
alloc()
будет достаточно?Нужны ли вам 32-битные адреса или подойдут любые?
// linux/drivers/gpu/drm/ttm/ttm_device.c
* @use_dma_alloc: If coherent DMA allocation API should be used.
* @use_dma32: If we should use GFP_DMA32 for device memory allocations.
int ttm_device_init(..., bool use_dma_alloc, bool use_dma32)
В первом случае драйвер отвечал на вопрос: «Нужен ли вам SWIOTLB — промежуточный буфер в первых 4ГиБ памяти на тот случай, если не вся оперативная память доступна подключенному устройству?» Такое может произойти, если основная память машины выползает за рамки данных 40 бит. Если вдруг userspace выделит буфер в «дальней», недоступной карте части памяти, то карта не сможет туда достучаться. Нашей карте такой буфер не нужен, поэтому драйвер говорит «нет».
Но вопрос был совсем о другом… Адрес в итоге выделяется обычным alloc()
, и у нас ничего не работает, так как память не согласована с кешем. Наши команды перестают в полной мере доходить до видеокарты.
Во втором аргументе ttm_device_init()
спрашивает, требуется ли использовать только 32-битные адреса для DMA, а драйвер amdgpu
снова отвечает на другой вопрос, из-за чего графическая подсистема пытается выделить невозможную память.
Так как первичным для нас в этой задаче было проверить наш PCIe-контроллер, то поиск красивого решения этой проблемы мы отложили на попозже, и на первое время обошлись костылем:
@@ -1829,8 +1829,8 @@ int amdgpu_ttm_init(struct amdgpu_device *adev)
r = ttm_device_init(
- adev->need_swiotlb,
- dma_addressing_limited(adev->dev));
+ true,
+ false);
Заключение
Теперь к насущным вопросам: кто-то в здравом уме будет запускать AMD GPU на ПЛИС? Вряд ли кто будет, но мы поняли, что от этого может быть польза. Если у вас есть ресурсы, руки и время, вашу новую платформу надо попытаться использовать в самых необычных ситуациях. Нам, например, удалось найти странный микроархитектурный баг, когда происходило исключение при трансляции вполне корректных виртуальных адресов.
Подключайте к вашей платформе самое странное и несовместимое с ней железо — это путь больших открытий!
Итоги работы

Статья написана по мотивам доклада. Его запись можно посмотреть на Rutube и YouTube.
nerudo
Интересно, но какая-то каша намешана, извините. Вроде про запуск GPU, но откуда-то сперва выплывает I2C. Потом появляется PCIe, преимущества которого перед I2C не слишком убедительны (если верить написанному). Вдруг берется NVMe. И главное, вы взяли VCU118, RTL-инженер сделал на ней какую-то магию, на которую периодически идут ссылки. Но что там сделано - структура, адресное пространство, откуда взялся RISC-V в конце-концов - все спрятано под большим синим квадратиком с надписью "ПЛИС"...