Автору всегда нравилась идея Litex, фреймворка для простой сборки SoC на FPGA, но постоянно не хватало времени, чтобы попробовать. Пришло время изменить это и задокументировать процесс! Мы будем использовать плату FPGA Sipeed Tang Nano 9K, которая является относительно недорогим оборудованием, тем не менее большая часть этой статьи применима к любому поддерживаемому Litex FPGA.
Пришлось кое-что подучить, Litex написан на Python, или, точнее, он использует Migen, инструмент на основе Python, который генерирует Verilog. Автор никогда не писал много кода на Python, не говоря уже о Migen. Таким образом, чтобы освоить основы Litex, необходимо было выполнить следующее:
Разобрать минимальный пример SoC
Настроить SoC с некоторыми периферийными устройствами, уже доступными в LiteX
Написать пользовательское приложение и запустить его на созданном SoC
Создать комфортную среду разработки
Прежде чем продолжить давайте сначала установим Litex и создадим пример!
Создание SoC из примера
Это довольно просто в случае использования актуального Linux, если следовать руководству все работает на Debian 12. Чтобы собирать некоторые примеры, необходим стандартный или полный конфиг, а также нужно установить тулчейн RISC-V. К счастью, руководство по быстрому старту хорошо объясняет все это.
Теперь о тулчейне Gowin, он не является открытым исходным кодом, хотя и бесплатен. Для получения лицензии нужно подать заявку. Ее можно скачать здесь после регистрации. Тулчейн с открытым исходным кодом находится в процессе разработки, однако на момент написания статьи (год назад) он еще не готов для использования с Litex.
Исполняемый файл gw_sh от Gowin необходимо добавить в путь, например через .bashrc:
PATH="$PATH:/path/to/gowin/IDE/bin"
После установки следует перейти в директорию “litex/litex-boards/litex_boards/targets” и выполнить:
./sipeed_tang_nano_9k.py --build --flash
Это займет довольно много времени, выполняется компиляция, синтез, размещение и трассировка, а затем прошивка FPGA. Светодиоды будут весело подмигивать, а после подключения последовательного порта на скорости 115200 бод будет отображено приветствие:
В итоге мы собрали пример, хотя и не имеем понятия, что и как он делает. К счастью, в комплекте есть исходник, давайте взглянем на него. Автор потратил некоторое время, чтобы удалить все, что мог из примера sipeed 9K, чтобы тот больше соответствовал simple.py, и в итоге получил следующее:
import os
from migen import *
from litex.gen import *
from litex_boards.platforms import sipeed_tang_nano_9k
from litex.build.io import CRG
from litex.soc.integration.soc_core import *
from litex.soc.integration.soc import SoCRegion
from litex.soc.integration.builder import *
kB = 1024
mB = 1024*kB
# BaseSoC ------------------------------------------------------------------------------------------
class BaseSoC(SoCCore):
def __init__(self, **kwargs):
platform = sipeed_tang_nano_9k.Platform()
sys_clk_freq = int(1e9/platform.default_clk_period)
# CRG --------------------------------------------------------------------------------------
self.crg = CRG(platform.request(platform.default_clk_name))
# SoCCore ----------------------------------------------------------------------------------
kwargs["integrated_rom_size"] = 64*kB
kwargs["integrated_sram_size"] = 8*kB
SoCCore.__init__(self, platform, sys_clk_freq, ident="Tiny LiteX SoC on Tang Nano 9K", **kwargs)
# Build --------------------------------------------------------------------------------------------
def main():
from litex.build.parser import LiteXArgumentParser
parser = LiteXArgumentParser(platform=sipeed_tang_nano_9K_platform.Platform, description="Tiny LiteX SoC on Tang Nano 9K.")
parser.add_target_argument("--flash", action="store_true", help="Flash Bitstream.")
args = parser.parse_args()
soc = BaseSoC( **parser.soc_argdict)
builder = Builder(soc, **parser.builder_argdict)
if args.build:
builder.build(**parser.toolchain_argdict)
if args.load:
prog = soc.platform.create_programmer("openfpgaloader")
prog.load_bitstream(builder.get_bitstream_filename(mode="sram"))
if args.flash:
prog = soc.platform.create_programmer("openfpgaloader")
prog.flash(0, builder.get_bitstream_filename(mode="flash", ext=".fs"))
prog.flash(0, builder.get_bios_filename(), external=True)
if __name__ == "__main__":
main()
Ух ты, это около 50 строк, неплохо. Оказывается в Litex происходит много волшебства, чтобы сохранить код компактным, давайте попробуем разобрать его!
Сначала несколько директив импорта и определений,
from litex_boards.platforms import sipeed_tang_nano_9k
импортирует файл платформы, этот файл содержит описание всех входов-выходов и периферийных устройств, а также включает информацию об используемом программаторе и частоте встроенного тактового генератора. В случае кастомной платы такой файл нужно будет создавать с нуля.
Остальные директивы подключают migen, язык HDL, используемый в Litex, и некоторые базовые блоки для создания SoC.
import os
from migen import *
from litex.gen import *
from litex_boards.platforms import sipeed_tang_nano_9k
from litex.build.io import CRG
from litex.soc.integration.soc_core import *
from litex.soc.integration.soc import SoCRegion
from litex.soc.integration.builder import *
kB = 1024
mB = 1024*kB
Теперь пора перейти к концу кода и взглянуть на основную функцию:
def main():
from litex.build.parser import LiteXArgumentParser
parser = LiteXArgumentParser(platform=sipeed_tang_nano_9K_platform.Platform, description="Tiny LiteX SoC on Tang Nano 9K.")
parser.add_target_argument("--flash", action="store_true", help="Flash Bitstream.")
args = parser.parse_args()
soc = BaseSoC( **parser.soc_argdict)
builder = Builder(soc, **parser.builder_argdict)
if args.build:
builder.build(**parser.toolchain_argdict)
if args.load:
prog = soc.platform.create_programmer("openfpgaloader")
prog.load_bitstream(builder.get_bitstream_filename(mode="sram"))
if args.flash:
prog = soc.platform.create_programmer("openfpgaloader")
prog.flash(0, builder.get_bitstream_filename(mode="flash", ext=".fs")) # FIXME
prog.flash(0, builder.get_bios_filename(), external=True)
Прежде всего импортируется LitexArgumentParser и создается его экземпляр. Это очень удобная функция в Litex, которая упрощает настройку SoC с помощью аргументов командной строки. Выполнив:
./sipeed_tang_nano_9k.py --help
мы получим полный перечень параметров, вот лишь некоторые из них:
Да, тип процессора - это всего лишь аргумент командной строки, потрясающе!
Затем вызывается функция BaseSoc, которая используется для настройки SoC. Рассмотрим это немного позже. После этого вызывается Litex Builder с SoC в качестве аргумента для построения окончательной SoC.
Наконец, обрабатываются аргументы -load и -flash. Они оба вызывают инструмент OpenFPGALoader, чтобы либо загрузить битстрим в ОЗУ, либо прошить его в SPI-флеш на плате FPGA. OpenFPGALoader устанавливается с помощью скрипта Litex_setup.
А вот и сама SoC!
# BaseSoC ------------------------------------------------------------------------------------------
class BaseSoC(SoCCore):
def __init__(self, **kwargs):
platform = sipeed_tang_nano_9k.Platform()
sys_clk_freq = int(1e9/platform.default_clk_period)
# CRG --------------------------------------------------------------------------------------
self.crg = CRG(platform.request(platform.default_clk_name))
# SoCCore ----------------------------------------------------------------------------------
kwargs["integrated_rom_size"] = 64*kB
kwargs["integrated_sram_size"] = 8*kB
SoCCore.__init__(self, platform, sys_clk_freq, ident="Tiny LiteX SoC on Tang Nano 9K", **kwargs)
Класс BaseSoC создает SoC, который будет передан в Litex Builder немного позже. Исходный SoC в Litex содержит процессор Vexriscv, шину wishbone, немного ОЗУ, ПЗУ, таймер и UART. Все это базовые настраиваемые параметры. Здесь мы задаем тактовых частоту и создаем CRG, формирователь сброса и тактирования, который должен содержать все сигналы сброса и тактирующие сигналы. Пока что есть только один тактовый сигнал, мы рассмотрим это более подробно позже.
Также мы задаем размер ПЗУ и ОЗУ, что, строго говоря, не является обязательным, в случае если подходят стандартные значения. Вся эта информация передается в функцию SoCCore.init, которая возвращает наш SoC.
Вот и все, минимальный SoC готов, потрясающе. Полный пример можно посмотреть на GitHub.
Теперь давайте постепенно добавим к нему новые функции!
Добавляем CRG
В настоящее время CRG очень ограничен по сравнению с приведенным в примере, нет даже кнопки сброса! Давайте изменим это и добавим PLL и сброс.
class _CRG(LiteXModule):
def __init__(self, platform, sys_clk_freq):
self.rst = Signal()
self.cd_sys = ClockDomain()
# Clk / Rst
clk27 = platform.request("clk27")
rst_n = platform.request("user_btn", 0)
# PLL
self.pll = pll = GW1NPLL(devicename=platform.devicename, device=platform.device)
self.comb += pll.reset.eq(~rst_n)
pll.register_clkin(clk27, 27e6)
pll.create_clkout(self.cd_sys, sys_clk_freq)
По сравнению с предыдущим вариантом, CRG теперь использует одну из пользовательских кнопок в качестве входа сброса. Генерируется PLL, пока с одинаковой частотой на входе и выходе, но это можно изменить, передав в качестве параметра запрашиваемую частоту тактового сигнала, здорово! Сигнал сброса сбрасывает PLL, что, в свою очередь, сбрасывает процессор.
Пришло время периферии
В Litex уже доступно довольно много периферийных устройств: таймеры, UART, I2C, SPI и прочее. К сожалению, документация оставляет желать лучшего, однако после некоторых изысканий автор смог заставить большинство из них работать.
Давайте добавим несколько устройств в файл sipeed_tang_nano_9k.py!
from litex.soc.cores.timer import *
from litex.soc.cores.gpio import *
from litex.soc.cores.bitbang import I2CMaster
from litex.soc.cores.spi import SPIMaster
from litex.soc.cores import uart
Готово, это решает проблему с наиболее распространенными периферийными устройствами. К счастью, инициализация тоже не вызывает затруднений!
self.timer1 = Timer()
self.timer2 = Timer()
self.leds = GPIOOut(pads = platform.request_all("user_led"))
# Serial stuff
self.i2c0 = I2CMaster(pads = platform.request("i2c0"))
self.add_uart("serial0", "uart0")
self.gpio = GPIOIn(platform.request("user_btn", 1))
Два дополнительных таймера, несколько светодиодов, I2C, UART и вход GPIO в десятке строк кода. Это намного проще, чем VHDL или Verilog. Теперь файл платформы необходимо дополнить, чтобы Litex знал, что размещать на каких входах-выходах:
("gpio", 0, Pins("25"), IOStandard("LVCMOS33")),
("gpio", 1, Pins("26"), IOStandard("LVCMOS33")),
("gpio", 2, Pins("27"), IOStandard("LVCMOS33")),
("gpio", 3, Pins("28"), IOStandard("LVCMOS33")),
("gpio", 4, Pins("29"), IOStandard("LVCMOS33")),
("gpio", 5, Pins("30"), IOStandard("LVCMOS33")),
("gpio", 6, Pins("33"), IOStandard("LVCMOS33")),
("gpio", 7, Pins("34"), IOStandard("LVCMOS33")),
("i2c0", 0,
Subsignal("sda", Pins("40")),
Subsignal("scl", Pins("35")),
IOStandard("LVCMOS33"),
),
("uart0", 0,
Subsignal("rx", Pins("41")),
Subsignal("tx", Pins("42")),
IOStandard("LVCMOS33")
),
Отлично! Но все же есть небольшая проблема, редактировать все это в репозитории litex-boards не совсем правильно.
Пора создать отдельную директорию для всего этого, а еще лучше - использовать Docker.
Контейнеризация
При обсуждении с другом запуска всего этого на MacBook (IDE Gowin недоступна для Mac OS), для развертывания он создал небольшой контейнер Docker, достаточно указать расположение файла лицензии, и все готово! Автор внес несколько небольших изменений, в основном чтобы установить рабочую директорию и добавить vim. Так что загляните в этот репозиторий и попробуйте!
Это позволит надежно запускать Litex с инструментами Gowin на любом компьютере, независимо от операционной системы и дистрибутива.
Одна проблема решена, теперь нужно навести порядок, автор остановился на следующей структуре директорий:
Директория "platform" содержит файл платформы, а "software" - исходный код на C для программы SoC, который можно найти на моем GitHub.
Команда запуска контейнера в Docker выглядит следующим образом:
docker run --rm \
--platform linux/amd64 \
--mac-address xx:xx:xx:xx:xx:xx \
-v "${HOME}/gowin_E_xxxxxxxxxx.lic:/data/license.lic" \
-v ${HOME}/Documents/Git/LitexTang9KExperiments:/data/work \
-it gowin-docker:latest
Файл лицензии привязывается к MAC-адресу сетевой карты, поэтому убедитесь, что вы установили свой MAC-адрес в Docker, чтобы он соответствовал тому, который указан в вашей лицензии. Рекомендуется использовать генератор MAC-адресов, предварительно убедившись в отсутствии потенциальных коллизий.
После запуска контейнера мы сразу оказываемся в нужной папке,
./sipeed_tang_nano_9k.py --build
в шаге от сборки.
Передряги с ПО
Для начала автор посмотрел на демонстрационное приложение в Litex и скомпилировал его. Его можно прошить, интегрировав в внутреннее ПЗУ SoC, но это означает пересборку всей SoC при каждом изменении кода. Это довольно неудобно, если вы хотите быстро вносить изменения в код.
К счастью, у Litex есть отличная программа под названием litex_term, которая может использоваться для загрузки бинарных файлов и подключения терминала к SoC.
Стандартный BIOS в Litex поддерживает загрузку и выполнение бинарных файлов, похоже на загрузчик в Arduino. Использовать его довольно просто:
litex_term /dev/TTYhere --kernel=yourapp.bin
чтобы повторно загрузить бинарный файл после внесения изменений достаточно просто перезагрузить плату!
На SoC должно быть доступно немного ОЗУ, которое не используется BIOS. Логично, что нельзя загружать новый код в область ОЗУ BIOS. Мой выбор пал на использование внутренней HyperRAM FPGA. В примере также используется данный подход, и он, похоже, работает довольно хорошо. Код для добавления этого в SoC выглядит следующим образом:
# HyperRAM ---------------------------------------------------------------------------------
if not self.integrated_main_ram_size:
# TODO: Use second 32Mbit PSRAM chip.
dq = platform.request("IO_psram_dq")
rwds = platform.request("IO_psram_rwds")
reset_n = platform.request("O_psram_reset_n")
cs_n = platform.request("O_psram_cs_n")
ck = platform.request("O_psram_ck")
ck_n = platform.request("O_psram_ck_n")
class HyperRAMPads:
def __init__(self, n):
self.clk = Signal()
self.rst_n = reset_n[n]
self.dq = dq[8*n:8*(n+1)]
self.cs_n = cs_n[n]
self.rwds = rwds[n]
# FIXME: Issue with upstream HyperRAM core, so the old one is checked in in the repo for now
hyperram_pads = HyperRAMPads(0)
self.comb += ck[0].eq(hyperram_pads.clk)
self.comb += ck_n[0].eq(~hyperram_pads.clk)
self.hyperram = HyperRAM(hyperram_pads)
self.bus.add_slave("main_ram", slave=self.hyperram.bus, region=SoCRegion(origin=self.mem_map["main_ram"], size=4*mB))
self.add_constant("CONFIG_MAIN_RAM_INIT") # This disables the memory test on the hyperram and saves some boottime
Одной проблемой меньше, однако хотелось бы иметь возможность компилировать свой собственный код, отдельно от репозитория Litex, и при этом использовать их готовые драйверы и прочее. После некоторых экспериментов получился следующий makefile.
Вся магия заключается в директориях сборки и заголовков вверху:
BUILD_DIR=../../build/sipeed_tang_nano_9k
SOC_DIR=/usr/local/share/litex/litex/litex/litex/soc/
include $(BUILD_DIR)/software/include/generated/variables.mak
include $(SOC_DIR)/software/common.mak
Код в значительной степени основан на демонстрационном приложении: сначала автор его упростил, а затем дополнил новыми периферийными устройствами.
Драйверы периферии
После построения SoC с набором входов/выходов, I2C и прочего, возникает желание подключить периферию! Большинство из них довольно просто использовать, но на самом деле нет никакой документации о том, как это сделать. Лучший способ - посмотреть на код migen и позволить Litex сгенерировать файл со всеми регистрами. Это можно сделать, добавив опцию "-soc-csv". Например:
./sipeed_tang_nano_9k.py --build --soc-csv=soc.csv
сгенерирует файл soc.csv со всеми регистрами внутри. Также доступны опции -soc-json и -soc-svd для генерации файлов в формате JSON и SVD соответственно.
Некоторые файлы заголовков C также генерируются при сборке. В частности файл csr.h, расположенный в директории build/sipeed_tang_nano_9k/software/include/generated/ очень полезен. Для небольших периферийных устройств использование функций из этого файла вполне реализуемо.
Например, функция "gpio_in_read" для чтения состояния GPIO, работает как ожидалось.
Для некоторых периферийных устройств в Litex доступны драйверы. Например, для I2C есть отличный драйвер, способный обрабатывать более одного созданного I2C устройства, потрясающе!
Пора разобраться с использованием прерываний.
Передряги с прерываниями
Задействование прерываний на стороне Litex/FPGA реализовано довольно просто, функция irq.add позаботится обо всем! Например:
self.gpio = GPIOIn(platform.request("user_btn", 1), with_irq=True)
self.timer1 = Timer()
self.timer2 = Timer()
# And add the interrupts!
self.irq.add("gpio", use_loc_if_exists=True)
self.irq.add("timer1", use_loc_if_exists=True)
self.irq.add("timer2", use_loc_if_exists=True)
Однако как использовать их в программном обеспечении? После просмотра существующего кода, вот здесь был найден обработчик прерываний. Но есть крошечная проблема:
void isr(void)
{
__attribute__((unused)) unsigned int irqs;
irqs = irq_pending() & irq_getmask();
if(irqs & (1 << UART_INTERRUPT))
uart_isr();
}
Автор удалил некоторые #define для ясности, таким образом код будет обрабатывать только прерывание UART для стандартного UART! Так что нужно либо изменить этот файл в Litex, либо не использовать библиотеки Litex. Либо сделать небольшое изменение:
// Weak function that can be overriden in own software for any IRQ that is not the uart.
// Return true (not zero) if an IRQ was handled, or 0 if not.
unsigned int __attribute__((weak)) isr_handler(int irqs);
// Override by default with return 0
unsigned int isr_handler(int irqs)
{
return 0;
}
...
void isr(void)
{
__attribute__((unused)) unsigned int irqs;
irqs = irq_pending() & irq_getmask();
if(irqs & (1 << UART_INTERRUPT))
uart_isr();
else
if(!isr_handler(irqs))
printf("Unhandled irq!\n");
}
Таким образом, простая функция с атрибутом weak определена вверху. Это означает, что если такая же функция существует где-либо еще, она переопределит weak функцию. Если же ее нет, будет вызвана weak функция.
Это означает, что если произойдет прерывание, которое не является прерыванием UART, будет вызвана функция isr_handler(). Если вы реализуете ее в своем коде, отлично, она будет вызвана и выполнится. В противном случае ничего страшного, будет вызвана функция из этого файла.
В собственном main.c можно просто сделать следующее:
unsigned int isr_handler(int irqs)
{
unsigned int irqHandled = 0;
if(irqs & (1 << GPIO_INTERRUPT))
{
GpioInClearPendingInterrupt();
irqHandled = 1;
}
return irqHandled;
}
В этом случае, если происходит прерывание GPIO_INTERRUPT, то оно будет обработано с возвратом 1, в противном случае будет возвращен 0, и обработчик прерываний сможет выдать предупреждение :)
В рамках демонстрации автор создал программу, которая считывает данные с последовательного порта и может выполнять несколько команд для тестирования I2C, GPIO, прерываний таймера и так далее. Полный код можно найти здесь.
Теперь осталась только одна вещь, которую надо опробовать. Создание собственного периферийного устройства!
Создание собственного периферийного устройства
В целях освоения создания периферийного устройства, автор решил реализовать простой периферийный модуль PWM. Что-то простое, что генерирует сигнал PWM с заданной частотой и коэффициентом заполнения. Внутри он должен иметь счетчик, и когда счетчик ниже или выше определенного значения, он будет переключать выход для управления коэффициентом заполнения PWM.
Он должен иметь несколько регистров:
Регистры включения, чтобы включать/выключать периферийное устройство PWM
Регистры делителя, чтобы иметь возможность создавать PWM-сигналы с более низкой частотой
Регистры максимального счета, которые должны считать до этого значения, а затем сбрасывать свой внутренний счетчик
Регистры коэффициента заполнения: если счетчик ниже этого значения, состояние выхода должно быть низким, в противном случае - высоким.
Все это выглядит вполне выполнимо, и хотя Migen реализован иначе, чем Verilog или VHDL, он позволяет писать компактный код благодаря всем возможностям Litex.
Создание регистра и подключение его к процессору осуществляется очень просто:
from migen import *
from litex.soc.interconnect.csr import *
from litex.gen import *
class PwmModule(LiteXModule):
def __init__(self, pad, clock_domain="sys"):
self.divider = CSRStorage(size=16, reset=0, description="Clock divider")
Несколько строк, и простое периферийное устройство готово! Это лишь один 16-битный регистр, однако это удивительно! Не нужно беспокоиться о шинах процессора или чем-то подобном. CSRStorage не самый быстрый метод, но для периферийного устройства, такого как PWM, этого вполне достаточно.
Итак, давайте быстро создадим это периферийное устройство!
from migen import *
from litex.soc.interconnect.csr import *
from litex.gen import *
class PwmModule(LiteXModule):
def __init__(self, pad, clock_domain="sys"):
self.enable = CSRStorage(size=1, reset=0, description="Enable the PWM peripheral")
self.divider = CSRStorage(size=16, reset=0, description="Clock divider")
self.maxCount = CSRStorage(size=16, reset=0, description="Max count for the PWM counter")
self.dutycycle = CSRStorage(size=16, reset=0, description="IO dutycycle value")
divcounter = Signal(16, reset=0)
pwmcounter = Signal(16, reset=0)
sync = getattr(self.sync, clock_domain)
sync += [
If(self.enable.storage,
divcounter.eq(divcounter + 1),
If(divcounter >= self.divider.storage,
divcounter.eq(0),
pwmcounter.eq(pwmcounter + 1),
If(pwmcounter >= self.maxCount.storage,
pwmcounter.eq(0),
),
)
)
]
sync += pad.eq(self.enable.storage & (pwmcounter < self.dutycycle.storage))
Несколько дополнительных регистров и несколько внутренних счетчиков для деления тактового сигнала и счетчика PWM. Полное и работоспособное периферийное устройство всего чуть более 30 строк, потрясающе!
А чтобы использовать это периферийное устройство в SoC, нужна всего одна строка:
self.pwm0 = PwmModule(platform.request("pwm0"))
На стороне программного обеспечения нужно инициализировать всего несколько регистров:
pwm0_divider_write(10);
pwm0_maxCount_write(1000);
pwm0_toggle_write(400);
pwm0_enable_write(1);
Полный код для SoC можно найти здесь.
Заключение
Это было весело! От нуля до FPGA SoC с некоторыми пользовательскими периферийными устройствами - потрясающе. И все это с довольно небольшим количеством строк кода. Определенно Litex произвел впечатление!