Часть 5/2 корп. 1: Перекрёсток проспекта RocketChip и скользкой дорожки инструментации
В предыдущих четырёх частях велась подготовка к экспериментам с RISC-V ядром RocketChip, а именно, портирование этого ядра на «нестандартную» для него плату с ПЛИС фирмы Altera (теперь уже Intel). Наконец, в прошлой части на этой плате получилось запустить Linux. Знаете, что меня во всём этом забавляло? То, что одновременно приходилось работать с ассемблером RISC-V, C и Scala, и из всех них Scala была самым низкоуровневым языком (потому что именно на ней написан процессор).
Давайте в этой статье сделаем так, чтобы C тоже не было обидно. Более того, если связка Scala+Chisel использовалась лишь как domain-specific language для явного описания аппаратуры, то сегодня мы научимся «затягивать» простенькие функции на C в процессор в виде инструкций.
Конечная же цель — тривиальная реализация тривиальных AFL-like инструментаций по аналогии с QInst, а реализация отдельностоящих инструкций — лишь побочный продукт.
Понятно, что существует (и не один) коммерческий конвертер OpenCL в RTL. Также попадалась информация про некий проект COPILOT для RISC-V с похожими целями (намного более продвинутый), но что-то гуглится плохо, к тому же, это, скорее всего, тоже коммерческий продукт. Меня же интересуют в первую очередь OpenSource-решения, но даже если они есть, всё равно забавно попробовать реализовать такое самому — хотя бы как максимально упрощенный учебный пример, а там уж как получится…
Disclaimer (в дополнение к обычному предупреждению о «плясках с огнетушителем»): настоятельно не рекомендую бездумно применять получившееся софтовое ядро, особенно, с недоверенными данными — пока что у меня нет не то, что уверенности, а даже понимания, почему обрабатываемые данные не могут «перетечь» в каком-нибудь граничном случае между процессами и/или ядром. Ну, а про то, что данные могут «побиться», думаю, и так понятно. В общем, тут ещё валидировать и валидировать…
Для начала, что я называю «простенькой функцией»? Для целей этой статьи под этим подразумевается функция, при выполнении которой все переходы (условные и безусловные) только увеличивают счётчик команд на константное значение. То есть граф всех возможных переходов является (направленным) ациклическим, без «динамических» рёбер. Конечная цель в рамках этой статьи — иметь возможность взять простую функцию из программы и, заменив её ассемблерной заглушкой, «зашить» в процессор на этапе синтеза, опционально сделав её side effect-ом выполнения другой инструкции. Конкретно в этой статье ветвления показаны не будут, но в простейшем случае сделать их не составит труда.
Учимся понимать C (на самом деле, нет)
Для начала надо понять, как мы будем парсить C? Правильно, никак — не зря же я учился парсить ELF-файлы: нужно просто скомпилировать наш код на C / Rust / чём-то ещё в eBPF-байткод, и парсить уже его. Некоторые затруднения вызывает то, что в Scala нельзя просто подключить elf.h
и вычитывать поля структуры. Можно, конечно, было бы попробовать использовать JNAerator — им при необходимости можно делать биндинги к сишной библиотеке — не только структуры, но и генерировать код для работы через JNA (не путать с JNI). Я же как настоящий программист напишу свой велосипед и аккуратно выпишу константы перечислений и смещения из заголовочного файла. Результат и промежуточные структуры описываются следующей структурой case class-ов:
sealed trait SectionKind
case object RegularSection extends SectionKind
case object SymtabSection extends SectionKind
case object StrtabSection extends SectionKind
case object RelSection extends SectionKind
final case class Elf64Header(
sectionHeaders: Seq[ByteBuffer],
sectionStringTableIndex: Int
)
final case class Elf64Section(
data: ByteBuffer,
linkIndex: Int,
infoIndex: Int,
kind: SectionKind
)
final case class Symbol(
name: String,
value: Int,
size: Int,
shndx: Int,
isInstrumenter: Boolean
)
final case class Relocation(
relocatedSection: Int,
offset: Int,
symbol: Symbol
)
final case class BpfInsn(
opcode: Int,
dst: Int,
src: Int,
offset: Int,
imm: Either[Long, Symbol]
)
final case class BpfProg(
name: String,
insns: Seq[BpfInsn]
)
Процесс парсинга также описывать особо не буду — это всего лишь унылое перекладывание байтов из java.nio.ByteBuffer
— всё интересное уже было описано в статье про разбор ELF-файлов. Скажу лишь о том, что нужно аккуратно обрабатывать opcode == 0x18
(загрузка в регистр 64-bit immediate значения), поскольку он занимает сразу два 8-байтных слова (может, есть и другие такие опкоды, но я на них пока не натыкался), причём это не всегда загрузка адреса памяти, связанная с релокацией, как я думал изначально. Например, __builtin_popcountl
честно использует 64-битную константу 0x0101010101010101
. Почему я не делаю «честную» релокацию с патчингом загруженного файла — потому что хочется видеть символы в символьном виде (извините за каламбур), чтобы потом символы из секции COMMON
можно было бы заменить на регистры без использования костылей со специальной обработкой адресов специального вида (а значит, ещё с плясками с константными/неконстантными UInt
).
Строим hardware по набору инструкций
Итак, по предположению, все возможные пути исполнения идут исключительно вниз по списку инструкций, а значит, данные текут по ориентированному ациклическому графу, причём все его рёбра определены статически. При этом у нас есть чисто комбинационная логика (то есть без регистров на пути), получающаяся из операций над регистрами, а также задержки при операциях load/store с памятью. Таким образом, в общем случае операцию может быть невозможно завершить за один такт. Поступим просто: будем передавать значение на в виде UInt
, а как (UInt, Bool)
: первый элемент пары — это значение, а второй — признак его корректности. То есть не имеет большого смысла читать из памяти, пока адрес некорректен, а писать так и вообще нельзя.
Модель выполнения eBPF байткода предполагает некую оперативную память с 64-битной адресацией, а также набор из 16-и (или даже десяти) 64-битных регистров. Предлагается примитивный рекурсивный алгоритм:
- начинаем с контекста, в котором в
r1
иr2
лежат операнды инструкции, в остальных — нули, все валидные (точнее, валидность равна «готовности» команды сопроцессора) - если видим арифметико-логическую инструкцию, достаём её операнды-регистры из контекста, вызываем себя для хвоста списка и контекста, в котором выходной операнд заменён на пару
(data1 op data2, valid1 && valid2)
- если встречаем ветвление, просто рекурсивно строим обе ветви: если ветвление произошло, и если нет
- если встречаем загрузку или сохранение в память, как-нибудь выкручиваемся: выполняем переданный callback, предполагая инвариант, что однажды выставленный
valid
не может быть отозван в течение выполнения данной инструкции. Валидность операции сохранения мы AND-им с флагомglobalValid
, который должен быть выставлен перед возвратом управления. При этом чтение и запись мы должны делать по фронтуvalid
, чтобы корректно обрабатывать инкременты и прочие модификации.
Таким образом, операции будут выполняться как можно параллельнее, а не по шагам. При этом прошу обратить внимание, что все операции над конкретным байтом памяти должны быть естественным образом полностью упорядочены, иначе результат непредсказуем, UB.Т. е. *addr += 1
— это нормально, запись точно не начнётся, пока не завершится чтение (банально потому, что мы ещё не знаем, что писать), а вот *addr += 1; return *addr;
у меня вообще благополучно выдавало ноль или что-то подобное. Может, это и стоило бы отладить (может, оно скрывает какую-то более хитрую проблему), но само по себе подобное обращение в любом случае так себе идея, поскольку придётся отслеживать, с какими адресами памяти уже велась работа, а у меня есть желание значения valid
прокинуть по возможности статически. Именно так и будет сделано для глобальных переменных фиксированного размера.
В итоге получился абстрактный класс BpfCircuitConstructor
, имеющий не реализованные методы doMemLoad
, doMemStore
и resolveSymbol
:
trait BpfCircuitConstructor {
// ...
sealed abstract class LdStType(val lgsize: Int) {
val byteSize = 1 << lgsize
val bitSize = byteSize * 8
val mask: UInt = if (bitSize == 64) mask64 else ((1l << bitSize) - 1).U
}
case object u8 extends LdStType(0)
case object u16 extends LdStType(1)
case object u32 extends LdStType(2)
case object u64 extends LdStType(3)
def doMemLoad(addr: UInt, tpe: LdStType, valid: Bool): (UInt, Bool)
def doMemStore(addr: UInt, tpe: LdStType, data: UInt, valid: Bool): Bool
sealed trait Resolved {
def asPlainValue: UInt
def load(ctx: Context, offset: Int, tpe: LdStType, valid: Bool): LazyData
def store(offset: Int, tpe: LdStType, data: UInt, valid: Bool): Bool
}
def resolveSymbol(sym: BpfLoader.Symbol): Resolved
// ...
}
Интеграция с процессорным ядром
Я решил для начала пойти простым путём: подключиться к процессорному ядру по штатному протоколу RoCC (Rocket Custom Coprocessor). Насколько я понимаю, это штатное расширение не для всех RISC-V-совместимых ядер, а только для Rocket и BOOM (Berkeley Out-of-Order Machine), поэтому при затягивании в upstream наработок по компиляторам, из них были выкинуты ассемблерные мнемоники custom0
— custom3
, отвечающие за команды акселераторов.
В общем случае, у каждого процессорного ядра Rocket/BOOM может быть до четырёх RoCC ускорителей, добавляемых через конфиг, есть и примеры реализации:
Configs.scala:
class WithRoccExample extends Config((site, here, up) => {
case BuildRoCC => List(
(p: Parameters) => {
val accumulator = LazyModule(new AccumulatorExample(OpcodeSet.custom0, n = 4)(p))
accumulator
},
(p: Parameters) => {
val translator = LazyModule(new TranslatorExample(OpcodeSet.custom1)(p))
translator
},
(p: Parameters) => {
val counter = LazyModule(new CharacterCountExample(OpcodeSet.custom2)(p))
counter
})
})
Соответствующая реализация находится в файле LazyRoCC.scala
.
Реализация ускорителя представляет собой уже знакомые по контроллеру памяти два класса: один из них с данном случае наследуется от LazyRoCC
, другой — от LazyRoCCModuleImp
. Второй класс имеет порт io
типа RoCCIO
, содержащий внутри себя порт запросов cmd
, порт ответов resp
, порт доступа к L1D-кешу mem
, выходы busy
и interrupt
и вход exception
. Также есть порт page table walker и FPU, которые нам, вроде, пока не нужны (всё равно в eBPF нет вещественной арифметики). Пока что я хочу попробовать сделать с таким подходом хоть что-то, поэтому interrupt
я касаться не буду. Также там, насколько я понимаю, имеется TileLink-интерфейс для некешируемого доступа к памяти, но я его пока что трогать тоже не буду.
Упорядочиватель запросов
Итак, у нас есть порт для доступа к кешу, но только один. В то же время, функция может, например, инкрементировать какую-то переменную (что ещё худо-бедно можно превратить в одну атомарную операцию) или вообще как-то нетривиально её преобразовать, загрузив, обновив и сохранив. В конце концов, одна инструкция может делать несколько несвязанных запросов. Может, это и не самая лучшая идея с точки зрения производительности, но, с другой стороны, почему бы, скажем, не загрузить три слова (которые, вполне возможно, уже лежат в кеше), как-то их параллельно обработать комбинационной логикой (то есть за один такт) и сохранить результат. Поэтому нам нужна какая-то схема, эффективно «разруливающая» попытки параллельного доступа к единственному порту кеша.
Логика будет примерно следующая: в начале генерации реализации конкретной подынстукции (7-битное поле funct
в терминах RoCC) создаётся экземпляр сериализатора запросов (делать один глобальный видится мне довольно вредным, поскольку создаёт кучу лишних зависимостей между запросами, которые никогда не могут выполняться одновременно, а просаживать Fmax, скорее всего, будут). Далее каждый создаваемый «сохранятор»/«загружатор» регистрируется в сериализаторе. В порядке живой очереди, так сказать. На каждом такте выбирается первый в порядке регистрации выставленный запрос — ему и выдаётся разрешение на следующем такте. Естественно, такую логику нужно хорошенько обложить тестами (у меня их, правда, пока совсем не много, так что это не то, чтобы верификация, а так — минимально необходимый набор для получения хоть чего-то вразумительного). Я использовал стандартный PeekPokeTester
из более-менее официального компонента для тестирования чизелевских дизайнов. Его я уже когда-то описывал.
Получилась вот такая штуковина:
class Serializer(isComputing: Bool, next: Bool) {
def monotonic(x: Bool): Bool = {
val res = WireInit(false.B)
val prevRes = RegInit(false.B)
prevRes := res && isComputing
res := (x || prevRes) && isComputing
res
}
private def noone(bs: Seq[Bool]): Bool = !bs.foldLeft(false.B)(_ || _)
private val previousReqs = ArrayBuffer[Bool]()
def nextReq(x: Bool): (Bool, Int) = {
val enable = monotonic(x)
val result = RegInit(false.B)
val retired = RegInit(false.B)
val doRetire = result && next
val thisReq = enable && !retired && !doRetire
val reqWon = thisReq && noone(previousReqs)
when (isComputing) {
when(reqWon) {
result := true.B
}
when(doRetire) {
result := false.B
retired := true.B
}
} otherwise {
result := false.B
retired := false.B
}
previousReqs += thisReq
(result, previousReqs.length - 1)
}
}
Обратите внимание, что здесь в процессе создания цифровой схемы благополучно выполняется код на Scala. Если приглядеться, можно даже заметить ArrayBuffer
, в который складываются куски схемы (Boolean
— тип из Scala, Bool
— чизелевский тип, представляющий «живую аппаратуру», а не какой-то известный на этапе выполнения boolean).
Работа с L1D-кешом
Работа с кешом по большей части происходит через порт запросов io.mem.req
и порт ответов io.mem.resp
. При этом порт запросов оборудован традиционными сигналами ready
и valid
: первым кеш сообщает о готовности принять запрос, вторым мы говорим о том, что запрос готов и уже имеет корректную структуру, по фронту valid && resp
запрос считается принятым. В некоторых подобных интерфейсах есть требование «неотзывности» сигналов с момента выставления в true
и до последующего положительно фронта valid && resp
(это выражение для удобства можно сконструировать методом fire()
).
Порт ответов resp
, в свою очередь, имеет только признак valid
, и это уже проблемы процессора выгребать ответы за один такт: он по предположению «всегда готов», и fire()
возвращает просто valid
.
Также, как я уже говорил, нельзя выставлять запросы когда попало: нельзя писать то-не-знаю-что, да и читать заново то, что будет перезаписано позже на основе вычитанного значения тоже как-то странно. Но с этим уже разбирается класс Serializer
, мы же ему только отдаём признак того, что текущий запрос уже ушёл в кеш: next = io.mem.req.fire()
. Остаётся разве что следить, чтобы в «читателе» ответ обновлялся только, когда он реально пришёл — не раньше и не позже. Для этого есть удобный метод holdUnless
. В итоге получается примерно следующая реализация:
class Constructor extends BpfCircuitConstructor {
val serializer = new Serializer(isComputing, io.mem.req.fire())
override def doMemLoad(addr: UInt, tpe: LdStType, valid: Bool): (UInt, Bool) = {
val (doReq, thisTag) = serializer.nextReq(valid)
when (doReq) {
io.mem.req.bits.addr := addr
require((1 << io.mem.req.bits.tag.getWidth) > thisTag)
io.mem.req.bits.tag := thisTag.U
io.mem.req.bits.cmd := M_XRD
io.mem.req.bits.typ := (4 | tpe.lgsize).U
io.mem.req.bits.data := 0.U
io.mem.req.valid := true.B
}
val doResp = isComputing &&
serializer.monotonic(doReq && io.mem.req.fire()) &&
io.mem.resp.valid &&
io.mem.resp.bits.tag === thisTag.U &&
io.mem.resp.bits.cmd === M_XRD
(io.mem.resp.bits.data holdUnless doResp, serializer.monotonic(doResp))
}
override def doMemStore(addr: UInt, tpe: LdStType, data: UInt, valid: Bool): Bool = {
val (doReq, thisTag) = serializer.nextReq(valid)
when (doReq) {
io.mem.req.bits.addr := addr
require((1 << io.mem.req.bits.tag.getWidth) > thisTag)
io.mem.req.bits.tag := thisTag.U
io.mem.req.bits.cmd := M_XWR
io.mem.req.bits.typ := (4 | tpe.lgsize).U
io.mem.req.bits.data := data
io.mem.req.valid := true.B
}
serializer.monotonic(doReq && io.mem.req.fire())
}
override def resolveSymbol(sym: BpfLoader.Symbol): Resolved = sym match {
case BpfLoader.Symbol(symName, _, size, ElfConstants.Elf64_Shdr.SHN_COMMON, false) if size <= 8 =>
RegisterReference(regs.getOrElseUpdate(symName, RegInit(0.U(64.W))))
}
}
Экземпляр этого класса создаётся для каждой генерируемой подынструкции.
Не всё то в куче, что глобальная переменная
Хм, а каков модельный пример? Работоспособность чего я хотел бы обеспечить? Конечно, инструментации AFL! Выглядит в классическом варианте она примерно так:
#include
extern uint8_t *__afl_area_ptr;
extern uint64_t prev;
void inst_branch(uint64_t tag)
{
__afl_area_ptr[((prev >> 1) ^ tag) & 0xFFFF] += 1;
prev = tag;
}
Как можно заметить, в ней есть более-менее логичные загрузка и сохранение (а между ними — инкремент) одного байта из __afl_area_ptr
, но вот на роль prev
прямо-таки напрашивается регистр!
Вот для этого и нужен интерфейс Resolved
: он может как оборачивать обычный адрес памяти, так и являться ссылкой на регистр. При этом, пока что я рассматриваю только скалярные регистры размером 1, 2, 4 или 8 байт, читаемые всегда по нулевому смещению, поэтому для регистров можно относительно спокойно реализовать упорядоченность обращений. В данном случае весьма полезно знать, что prev
сначала должен быть вычитан и использован для вычисления индекса, и лишь потом перезаписан.
А теперь инструментация
В какой-то момент получился отдельно лежащий и более-менее работающий ускоритель с интерфейсом RoCC. Что же теперь? Заново реализовывать всё то же самое, продираясь через конвейер процессора? Мне показалось, что потребуется меньше костылей, если параллельно с инструментируемой инструкцией просто будет активироваться сопроцессор с автоматически выданным служебным значением funct
. В принципе, для этого тоже пришлось помучиться: я даже научился пользоваться SignalTap, потому что отладка почти в слепую, да ещё и с пятиминутной перекомпиляцией после малейшего изменения (за исключением изменения bootrom — там всё быстро) — это уже слишком.
В итоге был подправлен декодер команд и слегка «подрихтован» конвейер для учёта того факта, что что бы ни говорил декодер про оригинальную инструкцию, сам по себе внезапно активировавшийся RoCC ещё не означает, что будет long latency запись в выходной регистр, как при операции деления и промахе кеша данных.
В общем случае, описание инструкции — это пара ([паттерн для распознавания инструкции], [набор значений, конфигурирующий блоки data path процессорного ядра]). Например, default
(нераспознанная иструкция) выглядит так (взято как есть из IDecode.scala
, в десктопном Хабре выглядит, прямо скажем, некрасиво):
def default: List[BitPat] =
// jal renf1 fence.i
// val | jalr | renf2 |
// | fp_val| | renx2 | | renf3 |
// | | rocc| | | renx1 s_alu1 mem_val | | | wfd |
// | | | br| | | | s_alu2 | imm dw alu | mem_cmd mem_type| | | | mul |
// | | | | | | | | | | | | | | | | | | | | | div | fence
// | | | | | | | | | | | | | | | | | | | | | | wxd | | amo
// | | | | | | | | scie | | | | | | | | | | | | | | | | | dp
List(N,X,X,X,X,X,X,X,X,A2_X, A1_X, IMM_X, DW_X, FN_X, N,M_X, MT_X, X,X,X,X,X,X,X,CSR.X,X,X,X,X)
…, а типичное описание одного из расширений в Rocket core реализуется примерно так:
class IDecode(implicit val p: Parameters) extends DecodeConstants
{
val table: Array[(BitPat, List[BitPat])] = Array(
BNE-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SNE, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
BEQ-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SEQ, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
BLT-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SLT, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
BLTU-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SLTU, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
BGE-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SGE, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
BGEU-> List(Y,N,N,Y,N,N,Y,Y,N,A2_RS2, A1_RS1, IMM_SB,DW_X, FN_SGEU, N,M_X, MT_X, N,N,N,N,N,N,N,CSR.N,N,N,N,N),
// ...
Дело в том, что в RISC-V (не только в RocketChip, а в архитектуре команд в принципе) штатно поддерживается разбиение ISA на обязательное подмножество I (целочисленные операции), а также необязательные M (целочисленное умножение и деление), A (atomics) и т.д.
В итоге изначальный метод
def decode(inst: UInt, table: Iterable[(BitPat, List[BitPat])]) = {
val decoder = DecodeLogic(inst, default, table)
val sigs = Seq(legal, fp, rocc, branch, jal, jalr, rxs2, rxs1, scie, sel_alu2,
sel_alu1, sel_imm, alu_dw, alu_fn, mem, mem_cmd, mem_type,
rfs1, rfs2, rfs3, wfd, mul, div, wxd, csr, fence_i, fence, amo, dp)
sigs zip decoder map {case(s,d) => s := d}
this
}
был заменён на
def decode(inst: UInt, table: Iterable[(BitPat, List[BitPat])], handlers: Seq[OpcodeHandler]) = {
val decoder = DecodeLogic(inst, default, table)
val sigs=Seq(legal, fp, rocc_explicit, branch, jal, jalr, rxs2, rxs1, scie, sel_alu2,
sel_alu1, sel_imm, alu_dw, alu_fn, mem, mem_cmd, mem_type,
rfs1, rfs2, rfs3, wfd, mul, div, wxd, csr, fence_i, fence, amo, dp)
sigs zip decoder map {case(s,d) => s := d}
if (handlers.isEmpty) {
handler_rocc := false.B
handler_rocc_funct := 0.U
} else {
val handlerTable: Seq[(BitPat, List[BitPat])] = handlers.map {
case OpcodeHandler(pattern, funct) => pattern -> List(Y, BitPat(funct.U))
}
val handlerDecoder = DecodeLogic(inst, List(N, BitPat(0.U)), handlerTable)
Seq(handler_rocc, handler_rocc_funct) zip handlerDecoder map { case (s,d) => s:=d }
}
rocc := rocc_explicit || handler_rocc
this
}
Из изменений в конвейере процессора самым неочевидным, пожалуй, оказалось это:
io.rocc.exception := wb_xcpt && csr.io.status.xs.orR
io.rocc.cmd.bits.status := csr.io.status
io.rocc.cmd.bits.inst := new RoCCInstruction().fromBits(wb_reg_inst)
+ when (wb_ctrl.handler_rocc) {
+ io.rocc.cmd.bits.inst.opcode := 0x0b.U // custom0
+ io.rocc.cmd.bits.inst.funct := wb_ctrl.handler_rocc_funct
+ io.rocc.cmd.bits.inst.xd := false.B
+ io.rocc.cmd.bits.inst.rd := 0.U
+ }
io.rocc.cmd.bits.rs1 := wb_reg_wdata
io.rocc.cmd.bits.rs2 := wb_reg_rs2
Понятно, что нужно поправить некоторые параметры запроса к ускорителю: запись в регистр ответа не производится, а funct
равен тому, что вернул декодер. Но есть и чуть менее очевидное изменение: дело в том, что эта команда уходит не непосредственно в ускоритель (их же четыре — в который из?), а в роутер, поэтому нужно сделать вид, что команда имеет opcode == custom0
(да, обрабатывать, причём именно нулевым ускорителем!).
Проверка
На самом деле, у этой статьи предполагается продолжение, в котором будет сделана попытка довести этот подход до более-менее production-уровня. Как минимум, надо научиться сохранять и восстанавливать контекст (состояние регистров сопроцессора) при переключении задач. Пока же проверю, что оно хоть как-то работает в тепличных условиях:
#include
uint64_t counter;
uint64_t funct1(uint64_t x, uint64_t y)
{
return __builtin_popcountl(x);
}
uint64_t funct2(uint64_t x, uint64_t y)
{
return (x + y) * (x - y);
}
uint64_t instMUL()
{
counter += 1;
*((uint64_t *)0x81005000) = counter;
return 0;
}
Теперь добавим в bootrom/sdboot/sd.c
в main
строчки
#include "/path/to/freedom-u-sdk/riscv-pk/machine/encoding.h"
// ...
//// Целиком взято из какой-то документации по RoCC
#define STR1(x) #x
#define STR(x) STR1(x)
#define EXTRACT(a, size, offset) (((~(~0 << size) << offset) & a) >> offset)
#define CUSTOMX_OPCODE(x) CUSTOM_##x
#define CUSTOM_0 0b0001011
#define CUSTOM_1 0b0101011
#define CUSTOM_2 0b1011011
#define CUSTOM_3 0b1111011
#define CUSTOMX(X, rd, rs1, rs2, funct) \
CUSTOMX_OPCODE(X) | \
(rd << (7)) | \
(0x7 << (7+5)) | \
(rs1 << (7+5+3)) | \
(rs2 << (7+5+3+5)) | \
(EXTRACT(funct, 7, 0) << (7+5+3+5+5))
#define CUSTOMX_R_R_R(X, rd, rs1, rs2, funct) \
asm ("mv a4, %[_rs1]\n\t" \
"mv a5, %[_rs2]\n\t" \
".word "STR(CUSTOMX(X, 15, 14, 15, funct))"\n\t" \
"mv %[_rd], a5" \
: [_rd] "=r" (rd) \
: [_rs1] "r" (rs1), [_rs2] "r" (rs2) \
: "a4", "a5");
int main(void)
{
// ...
// Включаем RoCC extension
write_csr(mstatus, MSTATUS_XS & (MSTATUS_XS >> 1));
// Кладём в bootrom последовательность инструкций для экспериментов в отладчике
uint64_t res;
CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1);
CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 2);
// ... и для тестирования инструментации
uint64_t x = 1;
for (int i = 0; i < 123; ++i) x *= *(volatile uint8_t *)0x80000000;
kputc('0' + x % 10); // ОПТИМИЗАТОР НЕ КОРМИТЬ!!!
// ...
}
Вызов write_csr
нужен, чтобы включить обработку расширений custom0
-custom3
. Без этого можно долго пытаться понять, почему ловится illegal instruction, отлаживать ускоритель, а он, оказывается, просто явно отключен. Пляски с define
-ами нужны по большей части из-за того, что при «заапстримливании» binutils мнемоники customX
были выкинуты как специфичные для RocketChip, поэтому байты, соответствующие этим инструкциям, приходится генерировать вручную.
Поскольку стандартная библиотека в sdboot довольно урезанная, я просто положил необходимые инструкции в код, чтобы переходить на них в отладчике.
Тестируем инструментацию:
$ /hdd/trosinenko/rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set directories bootrom" builds/zeowaa-e115/sdboot.elf
Reading symbols from builds/zeowaa-e115/sdboot.elf...done.
Remote debugging using :3333
0x0000000000000000 in ?? ()
(gdb) x/d 0x81005000
0x81005000: 123
(gdb) set variable $pc=0x10000
(gdb) c
Continuing.
^C
Program received signal SIGINT, Interrupt.
0x0000000000010488 in crc16_round (data=, crc=) at sd.c:151
151 crc ^= data;
(gdb) x/d 0x81005000
0x81005000: 246
$ /hdd/trosinenko/rocket-tools/bin/riscv32-unknown-elf-gdb -q -ex "target remote :3333" -ex "set directories bootrom" builds/zeowaa-e115/sdboot.elf
Reading symbols from builds/zeowaa-e115/sdboot.elf...done.
Remote debugging using :3333
0x0000000000010194 in main () at sd.c:247
247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1);
(gdb) set variable $a5=0
(gdb) set variable $pc=0x10194
(gdb) set variable $a4=0xaa
(gdb) display/10i $pc-10
1: x/10i $pc-10
0x1018a : sw a3,124(a3)
0x1018c : addiw a0,a0,1110
0x10190 : mv a4,s0
0x10192 : mv a5,a0
=> 0x10194 : 0x2f7778b
0x10198 : mv s0,a5
0x1019a : lbu a5,0(a1)
0x1019e : addiw a3,a3,-1
0x101a0 : mul a2,a2,a5
0x101a4 : bnez a3,0x1019a
(gdb) display/x $a5
2: /x $a5 = 0x0
(gdb) si
0x0000000000010198 247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1);
1: x/10i $pc-10
0x1018e : li a0,25
0x10190 : mv a4,s0
0x10192 : mv a5,a0
0x10194 : 0x2f7778b
=> 0x10198 : mv s0,a5
0x1019a : lbu a5,0(a1)
0x1019e : addiw a3,a3,-1
0x101a0 : mul a2,a2,a5
0x101a4 : bnez a3,0x1019a
0x101a6 : li a5,10
2: /x $a5 = 0x4
(gdb) set variable $a4=0xaabc
(gdb) set variable $pc=0x10194
(gdb) si
0x0000000000010198 247 CUSTOMX_R_R_R(0, res, 0xabcdef, 0x123456, 1);
1: x/10i $pc-10
0x1018e : li a0,25
0x10190 : mv a4,s0
0x10192 : mv a5,a0
0x10194 : 0x2f7778b
=> 0x10198 : mv s0,a5
0x1019a : lbu a5,0(a1)
0x1019e : addiw a3,a3,-1
0x101a0 : mul a2,a2,a5
0x101a4 : bnez a3,0x1019a
0x101a6 : li a5,10
2: /x $a5 = 0x9
Исходный код