Как стать автором
Обновить

Разработка цифровой аппаратуры нетрадиционным методом: Yosys, SpinalHDL, VexRiscv (ч. 2)

Уровень сложностиСложный
Время на прочтение118 мин
Количество просмотров2.2K

Это вторая и заключительная часть большой статьи. Ознакомиться с первой частью можно по ссылке.

Основная прелесть использования ПЛИС, на мой взгляд, состоит в том, что разработка аппаратуры превращается в программирование со всеми его свойствами: написание и отладка кода как текста на специализированных языках описания аппаратуры (HDL); код распространяется в виде параметризованных модулей (IP-блоков), что позволяет его легко переиспользовать в других проектах; распределенная разработка обширным коллективом разработчиков с системой контроля версий, такой же, как у программистов (Git); и, как и в программировании, ничтожно низкая стоимость ошибки.

Последнее очень важно, так как если при разработке устройства классическим методом разработчик несет вполне существенные затраты на сборку и производство изделия, и любая схемотехническая ошибка или ошибка трассировки печатной платы — это всегда выход на очередную итерацию и попадание на деньги, то при работе с ПЛИС ошибки ничтожны по своей стоимости и легко устранимы. И даже если в серийном изделии обнаруживается ошибка, то её во многих случаях можно устранить очередным апгрейдом прошивки «в поле» без замены изделия. Короче, с приходом ПЛИС разработка цифровой аппаратуры все больше и больше выглядит как программирование, а это, помимо всего прочего, существенно понижает порог вхождения в тему, и все больше программистов становятся разработчиками «железа». А новые люди, в свою очередь, приносят с собой в индустрию новые подходы и принципы.

В этой статье я хочу поделиться своим небольшим опытом «программирования» микросхем ПЛИС и тем, как я постепенно погружался в тему ПЛИСоводства. Изначально я собирался написать небольшую заметку про открытый тулчейн для синтеза Yosys. Потом — про язык SpinalHDL и синтезируемое микропроцессорное ядро VexRiscv, на нём написанное. Потом — про замену микроконтроллеров микросхемами ПЛИС на примере моей отладочной платы «Карно». Но в процессе я погрузился в историю появления Hardware Description Languages (HDL), и когда я начал писать, Остапа, как это часто бывает, понесло... В общем, получилось то, что получилось.

СОДЕРЖАНИЕ

1. Краткий экскурс в историю

2. Появления программируемых логических устройств

3. Производители микросхем ПЛИС

4. Синтез цифровых схем для ПЛИС

5. Yosys Open SYnthesis Suite

6. Как установить утилиты тулчейна Yosys

7. Пример использования открытого тулчейна Yosys

7.1. Установка и настройка «basics-graphics-music»

7.2 Лабораторная работа «01_and_or_not_xor_de_morgan»

7.3 Загрузка битстрима в микросхему ПЛИС

8. Типовой Makefile для синтеза с помощью Yosys

9. Файл описания внешних сигналов и ограничений (LPF, PCF, CST)

10. Особенности синтаксиса Yosys

10.1 Макро YOSYS

10.2 Многомерные массивы сигналов

10.3 Функция возведения в степень ($POW) и оператор **

10.4 Функция не может иметь доступ к сигналу, описанному за её пределами

10.5 Сигналы нулевой или отрицательной размерности

10.6 Сложные битовые манипуляции иногда могут выдавать ошибку о loopback-ах

10.7 Глобализация сигналов

11. Анализируем сообщения от тулов

11.1 Сообщения от утилиты yosys

11.2 Сообщения от утилиты nextpnr

12. Синтезируемая ЭВМ

12.1 Синтезируемое вычислительное ядро VexRiscv

12.2 Синтезируемые системы-на-кристалле на базе VexRiscv

13. Язык описания аппаратуры SpinalHDL

13.1 Базовые конструкции языка SpinalHDL

13.2 FSM, потоки, конвейеры, тактовые домены

13.3 Установка SpinalHDL

13.4 Разбор шаблона SpinalTemplateSbt

13.5 Подготовка Makefile-а для синтеза из SpinalHDL в битстрим

13.6 Анализируем вывод сообщений SpinalHDL

13.7 Симуляция и верификация в SpinalHDL

14. Синтез вычислительного ядра VexRiscv

14.1 Знакомство с репозиторием VexRiscv

14.2 Установка компилятора для архитектуры RISC-V

14.3 Подготавливаем Makefile, LPF и toplevel.v

14.4 Добавляем точку входа для генерации СнК Murax для платы «Карно»

14.5 Модифицируем код программы «hello_world»

14.6 Сборка СнК Murax и ядра VexRiscv

14.7 Запускаем СнК Murax на ПЛИС и тестируем «hello_world»

15. Устройство синтезируемого СнК Murax и ядра VexRiscv

15.1 Структура СнК Murax

15.2 Структура вычислительного ядра VexRiscv

15.3 Плагины вычислительного ядра VexRiscv

16. Эксперименты с ядром VexRiscv и СнК Murax

16.1 Оптимизация на примере плагина RegFilePlugin

16.2 Увеличиваем тактовую частоту ядра используя встроенный PLL

16.3 Увеличиваем производительность инструкций сдвига

17. Добавляем свои аппаратные блоки (IP-блоки)

17.1 Микросекундный машинный таймер MTIME

17.2 Подключаем микросхему SRAM

17.2.1 Разрабатываем контроллер SRAM

17.2.2 Тестируем память и контроллер SRAM

17.2.3 Используем SRAM с функцией malloc

17.2.4 Добавляем блоки вычислителей Div и Mul

17.2.5 Задействуем функцию printf

17.3 Подключаем контроллер прерываний PLIC

17.3.1 Разрабатываем простейший контроллер прерываний MicroPLIC

17.3.2 Задействуем контроллер прерываний MicroPLIC из Си программы

17.4 Подключаем контроллер FastEthernet (MAC)

17.4.1 Как работает Ethernet

17.4.2 Подключаем компонент MacEth

17.4.3 Разрабатываем драйвер для компонента MacEth

17.4.4 Отправляем запрос в DHCP сервер

18. Потактовая симуляция СнК Murax

19. Более сложный пример: VexRiscvWithHUB12ForKarnix

20. Эпилог

Ссылки на репозитории

15. Устройство синтезируемого СнК Murax и ядра VexRiscv

Теперь, когда у нас есть возможность модифицировать, собирать и пересобирать СнК Murax и Сишный код программы для него, настало время погрузиться в детали и посмотреть, как код СнК выглядит на языке SpinalHDL, как задается конфигурация ядра VexRiscv и как к нему подключается различная периферия.

15.1 Структура СнК Murax

Напомню, что код СнК Murax находится в файле ./src/main/scala/vexriscv/demo/Murax.scala, выше мы уже добавляли свой код в этот файл — дополнительную точку входа для сборки. Сейчас посмотрим немного глубже.

СнК Murax по структуре очень похож на СнК Briey, структура которого изображена рис. 20, но вместо AxiCrossbar в СнК Murax используется PipelinedMemoryBus.

Весь код СнК Murax описывается одним классом Murax, который является наследником класса Component, т. е. Murax является аппаратным компонентом. Конструктор класса Murax имеет параметр config, который является структурой типа MuraxConfig, содержащей дефолтные параметры СнК (тактовую частоту, размер ОЗУ и т.д.)

case class Murax(config : MuraxConfig) extends Component{ 
  import config._ 
  ...

Как и у всякого компонента, у Murax есть внешние сигналы ввода/вывода, сигналы сброса и тактирования:

  val io = new Bundle { 
    //Clocks / reset 
    val asyncReset = in Bool() 
    val mainClk = in Bool() 
    //Main components IO 
    val jtag = slave(Jtag()) 
    //Peripherals IO 
    val gpioA = master(TriStateArray(gpioWidth bits)) 
    val uart = master(Uart()) 
    val xip = ifGen(genXip)(master(SpiXdrMaster(xipConfig.ctrl.spi))) 
  } 

Конструкция ifGen(genXip) добавит генерацию следующего за ней кода, если параметр genXip будет установлен в значение True. XiP это флэш память с SPI интерфейсом и функцией «eXecution In Place» — возможностью исполнения кода прямо из флэш памяти. В данном случае она не задействована.

Murax содержит два домена тактирования:

  val systemClockDomain = ClockDomain( 
    clock = io.mainClk, 
    reset = resetCtrl.systemReset, 
    frequency = FixedFrequency(coreFrequency) 
  ) 
  val debugClockDomain = ClockDomain( 
    clock = io.mainClk, 
    reset = resetCtrl.mainClkReset, 
    frequency = FixedFrequency(coreFrequency) 
  ) 

Домен тактирования debugClockDomain содержит JTAG интерфейс и все что с ним связано. Вся остальная периферия, а также ядро VexRiscv находятся в домене systemClockDomain. Далее будем рассматривать только домен systemClockDomain.

Центральным элементом в системном домене является шина, которая называется PipelinedMemoryBus, управляет которой шинный арбитр MuraxMasterArbiter:

    val mainBusArbiter = new MuraxMasterArbiter(pipelinedMemoryBusConfig, bigEndianDBus) 

Арбитр имеет три порта: два ведомых (slave) порта для подключения к ядру VexRiscv (порт инструкций iBus и порт данных dBus) и один порт мастер MainBus.

    val mainBusMapping = ArrayBuffer[(PipelinedMemoryBus,SizeMapping)]()

Подключение ядра VexRiscv к арбитру осуществляется следующим затейливым образом. Сначала инстанциируется класс VexRiscv с передачей ему большой структуры с настройками VexRiscvConfig — она содержит описание того, какие плагины и функции должны быть включены/выключены:

    //Instanciate the CPU 
    val cpu = new VexRiscv(   
      config = VexRiscvConfig( 
        plugins = cpuPlugins += new DebugPlugin(debugClockDomain, hardwareBreakpointCount) 
      ) 
    ) 

После этого, в цикле перебираются все подключенные в данный момент плагины из которых состоит ядро VexRiscv, и каждый плагин линкуется с шинами iBus и dBus - они являются объектами типа Stream. Напомню, что Stream состоит из комплексного сигнала cmd для передачи запроса и комплексного сигнала rsp для ответа. Линковка происходит операторами <>, << или >>, вот так:

    //Checkout plugins used to instanciate the CPU to connect them to the SoC 
    val timerInterrupt = False 
    val externalInterrupt = False 
    for(plugin <- cpu.plugins) plugin match{ 
      case plugin : IBusSimplePlugin => 
        mainBusArbiter.io.iBus.cmd <> plugin.iBus.cmd 
        mainBusArbiter.io.iBus.rsp <> plugin.iBus.rsp 
      case plugin : DBusSimplePlugin => { 
        if(!pipelineDBus) 
          mainBusArbiter.io.dBus <> plugin.dBus 
        else { 
          mainBusArbiter.io.dBus.cmd << plugin.dBus.cmd.halfPipe() 
          mainBusArbiter.io.dBus.rsp <> plugin.dBus.rsp 
        } 
      } 
      case plugin : CsrPlugin        => { 
        plugin.externalInterrupt := externalInterrupt 
        plugin.timerInterrupt := timerInterrupt 
      } 
      case plugin : DebugPlugin         => plugin.debugClockDomain{ 
        resetCtrl.systemReset setWhen(RegNext(plugin.io.resetOut)) 
        io.jtag <> plugin.io.bus.fromJtag() 
      } 
      case _ => 
    } 

Для специфических плагинов, таких как CsrPlugin и DebugPlugin производится линковка дополнительных сигналов, в данном случае это сигналы externalInterrupt, timerInterrupt и io.jtag.

С другой стороны арбитра к шине MainBus подключается шина набортного ОЗУ ram типа MuraxPipelinedMemoryBusRam и мост apbBridge типа PipelinedMemoryBusToApbBridge

//****** MainBus slaves ******** 
    val mainBusMapping = ArrayBuffer[(PipelinedMemoryBus,SizeMapping)]() 
    val ram = new MuraxPipelinedMemoryBusRam( 
      onChipRamSize = onChipRamSize, 
      onChipRamHexFile = onChipRamHexFile, 
      pipelinedMemoryBusConfig = pipelinedMemoryBusConfig, 
      bigEndian = bigEndianDBus 
    ) 
    mainBusMapping += ram.io.bus -> (0x80000000l, onChipRamSize) 

    val apbBridge = new PipelinedMemoryBusToApbBridge( 
      apb3Config = Apb3Config( 
        addressWidth = 20, 
        dataWidth = 32 
      ), 
      pipelineBridge = pipelineApbBridge, 
      pipelinedMemoryBusConfig = pipelinedMemoryBusConfig 
    ) 
    mainBusMapping += apbBridge.io.pipelinedMemoryBus -> (0xF0000000l, 1 MB) 

Шине ОЗУ выделяется область адресного пространства, начиная с 0x80000000 и длиной onChipRamSize. Переменная оnChipRamSize — это член класса, которая задается при инстанциировании класса Murax в точке входа. В нашем случае, для платы «Карно», она установлена в 96 КБ (см. главу «14.4 Добавляем точку входа для генерации СнК Murax для платы «Карно»).

Для моста apbBridge резервируется адресное пространство, начиная с 0xF0000000 и размером 1 МБ. Через мост apbBridge подключается вся стандартная периферия:

//******** APB peripherals ********* 
    val apbMapping = ArrayBuffer[(Apb3, SizeMapping)]() 

    val gpioACtrl = Apb3Gpio(gpioWidth = gpioWidth, withReadSync = true) 
    io.gpioA <> gpioACtrl.io.gpio 
    apbMapping += gpioACtrl.io.apb -> (0x00000, 4 kB) 

    val uartCtrl = Apb3UartCtrl(uartCtrlConfig) 
    uartCtrl.io.uart <> io.uart 
    externalInterrupt setWhen(uartCtrl.io.interrupt) 
    apbMapping += uartCtrl.io.apb  -> (0x10000, 4 kB) 

    val timer = new MuraxApb3Timer() 
    timerInterrupt setWhen(timer.io.interrupt) 
    apbMapping += timer.io.apb     -> (0x20000, 4 kB) 

    ...

Каждому компоненту на шине apbBrigde резервируется своё адресное пространство для регистров управления. Из кода выше видно, что регистры периферийного порта gpioA будут расположены начиная с адреса 0xF0000000 + 0x00000, а регистры UART будут находиться с адреса 0xF0000000 + 0x10000, и т. д.

На этом код СнК Murax заканчивается.

Если мы заходим создать свой периферийный компонент, то нам потребуется сделать его наследником класса Component, содержащего интерфейс определяемый классом Apb3, после чего подключить его к шине apbBrigde в этом же месте и аналогичным образом. Далее я покажу, как это сделать на нескольких примерах.

15.2 Структура вычислительного ядра VexRiscv

Как уже было отмечено выше, вычислительное ядро VexRisсv — это обычный компонент, т. е. является наследником класса Component, описан в файле ./src/main/scala/vexriscv/VexRiscv.scala и инстанциируется строкой кода вида:

val cpu = new VexRiscv( config = VexRiscvConfig( ...) )

где параметр config задает настройки вычислительно ядра, описываемые классом VexRiscvConfig. Посмотрим на структуру этого конфигурационного класса, чтобы понять, какие «цапы» мы можем крутить и в какой степени мы можем изменять структуру ядра VecRiscv:

case class VexRiscvConfig(){ 
  var withMemoryStage = true 
  var withWriteBackStage = true 
  val plugins = ArrayBuffer[Plugin[VexRiscv]]() 
...

На первый взгляд, настроек немного: две булевых переменных withMemoryStage и withWriteBackStage, и переменная plugins описывающая массив абстрактных интерфейсных объектов типа Plugin[VexRiscv]. Булевы переменные задают возможность подключения двух ступеней конвейера: Memory и WriteBack. Напомню, что ядро VexRiscv является конвейерной реализацией архитектуры RISC-V, в которой конвейер может содержать от двух до пяти ступеней: [Fetch], Decode, Execute, [Memory], [WriteBack]. Таким образом, ступени Memory и WriteBack можно включать/выключать, изменяя переменные withMemoryStage и withWriteBackStage. Что касается ступени Fetch, то она подключается при необходимости как плагин добавлением в массив plugins. Плагины — это то, что делает VexRiscv расширяемым, интересным и уникальным. На рис. 27 приведено обобщенное изображение структуры конвейера VexRiscv и того, как некоторые плагины распределены между его ступенями.

Рис. 27. Структура конвейера VexRiscv  в общих чертах.
Рис. 27. Структура конвейера VexRiscv в общих чертах.

15.3 Плагины вычислительного ядра VexRiscv

Посмотрим, какими плагинами на данный момент мы располагаем и какими индивидуальными настройками они обладают. Весь код плагинов находится в подкаталоге ./src/main/scala/vexriscv/plugin/, по одному файлу на плагин:

rz@devbox:~/VexRiscv$ ls -w 120 ./src/main/scala/vexriscv/plugin/
AesPlugin.scala                     FpuPlugin.scala                NoPipeliningPlugin.scala 
BranchPlugin.scala                  HaltOnExceptionPlugin.scala    PcManagerSimplePlugin.scala 
CfuPlugin.scala                     HazardPessimisticPlugin.scala  Plugin.scala 
CsrPlugin.scala                     HazardSimplePlugin.scala       PmpPlugin.scala 
DBusCachedPlugin.scala              IBusCachedPlugin.scala         PmpPluginNapot.scala 
DBusSimplePlugin.scala              IBusSimplePlugin.scala         RegFilePlugin.scala 
DebugPlugin.scala                   IntAluPlugin.scala             ShiftPlugins.scala 
DecoderSimplePlugin.scala           MemoryTranslatorPlugin.scala   SingleInstructionLimiterPlugin.scala 
DivPlugin.scala                     Misc.scala                     SrcPlugin.scala 
DummyFencePlugin.scala              MmuPlugin.scala                StaticMemoryTranslatorPlugin.scala 
EmbeddedRiscvJtag.scala             Mul16Plugin.scala              VfuPlugin.scala 
ExternalInterruptArrayPlugin.scala  MulDivIterativePlugin.scala    YamlPlugin.scala 
Fetcher.scala                       MulPlugin.scala 
FormalPlugin.scala                  MulSimplePlugin.scala 

Детальное описание большинства плагинов можно найти в README файле в репозитории VexRiscv по ссылке.

Ниже я приведу описания только части наиболее важных и часто используемых плагинов с некоторыми поясняющими комментариями. С этими плагинами мы далее проведем ряд экспериментов.

DBusSimplePlugin — создает шину данных для вычислительного ядра, без кеширования данных. Позволяет подключать ядро к шинам вида Axi4Shared, Avalon, Withbone, AhbLite3, Bmb, PipelinedMemoryBus. Последняя является самой простой и используется в СнК Murax.

Ниже приведено определение класса DBusSimplePlugin, из которого можно понять, какими фичами обладает данный плагин.

class DBusSimplePlugin(catchAddressMisaligned : Boolean = false, 
                       catchAccessFault : Boolean = false, 
                       earlyInjection : Boolean = false, 
				/*, idempotentRegions : (UInt) => Bool = (x) => False*/ 
                       emitCmdInMemoryStage : Boolean = false, 
                       onlyLoadWords : Boolean = false, 
                       withLrSc : Boolean = false, 
                       val bigEndian : Boolean = false, 
                       memoryTranslatorPortConfig : Any = null) extends Plugin[VexRiscv] with
				DbusAccessService

Видно, что DBusSimplePlugin имеет порт для подключения транслятора адресного пространства памяти (MMU), который тоже является отдельным плагином.

DBusCachedPlugin — создает шину данных для вычислительно ядра, но с организацией кеша данных. Позволяет подключать ядро к шинам: Axi4Shared, Avalon, Withbone, Bmb и PipelinedMemoryBus. Используется в СнК Briey с шиной Axi4Shared. Как и его упрощенный собрат имеет порт для подключения транслятора адресного пространства памяти (MMU).

Определение класса DBusCachedPlugin и его параметров следующее:

class DBusCachedPlugin(val config : DataCacheConfig, 
                       memoryTranslatorPortConfig : Any = null, 
                       var dBusCmdMasterPipe : Boolean = false, 
                       dBusCmdSlavePipe : Boolean = false, 
                       dBusRspSlavePipe : Boolean = false, 
                       relaxedMemoryTranslationRegister : Boolean = false, 
                       csrInfo : Boolean = false, 
                       tightlyCoupledAddressStage : Boolean = false)  extends Plugin[VexRiscv] with 
				DBusAccessService with DBusEncoding 
				Service with VexRiscvRegressionArg

Данный плагин, используя сервис от плагина CsrPlugin, может экспортировать читаемый csrInfo регистр 0xCC0 с двумя полями [7:0] и [27:20] содержащий информацию о кеше:

    if(csrInfo){ 
      val csr = service(classOf[CsrPlugin]) 
      csr.r(0xCC0, 0 ->  U(cacheSize/wayCount),  20 ->  U(bytePerLine)) 
    } 

IBusSimplePlugin — обеспечивает шину для передачи инструкций в вычислительное ядро, без организации кеша. Позволяет подключать ядро к шинам вида Avalon, Withbone, AhbLite3, Bmb, PipelinedMemoryBus и Axi4ReadOnly. Используется в СнК Murax. По сути, данный плагин является Fetch ступенью конвейера.

Описание класса IBusSimplePlugin и его параметров выглядит следующим образом:

class IBusSimplePlugin(    resetVector : BigInt, 
                       val cmdForkOnSecondStage : Boolean, 
                       val cmdForkPersistence : Boolean, 
                       val catchAccessFault : Boolean = false, 
                           prediction : BranchPrediction = NONE, 
                           historyRamSizeLog2 : Int = 10, 
                           keepPcPlus4 : Boolean = false, 
                           compressedGen : Boolean = false, 
                       val busLatencyMin : Int = 1, 
                       val pendingMax : Int = 7, 
                           injectorStage : Boolean = true, 
                       val rspHoldValue : Boolean = false, 
                       val singleInstructionPipeline : Boolean = false, 
                       val memoryTranslatorPortConfig : Any = null, 
                           relaxPredictorAddress : Boolean = true, 
                           predictionBuffer : Boolean = true, 
                           bigEndian : Boolean = false, 
                           vecRspBuffer : Boolean = false 
                      ) extends IbusFetcherImpl

Данный плагин имеет множество интересных параметров. Например, параметр resetVector задает адрес в памяти, с которого вычислительное ядро начнет исполнение программы; параметр compressedGen позволяет подключить код для декодера сжатого набора инструкций RV32C; параметр prediction позволяет подключать код предсказателя ветвлений, тоже в виде отдельного плагина.

IBusCachedPlugin — обеспечивает шину для передачи инструкций в вычислительный модуль с использованием кеша. Функционал этого плагина аналогичен IBusSimplePlugin. Объявление класса IBusCachedPlugin выглядит следующим образом:

class IBusCachedPlugin(resetVector : BigInt = 0x80000000l, 
                       relaxedPcCalculation : Boolean = false, 
                       prediction : BranchPrediction = NONE, 
                       historyRamSizeLog2 : Int = 10, 
                       compressedGen : Boolean = false, 
                       keepPcPlus4 : Boolean = false, 
                       val config : InstructionCacheConfig, 
                       memoryTranslatorPortConfig : Any = null, 
                       injectorStage : Boolean = false, 
                       withoutInjectorStage : Boolean = false, 
                       relaxPredictorAddress : Boolean = true, 
                       predictionBuffer : Boolean = true)  extends IbusFetcherImpl

DecoderSimplePlugin — обеспечивает другие плагины сервисом дешифрации инструкций.

class DecoderSimplePlugin(catchIllegalInstruction : Boolean = false, 
                          throwIllegalInstruction : Boolean = false, 
                          assertIllegalInstruction : Boolean = false, 
                          forceLegalInstructionComputation : Boolean = false, 
                          decoderIsolationBench : Boolean = false, 
                          stupidDecoder : Boolean = false) extends Plugin[VexRiscv] with DecoderService

Имеет опции для включения/отключения отлова нелегальных (нереализованных) инструкций: если параметр catchIllegalInstruction установлен в True, то ядро будет формировать trap и соответствующее исключение, если на вход дешифратора попадет инструкция несоответствующая текущему набору.

Добавление инструкций в дешифратор осуществляется вызовом метода decoderService.add следующим образом:

decoderService.add(
      //Bit pattern of the new instruction
      key = M"0000011----------000-----0110011",

      //Decoding specification when the 'key' pattern is recognized in the instruction
      List(
        IS_SIMD_ADD              -> True, //Inform the pipeline that the current instruction is a SIMD_ADD instruction
        REGFILE_WRITE_VALID      -> True, //Notify the hazard management unit that this instruction writes to the register file
        BYPASSABLE_EXECUTE_STAGE -> True, //Notify the hazard management unit that the instruction result is already accessible in the EXECUTE stage (Bypass ready)
        BYPASSABLE_MEMORY_STAGE  -> True, //Same as above but for the memory stage
        RS1_USE                  -> True, //Notify the hazard management unit that this instruction uses the RS1 value
        RS2_USE                  -> True  //Same than above but for RS2.
      )
    )

RegFilePlugin — обеспечивает стандартный регистровый файл согласно спецификации RISC-V. Описание класса RegFilePlugin следующее:

class RegFilePlugin(regFileReadyKind : RegFileReadKind, 
                    zeroBoot : Boolean = false, 
                    x0Init : Boolean = true, 
                    writeRfInMemoryStage : Boolean = false, 
                    readInExecute : Boolean = false, 
                    syncUpdateOnStall : Boolean = true, 
                    rv32e : Boolean = false, 
                    withShadow : Boolean = false //shadow registers aren't transition hazard 				free 
                   ) extends Plugin[VexRiscv] with RegFileService

Данный плагин имеет ряд опций для оптимизации. Параметр regFileReadyKind задает тип используемых ячеек памяти для регистров: SYNC или ASYNС. Первый вариант (SYNC) — будут задействованы синхронные D-триггеры (задержка в 1 такт), хорошо ложится на стандартные LUT ячейки ПЛИС. Вариант ASYNC позволяет использовать асинхронные ячейки памяти SRAM и BRAM (без задержки), подходит для проектирования микросхем (ASIC).

HazardSimplePlugin — реализует очень важный для конвейерных процессоров механизм называемый «Hazard», суть которого состоит в том, чтобы отслеживать взаимозависимости между разными инструкциями, исполняемыми на разных ступенях конвейера в один и тот же момент времени. В случае выявления зависимостей, данный плагин может придержать (остановить) исполнение инструкции в ступени дешифратора (Decoder) или, если есть такая возможность, передать результат с более поздних ступеней, чтобы не задерживать исполнение. Описание класса HazardSimplePlugin и его параметров приведено ниже:

class HazardSimplePlugin(bypassExecute : Boolean = false, 
                         bypassMemory: Boolean = false, 
                         bypassWriteBack: Boolean = false, 
                         bypassWriteBackBuffer : Boolean = false, 
                         pessimisticUseSrc : Boolean = false, 
                         pessimisticWriteRegFile : Boolean = false, 
                         pessimisticAddressMatch : Boolean = false) extends Plugin[VexRiscv] with HazardService

Параметры bypassExecute, bypassMemory, bypassWriteBack разрешают передачу результата из соответствующих ступеней на стадию (ступень) Decoder.

IntAluPlugin — плагин, реализующий целочисленное АЛУ. Встраивается в стадию Execute и реализует такие инструкции как ADD, SUB, SLT, SLTU, XOR, OR, AND, LUI и AUIPC. Это очень простой плагин не имеющий параметров, определение класса IntAluPlugin выглядит следующим образом:

class IntAluPlugin extends Plugin[VexRiscv]

LightShifterPlugin и FullBarrelShifterPlugin — эти плагины реализуют инструкции побитного сдвига SLL, SRL и SRA. Плагин LightShifterPlugin является простой многотактовой реализацией сдвига, выполняющий задачу за N тактов, где N — число бит на которое требуется выполнить сдвиг. FullBarrelShifterPlugin — выполняет полный сдвиг на заданное число бит за один такт. Полная реализация является очень ресурсоемкой (требует большого количества логических элементов) и вносит большую задержку в исполнение, а значит существенно уменьшает максимальную тактовую частоту ядра. Определения классов:

class FullBarrelShifterPlugin(earlyInjection : Boolean = false) extends Plugin[VexRiscv]

и

class LightShifterPlugin extends Plugin[VexRiscv]

FpuPlugin — плагин, реализующий инструкции с плавающей запятой («Floating Point Unit» - FPU). Блок FPU может быть как встроенным в конвейер, так и использовать внешний FPU блок (внешний сопроцессор). Описание класса FpuPlugin выглядит следующим образом:

class FpuPlugin(externalFpu : Boolean = false, 
                var simHalt : Boolean = false, 
                val p : FpuParameter) extends Plugin[VexRiscv] with VexRiscvRegressionArg

Параметр p является сложной структурой с настройками блока FPU, определение этой структуры выглядит следующим образом:

case class FpuParameter( withDouble : Boolean, 
                         asyncRegFile : Boolean = false, 
                         mulWidthA : Int = 18, 
                         mulWidthB : Int = 18, 
                         schedulerM2sPipe : Boolean = false, 
                         sim : Boolean = false, 
                         withAdd : Boolean = true, 
                         withMul : Boolean = true, 
                         withDivSqrt : Boolean = false, 
                         withDiv : Boolean = true, 
                         withSqrt : Boolean = true, 
                         withShortPipMisc : Boolean = true)

В файле README.md репозитория VexRiscv приводится детальное описание устройства блока FPU и его настроек. Ознакомиться можно по ссылке.

PcManagerSimplePlugin — простой плагин реализует регистр счетчика команд (PC). Данный плагин в текущей версии ядра не используется, а регистр счетчика команд интегрирован в базовый функционал ядра, в класс VexRiscv.

BranchPlugin — данный плагин осуществляется предсказание ветвлений с целью оптимизации хода исполнения программы, так как каждый «промах» (загрузка неверной инструкции в декодер) может иметь стоимость в 2 или 4 такта. Этот плагин уменьшает негативное влияния команд ветвления на кеш инструкций. Плагин реализует все инструкции ветвления: JAL, JALR, BEQ, BNE, BLT, BGE, BLTU и BGEU. Определение класса BranchPlugin:

class BranchPlugin(earlyBranch : Boolean, 
                   catchAddressMisaligned : Boolean = false, 
                   fenceiGenAsAJump : Boolean = false, 
                   fenceiGenAsANop : Boolean = false, 
                   decodeBranchSrc2 : Boolean = false) extends Plugin[VexRiscv] with PredictionInterface

В VexRiscv реализованы следующие алгоритмы предсказания ветвлений, которые задаются параметром prediction и плагина IBusSimplePlugin или IBusCachedPlugin (напомню, что эти два плагина реализуют Fetch ступень конвейера):

  • NONE — без предсказания, любое изменение регистра PC инструкциями ветвления приводит к полному набору «штрафных» циклов (от 2 до 4).

  • STATIC — спекулятивное предсказание, фактически всегда выбирается одна и та же ветвь выполнения инструкции ветвления. Если предсказание сбылось, то «штраф» уменьшается до одного дополнительного такта, иначе «штраф» будет полным.

  • DYNAMIC — для предсказания используется статистика, т. н. BHT кеш, который определяет какое из направлений ветвления будет более вероятным.

  • DYNAMIC_TARGET — использует для предсказания т. н. «целевой буфер ветвления с прямым отображением» (BTB) в ступени Fetch, который сохраняет значение регистра PC, где расположена инструкция ветвления, значение PC по которому был осуществлен переход и два бита истории/вероятности. Это наиболее эффективный вариант предсказания ветвлений, так как если предсказание угадано, то никаких «штрафных» тактов не происходит. Существенным недостатком является очень длинная комбинационная цепочка тянущаяся от кеша предсказаний до регистра счетчика команд, проходящая через интерфейс ветвлений. Эта комбинационная цепочка существенно ограничивает максимальную тактовую частоту ядра.

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

16. Эксперименты с ядром VexRiscv и СнК Murax

Теперь, когда мы немного ознакомились с внутренним устройством вычислительного ядра VexRiscv, настало время немного поэкспериментировать с различными параметрами ядра VexRiscv и СнК Murax, то есть начать его модифицировать под свои нужны.

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

16.1 Оптимизация на примере плагина RegFilePlugin

Напомню, что у плагина RegFilePlugin имеется параметр, позволяющий задавать тип используемой памяти для организации регистрового файла. В СнК Murax по умолчанию используется синхронный вариант построения регистров, т. е. regFileReadyKind установлена в значение SYNC. Так как микросхема ПЛИС Lattice ECP5 имеет распределенные блоки двухпортовой статической памяти, из которых можно соорудить асинхронный регистровый файл, то резонно проверить насколько это может быть (или не быть) эффективным.

Отредактируем файл Murax.scala — найдем в нем добавление плагина, и заменим строку кода:

new RegFilePlugin( 
        regFileReadyKind = plugin.SYNC, 
        zeroBoot = false 
      ), 

на строку:

new RegFilePlugin( 
        regFileReadyKind = plugin.ASYNC, 
        zeroBoot = false 
      ), 

Запустим пересборку проекта командой make и понаблюдаем за статистикой утилизируемых ресурсов и рассчитываемых максимальных значений тактовых частот. Вот что мы должны увидеть:

Для режима ASYNC:

Info: Device utilisation: 
..
Info:             TRELLIS_FF:  1325/24288     5% 
Info:           TRELLIS_COMB:  2284/24288     9% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
Info: Max frequency for clock         'io_clk2574.47 MHz (PASS at 25.00 MHz) 
Info: Max frequency for clock '" class="formula inline">glbnetTRELLIS_IO_IN': 128.97 MHz (PASS at 12.00 MHz) 

Сравним эти же показатели для режима SYNC который используется по умолчанию:

Info:             TRELLIS_FF:  1379/24288     5% 
Info:           TRELLIS_COMB:  2346/24288     9% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
Info: Max frequency for clock         'io_clk2569.80 MHz (PASS at 25.00 MHz) 
Info: Max frequency for clock '" class="formula inline">glbnetTRELLIS_IO_IN': 149.30 MHz (PASS at 12.00 MHz) 

Видно, что в режиме ASYNC уменьшился расход Flip-Flop триггеров и комбинационных ячеек LUT-4. Также видно, что максимально допустимая частота для основного тактового сигнала io_clk25 существенно подросла — с 69.80 МГц до 74.47. МГц. Это означает, что мы, теоретически, имеем возможность увеличить частоту тактирования ядра до 74 МГц или даже немного выше! Это можно сделать, не изменяя кварцевого осциллятора, путем преобразования частоты с помощью встроенного в микросхему ПЛИС блока PLL.

16.2 Увеличиваем тактовую частоту ядра используя встроенный PLL

Если кратко, то PLL или «Phase-locked Loop» («Фазовая автоподстройка частоты», «ФАПЧ») это такой блок аппаратуры, который, используя ряд аналоговых ухищрений, позволяет делить и умножать тактовые частоты в определенных пределах и с определенной точностью. Микросхема ПЛИС Lattice ECP5 содержит два таких аппаратных блока, которые называются EHXPLLL. Тулчейн Yosys поддерживает эти блоки, а значит, мы можем задействовать один из них для преобразования частоты, поступающей от кварцевого осциллятора распаянного на плате «Карно» номиналом 25.0 МГц в (почти) любую другую частоту. В этом примере я продемонстрирую, как добавить PLL, настроить его, получить на выходе тактовый сигнал частотой 75.0 МГц и тактировать от него СнК Murax и вычислительное ядро в его составе.

PLL — это достаточно сложный блок аппаратуры, имеющий ряд параметров, изменяя которые можно получать разные характеристики выходного тактового сигнала. Чтобы облегчить работу дизайнеру, в составе тулчейна Yosys имеется утилита ecppll, задав которой пару исходных параметров (значения частоты входного и выходного сигнала), можно получить код на языке Verilog. Этот код при синтезе с помощью Yosys подключит и задействует один из аппаратных блоков EHXPLLL.

Запустим ecppll со следующими параметрами:

rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ ecppll --clkin_name in_clk25 -i 25 --clkout0_name out_clk75 -o 75 -f pll25to75.v 
Pll parameters: 
Refclk divisor: 1 
Feedback divisor: 3 
clkout0 divisor: 8 
clkout0 frequency: 75 MHz 
VCO frequency: 600 

Здесь мы указываем утилите номиналы входной и выходной частоты, а также наименования сигнальных линий для входа и выхода. Утилита ecppll сформирует и запишет код на языке Verilog для модуля PLL, с использованием встроенного блока EHXPLLL, в файл pll25to75.v.

Посмотрим на его содержимое:

pll25to75.v
// diamond 3.7 accepts this PLL 
// diamond 3.8-3.9 is untested 
// diamond 3.10 or higher is likely to abort with error about unable to use feedback signal 
// cause of this could be from wrong CPHASE/FPHASE parameters 
module pll 
( 
    input in_clk25, // 25 MHz, 0 deg 
    output out_clk75, // 75 MHz, 0 deg 
    output locked 
); 
(* FREQUENCY_PIN_CLKI="25" *) 
(* FREQUENCY_PIN_CLKOP="75" *) 
(* ICP_CURRENT="12" *) (* LPF_RESISTOR="8" *) (* MFG_ENABLE_FILTEROPAMP="1" *) (* MFG_GMCREF_SEL="2" *) 
EHXPLLL #( 
        .PLLRST_ENA("DISABLED"), 
        .INTFB_WAKE("DISABLED"), 
        .STDBY_ENABLE("DISABLED"), 
        .DPHASE_SOURCE("DISABLED"), 
        .OUTDIVIDER_MUXA("DIVA"), 
        .OUTDIVIDER_MUXB("DIVB"), 
        .OUTDIVIDER_MUXC("DIVC"), 
        .OUTDIVIDER_MUXD("DIVD"), 
        .CLKI_DIV(1), 
        .CLKOP_ENABLE("ENABLED"), 
        .CLKOP_DIV(8), 
        .CLKOP_CPHASE(4), 
        .CLKOP_FPHASE(0), 
        .FEEDBK_PATH("CLKOP"), 
        .CLKFB_DIV(3) 
    ) pll_i ( 
        .RST(1'b0), 
        .STDBY(1'b0), 
        .CLKI(io_clk25), 
        .CLKOP(clk75), 
        .CLKFB(clk75), 
        .CLKINTFB(), 
        .PHASESEL0(1'b0), 
        .PHASESEL1(1'b0), 
        .PHASEDIR(1'b1), 
        .PHASESTEP(1'b1), 
        .PHASELOADREG(1'b1), 
        .PLLWAKESYNC(1'b0), 
        .ENCLKOP(1'b0), 
        .LOCK(locked) 
        ); 
endmodule 

Видно, что утилита ecppll произвела расчет значений большого числа параметров и подставила их в модуль EHXPLLL. Описание всех параметров доступно в документации на микросхему, но рассчитать их значения вручную будет не просто.

Теперь осталось подключить этот файл в toplevel.v и произвести коммутацию тактовых сигналов: внешний сигнал io_clk25 необходимо подать на вход in_clk25 модуля pll, а выходной сигнал out_clk75 через дополнительный проводник clk75 подсоединить к сигналу тактирования mainClk модуля Murax. На Verilog-е это выглядит так:

toplevel.v
`timescale 1ns / 1ps 

`include "pll25to75.v" 

module toplevel( 
    input   io_clk25, 
    input   [3:0] io_key, 
    output  [3:0] io_led, 
    input   io_core_jtag_tck, 
    output  io_core_jtag_tdo, 
    input   io_core_jtag_tdi, 
    input   io_core_jtag_tms, 
    output  io_uart_debug_txd, 
    input   io_uart_debug_rxd 
  ); 

  assign io_led[0] = io_gpioA_write[0]; 
  assign io_led[1] = io_gpioA_write[1]; 
  assign io_led[2] = io_gpioA_write[2]; 
  assign io_led[3] = io_gpioA_write[3]; 

  wire [31:0] io_gpioA_read; 
  wire [31:0] io_gpioA_write; 
  wire [31:0] io_gpioA_writeEnable; 
 
  assign io_gpioA_read[0] = io_key[0]; 
  assign io_gpioA_read[1] = io_key[1]; 
  assign io_gpioA_read[2] = io_key[2]; 
  assign io_gpioA_read[3] = io_key[3]; 

  wire clk75; 

  pll i_pll(.in_clk25(io_clk25), .out_clk75(clk75)); 

  Murax murax ( 
    .io_asyncReset(io_key[3]), 
    //.io_mainClk (io_clk25), 
    .io_mainClk (clk75), 
    .io_jtag_tck(io_core_jtag_tck), 
    .io_jtag_tdi(io_core_jtag_tdi), 
    .io_jtag_tdo(io_core_jtag_tdo), 
    .io_jtag_tms(io_core_jtag_tms), 
    .io_gpioA_read       (io_gpioA_read), 
    .io_gpioA_write      (io_gpioA_write), 
    .io_gpioA_writeEnable(io_gpioA_writeEnable), 
    .io_uart_txd(io_uart_debug_txd), 
    .io_uart_rxd(io_uart_debug_rxd) 
  ); 

endmodule 

Далее, укажем программе «hello_world» на тот факт, что тактовая частота процессора изменилась, это требуется для правильного расчета задержек в программе. Для этого в файле ./src/main/c/murax/hello_world/src/murax.h заменим значение константы SYSTEM_CLOCK_HZ на 75000000L и выполним пересборку Си программы:

rz@devbox:~/VexRiscv/src/main/c/murax/hello_world$ make clean && make

Не забываем установить regFileReadyKind = plugin.ASYNC для плагина RegFilePlugin (см. предыдущую главу), а также указать для СнК Murax новую частоту в параметре coreFrequency = 75 Mhz, после чего запускаем сборку проекта и наблюдаем:

rz@devbox:~/VexRiscvWithKarnix/scripts/Murax/Karnix$ make
...
Info: Device utilisation: 
Info:             TRELLIS_IO:    15/  197     7% 
Info:                   DCCA:     2/   56     3% 
Info:                 DP16KD:    48/   56    85% 
...
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  1325/24288     5% 
Info:           TRELLIS_COMB:  2270/24288     9% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
...
Info: Routing globals... 
Info:     routing clock net io_core_jtag_tckInfo:     routing clock net $glbnet" class="formula inline">clk75 using global 1 
...
Info: Max frequency for clock                          'clk75': 75.55 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock 'io_core_jtag_tck$TRELLIS_IO_IN': 124.66 MHz (PASS at 12.00 MHz) 

Хохо! Наш дизайн прошел контроль STA по самому краешку — 75.00 МГц из 75.55 МГц разрешенных.

Подключаем плату «Карно», загружаем битстрим командой make upload и наблюдаем за тем, как «пляшут» светодиоды в три раза быстрее прежнего.

16.3 Увеличиваем производительность инструкций сдвига

В главе «15.3 Плагины вычислительного ядра VexRiscv» я упоминал о том, что автор VexRiscv снабдил своё вычислительное ядро двумя реализациями (двумя плагинами) инструкций побитового сдвига: LightShifterPlugin — многотактовая реализация, выполняющая сдвиг на N бит за N тактов, и FullBarrelShifterPlugin — полная реализация («бочковой сдвигатель»), выполняющая операции сдвига на N бит за 1 такт. Очевидно, что последний является более интересным с точки зрения производительности, но имеет существенный недостаток — потребляет большое количество логических элементов и имеет достаточно длинную цепочку комбинационной логики (длинный критический путь), что может существенно увеличить время работы стадии конвейера Execute и сократить максимальную частоту тактирования ядра. По умолчанию в СнК Murax используется облегченный вариант, сделано это с целью экономии ресурсов микросхемы и демонстрации минимально возможного варианта. Давайте же перенастроим наше вычислительное ядро на использование полной реализации сдвигателя и посмотрим, во что это выливается.

Прежде чем мы приступим к модификации аппаратуры, нам необходимо добавить небольшой код на языке Си в программу «hello_world», для измерения производительности инструкций сдвига. Для этого воспользуемся уже имеющимся в СнК Murax блоком таймеров MuraxApb3Timer, который состоит из двух 16-ти битных таймеров timerA и timerB и одного общего 16-ти битного прескейлера (делителя частоты). Проинициализируем прескейлер так, чтобы таймер вел расчет в миллисекундах, т. е. занесем в прескейлер значение (SYSTEM_CLOCK_HZ / 1000000 - 1). Далее, добавим в код функции delay() ассемблерную инструкцию сдвига на 16, а время исполнения функции delay() будем измерять одним из таймеров и выводить в порт UART в виде шестнадцатеричного числа.

Файл main.c программы «hello_world» примет следующие изменения:

main.c
void delay(uint32_t loops){ 
        for(int i=0;i<loops;i++){ 
                //int tmp = GPIO_A->OUTPUT; 
                asm volatile ("slli t1,t1,0x10"); 
        } 
} 

void printhex(unsigned int number){ 
        unsigned int mask = 0xf0000000; 
        static char hex_digits[11] = {0,0,0,0,0,0,0,0,'\r','\n','\0'}; 

        for(int i = 0; i < 8; i++) { 
                int digit = (number & mask) >> 4*(7-i); 
                if(digit < 0x0a) 
                        hex_digits[i] = digit + 0x30; 
                else 
                        hex_digits[i] = (digit - 0x0a) + 'A'; 
                mask = mask >> 4; 
        } 

        print(hex_digits); 
} 

        const int nleds = 4; 
        const int nloops = 1000000; 
        TIMER_PRESCALER->LIMIT = 0xFFFF; // Set max possible clock divider 
        while(1){ 
                unsigned int shift_time; 
                println("Hello world, this is VexRiscv!"); 
                for(unsigned int i=0;i<nleds-1;i++){ 
                        GPIO_A->OUTPUT = 1<<i; 
                        TIMER_A->CLEARS_TICKS = 0x00020002; 
                        TIMER_A->LIMIT = 0xFFFF; 
                        delay(nloops); 
                        shift_time = TIMER_A->VALUE; 
                } 
                for(unsigned int i=0;i<nleds-1;i++){ 
                        GPIO_A->OUTPUT = (1<<(nleds-1))>>i; 
                        delay(nloops); 
                } 

                printhex(shift_time); 
        } 

Жирным шрифтом показаны новые или измененные строки кода. Запускаем сборку и проверяем что у нас получилось:

rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ make clean && make

Если все прошло успешно, т. е. в программе нет синтаксических ошибок, то произойдет компиляция, синтез и полная сборка битстрима.

Как обычно, подключаем плату «Карно» и загружаем битстрим командой make upload. После этого подключаемся к отладочному UART с помощью терминала minicom и наблюдаем сообщения вида:

rz@butterfly:~ % sudo minicom -b 115200 -D /dev/ttyU1

Hello world, this is VexRiscv!                                                   
000001AC                                                                         
Hello world, this is VexRiscv!                                                   
000001AC                                                                         
Hello world, this is VexRiscv!                                                   
000001AB                                                                         
Hello world, this is VexRiscv!                                                   
000001AB  

Выводимое шестнадцатеричное число — это количество отсчетов таймера, которое произошло за момент выполнения модифицированной функции delay(). Мы можем приближенно пересчитать во сколько тактов исполняется одна итерация цикла внутри этой функции: (0xFFFF+1) * 0x01AC / 1000000 = 28 тактов на цикл. Где 0xFFFF — делитель (прескейлер), а 1000000 число итераций цикла.

Теперь модифицируем СнК Murax и вместо LightShifterPlugin подключим FullBarrelShifterPlugin. Для этого отредактируем файл Murax.scala, найдем в нём переменную cpuPlugins, которой присваивается инициализированный массив плагинов, закомментируем плагин LightShifterPlugin и добавим FullBarrelShifterPlugin:

cpuPlugins = ArrayBuffer(
      new IBusSimplePlugin( 
      ...
      ),
      new SrcPlugin( 
        ...
      ), 
      //new LightShifterPlugin, 
      new FullBarrelShifterPlugin, 
      new HazardSimplePlugin( 
        ...
      ),

Еще раз пересоберем проект командой make clean && make, по окончанию сборки загрузим битстрим командой make upload, подключим minicom и наблюдаем сообщения вида:

Hello world, this is VexRiscv! 
000000C7 
Hello world, this is VexRiscv! 
000000C7 
Hello world, this is VexRiscv! 
000000C6 
Hello world, this is VexRiscv! 
000000C6 
Hello world, this is VexRiscv! 
000000C7 

Видно, что число отсчетов таймера значительно сократилось, а значит функция delay() стала работать быстрее. Пересчитаем в количество тактов: (0xFFFF+1) * 0x00C7 / 1000000 = 13. Т.е. время исполнения одной итерации цикла внутри delay() сократилось на 15 тактов. И это полностью соответствует теории: машинная команда slli t1,t1,0x10 которая составляет тело цикла, выполняет сдвиг регистра t1 (временный регистр #1) влево на 16 бит. В первом случае она исполняется за 16 тактов, во втором — за один такт, т. е. на 15 тактов быстрее!

А сейчас посмотрим на статистику, полученную при синтезе.

При использовании плагина LightShifterPlugin, выполняющего облегченный вариант сдвига на N бит за N тактов:

Info: Device utilisation: 
Info:             TRELLIS_IO:    15/  197     7% 
Info:                   DCCA:     2/   56     3% 
Info:                 DP16KD:    48/   56    85% 
...
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  1325/24288     5% 
Info:           TRELLIS_COMB:  2255/24288     9% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 

Info: Max frequency for clock                          '$glbnet$clk75': 77.29 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock '$glbnet$io_core_jtag_tck$TRELLIS_IO_IN': 118.46 MHz (PASS at 12.00 MHz) 

При использовании «полного бочкового сдвигателя», реализуемого плагином FullBarrelShifterPlugin, получаем следующую статистику:

Info: Device utilisation: 
Info:             TRELLIS_IO:    15/  197     7% 
Info:                   DCCA:     2/   56     3% 
Info:                 DP16KD:    48/   56    85% 
...
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  1353/24288     5% 
Info:           TRELLIS_COMB:  2447/24288    10% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
Info: Max frequency for clock                          'clk75': 79.73 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock 'io_core_jtag_tck$TRELLIS_IO_IN': 124.01 MHz (PASS at 12.00 MHz) 

Такое поведение тулчейна мне объяснить сложно. Моё предположение состоит в том, что код «бочкового сдвигателя» хорошо оптимизируется и синтезатором и плейсером. Отчасти это подтверждается статистикой, полученной после выполнения размещения (до оптимизации маршрутов) — уже на этом этапе видно, что «бочковой сдвигатель» более эффективен с точки зрения STA.

Статистика до оптимизации:

Для LightShifterPlugin:

Info: Max frequency for clock                          '$glbnet$clk75': 49.10 MHz (FAIL at 75.00 MHz) 
Info: Max frequency for clock '$glbnet$io_core_jtag_tck$TRELLIS_IO_IN': 156.32 MHz (PASS at 12.00 MHz) 

Для FullBarrelShifterPlugin:

Info: Max frequency for clock                          '$glbnet$clk75': 51.05 MHz (FAIL at 75.00 MHz) 
Info: Max frequency for clock '$glbnet$io_core_jtag_tck$TRELLIS_IO_IN': 124.73 MHz (PASS at 12.00 MHz) 

17. Добавляем свои аппаратные блоки (IP-блоки)

Конфигурация СнК Murax и ядра VexRiscv в его составе, которая представлена по умолчанию в репозитории VexRiscv, служит для целей демонстрации и имеет очень ограниченные возможности. Настало время посмотреть, как можно расширить функционал Murax и создать свою СнК, подходящую для решения широкого спектра практических задач.

17.1 Микросекундный машинный таймер MTIME

Для дальнейшего исследования и экспериментов нам понадобится микросекундный машинный таймер, который монотонно возрастает и никогда не переполняется (или делает это очень редко). С таким таймером удобно работать при проведении измерений производительности, как, например, в предыдущей главе. Добавить такой таймер не сложно, язык SpinalHDL уже содержит все необходимые примитивы, требуется только сложить их вместе и подвязать получившийся IP-блок к периферийной шине Apb3. Сейчас мы это и проделаем.

Схема нашего машинного микросекундного таймера будет содержать два регистра-счетчика. Первый счетчик, counter размером 8 бит, будет отсчитывать число тактов до полной микросекунды, опираясь на значение параметра clockMHz, который будет задаваться извне и содержать значение частоты системного клока в мегагерцах. В нашем случае, при частоте clockMHz = 75.0 МГц, этот счетчик будет принимать максимальное значение 75 - 1 = 74. Второй регистр-счетчик, microCounter размерностью 32 бита, это собственно наш микросекундный таймер, который будет прирастать на единицу каждый раз когда первый счетчик достигает значения clockMHz — 1. Сигнал io.micros будет ссылаться на значение счетчика microCounter и представлять внешний интерфейс (сигнал) нашего машинного таймера. Вот как это выглядит на языке SpinalHDL:

package mylib 
import spinal.core._ 
import spinal.lib._ 
import spinal.lib.bus.amba3.apb._ 
import spinal.lib.bus.misc._ 
case class MachineTimer(clockMHz: Int = 25) extends Component { 
  val io = new Bundle { 
    val micros = out UInt(32 bits) 
  } 
  val counter = Reg(UInt(8 bits)) 
  val microCounter = Reg(UInt(32 bits)) 
  io.micros := microCounter 
  counter := counter + 1 
  when (counter === clockMHz - 1) { 
    counter := 0; 
    microCounter := microCounter + 1 
  } 
  def driveFrom(busCtrl : BusSlaveFactory, baseAddress : Int = 0) () = new Area { 
    busCtrl.read(io.micros, baseAddress) 
  } 
} 

Метод driveFrom() обеспечивает привязку интерфейсного сигнала io.micros к шине BusSlaveFactory при чтении слова по адресу baseAddress. BusSlaveFactory является внешней шиной для моста Apb3, смотрящей в сторону периферии, а наше новое устройство будет являться слэйвом на этой шине.

Для привязки нашего нового компонента потребуется создать еще один, оберточный, компонент Apb3MachineTimer - он будет инкапсулировать в себя компонент MachineTimer и интерфейсный класс Apb3SlaveFactory. Apb3SlaveFactory является интерфейсом для моста Apb3. Оберточный компонент также будет содержать одну строку кода, обеспечивающую связь между новым компонентом и Apb3. Код для этого оберточного компонента на SpinalHDL выглядит следующим образом:

case class Apb3MachineTimer(clockMHz : Int = 25) extends Component { 
  val io = new Bundle { 
    val apb = slave(Apb3(Apb3Config(addressWidth = 8, dataWidth = 32))) 
  } 
  val busCtrl = Apb3SlaveFactory(io.apb) 
  val mtCtrl = MachineTimerCtrl(clockMHz) 
  mtCtrl.driveFrom(busCtrl)() // connect component to the bus
} 

Строго говоря, этот оберточный компонент не является обязательным, но тогда нам пришлось бы вытащить этот код на верхний уровень, в Murax, что не очень эстетично.

Разместим весь этот код в одном файле ./src/main/scala/mylib/MachineTimer.scala, при этом подкаталог ./src/main/scala/mylib потребуется создать.

Теперь, для того чтобы подключить наш новый компонент к шине Apb3 внутри СнК Murax, наравне с другой периферией, необходимо добавить его в ассоциативный список, содержащийся в переменной apbMapping, указав адрес привязки 0xB0000. Вставим выделенный жирным код сразу после подключения уже существующего таймера:

    val timer = new MuraxApb3Timer() 
    timerInterrupt setWhen(timer.io.interrupt) 
    apbMapping += timer.io.apb     -> (0x20000, 4 kB) 

    val machineTimer = Apb3MachineTimer(coreFrequency.toInt / 1000000) 
    apbMapping += machineTimer.io.apb   -> (0xB0000, 4 kB)

Таким образом, регистр нашего микросекундного таймера будет доступен из программы при чтении 32-х битного слова по адресу: 0xF0000000 + 0xB0000 = 0xF00B0000. Для того, чтобы при сборке SBT нашел код нашего нового компонента, поможем ему в этом, добавив в заголовке файла Murax.scala следующу строку:

import mylib.Apb3MachineTimer

Чтобы проверить, как работает созданный нами машинный микросекундный таймер, добавим в файл src/murax.h макро MTIME для доступа к регистру:

#define MTIME   (*(volatile unsigned long*)(0xF00B0000))

А в код программы «hello_world» добавим измерение времени выполнения функции delay() с помощью микросекундного таймера. Модифицированный Си код в файле src/main.c приведен ниже:

       while(1){ 
                unsigned int shift_time; 
                unsigned int t1, t2; 
                println("Hello world, this is VexRiscv!"); 
                for(unsigned int i=0;i<nleds-1;i++){ 
                        GPIO_A->OUTPUT = 1<<i; 
                        TIMER_A->CLEARS_TICKS = 0x00020002; 
                        TIMER_A->LIMIT = 0xFFFF; 
                        t1 = MTIME; 
                        delay(nloops); 
                        t2 = MTIME; 
                        shift_time = TIMER_A->VALUE; 
                } 
                for(unsigned int i=0;i<nleds-1;i++){ 
                        GPIO_A->OUTPUT = (1<<(nleds-1))>>i; 
                        delay(nloops); 
                } 

                printhex(shift_time); 
                printhex(t2 - t1); 
        } 	

Как обычно, выполняем сборку проекта командой make clean && make, устраняем все синтаксические ошибки и опечатки, подключаем плату «Карно» и загружаем полученный битстрим. Запускаем эмулятор терминала minicom и наблюдаем в порту следующие строки:

Hello world, this is VexRiscv!                                                   
000000C7                                                                         
0002A516                                                                         
Hello world, this is VexRiscv!                                                   
000000C6                                                                         
0002A515                                                                         
Hello world, this is VexRiscv!                                                   
000000C6                                                                         
0002A515

Здесь первое число — это измерение числа тактов (умноженное на 65536), выполненное таймером TIMER_A, а второе число — результат замеров с помощью микросекундного таймера. Число 0002A515 есть не что иное как 173333 микросекунд, которые требуются для выполнения функции delay(1000000).

Теперь, когда у нас есть микросекундный таймер, мы можем легко соорудить сервисные функции delay_us() и delay_ms(), осуществляющие задержку исполнения программы на заданное количество микро- и миллисекунд:

void delay_us(unsigned int us){ 
        unsigned int t = MTIME; 
        while(MTIME - t < us); 
} 
void delay_ms(unsigned int ms){ 
        for(int i = 0; i < ms; i++) 
                delay_us(1000); 
} 

17.2 Подключаем микросхему SRAM

На плате «Карно» имеется распаянная и подключенная к ПЛИС микросхема статической памяти K6R4016V1D-10 объемом 16x256 КБ (512 КБ) и было бы очень неплохо задействовать эту память в своих программах, исполняемых на синтезированном ядре. Эта микросхема имеет стандартный набор сигнальных линий:

  • A0-A9 (линии выбора строки) и A10-A17 (линии выбора столбца) вместе формируют адрес 16-битного слова, т. е. микросхема содержит всего 262144 слов по 16 бит.

  • D0-D15 — линии данных.

  • #CS — сигнал выбора микросхемы, лог «0» — выбрана.

  • #WE — сигнал разрешения записи в память, лог «0» - запись, лог «1» - чтение.

  • #OE — сигнал разрешения выставить данные на выходную шину данных. Лог «0» - данные на выходе активны, лог «1» - линии данных находятся в состоянии высокого импеданса.

  • #LB — сигнал, разрешающий запись младшего байта.

  • #UB — сигнал, разрешающий запись старшего байта.

Опишем эти сигнальные линии в LPF файле, чтобы ими можно было пользоваться, добавим в файл karnix_cabga256.lpf следующие строки:

Дополнительные сигналы io_sram к karnix_cabga256.lpf
LOCATE COMP "io_sram_cs" SITE "H15";            # SRAM #CS 
IOBUF PORT "io_sram_cs" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_we" SITE "K14";            # SRAM #WE 
IOBUF PORT "io_sram_we" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_oe" SITE "J14";            # SRAM #OE 
IOBUF PORT "io_sram_oe" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_bhe" SITE "J16";           # SRAM #BHE 
IOBUF PORT "io_sram_bhe" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_ble" SITE "J15";           # SRAM #BLE 
IOBUF PORT "io_sram_ble" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[0]" SITE "L16";        # SRAM D00 
IOBUF PORT "io_sram_dat[0]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[1]" SITE "L15";        # SRAM D01 
IOBUF PORT "io_sram_dat[1]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[2]" SITE "M16";        # SRAM D02 
IOBUF PORT "io_sram_dat[2]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[3]" SITE "M15";        # SRAM D03 
IOBUF PORT "io_sram_dat[3]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[4]" SITE "K13";        # SRAM D04 
IOBUF PORT "io_sram_dat[4]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[5]" SITE "K12";        # SRAM D05 
IOBUF PORT "io_sram_dat[5]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[6]" SITE "L13";        # SRAM D06 
IOBUF PORT "io_sram_dat[6]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[7]" SITE "L12";        # SRAM D07 
IOBUF PORT "io_sram_dat[7]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[8]" SITE "N16";        # SRAM D08 
IOBUF PORT "io_sram_dat[8]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[9]" SITE "P15";        # SRAM D09 
IOBUF PORT "io_sram_dat[9]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[10]" SITE "L14";       # SRAM D10 
IOBUF PORT "io_sram_dat[10]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[11]" SITE "M14";       # SRAM D11 
IOBUF PORT "io_sram_dat[11]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[12]" SITE "P16";       # SRAM D12 
IOBUF PORT "io_sram_dat[12]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[13]" SITE "R16";       # SRAM D13 
IOBUF PORT "io_sram_dat[13]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[14]" SITE "M13";       # SRAM D14 
IOBUF PORT "io_sram_dat[14]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_dat[15]" SITE "N14";       # SRAM D15 
IOBUF PORT "io_sram_dat[15]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[0]" SITE "N13";       # SRAM A00 
IOBUF PORT "io_sram_addr[0]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[1]" SITE "P14";       # SRAM A01 
IOBUF PORT "io_sram_addr[1]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[2]" SITE "R15";       # SRAM A02 
IOBUF PORT "io_sram_addr[2]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[3]" SITE "T15";       # SRAM A03 
IOBUF PORT "io_sram_addr[3]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[4]" SITE "P13";       # SRAM A04 
IOBUF PORT "io_sram_addr[4]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[5]" SITE "R14";       # SRAM A05 
IOBUF PORT "io_sram_addr[5]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[6]" SITE "R13";       # SRAM A06 
IOBUF PORT "io_sram_addr[6]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[7]" SITE "T14";       # SRAM A07 
IOBUF PORT "io_sram_addr[7]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[8]" SITE "R12";       # SRAM A08 
IOBUF PORT "io_sram_addr[8]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[9]" SITE "T13";       # SRAM A09 
IOBUF PORT "io_sram_addr[9]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[10]" SITE "M12";      # SRAM A10 
IOBUF PORT "io_sram_addr[10]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[11]" SITE "N12";      # SRAM A11 
IOBUF PORT "io_sram_addr[11]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[12]" SITE "M11";      # SRAM A12 
IOBUF PORT "io_sram_addr[12]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[13]" SITE "N11";      # SRAM A13 
IOBUF PORT "io_sram_addr[13]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[14]" SITE "P11";      # SRAM A14 
IOBUF PORT "io_sram_addr[14]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[15]" SITE "P12";      # SRAM A15 
IOBUF PORT "io_sram_addr[15]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[16]" SITE "K16";      # SRAM A16 
IOBUF PORT "io_sram_addr[16]" IO_TYPE=LVCMOS33; 
LOCATE COMP "io_sram_addr[17]" SITE "K15";      # SRAM A17 
IOBUF PORT "io_sram_addr[17]" IO_TYPE=LVCMOS33; 

И передадим эти сигналы из модуля toplevel в модуль Murax в оберточном файле toplevel.v, как показано в выдержке ниже:

Дополнительные сигналы io_sram к toplevel.v
module toplevel( 
    input   io_clk25, 
    input   [3:0] io_key, 
    output  [3:0] io_led, 
    input   io_core_jtag_tck, 
    output  io_core_jtag_tdo, 
    input   io_core_jtag_tdi, 
    input   io_core_jtag_tms, 
    output  io_uart_debug_txd, 
    input   io_uart_debug_rxd, 
    output  io_sram_cs,  // #CS 
    output  io_sram_we,  // #WE 
    output  io_sram_oe,  // #OE 
    output  io_sram_bhe, // #UB 
    output  io_sram_ble, // #LB 
    output  [17:0] io_sram_addr, // A0-A17 
    inout   [15:0] io_sram_dat   // D0-D15 
  );
...
  Murax murax ( 
    .io_asyncReset(io_key[3]), 
    //.io_mainClk (io_clk25), 
    .io_mainClk (clk75), 
    .io_jtag_tck(io_core_jtag_tck), 
    .io_jtag_tdi(io_core_jtag_tdi), 
    .io_jtag_tdo(io_core_jtag_tdo), 
    .io_jtag_tms(io_core_jtag_tms), 
    .io_gpioA_read       (io_gpioA_read), 
    .io_gpioA_write      (io_gpioA_write), 
    .io_gpioA_writeEnable(io_gpioA_writeEnable), 
    .io_uart_txd(io_uart_debug_txd), 
    .io_uart_rxd(io_uart_debug_rxd), 
    .io_sram_cs(io_sram_cs), 
    .io_sram_we(io_sram_we), 
    .io_sram_oe(io_sram_oe), 
    .io_sram_bhe(io_sram_bhe), 
    .io_sram_ble(io_sram_ble), 
    .io_sram_addr(io_sram_addr), 
    .io_sram_dat(io_sram_dat) 
  ); 

17.2.1 Разрабатываем контроллер SRAM

А теперь подумаем вот о чем. У нас есть типовой набор сигналов для интерфейса к микросхеме SRAM. Чтобы не таскать далее по всему коду эту пачку сигналов, имеет смысл описать её как комплексный сигнал, и тогда нам будет достаточно передавать одну переменную. На SpinalHDL такой комплексный сигнал можно описать следующим образом.

Создадим класс SramInterface с двойным наследованием: от класса Bundle (сложная структура сигналов) и от класса IMasterSlave. Последний позволяет изменять направления действия сигналов в зависимости от амплуа компонента, который будет использовать наш интерфейсный класс: «мастер» (ведущий) или «слэйв» (ведомый).

Так как наш интерфейс по большей части будет выступать в амплуа «мастер», то мы переопределим абстрактный метод asMaster(), он будет использовать сервисные функции out() и inout(), унаследованные от класса Bundle, для изменения направленности сигналов.

Для того, чтобы сделать наш интерфейс чуть более гибким, нам необходимо добавить к нему пару параметров, а именно — параметры, задающие размерность шины адреса и шины данных. Сделаем это с помощью простой структуры SramLayout:

case class SramLayout(addressWidth: Int, dataWidth : Int){ 
  def bytePerWord = dataWidth/8 
  def capacity = BigInt(1) << addressWidth 
} 

Тогда описание нашего интерфейсного класса примет следующий вид:

case class SramInterface(g : SramLayout) extends Bundle with IMasterSlave{ 
  val addr = inout(Analog(Bits(g.addressWidth bits))) 
  val dat = inout(Analog(Bits(g.dataWidth bits))) 
  val cs  = Bool 
  val we  = Bool 
  val oe  = Bool 
  val ble  = Bool 
  val bhe  = Bool 
  override def asMaster(): Unit = { 
    out(cs,we,oe,ble,bhe) 
    inout(dat, addr) 
  } 
} 

Поместим все это дело в файл ./src/main/scala/mylib/Sram.scala, то есть в тот же каталог, где располагается ранее созданный компонент MachineTimer. В заголовке файла также укажем имя пакета и перечень используемых библиотек:

package mylib 
import spinal.core._ 
import spinal.lib._ 
import spinal.lib.bus.simple._ 
import spinal.lib.io.TriState 

Теперь можно описать комплексный сигнал sram на входе компонента Murax, представляющего синтезируемый СнК. Отредактируем файл Murax.scala следующим образом:

Во-первых, подключим наши новые классы, добавив в заголовок следующую строку:

import mylib.Apb3MachineTimer 
import mylib.{SramInterface,SramLayout,PipelinedMemoryBusSram} 

Во-вторых, найдем описание внешних сигналов компонента Murax и добавим новый сигнал sram, сразу указав параметры шин адреса и данных:

case class Murax(config : MuraxConfig) extends Component{ 
  import config._ 
  val io = new Bundle { 
    //Clocks / reset 
    val asyncReset = in Bool() 
    val mainClk = in Bool() 
    //Main components IO 
    val jtag = slave(Jtag()) 
    ...
    val sram = master(SramInterface(SramLayout(addressWidth = 18, dataWidth = 16))) 
  } 

С интерфейсом разобрались. Теперь нужно реализовать компонент «контроллера» микросхемы SRAM, который, с одной стороны, будет выступать «мастером» и взаимодействовать с микросхемой памяти через описанный выше интерфейс, а с другой — выступать в роли «слэйв» устройства на шине MainBus. Напомню, что шина MainBus является в СнК Murax основой для интерконнекта с его частями и определяется классом PipelinedMemoryBus. Тогда код «заготовки» нашего контроллера, назовем его PipelinedMemoryBusSram, будет выглядеть следующим образом:

case class PipelinedMemoryBusSram(pipelinedMemoryBusConfig: PipelinedMemoryBusConfig, 
                                       sramLayout : SramLayout) extends Component{ 
  val io = new Bundle{ 
    val bus = slave(PipelinedMemoryBus(pipelinedMemoryBusConfig)) 
    val sram = master(SramInterface(sramLayout)) 
  } 
  ... 
  // здесь будет имплементация контроллера SRAM
}

Настало время подумать о логике работы нашего контроллера микросхемы SRAM.

Для тех, кто не в курсе особенностей, микросхема статической памяти (Static Random Access Memory) работает асинхронно, то есть не имеет тактирования — чтение и запись в ячейки осуществляется после подачи на микросхему набора разрешающих сигналов (#CS — chip-select, #WE — write-enable, #OE - output enable) и занимает какое-то время. Это время, требуемое микросхеме на выполнения операции, обозначается в спецификации как «Read Cycle Time» (trc)и «Write Cycle Time» (twc). Заглянув в спецификацию на микросхему K6R4016V1D мы увидим, что оба этих параметра имеют одинаковое значение — от 10нс, а это означает, что данная микросхема способна выполнять почти 100 млн операций записи и чтения в секунду. Микросхема оперирует 16-ти битными словами, т. е. за одну операцию записывает или считывает по 16 бит данных, которые подаются или снимаются с шины данных (сигналы D0-D15), а адрес слова, которое будет записано или считано, предварительно подается на шину адреса (сигналы A0-A17).

Рис. 28. Временные диаграммы для цикла чтения микросхемы SRAM K6R4016V1D.
Рис. 28. Временные диаграммы для цикла чтения микросхемы SRAM K6R4016V1D.

Если посмотреть на временные диаграммы на рис. 28, то можно увидеть, что адрес подается с небольшим опережением сигналов разрешения, но если длительность удержания сигналов достаточно большая, то адрес можно подавать вместе с разрешающими сигналами. В нашем дизайне частота тактового сигнала составляет 75 МГц или иными словами, тактовый сигнал имеет длительность 13нс. Этого времени, теоретически, должно быть достаточно, чтобы осуществлять операции чтения и записи в микросхему SRAM за один такт, подавая все необходимые сигналы сразу. То есть за один системный такт мы могли бы читать или записывать по 16 бит данных в SRAM. Однако, опыт показал, что на частоте 60 МГц и выше, при чтении возникают множественный битфлипы (искажения данных), при этом запись на частоте 75 МГц производится без ошибок. Я не стал вдаваться в подробности почему так происходит, но пришел к выводу, что для надежного чтения каждого 16-битного слова в реальности потребуется два такта.

Вычислительное ядро VexRiscv является 32-х битным, а это означает, что ядро за один такт считывает или записывает в шину PipelinedMemoryBus слова размерностью 32 бита и тут возникает дополнительная сложность. Чтобы выполнять операции чтения/записи с данной микросхемой SRAM, нам потребуется разбивать операцию записи на две части (два такта), а операцию чтения — на четыре. При чтении придется «склеивать» полученный результат в 32-битное слово перед выдачей его в шину.

Еще один момент состоит в том, что вычислительное ядро может потребовать записать не все 32 бита, а только часть из них, например младшие 8 бит (младший байт) или старшие 8 бит любого полуслова, или комбинацию из них. Для того, чтобы наш контроллер SRAM понял, какую именно часть данных требуется записать, шина PipelinedMemoryBus передает набор из четырех сигналов-масок io.bus.cmd.mask[3:0] — наличие лог «1» в соответствующем бите маске сигнализирует о том, что соответствующий байт (один из четырех байтов 32-х битного слова) требуется записать, а остальные оставить без изменения.

Таким образом, в реализации нашего контроллера SRAM вырисовывается автомат состояний, работающий по следующему алгоритму:

При записи в SRAM (io.bus.cmd.write === True):

  1. Автомат начинает работу в состоянии #0, в котором он ожидает сигнала готовности io.bus.cmd.valid от шины.

  2. Получив сигнал готовности, автомат устанавливает управляющие сигналы io.sram.oe = False и io.sram.we = False; параллельно с этим выставляет на шину адреса микросхемы io.sram.addr адрес младшего 16-ти битного слова, полученного с внешней шины io.bus.cmd.address; на шину данных микросхемы io.sram.dat - младшие 16 бит данных; на линии io.sram.ble и io.sram.bhe подает значения масок из io.bus.cmd.mask(0) и io.bus.cmd.mask(1) соответственно; и переводит автомат в состояние #1.

  3. В состоянии #1 автомат выставляет на шину адреса io.sram.addr адрес старшего 16-ти битного слова; на шину данных io.sram.dat - старшие 16 бит данных; на линии io.sram.ble и io.sram.bhe подает значения масок io.bus.cmd.mask(2) и io.bus.cmd.mask(3) соответственно. В этом же состоянии автомат переводит своё состояние в #0 и сигнализирует о завершении выполнения операции установкой сигнала io.bus.cmd.ready в True.

Цикл записи требует всего два такта (два состояния).

При чтении из SRAM (io.bus.cmd.write === False) все становится немного сложнее:

  1. Автомат так же начинает работу в состоянии #0 в котором он ожидает сигнала готовности io.bus.cmd.valid от шины.

  2. Получив сигнал готовности, автомат устанавливает управляющие сигналы io.sram.oe = False, io.sram.we = True и выставляет на шину адреса io.sram.addr адрес младшего 16-ти битного слова, полученного с шины io.bus.cmd.address, а данные с шины io.sram.dat копирует во внутренний (временный) регистр rsp_data - в его младшие 16 бит, и переходит в состояние #1.

  3. В состоянии #1 автомат продолжает удерживать на шине io.sram.addr адрес младшего 16-ти битного слова и продолжает копировать данные во внутренний регистр, т. е. выполняет пустой цикл и переходит в состояние #2.

  4. В состоянии #2 автомат выставляет на шину адреса io.sram.addr адрес старшего 16-ти битного слова, полученного с шины io.bus.cmd.address, а данные с шины io.sram.dat копирует во внутренний регистр rsp_data, в его старшие 16 бит и переходит в состояние #3.

  5. В состоянии #3 автомат продолжает удерживать на шине io.sram.addr адрес старшего 16-ти битного слова, продолжает копировать данные во внутренний регистр. При этом автомат в этом же цикле формируется сигнал завершения операции io.bus.cmd.ready в True, а также устанавливает сигнал готовности данных io.bus.rsp.valid.

    Таким образом, цикл чтения требует четыре такта (четыре состояния автомата).

Здесь требуется сделать еще пару замечаний по работе алгоритма:

  1. Выходной сигнал io.bus.rsp.valid должен буферизироваться через регистр (в нашем случае это будет регистр rsp_valid). Это требуется для того, чтобы разнести во времени сигналы io.bus.cmd.ready и io.bus.rsp.valid из-за особенностей реализации шины PipelinedMemoryBus. Если этого не сделать, то получив оба сигнала в одном такте шина остановится (перейдет в состояние STALL) и система прекратит работу.

  2. Выходные данные при цикле чтении должны браться из временного регистра rsp_data и передаваться на шину io.bus.rsp.data.

  3. При установке управляющих сигналов для микросхемы SRAM нужно учитывать, что все управляющие сигналы имеют инверсное значение (active low), т. е. эти сигналы требуется инвертировать.

Переведя описанный выше алгоритм на язык SpinalHDL, получим следующий код для контроллера SRAM, описываемого классом PipelinedMemoryBusSram:

Sram.scala
case class PipelinedMemoryBusSram(pipelinedMemoryBusConfig: PipelinedMemoryBusConfig, 
                                       sramLayout : SramLayout) extends Component{ 
  val io = new Bundle{ 
    val bus = slave(PipelinedMemoryBus(pipelinedMemoryBusConfig)) 
    val sram = master(SramInterface(sramLayout)) 
  } 
  val state = Reg(UInt(3 bits)) init(0) 
  val rsp_data = Reg(Bits(pipelinedMemoryBusConfig.addressWidth bits)) init(0) 
  val rsp_valid = Reg(Bool()) init(False) 
   
  io.sram.cs := ~io.bus.cmd.valid 
  io.sram.we := True 
  io.sram.oe := True 
  io.sram.bhe := True 
  io.sram.ble := True 
  io.bus.rsp.data := rsp_data; 
  io.bus.rsp.valid := rsp_valid 
  io.bus.cmd.ready := (state === 1 && io.bus.cmd.write) || (state === 3 && !io.bus.cmd.write) 
  when (io.bus.cmd.valid) { 
    when (io.bus.cmd.write) { // Write 
      io.sram.we := False // active low 
      when (state === 0) { // Write low 16 bits 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"0" 
        io.sram.dat := io.bus.cmd.data(15 downto 0).asBits 
        io.sram.ble := ~io.bus.cmd.mask(0) 
        io.sram.bhe := ~io.bus.cmd.mask(1) 
        state := 1 
      } otherwise { // Write high 16 bits 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"1" 
        io.sram.dat := io.bus.cmd.data(31 downto 16).asBits 
        io.sram.ble := ~io.bus.cmd.mask(2) 
        io.sram.bhe := ~io.bus.cmd.mask(3) 
        state := 0 
      } 
    } otherwise { // Read 
      io.sram.ble := False 
      io.sram.bhe := False 
      io.sram.oe := False 
      when (state === 0) { 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"0" 
        rsp_data(15 downto 0) := io.sram.dat // buffer low 16 bits - first time 
        state := 1 
        rsp_valid := False 
      } elsewhen (state === 1) { 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"0" 
        rsp_data(15 downto 0) := io.sram.dat // buffer low 16 bits - second time 
        state := 2 
      } elsewhen (state === 2) { 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"1" 
        rsp_data(31 downto 16) := io.sram.dat // buffer high 16 bits - first time 
        state := 3 
      } otherwise { 
        io.sram.addr := io.bus.cmd.address(sramLayout.addressWidth downto 2).asBits ## B"1" 
        rsp_data(31 downto 16) := io.sram.dat // buffer high 16 bits - second time 
        rsp_valid := True // Signal data is READY
        state := 0 
      } 
    } 
  } otherwise { // not Valid 
    state := 0 
    rsp_valid := False 
  } 
} 

Поместим этот код в файл Sram.scala и попробуем запустить сборку командой make clean && make. Если никаких синтаксических ошибок не возникло, то на стадии генерации кода Verilog (т. е. при исполнении байт-кода на JVM), мы должны получить длинный список из приведенных ниже сообщений:

rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ make clean && make 
...
[info] [Progress] at 2.396 : Checks and transforms 
...
[error] Exception in thread "main" spinal.core.SpinalExit: 
[error]  Error detected in phase PhaseCheck_noLatchNoOverride 
[error] ******************************************************************************** 
[error] ******************************************************************************** 
[error] NO DRIVER ON (toplevel/io_sram_cs : out Bool), defined at 
[error]     mylib.SramInterface.<init>(Sram.scala:16) 
[error]     vexriscv.demo.Murax$$anon$1.<init>(Murax.scala:176) 
[error]     vexriscv.demo.Murax.<init>(Murax.scala:162) 
[error]     vexriscv.demo.Murax_karnix$$anonfun$main$8.apply(Murax.scala:556) 
[error]     vexriscv.demo.Murax_karnix$$anonfun$main$8.apply(Murax.scala:556) 
[error]     spinal.sim.JvmThread.run(SimManager.scala:51) 
[error] ******************************************************************************** 
[error] ******************************************************************************** 
[error] NO DRIVER ON (toplevel/io_sram_we : out Bool), defined at 
[error]     mylib.SramInterface.<init>(Sram.scala:17) 
[error]     vexriscv.demo.Murax$$anon$1.<init>(Murax.scala:176) 
[error]     vexriscv.demo.Murax.<init>(Murax.scala:162) 
[error]     vexriscv.demo.Murax_karnix$$anonfun$main$8.apply(Murax.scala:556) 
[error]     vexriscv.demo.Murax_karnix$$anonfun$main$8.apply(Murax.scala:556) 
[error]     spinal.sim.JvmThread.run(SimManager.scala:51) 
[error] ******************************************************************************** 
...
[error] Nonzero exit code returned from runner: 1 
[error] (Compile / runMain) Nonzero exit code returned from runner: 1 
[error] Total time: 32 s, completed Feb 14, 2024, 11:47:45 PM 

Сообщение об ошибке вида «NO DRIVER ON» говорит о том, что соответствующий сигнал объявлен и используется, но для него не назначен источник (нет «драйвера»), то есть нет схемы, формирующей этот сигнал. В нашем случае это легко объясняется тем, что мы описали интерфейсные сигналы для микросхемы SRAM, подключили сигнальные линии к самой микросхеме, но не подключили их к контроллеру PipelinedMemoryBusSram, да и сам контроллер не подключили к шине MainBus.

Ну что же, исправим это недоразумение — отредактируем файл Murax.scala, найдем в нём участок кода, в котором производится подключение «слэйв» устройств к шине MainBus путем добавления их в ассоциативный массив mainBusMapping:

//****** MainBus slaves ******** 
    val mainBusMapping = ArrayBuffer[(PipelinedMemoryBus,SizeMapping)]() 

    val ram = new MuraxPipelinedMemoryBusRam( 
      onChipRamSize = onChipRamSize, 
      onChipRamHexFile = onChipRamHexFile, 
      pipelinedMemoryBusConfig = pipelinedMemoryBusConfig, 
      bigEndian = bigEndianDBus 
    ) 
    mainBusMapping += ram.io.bus -> (0x80000000l, onChipRamSize) 

    ...

И добавим сюда наш контроллер микросхемы SRAM:

    val sramCtrl = new PipelinedMemoryBusSram( 
      pipelinedMemoryBusConfig = pipelinedMemoryBusConfig, 
      sramLayout = SramLayout(addressWidth = 18, dataWidth = 16) 
    ) 
    sramCtrl.io.sram <> io.sram 
    mainBusMapping += sramCtrl.io.bus -> (0x90000000l, 512 kB)

При добавлении нового компонента декодеру шины сообщается, что контроллеру SRAM будет доступно адресном пространстве 512KB @ 0x90000000. Напомню, что адресное пространство «бортовой» RAM находится в диапазоне 96KB @ 0x80000000.

Запустим еще раз сборку и убедимся, что всё прошло гладко и без ошибок:

Статистика синтеза СнК Murax после добавления контроллера Sram
rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ make 
(cd ../../..; sbt "runMain vexriscv.demo.Murax_karnix") 
[info] welcome to sbt 1.6.0 (Ubuntu Java 11.0.9.1) 
…
[info] running (fork) vexriscv.demo.Murax_karnix 
[info] [Runtime] SpinalHDL v1.10.1    git head : 2527c7c6b0fb0f95e5e1a5722a0be732b364ce43 
[info] [Runtime] JVM max memory : 8294.0MiB 
[info] [Runtime] Current date : 2024.02.15 00:24:16 
[info] [Progress] at 0.000 : Elaborate components 
[info] [Warning] This VexRiscv configuration is set without software ebreak instruction support. Some software may rely on it (ex: Rust). (This isn't related to JTAG ebreak) 
[info] [Warning] This VexRiscv configuration is set without illegal instruction catch support. Some software may rely on it (ex: Rust) 
[info] [Progress] at 2.281 : Checks and transforms 
[info] [Progress] at 3.035 : Generate Verilog 
…
Info: Logic utilisation before packing: 
Info:     Total LUT4s:      2637/24288    10% 
Info:         logic LUTs:   2149/24288     8% 
Info:         carry LUTs:    272/24288     1% 
Info:           RAM LUTs:    144/ 3036     4% 
Info:          RAMW LUTs:     72/ 6072     1% 
Info:      Total DFFs:      1428/24288     5% 
…
Info: Packing constants.. 
Info: Packing carries... 
Info: Packing LUTs... 
Info: Packing LUT5-7s... 
Info: Packing FFs... 
Info:     675 FFs paired with LUTs. 
Info: Generating derived timing constraints... 
Info:     Input frequency of PLL 'i_pll.pll_i' is constrained to 25.0 MHz 
Info:     Derived frequency constraint of 75.0 MHz for net clk75 
Info: Promoting globals... 
Info:     promoting clock net clk75 to global network 
Info:     promoting clock net io_core_jtag_tck$TRELLIS_IO_IN to global network 
Info: Checksum: 0x8aa53f27 
Info: Device utilisation: 
Info:             TRELLIS_IO:    54/  197    27% 
Info:                   DCCA:     2/   56     3% 
Info:                 DP16KD:    48/   56    85% 
Info:             MULT18X18D:     0/   28     0% 
Info:                 ALU54B:     0/   14     0% 
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  1428/24288     5% 
Info:           TRELLIS_COMB:  2749/24288    11% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
…
Info: Max frequency for clock                          'clk75': 80.34 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock 'io_core_jtag_tck$TRELLIS_IO_IN': 130.21 MHz (PASS at 12.00 MHz) 
…
2 warnings, 0 errors 
Info: Program finished normally. 
ecppack --compress --freq 38.8 --input murax_hello_world_out.config --bit murax_hello_world.bit 

Главное, на что следует обращать во всей этой статистике то, что наш дизайн всё еще проходит по STA и максимально допустимая тактовая частота составляет 80,34 МГц, т. е. имеется некоторый запас «прочности».

17.2.2 Тестируем память и контроллер SRAM

Перед тем, как задействовать полученную область памяти в программе, нам потребуется некая функция, которая проведет тест всего пространства SRAM памяти объемом 512 КБ и выяснит, рабочая у нас SRAM или нет — вполне возможна такая ситуация, при которой микросхема SRAM перестанет справляться с задачей, т. е. попросту не будет успевать выполнять операции чтения/записи. Такая ситуация может возникнуть, если продолжить увеличивать тактовую частоту системного домена.

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

Фукнция тестирования области памяти использующая псевдостучайную последовательность.
#define SRAM_SIZE       (512*1024) 
#define SRAM_ADDR_BEGIN 0x90000000 
#define SRAM_ADDR_END   (0x90000000 + SRAM_SIZE)
// ...
int sram_test_write_random_ints(void) { 
        volatile unsigned int *mem; 
        unsigned int fill; 
        int fails = 0; 
        fill = 0xdeadbeef; // random seed 
        mem = (unsigned int*) SRAM_ADDR_BEGIN; 
        print("Filling SRAM at: "); 
        printhex((unsigned int)mem); 
        while((unsigned int)mem < SRAM_ADDR_END) { 
                *mem++ = fill; 
                fill += 0xdeadbeef; // generate pseudo-random data 
        } 
        fill = 0xdeadbeef; // random seed 
        mem = (unsigned int*) SRAM_ADDR_BEGIN; 
        print("Checking SRAM at: "); 
        printhex((unsigned int)mem); 
        while((unsigned int)mem < SRAM_ADDR_END) { 
                if(*mem != fill) { 
                        print("SRAM check failed at: "); 
                        printhex((unsigned int)mem); 
                        print("expected: "); 
                        printhex((unsigned int)fill); 
                        print("got: "); 
                        printhex((unsigned int)*mem); 
                        fails++; 
                } 
                mem++; 
                fill += 0xdeadbeef; // generate pseudo-random data 
        } 
        if((unsigned int)mem == SRAM_ADDR_END) 
                print("SRAM total fails: "); 
                printhex((unsigned int)fails); 
        return fails++; 
} 

В приведенном выше коде на языке Си, мы для удобства объявляем набор констант для описания области памяти — её начальный адрес, конечный адрес и размер. В функции sram_test_write_random_ints(), мы сначала циклом заполняем тестируемую область 32-х битными числами, которые вычисляются по известной нам формуле — первоначальное число 0xDEADBEEF каждую итерацию цикла прибавляется к накопленной сумме в переменной fill, формируя псевдослучайное число. Затем производится чтение данных из этой же области и сравнение считанного числа с рассчитанным значением. Функция возвращает количество выявленных несовпадений, т. е. ошибок записи или чтения.

Добавим вызов функции sram_test_write_random_ints() в начало функции main() программы «hello_world», сразу после процедуры инициализации UART:

void main() { 
        Uart_Config uart_config; 
        uart_config.dataLength = UART_DATA_8; 
        uart_config.parity = UART_PARITY_NONE; 
        uart_config.stop = UART_STOP_ONE; 
        uint32_t rxSamplePerBit = UART_PRE_SAMPLING_SIZE + UART_SAMPLING_SIZE + UART_POST_SAMPLING_SIZE; 
        uart_config.clockDivider = SYSTEM_CLOCK_HZ / UART_BAUD_RATE / rxSamplePerBit - 1; 
        uart_applyConfig(UART, &uart_config); 
        sram_test_write_random_ints(); 
	
	... 

Выполняем сборку всего проекта командой make clean && make. После устранения ошибок и получения битстрима, загружаем его в плату «Карно» командой make upload, запускаем эмулятор терминала minicom и наблюдаем за тем, как отрабатывает тест SRAM памяти. Если всё в порядке, то в порт будут выводиться следующие сообщения:

Filling SRAM at: 90000000 
Checking SRAM at: 90000000 
SRAM total fails: 00000000 
Hello world, this is VexRiscv! 
000000C6 
000282CD 
Hello world, this is VexRiscv! 
000000C6 
000282CE 
Hello world, this is VexRiscv! 
000000C6 
000282CD 

Если же микросхема памяти сбоит или отсутствует, мы будем наблюдать сообщения вида:

Filling SRAM at: 90000000 
SRAM check failed at: 90000000 
expected: DEADBEEF 
got: BEEF0000 
SRAM check failed at: 90000004 
expected: BD5B7DDE 
got: 7DDE0000 
SRAM check failed at: 90000008 
expected: 9C093CCD 
got: 3CCD0000 

17.2.3 Используем SRAM с функцией malloc

Осталось задействовать добытый кусок памяти в Си программах. Самый простой способ сделать это - просто читать и писать в область адресного пространства начиная с 0x90000000, используя прямой указатель. Более сложный вариант — задействовать этот блок памяти для «кучи» («heap»), то есть позволить библиотечной функции malloc() распределять блоки в этой области памяти. Давайте разберемся как это реализуется.

Функция malloc() входит в состав стандартной библиотеки ввода/вывода, которую принято называть libc, а её определение традиционно находится в заголовочном файле stdlib.h. Давайте вставим в код программы «hello_world» вызов этой функции и посмотрим, что у нас получится. Отредактируем файл main.c, включим в него заголовочный файл stdlib.h, вызовем функцию malloc() из тела функции main() и распечатаем возвращаемый результат:

#include <stdint.h> 
#include <stdlib.h> 
...

void main() { 
	...
       sram_test_write_random_ints(); 
  
       char *test = malloc(1024); 
       println("Malloc test:"); 
       printhex((unsigned int)test); 
		…
}

Помимо этого, нам нужно объяснить компилятору, чтобы на стадии линковки программы «hello_world» он подключал библиотеку libc_nano.a — это упрощенная версия библиотеки libc, предназначенная для вычислительных систем с небольшим объёмом ОЗУ. Для этого в файле сборки makefile добавим следующую строку:

rz@devbox:~/VexRiscv/src/main/c/murax/hello_world$ cat makefile 
...
INC  = 
LIBS = -lc_nano

Запустив сборку программы командой make, мы увидим следующее сообщение об ошибке:

/opt/riscv64-unknown-elf-gcc-8.3.0-2019.08.0-x86_64-linux-ubuntu14/bin/../lib/gcc/riscv64-unknown-elf/8.3.0/../../../../riscv64-unknown-elf/bin/ld: /opt/riscv64-unknown-elf-gcc-8.3.0-2019.08.0-x86_64-linux-ubuntu14/bin/../lib/gcc/riscv64-unknown-elf/8.3.0/../../../../riscv64-unknown-elf/lib/rv32i/ilp32/libc.a(lib_a-sbrkr.o): in function `.L0 ': 
sbrkr.c:(.text+0x18): undefined reference to `_sbrk' 

Оказывается, что для работы функции malloc() требуется некоторая функция с именем _sbrk(), которой у нас нет. Что это за функция и где её взять? Быстрый ответ на это вопрос — написать самостоятельно.

Оказывается, что для работы функции malloc() требуется некоторая функция с именем _sbrk(), которой у нас нет. Что это за функция и где её взять? Быстрый ответ на это вопрос — написать самостоятельно.

Функция malloc() получает на вход один параметр — размер блока памяти который нужно выделить программе. Она выполняет эффективный поиск на «куче» незадействованного блока памяти требуемого размера, используя двоичное дерево поиска (или иной алгоритм — имеется много реализаций malloc). В результате она возвращает указатель на выделенный блок памяти или NULL, если выделить заданный блок не удалось. Функция malloc() выделяет запрашиваемые блоки из области, которую приято называть «кучей» или «heap» — это специально зарезервированное редактором объектных связей (линкером) адресное пространство.

Как правило, «куча» располагается в верхней части адресного пространства программы, после всех других областей, а за «кучей» следует свободная, неиспользуемая память. Делается это для того, чтобы «кучу» можно было динамически расширять по мере надобности, постепенно отодвигая условную линию разделения областей (сегментов) «кучи» и свободной памяти. Чтобы контролировать это перемещение (рост «кучи»), функция malloc() вызывает функцию _sbrk() (производное от «segment break») каждый раз, когда в «куче» не хватает места для выполнения очередного запроса на распределение блока памяти. Фактически, все, что делает функция _sbrk(), это запоминает во внутреннем указателе адрес ячейки памяти, где находится граница областей; проверяет, можно ли её расширить (сдвинуть на заданное количество байт); если можно, то запоминает новое значение и возвращает его в функцию malloc(). В современных операционных системах с виртуальной памятью функция _sbrk() реализуется не простым механизмом описанным выше, а через системный вызов mmap(). В старых UNIX истемах попытка программы осуществить доступ за пределы используемой области памяти отслеживалось операционной системой и автоматически рассматривалось как необходимость выделить программе (процессу) дополнительной памяти к уже имеющейся. Если в системе нет свободной памяти для процесса, то такой процесс снимается с исполнения. Но нас это всё не касается, так как в данном случае мы работаем с «голым железом».

Так как у нас нет виртуальной памяти и нет операционной системы, то мы можем реализовать функцию _sbrk() тем способом, который нам удобен, а именно — заведем указатель на начало свободной памяти и будем его постепенно передвигать на запрашиваемое количество байт. Как только указатель перейдет за пределы доступной физической памяти, наша функция _sbrk() вернет NULL, сигнализируя таким образом функции malloc() о том, что вся память израсходована. Осталось выяснить, где мы можем расположить «кучу» и как это сделать.

Помимо «кучи», линкер также резервирует адресное пространство для стека, для изменяемых и не изменяемых данных программы, а также для самого текста (кода) программы — всё это называется структурой размещения программы в памяти. Данная структура определяется настройками линковщика, которые, в нашем случае для программы «hello_world» находятся в файл linker.ld и используются при сборки программы.

Заглянув в файл linker.ld, мы увидим, что:

  1. Линкер будет производить размещение всех областей программы в адресном пространстве начиная с 0x80000000 и размером 96КБ.

  2. Линкер по умолчанию, если не определена переменная _stack_size, выделяет под стек 2048 байт.

  3. Линкер по умолчанию, если не определена переменная _heap_size, выделяет под «кучу» 0 байт, т. е. «кучи» нет.

  4. Линкер будет производить размещение областей в следующей, не очень удобной последовательности (от младших адресов к старшим):

  • ._vector — таблица векторов, состоящая из одного JUMP;

  • .text(crt.o) — текст «C run-time»;

  • ._user_heap — «куча» размером 0 байт по умолчанию;

  • ._stack — стек программы размером 2048 байт;

  • .data — область неинициализированных данных программы;

  • .rodate — область инициализированных неизменяемых данных программы;

  • .text — текст (машинный код) программы «hello_world».

Неудобство такой структуры размещения состоит в том, что «куча» здесь окружена другими областями и не может быть безопасно расширена путем перемещения указателя, не повредив стек, область которого следует сразу за «кучей». Поэтому, перед тем как имплементировать функцию _sbrk(), мы немного переработаем структуру размещения, а именно — передвинем «кучу» в самый конец адресного пространства и присвоим ей название _ram_heap. Это будет область для «кучи» в пределах синтезируемой RAM. Размер этой области мы оставим равным нулю, функция _sbrk() расширит её при первом же вызове на требуемое количество байт.

Наш новый файл linker.ld будет выглядеть следующим образом:

linker.ld
OUTPUT_FORMAT("elf32-littleriscv", "elf32-littleriscv", "elf32-littleriscv") 
OUTPUT_ARCH(riscv) 
ENTRY(crtStart) 
MEMORY { 
  RAM      (rwx): ORIGIN = 0x80000000, LENGTH = 96k 
} 
_stack_size = DEFINED(_stack_size) ? _stack_size : 2048; 
_ram_heap_size = DEFINED(_ram_heap_size) ? _ram_heap_size : 0; 
SECTIONS { 
  ._vector ORIGIN(RAM): { 
    *crt.o(.start_jump); 
    *crt.o(.text); 
  } > RAM 
  .data : 
  { 
    (.rdata) 
    (.rodata .rodata.) 
    (.gnu.linkonce.r.) 
    (.data .data.) 
    (.gnu.linkonce.d.) 
    . = ALIGN(8); 
    PROVIDE( __global_pointer$ = . + 0x800 ); 
    (.sdata .sdata.) 
    (.gnu.linkonce.s.) 
    . = ALIGN(8); 
    (.srodata.cst16) 
    (.srodata.cst8) 
    (.srodata.cst4) 
    (.srodata.cst2) 
    (.srodata .srodata.*) 
  } > RAM 
  .bss (NOLOAD) : { 
                . = ALIGN(4); 
                /* This is used by the startup in order to initialize the .bss secion / 
                _bss_start = .; 
    (.sbss*) 
    (.gnu.linkonce.sb.) 
    (.bss .bss.) 
    (.gnu.linkonce.b.) 
    *(COMMON) 
                . = ALIGN(4); 
                _bss_end = .; 
  } > RAM 
  .rodata         : 
  { 
    (.rdata) 
    (.rodata .rodata.) 
    (.gnu.linkonce.r.*) 
  } > RAM 
  .noinit (NOLOAD) : { 
      . = ALIGN(4); 
      (.noinit .noinit.) 
      . = ALIGN(4); 
  } > RAM 
  .memory : { 
    *(.text); 
    end = .; 
  } > RAM 
  .ctors          : 
  { 
    . = ALIGN(4); 
    _ctors_start = .; 
    KEEP((.init_array)) 
    KEEP ((SORT(.ctors.))) 
    KEEP (*(.ctors)) 
    . = ALIGN(4); 
    _ctors_end = .; 
    PROVIDE ( END_OF_SW_IMAGE = . ); 
  } > RAM 
  ._stack (NOLOAD): 
  { 
    . = ALIGN(16); 
    PROVIDE (_stack_end = .); 
    . = . + _stack_size; 
    . = ALIGN(16); 
    PROVIDE (_stack_start = .); 
  } > RAM 
  ._ram_heap (NOLOAD): 
  { 
    . = ALIGN(8); 
    PROVIDE ( end = . ); 
    PROVIDE ( _end = . ); 
    PROVIDE ( _ram_heap_start = .); 
    . = . + _ram_heap_size; 
    PROVIDE ( _ram_heap_end = ALIGN(ORIGIN(RAM) + LENGTH(RAM) ,8) ); 
  } > RAM 
} 

Настало время имплементировать функцию _sbrk(). Напомню, что основной нашей целью было сделать так, чтобы функция malloc() распределяла блоки памяти из внешней SRAM памяти — области, которая располагается с адреса 0x90000000. В то же время, у нас имеется неиспользуемая область в синтезируемой RAM памяти, где-то там выше стека. Было бы не плохо, если бы наша программа могла проверить доступность внешней SRAM и если она работает без ошибок, то задействовать её под «кучу». Если же SRAM выдает ошибки при тестировании (либо отсутствует), то для «кучи» следует использовать свободную область из синтезированной RAM. Иными словами, нам требуется функция инициализации указателя границы «кучи» для _sbrk(), которую мы будем вызывать с параметром в зависимости от результата тестирования SRAM.

Чтобы ссылаться на области, которые определены в файле linker.ld, в файле main.c заведем следующие переменны:

// Below is some linker specific stuff 
extern unsigned int end; /* Set by linker.  */ 
extern unsigned int _ram_heap_start; /* Set by linker.  */ 
extern unsigned int _ram_heap_end; /* Set by linker.  */ 
extern unsigned int _stack_start; /* Set by linker.  */ 
extern unsigned int _stack_size; /* Set by linker.  */ 

Это переменные представляют собой адреса размещения областей в памяти, они соответствуют тому, что описано в файле linker.ld.

Далее, заведем несколько своих указателей, с которыми будет работать наша функция _sbrk():

unsigned char* sbrk_heap_end = 0; /* tracks heap usage */ 
unsigned int* heap_start = 0; /* programmer define heap start */ 
unsigned int* heap_end = 0; /* programmer defined heap end */ 

Переменная sbrk_heap_end — это и будет наш указатель на границу раздела, то есть он всегда указывает на то место, где заканчивается «куча». Переменные heap_start и heap_end будут ограничивать пределы роста «кучи».

Теперь определим функцию init_sbrk() для инициализации этих переменных и тоже добавим её в файл main.c:

void init_sbrk(unsigned int* heap, int size) { 
        if(heap == NULL) { 
                heap_start = (unsigned int*)& _ram_heap_start; 
                heap_end = (unsigned int*)& _ram_heap_end; 
        } else { 
                heap_start = heap; 
                heap_end = heap_start + size; 
        } 
        sbrk_heap_end = (char*) heap_start; 
} 

Ну и добавим функцию _sbrk() следующего содержания:

void* _sbrk(unsigned int incr) { 
        unsigned char* prev_heap_end; 
        if (sbrk_heap_end == 0) { 
                // In case init_sbrk() has not been called 
                // use on-chip RAM by default 
                heap_start = & _ram_heap_start; 
                heap_end = & _ram_heap_end; 
                sbrk_heap_end = (char*) heap_start; 
        } 
        prev_heap_end = sbrk_heap_end; 
        if((unsigned int)(sbrk_heap_end + incr) >= (unsigned int)heap_end) { 
                println("_sbrk() OUT OF MEM:"); 
                print("sbrk_heap_end = "); 
                printhex((unsigned int)sbrk_heap_end); 
                print("heap_end = "); 
                printhex((unsigned int)heap_end); 
                print("incr = "); 
                printhex((unsigned int)incr); 
                return ((void*)-1); // error - no more free memory 
        } 
        sbrk_heap_end += incr; 
        return (void *) prev_heap_end; 
} 

Единственное, что делает функция _sbrk() — это перемещает (увеличивает) указатель sbrk_heap_end на значение incr, предварительно проверив пределы области памяти в которых «куче» дозволено быть.

Осталось вызвать функцию init_sbrk() из тела функции main(). Для этого добавим следующий код:

        ...
        uart_config.clockDivider = SYSTEM_CLOCK_HZ / UART_BAUD_RATE / rxSamplePerBit - 1; 
        uart_applyConfig(UART, &uart_config); 

        if(sram_test_write_random_ints() == 0) { 
                init_sbrk((unsigned int*)SRAM_ADDR_BEGIN, SRAM_SIZE); 
                println("Enabled heap on SRAM"); 
        } else { 
                init_sbrk(NULL, 0); 
                println("Enabled heap on on-chip RAM"); 
        } 

        char *test = malloc(1024); 
        println("Malloc test:"); 
        printhex((unsigned int)test); 
        ...

Собираем проект и устраняем синтаксические ошибки. При сборке будет выполнена компиляция Си кода, и мы увидим следующие сообщения:

make clean && make
rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ make clean && make 
...
(cd ../../../src/main/c/murax/hello_world/; make) 
make[1]: Entering directory '/home/rz/VexRiscvWithKarnix/src/main/c/murax/hello_world' 
mkdir -p build/src/ 
/opt/riscv//bin/riscv64-unknown-elf-gcc -c -march=rv32i  -mabi=ilp32 -DNDEBUG -g -Os -MD -fstrict-volatile-bitfields -fno-strict-aliasing   -o build/src/main.o src/main.c 
/opt/riscv//bin/riscv64-unknown-elf-gcc -S -march=rv32i  -mabi=ilp32 -DNDEBUG -g -Os -MD -fstrict-volatile-bitfields -fno-strict-aliasing   -o build/src/main.o.disasm src/main.c 
/opt/riscv//bin/riscv64-unknown-elf-gcc -march=rv32i  -mabi=ilp32 -DNDEBUG -g -Os -MD -fstrict-volatile-bitfields -fno-strict-aliasing -o build/hello_world.elf build/src/main.o build/src/crt.o -march=rv32i  -mabi=ilp32 -nostdlib -lgcc -mcmodel=medany -nostartfiles -ffreestanding -Wl,-Bstatic,-T,./src/linker.ld,-Map,build/hello_world.map,--print-memory-usage -Lbuild -lc 
Memory region         Used Size  Region Size  %age Used 
             RAM:        9760 B        96 KB      9.93% 
/opt/riscv//bin/riscv64-unknown-elf-objcopy -O ihex build/hello_world.elf build/hello_world.hex 
/opt/riscv//bin/riscv64-unknown-elf-objdump -S -d build/hello_world.elf > build/hello_world.asm 
/opt/riscv//bin/riscv64-unknown-elf-objcopy -O verilog build/hello_world.elf build/hello_world.v 
make[1]: Leaving directory '/home/rz/VexRiscvWithKarnix/src/main/c/murax/hello_world' 
(cd ../../..; sbt "runMain vexriscv.demo.Murax_karnix") 
[info] welcome to sbt 1.6.0 (Ubuntu Java 11.0.9.1) 
...

Размер бинарного кода Си программы заметно подрос: с 2756 до 9760 байт. Это место заняли функция malloc() и сопутствующие ей материалы из libc.

Загружаем получившийся битстрим в ПЛИС и наблюдаем в порту следующие сообщения:

Checking SRAM at: 90000000 
SRAM total fails: 00000000 special keys 
Enabled heap on SRAM 
Malloc test: 
90000008 
Hello world, this is VexRiscv! 
000000C6 
000282CD 
Hello world, this is VexRiscv! 
000000C6 
000282CE

Видно, что тест SRAM прошел успешно и «куча» инициализировалась в области SRAM, что подтверждается указателем на первый блок памяти, выделенный функцией malloc(): 0x90000008.

С этого момента мы можем смело использовать malloc() и free().

17.2.4 Добавляем блоки вычислителей Div и Mul

Сейчас, когда у нас имеется функционирующий malloc(), мы могли бы попытаться использовать все остальные функции из библиотеки libc, но спешу предупредить, что не все так просто. Дело в том, что libc очень сильно завязана на использование математических инструкций целочисленного умножения и деления (MUL и DIV), которые на данный момент отсутствуют в нашей конфигурации ядра VexRiscv, соответственно эти инструкции отключены в опциях компилятора и при попытке собрать Си программу, скажем, с использованием функции vsnprintf(), приведет к выводу большого списка ошибок об отсутствующих встроенных функций __mulsi3() и __divsi3(). Эти функции являются обертками для машинных инструкций умножения и деления.

Технически, мы можем попросить компилятор эмулировать умножение и деление программно, для этого в makefile-е в опциях линкеру (LDFLAGS) нужно убрать параметр -nostdlib и добавить -lgcc, это заставит его подключать набор функций целочисленной математики. Но, во-первых, это увеличит объём кода, во-вторых, производительность такого решения будет очень низкой. И в-третьих, зачем нам программная эмуляция целочисленного умножения и деления, если мы можем легко добавить эти инструкции в набор команд VexrRiscv, включив соответствующие плагины.

Всё что нам требуется, это в файле Murax.scala найти инициализацию массива с плагинами и добавить в него два плагина: MulPlugin и DivPlugin. Изменения в Murax.scala выглядят следующим образом:

      new FullBarrelShifterPlugin, 
      new MulPlugin, // добавлено
      new DivPlugin, // добавлено
      new HazardSimplePlugin( 
        bypassExecute = false, 
        bypassMemory = false, 
        bypassWriteBack = false, 
        bypassWriteBackBuffer = false, 
        pessimisticUseSrc = false, 
        pessimisticWriteRegFile = false, 
        pessimisticAddressMatch = false 
      ), 

Жирным шрифтом выделены две строки, которые требуется добавить.

Перед тем как мы запустим сборку, нам нужно разрешить компилятору использовать инструкции из расширения M, для этого нужно чтобы ему передавался параметр -march=rv32im (сейчас ему передается -march=rv32i — без буквы m). Сделать это можно отредактировав makefile для Си программы «hello_world», изменив переменную MULDIV=no на MULDIV=yes.

Собственно, на этом вся магия заканчивается. Для того, чтобы проверить работоспособность функций libc, добавим в текст Си программы в тело функции main() вывод строки приветствия, используя функцию vsnprintf():

        while(1){ 
                unsigned int shift_time; 
                unsigned int t1, t2; 

                //println("Hello world, this is VexRiscv!"); 
                char str[128]; 
                vsnprintf(str, 128, "Hello world, this is VexRiscv!\r\n", NULL); 
                print(str); 

                for(unsigned int i=0;i<nleds-1;i++){ 
	            ...

Как обычно, командой make clean && make запустим сборку и посмотрим с какими параметрами происходит линковка:

...
/opt/riscv//bin/riscv64-unknown-elf-gcc -march=rv32im  -mabi=ilp32 -DNDEBUG -g -Os -MD -fstrict-volatile-bitfields -fno-strict-aliasing -o build/hello_world.elf build/src/main.o build/src/crt.o -march=rv32im  -mabi=ilp32 -nostdlib -lgcc -mcmodel=medany -nostartfiles -ffreestanding -Wl,-Bstatic,-T,./src/linker.ld,-Map,build/hello_world.map,--print-memory-usage -Lbuild -lc_nano 
Memory region         Used Size  Region Size  %age Used 
             RAM:        9152 B        96 KB      9.31% 
...

Все ок, используется флаг -march=rv32im , линковка Си программы и сборка всего проекта завершается успешно. Загружаем битстрим в ПЛИС и убеждаемся, что сообщение приветствия также хорошо выводится в отладочный порт, как и в предыдущем примере.

17.2.5 Задействуем функцию printf

Самой полезной из всех, без сомнения, является функция printfs(), которая является оберткой для более универсальной функции vsnprintf(). С функцией vsnprintf() мы только что разобрались и для её работы более ничего не требуется. А вот для того, чтобы заработала функция printf(), ей необходимо обеспечить механизм для вывода строки на «стандартное устройство вывода». В операционных системах для этих целей используется системный вызов write() с файловым дескриптором #1, но у нас нет операционной системы. Как тут быть? Всё просто. Нужно определить ряд оберточных функций, в том числе функцию для вывода строки в последовательный порт, а именно:

  • _write() - вызывается библиотечными функциями, когда требуется вывести блок данных (строку) на стандартное устройство вывода. В нашем случае таким устройством будет служить отладочный порт UART, поэтому тело функции _write() определим как обертку у уже имеющейся у нас функции uart_write() которая отправляет символ в порт UART.

  • _close() - закрывает файловый дескриптор. В нашем случае это будет пустая функция.

  • _lseek() - позиционирует курсор внутри открытого файла. Тоже определим как пустую функцию.

  • _read() - читает блок данных из стандартного ввода. На данный момент определим её как пустую, ничего не делающую функцию. Если нам потребуется использовать функции scanf() или getc(), то эту функцию придется написать.

  • _fstat() - выдает информацию об открытом файле. Определим как пустую функцию.

  • _isatty() - позволяет выяснить, является ли файл именем терминалом (возвращает 1 если файл является терминалом, иначе возвращает 0). Будем всегда возвращать 1 — пусть библиотека libc думает что имеет дело с терминалом.

Добавим реализацию описанных выше функций «заглушек» в файл main.c:

#include <stdio.h> 
#include <sys/stat.h>

...

int _write (int fd, const void *buf, size_t count) { 
        int i; 
        char* p = (char*) buf; 
        for(i = 0; i < count; i++) { uart_write(UART, *p++); } 
        return count; 
} 

int _read (int fd, const void *buf, size_t count) { return 1; } 
int _close(int fd) { return -1; } 
int _lseek(int fd, int offset, int whence) { return 0 ;} 
int _isatty(int fd) { return 1; } 

int _fstat(int fd, struct stat *sb) { 
        sb->st_mode = S_IFCHR; 
        return 0; 
} 

В теле функции main() заменим вывод приветствия на использование функции printf():

 while(1){ 
 	...
	//println("Hello world, this is VexRiscv!"); 
	//char str[128]; 
	//vsnprintf(str, 128, "Hello world, this is VexRiscv!\r\n", NULL); 
	//print(str); 
	printf("Hello world, this is VexRiscv!\r\n"); 

Собираем, прошиваем битстрим в ПЛИС и наслаждаемся работой одной из самых сложных функций стандартной библиотеки libc — функцией printf().

Ну и традиционно, статистика после компиляции и линковки Си программы:

/opt/riscv//bin/riscv64-unknown-elf-gcc -march=rv32im  -mabi=ilp32 -DNDEBUG -g -Os -MD -fstrict-volatile-bitfields -fno-strict-aliasing -o build/hello_world.elf build/src/main.o build/src/crt.o -march=rv32im  -mabi=ilp32 -nostdlib -lgcc -mcmodel=medany -nostartfiles -ffreestanding -Wl,-Bstatic,-T,./src/linker.ld,-Map,build/hello_world.map,--print-memory-usage -Lbuild -lc_nano 
Memory region         Used Size  Region Size  %age Used 
             RAM:        9728 B        96 KB      9.90% 

17.3 Подключаем контроллер прерываний PLIC

Почти любое периферийное оборудование может быть настроено для формирования потока асинхронных событий, которые принято называть аппаратными прерываниями или IRQ (от «Interrupt ReQuest»). Использование аппаратных прерываний для работы с периферийными устройствами позволяет освободить центральный процессор (вычислительное ядро) от необходимости тратить понапрасну циклы для опроса регистров готовности или состояния периферийных устройств, они (устройства) сами сообщают о своей готовности путем формирования аппаратного прерывания — устанавливают сигнал на линиях прерывания в определенное состояние. Аппаратное прерывание обычно обрабатывается процессором путем приостанова выполнения главной программы и передачи управления подпрограмме-обработчику, которую принято называть ISR (от «Interrupt Service Routine») или «IRQ handler». В обработчике происходит быстрое взаимодействие с аппаратурой, например, ввод/вывод блока данных, чтение регистра статуса и т. д., после чего управление возвращается к главной программе, к следующей инструкции в месте её прерывания.

17.3.1 Разрабатываем простейший контроллер прерываний MicroPLIC

В синтезируемом ядре VexRiscv поддерживается два варианта обработки прерываний согласно спецификации RISC-V: «прямой» и «векторный». «Прямой» вариант, это когда все виды исключений и прерываний имеют один общий обработчик, адрес которого содержится в машинном CSR регистре (от «Control and State Register») mtvec. Этот обработчик анализирует содержимое другого машинного CSR регистра mcause и, в зависимости от его значения, производит вызов соответствующей процедуры обработки. «Векторный» вариант — когда на каждый вид исключения имеется свой обработчик, а таблица векторов располагается по адресу, содержащемуся во все том же CSR регистре mtvec. Выбор способа, которым будут обрабатываться прерывания, осуществляется установкой 0-го бита в этом же регистре mtvec. Если этот бит равен 0 — то используется «прямой» способ, иначе - «векторный».

Традиционно в RISC архитектурах предпочтение отдается «прямому» способу, и далее все наши рассуждения будут исходить из этого. Следует заметить, что все виды прерываний (аппаратные и программные) в архитектуре RISC-V принято называть «исключением» (exceptions). Кстати, процесс старта (reset) вычислительного ядра тоже является исключением.

Если мы посмотрим на список подключаемых плагинов в СнК Murax, то увидим следующую строку:

new CsrPlugin(CsrPluginConfig.smallest(mtvecInit = if(withXip) 0xE0040020l 
    else 0x80000020l)),

Плагин CsrPlugin, помимо поддержки CSR регистров, также отвечает за реализацию исключений (прерываний) и здесь при подключении этого плагина производится первоначальная настройка содержимого регистра mtvec. В нашем случае, так как мы не используем XiP, он устанавливается в значение 0x80000020 — т. е. выбирается «прямой» способ обработки исключений и единый обработчик будет находится по адресу с начала области RAM со смещением 0x20 байт. В этом месте компилятор GCC расположит нам код инициализации («C Run-time» — или CRT) и обработчик исключений trap_entry. Код этой «ловушки» прост — он сохраняет значения важных регистров на стеке и передает управление функции irqCallback() для дальнейшей обработки исключения или прерывания.

Этим же плагином CsrPlugin в VexRiscv определено два сигнала аппаратных прерываний: timerInterrupt и externalInterrupt. Отделить аппаратное прерывание от внутреннего исключения можно по старшему биту CSR регистра mcause — если он установлен в «1», то произошло аппаратное прерывание. К сигналу прерывания timerInterrupt обычно подключается системный таймер, а к externalInterrupt подключают какое-то внешнее периферийное устройство.

Если требуется обрабатывать прерывания от нескольких устройств, то на сигнал externalInterrupt подключается контроллер прерываний (PLIC - «Platform-Level Interrupt Controller»), который как бы расширяет количество входных линий прерывания за счет того, что он запоминает в своих внутренних регистрах источники внешних прерываний, активных на данный момент, и предоставляет программный механизм для того, чтобы а) выяснить от какого источника поступило прерывание и б) обработать прерывания в каком-то порядке, т. е. приоритизировать источники.

По умолчанию в СнК Murax отсутствует какой-либо контроллер прерываний, а на входную линия вектора externalInterrupt подключен сигнал прерывания от UART (последовательного порта). Так как мы собираемся расширять наш СнК и постепенно добавлять в него периферийные устройства, то встает необходимость обеспечить подключение линий прерываний от этих устройств. Иными словами, нам потребуется свой маленький PLIC.

Создать простейший PLIC, без приоритизации и очередей, несложно, достаточно завести несколько регистров для сохранения состояния входных линий прерываний и регистр для их маскирования. Код такого контроллера на языке SpinalHDL приведен ниже.

MicroPLIC.scala
package mylib 
import spinal.core._ 
import spinal.lib._ 
import spinal.lib.Counter 
import spinal.lib.bus.amba3.apb.{Apb3, Apb3Config, Apb3SlaveFactory} 
class Apb3MicroPLICCtrl(irq_nums: Int = 32) extends Component { 
        val io = new Bundle { 
                val apb = slave( Apb3( addressWidth = 16, dataWidth = 32)) 
                val externalInterrupt = out Bool() 
                val IRQLines = in Bits(irq_nums bits) 
        } 
        // Define control words and connect it to APB3 bus 
        val busCtrl = Apb3SlaveFactory(io.apb) 
        val IRQEnabled = Reg(Bits(irq_nums bits)) init(0) 
        busCtrl.readAndWrite(IRQEnabled, address = 0) 
        val IRQPending = Reg(Bits(irq_nums bits)) init(0) 
        busCtrl.readAndWrite(IRQPending, address = 1 * (irq_nums / 8)) 
        busCtrl.read(io.IRQLines, address = 2 * (irq_nums / 8)) 
        val IRQPolarity = Reg(Bits(irq_nums bits)) init(0) 
        busCtrl.readAndWrite(IRQPolarity, address = 3 * (irq_nums / 8)) 
        val IRQLastValue = Reg(Bits(irq_nums bits)) init(0) 
        busCtrl.read(IRQLastValue, address = 4 * (irq_nums / 8)) 
        def setIRQ(irq_line: Bool, irq_num: Int): Unit = { 
                if(irq_num >= irq_nums) { 
                        throw new Exception("MicroPLIC: Cannot add IRQ line with number: " + irq_num + " as it is too big!"); 
                } 
                io.IRQLines(irq_num) := irq_line 
        } 
        for (i <- 0 to (irq_nums - 1)) { 
                IRQPending(i).setWhen( IRQEnabled(i) && 
                                !(io.IRQLines(i) === IRQLastValue(i)) && 
                                (io.IRQLines(i) === IRQPolarity(i)) 
                ) 
        } 
        io.externalInterrupt := !(IRQPending === 0) // trigger external interrupt if there's any pending bits set 
        IRQLastValue := io.IRQLines // recall last state of all IRQ lines 
} 

Данный PLIC выполнен в виде класса Apb3MicroPLICCtrl, который является компонентом и имеет следующий интерфейс:

  • apb — представляет собой интерфейс к периферийной шине Apb3, к которой данный компонент будет подключаться в режиме «слэйва».

  • IRQLines — многобитный входной сигнал от источников прерываний, подключаемых к контроллеру. По умолчания задана размерность 32 бита, что позволяет отслеживать прерывания от 32-х периферийных устройств.

  • externalInterrupt — выходной сигнал прерывания для подключения контроллера к вычислительному ядру.

Внутри контроллер содержит следующие регистры, которые отображаются на адресное пространство машины и доступны программно:

  • IRQEnabled — содержит битовые маски разрешения прерываний от обслуживаемых источников. Состояние лог «1» означает, что прерывание от данного источника разрешено.

  • IRQPending — содержит битовые флаги, индицирующие о том, что соответствующий источник сформировал сигнал прерывания и ждет его обработки.

  • IRQPolarity — содержит конфигурационные флаги, указывающие на полярность входных линий запросов прерываний от источников. Если в соответствующем бите содержится лог «0», то прерывание от данного источника регистрируется при переходе линии прерывания из «high» в «low» (по спаду), иначе — по переходу «low» в «high» (по фронту).

  • IRQLastValue — регистр для отслеживания изменений состояний входных линий запросов прерывания.

Из кода контроллера видно, что вся его логика работы состоит в том, чтобы по каждому тактовому сигналу сравнивать состояния входных линий запросов прерываний с их предыдущими значениями и если обнаруживается изменение, согласно полярности, то устанавливается соответствующий бит в регистре IRQPending. Если в регистре IRQPending установлен хотя бы один бит в лог «1», то на выходе контроллера формируется сигнал externalInterrupt, который будучи подключенным к вычислительному ядру вызовет обработку вектора прерываний (вызов ISR). Для подключения линий запроса прерывания от источников имеется метод setIRQ(), принимающий на вход два параметра: первый параметр это сигнал запроса на прерывание от источника, второй — номер бита, ассоциируемого с данным источником.

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

Разместим текст нашего контроллера в файле ./src/main/scala/mylib/MicroPLIC.scala, а в код СнК Murax добавим следующее:

В заголовок файла Murax.scala добавим строку подключения библиотеки:

import mylib.Apb3MicroPLICCtrl

В ассоциативный массив apbMapping добавим новый элемент, подобно тому, как мы делали при добавлении машинного таймера MachineTimer в главе «17.1 Микросекундный машинный таймер MTIME»:

    //******** APB peripherals ********* 
    val apbMapping = ArrayBuffer[(Apb3, SizeMapping)]() 
    ...
    val plic = new Apb3MicroPLICCtrl() 
    apbMapping += plic.io.apb     -> (0x60000, 64 kB) 
    externalInterrupt := plic.io.externalInterrupt 
    plic.io.IRQLines := 0 

Теперь пересадим порт UART, описываемый компонентом Apb3UartCtrl, на нулевой канал (нулевой бит) нашего контроллера PLIC, а системный таймер MuraxApb3Timer на 3-й канал:

    val uartCtrl = Apb3UartCtrl(uartCtrlConfig) 
    uartCtrl.io.uart <> io.uart 
    ///externalInterrupt setWhen(uartCtrl.io.interrupt) 
    plic.setIRQ(uartCtrl.io.interrupt, 0) 
    apbMapping += uartCtrl.io.apb  -> (0x10000, 4 kB) 

    val timer = new MuraxApb3Timer() 
    //timerInterrupt setWhen(timer.io.interrupt) 
    plic.setIRQ(timer.io.interrupt, 3) 
    apbMapping += timer.io.apb     -> (0x20000, 4 kB)

При необходимости, аналогичным образом на этот PLIC тут же можно завести прерывания от ряда линий Apb3Gpio.

И последний штрих — разрешим ядру VexRiscv обрабатывать внешние прерывания. Для этого найдем в коде СнК Murax переменную:

//val externalInterrupt = False 
val externalInterrupt = Bool()

и заменим на её на объявление сигнала типа Bool, как показано выделенным шрифтом.

Традиционно выполняем сборку проекта командой make clean && make и добиваемся устранения синтаксических ошибок.

Статистика после выполнения синтеза и размещения выглядит следующим образом:

Info: Device utilisation: 
Info:             TRELLIS_IO:    54/  197    27% 
Info:                   DCCA:     2/   56     3% 
Info:                 DP16KD:    48/   56    85% 
...
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  1528/24288     6% 
Info:           TRELLIS_COMB:  2815/24288    11% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
Info: Max frequency for clock                          'clk75': 84.40 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock 'io_core_jtag_tck$TRELLIS_IO_IN': 158.78 MHz (PASS at 12.00 MHz) 

17.3.2 Задействуем контроллер прерываний MicroPLIC из Си программы

Наш PLIC готов к работе. Его регистры будут доступны с адресного пространства 0xF0060000. Для удобства взаимодействия с ним добавим соответствующие константы в Си код программы «hello_world»:

В файл murax.h:

#include "plic.h"
...
#define PLIC            ((PLIC_Reg*)(0xF0060000))

Создадим новый заготовочный файл plic.h следующего содержания:

plic.h
#ifndef _PLIC_H_ 
#define _PLIC_H_ 
#include <stdint.h> 
typedef struct 
{ 
  volatile uint32_t ENABLE; 
  volatile uint32_t PENDING; 
  volatile uint32_t IRQLINE; 
  volatile uint32_t POLARITY; 
  volatile uint32_t IRQLAST; 
} PLIC_Reg; 
#define PLIC_IRQ_UART0          0x00000001 
#define PLIC_IRQ_TIMER0         0x00000008
#endif /* PLIC_H */ 

Далее при работе с прерываниями нам потребуются примитивы для чтения, записи и изменения битов в регистрах управления машиной или CSR (Control and Status Register). Для этого создадим заголовочный файл с именем riscv.h и добавим в него следующие макроопределения:

riscv.h
#ifndef _RISCV_H_ 
#define _RISCV_H_ 
//exceptions 
#define CAUSE_ILLEGAL_INSTRUCTION 2 
#define CAUSE_MACHINE_TIMER 7 
#define CAUSE_SCALL 9 
//interrupts 
#define CAUSE_MACHINE_EXTERNAL 11 
#define MEDELEG_INSTRUCTION_PAGE_FAULT (1 << 12) 
#define MEDELEG_LOAD_PAGE_FAULT (1 << 13) 
#define MEDELEG_STORE_PAGE_FAULT (1 << 15) 
#define MEDELEG_USER_ENVIRONNEMENT_CALL (1 << 8) 
#define MIDELEG_SUPERVISOR_SOFTWARE (1 << 1) 
#define MIDELEG_SUPERVISOR_TIMER (1 << 5) 
#define MIDELEG_SUPERVISOR_EXTERNAL (1 << 9) 
#define MIP_STIP (1 << 5) 
#define MIE_MTIE (1 << CAUSE_MACHINE_TIMER) 
#define MIE_MEIE (1 << CAUSE_MACHINE_EXTERNAL) 
#define MSTATUS_UIE         0x00000001 
#define MSTATUS_SIE         0x00000002 
#define MSTATUS_HIE         0x00000004 
#define MSTATUS_MIE         0x00000008 
#define MSTATUS_UPIE        0x00000010 
#define MSTATUS_SPIE        0x00000020 
#define MSTATUS_HPIE        0x00000040 
#define MSTATUS_MPIE        0x00000080 
#define MSTATUS_SPP         0x00000100 
#define MSTATUS_HPP         0x00000600 
#define MSTATUS_MPP         0x00001800 
#define MSTATUS_FS          0x00006000 
#define MSTATUS_XS          0x00018000 
#define MSTATUS_MPRV        0x00020000 
#define MSTATUS_SUM         0x00040000 
#define MSTATUS_MXR         0x00080000 
#define MSTATUS_TVM         0x00100000 
#define MSTATUS_TW          0x00200000 
#define MSTATUS_TSR         0x00400000 
#define MSTATUS32_SD        0x80000000 
#define MSTATUS_UXL         0x0000000300000000 
#define MSTATUS_SXL         0x0000000C00000000 
#define MSTATUS64_SD        0x8000000000000000 
#define SSTATUS_UIE         0x00000001 
#define SSTATUS_SIE         0x00000002 
#define SSTATUS_UPIE        0x00000010 
#define SSTATUS_SPIE        0x00000020 
#define SSTATUS_SPP         0x00000100 
#define SSTATUS_FS          0x00006000 
#define SSTATUS_XS          0x00018000 
#define SSTATUS_SUM         0x00040000 
#define SSTATUS_MXR         0x00080000 
#define SSTATUS32_SD        0x80000000 
#define SSTATUS_UXL         0x0000000300000000 
#define SSTATUS64_SD        0x8000000000000000 
#define PMP_R     0x01 
#define PMP_W     0x02 
#define PMP_X     0x04 
#define PMP_A     0x18 
#define PMP_L     0x80 
#define PMP_SHIFT 2 
#define PMP_TOR   0x08 
#define PMP_NA4   0x10 
#define PMP_NAPOT 0x18 
#define RDCYCLE 0xC00 //Read-only cycle Cycle counter for RDCYCLE instruction. 
#define RDTIME 0xC01 //Read-only time Timer for RDTIME instruction. 
#define RDINSTRET 0xC02 //Read-only instret Instructions-retired counter for RDINSTRET instruction. 
#define RDCYCLEH 0xC80 //Read-only cycleh Upper 32 bits of cycle, RV32I only. 
#define RDTIMEH 0xC81 //Read-only timeh Upper 32 bits of time, RV32I only. 
#define RDINSTRETH 0xC82 //Read-only instreth Upper 32 bits of instret, RV32I only. 
#define csr_swap(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrrw %0, " #csr ", %1" \ 
                  : "=r" (__v) : "rK" (__v)); \ 
    __v;                            \ 
}) 
#define csr_read(csr) \ 
({ \ 
    register unsigned long __v; \ 
    asm volatile ("csrr %0, " #csr \ 
                  : "=r" (__v)); \ 
    __v; \ 
}) 
#define csr_write(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrw " #csr ", %0" \ 
                  : : "rK" (__v)); \ 
}) 
#define csr_read_set(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrrs %0, " #csr ", %1" \ 
                  : "=r" (__v) : "rK" (__v)); \ 
    __v; \ 
}) 
#define csr_set(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrs " #csr ", %0" \ 
                  : : "rK" (__v)); \ 
}) 
#define csr_read_clear(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrrc %0, " #csr ", %1" \ 
                  : "=r" (__v) : "rK" (__v)); \ 
    __v; \ 
}) 
#define csr_clear(csr, val) \ 
({ \ 
    unsigned long __v = (unsigned long)(val); \ 
    asm volatile ("csrc " #csr ", %0" \ 
                  : : "rK" (__v)); \ 
}) 
static inline unsigned long attribute((const)) cpuid() { 
        unsigned long res; 
        asm ("csrr %0, mcpuid" : "=r"(res)); 
        return res; 
} 
static inline unsigned long attribute((const)) impid() { 
        unsigned long res; 
        asm ("csrr %0, mimpid" : "=r"(res)); 
        return res; 
} 
#endif // RISCV_H 

В данной конфигурации VexRiscv у нас имеется всего один вектор прерывания, общий, как для всех аппаратных прерываний, связанных с externalInterrupt, так и для генерируемых вычислительным ядром исключений. Поэтому для того, чтобы обрабатывать аппаратные прерывания от конкретного устройства в обработчике прерываний (для Си программ это функция irqCallback()), необходимо сначала выяснить, что является источником прерывания, проверив старший бит машинного слова mcause — исключение или прерывание от внешнего устройства. Если источником является сигнал externalInterrupt, то далее следует проверить регистр PLIC->PENDING, содержащий флаги сработанных прерываний, и в соответствии с флагами самостоятельно вызывать обработчик для нужного устройства. Таким образом, код обработки прерываний может выглядеть следующим образом:

Подключим заголовочный файл с описанием примитивов CSR:

#include "riscv.h"

Объявим пару глобальных переменных для подсчета числа прерываний:

volatile int total_irqs = 0; 
volatile int extint_irqs = 0; 

Объявим функцию обработки исключений, которая распечатает нам код исключения и «зависнет» в бесконечном цикле:

void crash(int cause) { 
        print("\r\n*** EXCEPTION: "); 
        printhex(cause); 
        print("\r\n"); 
        while(1); 
} 

Объявим функцию обработки прерываний от внешних устройств:

void externalInterrupt() { 
        unsigned int pending_irqs = PLIC->PENDING; 
        print("EXTIRQ: pending flags = "); 
        printhex(pending_irqs); 
        unsigned int a = UART->DATA; 
        printhex(a); 
        PLIC->PENDING &= 0x0; // clear all pending ext interrupts 
} 

Объявим функцию — вектор прерываний:

void irqCallback() { 
        // Interrupts are already disabled by machine 
        int32_t mcause = csr_read(mcause); 
        int32_t interrupt = mcause < 0;    // HW interrupt if true, exception if false 
        int32_t cause     = mcause & 0xF; 
        if(interrupt){ 
                switch(cause) { 
                        case CAUSE_MACHINE_TIMER: { 
                                print("\r\n*** irqCallback: machine timer irq ? WEIRD!\r\n"); 
                                break; 
                        } 
                        case CAUSE_MACHINE_EXTERNAL: { 
                                externalInterrupt(); 
                                extint_irqs++; 
                                break; 
                        } 
                        default: { 
                                print("\r\n*** irqCallback: unsupported exception cause: "); 
                                printhex((unsigned int)cause); 
                        } break; 
                } 
        } else { 
                crash(cause); 
        } 
        total_irqs++; 
} 

Весь этот код поместим в файл main.c.

Теперь в тело основного цикла программы, в функцию main(), добавим код разрешения прерываний от всех устройств:

        char *test = malloc(1024); 
        println("Malloc test:"); 
        printhex((unsigned int)test); 

        // Configure interrupt controller 
        PLIC->POLARITY = 0xffffffff; // Set all IRQ polarity to High 
        PLIC->PENDING = 0; // Clear pending IRQs 
        PLIC->ENABLE = 0xffffffff; // Enable all ext interrupts 

        // Configure UART IRQ sources: bit(0) - TX interrupts, bit(1) - RX interrupts 
        UART->STATUS |= (1<<1); // Allow only RX interrupts 

        csr_set(mstatus, MSTATUS_MIE); // Enable Machine interrupts 
	...

и распечатку значений накопленного числа прерываний:

    while(1){ 
                unsigned int shift_time; 
                unsigned int t1, t2; 
                //println("Hello world, this is VexRiscv!"); 
                //char str[128]; 
                //vsnprintf(str, 128, "Hello world, this is VexRiscv!\r\n", NULL); 
                //print(str); 
                printf("Hello world, this is VexRiscv!\r\n"); 
                printf("PLIC: pending = %0X, total_irqs = %d, extint_irqs = %d\r\n", PLIC->PENDING, total_irqs, extint_irqs); 
	...

Традиционно выполняем сборку проекта командой make clean && make, загружаем битстрим, запускаем minicom и наблюдаем следующие сообщения:

Checking SRAM at: 90000000 
SRAM total fails: 00000000 
Enabled heap on SRAM 
Malloc test: 
90000008 
EXTIRQ: pending flags = 00000001 
0001004D 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 0, extint_irqs = 0 
000000C6 
000282CD 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 0, extint_irqs = 0
000000C6 

Теперь, если в порт отправить какие-нибудь данные, а для этого достаточно просто понажимать клавиши в эмуляторе терминала, то мы будем наблюдать следующие сообщения:

Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 0, extint_irqs = 0 
EXTIRQ: pending flags = 00000001 
00010073 
EXTIRQ: pending flags = 00000001 
00010064 
EXTIRQ: pending flags = 00000001 
00010066 
EXTIRQ: pending flags = 00000001                                                 
00010073                                                                         
000000C7                                                                         
000282CD                                                                         
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 4, extint_irqs = 4 

Видно, что аппаратные прерывания приводят к вызову функции irqCallback(), а за ней функции externalInterrupt(), о чем говорит нам битовое поле pending flags = 00000001, где нулевой бит установлен в лог «1» и отвечает за контроллер UART.

Замечание: если при посылке данных в порт UART не формируются прерывания, то следует проверить опцию аппаратного контроля RTS/CTS в программе minicom, она должна быть отключена. В противном случае minicom не будет посылать данные в порт, дожидаясь готовности физически отсутствующего сигнала.

17.4 Подключаем контроллер FastEthernet (MAC)

Плата «Карно» снабжена интерфейсом для подключения к сети с помощью микросхемы LAN8710, которая представляет собой PHY трансивер стандарта FastEthernet (100Base-T). Трансивер — это такое устройство, которое осуществляет преобразование цифрового сигнала от устройства в аналоговый сигнал для передачи его в линию связи и обратно, т. е. выполняет модуляцию и демодуляцию сигналов, их кодирование и декодирование. Трансивер LAN8710 оснащен MII интерфейсом (Media-independent Interface) для взаимодействия с вычислительной системой, в составе которой он работает. Линии MII интерфейса трансивера напрямую подключены к сигнальным линиям ПЛИС, что позволяет осуществлять работу с сетью прямо внутри микросхемы ПЛИС, иными словами — позволяет синтезировать MAC контроллер и интегрировать его в вычислительную систему, т. е. в наш вариант СнК Murax. Наличие сетевого интерфейса открывает богатые возможности к творчеству и решению практических задач, поэтому задействовать этот блок периферийной аппаратуры является крайне важным. Разрабатывать свой MAC контроллер мы не станем, так как это достаточно сложная задача для начинающих, и для описания всего процесса мне пришлось бы написать статью сопоставимого размера. Вместо этого мы задействуем уже готовый компонент из библиотеки SpinalHDL, а точнее — целый набор компонентов. Но прежде следует сказать несколько слов о том, как устроена работа с сетями Ethernet.

17.4.1 Как работает Ethernet

Сети Ethernet — это совокупный набор стандартов проводной пакетной связи без подтверждения и без гарантии доставки пакетов, предложенный компанией Xerox и разработанный в небезызвестном исследовательском центре Xerox Palo Alto Research Center (Xerox PARC). Первый коммерческий вариант Ethernet был принят как стандарт IEEE 802.3 в 1982 году, он использовал в качестве среды передачи сигнала «толстый» коаксиальный кабель (см рис. 29) и имел максимальную скорость передачи 10 Мбит/сек (в то время модемная связь на скорости 9600 бод была вершиной чудес). Этот стандарт получил сокращенное название 10Base-5. Чуть позже он был упрощен (удешевлен) и адаптирован для использования «тонкого» коаксиального кабеля с волновым сопротивлением 50 Ом (см. рис. 30). Этот стандарт получил название 10Base-2.

Рис. 29. Трансивер сети Ethernet (10Base-5) буквально «врезался» в коаксиальный кабель.
Рис. 29. Трансивер сети Ethernet (10Base-5) буквально «врезался» в коаксиальный кабель.

В обоих стандартах среда (медный провод) представляет собой «общую комнату для переговоров», а значит, в один момент времени «говорить» может только одна станция, остальные в этот момент должны только «слушать». Чтобы выйти на связь, т. е. передать пакет данных, станция обязана какое-то время понаблюдать за активностью в «переговорной комнате» и как только в ней установится «тишина» — начать подавать несущую и через некоторое время передать пакет, предварительно закодировав его, используя специально разработанный метод. После чего станция должна быстро вернуться в состояние прослушивания. В процессе передачи станция так же обязана прослушивать саму себя и сравнивать то, что она передала с тем, что она принимает. Это необходимо для того, чтобы понять, не возникла ли коллизия — ситуация, когда две (и более) станции ведут передачу данных в один и тот же момент времени. Если было детектировано состоянии коллизии, то станция должна «замолчать» на некоторое случайное время, после чего повторить попытку передачи пакета с самого начала. Если во второй попытке также была детектирована коллизия, то станция также обязана «замолчать», удвоив время ожидания и повторить попытку и так далее. Если в течении вразумительного времени станции не удалось передать пакет без коллизии, то это фиксируется как ошибка и пакет выбрасывается, а пользователю (приложению, инициирующему передачу данных) формируется соответствующий сигнал. Если в процессе передачи пакета коллизий не возникло, то считается, что пакет успешно отправлен. Никаких подтверждений на отправленный пакет не принимается и на физическом и канальном уровне не предусматривается. Такой способ доступа к среде (к «переговорной комнате») был назван «Carrier-sense multiple access with collision detection media access control» (CSMA/CD MAC) — множественный доступ к среде с определением наличия несущей и детектированием коллизий. Сейчас это упрощенно принято называть MAC, что не совсем правильно, но, как говорится, «who cares?».

Рис. 30. Трансивер Ethernet стандарта 10Base-2 подключался к «тонкому» коаксиальному кабелю через разъем типа BNC.
Рис. 30. Трансивер Ethernet стандарта 10Base-2 подключался к «тонкому» коаксиальному кабелю через разъем типа BNC.
Рис. 31. Схема построения сети Ethernet стандарта 10Base-2. К коаксиальному кабелю с помощью «тройников» (BNC-T) подключаются станции. Кабель на обоих концах терминируется сопротивлением 50 Ом (BNC terminator).
Рис. 31. Схема построения сети Ethernet стандарта 10Base-2.
К коаксиальному кабелю с помощью «тройников» (BNC-T) подключаются станции. Кабель на обоих концах терминируется сопротивлением 50 Ом (BNC terminator).

В 1990 году Ethernet был адаптирован для использования кабеля типа «витая пара» — четыре проводника, попарно свитых друг с другом, одна пара на прием, другая — на передачу данных, а для их коммутации было предложено использовать простое «повторяющее» устройство — Ethernet HUB, которое повторяло принятый сигнал из одного порта во все остальные. Использование такой схемы позволило разделить приемный и передающий каналы, а значит, существенно уменьшить влияние помех и снизить задержку на передачу пакета. Пропускная способность все еще сильно зависела от количества одновременно подключенных к сети станций — чем больше станций, тем больше вероятность возникновения коллизии. Этот стандарт получил название 10Base-T. Быстро стало понятно, что если Ethernet HUB снабдить небольшим интеллектом — т. е. заставить его проверять адрес назначения пакета и транслировать его только в тот порт, который ведет к станции-приемнику, то можно сильно разгрузить сеть и сделать так, чтобы пропускная способность не зависела от числа станций в сети. Такое устройство назвали Level-2 Ethernet Switch (или L2 Ethernet-коммутатор). Современные Ethernet-коммутаторы — это существенно более сложные устройства: в протокол и в функции коммутаторов добавили понятие «виртуальной переговорной комнаты» (VLAN), снабдили функциями маршрутизаторов (Level-3), фильтров, анализа пакетов и т. д.

В 1995 году появляется стандарт FastEthernet (100Base-TX), принятый как IEEE 802.3u и работающий на скорости 100 Мбит/сек по все тем же двум свитым вместе парам, при этом усилились требования к качеству кабеля и его характеристикам (Cat5).

В 1999 году был прият стандарт GigabitEthernet (1000Base-T), известный под номером IEEE 802.3ab. Как следует из названия, данный стандарт позволяет передавать данные со скоростью 1000 Мбит/сек и использует уже четыре свитые пары — причем все четыре одновременно на прием и на передачу, а для отделения передаваемого сигнала от принимаемого используются алгоритмы «эхо подавления» (echo cancellation). Требования к кабелю возрастают еще сильнее (Cat5e, Cat6). В GigabitEthernet при первом включении станции проводят «переговоры» и договариваются, кто из них будет работать «мастером», а кто «слэйвом» - от этого зависит источник синхросигнала и способы управления потоком.

В 2016 году появляются сразу несколько стандартов: 2.5GBase-T, 5GBase-T, 10GBase-T, 25GBase-T и 40GBase-T со скоростями передачи 2.5, 5, 10, 25 и 40 Гбит/сек соответственно.

Несмотря на существование более высокоскоростных стандартов, стандарты FastEthernet (100Base-TX) и GigabitEthernet (1000Base-T) являются самыми популярными по сей день за счет дешевизны и непритязательности к инфраструктуре и кабельному хозяйству — витая пара категории Cat5e, это самый распространенный вид кабеля во всем мире. А стандарт FastEthernet за счет более низкой скорости обмена несложно реализовать в простых микроконтроллерных устройствах. Так, используемый для FastEthernet интерфейс MII рассчитан на синхронный обмен с хост-устройством на частоте 25 МГц, в то время как RGMII для GigabitEthernet работает на частоте 125 МГц и требует передачи блока по 4 бита как по фронту, так и по спаду (DDR), что очень сильно усложняет реализацию MAC контроллера и делает мало пригодным использование GigabitEthernet на дешевых микроконтроллерных устройствах, неспособных формировать сигналы с такой тактовой частотой. По этой причине подавляющее большинство IoT устройств снабжается встроенным MAC контроллерами с MII интерфейсом для подключения к 100Base-TX сети.

Теперь рассмотрим, как осуществляется подключение станции (устройства) к сети Ethernet на примере 100Base-TX. На рис. 32 изображена структурная схема такого соединения с использованием MII и RMII интерфейса. Отличие RMII от MII состоит в том, что в RMII сокращено число линий данных в два раза за счет удвоения частоты до 50 МГц, в остальном они функционируют одинаково.

Рис. 32. Структурная схема подключения устройства к сети 100Base-TX с помощью MII/RMII интерфейса.
Рис. 32. Структурная схема подключения устройства к сети 100Base-TX с помощью MII/RMII интерфейса.

На стороне хост-устройства выделяется специальный цифровой блок аппаратуры, который принято называть MAC. Обычно это IP-блок, входящий в состав микросхемы микроконтроллера или более сложного СнК. В случае с платой «Карно» MAC мы будем синтезировать внутри микросхемы ПЛИС. Задача MAC состоит в том, чтобы передавать данные пользователя (приложения) в трансивер PHY и обратно по цифровой шине из 4х бит (или 2-х бит в случае RMII). При этом для приема и передачи используются раздельные шины TX_D[0:3] и RX_D[0:4], каждая со своим сигналом тактирования TX_CLK и RX_CLK. Источником тактового сигнала для обеих шин является трансивер (PHY), который для передачи использует свой независимый тактовый генератор (CLK = 25 МГц), а при приеме — восстанавливает тактовый сигнал из линии. Таким образом, на стыке между MAC и PHY возникает пересечение тактовых доменов CoreCLK<->TX_CLK и CoreCLK<->RX_CLK. Для управления передачей шина TX снабжается сигналом TX_EN, который формируется MAC в тот момент, когда он готов начать передачу данных. Аналогично, для приема шина RX снабжается сигналом RX_DV (receive data valid), который передается от PHY к MAC в момент, когда трансивер обнаружил в линии несущую и начал прием данных. Прием и передача пакета осуществляется непрерывно, т. е. как только поступил сигнал RX_DV, MAC контроллер обязан принять всё до последнего полубайта, поступление которого сигнализируется снятием сигнала RX_DV. Помимо этого, шина приема содержит еще три сигнала: RX_COL — сигнализирует о возникновении коллизии, RX_ER — ошибка при приеме и RX_CRS — индикатор наличия несущей. Все эти сигналы необходимы для того, чтобы MAC мог выполнить требования CSMA/CD.

Задача PHY гораздо более сложная — произвести кодирование полученных данных и их модуляцию в линию и наоборот. К счастью, существуют множество готовых микросхем Ethernet трансиверов (PHY) для всех стандартов и разновидностей интерфейсов, поэтому данного вопроса касаться не будем.

Помимо двух шин обмена данных (TX_D и RX_D), Ethernet трансиверы обычно имеют отдельную шину управления «Management Data Input/Output», состоящую из двух сигналов: Management IO (MDIO) — для передачи команд и считывания состояний регистров и Management CLK (MDC) — для формирования тактовых сигналов. С помощью шины управления MAC может задавать режимы работы трансивера (например, Full/Half-duplex, скорость в линии 10 или 100 Мбит/с), а также считывать его состояние и узнавать подробности о возникающих ошибках. Взаимодействие с трансивером по шине управления MDIO походит на работу с периферийными устройствами по шине типа I2C, но имеет ряд отличий — хост-устройство передает по шине 5 бит адрес подчиненного трансивера (их на шине может быть до 31 шт), 5 бит номера регистра трансивера и 16 бит данных, которые в этот регистр требуется записать (или считать). При обмене по шине MDIO максимальная частота тактового сигнала MDC не должна превышать 2,5 МГц. Временные диаграммы MDIO интерфейса приведены ниже на рис. 33.

Рис. 33. Временные диаграммы циклов записи (вверху) и чтения (внизу) регистра трансивера по управляющей шине MDIO.
Рис. 33. Временные диаграммы циклов записи (вверху) и чтения (внизу) регистра трансивера по управляющей шине MDIO.

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

Ethernet трансиверы могут формировать отельный сигнал прерывания (LAN_nINT) при изменении статуса — т. е. установке стабильного соединения (окончании процедуры «handshake») или смене параметров линии.

В простых устройствах большой необходимости в шине управления MDIO нет, так как большинство Ethernet трансиверов имеют настройки по умолчанию, позволяющие произвести автоматическую настройку параметров линии без участия MAC, а в интерфейсе MII уже присутствует всё необходимое для приема/передачи данных по сети. Поэтому поддержку шины MDIO мы реализовывать не будем. При необходимости читатель может легко реализовать MDIO программным путем, манипулируя двумя линиями GPIO.

Для того, чтобы принять пакет целиком, MAC должен иметь встроенный буфер размером не меньше, чем максимальный размер Ethernet пакета, который называется «фреймом». Для 100Base-TX максимальный размер фрейма составляет 1522 байта, четыре последний байта фрейма это контрольная сумма (FCS — frame check sequence), которую MAC обязан проверить после приема и перед выдачей данных пользователю. Аналогично требуется отдельный буфер на передачу — в него пользователь помещает пакет, который MAC выдает в трансивер. При передаче MAC обязан рассчитать контрольную сумму и добавить её к данным фрейма при передаче в PHY.

Каждый Ethernet фрейм содержит 6 байт адреса принимающей станции, 6 байт адреса станции отправителя, два байта EtherType, определяющих содержимое полезной нагрузки и до 1500 байт данных самой нагрузки. Опционально фрейм может содержать один или два тэга по четыре байта каждый. Присутствие тэгов определяется настройкой коммутатора, первые два байта тэга всегда содержат число 0x8100, что рассматривается как зарезервированный EtherType для тэгированных фреймов, а следующие два байта — номер VLAN-а («виртуальной комнаты»). Мы не будем рассматривать тэгированные фреймы и способ их формирования, так как, во-первых, это тема отдельного длинного разговора, а во-вторых, для нашей цели (реализация MAC в ПЛИС) это не существенно. На рис. 34 ниже представлен формат нетэгированного Ethernet фрейма.

Рис. 34. Формат Ethernet фрейма.
Рис. 34. Формат Ethernet фрейма.

17.4.2 Подключаем компонент MacEth

Итак, к MAC контроллеру для FastEthernet в составе нашей платы «Карно» мы можем предъявить следующие требования:

  • поддержка сигналов интерфейса MII: RX_D[3:0], RX_CLK, RX_ER, RX_DV, RX_COL, RX_CRS, TX_D[3:0], TX_EN, TX_CLK;

  • наличие встроенных буферов для приема и передачи фреймов в виде FIFO;

  • поддержка пересечения тактовых доменов, отдельно для тракта приема и тракта передачи;

  • поддержка управляющих регистров, отображаемых на общее адресное пространство, сброс TX/RX FIFO, определение количества слов свободного места в TX FIFO и количества слов готовых для чтения из RX FIFO;

  • поддержка прерываний для TX — при освобождении FIFO (конец передачи), для RX — при приеме фрейма.

  • поддержка регистров для чтения и записи в FIFO;

  • поддержка регистров статуса (количество переданных фреймов, количество ошибок и коллизий);

  • автоматический расчет контрольной суммы (FCS).

Как отмечалось выше, в составе SpinalHDL имеется весь необходимый набор компонентов для реализации MAC контроллера с описанными выше требования, расположены они в каталоге lib/src/main/scala/spinal/lib/com/eth/ репозитория SpinalHDL:

rz@devbox:~$ cd ~/SpinalHDL
rz@devbox:~/SpinalHDL$ ls -l lib/src/main/scala/spinal/lib/com/eth/ 
total 40 
-rw-rw-r-- 1 rz rz  841 Feb  1 09:14 BmbMacEth.scala 
-rw-rw-r-- 1 rz rz 5158 Apr  3  2021 Mac.scala 
-rw-rw-r-- 1 rz rz 9271 Feb  1 09:14 MacRx.scala 
-rw-rw-r-- 1 rz rz 9858 Feb  1 09:14 MacTx.scala 
-rw-rw-r-- 1 rz rz 4091 Feb  1 09:14 Phy.scala 

Файл Mac.scala содержит управляющую логику контроллера, его главный компонент — MacEth. Также в этом файле содержится структура MacEthParameter, описывающая параметры MII интерфейса.

Файлы MacRx.scala и MacTx.scala содержат реализацию тракта приёма и передачи по интерфейсу MII и FIFO буферы.

Файл Phy.scala содержит описание интерфейса MII, разделенного на три компонента: MiiRx, MiiTx и Mdio.

Для того чтобы подключить компонент MacEth к шине Apb3, нам потребуется описать свой компонент-обертку. Назовем его Apb3MacEthCtrl и разместим в файл ./src/main/scala/mylib/Apb3MacEthCtrl.scala репозитория VexRiscv рядом с другими нашими компонентами:

./src/main/scala/mylib/Apb3MacEthCtrl.scala
package mylib 
import spinal.core._ 
import spinal.lib._ 
import spinal.lib.bus.amba3.apb.{Apb3, Apb3Config, Apb3SlaveFactory} 
import spinal.lib.eda.altera.QSysify 
import spinal.lib.com.eth._ 
object Apb3MacEthCtrl{ 
  def getApb3Config = Apb3Config( 
    addressWidth = 16, 
    dataWidth = 32, 
    selWidth = 1, 
    useSlaveError = false 
  ) 
} 
case class Apb3MacEthCtrl(p : MacEthParameter) extends Component{ 
        val io = new Bundle{ 
                val apb =  slave(Apb3(Apb3MacEthCtrl.getApb3Config)) 
                val interrupt = out Bool() 
                val mii = master(Mii( MiiParameter( MiiTxParameter( dataWidth = p.phy.txDataWidth, withEr = false), MiiRxParameter( dataWidth = p.phy.rxDataWidth)))) 
        } 
        val txCd = ClockDomain(io.mii.TX.CLK) 
        val rxCd = ClockDomain(io.mii.RX.CLK) 
        val mac = new MacEth(p, txCd, rxCd) 
        val phy = PhyIo(p.phy) 
        phy <> mac.io.phy 
        txCd.copy(reset = mac.txReset) on { 
                val tailer = MacTxInterFrame(dataWidth = p.phy.txDataWidth) 
                tailer.io.input << phy.tx 
                io.mii.TX.EN := RegNext(tailer.io.output.valid) 
                io.mii.TX.D := RegNext(tailer.io.output.data) 
        } 
        rxCd on { 
                phy.rx << io.mii.RX.toRxFlow().toStream 
        } 
        val busCtrl = Apb3SlaveFactory(io.apb) 
        val bridge = mac.io.ctrl.driveFrom(busCtrl) 
        io.interrupt := bridge.interruptCtrl.pending 
}

Как видно из кода, компонент Apb3MacEthCtrl инкапсулирует два других компонента: MacEth и PhyIo, в нем создаются два тактовых домена txCd и rxCd, которые передаются в компонент MacEth и осуществляются связи интерфейсных сигналов. Компонент Apb3MacEthCtrl имеет три интерфейсных сигнала: apb — для стыковки с шиной Apb3 вычислительного ядра, сигнал mii — для соединения с трансивером и сигнал interrupt для формирования сигнала прерывания. Сигнал interrupt мы далее назначим на канал #2 ранее разработанного контроллера прерываний (компонент MicroPLIC).

Подключение компонента Apb3MacEthCtrl к шине Apb3 выполняется традиционным способом:

Сначала добавим внешний комплексный сигнал mii в компонент Murax:

case class Murax(config : MuraxConfig) extends Component{ 
  import config._ 
  val io = new Bundle { 
    ...
    //Peripherals IO 
    val gpioA = master(TriStateArray(gpioWidth bits)) 
    val uart = master(Uart()) 
    val mii = master(Mii(MiiParameter(MiiTxParameter(dataWidth = config.macConfig.phy.txDataWidth, withEr = false), MiiRxParameter( dat 
aWidth = config.macConfig.phy.rxDataWidth)))) 
   ...
  }

Далее добавим компонент Apb3MacEthCtrl в ассоциативный массив apbMapping в файле Murax.scala, также как это было сделано ранее для компонентов Apb3MicroPLICCtrl и Apb3MachineTimer и привяжем внешние сигналы io.mii. Код подключения выглядит следующим образом:

    val macCtrl = new Apb3MacEthCtrl(macConfig) 
    apbMapping += macCtrl.io.apb     -> (0x70000, 64 kB) 
    macCtrl.io.mii <> io.mii 
    plic.setIRQ(macCtrl.io.interrupt, 2) 

Данный компонент будет иметь регистры управления, отображаемые на адресное пространство 0xF0000000 + 0x70000 = 0xF0070000, а линия прерывания будет формировать прерывание по каналу #2 (бит 2 в регистрах) контроллера MicroPLIC.

Помимо этого, нам еще потребуется заполнить параметрами конфигурационную структуру MacEthParameter. Для этого добавим новый параметр macConfig в определение класса MuraxConfig():

case class MuraxConfig(coreFrequency : HertzNumber, 
                       onChipRamSize      : BigInt, 
                       onChipRamHexFile   : String, 
                       pipelineDBus       : Boolean, 
                       pipelineMainBus    : Boolean, 
                       pipelineApbBridge  : Boolean, 
                       gpioWidth          : Int, 
                       macConfig          : MacEthParameter, 
                       uartCtrlConfig     : UartCtrlMemoryMappedConfig, 
                       xipConfig          : SpiXdrMasterCtrl.MemoryMappingParameters, 
                       hardwareBreakpointCount : Int, 
                       cpuPlugins         : ArrayBuffer[Plugin[VexRiscv]])

Заполним структуру и разместим рядом с параметром uartCtrlConfig в реализации объекта MuraxConfig():

    macConfig = MacEthParameter( 
      phy = PhyParameter( 
        txDataWidth = 4, 
        rxDataWidth = 4 
      ), 
      rxDataWidth = 32, 
      rxBufferByteSize = 4096, 
      txDataWidth = 32, 
      txBufferByteSize = 4096 
    ), 

В структуре MacEthParameter параметры rxBufferByteSize и txBufferByteSize определяют размер буферов FIFO для входных и выходных Ethernet фреймов и в нашем случае имеют размер достаточный для сохранения двух обычных фреймов. Jumbo фреймы поддерживать не будем в виду ограниченного объема набортной RAM памяти.

Параметры rxDataWidth и txDataWidth задают размер слова FIFO. В нашем случае размер слова обуславливается размерностью шины — 32 бита. Это означает, что при работе с FIFO все операции будут выполнятся в 32-х битных словах, а не в байтах. На это следует обратить внимание при написании драйвера.

Вложенная структура PhyParameter задает параметры MII интерфейса — по 4 бита на прием и передачу.

Чтобы этот код успешно компилировался, не забываем подключить соответствующие библиотеки в заголовке файла Murax.scala:

import mylib.Apb3MicroPLICCtrl 
import mylib.Apb3MacEthCtrl 
import spinal.lib.com.eth._ 

Остался последних штрих: добавить описание внешних интерфейсных сигналов MII в файл karnix_cabga256.lpf и в файл-обертку toplevel.v.

Добавим следующие директивы в файл karnix_cabga256.lpf:

Добавка к файлу karnix_cabga256.lpf для MII интерфейса
LOCATE COMP "io_mii_mdio" SITE "B1";            # LAN_MDIO 
IOBUF PORT "io_mii_mdio" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_mdio" PULLMODE=NONE; 
LOCATE COMP "io_mii_mdc" SITE "A2";             # LAN_MDC 
IOBUF PORT "io_mii_mdc" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_mdc" PULLMODE=NONE; 
LOCATE COMP "io_mii_nint" SITE "B3";            # LAN_nINT 
IOBUF PORT "io_mii_nint" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_nint" PULLMODE=NONE; 
LOCATE COMP "io_mii_nrst" SITE "A3";            # LAN_nRST 
IOBUF PORT "io_mii_nrst" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_nrst" PULLMODE=NONE; 
FREQUENCY PORT "io_mii_TX_CLK" 25.0 MHz; 
LOCATE COMP "io_mii_TX_CLK" SITE "B4";          # LAN_TXCLK 
IOBUF PORT "io_mii_TX_CLK" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_CLK" PULLMODE=NONE; 
LOCATE COMP "io_mii_TX_EN" SITE "A4";           # LAN_TXEN 
IOBUF PORT "io_mii_TX_EN" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_EN" PULLMODE=NONE; 
LOCATE COMP "io_mii_TX_D[0]" SITE "B5";         # LAN_TXD0 
IOBUF PORT "io_mii_TX_D[0]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_D[0]" PULLMODE=NONE; 
LOCATE COMP "io_mii_TX_D[1]" SITE "A5";         # LAN_TXD1 
IOBUF PORT "io_mii_TX_D[1]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_D[1]" PULLMODE=NONE; 
LOCATE COMP "io_mii_TX_D[2]" SITE "B6";         # LAN_TXD2 
IOBUF PORT "io_mii_TX_D[2]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_D[2]" PULLMODE=NONE; 
LOCATE COMP "io_mii_TX_D[3]" SITE "A6";         # LAN_TXD3 
IOBUF PORT "io_mii_TX_D[3]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_TX_D[3]" PULLMODE=NONE; 
FREQUENCY PORT "io_mii_RX_CLK" 25.0 MHz; 
LOCATE COMP "io_mii_RX_COL" SITE "B2";          # LAN_COL 
IOBUF PORT "io_mii_RX_COL" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_COL" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_CRS" SITE "C1";          # LAN_CRS 
IOBUF PORT "io_mii_RX_CRS" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_CRS" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_ER" SITE "C2";           # LAN_RXER 
IOBUF PORT "io_mii_RX_ER" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_ER" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_DV" SITE "B7";           # LAN_RXDV 
IOBUF PORT "io_mii_RX_DV" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_DV" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_CLK" SITE "F1";          # LAN_RXCLK 
IOBUF PORT "io_mii_RX_CLK" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_CLK" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_D[0]" SITE "D1";         # LAN_RXD0 
IOBUF PORT "io_mii_RX_D[0]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_D[0]" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_D[1]" SITE "E2";         # LAN_RXD1 
IOBUF PORT "io_mii_RX_D[1]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_D[1]" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_D[2]" SITE "E1";         # LAN_RXD2 
IOBUF PORT "io_mii_RX_D[2]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_D[2]" PULLMODE=NONE; 
LOCATE COMP "io_mii_RX_D[3]" SITE "F2"; #       LAN_RXD3 
IOBUF PORT "io_mii_RX_D[3]" IO_TYPE=LVCMOS33; 
IOBUF PORT "io_mii_RX_D[3]" PULLMODE=NONE; 

Отредактируем файл toplevel.v и добавим проброс сигналов io_mii_* из модуля toplevel в модуль Murax:

Добавка к файлу toplevel.v для MII интерфейса
module toplevel( 
    input   io_clk25, 
    ...
    input   [3:0] io_mii_RX_D, // MII RX data lines 
    input   io_mii_RX_DV, // MII RX data valid 
    input   io_mii_RX_CLK, // MII RX clock 
    input   io_mii_RX_ER, // MII RX error flag 
    input   io_mii_RX_CRS, // MII RX carrier sense flag 
    input   io_mii_RX_COL, // MII RX collision detection flag 
    output  [3:0] io_mii_TX_D, // MII TX data lines 
    output  io_mii_TX_EN, // MII TX data enable 
    input  io_mii_TX_CLK, // MII TX clock 
  ); 
    …

  Murax murax ( 
    ...
    .io_mii_RX_D(io_mii_RX_D), 
    .io_mii_RX_CLK(io_mii_RX_CLK), 
    .io_mii_RX_DV(io_mii_RX_DV), 
    .io_mii_RX_ER(io_mii_RX_ER), 
    .io_mii_RX_CRS(io_mii_RX_CRS), 
    .io_mii_RX_COL(io_mii_RX_COL), 
    .io_mii_TX_D(io_mii_TX_D), 
    .io_mii_TX_CLK(io_mii_TX_CLK), 
    .io_mii_TX_EN(io_mii_TX_EN), 
  ); 

Собираем проект командой make clean && make, устраняем синтаксические ошибки, смотрим на статистику в результате синтеза и наблюдаем как ресурсы ПЛИС обильно пошли в расход:

Info: Device utilisation: 
Info:             TRELLIS_IO:    69/  197    35% 
Info:                   DCCA:     4/   56     7% 
Info:                 DP16KD:    52/   56    92% 
Info:             MULT18X18D:     4/   28    14% 
Info:                 ALU54B:     0/   14     0% 
Info:                EHXPLLL:     1/    2    50% 
...
Info:             TRELLIS_FF:  2360/24288     9% 
Info:           TRELLIS_COMB:  4256/24288    17% 
Info:           TRELLIS_RAMW:    36/ 3036     1% 
...
Info: Routing globals... 
Info:     routing clock net $glbnet$io_core_jtag_tck$TRELLIS_IO_IN using global 0 
Info:     routing clock net $glbnet$io_mii_RX_CLK$TRELLIS_IO_IN using global 1 
Info:     routing clock net $glbnet$io_mii_TX_CLK$TRELLIS_IO_IN using global 2 
Info:     routing clock net $glbnet$clk using global 3 
...
Info: Max frequency for clock    '$glbnet$io_mii_TX_CLK$TRELLIS_IO_IN': 73.72 MHz (PASS at 25.00 MHz) 
Info: Max frequency for clock    '$glbnet$io_mii_RX_CLK$TRELLIS_IO_IN': 113.21 MHz (PASS at 25.00 MHz) 
Info: Max frequency for clock                            '$glbnet$clk': 75.26 MHz (PASS at 75.00 MHz) 
Info: Max frequency for clock '$glbnet$io_core_jtag_tck$TRELLIS_IO_IN': 135.35 MHz (PASS at 12.00 MHz) 
...
Info: Program finished normally. 

У нас готов контроллер MAC. В следующей главе мы рассмотрим, как сделать простейший драйвер и выполнить обмен пакетами с DHCP сервером из Си программы «hello_world».

17.4.3 Разрабатываем драйвер для компонента MacEth

Слово «драйвер», наверное, это слишком громкое слово для такого проекта, тем не менее, нам потребуется создать три функции, которые вполне сойдут за минималистичный драйвер. Это будут функции: mac_init() — для инициализации (сброса) MAC контроллера, mac_tx() — для передачи Ethernet фрейма и mac_rx() — для считывания принятого фрейма из буфера MAC контроллера. Также мы подвесим код обработки прерываний, поступающих от MAC контроллера через MicroPLIC.

Перед тем как начать реализацию драйвера, разберем программный интерфейс (API), предоставляемый модулем MacEth. Как уже отмечалось, тракты приема и передачи работают независимо друг от друга, а значит их можно рассматривать как два отдельных устройства, каждое из которых имеет свой FIFO буфер длиной в два обычных Ethernet фрейма, элементом которого является 32-х битное слово. Для управления FIFO буферами MacEth предоставляет один общий регистр управления CTRL; один общий регистр STAT для статистики ошибок; раздельные регистры TX/RX для записи/чтения данных; и раздельные регистры TX_AVAIL и RX_AVAIL для выяснения текущей заполненности FIFO. Все регистры MacEth находятся в адресном пространстве, начиная с 0xF0070000, их можно представить приведенной ниже структурой. Описание этой структуры мы поместим в новый заголовочный файл mac.h в каталоге с кодом программы «hello_world», а базовый адрес области с регистрами контроллера опишем с помощью макро MAC:

#define MAC     ((MAC_Reg*)(0xF0070000))

typedef struct 
{ 
        volatile uint32_t CTRL; 
        volatile uint32_t res1[3]; 
        volatile uint32_t TX; 
        volatile uint32_t TX_AVAIL; 
        volatile uint32_t res2[2]; 
        volatile uint32_t RX; 
        volatile uint32_t res3[2]; 
        volatile uint32_t STAT; 
        volatile uint32_t RX_AVAIL; 
} MAC_Reg; 

Регистр CTRL имеет смещение 0 байт и содержит биты следующего назначения:
• Бит 0 — запись лог «1» вызывает сброс аппаратуры передающего тракта и обнуление FIFO.
• Бит 1 — чтение лог «1» сигнализирует готовность и наличие места в FIFO для записи минимум одного слова.
• Бит 2 — запись лог «1» включает выравнивание данных на передающих линиях MII по границе 16 бит. Мне не совсем понятно, зачем это может понадобиться, предполагаю, что имеются трансиверы, которые имеют большее число линий данных, чем 4 для стандарта MII.
• Бит 4 — запись лог «1» вызывает сброс аппаратуры принимающего тракта и обнуление FIFO.
• Бит 5 — чтение лог «1» сигнализирует о наличии в FIFO входного тракта полностью принятого фрейма.
• Бит 6 — запись лог «1» включает выравнивание данных по 16 бит на приемных линиях интерфейса MII.
• Бит 7 — чтение лог «1» сигнализирует о том, что FIFO буфер входного тракта полностью заполнен и есть вероятность переполнения (потери части данных принятого фрейма).

Регистр TX имеет смещение 8 байт. Запись 32-х битного слова в этот регистр помещает слово в FIFO передающего тракта.

Регистр TX_AVAIL имеет смещение 12 байт — чтение из этого регистра возвращает количество свободных слов, которые еще можно поместить в буфер FIFO передающего тракта.

Регистр RX имеет смещение 20 байт — чтение из этого регистра изымает одно слово принятого фрейма из FIFO в принимающем тракте.

Регистр STAT имеет смещение 28 байт — содержит следующие поля:
• Биты [7:0] — содержат счетчик числа ошибок по приему (ERRORS).
• Биты [15:8] — содержат счетчик числа ошибок по передачи (DROPS).

Регистр RX_AVAIL имеет смещение 32 байта — чтение этого регистра возвращает число слов, содержащихся в FIFO приемного тракта, готовых к изъятию.

Опишем все это дело следующими макроопределениями:

#define MAC_CTRL_TX_RESET       0x00000001 
#define MAC_CTRL_TX_READY       0x00000002 
#define MAC_CTRL_TX_ALIGN_EN    0x00000004 
#define MAC_CTRL_RX_RESET       0x00000010 
#define MAC_CTRL_RX_PENDING     0x00000020 
#define MAC_CTRL_RX_ALIGN_EN    0x00000040 
#define MAC_CTRL_RX_FULL        0x00000080 
#define MAC_STAT_ERRORS         0x000000ff 
#define MAC_STAT_DROPS          0x0000ff00 

Далее для удобства работы с аппаратными регистрами добавим в этот же заголовочный файл следующие инструментальные inline функции:

inline uint32_t mac_writeAvailability(MAC_Reg *reg){ 
        return reg->TX_AVAIL; 
} 
inline uint32_t mac_readAvailability(MAC_Reg *reg){ 
        return reg->RX_AVAIL; 
} 
inline uint32_t mac_readDrops(MAC_Reg *reg){ 
        return reg->STAT >> 8; 
} 
inline uint32_t mac_readErrors(MAC_Reg *reg){ 
        return reg->STAT & 0xff; 
} 
inline uint32_t mac_rxPending(MAC_Reg *reg){ 
        return reg->CTRL & MAC_CTRL_RX_PENDING; 
} 
inline uint32_t mac_rxFull(MAC_Reg *reg){ 
        return reg->CTRL & MAC_CTRL_RX_FULL; 
} 
inline uint32_t mac_txReady(MAC_Reg *reg){ 
        return reg->CTRL & MAC_CTRL_TX_READY; 
} 
inline uint32_t mac_getCtrl(MAC_Reg *reg){ 
        return reg->CTRL; 
} 
inline uint32_t mac_setCtrl(MAC_Reg *reg, uint32_t val){ 
        return reg->CTRL = val; 
} 
inline uint32_t mac_getRx(MAC_Reg *reg){ 
        return reg->RX; 
} 
inline uint32_t mac_pushTx(MAC_Reg *reg, uint32_t val){ 
        return reg->TX = val; 
} 

Добавим заголовки для трех функций драйвера:

void mac_init(void); 
int mac_rx(uint8_t* mac_buf); 
int mac_tx(uint8_t *mac_buf, int frame_size); 

Добавим макроопределения для кодов ошибок, выдаваемых драйвером:

#define MAC_ERR_OK              0 
#define MAC_ERR_RX_FIFO         1 
#define MAC_ERR_ARGS            2 
#define MAC_ERR_RX_TIMEOUT      3 

Доработаем заголовочный файл mac.h, добавив условную компиляцию для ограничения избыточных включений, подключим файл stdint.h с описанием используемых типов данных и включим вывод отладочных сообщений определив макро MAC_DEBUG:

#ifndef _MAC_H_ 
#define _MAC_H_ 
#include <stdint.h> 
#define MAC_DEBUG       1 // включим вывод отладочных сообщений
// … здесь располагается приведенный выше кодирование
#endif // MAC_H 

У нас готов заголовочный файл для драйвера. Теперь приступим к реализации.

Сначала реализуем функцию инициализации аппаратуры (сброса), так как она очень простая. Для сброса достаточно установить в лог «1» соответствующие биты, выждать какое-то время (не менее 10 мс) и перевести в лог «0» для перехода устройства в рабочее состояние. Поместим следующий код инициализации в файл mac.c в том же каталоге:

extern void delay_us(unsigned int);

void mac_init(void) { 
        mac_setCtrl(MAC, MAC_CTRL_TX_RESET | MAC_CTRL_RX_RESET); 
        delay_us(10000); 
        mac_setCtrl(MAC, 0); 
} 

Задержка осуществляется функций delay_us() которую мы ранее имплементировали в основном файле программы — в main.c, поэтому просто сошлемся на неё как на extern функцию.

Добавим код вспомогательной функции mac_printf() для вывода отладочной информации, используя внутренний буфер. Этот код можно будет легко отключить установкой макро-переменной MAC_DEBUG:

#define MAC_DEBUG       1 
#ifdef MAC_DEBUG 

#include <stdarg.h> 

extern void print(char *); 

char mac_dbg_msg[256]; 

void mac_printf(const char* fmt, ...) { 
        va_list args; 
        va_start(args, fmt); 
        vsnprintf(mac_dbg_msg, 256, fmt, args); 
        va_end(args); 
        print(mac_dbg_msg); 
} 
#endif 

Далее реализуем функцию для отправки данных mac_tx(). Эта функция будет принимать два параметра: mac_buf — указатель на буфер в памяти машины, содержащий подготовленный к отправке Ethernet фрейм и frame_size — размер фрейма в байтах.

Используемый нами MAC контроллер MacEth имеет следующую специфику при отправке фрейма:

  • перед началом отправки фрейма необходимо дождаться готовности устройства;

  • перед записью любого слова в FIFO необходимо дождаться освобождения места в нём;

  • первое слово, которое помещается в FIFO, обязано содержать размер фрейма, исчисляемый в битах;

  • в след за этим словом в FIFO помещаются данные фрейма слово за словом по 32 бита, данные в словах размещаются в формате «network» (big endian).

Таким образом, в функции mac_tx() нам необходимо сначала выполнить проверку валидности входных данных, рассчитать число передаваемых бит, после чего дождаться готовности аппаратуры и FIFO, передать первое слово, содержащее количество бит в фрейме и далее, в цикле перемещая по четыре байта, отправить весь фрейм в FIFO. Выглядеть это может следующим образом:

int mac_tx(uint8_t *mac_buf, int frame_size)
int mac_tx(uint8_t *mac_buf, int frame_size) { 
        if(mac_buf == NULL || frame_size < 0 || frame_size >= 2048) { 
                #ifdef MAC_DEBUG 
                mac_printf("mac_tx() wrong args: mac_buf = %p, frame_size = %d\r\n", mac_buf, frame_size); 
                #endif 
                return -MAC_ERR_ARGS; 
        } 
        uint32_t bits = frame_size*8; 
        uint32_t words = (bits+31)/32; 
        #ifdef MAC_DEBUG 
        mac_printf("mac_tx() sending MAC frame size = %d (%d words)\r\n", frame_size, words); 
        #endif 
        // wait for MAC controller to get ready to send 
        while(!mac_txReady(MAC)); 
        // wait for space in TX FIFO 
        while(mac_writeAvailability(MAC) == 0); 
        mac_pushTx(MAC, bits); // first word in FIFO is number of bits in following packet 
        uint32_t byte_idx = 0; 
        uint32_t word = 0; 
        uint32_t words_sent = 0; 
        uint8_t *p = mac_buf; 
        uint32_t bytes_left = frame_size; 
        while(bytes_left) { 
                word |= ((*p++) & 0xff) << (byte_idx * 8); 
                if(byte_idx == 3) { 
                        while(mac_writeAvailability(MAC) == 0); 
                        mac_pushTx(MAC, word); 
                        word = 0; 
                        words_sent++; 
                } 
                byte_idx = (byte_idx + 1) & 0x03; 
                bytes_left--; 
        } 
        // Write remaining tail 
        if(byte_idx != 0) { 
                while(mac_writeAvailability(MAC) == 0); 
                mac_pushTx(MAC, word); 
                words_sent++; 
        } 
        #ifdef MAC_DEBUG 
        mac_printf("mac_tx() %02x:%02x:%02x:%02x:%02x:%02x <- %02x:%02x:%02x:%02x:%02x:%02x " 
                "type: 0x%02x%02x, byte_idx = %d, words sent = %d, frame_size = %d\r\n", 
                mac_buf[0], mac_buf[1], mac_buf[2], mac_buf[3], mac_buf[4], mac_buf[5], 
                mac_buf[6], mac_buf[7], mac_buf[8], mac_buf[9], mac_buf[10], mac_buf[11], 
                mac_buf[12], mac_buf[13], 
                byte_idx, words_sent, frame_size); 
        #endif 
        return MAC_ERR_OK; 
} 

Следует обратить внимание на то, что в FIFO записываются слова по четыре байта, а значит возможны случаи, когда размер фрейма не будет кратным числу 4. Такой случай в приведенном коде обработан отдельно путем проверки переменной byte_idx, она указывает на номер байта в передаваемом слове.

С отправкой фрейма разобрались. Теперь займемся изъятием из FIFO приемного тракта полученного фрейма. Специфика работы контроллера MacEth здесь следующая:

  • перед чтением данных из FIFO приемного тракта необходимо дождаться появления в нём данных;

  • первое слово, считанное из FIFO, содержит размер принятого фрейма, исчисляемый в битах, а значит нужно считать его и вычислить количество слов для цикла чтения самого фрейма;

  • произвести считывание всего фрейма из FIFO словами по 32 бита, данные в котором поступают в формате «network» (big endian);

Специальная обработка случая, когда длина изымаемого фрейма не кратна 4 байтам, здесь не требуется, достаточно корректно вычислить число циклов чтения (число считываемых слов).

Реализация функции mac_rx() может выглядеть следующим образом:

int mac_rx(uint8_t* mac_buf)
int mac_rx(uint8_t* mac_buf) { 
        uint32_t bytes_read = 0; 
        #ifdef MAC_DEBUG 
        mac_printf("mac_rx() begin\r\n"); 
        #endif 
        if(mac_rxPending(MAC)) { 
                uint32_t bits = mac_getRx(MAC); 
                uint32_t words = (bits+31)/32; 
                uint32_t bytes_left = (bits+7)/8; 
                uint8_t* p = mac_buf; 
                uint32_t word; 
                if(bytes_left > 2048) { 
                        mac_printf("mac_rx() RX FIFO error, bytes_left = %d bytes (%d bits)\r\n", bytes_left, bits); 
                        mac_init(); 
                        return -MAC_ERR_RX_FIFO; 
                } 
                #ifdef MAC_DEBUG 
                mac_printf("mac_rx() reading %d bytes (%d bits)\r\n", bytes_left, bits); 
                #endif 
                while(bytes_left) { 
                        int i = 1000; 
 
                        while(mac_rxPending(MAC) == 0 && i-- > 1000); 
                        if(i == 0) { 
                                #if MAC_DEBUG 
                                mac_printf("mac_rx() timeout, bytes_left = %d\r\n", bytes_left); 
                                #endif 
                                return -MAC_ERR_RX_TIMEOUT; 
                        } 
                        word = mac_getRx(MAC); 
                        for(i = 0; i < 4 && bytes_left; i++) { 
                                *p++ = (uint8_t) word; 
                                word = word >> 8; 
                                bytes_left--; 
                                bytes_read++; 
                        } 
                } 
                #ifdef MAC_DEBUG 
                mac_printf("mac_rx() %02x:%02x:%02x:%02x:%02x:%02x <- %02x:%02x:%02x:%02x:%02x:%02x " 
                                "type: 0x%02x%02x, bytes_left = %d, " 
                                "words = %d, bytes_read = %d, last word = 0x%08x\r\n", 
                        mac_buf[0], mac_buf[1], mac_buf[2], mac_buf[3], mac_buf[4], mac_buf[5], 
                        mac_buf[6], mac_buf[7], mac_buf[8], mac_buf[9], mac_buf[10], mac_buf[11], 
                        mac_buf[12], mac_buf[13], 
                        bytes_left, words, bytes_read, word); 
                #endif 
        } 
        #ifdef MAC_DEBUG 
        mac_printf("mac_rx() done, bytes read = %d\r\n", bytes_read); 
        #endif 
        return bytes_read; 
} 

Функция mac_rx() принимает в качестве единственного параметра указатель на область памяти, в которую следует поместить изымаемый фрейм из FIFO приемного тракта. Предполагается, что пользователь заранее позаботился о том, чтобы выделить (статически или на «куче») буфер достаточного объема для размещения фрейма. Напомню, что стандартный Ethernet фрейм не может превышать 1522 байта.

Обычно следующим шагом в написании драйвера является создание обработчика прерываний от устройства. Используемый нами компонент MacEth умеет формировать прерывание в момент, когда у него в FIFO буфере приемного тракта появляются данные для чтения. Сигнал interrupt оберточного компонента Apb3MacEthCtrl мы ранее уже подвязали к 2-му каналу нашего контроллера прерываний. Но в данном примере с целью упрощения мы пойдем другим путем — будем опрашивать состояние входного FIFO из функции delay(), которая циклически вызывается в главном цикле программы, и если в FIFO что-то имеется, то вызывать функцию mac_rx(). Такой подход существенно упростит код и позволит избавиться от ряда неприятных артефактов, связанных с обработкой прерываний. Напомню, что цель данной главы является продемонстрировать работоспособность компонента MacEth, а не написание правильного и всесторонне выверенного драйвера.

Добавим в код функции delay(), находящейся в основном файле программы — main.c, а также заведем статический буфер для принимаемого фрейма:

static char mac_rx_buf[2048]; 

void delay(uint32_t loops){ 
        if(mac_rxPending(MAC)) { 
                mac_rx(mac_rx_buf); 
        } 
        for(int i=0;i<loops;i++){ 
                //int tmp = GPIO_A->OUTPUT; 
                asm volatile ("slli t1,t1,0x10"); 
        } 
} 

Принятые данные мы обрабатывать не будем, но при включенном макро MAC_DEBUG мы будем наблюдать в отладочном порту сообщения от функции mac_rx(), в том числе распечатку заголовка принятого Ethernet фрейма. Этого вполне достаточно для демонстрационных целей.

Последний штрих в написании драйвера — вызов функции инициализации MAC контроллера. Добавим её в код функции main() сразу после конфигурирования UART:

    csr_set(mstatus, MSTATUS_MIE); // Enable Machine interrupts
    // Configure UART0 IRQ sources: bit(0) - TX interrupts, bit(1) - RX interrupts 
    UART->STATUS |= (1<<1); // Allow only RX interrupts 

    mac_init(); 

    GPIO_A->OUTPUT_ENABLE = 0x0000000F; 

Не забываем добавить #include <mac.h> в заголовок файла main.c. Собираем проект как обычно, с помощью make clean && make, устраняем ошибки компиляции, но не спешим с загрузкой в ПЛИС. Ведь для того, чтобы получить какой-то Ethernet пакет, неплохо было бы сначала послать в сеть какой-то пакет, и здесь вполне логичным является отправка запроса Discover в DHCP сервер. Формированием примитивного DHCP запроса мы займемся в следующей главе.

17.4.4 Отправляем запрос в DHCP сервер

Работа устройства с сетью в современных реалиях не мыслима без поддержки стека протоколов TCP/IP, а его функционирование не возможно без IP адреса. Получение IP адреса является первоочередной задачей любого сетевого устройства и осуществляется это обычно посредством широко используемого протокола Dynamic Host Configuration Protocol (DHCP). В рамках данной статьи я не буду рассказывать о том, как запустить весь TCP/IP стек на нашем синтезированном СнК, хоть это и очень интересная тема, скажу лишь, что это возможно и совсем несложно, если задействовать готовый стек lwIP. Здесь же мы ограничимся созданием примитивного запроса к DHCP серверу на получение IP адреса.

Протокол DHCP является достаточно сложным и многогранным механизмом управления сетью. Одной из его основных задач является выделение вновь подключаемым к сети устройствам уникальных IP адресов и снабжение их конфигурационной информацией, достаточной для работы в сети. Выполняет эту функцию специальное программное обеспечение — DHCP сервер, один или несколько таких серверов устанавливаются и настраиваются для обслуживания запросов от DHCP клиентов в каждый широковещательный сегмент сети Ethernet.

Рис. 35. Обмен между клиентом и сервером по протоколу DHCP на стадии первичного подключения.
Рис. 35. Обмен между клиентом и сервером по протоколу DHCP на стадии первичного подключения.

Первоначальный запрос к DHCP серверу поступает от клиент с пустым (нулевым) IP адресом на широковещательный IP адрес 255.255.255.255 в виде IP/UDP пакета, при этом используются следующие номера UDP портов: 68 — для клиента и 67 — для сервера. В теле запроса клиент указывает тип запроса (например Discover) и список конфигурационных параметров, которые он хочет получить от сервера. Отправив запрос, клиент дожидается ответа от сервера. Сервер на первоначальный запрос Discover формирует ответ-предложение — Offer, указывая список предлагаемых к использованию параметров, в том числе IP адрес и сетевую маску, а также запрашиваемые опциональные параметры (адрес маршрутизатора, адрес DNS сервера, доменное имя и т. д.). Клиент применяет полученные параметры, т. е. соглашается с предложением, о чем уведомляет сервер другим запросом — Request, на что сервер отвечает подтверждением — Acknowledge. Типовая схема взаимодействия клиента и сервера по протоколу DHCP представлена на рис. 35. Реализовывать весь обмен по протоколу DHCP нам не придется. Чтобы получить какой-то ответ от DHCP сервера, нам достаточно реализовать только запрос Discover, это уже создаст какой-то обмен по сети и позволит нам оценить работоспособность задействованного MAC контроллера и созданного нами примитивного драйвера.

Ethernet фрейм с DHCP запросом имеет следующую общую структуру:

  • Заголовок Ethernet фрейма — содержит MAC адреса станции назначения и станции отправителя. В запросе Discover адрес назначения устанавливается в ff:ff:ff:ff:ff:ff, что означает «отправить всем».

  • IP заголовок — содержит нулевой IP адреса хоста отправителя и широковещательный IP адрес получателя (255.255.255.255).

  • UDP заголовок — содержит номера портов клиента (68) и сервера (67).

  • DHCP запрос — содержит уникальный номер запроса XID, тип запроса и список параметров. По уникальному XID клиент и сервер идентифицируют запросы/ответы друг друга.

Для создания DHCP запроса нам потребуются структуры MAC, IP и UDP заголовков, а также структура самого DHCP запроса.

Структура для MAC заголовка:

typedef struct 
{ 
        uint8_t hw_dst_addr[6]; 
        uint8_t hw_src_addr[6]; 
        uint16_t etype; 
} MAC_Header; 

Структура для IP заголовка:

typedef struct { 
        uint8_t ver_ihl; 
        uint8_t dscp_ecn; 
        uint16_t pkt_len; 
        uint16_t ident; 
        uint16_t flags_frags; 
        uint16_t ttl_proto; 
        uint16_t header_csum; 
        uint32_t src_addr; 
        uint32_t dst_addr; 
} attribute((packed)) IP_Header;

Структура для UDP заголовка:

typedef struct { 
        uint16_t src_port; 
        uint16_t dst_port; 
        uint16_t pkt_len; 
        uint16_t crc; 
} __attribute__((__packed__)) UDP_Header; 

Структура для DHCP запроса:

typedef struct { 
        uint8_t op; 
        uint8_t htype; 
        uint8_t hlen; 
        uint8_t hops; 
        uint32_t xid; 
        uint16_t secs; 
        uint16_t flags; 
        uint32_t ciaddr; 
        uint32_t yiaddr; 
        uint32_t siaddr; 
        uint32_t giaddr; 
        char chaddr[16]; 
        char sname[64]; 
        char file[128]; 
        char magic[4]; 
        char opt[10]; 
} __attribute__((__packed__)) DHCP_Message; 

Структура отправляемого сообщения содержит все вышеперечисленные заголовки:

typedef struct { 
        MAC_Header mac; 
        IP_Header ip; 
        UDP_Header udp; 
        DHCP_Message dhcp; 
} __attribute__((__packed__)) MAC_IP_UDP_DHCP_Message; 

Для того чтобы заполнить структуру IP заготовка нам потребуется вычислить контрольную сумму. Позаимствуем с сайта Wikipedia готовую функцию для этих целей:

uint16_t ip_check_sum(uint16_t *addr, int len)
uint16_t ip_check_sum(uint16_t *addr, int len) 
{ 
        int nleft = len; 
        uint16_t *w = addr; 
        uint32_t sum = 0; 
        uint16_t answer = 0; 
        while (nleft > 1)  { 
                sum += *w++; 
                nleft -= 2; 
        } 
        if (nleft == 1) { 
                *(uint8_t *)(&answer) = *(uint8_t *)w ; 
                sum += answer; 
        } 
        sum = (sum >> 16) + (sum & 0xffff); 
        sum += (sum >> 16); 
        answer = ~sum; 
        return(answer); 
} 

В UDP заголовке тоже присутствует поле контрольной суммы, но для IP протокола версии 4 это поле опционально, т. е. будем помещать в него нули.

Для заполнения MAC заголовка нам потребуется два MAC адреса — отправителя (наше устройство) и получателя (сервер). В качестве получателя в протоколе DHCP регламентируется использовать широковещательный MAC адрес вида ff:ff:ff:ff:ff:ff. В качестве MAC адреса нашего устройства выберем случайный адрес, удовлетворяющий требованиям Local Administered Address стандарта IEEE 802: 06:aa:bb:cc:dd:ee.

uint8_t hw_my_addr[6] = { 0x06, 0xaa, 0xbb, 0xcc, 0xdd, 0xee }; 
uint8_t hw_brd_addr[6] = { 0xff, 0xff, 0xff, 0xff, 0xff, 0xff }; 

Для заполнения некоторых полей в структурах нам понадобится пара макроопределений для перестановки последовательности байт в слове:

#define SWAP32(X)       __builtin_bswap32((X)) 
#define SWAP16(X)       __builtin_bswap16((X)) 

Теперь мы можем описать функцию для заполнения DHCP сообщения и его отправки в сеть. На странице в Wikipedia, посвященной протоколу DHCP, приведен детальный пример пакета с запросом Discover, воспользуемся этими данными:

int dhcp_send_discover(void)
int dhcp_send_discover(void) { 
        static uint32_t xid = 0x12345678; 
        int frame_size = 0; 
        MAC_IP_UDP_DHCP_Message msg = (MAC_IP_UDP_DHCP_Message) malloc(2048); 
        if(msg == NULL) { 
                printf("dhcp_send_discover: failed to allocate buffer\r\n"); 
                return -100; 
        } 
        bzero(msg, 2048); 
        msg->dhcp.op = 1; msg->dhcp.htype = 1; msg->dhcp.hlen = 6; msg->dhcp.hops = 0; 
        msg->dhcp.xid = SWAP32(xid++); msg->dhcp.secs = 0; msg->dhcp.flags = 0; 
        msg->dhcp.magic[0]=0x63; msg->dhcp.magic[1]=0x82; msg->dhcp.magic[2]=0x53; msg->dhcp.magic[3]=0x63; 
        msg->dhcp.opt[0]=0x35; msg->dhcp.opt[1]=1; msg->dhcp.opt[2]=1; // Discovery 
        msg->dhcp.opt[3]=0x37; msg->dhcp.opt[4]=4; // Parameter request list 
        msg->dhcp.opt[5]=1; msg->dhcp.opt[6]=3; // mask, router 
        msg->dhcp.opt[7]=15; msg->dhcp.opt[8]=6; // domain name, dns 
        msg->dhcp.opt[9]=0xff; // end of params 
        memcpy(msg->dhcp.chaddr, hw_my_addr, 6); 
        frame_size += sizeof(msg->dhcp) + sizeof(msg->udp); 
        msg->udp.pkt_len = SWAP16(frame_size); 
        msg->udp.src_port = SWAP16(68); msg->udp.dst_port = SWAP16(67); 
        frame_size += sizeof(msg->ip); 
        msg->ip.src_addr = 0x0; msg->ip.dst_addr = 0xffffffff; 
        msg->ip.ver_ihl = 0x45; msg->ip.ttl_proto = SWAP16((255 << 8) + 17); 
        msg->ip.pkt_len = SWAP16(frame_size); 
        msg->ip.header_csum = ip_check_sum((uint16_t*)&(msg->ip), sizeof(msg->ip)); 
        frame_size += sizeof(msg->mac); 
        memcpy(msg->mac.hw_dst_addr, hw_brd_addr, 6); 
        memcpy(msg->mac.hw_src_addr, hw_my_addr, 6); 
        msg->mac.etype = SWAP16(0x0800); 
        int ret = mac_tx((uint8_t*)msg, frame_size); 
        free(msg); 
        return ret; 
} 

Приведенная выше функция dhcp_send_discover(), используя malloc(), выделяет память под структура типа MAC_IP_UDP_DHCP_Message. Далее последовательно заполняет её, начиная с самого конца (с тела DCHP запроса), рассчитывает контрольную сумму IP заголовка, устанавливает IP и MAC адреса и отправляет полученный Ethernet фрейм в сеть, используя функцию драйвера maс_tx(). После отправки выделенная область памяти высвобождается библиотечной функцией free(). В качеcтве уникального идентификатора XID используется число 0x12345678, которое заносится в статическую переменную и увеличивается на единицу каждый раз при вызове функции.

Помещаем весь приведенный выше код в отдельный файл dhcp.c в том же каталоге, где располагается остальной код программы «hello_world». В заголовке файла dhcp.c подключаем используемые библиотеки и не забываем про заголовочный файл нашего драйвера mac.h:

#include <stdint.h> 
#include <stdlib.h> 
#include <stdio.h> 
#include <string.h> 
#include "mac.h"

Теперь осталось добавить вызов функции dhcp_send_discover() из функции main() программы «hello_world». Добавим описание функции в заголовок файла main.c:

volatile int total_irqs = 0; 
volatile int extint_irqs = 0; 

int dhcp_send_discover(void); 

Вызов dhcp_send_discover() расположим в конце тела цикла while, после распечатки времени исполнения операции сдвига:

                ...
                printhex(shift_time); 
                printhex(t2 - t1); 

                dhcp_send_discover(); 

Собираем проект, устраняем синтаксические ошибки и загружаем в ПЛИС, подключаем кабель Ethernet, запускаем minicom и наблюдаем сообщения:

Filling SRAM at: 90000000 
Checking SRAM at: 90000000 
SRAM total fails: 00000000 
Enabled heap on SRAM 
Malloc test: 
90000008 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 0, extint_irqs = 0 
EXTIRQ: pending flags = 00000004 
mac_rx() begin 
mac_rx() reading 64 bytes (512 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0806, bytes_left = 0, words = 16, bytes_read = 64, last word = 0x00000000 
mac_rx() done, bytes read = 64 
000000C6 
0002A516 
mac_tx() sending MAC frame size = 292 (73 words) 
mac_tx() fEXTIRQ: pending flags = 00000004 
f:ff:ff:ff:ff:ff <- 06:aa:bb:cc:dd:ee type: 0x0800, byte_idx = 0, words sent = 73, frame_size = 292 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 2, extint_irqs = 2 
mac_rx() begin 
mac_rx() reading 66 bytes (528 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0800, bytes_left = 0, words = 17, bytes_read = 66, last word = 0x0000007e 
mac_rx() done, bytes read = 66 
EXTIRQ: pending flags = 00000004 
mac_rx() begin 
mac_rx() reading 219 bytes (1752 bits) 
mac_rx() ff:ff:ff:ff:ff:ff <- c0:25:e9:ad:2b:1a type: 0x0800, bytes_left = 0, words = 55, bytes_read = 219, last word = 0x0000000c 
mac_rx() done, bytes read = 219 
EXTIRQ: pending flags = 00000004 
mac_rx() begin 
mac_rx() reading 64 bytes (512 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0806, bytes_left = 0, words = 16, bytes_read = 64, last word = 0x00000000 
mac_rx() done, bytes read = 64 
000000C8 
0002AB3C 
mac_tx() sending MAC frame size = 292 (73 words) 
mac_tx() ff:ff:ff:ff:ff:ff <- 06:aa:bb:cc:dd:ee type: 0x0800, byte_idx = 0, words sent = 73, frame_size = 292 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 4, extint_irqs = 4 
000000C7 
0002A516 
mac_tx() sending MAC frame size = 292 (73 words) 
mac_tx() ff:ff:ff:ff:ff:ff <- 06:aa:bb:cc:dd:ee type: 0x0800, byte_idx = 0, words sent = 73, frame_size = 292 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 4, extint_irqs = 4 
EXTIRQ: pending flags = 00000004 
mac_rx() begin 
mac_rx() reading 346 bytes (2768 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0800, bytes_left = 0, words = 87, bytes_read = 346, last word = 0x00000add 
mac_rx() done, bytes read = 346 
mac_rx() begin 
mac_rx() reading 346 bytes (2768 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0800, bytes_left = 0, words = 87, bytes_read = 346, last word = 0x00000199 
mac_rx() done, bytes read = 346 
000000C6 
0002A516 

Жирным шрифтом выделена информация о переданных и принятых Ethernet фреймах, содержащих запрос Disсover и ответ Offer от DHCP сервера. В моём случае сервер имеет MAC адрес c0:25:e9:ad:2b:1a и это мой домашний маршрутизатор. Если присмотреться, то можно увидеть фреймы с запросами ARP (EtherType = 0x0806), адресованные нашему устройству, но ответить мы на них пока что не можем.

Проведем небольшое тестирование - погоняем наше устройство некоторое время в таком режиме запрос-ответ, чтобы понять не зависнет ли драйвер или контроллер. Через 15 минут продолжаем наблюдать в отладочном порту обмен с DHCP сервером:

Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 885, extint_irqs = 885 
mac_rx() begin 
mac_rx() reading 346 bytes (2768 bits) 
mac_rx() 06:aa:bb:cc:dd:ee <- c0:25:e9:ad:2b:1a type: 0x0800, bytes_left = 0, words = 87, bytes_read = 346, last word = 0x00000760 
mac_rx() done, bytes read = 346 
000000C6 
0002A516                                                                         
mac_tx() sending MAC frame size = 292 (73 words)                                 
mac_tx() ff:fEXTIRQ: pending flags = 00000004                                    
f:ff:ff:ff:ff <- 06:aa:bb:cc:dd:ee type: 0x0800, byte_idx = 0, words sent = 73, frame_size = 292 
Hello world, this is VexRiscv! 
PLIC: pending = 0, total_irqs = 886, extint_irqs = 886 

По счетчику прерываний видно, что уже было принято 886 фреймов. Делаем вывод о том, что MAC контроллер и написанный нами драйвер работают стабильно.

Следующим логическим шагом было бы подключение TCP/IP стека, но эту тему я оставлю для отдельной статьи.

18. Потактовая симуляция СнК Murax

В главе «13.7 Симуляция и верификация в SpinalHDL» я обещал продемонстрировать, как выполняется потактовая симуляция компонента в SpinalHDL. Настало время это сделать, к тому же есть повод.

В процессе отладки компонента PipelinedMemoryBusSram для работы с внешней микросхемой SRAM у меня возникли определенные трудности с реализацией цикла чтения. Напомню, что на плате «Карно» имеется микросхема статической памяти, содержащая 256К ячеек по 16 бит каждая, адресация ячеек которой происходит пословно. Для выбора старшего или младшего байта микросхема имеет два сигнала #UB и #LB (у компонента PipelinedMemoryBusSram это будут сигналы io.sram_bhe и io.sram_ble). Запись 32-х битного слова в SRAM осуществляется за два такта, а вот чтение за два такта выполнить не удалось — очень часто на шине данных образовывался «мусор» и чтобы нивелировать эту проблему мне пришлось выделять по два такта при чтении каждого 16-ти битного слова. При реализации этого алгоритма у меня возникла некоторая путаница в голове, и чтобы её развеять, мне захотелось посмотреть, как и в какой последовательности приходят сигналы в разрабатываемый компонент PipelinedMemoryBusSram и что компонент выдает в ответ. Для этого пришлось освоить, как выполняется потактовая симуляция в SpinalHDL.

По сути, симуляция в SpinalHDL ничем не отличается от верификации. При верификации мы подаем на вход компонента синтетические данные и сравниваем результат с эталонной моделью. При симуляции мы делаем только половину работы — формируем требуемое нам количество тактовых импульсов и наблюдаем за работой составных частей компонента, т. е. просто печатаем или сохраняем интересующие нас сигналы для последующего анализа. Верифицировать и симулировать можно как отдельные компоненты системы, так и всю систему целиком. Правда, верифицировать такую систему, как вычислительное ядро с работающей программой, крайне затратно по времени, и к такому решению прибегают нечасто. А вот просимулировать работу системы в какие-то заданные интервалы времени и понаблюдать за состоянием определенных сигналов — это всегда полезно.

Ну что же, попробуем просимулировать всю нашу синтезируемую систему-на-кристалле Murax в течении 10000 тактов и понаблюдаем за сигналами на стыке компонента PipelinedMemoryBusSram и mainBus, или, если быть более точным, за сигналами sramCtrl.io.bus в составе области тактирования system. Основная сложность здесь состоит в том, как добраться до интересующих нас сигналов из виртуального испытательного стенда и как их распечатать в лог. Но об этом чуть позже, а для начала опишем испытательный стенд для всей системы:

import spinal.sim._ 
import spinal.core.sim._ 
object Murax_karnix_Sim { 
  def main(args: Array[String]) { 
    SimConfig.withWave.compile{ 
      val dut = new Murax(MuraxConfig.default(withXip = false).copy( 
              coreFrequency = 75.5 MHz, 
              onChipRamSize = 96 kB , 
              //pipelineMainBus = true, 
              onChipRamHexFile = "src/main/c/murax/hello_world/build/hello_world.hex" 
      )) 
      // More DUT preparations should be put here
      dut 
    }.doSim 	{ 
      dut => 
      // Create our own clock domain using external signals mainClk and asyncReset 
      val myClockDomain = ClockDomain(dut.io.mainClk, dut.io.asyncReset) 
      // Fork process that drives our clock 
      myClockDomain.forkStimulus(period = 10) 
      // We are in reset state at the beginning 
      myClockDomain.assertReset() 
      // Simulate next 1K clock cycles 
      for(idx <- 0 to 9999) { 
        if(idx > 1) { myClockDomain.deassertReset() } 
       myClockDomain.waitRisingEdge() 
        // … some printouts should be put here
     } 
    } 
  } 
} 

Как и при верификации, здесь создается новый объект Murax_karnix_Sim, который будет выступать точкой входа при вызове сборочной утилиты SBT. SimConfig задает параметры симуляции — в данном случае симулятор, помимо прочего, будет формировать файл с временной диаграммой (напомню, что симуляция выполняется внешним тулом Verilator, который должен быть установлен в системе пользователя). Далее создается новая область myClockDomain для домена тактирования и в ней в цикле формируются тактовые импульсы. В нулевом такте происходит установка сигнала сброса вызовом метода myClockDomain.assertReset(), а в 1-м и последующих тактах сигнал сброса снимается вызовом метода myClockDomain.deassertReset(). Формирование тактовых сигналов происходит путем вызова метода myClockDomain.waitRisingEdge(), который как бы «дожидается» появления переднего фронта тактового сигнала, но на самом деле он же его и формирует. Собственно, в этом же цикле мы и должны анализировать состояние сигналов и выводить интересующие нас моменты в лог.

Разберемся, какие сигналы мы будем анализировать и выводить. Прежде всего нас интересует комплексный сигнал sramCtrl.io.bus и его составные части: .cmd.valid, .cmd.ready, .cmd.write, .cmd.address, .cmd.mask, .cmd.data, .rsp.valid и .rsp.data (см. главу «17.2.1 Разрабатываем контроллер SRAM»). Если заглянуть в файл Murax.scala, то можно обнаружить, что компонент sramCtrl (и все его сигналы) инкапсулирован в область тактирования system. Поэтому глобально доступное имя интересующих нас сигналов будет иметь вид: system.sramCtrl.io.bus.cmd.valid, system.sramCtrl.io.bus.cmd.ready и т. д.

Помимо сигналов отлаживаемого компонента, нам было бы не плохо понимать какую машинную инструкцию в данный момент выполняет ядро и какой адрес сейчас загружен в счетчик команд PC. В компоненте VexRiscv, реализующий вычислительное ядро, имеются сигналы lastStageInstruction и lastStagePc, содержащие интересующие нас данные. В СнК Murax компонент VexRiscv инкапсулирован в ту же область тактирования system и поименован как cpu. Поэтому адресовать интересующие нас сигналы можно аналогичным образом: system.cpu.lastStagePc и system.cpu.lastStageInstruction.

Теперь разберемся, как получить доступ к сигналам из испытательного стенда и как их распечатать в лог. Чтобы сигналы компонента были доступны внутри стенда, их нужно «опубликовать» вызовом метода .simPublic() у каждого из сигналов. Выше мы создали переменную dut типа Murax в качестве испытуемого компонента. Сошлемся относительно неё и опубликуем все интересующие нас сигналы следующим образом:

      // More DUT preparations should be put here
      dut.system.cpu.lastStageInstruction.simPublic() 
      dut.system.cpu.lastStagePc.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.valid.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.write.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.mask.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.ready.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.address.simPublic() 
      dut.system.sramCtrl.io.bus.cmd.data.simPublic() 
      dut.system.sramCtrl.io.bus.rsp.valid.simPublic() 
      dut.system.sramCtrl.io.bus.rsp.data.simPublic() 

      dut 

    }.doSim { 

Чтобы распечатать состояния сигналов, будем использовать стандартную функцию println() с форматировщиком .format(). При выводе каждый сигнал (переменную) требуется преобразовать к нужному типу: к числу, к булевому значению или к строке символов. Вывод в лог интересующих нас сигналов будет выглядеть так:

            println("[cycle: %8d, pc: %08x, instr: %08x] dut.system.sramCtrl: cmd.valid = %s, cmd.ready = %s, cmd.write = %s, cmd.mask 
= %08x, cmd.address = %08x, cmd.data = %08x, rsp.valid = %s, rsp.data = %08x".format( 
              idx, 
              dut.system.cpu.lastStagePc.toLong, 
              dut.system.cpu.lastStageInstruction.toLong, 
              dut.system.sramCtrl.io.bus.cmd.valid.toBoolean, 
              dut.system.sramCtrl.io.bus.cmd.ready.toBoolean, 
              dut.system.sramCtrl.io.bus.cmd.write.toBoolean, 
              dut.system.sramCtrl.io.bus.cmd.mask.toLong, 
              dut.system.sramCtrl.io.bus.cmd.address.toLong, 
              dut.system.sramCtrl.io.bus.cmd.data.toLong, 
              dut.system.sramCtrl.io.bus.rsp.valid.toBoolean, 
              dut.system.sramCtrl.io.bus.rsp.data.toLong 
            )) 

Этот код должен располагаться внутри цикла, после «ожидания» переднего фронта тактового сигнала.

Собственно, этого уже достаточно, чтобы запустить симуляцию и получить результат. Но, анализировать 10000 строк лога не очень интересно, поэтому имеет смысл внести в код условие, ограничивающее вывод только интересующего нас момента. Напомню, что нас интересует процесс считывания 32-х битного слова из SRAM. Чтобы ограничить вывод, введем условие по адресу ячейки памяти, к которой осуществляется доступ. Напомню, что область SRAM находится в диапазоне 0x900000000x900400ff.

        if(!dut.system.sramCtrl.io.bus.cmd.write.toBoolean && 
           dut.system.sramCtrl.io.bus.cmd.address.toLong >= 0x90000000l && 
           dut.system.sramCtrl.io.bus.cmd.address.toLong <= 0x900400ffl) { 

            println("...".format(
               …
            )) 
        } 

Условие вида !dut.system.sramCtrl.io.bus.cmd.write.toBoolean проверяет, что в текущем такте выполняется операция чтения (т. е. «не запись»).

Добавляем весь приведенный код в самый конец файла Murax.scala и готовимся запустить симуляцию.

Перед запуском симуляции нам нужно сделать кое-что еще — требуется подправить исходный код Си программы «hello_world» так, чтобы в первых циклах работы программы сразу был доступ к области SRAM на чтение. Иначе, наша симуляция из 10000 тактов завершится быстрее, чем программа попытается выполнить интересующее нас действие и в логе мы ничего полезного не обнаружим. Добавим следующий код в файл main.c:

        uart_config.clockDivider = SYSTEM_CLOCK_HZ / UART_BAUD_RATE / rxSamplePerBit - 1; 
        uart_applyConfig(UART, &uart_config); 

        char a[8]; 
        a[0] = *(char*)(0x90000000 + 3); 
        a[1] = 0; 
        println(a); 

        if(sram_test_write_random_ints() == 0) { 

То есть считаем один байт из ячейки 0x90000003. Здесь я умышленно ставлю адрес не кратный степени двойки чтобы проверить как это выглядит на стороне разрабатываемого компонента, работает ли это вообще или произойдет программное исключение. Также нас интересует, какие биты будут установлены в сигнале маски .io.bus.cmd.mask.

Компилируем Си программу командой make clean && make из каталога ./src/main/c/murax/hello_world:

rz@devbox:~/VexRiscv/src/main/c/murax/hello_world$ make clean && make 
...
Memory region         Used Size  Region Size  %age Used 
             RAM:       21104 B        96 KB     21.47% 
/opt/riscv//bin/riscv64-unknown-elf-objcopy -O ihex build/hello_world.elf build/hello_world.hex 
/opt/riscv//bin/riscv64-unknown-elf-objdump -S -d build/hello_world.elf > build/hello_world.asm 
/opt/riscv//bin/riscv64-unknown-elf-objcopy -O verilog build/hello_world.elf build/hello_world.v 

Переходим в каталог ./scripts/Murax/Karnix и запускаем симуляцию следующей командой:

rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ (cd ../../../; sbt "runMain vexriscv.demo.Murax_karnix_Sim")

Если сборка прошла успешно и проект откомпилировался для запуска в Verilator-е, мы увидим следующие сообщения:

[info] welcome to sbt 1.6.0 (Ubuntu Java 11.0.9.1) 
[info] loading settings for project vexriscv-build from plugins.sbt ... 
[info] loading project definition from /home/rz/VexRiscv/project 
[info] loading settings for project root from build.sbt ... 
[info] set current project to VexRiscv (in build file:/home/rz/VexRiscv/) 
[info] running (fork) vexriscv.demo.Murax_karnix_Sim 
[info] [Runtime] SpinalHDL v1.10.1    git head : 2527c7c6b0fb0f95e5e1a5722a0be732b364ce43 
[info] [Runtime] JVM max memory : 8294.0MiB 
[info] [Runtime] Current date : 2024.03.17 00:27:40 
[info] [Progress] at 0.000 : Elaborate components 
[info] [Warning] This VexRiscv configuration is set without software ebreak instruction support. Some software may rely on it (ex: Rust). (This isn't related to JTAG ebreak) 
[info] [Warning] This VexRiscv configuration is set without illegal instruction catch support. Some software may rely on it (ex: Rust) 
[info] [Progress] at 2.478 : Checks and transforms 
[info] [Progress] at 3.453 : Generate Verilog 
[info] [Warning] toplevel/system_cpu/RegFilePlugin_regFile : Mem[32*32 bits].readAsync can only be write first into Verilog 
[info] [Warning] toplevel/system_cpu/RegFilePlugin_regFile : Mem[32*32 bits].readAsync can only be write first into Verilog 
[info] [Warning] 195 signals were pruned. You can call printPruned on the backend report to get more informations. 
[info] [Done] at 4.903 
[info] [Progress] Simulation workspace in /home/rz/VexRiscv/./simWorkspace/Murax 
[info] [Progress] Verilator compilation started 
[info] [info] Found cached verilator binaries 
[info] [Progress] Verilator compilation done in 1373.702 ms 
[info] [Progress] Start Murax test simulation with seed 923932021 
[info] [cycle:     4885, pc: 80001bac, instr: 0037c783] dut.system.sramCtrl: cmd.valid = true, cmd.ready = false, cmd.write = false, cmd.mask = 00000008, cmd.address = 90000003, cmd.data = 88888888, rsp.valid = false, rsp.data = 00000000 
[info] [cycle:     4886, pc: 80001bac, instr: 0037c783] dut.system.sramCtrl: cmd.valid = true, cmd.ready = false, cmd.write = false, cmd.mask = 00000008, cmd.address = 90000003, cmd.data = 88888888, rsp.valid = false, rsp.data = 00000000 
[info] [cycle:     4887, pc: 80001bac, instr: 0037c783] dut.system.sramCtrl: cmd.valid = true, cmd.ready = false, cmd.write = false, cmd.mask = 00000008, cmd.address = 90000003, cmd.data = 88888888, rsp.valid = false, rsp.data = 00000000 
[info] [cycle:     4888, pc: 80001bac, instr: 0037c783] dut.system.sramCtrl: cmd.valid = true, cmd.ready = true, cmd.write = false, cmd.mask = 00000008, cmd.address = 90000003, cmd.data = 88888888, rsp.valid = false, rsp.data = 00000000 
[info] [Done] Simulation done in 1645.485 ms 
[success] Total time: 14 s, completed Mar 17, 2024, 12:27:49 AM 

Последние четыре строки, выделенные жирным шрифтом — это и есть вывод функции println() в процессе симуляции. Здесь мы видим, что операция чтения из ячейки с адресом 0x90000003 была выполнена за четыре машинных такта — так отработал компонент PipelinedMemoryBusSram.

Из вывода видно, что операцию чтения запросила машинная инструкция с кодом 0x0037c783 расположенная в основной памяти по адресу 0x80001bac. Давайте заглянем в файл build/hello_world.asm содержащий ассемблерный код программы «hello_world» и посмотрим, что это за инструкция. Ниже приведен фрагмент этого файла:

        a[0] = *(char*)(0x90000000 + 3); 
80001ba8:       900007b7                lui     a5,0x90000 
80001bac:       0037c783                lbu     a5,3(a5) # 90000003 <_ram_heap_end+0xffe8003> 

Жирный шрифтом выделена именно эта инструкция: lbu a5,3(a5) — «load byte unsigned», она читает один байт (без знака) из ячейки памяти со смещением 3 байта относительно адреса, содержащегося в регистре a5, и помещает значение в этот же регистр a5.

Из лога симуляции также видно, что при чтении сигнал масок был установлен в значение 0x00000008, то есть установлен бит в 3-ем разряде, что сигнализирует компоненту о необходимости (и достаточности) читать только 3-й байт. Текущая имплементация компонента PipelinedMemoryBusSram игнорирует значения масок при чтении, извлекает из памяти и выдает все 32 бита данных, что занимает целых четыре такта. Очевидно, что при байтовых операциях такое поведение компонента является избыточным и операцию можно сократить до двух тактов. Желающие улучить работу компонента PipelinedMemoryBusSram могут присылать мне PR на Github-е или патчи по электронной почте.

В результат симуляции всей системы также будет записан в файл ./simWorkspace/Murax/test/wave.vcd , визуализировать который можно утилитой GTKWave следующим образом:

rz@devbox:~/VexRiscv/scripts/Murax/Karnix$ gtkwave ../../../simWorkspace/Murax/test/wave.vcd 
Рис. 36. Визуализация сигналов в виде временных диаграмм в GTKWave.
Рис. 36. Визуализация сигналов в виде временных диаграмм в GTKWave.

Разница в (4885 - 4821) = 64 машинных цикла объясняется тем, что ядро VexRiscv при старте требует некоторое количество машинных тактов на инициализацию.

19. Более сложный пример: VexRiscvWithHUB12ForKarnix

В качестве заключения мне хотелось бы продемонстрировать более сложный пример синтезируемой системы-на-кристалле на базе всё той-же Murax, которая вполне годится для использования в промышленных решениях. Это отдельный проект, который я назвал VexRiscvWithHUB12ForKarnix. Данный проект был специально разработан для взаимодействия со светодиодными матрицами формата HUB12 и HUB75e по единому набору сигнальных линий. Светодиодные матрицы этого формата оснащены 16-ти пиновым штыревым разъемом IDC-16 для передачи данных в сдвиговые регистры. Эти регистры требуется непрерывно обновлять для динамического формирования изображения на матрице. Китайская промышленность выпускает большое количество различных светодиодных матриц формата «HUB» с различным набором светодиодов, сдвиговых регистров, с несовместимым форматом загрузки и несовместимым набором сигналов. Для того, чтобы взаимодействовать с такими матрицами, пользователю приходится приобретать специализированные контроллеры с большим набором разъемов — по одному на каждый тип матрицы или даже разные контроллеры. Обойти проблему несовместимости различных типов матриц я решил путем реализации синтезируемого в ПЛИС компонента — контроллера HUB интерфейсов. Разработанный мной контроллер HUB, в зависимости от выбранного типа подключенной матрицы перенастраивает сигнальные линии из общего набора (16 линий GPIO) и реализует требуемый протокол загрузки сдвиговых регистров. Контроллер получает данные из сформированного в набортной RAM фреймбуфера и непрерывно, с максимально возможной скоростью (опыт показал, что максимальная частота тактирования сдвиговых регистров для используемой нами матрицы составляет ~20 МГц), загружает их в матрицу. Пользовательскому ПО остается только формировать изображение в памяти фреймбуфера, имеющего линейную структуру. Иными словами, вычислительное ядро свободно и не задействовано в формировании изображения на матрице. Проект (и изделие на его основе) в несколько ином виде имел коммерческое применение.

Новогоднее видео, ссылка на которое приведена в самом начале статьи, является демонстрацией работы VexRiscvWithHUB12ForKarnix. Все исходные коды и материалы для сборки и повторения установки, показанной на видео доступны из репозитория по ссылке:

https://github.com/Fabmicro-LLC/VexRiscvWithHUB12ForKarnix.git

В проекте VexRiscvWithHUB12ForKarnix реализованы следующие синтезируемые аппаратные блоки (компоненты):

  • Поддержка SRAM формата 16x256К.

    • Файл: ./src/main/scala/spinal/lib/mem/Sram.scala

  • Контроллер прерываний (PLIC0).

    • Файл: ./src/main/scala/mylib/MicroPLIC.scala

  • Два последовательных порта (UART0 и UART1) с поддержкой прерываний на TX и RX.

    • Файл: ./src/main/scala/spinal/lib/com/uart/Apb3UartCtrl.scala

  • Два 32-ти битных таймера (TMER0 и TIMER1) с 32-х битным прескейлером и поддержкой прерываний по окончанию счета.

    • Файл: ./src/main/scala/mylib/Apb3Timer.scala

  • 32-х битный ШИМ таймер (PWM0).

    • Файл: ./src/main/scala/spinal/lib/bus/simple/PWM.scala

  • Сторожевой таймер (WD0).

    • Файл: ./src/main/scala/mylib/Apb3WatchDog.scala

  • Машинный 1мкс таймер (MTIME0) доступный через CSR.

    • Файл: ./src/main/scala/mylib/MachineTimer.scala

  • Порт дискретных линий ввода/вывода на 32 сигнала (GPIO0).

    • Файл: ${SpinalHDL}./lib/src/main/scala/spinal/lib/io/Gpio.scala

  • Контроллер шины I2C (I2C0).

    • Файл: ./src/main/scala/mylib/MicroI2C.scala

  • SPI интерфейс для управления многоканальным ЦАП в составе платы «Карно» (AUDIODAC0).

    • Файл: ./src/main/scala/spinal/lib/com/spi/Apb3SpiMasterCtrl.scala

  • Контроллера светодиодных матриц стандарта HUB12 и HUB75e (HUB0) которые подключаются к плате «Карно» через 16 линий GPIO.

    • Файл: ./src/main/scala/mylib/HUB.scala

  • Контроллера FastEthernet (MAC0);

    • Файл: ./src/main/scala/mylib/Apb3MacEthCtrl.scala

В проекте VexRiscvWithHUB12ForKarnix реализованы следующие программные возможности:

  • Тестирование SRAM и использование блока SRAM для формирования «кучи».

  • Взаимодействие с микросхемой EEPROM по шине I2C для сохранения настроек (в том числе IP адреса, типа подключенной матрицы и её разрешение).

  • Драйвер MAC контроллера.

  • Поддержка стека протоколов TCP/IP на базе библиотеки lwIP (Light Weight IP). Полный стек протоколов, включая ARP, IP, ICMP, UDP, TCP, HTTP и DHCP, требует около 45КБ ОЗУ. Сокращенный вариант стека в составе ARP, IP, ICMP, UDP и DHCP умещаются в 25КБ ОЗУ.

  • Поддержка протокола Modbus/RTU (через последовательный корт) и Modbus/UDP (поверх IP).

  • Работа со светодиодными матрицами стандарта HUB12 и HUB75e через аппаратный видео фреймбуфер.

  • Драйвер с циклическим буфером для проигрывания звука на ЦАП через аппаратный компонент AUDIODAC0.

  • В протокол Modbus добавлены расширения для записи блока данных в аудио ЦАП и видео во фреймбуфер HUB контроллера.

При синтезе данная система потребляет следующие ресурсы:

Info: Device utilisation:
Info:             TRELLIS_IO:   100/  197    50%
Info:                   DCCA:     3/   56     5%
Info:                 DP16KD:    55/   56    98%
Info:             MULT18X18D:     4/   28    14%
Info:                 ALU54B:     0/   14     0%
Info:                EHXPLLL:     1/    2    50%
Info:                EXTREFB:     0/    1     0%
Info:                   DCUA:     0/    1     0%
Info:              PCSCLKDIV:     0/    2     0%
Info:                IOLOGIC:     0/  128     0%
Info:               SIOLOGIC:     0/   69     0%
Info:                    GSR:     0/    1     0%
Info:                  JTAGG:     0/    1     0%
Info:                   OSCG:     0/    1     0%
Info:                  SEDGA:     0/    1     0%
Info:                    DTR:     0/    1     0%
Info:                USRMCLK:     0/    1     0%
Info:                CLKDIVF:     0/    4     0%
Info:              ECLKSYNCB:     0/   10     0%
Info:                DLLDELD:     0/    8     0%
Info:                 DDRDLL:     0/    4     0%
Info:                DQSBUFM:     0/    8     0%
Info:        TRELLIS_ECLKBUF:     0/    8     0%
Info:           ECLKBRIDGECS:     0/    2     0%
Info:                   DCSC:     0/    2     0%
Info:             TRELLIS_FF:  3690/24288    15%
Info:           TRELLIS_COMB: 16880/24288    69%
Info:           TRELLIS_RAMW:  1064/ 3036    35%
Info: Max frequency for clock 'io_lan_rxclkInfo: Max frequency for clock '" class="formula inline">glbnetTRELLIS_IO_IN': 100.35 MHz (PASS at 25.00 MHz)
Info: Max frequency for clock             'core_pll_CLKOP': 82.24 MHz (PASS at 75.00 MHz)

Результат тестирования показал, что максимальная частота тактирования ядра, позволяющая стабильно работать при полной нагрузке, составляет 60 МГц для микросхем 6-го «грейда» и 80 МГц для микросхем 8-го «грейда».

В качестве демонстрации всей мощи полученной системы, используя вышеописанные возможности на плате «Карно», мною было реализовано стриминговое проигрывание видео ролика «Bad apple» с выводом изображения на светодиодную матрицу разрешением 80x40 пикселов (интерфейс HUB75e) и проигрыванием звука на один из ЦАП, распаянных на плате. Схема собранной установки для проигрывания показана на рис. 37.

Процесс проигрывания известного видеоролика выглядел следующим образом:

  1. Видеоролик был выкачан с Youtube, пережат утилитой FFMPEG в разрешение 80x40 и записан в локальный файл в формате BGR8. Звуковой ряд был сохранен в отдельный файл в формате PCM_S16LE и частотой дискретизации 16кГц.

  2. В имеющийся Perl скрипт для управления матрицами по протоколу Modbus было добавлено расширение, позволяющее считывать видео и аудио, разбивать на небольшие пакеты и отправлять их по Modbus/UDP на плату «Карно».

  3. На стороне «Карно», из принимаемых Modbus/UDP пакетов изымались данные и записывались либо в видео фреймбуфер, либо в циклический аудиобуфер.

  4. Скрипт в ответ от «Карно» получает по Modbus/UDP значение отражающее текущее заполнение циклического аудиобуфера (количество свободного места в нём).

  5. Основываясь на степени заполненности аудиобуфера, скрипт регулирует интенсивность отправляемых в сторону «Карно» данных путем системного вызова sleep(), не допуская переполнения буфера и его полного опорожнения.

Рис.37. Плата «Карно» с загруженным проектом VexRiscvHUB12ForKarnix принимает из сети пакеты Modbus/UDP, изымает из них аудио и видео данные и проигрывает их на имеющейся аппаратуре.
Рис.37. Плата «Карно» с загруженным проектом VexRiscvHUB12ForKarnix принимает из сети пакеты Modbus/UDP, изымает из них аудио и видео данные и проигрывает их на имеющейся аппаратуре.

Все материалы для желающих повторить проигрывание видео таким затейливым способом на плате «Карно» или поработать с матрицами формата HUB, находятся в подкаталоге ./scripts/Perl репозитория VexRiscvHUB12ForKarnix. Там же располагается README файл с инструкцией по запуску скрипта.

20. Эпилог

В процессе работы с микросхемами ПЛИС я все больше прихожу к выводу, что современные микроконтроллеры неудобны в использовании, и если бы не существенная разница в цене, то микроконтроллеры уже давно прозябали бы на свалке истории. Одна из традиционных сфер применения МК — это системы с экстремально низким потреблением электроэнергии или устройства с батарейным питанием. Но и в эту вотчину уже зашли ПЛИС. Например, известна серия CPLD микросхем Lattice MachXO2 с ультранизким потреблением, встроенной перепрограммируемой постоянной памятью и встроенным осциллятором. Микросхемы этой серии просты, но имеют достаточное количество ресурсов для синтезирования вычислительной системы с некоторым количеством адаптированной под задачу аппаратуры. Такие микросхемы позволяют создавать «кастомный» микроконтроллер всего лишь на одной микросхеме с низким уровнем потребления тока (единицы мА) и питанием от 1.2В.

Кто-то может возразить: «а как же ЦАП и АЦП?». Традиционно эти блоки стыковки с аналоговой аппаратурой присутствуют во всех МК и отсутствуют в недорогих микросхемах ПЛИС. Да, ПЛИС — чисто цифровые устройства, но и тут не все однозначно. ЦАП легко реализуется на ПЛИС с помощью ШИМ и несложного НЧ фильтра на выходе. А что же с АЦП? Недавно я узнал про «All-Digital ADC» или A2D — способ организации АЦП посредством только цифровой аппаратуры внутри ПЛИС (или ASIC), используя один дифференциальный сигнал LVDS (две линии) и небольшой пассивной обвязки. Принцип простой — вход LVDS обычно реализуется в виде компаратора, который сравнивает напряжения двух линий и формирует логический «0» или «1» внутри ПЛИС. Если подвести входной аналоговый сигнал к одной из линий LVDS (положительной), а к второй (отрицательной) подключить конденсатор и управлять его зарядом с помощью ШИМ, то компаратор внутри ПЛИС будет сравнивать уровень заряда конденсатора с уровнем входного аналогового сигнала. Несложная схема обработки с «оверсэмплингом» дает полностью цифровой АЦП. На рис. 38 показана схема устройства такого АЦП.

Рис. 38. Схема полностью цифрового АЦП (All-Digital ADC).
Рис. 38. Схема полностью цифрового АЦП (All-Digital ADC).

Возьмусь предположить, что со временем, чем дешевле будет становиться «кремний», тем дальше микросхемы ПЛИС будут проникать в нашу повседневную жизнь и тем больше вытеснять микроконтроллеры из оборота. Единственное, что сильно удерживает МК на «плаву», это устоявшиеся привычки разработчиков и инертность мышления, а также накопленная база ПО и инструментария.

На рынке встроенных систем долгое время доминировали МК двух архитектур — AVR и ARM (про PIC и 8051 как-то давно позабылось). Но в последние годы в эту область наметилось серьезное проникновение открытой архитектуры RISC-V, что ознаменовалось выходом микроконтроллеров серии GD32 и ESP32-С3. Отечественные производители также отметились своими изделиями на базе 32-битного RISC-V — полностью отечественный МК MIK32 производства зеленоградского АО «Микрон» заслуживает внимания. Но как мы увидели, 32-битные ядра RISC-V отлично синтезируются в ПЛИС и показывают весьма неплохую производительность. Для сравнения, тот же MIK32 имеет тактовую частоту 32 МГц, что в два раза ниже предложенного мной варианта на базе синтезируемого VexRiscv. А по цене MIK32 выше, чем стоимость микросхемы ПЛИС на нашей плате «Карно», при том, что ПЛИС — это гораздо больше, чем готовый микроконтроллер!

Всем дочитавшим эту статью до конца — огромное спасибо! И простите за то, что получилось так много.

PS: Плату «Карно» можно изготовить самостоятельно — Gerber файлы, BOM и схема в формате PDF присутствуют в репозитории. Или приобрести с ozon.ru уже собранную и протестированную.

Благодарности

Юрию Панчулу (@YuriPanchul) и команде "Школа цифрового синтеза" за отличный обучающий проект basics-graphics-music.

Дмитрию (@DmitryZlobec) за идеи по плате "Карно".

Виктору Сергееву за сборку серии плат "Карно" на страшном и ужасно глюкавом Autotronik BA385V2.

Евгению Короленко за прогон примеров на плате "Карно", вычитку статьи и исправление ошибок.

Ссылки на репозитории

Плата «Карно» (KiCAD): https://github.com/Fabmicro-LLC/Karnix_ASB-254
Проект «basics-graphics-music»: https://github.com/yuri-panchul/basics-graphics-music
YosysHQ: https://github.com/YosysHQ/yosys
Yosys Manual: https://yosyshq.readthedocs.io/projects/yosys/en/latest/index.html#yosys-manual
SpinalHDL: https://github.com/SpinalHDL/SpinalHDL
SpinalHDL Docs: https://spinalhdl.github.io/SpinalDoc-RTD/master/index.html
SpinalTemplateSbtForKarnix: https://github.com/Fabmicro-LLC/SpinalTemplateSbtForKarnix
VexRiscv: https://github.com/SpinalHDL/VexRiscv
VexRiscvWithKarnix: https://github.com/Fabmicro-LLC/VexRiscvWithKarnix
VexRiscvWithHUB12ForKarnix: https://github.com/Fabmicro-LLC/VexRiscvWithHUB12ForKarnix

Ссылка на статью в формате PDF: http://www.fabmicro.ru/pub/articles/Unconventional approach to digital hardware design - Yosys, SpinalHDL, VexRiscv.pdf

Теги:
Хабы:
Всего голосов 24: ↑24 и ↓0+24
Комментарии15

Публикации

Истории

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург