RISC-V do zero

Neste artigo, exploramos vários conceitos de baixo nível (compilação e layout, tempos de execução primitivos, assembler e muito mais) por meio do prisma da arquitetura RISC-V e de seu ecossistema. Sou desenvolvedor web, não faço nada no trabalho, mas é muito interessante para mim, é daí que veio o artigo! Junte-se a mim nesta jornada agitada para as profundezas do caos de baixo nível.

Primeiro, vamos falar um pouco sobre o RISC-V e a importância dessa arquitetura, configurar a cadeia de ferramentas RISC-V e executar um programa C simples em hardware RISC-V emulado.

Conteúdo


  1. O que é o RISC-V?
  2. Configurando ferramentas QEMU e RISC-V
  3. Olá RISC-V!
  4. Abordagem ingênua
  5. Levantando a cortina -v
  6. Pesquise nossa pilha
  7. Layout
  8. Pare com isso! Hammertime! Tempo de execução!
  9. Depurar, mas agora de verdade
  10. O que vem a seguir?
  11. Opcional

O que é o RISC-V?


O RISC-V é uma arquitetura de conjunto de instruções gratuita. O projeto teve origem na Universidade da Califórnia em Berkeley em 2010. Um papel importante em seu sucesso foi desempenhado pela abertura de código e liberdade de uso, muito diferente de muitas outras arquiteturas. Tome o ARM: para criar um processador compatível, você deve pagar uma taxa antecipada de US $ 1 milhão a US $ 10 milhões e também pagar royalties de 0,5 a 2% nas vendas . Um modelo livre e aberto torna o RISC-V uma opção atraente para muitos, inclusive para startups que não podem pagar uma licença para um ARM ou outro processador, para pesquisadores acadêmicos e (obviamente) para a comunidade de código aberto.

O rápido crescimento da popularidade do RISC-V não passou despercebido. A ARM lançou um site que tentou (sem êxito) destacar os supostos benefícios do ARM sobre o RISC-V (o site já está fechado). O projeto RISC-V é suportado por muitas grandes empresas , incluindo Google, Nvidia e Western Digital.

Configurando ferramentas QEMU e RISC-V


Não podemos executar o código no processador RISC-V até que configuremos o ambiente. Felizmente, isso não requer um processador RISC-V físico; em vez disso, adotamos o qemu . Siga as instruções para a instalação do seu sistema operacional . Eu tenho o MacOS, então basta digitar um comando:

# also available via MacPorts - `sudo port install qemu` brew install qemu 

Convenientemente, o qemu vem com várias máquinas prontas para qemu-system-riscv32 -machine (consulte a opção qemu-system-riscv32 -machine ).

Em seguida, instale o OpenOCD para ferramentas RISC-V e RISC-V.

Faça o download de montagens prontas das ferramentas RISC-V OpenOCD e RISC-V aqui .
Nós extraímos os arquivos para qualquer diretório, eu tenho ~/usys/riscv . Lembre-se disso para uso futuro.

 mkdir -p ~/usys/riscv cd ~/Downloads cp openocd-<date>-<platform>.tar.gz ~/usys/riscv cp riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz ~/usys/riscv cd ~/usys/riscv tar -xvf openocd-<date>-<platform>.tar.gz tar -xvf riscv64-unknown-elf-gcc-<date>-<platform>.tar.gz 

Defina as variáveis ​​de ambiente RISCV_OPENOCD_PATH e RISCV_PATH para que outros programas possam encontrar nossa cadeia de ferramentas. Isso pode parecer diferente dependendo do sistema operacional e do shell: adicionei os caminhos ao ~/.zshenv .

 # I put these two exports directly in my ~/.zshenv file - you may have to do something else. export RISCV_OPENOCD_PATH="$HOME/usys/riscv/openocd-<date>-<version>" export RISCV_PATH="$HOME/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>" # Reload .zshenv with our new environment variables. Restarting your shell will have a similar effect. source ~/.zshenv 

Crie um link simbólico em /usr/local/bin para esse arquivo executável, para que você possa executá-lo a qualquer momento sem especificar o caminho completo para ~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/riscv64-unknown-elf-gcc .

 # Symbolically link our gcc executable into /usr/local/bin. Repeat this process for any other executables you want to quickly access. ln -s ~/usys/riscv/riscv64-unknown-elf-gcc-8.2.0-<date>-<version>/bin/riscv64-unknown-elf-gcc /usr/local/bin 

E pronto, temos um kit de ferramentas RISC-V em funcionamento! Todos os nossos executáveis, como riscv64-unknown-elf-gcc , riscv64-unknown-elf-gdb , riscv64-unknown-elf-ld e outros, estão em ~/usys/riscv/riscv64-unknown-elf-gcc-<date>-<version>/bin/ .

Olá RISC-V!


Atualização de 26 de maio de 2019:

Infelizmente, devido a um erro no RISC-V QEMU, o programa freedom-e-sdk 'olá mundo' no QEMU não funciona mais. Um patch foi lançado para resolver esse problema, mas, por enquanto, pule esta seção. Este programa não será necessário nas seções subseqüentes do artigo. Acompanho a situação e atualizo o artigo após corrigir o erro.

Veja este comentário para mais informações.

Com as ferramentas configuradas, vamos executar o simples programa RISC-V. Vamos começar clonando o repositório SiFive freedom-e-sdk :

 cd ~/wherever/you/want/to/clone/this git clone --recursive https://github.com/sifive/freedom-e-sdk.git cd freedom-e-sdk 

Por tradição , vamos começar com o programa 'Hello, world' do repositório freedom-e-sdk . Usamos o Makefile pronto que eles fornecem para compilar este programa no modo de depuração:

 make PROGRAM=hello TARGET=sifive-hifive1 CONFIGURATION=debug software 

E execute no QEMU:

 qemu-system-riscv32 -nographic -machine sifive_e -kernel software/hello/debug/hello.elf Hello, World! 

Este é um ótimo começo. Você pode executar outros exemplos no freedom-e-sdk . Depois disso, escreveremos e tentaremos depurar nosso próprio programa em C.

Abordagem ingênua


Vamos começar com um programa simples que adiciona infinitamente dois números.

 cat add.c int main() { int a = 4; int b = 12; while (1) { int c = a + b; } return 0; } 

Queremos executar este programa e a primeira coisa que precisamos compilá-lo para o processador RISC-V.

 # -O0 to disable all optimizations. Without this, GCC might optimize # away our infinite addition since the result 'c' is never used. # -g to tell GCC to preserve debug info in our executable. riscv64-unknown-elf-gcc add.c -O0 -g 

Isso cria o arquivo a.out , cujo padrão é o gcc para arquivos executáveis. Agora execute este arquivo no qemu :

 # -machine tells QEMU which among our list of available machines we want to # run our executable against. Run qemu-system-riscv64 -machine help to list # all available machines. # -m is the amount of memory to allocate to our virtual machine. # -gdb tcp::1234 tells QEMU to also start a GDB server on localhost:1234 where # TCP is the means of communication. # -kernel tells QEMU what we're looking to run, even if our executable isn't # exactly a "kernel". qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out 

Escolhemos a máquina riscv-qemu qual o riscv-qemu veio originalmente .

Agora que nosso programa é executado dentro do QEMU com o servidor GDB no localhost:1234 , nos conectamos a ele com o cliente RISC-V GDB a partir de um terminal separado:

 # --tui gives us a (t)extual (ui) for our GDB session. # While we can start GDB without any arguments, specifying 'a.out' tells GDB # to load debug symbols from that file for the newly created session. riscv64-unknown-elf-gdb --tui a.out 

E nós estamos dentro do GDB!

  Este GDB foi configurado como "--host = x86_64-apple-darwin17.7.0 --target = riscv64-unknown-elf".  │
 Digite "show configuration" para obter detalhes da configuração.  │
 Para obter instruções sobre relatórios de erros, consulte: │
 <http://www.gnu.org/software/gdb/bugs/>.  │
 Encontre o manual do GDB e outros recursos de documentação on-line em: │
     <http://www.gnu.org/software/gdb/documentation/>.  │
                                                                                                       │
 Para obter ajuda, digite "ajuda".  │
 Digite "palavra apropriada" para procurar comandos relacionados à "palavra" ... │
 Lendo símbolos de a.out ... │
 (gdb) 

Podemos tentar executar os comandos run ou start do arquivo executável a.out no GDB, mas no momento isso não funcionará por um motivo óbvio. Nós compilamos o programa como riscv64-unknown-elf-gcc , para que o host seja executado na arquitetura riscv64 .

Mas há uma saída! Essa situação é uma das principais razões para a existência do modelo cliente-servidor do GDB. Podemos pegar o arquivo executável riscv64-unknown-elf-gdb e, em vez de iniciá-lo no host, especificar um destino remoto (servidor GDB). Como você se lembra, nós apenas iniciamos o riscv-qemu e nos disse para iniciar o servidor GDB no localhost:1234 . Basta conectar-se a este servidor:

  (gdb) destino remoto: 1234 │
 Depuração remota usando: 1234 

Agora você pode definir alguns pontos de interrupção:

 (gdb) b main Breakpoint 1 at 0x1018e: file add.c, line 2. (gdb) b 5 # this is the line within the forever-while loop. int c = a + b; Breakpoint 2 at 0x1019a: file add.c, line 5. 

E, finalmente, especifique GDB continue (comando abreviado c ) até chegarmos ao ponto de interrupção:

 (gdb) c Continuing. 

Você notará rapidamente que o processo não termina de forma alguma. Isso é estranho ... não devemos chegar imediatamente ao ponto de interrupção b 5 ? O que aconteceu



Aqui você pode ver vários problemas:

  1. A interface do usuário do texto não pode encontrar a fonte. A interface deve exibir nosso código e quaisquer pontos de interrupção próximos.
  2. O GDB não vê a linha de execução atual ( L?? ) e exibe o contador 0x0 ( PC: 0x0 ).
  3. Algum texto na linha de entrada, que na sua totalidade se parece com isso: 0x0000000000000000 in ?? () 0x0000000000000000 in ?? ()

Combinado com o fato de que não podemos alcançar o ponto de interrupção, esses indicadores indicam: fizemos algo errado. Mas o que?

Levantando a cortina -v


Para entender o que está acontecendo, você precisa dar um passo atrás e falar sobre como nosso simples programa C realmente funciona. A função main faz uma adição simples, mas o que é realmente? Por que deveria ser chamado de main , não de origin ou begin ? De acordo com a convenção, todos os arquivos executáveis ​​começam a ser executados com a função main , mas que mágica fornece esse comportamento?

Para responder a essas perguntas, repita nossa equipe do GCC com o sinalizador -v para obter uma saída mais detalhada do que realmente está acontecendo.

 riscv64-unknown-elf-gcc add.c -O0 -g -v 

Como a saída é grande, não exibiremos a listagem inteira. É importante observar que, embora o GCC seja formalmente um compilador, o padrão é compilar (para limitar-se à compilação e montagem, você deve especificar o sinalizador -c ). Por que isso é importante? Bem, dê uma olhada no fragmento da saída detalhada do gcc :

  # O comando `gcc -v` atual gera caminhos completos, mas esses são bastante
 # long, então finja que essas variáveis ​​existem.
 # $ RV_GCC_BIN_PATH = / Usuários / twilcock / usys / riscv / riscv64-unknown-elf-gcc- <data> - <versão> / bin /
 # $ RV_GCC_LIB_PATH = $ RV_GCC_BIN_PATH /../ lib / gcc / riscv64-unknown-elf / 8.2.0

 $ RV_GCC_BIN_PATH /../ libexec / gcc / riscv64-unknown-elf / 8.2.0 / collect2 \
   ... truncado ... 
   $ RV_GCC_LIB_PATH /../../../../ riscv64-unknown-elf / lib / rv64imafdc / lp64d / crt0.o \ 
   $ RV_GCC_LIB_PATH / riscv64-unknown-elf / 8.2.0 / rv64imafdc / lp64d / crtbegin.o \
   -lgcc --start-group -lc -lgloss --end-group -lgcc \ 
   $ RV_GCC_LIB_PATH / rv64imafdc / lp64d / crtend.o
   ... truncado ...
 COLLECT_GCC_OPTIONS = '- O0' '-g' '-v' '-march = rv64imafdc' '-mabi = lp64d' 

Entendo que mesmo de forma abreviada isso é muito, então deixe-me explicar. Na primeira linha, o gcc executa o programa collect2 , transmite os argumentos crt0.o , crtbegin.o e crtend.o , os -lgcc e --start-group . A descrição do collect2 pode ser encontrada aqui : em resumo, o collect2 organiza várias funções de inicialização na inicialização, fazendo o layout em uma ou mais passagens.

Assim, o GCC compila vários arquivos crt com o nosso código. Como você pode imaginar, crt significa 'C runtime'. Aqui está descrito em detalhes para que cada crt destina, mas estamos interessados ​​em crt0 , que faz uma coisa importante:

"É esperado que este objeto [crt0] contenha o caractere _start , que indica a autoinicialização do programa."

A essência do “bootstrap” depende da plataforma, mas geralmente envolve tarefas importantes, como configurar um quadro de pilha, passar argumentos de linha de comando e chamar main . Sim, finalmente encontramos a resposta para a pergunta: é _start chama nossa função principal!

Pesquise nossa pilha


Resolvemos um enigma, mas como isso nos aproxima do objetivo original - executar um programa C simples em gdb ? Resta resolver vários problemas: o primeiro deles está relacionado a como o crt0 configura nossa pilha.

Como vimos acima, o padrão do gcc é o crt0 . Os parâmetros padrão são selecionados com base em vários fatores:

  • Objetivo trigêmeo correspondente à estrutura do sistema machine-vendor-operatingsystem . Nós temos riscv64-unknown-elf
  • Arquitetura de destino, rv64imafdc
  • Alvo ABI, lp64d

Geralmente tudo funciona bem, mas não para todos os processadores RISC-V. Como mencionado anteriormente, uma das tarefas do crt0 é configurar a pilha. Mas ele não sabe exatamente onde a pilha deve estar para nossa CPU ( -machine )? Ele não pode fazer isso sem a nossa ajuda.

No qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -kernel a.out usamos a máquina virt . Felizmente, o qemu facilita o despejo de informações da máquina em um despejo dtb (blob da árvore de dispositivos).

 # Go to the ~/usys/riscv folder we created before and create a new dir # for our machine information. cd ~/usys/riscv && mkdir machines cd machines # Use qemu to dump info about the 'virt' machine in dtb (device tree blob) # format. # The data in this file represents hardware components of a given # machine / device / board. qemu-system-riscv64 -machine virt -machine dumpdtb=riscv64-virt.dtb 

É difícil ler dados dtb porque é basicamente um formato binário, mas existe um utilitário de linha de comando dtc (compilador de árvore de dispositivos) que pode converter o arquivo em algo mais legível.

 # I'm running MacOS, so I use Homebrew to install this. If you're # running another OS you may need to do something else. brew install dtc # Convert our .dtb into a human-readable .dts (device tree source) file. dtc -I dtb -O dts -o riscv64-virt.dts riscv64-virt.dtb 

O arquivo de saída é riscv64-virt.dts , onde vemos muitas informações interessantes sobre virt : o número de núcleos de processador disponíveis, a localização da memória de vários dispositivos periféricos, como UART, a localização da memória interna (RAM). A pilha deve estar nessa memória, então procure-a com grep :

 grep memory riscv64-virt.dts -A 3 memory@80000000 { device_type = "memory"; reg = <0x00 0x80000000 0x00 0x8000000>; }; 

Como você pode ver, este nó possui 'memória' especificada como device_type . Aparentemente, encontramos o que estávamos procurando. Pelos valores dentro de reg = <...> ; Você pode determinar onde o banco de memória é iniciado e qual o seu comprimento.

Na especificação devicetree, vemos que a sintaxe reg é um número arbitrário de pares (base_address, length) . No entanto, existem quatro significados dentro de reg . Estranho, não existem dois valores suficientes para um banco de memória?

Novamente, a partir da especificação devicetree (procure a propriedade reg ), descobrimos que o número de células <u32> para especificar o endereço e o comprimento é determinado pelas propriedades #address-cells e #size-cells no nó pai (ou no próprio nó). Esses valores não são especificados em nosso nó de memória e o nó de memória pai é simplesmente a raiz do arquivo. Vamos procurar por esses valores:

 head -n8 riscv64-virt.dts /dts-v1/; / { #address-cells = <0x02>; #size-cells = <0x02>; compatible = "riscv-virtio"; model = "riscv-virtio,qemu"; 

Acontece que o endereço e o comprimento exigem dois valores de 32 bits. Isso significa que com reg = <0x00 0x80000000 0x00 0x8000000>; nossa memória começa 0x00 + 0x80000000 (0x80000000) e ocupa 0x00 + 0x8000000 (0x8000000) bytes, ou seja, termina em 0x88000000 , o que corresponde a 128 megabytes.

Layout


Usando qemu e dtc encontramos os endereços de RAM na máquina virtual virtual. Também sabemos que o gcc compõe crt0 por padrão, sem configurar a pilha conforme necessário. Mas como usar essas informações para eventualmente executar e depurar o programa?

Como crt0 não nos convém, existe uma opção óbvia: escreva seu próprio código e componha-o com o arquivo de objeto que obtivemos após compilar nosso programa simples. Nosso crt0 precisa saber onde a parte superior da pilha é iniciada para inicializá-la corretamente. Poderíamos crt0 valor 0x80000000 diretamente para crt0 , mas essa não é uma solução muito adequada, levando em consideração as alterações que possam ser necessárias no futuro. E se quisermos usar outra CPU, como sifive_e , com características diferentes no emulador?

Felizmente, não somos os primeiros a fazer essa pergunta e já existe uma boa solução. O vinculador GNU ld permite definir o caractere disponível em nosso crt0 . Podemos definir o símbolo __stack_top adequado para diferentes processadores.

Em vez de escrever seu próprio arquivo vinculador do zero, faz sentido pegar o script padrão com ld e modificá-lo um pouco para suportar caracteres adicionais. O que é um script vinculador? Aqui está uma boa descrição :

O principal objetivo do script do vinculador é descrever como as seções do arquivo são correspondidas na entrada e na saída e controlar o layout da memória do arquivo de saída.

Sabendo disso, vamos copiar o script do vinculador padrão riscv64-unknown-elf-ld para um novo arquivo:

 cd ~/usys/riscv # Make a new dir for custom linker scripts out RISC-V CPUs may require. mkdir ld && cd ld # Copy the default linker script into riscv64-virt.ld riscv64-unknown-elf-ld --verbose > riscv64-virt.ld 

Este arquivo possui muitas informações interessantes, muito mais do que podemos discutir neste artigo. Saída detalhada com a --Verbose inclui informações sobre a versão ld , arquiteturas suportadas e muito mais. É bom saber tudo isso, mas essa sintaxe é inaceitável no script do vinculador, portanto, abra um editor de texto e exclua tudo o que é supérfluo do arquivo.

  vim riscv64-virt.ld

 # Remova tudo acima e incluindo a linha =============
 GNU ld (GNU Binutils) 2.32
   Emulações suportadas:
    elf64lriscv
    elf32lriscv
 usando script vinculador interno:
 =====================================================
 / * Script para -z combreloc: combina e ordena seções de realocação * /
 / * Copyright (C) 2014-2019 Free Software Foundation, Inc.
    A cópia e distribuição deste script, com ou sem modificação,
    são permitidos em qualquer meio sem royalties, desde que os direitos autorais
    aviso e este aviso são preservados.  * /
 OUTPUT_FORMAT ("elf64-littleriscv", "elf64-littleriscv",
	       "elf64-littleriscv")
 ... restante do script do vinculador ... 

Depois disso, execute o comando MEMORY para determinar manualmente onde __stack_top estará. Localize a linha que começa com OUTPUT_ARCH(riscv) , ela deve estar na parte superior do arquivo e adicione o comando MEMORY abaixo:

 OUTPUT_ARCH(riscv) /* >>> Our addition. <<< */ MEMORY { /* qemu-system-risc64 virt machine */ RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } /* >>> End of our addition. <<< */ ENTRY(_start) 

Criamos um bloco de memória chamado RAM , para o qual a leitura ( r ), a gravação ( w ) e o armazenamento do código executável ( x ) são permitidos.

Ótimo, definimos um layout de memória que corresponde às especificações de nossa máquina virt RISC-V. Agora você pode usá-lo. Queremos colocar nossa pilha na memória.

Você precisa definir o caractere __stack_top . Abra o script do vinculador ( riscv64-virt.ld ) em um editor de texto e adicione algumas linhas:

 SECTIONS { /* Read-only sections, merged into text segment: */ PROVIDE (__executable_start = SEGMENT_START("text-segment", 0x10000)); . = SEGMENT_START("text-segment", 0x10000) + SIZEOF_HEADERS; /* >>> Our addition. <<< */ PROVIDE(__stack_top = ORIGIN(RAM) + LENGTH(RAM)); /* >>> End of our addition. <<< */ .interp : { *(.interp) } .note.gnu.build-id : { *(.note.gnu.build-id) } 

Como você pode ver, definimos __stack_top usando o comando PROVIDE . O símbolo estará acessível a partir de qualquer programa associado a esse script (supondo que o próprio programa não determine algo com o nome __stack_top ). Defina __stack_top como ORIGIN(RAM) . Sabemos que esse valor é 0x80000000 mais LENGTH(RAM) , que é de 128 megabytes ( 0x8000000 bytes). Isso significa que nosso __stack_top definido como 0x88000000 .

Por uma questão de brevidade, não listarei o arquivo vinculador inteiro aqui ; você pode vê-lo aqui .

Pare com isso! Hammertime! Tempo de execução!


Agora, temos tudo o que precisamos para criar nosso próprio tempo de execução C. Na verdade, essa é uma tarefa bastante simples, eis o arquivo crt0.s inteiro:

 .section .init, "ax" .global _start _start: .cfi_startproc .cfi_undefined ra .option push .option norelax la gp, __global_pointer$ .option pop la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end 

Atrai imediatamente um grande número de linhas que começam com um ponto. Este é um arquivo para o assembler as . Linhas com pontos são chamadas diretivas assembler : fornecem informações para assembler. Este não é um código executável, como as instruções do RISC-V assembler, como jal e add .

Vamos analisar o arquivo linha por linha. Trabalharemos com vários registradores RISC-V padrão; portanto, confira esta tabela , que abrange todos os registradores e sua finalidade.

 .section .init, "ax" 

Conforme indicado no manual do GNU assembler 'as' , esta linha diz ao assembler para inserir o seguinte código na seção .init , que é alocada ( a ) e executável ( x ). Esta seção é outra convenção comum para a execução de código no sistema operacional. Trabalhamos em hardware puro sem sistema operacional, portanto, no nosso caso, essas instruções podem não ser absolutamente necessárias, mas, em qualquer caso, é uma boa prática.

 .global _start _start: 

.global disponibiliza o seguinte caractere para ld . Sem isso, o link não funcionará, porque o ENTRY(_start) no script do vinculador aponta para o símbolo _start como o ponto de entrada para o arquivo executável. A próxima linha informa ao assembler que estamos iniciando a definição do caractere _start .

 _start: .cfi_startproc .cfi_undefined ra ...other stuff... .cfi_endproc 

Essas diretivas .cfi informam sobre a estrutura do quadro e como lidar com ele. As .cfi_startproc e .cfi_endproc sinalizam o início e o fim de uma função, e .cfi_undefined ra informa ao assembler que o registro ra não deve ser restaurado para qualquer valor que ele contenha antes do _start .

 .option push .option norelax la gp, __global_pointer$ .option pop 

Essas diretivas .option alteram o comportamento do assembler de acordo com o código quando você precisa aplicar um conjunto específico de opções. Aqui está uma descrição detalhada de por que o uso de .option neste segmento é importante:

... como possivelmente relaxamos o endereçamento de seqüências para sequências mais curtas em relação ao GP, o carregamento inicial do GP não deve ser enfraquecido e deve ser algo como isto:

 .option push .option norelax la gp, __global_pointer$ .option pop 

para que, após o relaxamento, você obtenha o seguinte código:

 auipc gp, %pcrel_hi(__global_pointer$) addi gp, gp, %pcrel_lo(__global_pointer$) 

em vez de simples:

 addi gp, gp, 0 

E agora a última parte do nosso crt0.s :

 _start: ...other stuff... la sp, __stack_top add s0, sp, zero jal zero, main .cfi_endproc .end 

Aqui podemos finalmente usar o símbolo __stack_top , que trabalhamos duro para criar. A pseudo-instrução la (endereço de carregamento) carrega o valor __stack_top no registrador sp (ponteiro da pilha), configurando-o para uso no restante do programa.

Em seguida, add s0, sp, zero adiciona os valores dos registradores sp e zero (que na verdade é o registrador x0 com uma referência rígida a 0) e coloca o resultado no registrador s0 . Este é um registro especial que é incomum em vários aspectos. Primeiro, é um “registro persistente”, ou seja, é salvo quando chamadas de função. Em segundo lugar, s0 às vezes atua como um ponteiro de quadro, o que concede a cada função um pequeno espaço na pilha para armazenar os parâmetros passados ​​para essa função. Como as chamadas de função funcionam com os ponteiros de pilha e quadro é um tópico muito interessante que você pode dedicar facilmente a um artigo separado, mas, por enquanto, saiba que em nosso tempo de execução é importante inicializar o ponteiro de quadro s0 .

Em seguida, vemos o jal zero, main declaração jal zero, main . Aqui jal significa pular e vincular. A instrução espera operandos na forma de jal rd (destination register), offset_address . Funcionalmente, jal grava o valor da próxima instrução (registrador pc mais quatro) em rd e, em seguida, define o registro pc valor atual do pc mais o endereço de deslocamento com extensão de sinal , efetivamente "chamando" esse endereço.

Como mencionado acima, x0 fortemente vinculado ao valor literal 0 e a gravação nele é inútil.Portanto, pode parecer estranho usarmos um registro como o registro de destino zero, que os montadores do RISC-V interpretam como um registro x0. Afinal, isso significa uma transição incondicional para offset_address. Por que fazer isso, porque em outras arquiteturas há uma instrução explícita para uma transição incondicional?

Esse padrão estranho jal zero, offset_addressé realmente otimização inteligente. O suporte para cada nova instrução significa um aumento e, consequentemente, um aumento no custo do processador. Portanto, quanto mais simples o ISA, melhor. Em vez de poluir o espaço de instruções com duas instruções jale unconditional jump, a arquitetura RISC-V suporta apenas jal, e saltos incondicionais são suportados jal zero, main.

O RISC-V possui muitas dessas otimizações, a maioria das quais toma a forma das chamadas pseudo - instruções . Os montadores sabem como traduzi-los em instruções reais de hardware. Por exemplo, j offset_addressmontadores RISC-V convertem pseudo- instruções para saltos incondicionais em jal zero, offset_address. Para obter uma lista completa das pseudo instruções oficialmente suportadas , consulte a especificação RISC-V (versão 2.2) .

 _start: ...other stuff... jal zero, main .cfi_endproc .end 

Nossa última linha é a diretiva assembler .end, que simplesmente marca o final do arquivo.

Depurar, mas agora de verdade


Tentando depurar um programa C simples em um processador RISC-V, resolvemos muitos problemas. Primeiro, usei qemue dtcencontrei nossa memória na máquina virtual virtRISC-V. Em seguida, usamos essas informações para controlar manualmente a alocação de memória em nossa versão do script padrão do vinculador riscv64-unknown-elf-ld, o que nos permitiu determinar com precisão o símbolo __stack_top. Em seguida, use este símbolo em sua própria versão crt0.s, que define os nossos índices de pilha e globais, e, finalmente, chamou a função main. Agora você pode atingir seu objetivo e começar a depurar nosso programa simples no GDB.

Lembre-se aqui é o próprio programa C:

 cat add.c int main() { int a = 4; int b = 12; while (1) { int c = a + b; } return 0; } 

Compilando e vinculando:

 riscv64-unknown-elf-gcc -g -ffreestanding -O0 -Wl,--gc-sections -nostartfiles -nostdlib -nodefaultlibs -Wl,-T,riscv64-virt.ld crt0.s add.c 

Aqui indicamos muito mais sinalizadores do que da última vez, então vamos ver os que não descrevemos antes.

-ffreestanding informa ao compilador que a biblioteca padrão pode não existir , portanto, não há necessidade de fazer suposições sobre sua presença obrigatória. Esse parâmetro não é necessário ao iniciar o aplicativo em seu host (no sistema operacional), mas, neste caso, não é, portanto, é importante informar o compilador dessas informações.

-Wl- Uma lista de sinalizadores separados por vírgula para passar ao vinculador ( ld). Aqui, --gc-sectionssignifica "seções de coleta de lixo" e ldé instruído a remover seções não utilizadas após a vinculação. Bandeiras -nostartfiles, -nostdlibe -nodefaultlibso ligador não processa os arquivos de inicialização do sistema padrão (por exemplo, padrãocrt0), implementações padrão do sistema stdlib e bibliotecas vinculadas padrão do sistema padrão. Como temos nosso próprio script crt0e vinculador, é importante passar esses sinalizadores para que os valores padrão não entrem em conflito com nossas preferências do usuário.

-Tindica o caminho para o script do vinculador, o que é simples no nosso caso riscv64-virt.ld. Por fim, especificamos os arquivos que queremos compilar, compilar e compor: crt0.se add.c. Como antes, o resultado é um arquivo completo e pronto para executar, chamado a.out.

Agora execute o nosso novo executável novíssimo em qemu:

 # -S freezes execution of our executable (-kernel) until we explicitly tell # it to start with a 'continue' or 'c' from our gdb client qemu-system-riscv64 -machine virt -m 128M -gdb tcp::1234 -S -kernel a.out 

Agora execute gdb, lembre-se de carregar os símbolos de depuração a.out, especificando-o com o último argumento:

 riscv64-unknown-elf-gdb --tui a.out GNU gdb (GDB) 8.2.90.20190228-git Copyright (C) 2019 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html> This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law. Type "show copying" and "show warranty" for details. This GDB was configured as "--host=x86_64-apple-darwin17.7.0 --target=riscv64-unknown-elf". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from a.out... (gdb) 

Em seguida, conecte nosso cliente gdbao servidor gdbque lançamos como parte do comando qemu:

 (gdb) target remote :1234 │ Remote debugging using :1234 

Defina um ponto de interrupção no principal:

 (gdb) b main Breakpoint 1 at 0x8000001e: file add.c, line 2. 

E inicie o programa:

 (gdb) c Continuing. Breakpoint 1, main () at add.c:2 

A partir da saída fornecida, é claro que atingimos com sucesso o ponto de interrupção na linha 2! Isso também é visível na interface de texto, finalmente temos a linha correta L, o valor PC:é L2e PC:- 0x8000001e. Se você fez tudo como no artigo, a saída será mais ou menos assim: a



partir de agora, você pode usá-lo gdbcomo de costume: -spara ir para a próxima instrução, info all-registerspara verificar os valores dentro dos registros enquanto o programa é executado, etc. Experimente o seu prazer ... nós, é claro , trabalhou muito para isso!

O que vem a seguir?


Hoje conseguimos muito e, espero, aprendemos muito! Eu nunca tive um plano formal para este e outros artigos, apenas segui o que era mais interessante para mim a cada momento. Portanto, não tenho certeza do que acontecerá a seguir. Eu gostei especialmente da imersão profunda nas instruções jal, portanto, talvez no próximo artigo tomemos como base o conhecimento adquirido aqui, mas substitua-o por add.calgum programa em puro montador RISC-V. Se você tiver algo específico que gostaria de ver ou tiver alguma dúvida, abra tickets .

Obrigado pela leitura! Espero encontrar no próximo artigo!

Opcional


Se você gostou do artigo e quer saber mais, confira a apresentação de Matt Godbolt intitulada “Bits entre bits: como entramos no main ()” da conferência CppCon2018. Ela aborda o assunto um pouco diferente do que estamos aqui. Realmente boa palestra, veja por si mesmo!

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


All Articles