Nas quatro partes anteriores, foram feitos preparativos para experimentos com o núcleo RISC-V RocketChip, ou seja, portar esse núcleo em uma placa "não-padrão" para ele com FPGAs da Altera (agora Intel). Finalmente, na última parte , acabou rodando o Linux nesta placa. Você sabe o que me divertiu sobre tudo isso? O fato de que, ao mesmo tempo, tive que trabalhar com o montador RISC-V, C e Scala, e de todos eles, Scala era a linguagem de nível mais baixo (porque o processador está escrito nela).
Vamos fazer com que C também não seja ofensivo neste artigo. Além disso, se o pacote Scala + Chisel foi usado apenas como uma linguagem específica de domínio para uma descrição explícita do hardware, hoje aprenderemos como "inserir" funções C simples no processador na forma de instruções.
O objetivo final é a implementação trivial de instrumentações triviais do tipo AFL por analogia com o QInst , e a implementação de instruções separadas é apenas um subproduto.
É claro que existe (e não um) conversor comercial OpenCL para RTL. Também encontrei informações sobre um determinado projeto COPILOT para o RISC-V com objetivos semelhantes (muito mais avançados), mas algo está pesando bastante no Google e, além disso, é provavelmente também um produto comercial. Estou interessado principalmente em soluções OpenSource, mas, mesmo que sejam, ainda é divertido tentar implementar isso você mesmo - pelo menos como um exemplo de treinamento simplificado e depois como ...
Isenção de responsabilidade (além do aviso habitual sobre "dançar com um extintor de incêndio"): eu não recomendo aplicar imprudentemente o núcleo do software resultante, especialmente com dados não confiáveis - até agora não tenho tanta confiança, mas mesmo entendo por que os dados sendo processados não podem "Fluxo" em alguns casos de fronteira entre processos e / ou o núcleo. Bem, sobre o fato de que os dados podem "bater", eu acho, e assim fica claro. Em geral, ainda há validação e validação ...
Para iniciantes, como chamo de "função simples"? Para os fins deste artigo, isso significa uma função na qual todas as transições (condicionais e incondicionais) apenas aumentam o contador de instruções por um valor constante. Ou seja, o gráfico de todas as transições possíveis é acíclico (direcionado), sem arestas "dinâmicas". O objetivo final na estrutura deste artigo é poder obter uma função simples do programa e, substituindo-a por um plug-in assembler, "costurá-la" no processador no estágio de síntese, opcionalmente tornando-o um efeito colateral de outra instrução. Especificamente, a ramificação não será mostrada neste artigo, mas, no caso mais simples, não será difícil criá-las.
Aprendendo a entender C (na verdade, não)
Primeiro você precisa entender como vamos analisar C? É isso mesmo, de jeito nenhum - não foi em vão que aprendi a analisar arquivos ELF : você só precisa compilar nosso código em C / Rust / outra coisa em um bytecode do eBPF e analisá-lo já. Algumas dificuldades são causadas pelo fato de que no Scala você não pode simplesmente conectar elf.h
ler os campos da estrutura. Você poderia, é claro, tentar usar o JNAerator - se necessário, eles podem fazer ligadores à biblioteca - não apenas estruturas, mas também gerar código para trabalhar com o JNA (não confundir com o JNI). Como programador de verdade, escreverei minha bicicleta e escreverei cuidadosamente as constantes de enumeração e deslocamento do arquivo de cabeçalho. As estruturas de resultado e intermediárias são descritas pela seguinte estrutura de classes 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] )
Não descreverei particularmente o processo de análise - esta é apenas uma transferência de bytes sem graça do java.nio.ByteBuffer
- todas as coisas interessantes já foram descritas no artigo sobre a análise de arquivos ELF . Eu direi apenas que você precisa manipular cuidadosamente opcode == 0x18
(carregando valores imediatos de 64 bits no registrador), pois são necessárias duas palavras de 8 bytes de uma só vez (talvez existam outros códigos de operação, mas ainda não os encontrei) , e isso nem sempre está carregando o endereço de memória associado à realocação, como eu pensava inicialmente. Por exemplo, __builtin_popcountl
usa honestamente a constante de 64 bits 0x0101010101010101
. Por que não estou realocando uma mudança "honesta" com o patch do arquivo baixado - porque quero ver os caracteres em forma simbólica (desculpe o trocadilho), para que mais tarde os caracteres da seção COMMON
possam ser substituídos por registros sem usar muletas com tratamento especial de endereços de um tipo especial (e isso significa, mesmo com danças com UInt
constante / não constante).
Construímos hardware de acordo com um conjunto de instruções
Portanto, por suposição, todos os caminhos de execução possíveis descem exclusivamente na lista de instruções, o que significa que os dados fluem ao longo de um gráfico acíclico orientado e todas as suas arestas são definidas estaticamente. Ao mesmo tempo, temos uma lógica puramente combinatória (isto é, sem registros a caminho), obtida de operações em registros, bem como atrasos durante operações de carregamento / armazenamento com memória. Portanto, no caso geral, talvez não seja possível concluir a operação em um ciclo de relógio. Vamos simplificar: transferiremos o valor para na forma de UInt
, mas como (UInt, Bool)
: o primeiro elemento do par é o valor e o segundo é um sinal de sua correção. Ou seja, não faz muito sentido ler da memória, desde que o endereço esteja incorreto e a gravação em geral seja impossível.
O modelo de execução do bytecode do eBPF assume algum tipo de RAM com endereçamento de 64 bits, bem como um conjunto de 16 (ou mesmo dez) registros de 64 bits. Um algoritmo recursivo primitivo é proposto:
- começamos com o contexto em que os operandos da instrução estão em
r1
e r2
, no restante - zeros, todos válidos (mais precisamente, a validade é igual à "prontidão" da instrução do coprocessador) - se virmos uma instrução aritmética-lógica, extraímos seus registradores de operandos do contexto, nos chamamos pelo final da lista e pelo contexto em que o operando de saída é substituído por um par
(data1 op data2, valid1 && valid2)
- se encontrarmos um ramo, simplesmente construímos os dois ramos recursivamente: se o ramo ocorrer e se não
- se encontrarmos carregando ou salvando na memória, de alguma forma saímos: executamos o retorno de chamada transferido, assumindo o invariante de que uma vez que a instrução
valid
não possa ser recuperada durante a execução desta instrução. A validade da operação de salvaguarda é E por nós com o sinalizador globalValid
, que deve ser definido antes de retornar o controle. Ao mesmo tempo, precisamos ler e escrever na frente para processar corretamente incrementos e outras modificações.
Assim, as operações serão executadas o mais paralelo possível, e não em etapas. Ao mesmo tempo, peço que você preste atenção para que todas as operações em um byte específico de memória sejam naturalmente ordenadas completamente, caso contrário, o resultado é imprevisível, UB. I.e. *addr += 1
- isso é normal, a gravação não será iniciada exatamente até que a leitura seja concluída (brega porque ainda não sabemos o que escrever), mas *addr += 1; return *addr;
*addr += 1; return *addr;
Eu geralmente dava zero ou algo parecido com segurança. Talvez valha a pena depurar (talvez oculte algum problema mais complicado), mas o apelo em si é, de qualquer forma, uma idéia mais ou menos, porque você precisa acompanhar quais endereços de memória o trabalho já foi feito, mas tenho um desejo Valide valores, valid
possível estaticamente. Isso é exatamente o que será feito para variáveis globais de tamanho fixo.
O resultado é uma classe abstrata BpfCircuitConstructor
, que não possui métodos implementados doMemLoad
, doMemStore
e resolveSymbol
:
trait BpfCircuitConstructor {
Integração do núcleo da CPU
Para iniciantes, decidi seguir o caminho simples: conectar-se ao núcleo do processador usando o protocolo RoCC (Rocket Custom Coprocessor) padrão. Pelo que entendi, essa é uma extensão regular, não para todos os kernels compatíveis com RISC-V, mas apenas para Rocket e BOOM (Berkeley Out-of-Order Machine), portanto, ao arrastar o trabalho upstream nos compiladores, os mnemônicos custom0
do assembler foram jogados fora deles - custom3
responsável pelos comandos do acelerador.
Em geral, cada núcleo do processador Rocket / BOOM pode ter até quatro aceleradores RoCC adicionados via configuração, também existem exemplos de implementação:
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 }) })
A implementação correspondente está no arquivo LazyRoCC.scala
.
A implementação do acelerador representa duas classes já conhecidas do controlador de memória: uma delas é herdada do LazyRoCC
e a outra do LazyRoCCModuleImp
. A segunda classe possui uma porta io
do tipo RoCCIO
, que contém a porta de solicitação cmd
, a porta de resposta resp
, a porta de cache L1D de acesso mem, as saídas de busy
e de interrupt
e a entrada de exception
. Também há uma porta de walker de tabela de páginas e FPUs que ainda não parecemos precisar (de qualquer forma, não há aritmética real no eBPF). Até agora, quero tentar fazer algo com essa abordagem, para não tocar em interrupt
. Além disso, como eu o entendo, há uma interface TileLink para acesso à memória não armazenada em cache, mas por enquanto também não vou tocá-la.
Organizador de consultas
Portanto, temos uma porta para acessar o cache, mas apenas uma. Ao mesmo tempo, uma função pode, por exemplo, incrementar uma variável (que, no mínimo, pode ser transformada em uma única operação atômica) ou mesmo de alguma forma transformá-la de maneira não trivial, carregando, atualizando e salvando. No final, uma única instrução pode fazer várias solicitações não relacionadas. Talvez essa não seja a melhor idéia em termos de desempenho, mas, por outro lado, por que não, digamos, carregar três palavras (que, possivelmente, já estão no cache), de alguma forma processá-las em paralelo com a lógica combinacional (então uma batida) e salve o resultado. Portanto, precisamos de algum tipo de esquema que efetivamente "resolva" tentativas de acesso paralelo a uma única porta de cache.
A lógica será aproximadamente a seguinte: no início da geração da implementação de uma sub-injeção específica (um campo funct de 7 bits em termos de RoCC), é criada uma instância do serializador de consultas (fazer um global parece muito prejudicial para mim, porque cria um monte de dependências extras entre solicitações que nunca podem ser executadas ao mesmo tempo, e desperdício Fmax provavelmente). Em seguida, cada "protetor" / "carregador" criado é registrado no serializador. Em uma fila ao vivo, por assim dizer. Em cada medida, a primeira solicitação na ordem de registro é selecionada - ele recebe permissão na próxima medida . Naturalmente, essa lógica precisa ser adequadamente sobreposta aos testes (no entanto, não tenho muitos deles, portanto não é essa verificação, mas é o conjunto mínimo necessário para obter pelo menos algo inteligível). Eu usei o PeekPokeTester
padrão de um componente mais ou menos oficial para testar projetos de cinzéis. Eu já o descrevi uma vez .
O resultado foi uma engenhoca:
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) } }
Observe que aqui no processo de criação de um circuito digital, o código Scala é executado com segurança. Se você olhar mais de perto, poderá até notar um ArrayBuffer
no qual as partes do circuito estão empilhadas ( Boolean
é um tipo de Scala, Bool
é um tipo de Cinzel que representa equipamento ArrayBuffer
e não algum booleano conhecido em tempo de execução).
Trabalhando com cache L1D
O trabalho com o cache ocorre principalmente através da io.mem.req
solicitação io.mem.req
e da io.mem.resp
resposta io.mem.resp
. Ao mesmo tempo, a porta da solicitação é equipada com os sinais tradicionais de ready
e valid
: o primeiro diz que está pronto para aceitar a solicitação, o segundo dizemos que a solicitação está pronta e já tem a estrutura correta, ao longo da frente, valid && resp
solicitação é considerada aceita. Em algumas dessas interfaces, há um requisito de "não resposta" de sinais desde o momento da configuração para true
e até o limite positivo subseqüente de valid && resp
(essa expressão pode ser construída usando o método fire()
por conveniência).
A porta de resposta resp
, por sua vez, possui apenas um sinal valid
, e esse é o problema do processador de obter respostas em um ciclo de clock: está "sempre pronto" por suposição, e o fire()
retorna apenas valid
.
Além disso, como eu já disse, você não pode fazer solicitações quando é horrível: você não pode escrever algo, não sei o que e ler novamente o que será substituído posteriormente com base no valor subtraído também é algo estranho. Mas a classe Serializer
já entende isso, mas apenas sinalizamos que a solicitação atual já entrou no cache: next = io.mem.req.fire()
. Tudo o que pode ser feito é garantir que, no "leitor", a resposta seja atualizada apenas quando realmente veio - nem mais cedo nem mais tarde. Existe um método holdUnless
conveniente para holdUnless
. O resultado é aproximadamente a seguinte implementação:
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)))) } }
Uma instância desta classe é criada para cada subinstrução gerada.
Nem tudo no heap é uma variável global
Hmm, o que é um exemplo de modelo? Qual desempenho eu gostaria de garantir? Claro, instrumentação AFL! Parece na versão clássica assim:
#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 você pode ver, ele tem carregamento e salvamento mais ou menos lógico (e entre eles um incremento) de um byte de __afl_area_ptr
, mas aqui o registro está solicitando a função prev
!
É por isso que a interface Resolved
é necessária: ela pode quebrar um endereço de memória regular ou ser uma referência de registro. Ao mesmo tempo, até agora, considero apenas os registros escalares de 1, 2, 4 ou 8 bytes de tamanho, que são sempre lidos com deslocamento zero, portanto, para os registros, você pode implementar com calma a ordem das chamadas. Nesse caso, é muito útil saber que o prev
deve primeiro ser subtraído e usado para calcular o índice e somente depois reescrito.
E agora a instrumentação
Em algum momento, conseguimos um acelerador separado e mais ou menos funcional com a interface RoCC. E agora? Reimplementar da mesma forma, empurrando o pipeline do processador? Pareceu-me que menos muletas seriam necessárias se, paralelamente à instrução instruída, o coprocessador com o valor de utilitário emitido automaticamente funct
fosse simplesmente ativado. Em princípio, eu também tive que me atormentar por isso: eu até aprendi a usar o SignalTap, porque a depuração é quase cega, e mesmo com uma recompilação de cinco minutos após a menor alteração (exceto para alterar o bootrom - tudo é rápido lá) - isso já é demais.
Como resultado, o decodificador de comando foi corrigido e o pipeline foi levemente "endireitado" para levar em conta o fato de que, independentemente do que o decodificador diga sobre a instrução original, o RoCC ativado de repente não significa que haverá uma gravação de longa latência no registro de saída, como durante a operação de divisão e perca o cache de dados.
Em geral, uma descrição de uma instrução é um par ([padrão para reconhecer uma instrução], [conjunto de valores que configuram blocos de caminho de dados do núcleo do processador]). Por exemplo, o default
(instrução não reconhecida) se parece com isso (do IDecode.scala
, na área de trabalho do Habr, francamente, é feio):
def default: List[BitPat] =
... e uma descrição típica de uma das extensões no núcleo do Rocket é implementada assim:
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),
O fato é que no RISC-V (não apenas no RocketChip, mas na arquitetura de comandos em princípio), o ISA se divide no subconjunto obrigatório I (operações inteiras), bem como no M opcional (multiplicação e divisão de números inteiros), A (atômica) é regularmente suportado etc.
Como resultado, o 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 }
foi substituído por
mesmo, mas com um decodificador para instrumentação e esclarecimento do motivo da ativação do 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 }
Das mudanças no pipeline do processador, a mais óbvia, talvez, foi a seguinte:
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
É claro que alguns parâmetros da solicitação ao acelerador precisam ser corrigidos: nenhuma resposta é gravada no registro e funct
é igual ao que o decodificador retornou. Mas há uma mudança um pouco menos óbvia: o fato é que esse comando não vai diretamente para o acelerador (quatro deles - qual?), Mas para o roteador, então você precisa fingir que o comando tem opcode == custom0
(sim, processo, e é precisamente o acelerador zero!).
Verifique
De fato, este artigo pressupõe uma continuação na qual será feita uma tentativa de elevar essa abordagem a um nível de produção mais ou menos. No mínimo, você deve aprender a salvar e restaurar o contexto (estado dos registros do coprocessador) ao alternar tarefas. Enquanto isso, vou verificar se de alguma forma funciona em condições de estufa:
#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; }
Agora adicione ao bootrom/sdboot/sd.c
na linha main
#include "/path/to/freedom-u-sdk/riscv-pk/machine/encoding.h"
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 fonte