Часть 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 наработок по компиляторам, из них были выкинуты ассемблерные мнемоники custom0custom3, отвечающие за команды акселераторов.

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

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

такой же, но с декодером для инструментации и уточнением причины активации 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

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

Тестируем 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

Исходный код


ссылка на оригинал статьи https://habr.com/ru/post/461577/


Комментарии

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

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