В предыдущих четырёх частях велась подготовка к экспериментам с 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 <stdint.h> 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 <stdint.h> 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=<optimized out>, crc=<optimized out>) 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 <main+46>: sw a3,124(a3) 0x1018c <main+48>: addiw a0,a0,1110 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 => 0x10194 <main+56>: 0x2f7778b 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> (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 <main+50>: li a0,25 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 0x10194 <main+56>: 0x2f7778b => 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> 0x101a6 <main+74>: 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 <main+50>: li a0,25 0x10190 <main+52>: mv a4,s0 0x10192 <main+54>: mv a5,a0 0x10194 <main+56>: 0x2f7778b => 0x10198 <main+60>: mv s0,a5 0x1019a <main+62>: lbu a5,0(a1) 0x1019e <main+66>: addiw a3,a3,-1 0x101a0 <main+68>: mul a2,a2,a5 0x101a4 <main+72>: bnez a3,0x1019a <main+62> 0x101a6 <main+74>: li a5,10 2: /x $a5 = 0x9
ссылка на оригинал статьи https://habr.com/ru/post/461577/
Добавить комментарий