Parte 5/2 Edif. 1: Crossroads of RocketChip Avenue y pista de instrumentación resbaladiza

En las cuatro partes anteriores, se hicieron preparativos para experimentos con el núcleo RISC-V RocketChip, es decir, portar este núcleo a una placa "no estándar" para ello con Altera FPGA (ahora Intel). Finalmente, en la última parte , resultó ejecutar Linux en esta placa. ¿Sabes lo que me divirtió de todo esto? El hecho de que al mismo tiempo tuve que trabajar con el ensamblador RISC-V, C y Scala, y de todos ellos, Scala era el lenguaje de nivel más bajo (porque el procesador está escrito en él).


Hagamos que C no sea ofensivo en este artículo tampoco. Además, si el paquete Scala + Chisel se usó solo como un lenguaje específico de dominio para una descripción explícita del hardware, hoy aprenderemos cómo "extraer" funciones C simples en el procesador en forma de instrucciones.


El objetivo final es la implementación trivial de instrumentaciones triviales similares a AFL por analogía con QInst , y la implementación de instrucciones independientes es solo un subproducto.


Está claro que hay (y no uno) convertidor comercial OpenCL a RTL. También encontré información sobre un determinado proyecto COPILOT para RISC-V con objetivos similares (mucho más avanzado), pero algo está buscando en Google mal y, además, lo más probable es que también sea un producto comercial. Estoy principalmente interesado en las soluciones OpenSource, pero incluso si lo son, sigue siendo divertido intentar implementarlo usted mismo, al menos como un ejemplo de capacitación simplificado, y luego cómo funciona ...


Descargo de responsabilidad (además de la advertencia habitual sobre “bailar con un extintor de incendios”): no recomiendo aplicar imprudentemente el núcleo del software resultante, especialmente con datos no confiables, hasta ahora no tengo tanta confianza como para entender por qué los datos que se procesan no pueden "Flujo" en algún caso límite entre procesos y / o el núcleo. Bueno, sobre el hecho de que los datos pueden "vencer", creo, y está claro. En general, todavía hay validación y validación ...


Para empezar, ¿a qué llamo una "función simple"? Para los propósitos de este artículo, esto significa una función en la que todas las transiciones (condicional e incondicional) solo aumentan el contador de instrucciones en un valor constante. Es decir, el gráfico de todas las transiciones posibles es (dirigido) acíclico, sin aristas "dinámicas". El objetivo final en el marco de este artículo es poder tomar una función simple del programa y, al reemplazarlo con un complemento de ensamblador, "coserlo" en el procesador en la etapa de síntesis, opcionalmente convirtiéndolo en un efecto secundario de otra instrucción. Específicamente, la ramificación no se mostrará en este artículo, pero en el caso más simple no será difícil hacerlas.


Aprendiendo a entender C (en realidad, no)


Primero necesitas entender cómo analizaremos C? Así es, de ninguna manera, no fue en vano que aprendí a analizar archivos ELF : solo necesita compilar nuestro código en C / Rust / algo más en un código de bytes eBPF, y analizarlo ya. Algunas dificultades son causadas por el hecho de que en Scala no puede simplemente conectar elf.h y leer los campos de estructura. Podría, por supuesto, intentar usar JNAerator , si es necesario, pueden hacer carpetas para la biblioteca, no solo estructuras, sino también generar código para trabajar a través de JNA (que no debe confundirse con JNI). Como programador real, escribiré mi bicicleta y cuidadosamente escribiré las enumeraciones y las constantes de desplazamiento del archivo de encabezado. El resultado y las estructuras intermedias se describen mediante la siguiente estructura de clases de casos:


 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] ) 

No describiré particularmente el proceso de análisis, esto es solo una transferencia de bytes aburrida de java.nio.ByteBuffer , todas las cosas interesantes ya se han descrito en el artículo sobre análisis de archivos ELF . Solo puedo decir que necesita manejar cuidadosamente opcode == 0x18 (cargar valores inmediatos de 64 bits en el registro), ya que ocupa dos palabras de 8 bytes a la vez (tal vez hay otros opcode == 0x18 , pero aún no los he encontrado) , y esto no siempre está cargando la dirección de memoria asociada con la reubicación, como pensé inicialmente. Por ejemplo, __builtin_popcountl usa honestamente la constante de 64 bits 0x0101010101010101 . ¿Por qué no estoy haciendo una reubicación "honesta" con el parcheo del archivo descargado, porque quiero ver los caracteres en forma simbólica (perdón por el juego de palabras), para que luego los caracteres de la sección COMMON puedan ser reemplazados por registros sin usar muletas con un manejo especial de direcciones de un tipo especial (y significa, incluso con bailes con UInt constante / no constante).


Construimos hardware de acuerdo con un conjunto de instrucciones


Por lo tanto, por supuesto, todas las rutas de ejecución posibles van exclusivamente por la lista de instrucciones, lo que significa que los datos fluyen a lo largo de un gráfico acíclico orientado, y todos sus bordes están definidos estáticamente. Al mismo tiempo, tenemos una lógica puramente combinacional (es decir, sin registros en el camino), obtenida de las operaciones en los registros, así como los retrasos durante las operaciones de carga / almacenamiento con memoria. Por lo tanto, en el caso general, la operación puede no ser posible completar en un ciclo de reloj. Haremos lo simple: transferiremos el valor a en forma de UInt , pero al igual que (UInt, Bool) : el primer elemento del par es el valor, y el segundo es un signo de su corrección. Es decir, no tiene mucho sentido leer de memoria, siempre y cuando la dirección sea incorrecta y la escritura en general sea imposible.


El modelo de ejecución de código de bytes eBPF supone algún tipo de RAM con direccionamiento de 64 bits, así como un conjunto de 16 (o incluso diez) registros de 64 bits. Se propone un algoritmo recursivo primitivo:


  • comenzamos con el contexto en el que los operandos de las instrucciones se encuentran en r1 y r2 , en el resto - ceros, todos válidos (más precisamente, la validez es igual a la "preparación" de la instrucción del coprocesador)
  • si vemos una instrucción aritmética-lógica, extraemos sus registros de operandos del contexto, nos llamamos a la cola de la lista y al contexto en el que el operando de salida se reemplaza por un par (data1 op data2, valid1 && valid2)
  • si encontramos una rama, simplemente construimos ambas ramas de forma recursiva: si se produce la rama, y ​​si no
  • si nos encontramos cargando o guardando en la memoria, de alguna manera salimos: ejecutamos la devolución de llamada transferida, asumiendo la invariante que una vez que la declaración valid no se puede recuperar durante la ejecución de esta instrucción. La validez de la operación de guardar es Y por nosotros con el indicador globalValid , que debe establecerse antes de devolver el control. Al mismo tiempo, debemos leer y escribir a lo largo del frente para procesar correctamente los incrementos y otras modificaciones.

Por lo tanto, las operaciones se realizarán lo más paralelas posible, y no en pasos. Al mismo tiempo, le pido que preste atención a que todas las operaciones en un byte de memoria específico deben ordenarse por completo, de lo contrario el resultado es impredecible, UB. Es decir *addr += 1 : esto es normal, la escritura no comenzará exactamente hasta que se complete la lectura (cursi porque todavía no sabemos qué escribir), pero *addr += 1; return *addr; *addr += 1; return *addr; Generalmente di cero o algo así con seguridad. Tal vez valga la pena depurarlo (tal vez oculte algún problema más complicado), pero tal atractivo en sí mismo es, en cualquier caso, una idea regular, ya que debe realizar un seguimiento de qué direcciones de memoria ya se ha realizado el trabajo, pero tengo un deseo Valide los valores valid posible estáticamente. Esto es exactamente lo que se hará para las variables globales de tamaño fijo.


El resultado es una clase abstracta BpfCircuitConstructor , que no tiene métodos implementados doMemLoad , doMemStore y 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 // ... } 

Integración del núcleo de la CPU


Para empezar, decidí seguir el camino simple: conectarme al núcleo del procesador utilizando el protocolo estándar RoCC (Rocket Custom Coprocessor). Según tengo entendido, esta es una extensión regular, no para todos los núcleos compatibles con RISC-V, sino solo para Rocket y BOOM (Berkeley Out-of-Order Machine), por lo tanto, al arrastrar el trabajo ascendente en los compiladores, el ensamblador custom0 mnemónicos fueron expulsados ​​de ellos. custom3 responsable de los comandos del acelerador.


En general, cada núcleo de procesador Rocket / BOOM puede tener hasta cuatro aceleradores RoCC agregados a través de la configuración, también hay ejemplos de implementación:


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 }) }) 

La implementación correspondiente está en el archivo LazyRoCC.scala .


La implementación del acelerador representa dos clases ya conocidas del controlador de memoria: una de ellas en este caso se hereda de LazyRoCC , la otra de LazyRoCCModuleImp . La segunda clase tiene un puerto io de tipo RoCCIO , que contiene el puerto de solicitud cmd , el puerto de respuesta resp , el puerto de caché L1D de acceso mem, salidas busy e interrupt , y la entrada de exception . También hay un puerto de tabla de páginas y FPU que parece que todavía no necesitamos (de todos modos, no hay una aritmética real en eBPF). Hasta ahora quiero intentar hacer algo con este enfoque, así que no tocaré interrupt . Además, según tengo entendido, hay una interfaz TileLink para acceso a memoria no almacenada en caché, pero por ahora tampoco la tocaré.


Organizador de consultas


Entonces, tenemos un puerto para acceder al caché, pero solo uno. Al mismo tiempo, una función puede, por ejemplo, incrementar una variable (que, como mínimo, puede convertirse en una sola operación atómica) o incluso transformarla de alguna manera no trivial al cargarla, actualizarla y guardarla. Al final, una sola instrucción puede hacer varias solicitudes no relacionadas. Quizás esta no sea la mejor idea en términos de rendimiento, pero, por otro lado, ¿por qué no, por ejemplo, cargar tres palabras (que, posiblemente, ya están en el caché), de alguna manera procesarlas en paralelo con la lógica combinacional (entonces tener un latido) y guardar el resultado. Por lo tanto, necesitamos algún tipo de esquema que efectivamente "resuelva" los intentos de acceso paralelo a un solo puerto de caché.


La lógica será aproximadamente la siguiente: al comienzo de la generación de la implementación de una subinyección específica (un campo de función de 7 bits en términos de RoCC), se crea una instancia del serializador de consultas (hacer un global parece bastante perjudicial para mí, porque crea un montón de dependencias adicionales entre solicitudes que nunca se pueden ejecutar al mismo tiempo, y derroche Fmax lo más probable). A continuación, cada "protector" / "cargador" creado se registra en el serializador. En una cola en vivo, por así decirlo. En cada medida, se selecciona la primera solicitud en el orden de registro; se le da permiso en la siguiente medida . Naturalmente, dicha lógica debe estar bien cubierta con pruebas (realmente todavía no tengo muchas, así que esto no es solo una verificación, sino el conjunto mínimo necesario para obtener al menos algo inteligible). Utilicé el PeekPokeTester estándar de un componente más o menos oficial para probar los diseños de Chisel. Ya lo describí una vez .


El resultado fue tal artilugio:


 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) } } 

Tenga en cuenta que aquí, en el proceso de creación de un circuito digital, el código Scala se ejecuta de forma segura. Si observa más de cerca, incluso puede observar un ArrayBuffer en el que se apilan las piezas del circuito ( Boolean es un tipo de Scala, Bool es un tipo Chisel que representa equipo en vivo, y no algún booleano conocido en tiempo de ejecución).


Trabajando con caché L1D


El trabajo con el caché se realiza principalmente a través del io.mem.req solicitud io.mem.req y el io.mem.resp respuesta io.mem.resp . Al mismo tiempo, el puerto de solicitud está equipado con las señales tradicionales ready y valid : la primera le dice que está lista para aceptar la solicitud, la segunda le decimos que la solicitud está lista y que ya tiene la estructura correcta, a lo largo de la parte delantera, valid && resp se considera aceptada. En algunas de estas interfaces, existe el requisito de "no respuesta" de las señales desde el momento de la configuración en true y hasta el borde positivo posterior de valid && resp (esta expresión se puede construir utilizando el método fire() por conveniencia).


El puerto de respuesta de respuesta, a su vez, solo tiene un signo valid , y este es el problema del procesador para obtener respuestas en un ciclo de reloj: está "siempre listo" por suposición, y fire() devuelve simplemente valid .


Además, como ya dije, no puede hacer solicitudes cuando es horrible: no puede escribir algo, no sé qué, y leer de nuevo lo que se sobrescribirá más adelante sobre la base del valor restado también es de alguna manera extraño. Pero la clase Serializer ya entiende esto, pero solo le damos una señal de que la solicitud actual ya ha entrado en el caché: next = io.mem.req.fire() . Todo lo que se puede hacer es asegurar que en el "lector" la respuesta se actualice solo cuando realmente llegó, ni antes ni después. Hay un método conveniente holdUnless para holdUnless . El resultado es aproximadamente la siguiente implementación:


  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)))) } } 

Se crea una instancia de esta clase para cada subinstrucción generada.


No todo en el montón es una variable global


¿Qué es un ejemplo modelo? ¿Qué rendimiento me gustaría asegurar? Por supuesto, instrumentación AFL! Se ve en la versión clásica así:


 #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; } 

Como puede ver, tiene una carga y un ahorro más o menos lógicos (y entre ellos un incremento) de un byte de __afl_area_ptr , ¡pero aquí el registro solicita el rol prev !


Es por eso que se necesita la interfaz Resolved : puede ajustar una dirección de memoria normal o ser una referencia de registro. Al mismo tiempo, hasta ahora solo considero registros escalares de 1, 2, 4 u 8 bytes de tamaño, que siempre se leen con desplazamiento cero, por lo que para los registros, puede implementar de manera relativamente tranquila el pedido de llamadas. En este caso, es muy útil saber que antes se debe restar primero y usarlo para calcular el índice, y solo luego reescribirse.


Y ahora la instrumentación


En algún momento, obtuvimos un acelerador separado y más o menos funcional con la interfaz RoCC. Que ahora ¿Volver a implementar de todos modos, empujando a través de la tubería del procesador? Me pareció que se necesitarían menos muletas si, en paralelo con la instrucción instruida, el coprocesador con la función de valor de utilidad emitido automáticamente simplemente se activara. En principio, también tuve que atormentarme por esto: incluso aprendí a usar SignalTap, porque la depuración es casi ciega, e incluso con una recompilación de cinco minutos después del más mínimo cambio (con la excepción de cambiar bootrom, todo es rápido allí), esto ya es demasiado.


Como resultado, el decodificador de comandos se modificó y la tubería se "enderezó" ligeramente para tener en cuenta el hecho de que, sin importar lo que el decodificador haya dicho sobre la instrucción original, el RoCC activado de repente no significa que habrá una escritura de latencia larga en el registro de salida, como durante la operación de división y perder el caché de datos.


En general, una descripción de una instrucción es un par ([patrón para reconocer una instrucción], [conjunto de valores que configuran los bloques de ruta de datos del núcleo del procesador]). Por ejemplo, el default (instrucción no reconocida) se ve así (tomado de IDecode.scala , en el escritorio Habr se ve, francamente, feo):


 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) 

... y una descripción típica de una de las extensiones en Rocket core se implementa así:


 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), // ... 

El hecho es que en RISC-V (no solo en RocketChip, sino en la arquitectura de comandos en principio), la división ISA se admite regularmente en el subconjunto obligatorio I (operaciones enteras), así como en M opcional (multiplicación y división entera), A (atómica) etc.


Como resultado, el método original


  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 } 

ha sido reemplazado por


lo mismo, pero con un decodificador para instrumentación y aclaración del motivo de la activación de 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 } 

De los cambios en la tubería del procesador, el más obvio, tal vez, fue este:


  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 

Está claro que algunos parámetros de la solicitud al acelerador deben corregirse: no se escribe ninguna respuesta en el registro, y la función es igual a lo que devolvió el decodificador. Pero hay un cambio un poco menos obvio: el hecho es que este comando no va directamente al acelerador (cuatro de ellos, ¿cuál?), Sino al enrutador, por lo que debe pretender que el comando tiene opcode == custom0 (sí, proceso, y es precisamente el acelerador cero!).


Cheque


De hecho, este artículo supone una continuación en la que se intentará llevar este enfoque a un nivel de producción más o menos. Como mínimo, debe aprender a guardar y restaurar el contexto (estado de los registros del coprocesador) al cambiar de tarea. Mientras tanto, comprobaré que de alguna manera funciona en condiciones de invernadero:


 #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; } 

Ahora agregue al bootrom/sdboot/sd.c en la línea 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 

Código fuente

Source: https://habr.com/ru/post/461577/


All Articles