[Перевод] Знакомство с Litex на Tang Nano 9K

Автору всегда нравилась идея Litex, фреймворка для простой сборки SoC на FPGA, но постоянно не хватало времени, чтобы попробовать. Пришло время изменить это и задокументировать процесс! Мы будем использовать плату FPGA Sipeed Tang Nano 9K, которая является относительно недорогим оборудованием, тем не менее большая часть этой статьи применима к любому поддерживаемому Litex FPGA.

4d3e4f8e1a0378da0adc18ed692fdee6.png

Пришлось кое-что подучить, Litex написан на Python, или, точнее, он использует Migen, инструмент на основе Python, который генерирует Verilog. Автор никогда не писал много кода на Python, не говоря уже о Migen. Таким образом, чтобы освоить основы Litex, необходимо было выполнить следующее:

  1. Разобрать минимальный пример SoC

  2. Настроить SoC с некоторыми периферийными устройствами, уже доступными в LiteX

  3. Написать пользовательскую прошивку и запустить ее на созданном SoC

  4. Создать комфортную среду разработки

Прежде чем продолжить давайте сначала установим 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 бод будет отображено приветствие:

f09a4c422011665a679e29039e0ba837.png

Все это отняло некоторое время, возможно кое-что будет уместно поместить в Docker контейнер.

В итоге мы собрали пример, хотя и не имеем понятия, что и как он делает. К счастью, в комплекте есть исходник, давайте взглянем на него. Автор потратил некоторое время, чтобы удалить все, что мог из примера 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

мы получим полный перечень параметров, вот лишь некоторые из них:

cbe4b91c4fa04f83ea5f39d98d3fbde2.png

Да, тип процессора — это всего лишь аргумент командной строки, потрясающе!

Затем вызывается функция 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 на любом компьютере, независимо от операционной системы и дистрибутива.

Одна проблема решена, теперь нужно навести порядок, автор остановился на следующей структуре директорий:

9ce55983b8b44b21f9e422de884b2f67.png

Директория «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.

Он должен иметь несколько регистров:

  1. Регистры включения, чтобы включать/выключать периферийное устройство PWM

  2. Регистры делителя, чтобы иметь возможность создавать PWM-сигналы с более низкой частотой

  3. Регистры максимального счета, которые должны считать до этого значения, а затем сбрасывать свой внутренний счетчик

  4. Регистры коэффициента заполнения: если счетчик ниже этого значения, состояние выхода должно быть низким, в противном случае — высоким.

Все это выглядит вполне выполнимо, и хотя 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 произвел впечатление!

© Habrahabr.ru