SoC: поднимаем простой DMA на FPGA
День добрый! В прошлой статье я описывал, как «поднять» с нуля SoC от Altrera.Мы остановились на том, что измерили пропускную способность между CPU и FPGA, когда копирование выполняется процессором.
В этом раз мы пойдем немного дальше и реализуем примитивный DMA в FPGA.Кому интересно — добро пожаловать под кат.
Используемое железоВ прошлый раз мы использовали плату SoCrates от EBV.В этот раз я буду использовать плату нашей собственной разработки — именно она представлена на фото.Основное отличие — в нашей плате 2 интерфейса Gigabit Ethernet и они заведены не на CPU, а на FPGA.Это позволяет выполнять очень гибкую обработку трафика. Плюс на разъемы выведено большое количество пинов.
Но данные отличия станут для нас принципиальными только в следующих статьях.В одной мы будем реализовывать NIC в FPGA — для этого, естественно, задействуем гигабитные интерфейсы.В другой напишем поддержку фреймбуфера для дисплея ILI9341, опять же, в FPGA — для этого будет нужна плата расширения.
А для выполнения действий, описанных ниже, подойдет любая плата с SoC Сyclone V
Исходный код По ходу статьи я буду приводить только важные куски кода с пояснениями.Весь исходный код можно посмотреть на гитхабеДетализация Подробности сборки ядра, получения bootloader и других действий, описанных в прошлой статье, я приводить не буду.Замечание насчёт ядра — лучше использовать более свежее ядро версии 3.18 отсюда:
git://git.rocketboards.org/linux-socfpga.git git checkout remotes/origin/socfpga-3.18 Думаем над реализацией Выбор DMA-контроллера Итак, наша цель — передать данные от FPGA до процессора и/или обратно с максимальной пропускной способностью и минимальной загрузкой CPU.Вариант копирования процессором сразу отпадает, нужно использовать DMA. Но кто может выполнять роль DMA-контроллера? Для нашего SoC есть два варианта — либо FPGA либо встроенный в HPS контроллер DMA-330.Судя по обсуждениям в сети, DMA-330 не очень производителен, а соответствующий драйвер, возможно, даже не полностью работоспособен.Возможно, когда-нибудь, мы попробуем «оживить» DMA-330, но сейчас наш выбор — FPGA
Выбор интерфейса Чтобы выполнять функции DMA-контроллера FPGA должнен быть мастером. Это возможно реализовать на одном из двух интерфейсов: FPGA-to-HPS (fpga2hps) FPGA-to-HPS SDRAM (fpga2sdram) Структурная схема компонентов HPS и интерфейсов между ними: Архитектура HPS Давайте посмотрим, какие преимущества и недостатки у каждого варианта.fpga2hps позволяет мастерам в FPGA получать доступ почти ко всем слейвам в системе. То есть не только как к памяти, но и к разнообразной периферии. При использовании fpga2sdram доступ ограничен только RAM.
fpga2sdram позволяет получить большую пропускную способность.
При использовании fpga2hps обмен происходит через один интерфейс. Если в FPGA требуется наличие нескольких мастеров, то необходим арбитраж. Значит нужно либо писать свои модули, либо использовать генерированные при помощи Qsys, а они достаточно ресурсоемкие.С другой стороны в fpga2sdram можно создать до 6 независимых портов, а все вопросы с арбитражем решит DDR-контроллер.
И fpga2hps и fpga2sdram перед использованием должны быть проинициализированы записью в соответствующие регистры. К сожалению, для fpga2sdram это необходимо сделать после прошивки FPGA, но в момент, когда никаких транзакций на интерфейсе не происходит. Фактически, при использовании Linux, это означает, что прошивать FPGA нужно в U-boot’е. Подробности можно прочитать тут.
При работе с fpga2hps мастер в FPGA должен использовать байтовый адрес, при работе с fpga2sdram — адрес слова.
Более подробную информацию можно найти в Cyclone V Device Handbook, Volume 3: Hard Processor System Technical Reference Manual.Главы 8 HPS-FPGA Bridges и 11 SDRAM Controller Subsystem.
Для нашей задачи нет принципиальной разницы, что использовать. Давайте выберем fpga2sdram в надежде получить большую пропускную способность.
Выбор реализации DMA-контроллера Мы определились с тем, что будем реализовывать DMA-контроллер в FPGA и с тем, через какой интерфейс он будет работать.Но как мы будем делать сам контроллер? Можно использовать одну из открытых «корок», например вот эту, которая также доступна через Qsys.Это неплохой DMA-контроллер, который имеет много полезных фич. Мы ещё вернемся к нему, когда будем реализовывать свой NIC.Но сейчас для нашей задачи такой контроллер — это ненужная функциональность и излишняя сложность.Для обучающей задачи гораздо лучше набросать пару счётчиков в FPGA, чтобы осознать, что суть DMA-контроллера очень проста.
Верхний уровень Со стороны софта всё тоже достаточно просто — нам нужен драйвер, который будет выделять память, получать шинный адрес этой памяти, конфигурировать и запускать DMA-контроллер в FPGA, дожидаться завершения транзакции и получать данные.И мы его напишем. Но начнём мы не с драйвера, а с немного странной программы в userspace, которая будет выполнять те же самые функции.Это позволит нам работать с DMA-контроллеров в FPGA без необходимости писать что-то на уровне ядра.Для «продакшена» такие решения обычно не применяют, но для отладки иногда это бывает удобно.
Для простоты прошивки в FPGA будем передавать данные в направлении FPGA → CPU.Передача данных в обратном направлении почти полностью аналогична, за исключением одного нюанса, о котором будет сказано ниже.С направлением CPU → FPGA мы будем работать при реализации фреймбуфера для LCD.
Итак, план:
Прошивка для FPGA Программа в userspace Драйвер ядра Реализация прошивки FPGA Начнём с нашего любимого Qsys. Нам потребуются три IP-корки: Processors and Peripherals → Hard Processor Systems → Arria V / Cyclone V Hard Processor System Basic Functions → Bridges and Adaptors → Memory Mapped → Avalon-MM Pipeline Bridge Basic Functions → Bridges and Adaptors → Clock → Clock Bridge Для HPS оставляем всё почти так же, как в предыдущей статье.На вкладке FPGA Interfaces нужно добавить FPGA-to-HPS SDRAM интерфейс.Выбираем тип Avalon-MM Bidirectional, ширину — 128 бит.Ещё нужно поставить галку напротив Enable FPGA-to-HPS Interrupts.Это позволит нашему DMA-контроллеру «сообщить» CPU о завершении транзакции посредством прерывания.
Также ширину интерфейса HPS-to-FPGA нужно поставить в 64 бита. Это интерфейс, через который CPU будет конфигурировать DMA-контроллер.Его ширина может быть любой, 64 бита ставим просто потом, что у меня была выбрана такая ширина, и исходный код, описываемый далее, настроен под это значение.
Вот что должно получиться:
FPGA Interfaces Переходим к Avalon-MM Bridge.Это корка будет выполнять роль конвейера. Нам нужно экспортировать HPS-to-FPGA из автогенерённого Qsys модуля наружу.Но если мы просто это сделаем, то получим интерфейс AXI, который намного сложнее, чем Avalon-MM. И работать с которым нам совсем не хочется. После добавления этого модуля Qsys автоматически конвертирует AXI в Avalon. Это займет часть ресурсов, но работать будет намного удобнее.Настроить модуль нужно так:
Avalon-MM Bridge Переходим к последнему модулю. Он нужен, чтобы мы могли экспортировать клок от HPS наружу и синхронизировать DMA-контроллер по этому клоку. Его настройка примитивна — нужно просто указать количество клоков, равное 1.После этого нужно соединить все наши модули (обратите внимание на имена в колонке Export):
Qsys Connections Осталось сохранить и сгенерировать файлы.Пришло время реализации нашего примитивного DMA-контроллера. Как мы будем его настраивать? Для настройки мы будем использовать так называемые Контрольные и Статусные Регистры (Control and Status Register, CSR)Это блоки фиксированного размера, которые доступны CPU на чтение/запись (контрольные) или только на чтение (статусные).
Доступ к этим регистрам будет осуществляться через HPS-to-FPGA.Так как интерфейс имеет ширину 64 бита, то можно либо сделать регистры такой же ширины, либо добавить конвертер.Делать регистры 64-битными сильно накладно. Ведь очень часто в целом регистре используется всего лишь несколько бит.Лучше сделать регистры 16-битными, а если возникнет необходимость иметь слово большой разрядности использовать 2 или 4 смежных регистра.
Теоретически можно было использовать конвертер, сгенерированный Qsys, указав для IP-корки Avalon-MM Bridge ширину в 16 бит, но на практике этого сделать не удалось — Qsys сгенерировал нерабочий модуль. Ничего страшного, будем использовать свой :)
В качестве конвертера используется модуль avalon_width_adapter.sv, а сами регистры реализованы в модуле regfile_with_be.v
Логика работы модуля регистров чрезвычайно проста — в зависимости от адреса выставляем на шину прочитанных данных содержимое нужного регистра. Если также пришел сигнал записи, то сохраняем в регистр входные данные. Адрес задает номер регистра, а не номер байта. Способ деление на контрольные и статусные регистры задается параметром при сборке — либо старшим битом адреса (адресное пространство в этом случае делится поровну между контрольными и статусными регистрами), либо по указанному параметрами количеству регистров.
Переходим непосредственно к DMA-контроллеру. Для простоты он расположен в топовом модуле.
Всё, из чего будет состоять наш DMA-контроллер — это три счётчика и пара сигналов.
Напомню, что данные наш контроллер выдает на интерфейс Avalon-MM. Подробное описание можно посмотреть тут, но в общем это достаточно простой интерфейс.Для того, чтобы записать данные, нужно выставить следующие сигналы:
sdram0_address — адрес (напомню, что для fpga2sdram это должен быть адрес слова). sdram0_writedata — данные для записи. sdram0_byteenable — сигнал, указывающий на то, какие байты из данных нужно записать. Для простоты ставим его равным 16'FFFF. sdram0_burstcount — сигнал для управления burst’ом. Опять же для простоты ставим его равным 1. sdram0_write — этот сигнал нужно установить в 1 для выполнения транзакции записи Единственный нюанс, про который нужно помнить — это наличие сигнала sdram0_waitrequest. Если он равен 1, это означает, что слейв не может в данный момент обработать транзакцию и мастер должен оставить все свои сигналы неизменными. Именно то, как часто сигнал sdram0_waitrequest будет выставляться в 1 и определит в итоге пропускную способность нашего DMA.
Итак, опишем используемые счётчики. Первый — это счётчик адреса, addr_cnt. При начале DMA-транзакции он устанавливается в адрес, заданный CPU. После каждой успешной транзакции (когда sdram0_waitrequest не равен 1) этот счётчик увеличивается на 1.
Второй — счётчик для эмуляции данных. Можно записать в данные всё, что хочется. Главное условие — после завершения транзакции софт должен вычитать из памяти точно те же данные, что были записаны. Поэтому записывать простой счётчик не очень правильно — в данных будет много нулей и сложно будет проверить валидность записи. Идеально было бы писать псевдослучайную последовательность, но для простоты хватит счётчика и его инвертированного значения.
Третий счётчик — счётчик тактов, cycle_cnt, будет сбрасываться в 0 при начале DMA-транзакции и дальше увеличиваться на 1 в каждом такте.Он нужен для того, чтобы мы могли узнать, сколько тактов заняла наша DMA-транзакция, и рассчитать пропускную способность.
Итого, для счётчиков мы получаем следующий код:
Описание счётчиков // For emulate data logic [63:0] data_cnt;
// Current address on SDRAM iface logic [31:0] addr_cnt;
// Overall cycles count. logic [31:0] cycle_cnt;
// Form pseudo-data always_ff @(posedge clk_w) if (! test_is_running) data_cnt <= '0; else if( !sdram0_waitrequest ) if( data_cnt != ( dma_data_size - 1 ) ) data_cnt <= data_cnt + 1;
// Increase address if no waitrequest always_ff @(posedge clk_w) if (run_test_stb) addr_cnt <= dma_addr; else if( !sdram0_waitrequest ) addr_cnt <= addr_cnt + 1;
always_ff @(posedge clk_w) if (test_is_running_stb) cycle_cnt <= '0; else if( test_is_running ) cycle_cnt <= cycle_cnt + 1;
Вернёмся к сигналам. Нам потребуется только: test_is_running — сигнал, указывающий, в процессе ли сейчас DMA-транзакция. run_test_stb — сигнал-строб, активный в течении 1 такта в момент, когда CPU запускает DMA-контроллер test_finished — сигнал, показывающий, что необходимое количество данных записано. Также заводится на прерывание. Формирование этих сигналов тривиально.Что нам нужно для настройки DMA-контроллера (это будут наши контрольные регистры)?
Адрес буфера, куда нужно скопировать данные Размер данных для записи Сигнал для запуска транзакции, из которого потом мы выделим фронт Статусными регистрами будут: Сигнал занятости DMA-контроллера Значение счётчика cycle_cnt Итого, вот так выглядит наше объявление регистров: Объявление регистров // Control registers `define DMA_CTRL_CR 0 `define DMA_CTRL_CR_RUN_STB 0
`define DMA_ADDR_CR0 1 `define DMA_ADDR_CR1 2
`define DMA_SIZE_CR0 3 `define DMA_SIZE_CR1 4
// Status registers `define DMA_STAT_SR 0 `define DMA_STAT_SR_BUSY 0
`define DMA_CYCLE_CNT_SR0 1 `define DMA_CYCLE_CNT_SR1 2 А вот так выглядит назначение регистров: Назначение регистров // Control from CPU — bit for start, DMA buffer address and transaction size. assign run_test = cregs_w[`DMA_CTRL_CR][`DMA_CTRL_CR_RUN_STB]; assign dma_addr = { cregs_w[`DMA_ADDR_CR1], cregs_w[`DMA_ADDR_CR0] }; assign dma_data_size = { cregs_w[`DMA_SIZE_CR1], cregs_w[`DMA_SIZE_CR0] };
// Status for CPU — current state and overall cycles count. assign sregs_w[`DMA_STAT_SR][`DMA_STAT_SR_BUSY] = test_is_running; assign { sregs_w[`DMA_CYCLE_CNT_SR1], sregs_w[`DMA_CYCLE_CNT_SR0] } = cycle_cnt; Всё, можно компилировать проект. Для начала выполним Analysis & Synthesis.После этого создадим файл SignalTap — с его помощью мы сможем смотреть значения сигналов внутри FPGAДля этого заходим File → New → SignalTap II Logic Analyzer File и жмём OK.В появившемся окне нужно добавить необходимые сигналы. Должно получиться что-то вроде:
SignalTap File Сохраняем файл, добавляем его в проект и выполняем полную сборку.После окончания сборки нам нужно получить .rbf файл:
quartus_cpf -c etln.sof dma.rbf Всё, прошивка готова. Переходим к софтовой части.Внимание: помните, что после изменения настроек в Qsys (в частности после включения fpga2sdram) нужно перегенерировать и пересобрать Preloader.
Реализация userspace программы Что нам нужно для того, чтобы работать с DMA-контроллером со стороны софта? Во-первых, нам нужно уметь настраивать и запускать DMA-контроллер. Для этого мы используем программу mem из предыдущей статьи.
Во-вторых, нам нужно получить область памяти, адрес которой мы сможем передать DMA-контроллеру.
Тут нужно маленькое отступление. Обычно все процессы в userspace и даже большинство в ядре работают с так называемыми виртуальными адресами. А вот DMA-контроллеру нужно передать физический адрес (более точно, шинный адрес, но для используемых нами платформ он равен физическому)
В ядре для выполнения подобных задач есть набор специальных функций, которые позволяют по виртуальному адресу получить физический (и наоборот) или выделить область памяти и получить сразу два адреса, которые будут указывать на неё.
Что же делать в userspace? Нам поможет замечательный файл /proc/[PID]/pagemap, который содержит информацию о отображении всех виртуальных страниц в физические для любого процесса.
Информация для каждой страницы в этом файле занимает равно 8 байт. При этом младшие 55 бит содержат так называемый номер физической страницы — Page Frame Number (PFN), а старшие 9 бит — различные флаги (наличие страницы, нахождение в swap и т.д.) Подробное описание можно посмотреть тут или в man proc
Таким образом, зная виртуальный адрес и размер страницы, легко вычислить номер виртуальной страницы. После этого из файла /proc/[PID]/pagemap нужно просто считать 8 байт по нужному смещению и в младших 55 битах будет номер физической страницы. А его уже легко перевести в физический адрес, который мы и запишем в DMA-контроллер.
Если наша область памяти начинается на границе страницы, то всё становится ещё немного проще.Поэтому вместо функции malloc () лучше использовать функцию posix_memalign (), которая позволяет задавать желаемое смещение.
Также, для того, чтобы предотвратить выгрузку данных из RAM в swap, желательно использовать функцию mlock ()
Описанные выше вещи и выполняет программа phys_addr.c
Важное замечание — страницы, смежные в виртуальном адресном пространстве, не обязательно будут смежными в RAM.Поэтому в данном способе мы не можем записывать DMA-контроллером данные, размером больше, чем размер страницы.Обойти это ограничение мы сможем, когда напишем драйвер.
Промежуточная проверка Итак, прошивка и тестовая программа готовы, время немного их потестировать.Скопируем бинарники на SD-карту, подключим USB-Blaster и запустим нашу плату.
Выше я писал, что включать fpga2sdram интерфейс нужно до загрузки Linux. Это действительно так, но не всегда.Если включить интерфейс уже в Linux и попытаться из FPGA прочитать данные в памяти, то система полностью зависнет.А вот записать данные получится. Естественно, этот вариант явно не стоит использовать на боевой системе и ниже я напишу, как правильно инициализировать интерфейс fpga2sdram. Но для промежуточного тестирования нам это вполне подойдет.
Для начала прошьём FPGA:
cat dma.rbf > /dev/fpga0 Теперь включим интерфейс HPS-to-FPGA: echo 1 > /sys/class/fpga-bridge/hps2fpga/enable Если сейчас мы запустим SignalTap, то увидим, что сигнал sdram0_waitrequest постоянно висит в 1. Это связано с тем, что интерфейс fpga2sdram выключен.Включим его:
./mem.o 0xFFC25080 0×3fff Запись единиц в биты регистра 0xFFC25080 включает соответствующие порты интерфейса fpga2sdram. Описание, какие биты за какие порты отвечают, приведено в вышеуказанном Handbook’е. Нам для простоты достаточно включить все порты (всего в регистре используются 14 бит).Теперь в SignalTap сигнал sdram0_waitrequest стал равен 0.
Запускаем утилиту phys_addr:
./phys_addr Она выделит буфер и выведет его физический адрес. У меня это 0×2d593000.Мы помним, что при использовании интерфейса fpga2sdram нужно адресоваться по словам.Так как слова у нас 128-битные, то адрес слова рассчитывается так: 0×2d593000 / 16 = 0×2d59300 Запишем этот адрес в регистры FPGA: ./mem.o 0xC0000002 0×2d59300 Для адреса у нас используются контрольные регистры под номерами 1 и 2. Каждый адрес — это 16 бит, или 2 байта. Так как HPS-to-FPGA начинается с адреса 0xC0000000, то у первого контрольного регистра байтовый адрес будет равен 0xC0000002Напомню, что утилита mem.c использует именно байтовые адреса.После этого запишем длину DMA-транзакции в контрольный регистр номер 3. Длина не должны превышать размера страницы, а для нас это 4096 байт. Так как наш fpga2sdram интерфейс имеет ширину в 128 бит, а размер транзакции мы указываем в словах, то в третий регистр мы должны записать число 256:
./mem.o 0xC0000006 256 Далее настроим SignalTap на захват по отрицательному фронту сигнала test_is_running и запустим DMA-контроллер.Для этого нужно записать в нулевой бит нулевого регистра вначале 0 (если его там нет), а потом 1. При этому нужно помнить, что утилита mem.o выполняет транзакции по 4 байта, а это 2 наших регистра. Поэтому, если мы не будем осторожны, то затрём данные в соседнем регистре.
Итого, нам нужно вначале прочитать данные по адресу 0xC0000000, а потом записать их же, но с установленным нулевым битом.
Читаем:
./mem.o 0xC0000000 У меня прочиталось 0×93000000Записываем:
./mem.o 0xC0000000 0×93000001 После этого мы должны получить в SignalTap примерно такую картинку: SignalTap Result Как видите, значение счётчика cycle_cnt на момент окончания транзакции равно 3167.Давайте посчитаем пропускную способность. Частота тактового сигнала в моем проекте — 150 МГц.Ширина — 128 бит. За 3167 тактов передано 256 слов. Итого: 128×150 / (3167/256) = 1551 Мбит/c Осталось убедиться, что данные записались правильно. «Снимаем» утилиту phys_addr с паузы, нажав Enter.Мы должны увидеть такой текст: Результат выполнения phys_addr 0: 0×0 1: 0xffffffffffffffff 2: 0×1 3: 0xfffffffffffffffe … 507: 0xffffffffffffff02 508: 0xfe 509: 0xffffffffffffff01 510: 0xff 511: 0xffffffffffffff00 Если увидели, то всё прошло успешно.Поэкспериментировав с разными параметрами, я увидел, что частота тактового сигнала почти не влияет на пропускную способность.Она остается примерно одинаковой, что для 25 МГц, что для 150 МГц.А вот ширина интерфейса fpga2sdram, напротив, даёт почти линейную зависимость — проверено при 64 и 128 битах. Для 256 не проверял.
Естественно, из-за того, что количество записываемых данных мало (всего 4096 байт), погрешность измерения довольно большая.Увеличить размер DMA-транзакции мы сможем, написав свой примитивный драйвер.
Написание драйвера Статья вышла чуть больше, чем я предполагал, поэтому про драйвер я расскажу совсем вкратце.Тем более, что с ним нам ещё предстоит поработать в следующих статьях.Но код лежит на гитхабе, кому интересно — можете посмотреть подробности.Основная идея проста — при запуске драйвера мы задаем параметром, какой размер транзакции нам необходим.Драйвер выделяет память и записывает шинный адрес и размер транзакции в FPGA.
Также драйвер регистрирует обработчик прерывания, которое мы задали в прошивке FPGA.
После этого драйвер создает два char-девайса:
/dev/etn-ctrl — для запуска DMA-транзакции /dev/etn-data — для получения данных При чтении из файла /dev/etn-ctrl, происходит запуск DMA-транзакции.После этого вызов блокируется до прихода прерывания от FPGA.Когда прерывание приходит, вызов завершается. Это означает, что данные записаны и их можно считать из файла /dev/etn-data.
Чтобы драйвер заработал в .dts файл нужно добавить следующие строки:
Изменения в .dts fpga { compatible = «mtk, etn»; interrupts = <0x0 0x28 0x1>; }; Первая строка задает совместимый драйвер, а вторая — номер и тип прерывания от FPGA.При использовании транзакции размером 4MB пропускная способность выходит порядка 2000 Мбит/с
Выводы Был написал примитивный DMA-контроллер в FPGA и измерена его пропускная способность. Она составила около 2 Гбит/c. Это не очень много, но следует учесть что, во-первых, не использовался интерфейс шириной 256 бит.Во-вторых, не производилось никакой настройки арбитража в DDR-контроллере.В-третьих, мы не использовали burst.В-четвертых, возможно, я ещё что-нибудь забыл :)Дальнейший план статей такой, если, конечно, они кому-нибудь интересны:
Реализация фреймбуфера для ILI9341 в FPGA Работа с SGDMA-контроллером Реализация гигабитного 2-х портового NIC в FPGA с использованием SGDMA-контроллера Спасибо тем, кто добрался до конца! Удачи!