C4 Шина
Вы спроектировали однотактный процессор NPC и понимаете, как работают устройства. Однако раньше мы позволяли среде моделирования (simulation environment) предоставлять функциональность устройств. Теперь настало время реализовать аппаратную логику, которая позволит NPC взаимодействовать с другими устройствами.
В компьютере модули не работают независимо друг от друга; между разными модулями необходим обмен данными: между CPU и контроллером памяти, между контроллером памяти и микросхемами памяти, между блоком выборки инструкций и блоком декодирования и так далее. Взаимодействие между этими модулями должно осуществляться по набору согласованных протоколов. То же самое верно и для программного обеспечения. В инструменте DiffTest, который мы используем, NEMU должен взаимодействовать со Spike, а NPC также должен взаимодействовать с NEMU для реализации соответствующей функциональности.
В широком смысле шина (bus) — это система связи, используемая для передачи данных между различными модулями. Сейчас мы сосредоточимся на аппаратном обеспечении и рассмотрим шину в более узком смысле, то есть протоколы связи между аппаратными модулями.
Шина — протоколы связи между аппаратными модулями
Начнём со связи между модулями внутри процессора, чтобы понять общую организацию шинных протоколов.
Простейшая шина
NPC содержит блок выборки инструкций IFU и блок декодирования инструкций IDU, и этим двум блокам необходимо взаимодействовать, чтобы передавать инструкции из IFU в IDU.
+-----+ +-----+
| IFU | inst ---> | IDU |
+-----+ +-----+
В этом простом сценарии подразумевается некий шинный протокол: модуль, инициирующий взаимодействие, обычно называется master (ведущий), а модуль, отвечающий на взаимодействие, — slave (ведомый). В приведённом выше простом сценарии взаимодействия IFU, выступая в роли master, отправляет информацию в IDU, который является slave, при этом информацией является текущая инструкция. Поскольку это однотактный процессор, очевидно, что их взаимодействие фактически подразумевает следующее соглашение:
- Каждый такт master отправляет slave корректную (valid) информацию.
- Как только master отправляет корректную информацию, slave может немедленно её принять.
Асинхронная шина
Однако если IFU не может гарантировать выдачу инструкции каждый такт, то IDU должен дождаться, пока IFU сможет выдовать инструкции. В этом случае необходимо добавить в содержимое сообщения сигнал valid, чтобы указать, когда IFU отправляет в IDU корректную инструкцию. Протокол связи также необходимо обновить следующим образом:
- Информация считается корректной только тогда, когда сигнал
validактивен. - Как только master отправляет корректную информацию, считается, что slave немедленно её принял.
+-----+ inst ---> +-----+
| IFU | valid ---> | IDU |
+-----+ +-----+
Итак, как же не допустить, чтобы IDU выполнял некорректные инструкции? Вспоминая модель процессора в виде конечного автомата, нам достаточно сохранить состояние процессора неизменным, когда инструкция некорректна. На уровне схемы состояние — это компоненты последовательностной (sequential) логики; поэтому нам достаточно сделать сигнал разрешения записи (write enable) этих компонентов неактивным, когда инструкция некорректна.
Далее предположим, что некоторые инструкции слишком сложны для декодирования, и IDU требуется несколько тактов для декодирования одной инструкции. При таком допущении, когда IFU успешно выбирает инструкцию, IDU может ещё не закончить декодирование предыдущей инструкции. В этот момент IFU должен дождаться, пока IDU завершит текущую работу по декодированию, прежде чем отправлять следующую инструкцию в IDU. Чтобы реализовать функцию ожидания для IFU, необходимо добавить в содержимое сообщения сигнал ready, указывающий, когда IDU может принять следующую инструкцию. Протокол связи также необходимо обновить следующим образом:
- Информация считается корректной только тогда, когда сигнал
validактивен. - Информация, отправленная master, считается принятой slave только тогда, когда сигнал
readyактивен.
+-----+ inst ---> +-----+
| IFU | valid ---> | IDU |
+-----+ <--- ready +-----+
Это фактически и есть асинхронная шина, в которой невозможно заранее предсказать, когда именно произойдёт обмен данными между двумя модулями; это происходит только тогда, когда одновременно активны и valid, и ready. Одновременная активность обоих сигналов, также называемая «рукопожатием» (handshake), означает, что master и slave достигли согласия об успешной передаче информации. Очевидно, что такой протокол связи более гибок, позволяя master и slave самостоятельно решать, когда отправлять или принимать сообщения, исходя из своей текущей работы. Когда сигнал valid активен, а сигнал ready неактивен, IFU должен дождаться готовности IDU. В этот момент IFU должен сохранить данные, чтобы не потерять их.
RTL-реализация асинхронной шины
Асинхронная шина определяет момент обмена данными с помощью протокола рукопожатия. На уровне RTL она в основном состоит из двух частей: сигналов интерфейса и логики связи.
Сигналы интерфейса — это сигналы, которые необходимо передавать между модулями, такие как сигналы inst, valid и ready в примере выше. Chisel предоставляет шаблон Decoupled, который уже включает valid и ready, и интерфейсы асинхронной шины легко реализуются с помощью метапрограммирования:
class Message extends Bundle {
val inst = Output(UInt(32.W))
}
class IFU extends Module {
val io = IO(new Bundle { val out = Decoupled(new Message) })
// ...
}
class IDU extends Module {
val io = IO(new Bundle { val in = Flipped(Decoupled(new Message)) })
// ...
}
Когда требуется передавать больше информации, добавлять сигналы также очень удобно. В этом и заключается преимущество абстракции!
class Message extends Bundle {
val inst = Output(UInt(32.W))
+ val pc = Output(UInt(32.W))
}
Далее рассмотрим логику связи, то есть то, как реализовать описанный выше шинный протокол с помощью схем. Протокол — это соглашение между master и slave, и в схеме на самом деле необходимо реализовать поведение обеих сторон при соблюдении этого соглашения. То есть обе стороны переходят в разные состояния в зависимости от разных условий сигналов рукопожатия и тем самым выполняют разные действия. Это и есть конечный автомат! Для master мы можем легко нарисовать диаграмму переходов состояний:
+-+ valid = 0
| v valid = 1
1. idle ----------------> 2. wait_ready <-+
^ | | | ready = 0
+--------------------------+ +----+
ready = 1
А именно:
- Изначально находимся в состоянии
idle, сигналvalidнеактивен- Если нет необходимости отправлять сообщение, остаёмся в состоянии
idle - Если необходимо отправить сообщение, устанавливаем
validв активное значение, переходим в состояниеwait_readyи ждём готовности slave
- Если нет необходимости отправлять сообщение, остаёмся в состоянии
- В состоянии
wait_readyодновременно отслеживаем сигналreadyот slave- Если сигнал
readyактивен, рукопожатие успешно, возвращаемся в состояниеidle - Если сигнал
readyнеактивен, продолжаем оставаться в состоянииwait_readyи ждать
- Если сигнал
Имея диаграмму переходов состояний, мы можем легко написать соответствующий RTL-код. Ниже приведён простой фрагмент кода на Chisel для справки:
class IFU extends Module {
val io = IO(new Bundle { val out = Decoupled(new Message) })
val s_idle :: s_wait_ready :: Nil = Enum(2)
val state = RegInit(s_idle)
state := MuxLookup(state, s_idle)(List(
s_idle -> Mux(io.out.valid, s_wait_ready, s_idle),
s_wait_ready -> Mux(io.out.ready, s_idle, s_wait_ready)
))
io.out.valid := нужно отправить инструкцию
// ...
}
Реализуйте взаимодействие IDU на RTL
Разобравшись в процессе реализации взаимодействия IFU, попробуйте самостоятельно проанализировать и нарисовать диаграмму переходов состояний для IDU, а также написать RTL-код для взаимодействия IDU. Это всего лишь упражнение на тему шины; пока не нужно изменять код IDU в вашем NPC.
Когда мы полностью реализуем логику связи как IFU, так и IDU, описанный выше шинный протокол также будет эффективно реализован.
Проектирование процессора с точки зрения шины
Мы можем переосмыслить сам дизайн процессора с точки зрения шины. Предположим, что процессор состоит из 4 модулей, которым необходимо взаимодействовать друг с другом: IFU отправляет инструкции в IDU, IDU отправляет результаты декодирования в EXU, EXU отправляет результаты вычислений в WBU.
+-----+ inst ---> +-----+ ... ---> +-----+ ... ---> +-----+
| IFU | valid ---> | IDU | valid ---> | EXU | valid ---> | WBU |
+-----+ <--- ready +-----+ <--- ready +-----+ <--- ready +-----+
С этой точки зрения различные микроархитектуры процессоров по сути представляют собой разные протоколы связи между модулями:
- Для однотактного процессора это по сути означает, что каждое сообщение, отправляемое вышестоящим (upstream) модулем, корректно каждый такт (всегда valid), а нижестоящий (downstream) модуль всегда готов принимать новые сообщения (всегда ready).
- Для многотактного процессора сообщения некорректны, когда вышестоящий модуль простаивает, а новые сообщения не принимаются, когда нижестоящий модуль занят; IFU выбирает следующую инструкцию только после получения сигнала завершения от WBU.
- В отличие от традиционных учебников, это распределённый многотактный процессор, управляемый сообщениями, где «распределённость» заключается в следующем: возможность взаимодействия двух модулей зависит только от их собственных состояний, независимо от других модулей.
Многотактный процессор в учебниках
В многотактном процессоре инструкция разбивается на разные стадии, выполняемые в разные такты, поэтому каждая инструкция выполняется за несколько тактов:
- В 1-м такте IFU выбирает инструкцию и передаёт её в IDU
- Во 2-м такте IDU отправляет результат декодирования в EXU
- В 3-м такте EXU передаёт результат вычисления в WBU
- В 4-м такте WBU записывает результат обратно в регистры
- В следующем такте IFU выбирает следующую инструкцию...
- Для конвейерного (pipelined) процессора IFU может непрерывно выбирать инструкции, и каждый модуль пытается отправить сообщение вниз по конвейеру каждый такт, как только завершает обработку очередного сообщения.
- Для процессора с внеочередным исполнением (out-of-order execution) его можно рассматривать как небольшое расширение конвейера: у каждого нижестоящего модуля есть очередь, и вышестоящему модулю достаточно отправлять сообщения в эту очередь, не заботясь о состоянии очереди нижестоящего модуля.
Обычно централизованное управление требует наличия глобального контроллера, который собирает состояния всех модулей, а затем использует эти состояния для управления последующими действиями каждого модуля. Многотактный процессор, который обычно рассматривается в традиционных учебниках, часто представляет собой централизованный многотактный процессор на основе большого конечного автомата. Он собирает состояния каждого модуля через глобальный конечный автомат, чтобы управлять взаимодействием между модулями и определять, каким должно быть следующее состояние. Например, если IFU завершает выборку инструкции в текущем такте, то в следующем такте он должен перейти в состояние декодирования и заставить IDU выполнить декодирование.
+--------------+
+-------------> | Controller | <--------------+
| +--------------+ |
| ^ ^ |
v v v v
+-----+ inst +-----+ ... +-----+ ... +-----+
| IFU | ------> | IDU | ------> | EXU | ------> | WBU |
+-----+ +-----+ +-----+ +-----+
В учебниках обычно рассматривается лишь небольшое число инструкций для объяснения базовых принципов многотактных конечных автоматов, и это не представляет большой проблемы. Однако масштабируемость централизованного управления относительно низка. По мере роста числа модулей и сложности проектирование контроллера становится всё сложнее и сложнее:
- С ростом числа инструкций растёт и число их типов. Централизованный конечный автомат должен учитывать все стадии выполнения для каждого типа инструкции.
- Добавление новой стадии в такой процессор потребует переработки контроллера.
- В реальных процессорах время работы каждого модуля может отличаться:
- Запрос инструкции в IFU может иметь задержки.
- IDU может завершать декодирование всего за один такт.
- В EXU разные инструкции могут иметь разное время выполнения:
- Целочисленные арифметические инструкции RVI обычно завершают вычисление за один такт.
- Деление обычно занимает очень много времени.
- Умножение может быть быстрее, но всё же может занимать несколько тактов.
- Инструкции доступа к памяти имеют непредсказуемое время ожидания.
- Выполнение некоторых инструкций может вызывать исключения, а прерывания могут приходить в любой момент.
В таких сложных сценариях учёт комбинаций разных состояний каждого модуля и принятие единого решения становится крайне сложной задачей.
При распределённом управлении, о котором говорилось выше, поведение каждого модуля зависит только от его собственного состояния и состояния нижестоящих модулей, поэтому каждый модуль может работать независимо. Например, в процессоре с внеочередным исполнением вышестоящий модуль может продолжать работу до тех пор, пока очередь в нижестоящем модуле не заполнится. При распределённом управлении очень легко добавить новый модуль: достаточно изменить реализацию интерфейса его вышестоящего и нижестоящего модулей. Таким образом, распределённое управление обеспечивает лучшую масштабируемость.
Принимая такое распределённое управление на основе рукопожатий, можно унифицировать дизайн процессоров с разными микроархитектурами. Более того, процессоры с внеочередным исполнением по своей природе являются системами с распределённым управлением, поскольку в них много модулей и много состояний у каждого модуля, а различные события могут приходить в любой момент (например, приход прерываний, остановки конвейера и т. д.). Если бы использовалось централизованное управление, было бы практически невозможно гарантировать, что контроллер принимает верные решения при возникновении различных событий в каждом модуле.
Преимущества шин при проектировании систем
Вы уже должны оценить одно из преимуществ использования шин при проектировании систем: разделяя модули и обеспечивая связь между ними, мы снижаем общую сложность проектирования и сопровождения системы. Централизованному контроллеру необходимо взаимодействовать с каждым модулем, что делает его проектирование и поддержку гораздо сложнее; разбивая глобальное взаимодействие на взаимодействия между вышестоящими и нижестоящими модулями через шинные протоколы, мы устраняем необходимость в централизованном контроллере, тем самым снижая сложность проектирования всего процессора.
На самом деле, во многих областях вычислительной техники передача сообщений используется для снижения сложности системы, например, микроядра в операционных системах, фреймворки программирования MPI в распределённых системах, модели клиент-сервер в архитектуре программного обеспечения и даже весь интернет, взаимодействующий посредством сетевых пакетов.
Хотя проектирование процессора имеет дело с аппаратным обеспечением, шаблоны проектирования — это не исключительно аппаратный вопрос, и мы всё же можем позаимствовать полезный опыт из области программного обеспечения, чтобы улучшить проектирование процессора.
Используя функциональную абстракцию и возможности метапрограммирования в Chisel, мы можем унифицировать шаблоны проектирования процессоров с разными микроархитектурами и легко «обновлять» микроархитектуру процессора.
class NPC extends Module {
val io = // ...
val ifu = Module(new IFU)
val idu = Module(new IDU)
val exu = Module(new EXU)
val wbu = Module(new WBU)
StageConnect(ifu.io.out, idu.io.in)
StageConnect(idu.io.out, exu.io.in)
StageConnect(exu.io.out, wbu.io.in)
// ...
}
object StageConnect {
def apply[T <: Data](left: DecoupledIO[T], right: DecoupledIO[T]) = {
val arch = "single"
// Чтобы проиллюстрировать концепцию абстракции, некоторые детали в этом коде опущены.
if (arch == "single") { right.bits := left.bits }
else if (arch == "multi") { right <> left }
else if (arch == "pipeline") { right <> RegEnable(left, left.fire) }
else if (arch == "ooo") { right <> Queue(left, 16) }
}
}
Рефакторинг NPC
Попробуйте выполнить рефакторинг NPC, используя описанную выше концепцию шины. Хотя это не обязательно, если вы разрабатываете на Chisel, мы настоятельно рекомендуем провести такой рефакторинг; это также подготовит вас к предстоящей интеграции в SoC и будущей реализации конвейера.
Если вы разрабатываете на Verilog, эта работа по рефакторингу может показаться вам несколько утомительной. Однако мы хотим подчеркнуть, что шаблоны проектирования и RTL-реализация — это разные уровни. Несмотря на возможные трудности, мы призываем вас подумать о том, как реализовать описанные выше правильные шаблоны проектирования средствами Verilog.
Системная шина
Помимо взаимосвязи модулей внутри процессора, о которой говорилось выше, крайне важно и то, как процессор подключается к памяти и устройствам, поскольку реальный процессор не может работать самостоятельно без памяти и периферии. Шина, соединяющая процессор с памятью и устройствами, обычно называется системной шиной. Далее мы рассмотрим на примере соединения процессора с памятью, как должна проектироваться системная шина.
Требования к связи для системной шины
Ранее NPC обращался к памяти через механизм DPI-C и интерфейсы вроде pmem_read(), предоставляемые средой моделирования. У этого процесса нет задержки чтения: считанные данные могут быть возвращены в текущем такте сразу по получении запроса на чтение. Но это лишь упрощает реализацию однотактного процессора; в реальности такого устройства памяти не существует.
По сравнению с механизмом DPI-C, в реальной микросхеме наибольшее отличие в обращении процессора к памяти заключается в том, что процесс доступа имеет задержку. Иными словами, от момента выдачи запроса на чтение до получения данных, возвращённых памятью, процессору необходимо подождать несколько тактов. Это требует от процессора дополнительной реализации следующих механизмов:
- Ему необходимо распознавать момент, когда приходят данные, возвращённые памятью.
- До прихода данных, возвращённых памятью, процессор должен ждать, и только после получения данных ответа процессор может продолжить выполнение текущей инструкции. Это означает, что процессор больше не удовлетворяет свойству «выполнение одной инструкции за такт».
- Кроме того, операция выборки инструкции процессором не обязана выполняться каждый такт. Например, не следует выбирать инструкции в ожидании ответа памяти; поэтому память необходимо информировать о том, когда выборка инструкции действительно происходит.
Чтобы решить эти проблемы, в реальных микросхемах процессор и память взаимодействуют через механизм асинхронной шины: обе взаимодействующие стороны действуют согласно некоторому согласованному соглашению, тем самым обеспечивая корректную передачу информации.
Однако реальные микросхемы обычно используют промышленные шинные протоколы, подобные AXI, которые содержат множество деталей; например, шинный протокол AXI насчитывает более 30 сигналов. Это означает, что для взаимодействия реальной микросхемы с устройствами процессору в принципе необходимо реализовать эти шинные протоколы. Чтобы облегчить вашу задачу, мы сначала начнём с простейшего собственного шинного протокола под названием SimpleBus.
Доступ к памяти только для чтения
Поскольку чтение данных из памяти — это самая базовая потребность, мы сначала рассмотрим, как процессор выполняет операции чтения через системную шину. Предположим, что спецификация процессора фиксирована как Nx32, то есть память содержит N слов, каждое слово — 32 бита. Также предположим, что задержка чтения памяти фиксирована и составляет 1 такт. Это фактически синхронная память, то есть задержка от получения запроса на чтение до возврата данных фиксирована, что как раз соответствует характеристикам доступа к SRAM.
Если не учитывать операции записи, достаточно памяти только для чтения (ROM, Read-Only Memory). Для чтения данных из ROM соответствующей шине требуются всего два сигнала — адрес и данные, разрядность которых составляет log2(N) и 32 соответственно.
+-----+ raddr[log2(N)-1:0] ---> +-----+
| CPU | <--- rdata[31:0] | MEM |
+-----+ +-----+
Её протокол связи таков:
- Master (CPU) отправляет slave (MEM) адрес чтения
raddr. - В следующем такте slave возвращает master данные
rdata. - Указанные выше действия происходят каждый такт.
Оцените тактовую частоту и производительность программ для однотактного NPC
Прежде чем продолжать модифицировать NPC, попробуйте оценить тактовую частоту текущего NPC с помощью проекта yosys-sta, который вы использовали на этапе предварительного изучения. Однако перед оценкой вам необходимо сделать следующее:
- Сначала запустите тест microbench в масштабе
trainи запишите количество тактов, необходимое для его завершения. - В RTL закомментируйте код, вызывающий
pmem_read()иpmem_write()через DPI-C, а затем создайте по одному экземпляру памяти для блока выборки инструкций и для блока доступа к памяти соответственно. Чтобы сохранить свойство однотактности, созданная память должна возвращать считанные данные в текущем такте, поэтому её можно реализовать на триггерах точно так же, как регистровый файл. Если вы используете Verilog, вы можете напрямую создать экземпляр модуляRegisterFile; разумеется, вам нужно правильно подключить порты. Чтобы унифицировать результаты тестирования, договоримся создавать экземпляры памяти размером 256x32 бита, то есть 1 КБ, всего два таких экземпляра памяти, суммарным размером 2 КБ.
Причина такой модификации в том, что однотактный NPC должен завершать полный жизненный цикл инструкции за каждый такт, поэтому его нельзя подключить к какой-либо реальной памяти; его можно оценивать только совместно с двумя блоками памяти, подобными регистровому файлу. После модификации вы сможете оценить тактовую частоту однотактного NPC.
На основе оценённой тактовой частоты и записанного ранее количества тактов выполнения microbench вы можете оценить, сколько времени потребуется будущему NPC на выполнение microbench. Обратите внимание, что это не время моделирования, а время выполнения программы в предположении, что NPC работает на указанной выше тактовой частоте. Например, у одной из версий NPC пользователя yzh тактовая частота на техпроцессе nangate45, предоставляемом по умолчанию проектом yosys-sta, составляет 51,491 МГц, поэтому расчётное время выполнения microbench составляет 3,870 с, тогда как моделирование заняло 19,148 с.
Разумеется, эта оценка на самом деле неточна и её даже можно назвать весьма оптимистичной:
- Этот однотактный NPC всё ещё далёк от конфигурации, готовой к tape-out; например, только что модифицируя память, мы фактически проигнорировали все части, связанные с вводом-выводом.
- Указанная выше тактовая частота — это частота после синтеза; задержки проводников, вносимые после размещения и трассировки (place and route), ещё сильнее снизят тактовую частоту.
- Память, соответствующая блоку выборки инструкций, была оптимизирована и удалена yosys, поскольку в ней нет операций записи.
- Память, соответствующая блоку доступа к памяти, на самом деле слишком мала для размещения microbench. Для успешного прохождения теста в масштабе
trainданным требуется 1 МБ памяти. Этот размер значительно превышает количество триггеров, которое может быть размещено в реальной конструкции процессорной микросхемы. Не говоря уже о времени обработки в EDA-инструментах, просто заполнение микросхемы таким количеством триггеров сделало бы оценочную задержку проводников, основанную на занимаемой площади, чрезвычайно большой.
Поэтому результат такой оценки имеет мало практической ценности; воспринимайте это просто как тренировку перед последующими оценками.
Поскольку инструкции хранятся в памяти, блоку IFU также необходимо обращаться к памяти при выборке инструкций. При этом процесс выборки инструкций никогда не записывает данные в память, поэтому для поддержки IFU достаточно памяти только для чтения. Чтобы смоделировать явление задержки в процессе доступа к памяти, после получения запроса на выборку инструкции память не может немедленно вернуть выбранную инструкцию, а должна задержать возврат выбранной инструкции на один такт.
Согласно вышесказанному, для IFU в SimpleBus задействованы следующие сигналы:
output [31:0] ifu_raddr,
input [31:0] ifu_rdata,
Временная диаграмма этих сигналов показана на рисунке ниже:
--\ /----------\ /----------\ /----------\ /----------\ /--
ifu_raddr X 0x80000000 X (1) X 0x80000004 X X
--/ \----------/ \----------/ \----------/ \----------/ \--
--\ /----------\ /----------\ /----------\ /----------\ /--
ifu_rdata X X 0x00000413 X (2) X 0x80051137 X
--/ \----------/ \----------/ \----------/ \----------/ \--
Чтобы NPC мог реализовать функцию «ожидания, пока память считает инструкцию», сначала нужно позволить IFU понимать, на какой стадии выборки инструкции он сейчас находится, и применять разные стратегии на разных стадиях. Такое «выполнение разных действий в разное время» можно реализовать с помощью конечного автомата цифровой схемы! В частности, мы можем реализовать для IFU два состояния — idle и wait:
- В состоянии
idleнеобходимо установитьifu_raddrравнымpc, а затем перейти в состояниеwait. - В состоянии
waitifu_rdataвыполняется как корректная инструкция, после чего происходит переход в состояниеidle.
Согласно упомянутому ранее шинному протоколу, поскольку взаимодействие происходит каждый такт, одновременно с отправкой запроса адреса в память память также возвращает данные, соответствующие предыдущему такту. Например, на рисунке выше, когда NPC отправляет запрос на выборку инструкции по адресу 0x80000004, память одновременно возвращает данные, отмеченные (2), которые соответствуют запросу адреса, отмеченному (1) в предыдущем такте. Если NPC выполнит (2) как корректную инструкцию, он либо повторно выполнит инструкцию, соответствующую адресу 0x80000000, либо выполнит инструкцию по какому-то другому адресу; в любом случае поведение NPC не будет соответствовать соглашениям ISA. Поэтому в состоянии idle NPC не должен рассматривать ifu_rdata как корректную инструкцию для выполнения; вместо этого он не должен выполнять никакую инструкцию.
Ранее мы по умолчанию предполагали, что NPC может выполнять одну инструкцию каждый такт, но после реализации SimpleBus у NPC нет корректной инструкции для выполнения, пока он ожидает возврата инструкции. Поэтому нам нужно рассмотреть вопрос «как сделать так, чтобы NPC не выполнял инструкции». Вспоминая модель конечного автомата вычислительных систем, процесс выполнения процессором инструкции — это процесс изменения состояния процессора. Иными словами, если мы найдём способ сохранить состояние процессора неизменным, мы сможем добиться эффекта «невыполнения инструкций». Сообразительный читатель уже должен догадаться о решении: состояние процессора — это компоненты последовательностной логики; пока их сигналы разрешения записи неактивны, значения этих компонентов не изменятся. Поэтому вам также необходимо правильно устанавливать сигналы разрешения записи различных компонентов последовательностной логики в NPC в соответствующих состояниях.
Поддержите SimpleBus для IFU
Согласно вышесказанному, позвольте IFU поддерживать протокол SimpleBus. Для части памяти, отвечающей за выборку инструкций, вы можете ориентироваться на следующий код:
always @(posedge clock) begin
ifu_rdata <= pmem_read(ifu_raddr);
end
Для части LSU, отвечающей за доступ к данным, пока изменений не требуется; мы позволим ей поддерживать SimpleBus далее.
После реализации попробуйте запустить несколько тестовых программ и убедитесь по временным диаграммам, что взаимодействие между NPC и памятью происходит так, как ожидается. В принципе, шинный протокол прозрачен для программ верхнего уровня, поэтому программы, которые успешно выполнялись раньше, должны успешно выполняться и после реализации SimpleBus.
Однако, поскольку памяти теперь требуется 1 такт для считывания данных, NPC уже не является строго однотактным процессором, а представляет собой простой многотактный процессор:
- В 1-м такте IFU выдаёт запрос на выборку инструкции.
- Во 2-м такте IFU получает инструкцию и передаёт её последующим модулям для декодирования и выполнения.
Если вы выполнили рефакторинг NPC согласно приведённым ранее рекомендациям, вы обнаружите, что превратить NPC в многотактный процессор не так уж сложно.
Адаптируйте DiffTest для многотактного процессора
После преобразования NPC в многотактный процессор NPC больше не выполняет инструкцию каждый такт. Чтобы механизм DiffTest продолжал работать корректно, вам необходимо немного скорректировать момент выполнения проверок. Для этого вам может понадобиться считывать некоторые состояния RTL из среды моделирования, чтобы решить, когда выполнять проверки DiffTest.
Доступ к памяти с возможностью чтения и записи
Чтобы поддержать операции записи, в шину необходимо добавить новые сигналы:
- Естественно, в первую очередь нужны адрес записи
waddrи данные записиwdata. - Поскольку операции записи происходят не каждый такт, также необходимо добавить сигнал разрешения записи
wen.- Хотя операции чтения тоже происходят не каждый такт — например, для рассмотренного выше многотактного NPC в состоянии
waitнет необходимости отправлять в память запрос на выборку инструкции, — поскольку операции чтения не изменяют состояние схемы, разрешение чтения теоретически не обязательно. - Однако на практике обычно всё же присутствует сигнал разрешения чтения
ren; если запросов на чтение нет, память не обязана выполнять операции чтения, что позволяет экономить энергию.
- Хотя операции чтения тоже происходят не каждый такт — например, для рассмотренного выше многотактного NPC в состоянии
- Операция записи может записывать только некоторые байты слова (например, инструкция
sbзаписывает только 1 байт), поэтому также необходимо добавить сигнал маски записиwmask, указывающий, какие байты записываемых данных нужно записать.
+-----+ addr[log2(N)-1:0] ---> +-----+
| | wen ---> | |
| CPU | wdata[31:0] ---> | MEM |
| | wmask[3:0] ---> | |
| | <--- rdata[31:0] | |
+-----+ +-----+
При этом протокол связи должен определять поведение операций записи. Представим это в виде псевдокода:
if (wen) {
// wmask_full — результат побитового расширения wmask
M[waddr] = (wdata & wmask_full) | M[waddr] & ~wmask_full;
}
По аналогии с IFU, дадим также LSU доступ к памяти по протоколу SimpleBus. Для LSU, поскольку инструкции store требуют записи в память, в SimpleBus задействованы следующие сигналы:
output [31:0] lsu_addr,
output lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input [31:0] lsu_rdata,
Для операций чтения wdata и wmask могут принимать произвольные значения, а временная диаграмма соответствующих сигналов показана на рисунке ниже:
--\ /----------\ /------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------
---+ +-------------------------
lsu_wen | |
+------------+
------------------------------------------
lsu_wdata
------------------------------------------
------------------------------------------
lsu_wmask
------------------------------------------
---------------\ /----------\ /-----------
lsu_rdata X 0x12345678 X
---------------/ \----------/ \-----------
Для операций записи rdata является сигналом, значение которого не важно (don't-care), а временная диаграмма соответствующих сигналов показана на рисунке ниже:
--\ /----------\ /------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------
+------------+
lsu_wen | |
---+ +-------------------------
--\ /----------\ /------------------------
lsu_wdata X 0x12345678 X
--/ \----------/ \------------------------
--\ /----------\ /------------------------
lsu_wmask X 0xf X
--/ \----------/ \------------------------
------------------------------------------
lsu_rdata
------------------------------------------
Поддержите SimpleBus для LSU
Согласно вышесказанному, дайте LSU возможность поддерживать протокол SimpleBus. Для части памяти, отвечающей за доступ к данным, вы можете ориентироваться на следующий код:
always @(posedge clock) begin
lsu_rdata <= (!lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
if (lsu_wen) begin
pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
end
end
На данном этапе функциональность доступа к устройствам в pmem_read()/pmem_write() можно сохранить; о том, как обращаться к периферии через шину, мы расскажем в последующих главах.
После того как LSU начнёт поддерживать SimpleBus, для инструкции load NPC потребуется 3 такта на выполнение:
- В 1-м такте IFU выдаёт запрос на загрузку инструкции.
- Во 2-м такте IFU получает инструкцию и передаёт её последующим модулям для декодирования; обнаружив, что это инструкция
load, он выдаёт запрос на доступ к памяти через LSU. - В 3-м такте LSU получает данные и передаёт их в WBU для записи обратно в регистр.
Поэтому вам также необходимо изменить IFU так, чтобы следующая инструкция выбиралась только после завершения выполнения инструкции load.
После реализации попробуйте запустить несколько тестовых программ и убедитесь по временным диаграммам, что взаимодействие между NPC и памятью происходит так, как ожидается. Точно так же программы, которые успешно выполнялись раньше, должны успешно выполняться и после реализации SimpleBus.
Более общая память
В реальности характеристика задержки чтения в 1 такт обычно достижима только для SRAM, поскольку SRAM может изготавливаться по тому же техпроцессу, что и сам процессор, но SRAM очень дорога. Чтобы получить более дешёвую память, обычно применяют другие техпроцессы с более высокой плотностью хранения, например DRAM. Однако из-за электрических характеристик задержка чтения такой памяти обычно превышает 1 такт процессора.
В этом случае процессор не может непрерывно отправлять запросы на чтение; иначе, поскольку скорость запросов процессора превышает скорость обслуживания памяти, память будет постоянно занята бесполезными запросами, что серьёзно снизит эффективность всей системы. Чтобы решить эту проблему, процессору необходимо сообщать памяти, когда он отправляет корректный запрос. Для этого мы можем добавить новый сигнал reqValid к сигналам, отправляемым процессором в память, чтобы память могла использовать этот сигнал для определения момента наличия реального запроса. Когда процессору нужно обратиться к памяти, он устанавливает addr и другие сигналы и активирует reqValid; когда процессору не нужно обращаться к памяти, он деактивирует reqValid. Для памяти достаточно обращаться к данным, соответствующим addr, только когда сигнал reqValid активен; когда reqValid неактивен, память не выполняет операцию доступа.
С другой стороны, момент, когда память сможет считать данные, также заранее непредсказуем. Например, DRAM периодически заряжает и обновляет (refresh) конденсаторы ячеек памяти; если в этот момент поступает запрос на чтение, данные фактически будут считаны только после завершения заряда-обновления. Поэтому память также должна сообщать процессору, когда она может вернуть корректные данные. Аналогично, чтобы распознавать момент прихода ответа памяти, мы можем добавить новый сигнал respValid к сигналам, которые процессор получает от памяти, чтобы процессор мог использовать этот сигнал для определения момента, когда ответ памяти корректен. На примере операции чтения: когда память считывает данные, она устанавливает rdata и активирует respValid; пока память ещё не считала данные, она деактивирует respValid. Для процессора данные rdata считаются корректными только тогда, когда сигнал respValid активен; когда respValid неактивен, процессор считает, что данные ещё не вернулись, и должен продолжать ждать.
+-----+ reqValid ---> +-----+
| | addr[log2(N)-1:0] ---> | |
| | wen ---> | |
| CPU | wdata[31:0] ---> | MEM |
| | wmask[3:0] ---> | |
| | <--- respValid | |
| | <--- rdata[31:0] | |
+-----+ +-----+
На примере LSU, в расширенном SimpleBus задействованы следующие сигналы:
output lsu_reqValid,
output [31:0] lsu_addr,
output lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input lsu_respValid,
input [31:0] lsu_rdata,
Для операций чтения wdata и wmask могут принимать произвольные значения, а временная диаграмма соответствующих сигналов показана на рисунке ниже:
+------------+
lsu_reqValid | |
---+ +-------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------------------------------
--\ /------------------------------------------------
lsu_wen X X
--/ ------------ \------------------------------------------------
------------------------------------------------------------------
lsu_wdata
------------------------------------------------------------------
------------------------------------------------------------------
lsu_wmask
------------------------------------------------------------------
+------------+
lsu_respValid | |
----------------------------------------+ +------------
---------------------------------------\ /----------\ /-----------
lsu_rdata X 0x12345678 X
---------------------------------------/ \----------/ \-----------
Для операций записи, хотя rdata является сигналом «не важно» (don't-care), SimpleBus всё же использует respValid, чтобы указать, что операция записи завершена. Временная диаграмма соответствующих сигналов показана на рисунке ниже:
+------------+
lsu_reqValid | |
---+ +-------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_addr X 0x80400000 X
--/ \----------/ \------------------------------------------------
--\ ------------ /------------------------------------------------
lsu_wen X X
--/ \------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_wdata X 0x12345678 X
--/ \----------/ \------------------------------------------------
--\ /----------\ /------------------------------------------------
lsu_wmask X 0xf X
--/ \----------/ \------------------------------------------------
+------------+
lsu_respValid | |
----------------------------------------+ +------------
------------------------------------------------------------------
lsu_rdata
------------------------------------------------------------------
После добавления сигналов valid в SimpleBus нам также необходимо расширить конечный автомат:
- В состоянии
idle, если в данный момент необходим доступ к памяти, установить сигналыreqValid,addrи другие и перейти в состояниеwait; если доступ к памяти в данный момент не нужен, остаться в состоянииidle. - В состоянии
wait, еслиrespValidактивен, выполнить инструкцию, используяrdataв качестве считанных данных, и перейти в состояниеidle; еслиrespValidнеактивен, остаться в состоянииwait, тем самым реализуя эффект ожидания.
Протокол SimpleBus с сигналами valid
Согласно вышесказанному, дайте IFU и LSU возможность обращаться к памяти на основе сигналов valid. Для части памяти, отвечающей за доступ к данным, вы можете ориентироваться на следующий код:
always @(posedge clock) begin
lsu_rdata <= (lsu_reqValid && !lsu_wen) ? pmem_read(lsu_addr) : 32'b0;
if (lsu_reqValid && lsu_wen) begin
pmem_write(lsu_addr, lsu_wdata, lsu_wmask);
end
lsu_respValid <= lsu_reqValid;
end
Часть памяти, отвечающую за запрос инструкций, можно модифицировать аналогичным образом.
После реализации попробуйте запустить несколько тестовых программ и убедитесь по временным диаграммам, что взаимодействие между NPC и памятью происходит так, как ожидается. Точно так же программы, которые успешно выполнялись раньше, должны успешно выполняться и после расширения SimpleBus.
Протестируйте реализацию SimpleBus
Добавьте в память функциональность случайной задержки, чтобы проверить, корректно ли работает реализация шины при произвольных задержках. Вы можете добавлять задержки доступа к памяти по порядку от простого к сложному:
- Последовательно изменяйте задержки доступа к памяти на 5, 10, 20 и т. д.
- Добавьте в модуль памяти LFSR и используйте его для определения задержки текущего запроса.
- Добавьте LFSR также в IFU и LSU и используйте их для определения задержки соответствующих сигналов
valid.
Если NPC по-прежнему сможет корректно выполнять программы при случайных задержках, заполняемых с помощью LFSR, это значительно укрепит вашу уверенность в собственном коде.
Friendly Reminder
Если вы изучаете шины для tape-out D-этапа, здесь можно остановиться.
Более общая память (2)
Иногда, даже если процессор хочет отправить запрос, память может быть не в состоянии немедленно его принять, возможно, потому что память занята или очередь запросов заполнена. Для этого мы можем добавить новый сигнал reqReady к сигналам, отправляемым памятью процессору, чтобы процессор мог использовать этот сигнал для распознавания момента, когда память способна принимать запросы. Когда процессору нужно обратиться к памяти, если reqReady активен, это означает, что текущий запрос может быть успешно принят памятью; если reqReady неактивен, это означает, что текущий запрос не может быть принят памятью, и процессору необходимо подождать.
С другой стороны, процессор также может быть не готов принять данные, возвращаемые памятью, потому что ранее считанные данные ещё не были использованы. Аналогично, мы можем добавить новый сигнал respReady к сигналам, отправляемым процессором памяти, чтобы память могла использовать этот сигнал для распознавания момента, когда процессор способен принимать ответы. Когда памяти нужно отправить ответ, если respReady активен, это означает, что текущий ответ может быть успешно принят процессором; если respReady неактивен, это означает, что текущий ответ не может быть принят процессором, и памяти необходимо подождать.
+-----+ reqValid ---> +-----+
| | <--- reqReady | |
| | addr[log2(N)-1:0] ---> | |
| | wen ---> | |
| CPU | wdata[31:0] ---> | MEM |
| | wmask[3:0] ---> | |
| | <--- respValid | |
| | respReady ---> | |
| | <--- rdata[31:0] | |
+-----+ +-----+
На примере LSU, в расширенном SimpleBus задействованы следующие сигналы:
output lsu_reqValid,
input lsu_reqReady,
output [31:0] lsu_addr,
output lsu_wen,
output [31:0] lsu_wdata,
output [ 3:0] lsu_wmask,
input lsu_respValid,
output lsu_respReady,
input [31:0] lsu_rdata,
Для операций чтения временная диаграмма соответствующих сигналов показана на рисунке ниже:
+-------------------------+
lsu_reqValid | |
---+ +---------------------------------------------------
+------------+
lsu_reqReady | |
----------------+ +---------------------------------------------------
--\ /-----------------------\ /--------------------------------------------------
lsu_addr X 0x80400000 X
--/ \-----------------------/ \--------------------------------------------------
--\ /--------------------------------------------------
lsu_wen X X
--/ ------------------------- \--------------------------------------------------
---------------------------------------------------------------------------------
lsu_wdata
---------------------------------------------------------------------------------
---------------------------------------------------------------------------------
lsu_wmask
---------------------------------------------------------------------------------
+-------------------------+
lsu_respValid | |
------------------------------------------+ +------------
+------------+
lsu_respReady | |
-------------------------------------------------------+ +------------
-----------------------------------------\ /-----------------------\ /-----------
lsu_rdata X 0x12345678 X
-----------------------------------------/ \-----------------------/ \-----------
Для операций записи временная диаграмма соответствующих сигналов показана на рисунке ниже:
+-------------------------+
lsu_reqValid | |
---+ +---------------------------------------------------
+------------+
lsu_reqReady | |
----------------+ +---------------------------------------------------
--\ /-----------------------\ /--------------------------------------------------
lsu_addr X 0x80400000 X
--/ \-----------------------/ \--------------------------------------------------
--\ ------------------------- /--------------------------------------------------
lsu_wen X X
--/ \--------------------------------------------------
--\ /-----------------------\ /--------------------------------------------------
lsu_wdata X 0x12345678 X
--/ \-----------------------/ ---------------------------------------------------
--\ /-----------------------\ /--------------------------------------------------
lsu_wmask X 0xf X
--/ \-----------------------/ \--------------------------------------------------
+-------------------------+
lsu_respValid | |
------------------------------------------+ +------------
+------------+
lsu_respReady | |
-------------------------------------------------------+ +------------
---------------------------------------------------------------------------------
lsu_rdata
---------------------------------------------------------------------------------
Как можно видеть, при завершении транзакции чтения и master, и slave должны пройти через два рукопожатия: master сначала ждёт reqReady, чтобы убедиться, что slave может принять запрос на чтение, а затем ждёт respValid, чтобы получить считанные данные; в то время как slave сначала ждёт reqValid, чтобы принять запрос на чтение, а затем ждёт respReady, чтобы убедиться, что master может принять считанные данные. Разумеется, на уровне RTL-реализации всё это конечные автоматы.
Значение сигналов рукопожатия для микроархитектурного проектирования — развязка (decoupling)
С точки зрения микроархитектурного проектирования, важнейшее значение сигналов рукопожатия заключается в том, чтобы скрыть задержку обработки у обеих взаимодействующих сторон. Например, момент, когда DRAM считывает данные, зависит от множества факторов, включая тайминг обновления (refresh), планирование запросов контроллером DRAM, попадание в буфер строки (row buffer) в микросхеме DRAM и даже электрические характеристики самой микросхемы. Аналогично, момент, когда CPU отправляет запросы и получает данные, также зависит от множества факторов, включая момент выполнения программой инструкций доступа к памяти, заторы в конвейере, состояние кэша и т. д. Если бы одной из сторон взаимодействия приходилось учитывать все эти особенности другой стороны, такой дизайн было бы невозможно реализовать: у нас нет способа узнать, в каком состоянии находится другая сторона.
Сигналы рукопожатия успешно убирают необходимость знания состояния обеих сторон. Благодаря сигналам рукопожатия ни одной из сторон не нужно заботиться о состоянии другого модуля; достаточно лишь дождаться рукопожатия. Поэтому, пока модули следуют одному и тому же шинному протоколу, они могут беспрепятственно работать сразу после подключения к шине.
Протокол SimpleBus с полным набором сигналов рукопожатия
Согласно вышесказанному, дайте IFU и LSU возможность обращаться к памяти на основе полного набора сигналов рукопожатия.
После реализации попробуйте запустить несколько тестовых программ и убедитесь по временным диаграммам, что взаимодействие между NPC и памятью происходит так, как ожидается.
Вы можете добавить случайные задержки к reqReady и respReady, чтобы более тщательно протестировать реализацию шины.
Обработка ошибок и исключения
В некоторых случаях при обработке памятью транзакций чтения и записи могут возникать ошибки, например при чтении или записи по адресу за пределами диапазона хранения, либо при обнаружении с помощью контрольных сумм, что считанные/записываемые ячейки памяти повреждены. В таких случаях slave обычно должен сообщить master о возникновении ошибки и позволить master решить, как её обрабатывать. Поэтому нам необходимо дополнительно передавать в ответе памяти сигнал error, указывающий на успешность операции. Если сигнал error активен, возвращённые считанные данные rdata некорректны, либо операция записи была неуспешной. Разумеется, сигнал error также должен участвовать в рукопожатии.
+-----+ reqValid ---> +-----+
| | <--- reqReady | |
| | addr[log2(N)-1:0] ---> | |
| | wen ---> | |
| | wdata[31:0] ---> | |
| CPU | wmask[3:0] ---> | MEM |
| | <--- respValid | |
| | respReady ---> | |
| | <--- rdata[31:0] | |
| | <--- error | |
+-----+ +-----+
В процессоре, если при операциях чтения или записи возникают ошибки, могут дополнительно генерироваться исключения, чтобы уведомить об этом программное обеспечение для последующей обработки. Например, RISC-V определяет 3 типа исключений Access Fault, представляющих ошибки при выборке инструкций из памяти, чтении данных и записи данных. Таким образом, ошибки внутри памяти могут передаваться процессору через шинный протокол, а затем сообщаться программному обеспечению через механизм обработки исключений процессора.
Широко используемый в промышленности шинный протокол — протокол шины AMBA
AMBA (Advanced Microcontroller Bus Architecture) — это семейство шинных протоколов, широко используемых в промышленности, которое определяет шинные протоколы для различных сценариев производительности. Разобравшись в SimpleBus, вы будете способны понимать эти широко используемые в промышленности шинные протоколы.
Протокол шины APB
В AMBA APB (Advanced Peripheral Bus) — это шинный протокол для низкоскоростной периферии. На самом деле, просто преобразовав SimpleBus без сигналов ready, мы можем получить протокол шины APB:
- Переименовать сигналы.
- Добавить
psel, используемый master для выбора одного slave среди нескольких.
reqValid ---> penable ---> psel --->
addr ---> paddr ---> penable --->
wen ---> pwrite ---> paddr --->
wdata ---> 1 pwdata ---> 2 pwrite --->
wmask ---> ===> pstrb ---> ===> pwdata --->
<--- respValid <--- pready pstrb --->
<--- rdata <--- prdata <--- pready
<--- error <--- pslverr <--- prdata
<--- pslverr
Так мы получаем протокол шины APB4 из руководства по APB! Однако требования APB4 к таймингу запросов отличаются от SimpleBus; мы не будем подробно останавливаться на этом здесь — просто обратитесь к официальному руководству (RTFM), когда вам это потребуется.
Протокол шины AXI
Поскольку в APB нет сигналов ready, slave может принимать и обрабатывать только один запрос за раз. Для низкоскоростной периферии, такой как последовательный порт, это приемлемо, потому что во время выполнения программы частота обращения к низкоскоростной периферии невысока; обработка низкоскоростным устройством только одного запроса за раз не приводит к заметным потерям производительности.
Но, напротив, доступ к памяти — это частая операция для программ: большинство инструкций load и store, выполняемых процессором, в конечном счёте требуют обращения к памяти, особенно к основной памяти. Поэтому для высокопроизводительных процессоров использование APB, который может «принимать и обрабатывать только один запрос за раз», привело бы к заметным потерям производительности, превращая процесс доступа к памяти в узкое место (bottleneck) всей системы. Чтобы обеспечить более эффективный доступ процессоров к памяти, в AMBA необходимо определить ещё один шинный протокол, отличный от APB, — это AXI (Advanced eXtensible Interface).
Чтобы помочь всем разобраться в AXI, мы возьмём за основу версию SimpleBus с полным набором сигналов рукопожатия и выполним простое преобразование:
- Разделить адреса запросов чтения и записи; сигналы, связанные с операциями чтения, используют префикс
r, а сигналы, связанные с операциями записи, — префиксw.- Поскольку у запроса на запись есть собственный сигнал valid
wreqValid, сигналwenне нужен.
- Поскольку у запроса на запись есть собственный сигнал valid
- Переименовать сигналы: адрес чтения использует префикс
ar, данные чтения — префиксr, запрос на запись — префиксw, ответ на запись — префиксb(отbackward). - Разделить адрес записи и данные записи; адрес записи использует префикс
aw, данные записи — префиксw. - Сгруппировать сигналы в 5 групп по функциональному назначению.
reqValid ---> rreqValid ---> arvalid --->
<--- reqReady <--- rreqReady <--- arready
addr ---> raddr ---> araddr --->
wen ---> <---rrespValid <--- rvalid
wdata ---> 1 rrespReady---> 2 rready --->
wmask ---> ===> <--- rdata ===> <--- rdata
<--- respValid <--- rerror <--- rresp
respReady ---> wreqValid ---> wvalid --->
<--- rdata <--- wreqReady <--- wready
<--- error waddr ---> waddr --->
wdata ---> wdata --->
wmask ---> wstrb --->
<---wrespValid <--- bvalid
wrespReady---> bready --->
<--- werror <--- bresp
arvalid ---> araddr ---> -+
<--- arready arvalid ---> AR
araddr ---> <--- arready -+
<--- rvalid
3 rready ---> 4 <--- rdata -+
===> <--- rdata ===> <--- rresp |
<--- rresp <--- rvalid R
awvalid ---> rready ---> -+
<--- awready
awaddr ---> awaddr ---> -+
wvalid ---> awvalid ---> AW
<--- wready <--- awready -+
wdata --->
wstrb ---> wdata ---> -+
<--- bvalid wstrb ---> |
bready ---> wvalid ---> W
<--- bresp <--- wready -+
<--- bresp -+
<--- bvalid B
bready ---> -+
Так мы получаем протокол шины AXI4-Lite из руководства по AXI! AXI4-Lite — это упрощённая версия AXI. У неё есть 5 транзакционных каналов, а именно: Read Address (AR, адрес чтения), Read Data (R, данные чтения), Write Address (AW, адрес записи), Write Data (W, данные записи) и Write Response (B, ответ на запись). Рабочие состояния этих транзакционных каналов зависят только от их собственных сигналов рукопожатия, поэтому они могут работать независимо друг от друга.
По сравнению с APB, AXI4-Lite обладает следующими преимуществами в производительности:
- Транзакции чтения и записи используют раздельные транзакционные каналы. Таким образом шина может передавать транзакции чтения и записи одновременно, в одном и том же такте, обеспечивая параллельную передачу и обработку запросов чтения и записи.
- Адрес записи и данные записи используют раздельные транзакционные каналы. Для некоторых slave-устройств операция записи должна выполняться в несколько этапов. Например, для памяти DRAM сначала необходимо активировать целевую ячейку памяти. Для таких slave-устройств, если адрес записи может быть принят заранее, даже если данные записи ещё не получены, первые несколько этапов операции записи всё же можно начать. Разделяя адрес записи и данные записи, AXI позволяет master передавать адрес записи до того, как данные записи будут готовы, позволяя slave начать операцию записи раньше, тем самым повышая эффективность обработки операций записи.
- У каждого канала есть полный набор сигналов рукопожатия. Таким образом шина может передавать slave-устройству следующий запрос ещё до того, как ответ на предыдущий запрос будет передан обратно master, обеспечивая параллельную передачу и обработку запросов.
Разумеется, текущий NPC всё ещё является многотактным процессором; как master, он не может отправлять несколько запросов доступа к памяти одновременно, поэтому он также не может воспользоваться перечисленными выше преимуществами AXI. Но для высокопроизводительных суперскалярных процессоров с внеочередным исполнением в системе могут одновременно отправляться несколько запросов доступа к памяти; использование шинного протокола AXI может значительно повысить параллелизм доступа процессора к памяти, тем самым повышая эффективность обращения процессора к памяти.
На самом деле AXI4-Lite, который мы сейчас реализуем, — это лишь упрощённая версия AXI4. Полная спецификация шины AXI содержит больше сигналов и тем самым поддерживает больше возможностей, таких как наложение запросов (overlap) и внеочередная обработка нескольких запросов. Если вам интересно, вы также можете изучить соответствующие детали, обратившись к официальному руководству (RTFM). Мы будем постепенно вводить дополнительные возможности, которые нам понадобятся, в последующих главах.
Одновременное чтение и запись по одному и тому же адресу — RTFM
AXI разделяет транзакции чтения и записи, а это значит, что один и тот же адрес в памяти может одновременно читаться и записываться. В этом случае какие данные память фактически считает? На самом деле конкретное поведение необходимо определять по официальному руководству (RTFM): в руководстве на устройство памяти указывается поведение для этой операции, и на некоторых устройствах результат чтения является неопределённым. Такой особенностью в основном обладают SRAM и Block RAM в FPGA.
Поэтому со стороны master следует по возможности избегать одновременного чтения и записи по одному и тому же адресу. Если этого действительно невозможно избежать, на стороне master можно добавить логику обнаружения. При обнаружении одновременного чтения и записи по одному и тому же адресу операцию записи можно отложить, сначала считав старые данные; либо можно отложить операцию чтения, сначала записав данные, а затем считав уже вновь записанные данные. Таким образом можно гарантировать по крайней мере корректную определённость результата чтения.
Однако в текущем NPC IFU выдаёт только запросы на чтение, а LSU не выдаёт одновременно запросы на чтение и запись (в зависимости от того, является ли текущая выполняемая инструкция load или store), поэтому пока вам не нужно учитывать эту проблему в NPC.
При практическом использовании AXI нам также необходимо избегать двух ситуаций, связанных с сигналами рукопожатия:
- И master, и slave ждут, что другая сторона первой установит сигнал рукопожатия в 1: master устанавливает
validв 1 только после того, как дождётся установки slave сигналаreadyв 1; в то время как slave устанавливаетreadyв 1 только после того, как дождётся установки master сигналаvalidв 1. В результате обе стороны ждут бесконечно, что приводит к взаимной блокировке (deadlock). - И master, и slave пытаются выполнить рукопожатие, но обе стороны отменяют его после неудачной попытки:
- В 1-м такте master устанавливает
validв 1, но в этот моментreadyравен 0, поэтому рукопожатие не удаётся. - Во 2-м такте slave обнаруживает, что в предыдущем такте master установил
validв 1, и поэтому в этом такте устанавливаетreadyв 1; но поскольку рукопожатие в предыдущем такте не удалось, master в этом такте устанавливаетvalidв 0, и рукопожатие в этом такте снова не удаётся. - В 3-м такте master обнаруживает, что в предыдущем такте slave установил
readyв 1, и поэтому в этом такте устанавливаетvalidв 1; но поскольку рукопожатие в предыдущем такте не удалось, slave в этом такте устанавливаетreadyв 0, и рукопожатие в этом такте снова не удаётся. - В результате обе стороны всё равно ждут бесконечно, что приводит к активной блокировке (livelock).
- В 1-м такте master устанавливает
+---+ +---+ +---+ +---+
clk | | | | | | | |
+ +---+ +---+ +---+ +---+
+-------+ +-------+
valid | | |
+-------+ +--------
+-------+ +--------
ready | | |
+-------+ +-------+
Избегайте взаимной блокировки и активной блокировки при рукопожатии
Чтобы избежать описанных выше проблем, стандарт AXI добавляет ряд ограничений на поведение сигналов рукопожатия. Вам необходимо найти эти ограничения по официальному руководству (RTFM) и правильно их понять.
Обратите внимание, что консультироваться нужно именно с официальным руководством. Если вы будете опираться на недостаточно формальные материалы, при интеграции в SoC вы попадёте в мучительную яму отладки.
Дайте NPC поддержку AXI4-Lite
К этому моменту вы уже поняли, почему в шину AXI4-Lite включён каждый сигнал, и разобрались в философии проектирования этой шинной спецификации с точки зрения требований. Теперь вы можете добавить AXI4-Lite в NPC и позволить NPC обращаться к памяти через AXI4-Lite.
Преобразуйте интерфейсы доступа к памяти IFU и LSU в AXI4-Lite
Вам необходимо корректно реализовать протокол шины AXI4-Lite с рукопожатиями как на стороне master, так и на стороне slave. В частности:
- Преобразуйте интерфейсы доступа к памяти IFU и LSU в AXI4-Lite.
- Преобразуйте два интерфейса SimpleBus памяти,
ifuиlsu, соответственно в AXI4-Lite.
Поскольку IFU выполняет только операции чтения из памяти и никогда не пишет в память, сигналы рукопожатия трёх каналов AW, W и B для IFU можно все установить в 0. Разумеется, более правильная практика — использовать assert() на другом конце этих сигналов рукопожатия, чтобы гарантировать, что они остаются равными 0.
После реализации попробуйте запустить несколько тестовых программ и убедитесь по временным диаграммам, что взаимодействие между NPC и памятью происходит так, как ожидается. Если NPC по-прежнему сможет корректно загружать RT-Thread при случайных задержках, заполняемых с помощью LFSR, это значительно укрепит вашу уверенность в собственном коде.
Знания, полученные через размышление, — это настоящее мастерство
Традиционные учебники часто рассматривают шины только на уровне протокола, не объясняя чётко, как реализовать шину на уровне RTL. Поэтому, если вы читаете только учебники, шина остаётся относительно абстрактным понятием. «Один студент — один чип» — это проект практического обучения, и мы уже рассказали в методических материалах, как реализовать шину на уровне RTL.
Однако если в процессе реализации вы всё же обнаруживаете некоторые трудности, которые тяжело преодолеть, — это повод забить тревогу: скорее всего, вам не хватает умения пошагово превращать требования в код. Вероятно, вам стоит переосмыслить свои прошлые методы обучения, например:
- Не слишком ли сильно вы зависите от блок-схем, из-за чего вам не хватает способности к микроархитектурному проектированию и вы теряетесь без блок-схем?
- Ещё хуже — не слишком ли сильно вы зависите от материалов и книг, полных примеров кода, из-за чего вам не хватает не только способности к микроархитектурному проектированию, но и умения превращать проекты в код? Мы настоятельно не рекомендуем начинающим читать подобные материалы и книги до тех пор, пока не заработает их первая версия кода!
Мы надеемся, что в своём обучении вы поставите «самостоятельное мышление» на первое место, а не будете просто переносчиком чужого кода. Если вы обнаруживаете, что самостоятельное мышление отныне даётся вам с большим трудом, весьма вероятно, что вы слишком многое упустили в предыдущем обучении, из-за чего не можете самостоятельно выполнять последующие задания. Если дело действительно обстоит так, мы рекомендуем вам заново начать изучение «Один студент — один чип» с правильным настроем.
Сохраняйте правильный настрой, постепенно осваивайте все детали через итеративную разработку и отладку
Тем не менее, будучи широко используемым в промышленности шинным протоколом, AXI всё же содержит немало деталей, из-за чего большинству начинающих трудно полностью разобраться в AXI при первом знакомстве. Поэтому всем также необходимо сохранять правильный настрой; идея «написать весь код шины сразу и никогда больше его не менять» нереалистична.
На самом деле все постепенно осваивают все детали шины через итеративную разработку и отладку. Даже опытные инженеры поступают так при изучении новых знаний; для начинающих идея «покорить вершину за один шаг» не соответствует объективным законам обучения.
Арбитр в шине
После описанных выше модификаций мы превратили память в два интерфейса AXI4-Lite, подключённых соответственно к IFU и LSU. Но в реальном аппаратном обеспечении большинство модулей памяти предоставляют только один интерфейс шины. Чтобы интегрировать это в последующий SoC, нам нужно продумать, как в аппаратной реализации дать IFU и LSU доступ к одному и тому же интерфейсу памяти.
Это фактически проблема множества master-устройств (multi-master). Поскольку у slave есть только один интерфейс, для принятия решения используется арбитр (arbiter): когда несколько master одновременно обращаются к одному и тому же slave, master, получивший право доступа, получает разрешение и может успешно обратиться к slave; запросы остальных master блокируются на арбитре, ожидая, пока master, получивший право доступа, не завершит своё обращение, после чего они смогут получить право доступа.
Кратко резюмируя, арбитр должен реализовывать следующие функции:
- Планирование (scheduling): выбор master, который в данный момент отправляет корректный запрос.
- Блокировка (blocking): блокирование доступа остальных master.
- Пересылка (forwarding): пересылка запроса master, получившего право доступа, к slave, а когда приходит ответ slave — пересылка его обратно исходному master.
Как именно выбирать при одновременном обращении нескольких master — по сути вопрос стратегии планирования. В сложных системах стратегии планирования должны учитывать больше вопросов: во-первых, избегать голодания (starvation), то есть любой master должен получать право доступа за конечное число арбитражей; во-вторых, избегать взаимной блокировки (deadlock), то есть блокировка, вызванная арбитром, не должна приводить к циклическому ожиданию во всей системе. Однако, поскольку текущий NPC является многотактным процессором, master IFU и LSU не будут отправлять запросы одновременно, поэтому вам не нужно рассматривать сложные стратегии планирования; подойдёт выбор любой простой стратегии планирования.
Реализуйте арбитр AXI4-Lite
Оставьте у памяти один интерфейс AXI4-Lite, напишите арбитр AXI4-Lite и выберите одного master из IFU и LSU для взаимодействия с памятью.
Подсказка: арбитр по сути является конечным автоматом, а функции блокировки и пересылки в основе своей реализуются путём манипулирования сигналами рукопожатия.
Оцените тактовую частоту NPC и производительность программ
После реализации AXI4-Lite NPC теперь можно подключить извне к реальной памяти. Объектом оценки будет NPC с одним интерфейсом AXI4-Lite, включая только что реализованный арбитр AXI4-Lite, тогда как модуль памяти с интерфейсом AXI4-Lite, реализованный через DPI-C, не входит в область оценки.
Следуя тому же методу оценки, у другой версии NPC пользователя yzh тактовая частота на техпроцессе nangate45, предоставляемом по умолчанию проектом yosys-sta, составляет 297,711 МГц, поэтому расчётное время выполнения microbench составляет 1,394 с, тогда как моделирование заняло 29,861 с. Можно заметить, что время моделирования увеличилось, поскольку у многотактного NPC IPC ниже, чем у однотактного NPC, и для выполнения программы требуется больше тактов. Хотя IPC снизился, поскольку тактовая частота значительно выросла, программа на самом деле выполняется быстрее.
Не забывайте, что приведённый выше результат оценки однотактного NPC весьма оптимистичен, причём настолько, что практически недостижим. А вот результат оценки этого многотактного NPC гораздо более реалистичен; по крайней мере, SRAM объёмом 1 МБ достижим. Однако это всё ещё сильно отличается от нашей предстоящей конфигурации для tape-out, ведь стоимость tape-out для SRAM объёмом 1 МБ по-прежнему очень высока. Далее мы выполним интеграцию в SoC, что приблизит результаты оценки к сценарию tape-out.
Так почему же сначала реализуется шина?
В прошлом многих студентов сильно озадачивал этот вопрос, и некоторые даже считали его ловушкой, специально заложенной в методических материалах. Главная причина в том, что они думают, будто весь код придётся переписывать при последующей реализации конвейера. Другое мнение — что если сначала написать однотактную версию, её тоже впоследствии придётся переписывать.
Причина, по которой эти студенты считают, что переписывание понадобится позже, отчасти происходит из традиционных учебников: традиционные учебники действительно очень чётко объясняют принципы проектирования процессоров, но они почти никогда не рассматривают, как плавно переходить между процессорами разных микроархитектур. В конце концов, это относится к сфере инженерной практики, а традиционные учебники не преподают её как отдельный пункт знаний. Поэтому вторая причина в том, что эти студенты слишком сильно полагаются на традиционные учебники: они просто реализуют всё согласно содержанию учебника, не задумываясь о том, как принять подходящие шаблоны проектирования для осуществления вышеописанного перехода.
Для начинающих это на самом деле не большая проблема, поскольку шаблоны проектирования можно абстрагировать и обобщить только после полного понимания различных деталей; мы не должны требовать от начинающих придумать хороший шаблон проектирования уже при первом изучении. Но если вы хотите узнать больше, вам не следует считать содержание учебника всем проектированием процессора и не следует считать переписывание пустой тратой времени. Для начинающего, который ещё глубоко не задумывался, переписывание на самом деле скрывает в себе огромную возможность для роста: почему две версии дизайна так сильно отличаются? Можем ли мы абстрагировать и обобщить из них какие-то общие черты? Если бы нам пришлось делать это заново, как мы могли бы сделать лучше?
На самом деле хороший опыт и возможности для инноваций всегда обобщаются из проблем. Когда в будущем вы пойдёте работать, вы непременно столкнётесь с ещё большим числом самых разных проблем, и у этих проблем уже не будет стандартных ответов, как в учебниках. Знания в учебниках ограничены, и, полагаясь только на учебники, высота, до которой мы можем вырасти, тоже ограничена; но то, что может дополнительно помочь нам решать неизвестные будущие проблемы, — это наши привычки мышления: привычки мышления, сформированные в процессе многократных размышлений, гораздо важнее знаний из учебников.
Возвращаясь к проблеме шаблонов проектирования процессоров: мы уже продумали и обобщили соответствующий опыт ещё в мае 2017 года и опробовали его в двух соревнованиях Longxin Cup, в которых участвовал Нанкинский университет в 2017 и 2018 годах, а также в первой программе «Один студент — один чип», инициированной Университетом Китайской академии наук в 2019 году, без значительного переписывания. Многие студенты не знают об этом и потому по-прежнему судят исходя из собственного опыта.
В частности, используя подходящие возможности Chisel, переход от однотактной архитектуры к многотактной требует изменения всего 30 строк кода, что составляет 3,75% от общего объёма в 800 строк; переход от многотактной архитектуры к конвейеру без forwarding требует изменения всего 50 строк, что составляет 5,00% от общего объёма в 1000 строк; добавление forwarding в конвейер требует изменения всего 20 строк, что составляет 1,82% от общего объёма в 1100 строк. Даже если вы разрабатываете на Verilog, при использовании правильных шаблонов проектирования доля изменений кода должна быть примерно такой же. С точки зрения этой доли, это далеко от уровня, требующего переписывания всего заново.
Мы хотим сказать следующее: как начинающему, вам необходимо сохранять любознательность и придерживаться философии «по-настоящему понять можно, только попробовав самому». Когда кто-то говорит вам, что нужно переписать код, не стоит просто слепо следовать этому «совету» — нужно подумать, почему это так, и через собственную практику вникнуть в этот вопрос и ответить на него самостоятельно. Ведь в обучении главный герой — именно вы.
Так почему же всё-таки сначала реализуется шина?
Это делается для того, чтобы отработать принцип проектирования «сначала сделай, потом отполируй» (complete first, perfect later).
Если взять однотактный процессор в качестве отправной точки, то конвейер должен относиться к части «потом отполируй». Поэтому реализация шины в первую очередь на самом деле приближает процессор к готовности для tape-out, достигая «сначала сделай», то есть удаление любой возможности либо помешает процессору запускать RT-Thread, либо сделает память и периферию весьма отличными от конструкции для tape-out.
На основе «сначала сделай» мы понимаем, какую выгоду в производительности приносит каждая мера оптимизации, с помощью методов количественной оценки. Мы также надеемся, что в будущем начинающие смогут количественно понимать, какой прирост производительности системе приносит конвейерное проектирование, а не рассматривать конвейерное проектирование как домашнее задание по переводу блок-схем в RTL-код, как это делается в традиционных учебниках.
Системы с несколькими устройствами
До сих пор в нашей системе была только память. Очевидно, что в реальной компьютерной системе есть не только память, но и другие устройства. Поэтому нам нужно продумать, как дать NPC доступ к другим устройствам.
Когда мы ранее изучали устройства, мы рассматривали memory-mapped I/O — механизм, использующий разные адреса памяти для обозначения того, к какому устройству обращается CPU. Ранее мы реализовывали memory-mapped I/O в среде моделирования, используя pmem_read() и pmem_write() для выбора устройства, к которому осуществляется доступ, на основе адреса обращения к памяти. Но в реальном аппаратном обеспечении нет этих двух функций среды моделирования, pmem_read() и pmem_write().
В реальности аппаратное обеспечение реализует memory-mapped I/O через модуль crossbar (иногда записывается как Xbar). Xbar — это модуль-мультиплексор (переключатель) шины, который может пересылать запрос на разные выходы в зависимости от адреса шинного запроса на входной стороне, тем самым передавая его различным нижестоящим модулям. Этими нижестоящими модулями могут быть устройства, либо ещё один Xbar. Например, на рисунке ниже Arbiter используется для выбора одного запроса из IFU и LSU для пересылки вниз по цепочке; после того как нижестоящий Xbar получает запрос, он пересылает его нижестоящему устройству на основе адреса в запросе.
+-----+ +---------+ +------+ +-----+
| IFU | ---> | | | | ---> | UART| [0x1000_0000, 0x1000_0fff)
+-----+ | | | | +-----+
| Arbiter | ---> | Xbar |
+-----+ | | | | +-----+
| LSU | ---> | | | | ---> | SRAM| [0x8000_0000, 0x80ff_ffff)
+-----+ +---------+ +------+ +-----+
Если запрошенный адрес попадает в диапазон адресного пространства последовательного устройства UART, Xbar перешлёт запрос в UART; если запрошенный адрес попадает в диапазон адресного пространства SRAM, он перешлёт запрос в SRAM. Если Xbar обнаруживает, что запрошенный адрес не принадлежит ни одному нижестоящему адресному пространству, например 0x0400_0000, он вернёт ошибку decerr через сигнал resp AXI4-Lite, указывающую на ошибку декодирования адреса. Здесь «декодирование адреса» означает преобразование запрошенного адреса в номер нижестоящего шинного канала, а декодер адреса внутри Xbar можно считать ключевым модулем memory-mapped I/O в аппаратной реализации.
Arbiter и Xbar также можно объединить в один многовходовый многовыходной Xbar, который в зависимости от ситуации также называют Interconnect или мостом шины (bus bridge). Например, на рисунке ниже показан 2-входовый 2-выходовый Xbar. Он может соединять несколько master и несколько slave. Сначала Arbiter фиксирует, от какого master пришёл текущий запрос, а затем на основе адреса этого запроса решает, какому slave его переслать.
+-----+ +------+ +-----+
| IFU | ---> | | ---> | UART| [0x1000_0000, 0x1000_0fff)
+-----+ | | +-----+
| Xbar |
+-----+ | | +-----+
| LSU | ---> | | ---> | SRAM| [0x8000_0000, 0x80ff_ffff)
+-----+ +------+ +-----+
Атрибуты физической памяти (PMA) и программирование bare metal
Описанный выше способ топологического соединения также может привести к некоторым особым проблемам. Поскольку IFU может подключаться к UART через Xbar, это означает, что CPU также может запрашивать инструкции из UART, но очевидно, что устройство UART не может хранить программы и инструкции.
Поэтому, если программа по ошибке переходит на 0x1000_0000, CPU отправит запрос на чтение в UART, и UART вернёт некоторые данные в зависимости от поведения устройства, которые могут представлять собой кодировку внутреннего состояния UART или символ, принятый UART. С одной стороны, CPU по ошибке выполнит результат, возвращённый UART, как инструкцию, что вызовет серьёзные ошибки; с другой стороны, обращение IFU к устройству UART может изменить состояние устройства, приведя UART в непредсказуемое состояние.
Чтобы избежать возникновения подобных проблем, в аппаратном обеспечении обычно добавляются некоторые механизмы проверки, добавляющие для каждого адресного пространства несколько атрибутов прав доступа, таких как флаг возможности чтения, флаг возможности записи, флаг возможности исполнения и т. д. Прежде чем IFU выдаст запрос на выборку инструкции, он сначала проверяет, является ли адресное пространство, которому принадлежит запрошенный адрес, исполняемым; если оно не исполняемо, немедленно генерируется исключение. В RISC-V, если адресные пространства устройств в системе фиксированы, проверку прав можно реализовать через механизм PMA (Physical Memory Attribute); а для современных устройств PCI-e, чьи адресные пространства динамически выделяются при инициализации операционной системы, проверку прав можно реализовать через механизм PMP (Physical Memory Protection) или механизм виртуальной памяти. Если вам интересны эти два механизма, вы можете обратиться к соответствующим разделам руководства по RISC-V.
Разумеется, если эти механизмы проверки не реализованы в CPU, вам нужно быть особенно осторожным при разработке программ bare metal: если программа bare metal выходит из-под контроля, последствия могут быть непредсказуемыми.
Имея Xbar, мы теперь можем перенести функции периферии, ранее реализованные в среде моделирования, в RTL.
Реализуйте функциональность UART с интерфейсом AXI4-Lite
Напишите slave-модуль с интерфейсом AXI4-Lite, содержащий регистр устройства. Когда в этот регистр устройства отправляется запрос на запись, младшие 8 бит записываемых данных рассматриваются как символ и выводятся через $write() или printf(). Для удобства тестирования адрес этого регистра устройства можно задать таким же, как адрес последовательного порта в предыдущей среде моделирования. После реализации вам также нужно самостоятельно написать модуль Xbar, чтобы интегрировать этот модуль с функциональностью UART в систему.
На самом деле мы ещё не полностью реализовали UART на RTL, поскольку $write() или printf() всё ещё полагаются на среду моделирования для реализации вывода символов. Но как упражнение по шине этого уже достаточно; ведь реализация UART также требует учёта множества электрических деталей. Однако вскоре мы выполним интеграцию в SoC, который содержит настоящий контроллер UART. Протестировав реализацию шины на этом упражнении сейчас, вы облегчите себе будущую интеграцию в SoC.
Реализуйте CLINT с интерфейсом AXI4-Lite
CLINT (Core Local INTerrupt controller) — это относительно универсальный контроллер прерываний в системах RISC-V, модуль, используемый для поддержки тактовых (clock) прерываний и программных (software) прерываний. Однако нашей системе пока не нужна функциональность прерываний, поэтому сначала мы рассмотрим только функциональность, связанную с тактированием (clock).
Вам необходимо реализовать модуль CLINT с интерфейсом AXI4-Lite и интегрировать его в систему. CLINT содержит регистр устройства mtime, доступный только для чтения, который увеличивается с определённой скоростью; простейшая реализация — увеличивать его на 1 каждый такт. Также для удобства тестирования его адрес можно задать таким же, как адрес часов (clock) в предыдущей среде моделирования.
Однако рост значения mtime не может напрямую отражать течение времени; между ними есть коэффициент различия, который необходимо считать программному обеспечению и затем обработать. В реальных процессорных микросхемах этот коэффициент обычно равен тактовой частоте модуля CLINT, чтобы программное обеспечение могло измерять реальное время. Но в среде моделирования нет понятия базовой частоты; если этот коэффициент равен скорости моделирования, мы можем рассчитать течение реального времени по росту mtime в среде моделирования. В частности, вам также нужно изменить соответствующий код IOE, чтобы AM_TIMER_UPTIME возвращало время, близкое к реальному.
Наконец, вам также нужно учесть разрядность регистра mtime. Регистр mtime, определённый в приведённом выше руководстве, имеет разрядность 64 бита, чтобы избежать переполнения при практическом использовании. Но текущий NPC является 32-битным; если мы будем считывать только младшие 32 бита mtime, через некоторое время mtime переполнится, что вызовет ошибки в функциональности времени системы. Хотя в среде моделирования нелегко дойти до момента переполнения mtime, если в будущем NPC будет работать на частоте 500 МГц, переполнение весьма вероятно произойдёт. Поэтому программному обеспечению, работающему на 32-битном NPC, необходимо последовательно считывать младшие 32 бита и старшие 32 бита mtime, объединять их в 64-битное значение и предоставлять его приложениям верхнего уровня.
yyz
