Привет! Меня зовут Данил, я разрабатываю прикладное ПО для радиочастотных систем YADRO. В этой статье я расскажу об одном из вариантов сбора данных AXI-Stream для обработки на CPU, рассмотрю в этом контексте возможности и требования блока AXI DMA, а напоследок порассуждаю о когерентности кешей и о том, что на самом деле здесь требуется от драйвера ядра.

Что нужно сделать

Мы работаем на платформе Zynq US+ (Ultra Scale Plus). Это SoC, система на чипе, где в одном корпусе объединены две части: PS и PL, Processing System и Programmable Logic. PL — это не что иное, как FPGA, или ПЛИС. В PS находится ARM-процессор, который умеет только читать память, считать и писать в память. SoC сделана по технологии Memory Mapped IO: некий диапазон адресов выделен под ПЛИС, и когда процессор пишет или читает из этих адресов, он попадает в IP-блоки.

В контексте телекома каждый IP-блок реализует некий алгоритм цифровой обработки сигналов. Между собой IP-блоки общаются по протоколу AXI-Stream:

Tvalid сообщает, что передающий блок сейчас действительно передает данные. Tready — что принимающий блок готов сейчас эти данные принимать. Tdata — это сами данные. Tlast сигнализирует о последнем такте в пакете.

В самом начале разработки алгоритма DPD мне нужно было поймать эти данные, то есть сделать данные из AXI-Stream доступными для CPU.

Как ловить данные AXI-Stream

Задача эта не уникальна, есть уже готовые решения.

AXI-Stream FIFO. IP-блок готов принимать пакет из AXI-Stream, и потом из его регистров можно вычитывать данные. Но здесь есть жесткое ограничение по объему — до 8 МБ. Кроме того, он использует Block RAM без возможности изменения на Ultra RAM, а ресурс BRAM ценится высоко. Сколько данных точно получится у меня, я не узнаю, пока не попробую. По предварительной оценке мне нужно было 1–20 МБ, так что FIFO не подходил.

AXI-DMA. В моем случае данные поступают сразу в DRAM, и поэтому мы решили использовать DMA. Здесь от CPU требуется только конфигурация и ограничения объема намного мягче — до 64 МБ. Это нам подходит.

Свой IP-блок. Всегда есть третий вариант: сделать что-то свое. Но для этого нужно знать, что и как делать, а такой информации у нас не было, поэтому мы остановились на DMA.

Подключение AXI-DMA

У AXI-DMA есть разные режимы. Нас устроит самый простой, то есть без scatter gather. AXI-Stream шина здесь полная, есть даже не обязательный по стандарту, но обязательный именно для этого блока tkeep — битовая маска, которая говорит, какие биты брать.

Откуда мы хотим забрать данные? Из АЦП, у которого в AXI-Stream ничего нет. То есть он игнорирует handshake и просто каждый такт выдает данные. У него нет tready, tlast, tkeep, готовых решений для этого тоже нет, так что нужно делать свой IP-блок.

Еще один аппаратный вопрос: как подключить DMA? В Zynq есть несколько slave-портов: четыре HP (High Performance), два HPC (High Performance Coherent) и один LPD (Low Power Domain). Они в итоге попадают в один и тот же DDR-контроллер, но по-разному разведены внутри SoC. На этом этапе сделать выбор сложно, так что мы вернемся к портам позже.

Программная сторона вопроса

Переходим к программной части. Нам нужно управлять транзакциями, выделить буфер для данных и получить туда доступ. При этом не забыть, что у CPU есть кеши.

Один блок у нас свой, поэтому мы используем драйвер userspace I/O (UIO): он позволяет через два нехитрых syscall и пару строк device tree привязаться к конкретному физическому адресу и управлять IP-блоками.

int fd = open(“/dev/uioX”);
uint32_t* phys_addr = mmap(0, N, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);

Чтобы сделать свой блок, мы уже изучили, как себя ведет блок AXI-DMA на аппаратном уровне. Поэтому написать UIO-драйвер для DMA намного проще, чем разбираться в уже готовых абстракциях DMA Engine внутри Linux.

Мы решили управлять транзакциями через UIO. Но по буферам и кешам из userspace просто так ничего не сделать: нужно посмотреть, что есть в ядре. В документации находим два полезных хедера:

  • #include <linux/dma-mapping.h> — во всех подробностях о нем рассказали на одной замечательной презентации еще в 2014 году. DMA Mapping утверждает, что кешируемость — это все-таки функция от маппинга, а не от адреса. То есть один и тот же адрес в разные моменты может быть по-разному замаплен и может быть как кешированным, так и некешированным.

  • #include <linux/dmaengine.h> — он заточен под Scatter Gather, абстрагирует детали DMA-блоков и унифицирует интерфейс управления транзакциями. Мы же имеем один конкретный IP-блок, так что DMA Engine никак не вписывается в контекст.

Аллоцируем буфер

Далее нам нужно аллоцировать буфер. На картинке выше есть SMMU (System Memory Management Unit) — это тот же самый IOMMU, только внутри ПЛИС, так что он вполне доступен. Если развести DMA через него, можно будет аллоцировать в userspace последовательные виртуальные страницы и передать их в DMA. Но это нам не подходит, потому что SMMU снижает throughput во много-много раз. Так сделано, скорее, для безопасности, но она нас пока что не волнует.

Поскольку у нас нет SMMU, нужно использовать последовательные физические страницы. Для этого тоже есть более-менее готовое решение: CMA — Contiguous Memory Allocator, фреймворк внутри Linux. В device tree ему выделен большой пул, и через DMA Mapping API можно обращаться к CMA. Dma_alloc_coherent() так и поступают. Формально это нам подходит, но в контексте телекома хочется иметь детерминированность: PL (programmable logic) делают так, чтобы все было настолько предсказуемо, насколько вообще возможно.

Есть другой вариант: просто reserved-memory секция в device tree. В самом начале после старта ядра можно выделить диапазон адресов для буфера и потом передать его драйверу. Системной памятью диапазон при этом будет проигнорирован.

Теперь встает вопрос кешей. Вот максимально упрощенная аппаратная схема. Есть CPU, есть память, между ними средства оптимизации архитектуры фон Неймана — кеш. Но появился еще и девайс, то есть AXI DMA, который также подключен к памяти и вообще ничего про кеш не знает. Девайс имеет равные CPU права, это мастер на шине памяти. Так что может возникнуть проблема: CPU что-то прочитал из памяти, это попало в кеш, девайс это в памяти переписал, CPU пытается это прочитать и читает из кеша старые данные. Одним словом, проблема когерентности.

Что делать с когерентностью?

Существуют когерентные системы — это системы, где все участники видят память одинаково. Не было бы кеша — не было бы и проблемы, так что формально первый способ ее решить — это выключить кеш. Можно сделать это программно, и ничего не сломается. Но тогда мы откатываемся в прошлый век, когда еще не было кешей и все работало медленно.

В Zynq есть возможность иметь аппаратную когерентность: помимо процессоров, ARM лицензирует еще и некоторую периферию для них. В списке этой периферии есть CCI — Cache Coherent Interconnect — который присутствует в Zynq. Если включить CCI, то все проблемы будут решены аппаратно, и со стороны софта про них не придется и думать. Но CCI есть не во всех портах, и если кеш не отключить, без него система будет некогерентной.

В DMA Mapping есть готовое решение — концепции владения буфером и направления данных. Вот функции из dma-mapping.h, которые передают владение буфером либо CPU, либо девайсу:

dma_sync_single_for_cpu()
dma_sync_single_for_device()

Загвоздка здесь в том, что, по идее, в один момент буфер принадлежит либо девайсу, либо CPU целиком, и писать то одному, то другому без синхронизации не получится. Важно также отметить, что, если система когерентна — то есть если нет кеша или есть аппаратная поддержка, — этот API просто потратит такты в никуда. Будет сбрасывать кеши просто так, за счет чего мы потеряем время.

Замеры портов и кешей

Для замеров задержек портов и кешей я сделал небольшой проект: счетчик, DMA и подключение к различным портам. Поскольку используется счетчик, я точно знаю: здесь именно те данные, что мне нужны, все синхронизировано. HP-порт не имеет аппаратной когерентности, поэтому для него необходимо было делать программную синхронизацию. А для HPC- и LPD-портов был включен CCI с аппаратной пометкой, что транзакции когерентные.

Показатели кешированного и некешированного чтения различаются во много раз. Я ожидал, что будет в 8 раз, потому что чтение производилось по восьмибайтовому указателю, а размер кеш-линии на ARM — 64 байта, то есть в 8 раз больше. И поскольку чтение было последовательным, должен был случаться спекулятивный префетчинг, и CPU должен был в восемь раз реже ходить в RAM. Но есть еще накладные расходы на синхронизацию.

Показатели кешированной и некешированной записи тоже различаются, потому что существует еще один «кеш» — write buffer — который мы игнорируем при некешированной записи.

На основе всех полученных данных нужно решить, куда подключать DMA-блок. Аппаратная когерентность дает явные преимущества, но всплывает проблема. Если собирать много данных, они теряются на стороне ПЛИС, потому что tready от DRAM падает, а входные данные неумолимо появляются каждый такт. То есть нам либо нужна минимальная задержка между нашим источником DMA-блоком и памятью, либо нужно сохранять данные в ПЛИС, пока они не смогут попасть в DRAM — то есть использовать полезную Block RAM. Минимальную задержку дает все-таки HP-порт, поэтому для реализации выбрали его. Ведь ресурсы ПЛИС в контексте телекома дороже этой небольшой разницы во времени чтения.

Что я узнал о DMA и Zynq-портах 

Мы сделали DMA, который позволил нам продвинуться с реализацией алгоритма обработки сигналов. Вначале не представляли, сколько данных понадобится, но, поработав с реальными данными, поняли, что нужно немного. По ходу проекта я узнал, что HPC-порт включить не так-то легко. Если просто переткнуть из HP в HPC, получится точно такой же некогерентный порт, но с большей задержкой, что создает дополнительные проблемы. Нужно еще и прописать некоторые значения в регистры CCI, доступные лишь до включения ядра, а также помечать транзакции как когерентные аппаратно через AXI-провода.

Что еще важно запомнить? Low-power domain вовсе не значит low-performance. Есть мнение, что LPD не подходит для DMA — нет, он вполне подходит. Пусть у него самый длинный путь внутри SoC из-за разведения через разные интерконнекты, это такой же high-performance с большим throughput. Наконец, от драйвера для DMA нам все-таки нужно управление кешами, поскольку все остальное можно сделать и без какого-либо драйвера.

Если вас заинтересовала эта статья, возможно, вам будут интересны и наши вакансии: