Хабрахабр

Часть 5/2 корп. 1: Перекрёсток проспекта RocketChip и скользкой дорожки инструментации

Наконец, в прошлой части на этой плате получилось запустить Linux. В предыдущих четырёх частях велась подготовка к экспериментам с RISC-V ядром RocketChip, а именно, портирование этого ядра на «нестандартную» для него плату с ПЛИС фирмы Altera (теперь уже Intel). То, что одновременно приходилось работать с ассемблером RISC-V, C и Scala, и из всех них Scala была самым низкоуровневым языком (потому что именно на ней написан процессор). Знаете, что меня во всём этом забавляло?

Более того, если связка Scala+Chisel использовалась лишь как domain-specific language для явного описания аппаратуры, то сегодня мы научимся «затягивать» простенькие функции на C в процессор в виде инструкций. Давайте в этой статье сделаем так, чтобы C тоже не было обидно.

Конечная же цель — тривиальная реализация тривиальных AFL-like инструментаций по аналогии с QInst, а реализация отдельностоящих инструкций — лишь побочный продукт.

Также попадалась информация про некий проект COPILOT для RISC-V с похожими целями (намного более продвинутый), но что-то гуглится плохо, к тому же, это, скорее всего, тоже коммерческий продукт. Понятно, что существует (и не один) коммерческий конвертер OpenCL в RTL. Меня же интересуют в первую очередь OpenSource-решения, но даже если они есть, всё равно забавно попробовать реализовать такое самому — хотя бы как максимально упрощенный учебный пример, а там уж как получится...

Ну а про то, что данные могут «побиться», думаю, и так понятно. Disclaimer (в дополнение к обычному предупреждению о «плясках с огнетушителем»): настоятельно не рекомендую бездумно применять получившееся софтовое ядро, особенно, с недоверенными данными — пока что у меня нет не то, что уверенности, а даже понимания, почему обрабатываемые данные не могут «перетечь» в каком-нибудь граничном случае между процессами и/или ядром. В общем, тут ещё валидировать и валидировать...

Для целей этой статьи под этим подразумевается функция, при выполнении которой все переходы (условные и безусловные) только увеличивают счётчик команд на константное значение. Для начала, что я называю «простенькой функцией»? Конечная цель в рамках этой статьи — иметь возможность взять простую функцию из программы и, заменив её ассемблерной заглушкой, «зашить» в процессор на этапе синтеза, опционально сделав её side effect-ом выполнения другой инструкции. То есть граф всех возможных переходов является (направленным) ациклическим, без «динамических» рёбер. Конкретно в этой статье ветвления показаны не будут, но в простейшем случае сделать их не составит труда.

Учимся понимать C (на самом деле, нет)

Правильно, никак — не зря же я учился парсить ELF-файлы: нужно просто скомпилировать наш код на C / Rust / чём-то ещё в eBPF-байткод, и парсить уже его. Для начала надо понять, как мы будем парсить C? Можно, конечно, было бы попробовать использовать JNAerator — им при необходимости можно делать биндинги к сишной библиотеке — не только структуры, но и генерировать код для работы через JNA (не путать с JNI). Некоторые затруднения вызывает то, что в Scala нельзя просто подключить elf.h и вычитывать поля структуры. Результат и промежуточные структуры описываются следующей структурой 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]
)

ByteBuffer — всё интересное уже было описано в статье про разбор ELF-файлов. Процесс парсинга также описывать особо не буду — это всего лишь унылое перекладывание байтов из java.nio. Например, __builtin_popcountl честно использует 64-битную константу 0x0101010101010101. Скажу лишь о том, что нужно аккуратно обрабатывать opcode == 0x18 (загрузка в регистр 64-bit immediate значения), поскольку он занимает сразу два 8-байтных слова (может, есть и другие такие опкоды, но я на них пока не натыкался), причём это не всегда загрузка адреса памяти, связанная с релокацией, как я думал изначально. Почему я не делаю «честную» релокацию с патчингом загруженного файла — потому что хочется видеть символы в символьном виде (извините за каламбур), чтобы потом символы из секции 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) 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
// ...
}

Интеграция с процессорным ядром

Насколько я понимаю, это штатное расширение не для всех RISC-V-совместимых ядер, а только для Rocket и BOOM (Berkeley Out-of-Order Machine), поэтому при затягивании в upstream наработок по компиляторам, из них были выкинуты ассемблерные мнемоники custom0custom3, отвечающие за команды акселераторов. Я решил для начала пойти простым путём: подключиться к процессорному ядру по штатному протоколу RoCC (Rocket Custom Coprocessor).

В общем случае, у каждого процессорного ядра 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.

Второй класс имеет порт io типа RoCCIO, содержащий внутри себя порт запросов cmd, порт ответов resp, порт доступа к L1D-кешу mem, выходы busy и interrupt и вход exception. Реализация ускорителя представляет собой уже знакомые по контроллеру памяти два класса: один из них с данном случае наследуется от LazyRoCC, другой — от LazyRoCCModuleImp. Пока что я хочу попробовать сделать с таким подходом хоть что-то, поэтому interrupt я касаться не буду. Также есть порт page table walker и FPU, которые нам, вроде, пока не нужны (всё равно в eBPF нет вещественной арифметики). Также там, насколько я понимаю, имеется 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) }
}

Если приглядеться, можно даже заметить ArrayBuffer, в который складываются куски схемы (Boolean — тип из Scala, Bool — чизелевский тип, представляющий «живую аппаратуру», а не какой-то известный на этапе выполнения boolean). Обратите внимание, что здесь в процессе создания цифровой схемы благополучно выполняется код на Scala.

Работа с L1D-кешом

При этом порт запросов оборудован традиционными сигналами ready и valid: первым кеш сообщает о готовности принять запрос, вторым мы говорим о том, что запрос готов и уже имеет корректную структуру, по фронту valid && resp запрос считается принятым. Работа с кешом по большей части происходит через порт запросов io.mem.req и порт ответов io.mem.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 прямо-таки напрашивается регистр!

При этом, пока что я рассматриваю только скалярные регистры размером 1, 2, 4 или 8 байт, читаемые всегда по нулевому смещению, поэтому для регистров можно относительно спокойно реализовать упорядоченность обращений. Вот для этого и нужен интерфейс Resolved: он может как оборачивать обычный адрес памяти, так и являться ссылкой на регистр. В данном случае весьма полезно знать, что prev сначала должен быть вычитан и использован для вычисления индекса, и лишь потом перезаписан.

А теперь инструментация

Что же теперь? В какой-то момент получился отдельно лежащий и более-менее работающий ускоритель с интерфейсом RoCC. Мне показалось, что потребуется меньше костылей, если параллельно с инструментируемой инструкцией просто будет активироваться сопроцессор с автоматически выданным служебным значением funct. Заново реализовывать всё то же самое, продираясь через конвейер процессора? В принципе, для этого тоже пришлось помучиться: я даже научился пользоваться SignalTap, потому что отладка почти в слепую, да ещё и с пятиминутной перекомпиляцией после малейшего изменения (за исключением изменения bootrom — там всё быстро) — это уже слишком.

В итоге был подправлен декодер команд и слегка «подрихтован» конвейер для учёта того факта, что что бы ни говорил декодер про оригинальную инструкцию, сам по себе внезапно активировавшийся RoCC ещё не означает, что будет long latency запись в выходной регистр, как при операции деления и промахе кеша данных.

Например, default (нераспознанная иструкция) выглядит так (взято как есть из IDecode.scala, в десктопном Хабре выглядит, прямо скажем, некрасиво): В общем случае, описание инструкции — это пара ([паттерн для распознавания инструкции], [набор значений, конфигурирующий блоки data path процессорного ядра]).

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
}

был заменён на

такой же, но с декодером для инструментации и уточнением причины активации rocc

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

Но есть и чуть менее очевидное изменение: дело в том, что эта команда уходит не непосредственно в ускоритель (их же четыре — в который из?), а в роутер, поэтому нужно сделать вид, что команда имеет opcode == custom0 (да, обрабатывать, причём именно нулевым ускорителем!). Понятно, что нужно поправить некоторые параметры запроса к ускорителю: запись в регистр ответа не производится, а funct равен тому, что вернул декодер.

Проверка

Как минимум, надо научиться сохранять и восстанавливать контекст (состояние регистров сопроцессора) при переключении задач. На самом деле, у этой статьи предполагается продолжение, в котором будет сделана попытка довести этот подход до более-менее 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); // ОПТИМИЗАТОР НЕ КОРМИТЬ!!!
// ...
}

Без этого можно долго пытаться понять, почему ловится illegal instruction, отлаживать ускоритель, а он, оказывается, просто явно отключен. Вызов write_csr нужен, чтобы включить обработку расширений custom0-custom3. Пляски с 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

Тестируем funct1

$ /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

Исходный код

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть