Как стать автором
Обновить

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

Время на прочтение17 мин
Количество просмотров1.5K

В предыдущих четырёх частях велась подготовка к экспериментам с 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 нужен, чтобы включить обработку расширений 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

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

Исходный код

Теги:
Хабы:
Всего голосов 19: ↑19 и ↓0+19
Комментарии0

Публикации

Истории

Работа

Программист С
32 вакансии
Scala разработчик
10 вакансий

Ближайшие события

7 – 8 ноября
Конференция byteoilgas_conf 2024
МоскваОнлайн
7 – 8 ноября
Конференция «Матемаркетинг»
МоскваОнлайн
15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань