День добрый! В прошлой статье я описывал, как «поднять» с нуля 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 и интерфейсов между ними:
Давайте посмотрим, какие преимущества и недостатки у каждого варианта.
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 бита ставим просто потом, что у меня была выбрана такая ширина, и исходный код, описываемый далее, настроен под это значение.
Вот что должно получиться:
Переходим к Avalon-MM Bridge.
Это корка будет выполнять роль конвейера. Нам нужно экспортировать HPS-to-FPGA из автогенерённого Qsys модуля наружу.
Но если мы просто это сделаем, то получим интерфейс AXI, который намного сложнее, чем Avalon-MM. И работать с которым нам совсем не хочется. После добавления этого модуля Qsys автоматически конвертирует AXI в Avalon. Это займет часть ресурсов, но работать будет намного удобнее.
Настроить модуль нужно так:
Переходим к последнему модулю. Он нужен, чтобы мы могли экспортировать клок от HPS наружу и синхронизировать DMA-контроллер по этому клоку. Его настройка примитивна — нужно просто указать количество клоков, равное 1.
После этого нужно соединить все наши модули (обратите внимание на имена в колонке Export):
Осталось сохранить и сгенерировать файлы.
Пришло время реализации нашего примитивного 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.
В появившемся окне нужно добавить необходимые сигналы. Должно получиться что-то вроде:
Сохраняем файл, добавляем его в проект и выполняем полную сборку.
После окончания сборки нам нужно получить .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 0x3fff
Запись единиц в биты регистра 0xFFC25080 включает соответствующие порты интерфейса fpga2sdram. Описание, какие биты за какие порты отвечают, приведено в вышеуказанном Handbook’е. Нам для простоты достаточно включить все порты (всего в регистре используются 14 бит).
Теперь в SignalTap сигнал sdram0_waitrequest стал равен 0.
Запускаем утилиту phys_addr:
./phys_addr
Она выделит буфер и выведет его физический адрес. У меня это 0x2d593000.
Мы помним, что при использовании интерфейса fpga2sdram нужно адресоваться по словам.
Так как слова у нас 128-битные, то адрес слова рассчитывается так:
0x2d593000 / 16 = 0x2d59300
Запишем этот адрес в регистры FPGA:
./mem.o 0xC0000002 0x2d59300
Для адреса у нас используются контрольные регистры под номерами 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
У меня прочиталось 0x93000000
Записываем:
./mem.o 0xC0000000 0x93000001
После этого мы должны получить в SignalTap примерно такую картинку:
Как видите, значение счётчика cycle_cnt на момент окончания транзакции равно 3167.
Давайте посчитаем пропускную способность. Частота тактового сигнала в моем проекте — 150 МГц.
Ширина — 128 бит. За 3167 тактов передано 256 слов. Итого:
128 * 150 / (3167/256) = 1551 Мбит/c
Осталось убедиться, что данные записались правильно. «Снимаем» утилиту phys_addr с паузы, нажав Enter.
Мы должны увидеть такой текст:
0: 0x0 1: 0xffffffffffffffff 2: 0x1 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 файл нужно добавить следующие строки:
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-контроллера
Спасибо тем, кто добрался до конца! Удачи!
ссылка на оригинал статьи http://habrahabr.ru/post/248145/
Добавить комментарий