Cada vez mais processadores e microcontroladores baseados em RISC-V vêm ganhando espaço no mercado como alternativas às arquiteturas ARM e MIPS. O RISC-V não é uma CPU específica nem uma empresa, mas sim um conjunto de instruções (ISA) aberto e de livre implementação, desenvolvido para ser modular, escalável e sem custos de licenciamento.

Esse padrão é mantido pela RISC-V International, uma organização sem fins lucrativos responsável por coordenar a evolução da arquitetura e suas especificações. Fundada em 2015 como RISC-V Foundation e transferida para a Suíça em 2020, a entidade atua para garantir neutralidade, continuidade e colaboração internacional. Com mais de 4.500 membros distribuídos em 70 países, a RISC-V International promove um ecossistema acessível e voltado à inovação, livre de royalties.

A arquitetura permite liberdade de design, sendo usada tanto em projetos abertos quanto proprietários. O conjunto de instruções RISC-V foi iniciado em 2010 por Krste Asanović, Yunsup Lee e Andrew Waterman no Par Lab da UC Berkeley, liderado por David Patterson. O Par Lab, financiado por Intel e Microsoft, visava avanços em computação paralela entre 2008 e 2013. O projeto RISC-V e a linguagem Chisel foram desenvolvidos como open source sob a licença BSD. Parte do financiamento veio da DARPA para implementação de processadores, mas não para a ISA RISC-V.

Na prática, isso quer dizer que as instruções são abertas e livres para implementação, porém não há nenhuma CPU com a marca RISC-V, as CPUs disponíveis no mercado com as instruções e extensões RISC-V são CPUs de empresas como SiFive, GigaDevices, Microchip, entre outras com projeto fechado e proprietário, e não deixariam de ser, visto que a fabricação física de uma CPU envolve projeto e processos fabris em microeletrônica protegidos e caros.

O interesse no RISC-V aliado a sua liberdade de uso faz com que algumas pessoas implementem sua própria CPU RISC-V, seja com emuladores, em Verilog ou VHDL, simulando em software ou sintetizados para alguma FPGA. De fato, pode-se inclusive comercializar uma implementação em FPGA, ao contrário do conjunto de instruções ARM.

Como fazer uma CPU envolve processos complexos, como conhecer uma linguagem de descrição de hardware (Verilog ou VHDL), conhecer arquitetura de CPUs, memória, barramento e eletrônica digital, este artigo foi feito para ajudar no entendimento da arquitetura RISC-V com ilustrações e trechos de códigos em Verilog e C. E espero que seja de grande ajuda!

Escrever sua própria CPU é um exercício de gente grande! Extremamente útil na compreensão do funcionamento interno das CPUs e arquitetura de computadores.

Instruções Base RVI32

A especificação RISC-V divide-se em instruções Base e Extensões. Em 32 bits as instruções Base são conhecidas como RVI32, que contêm instruções de Movimentação, Soma, Lógicas, Saltos e de Sistema (não-protegido). Elas são divididas em 6 grupos de codificação de imediatos chamados R, I, S, B, U e J cujos formatos são ilustrados abaixo:

  • opcode - 7 bits identificando cada grupo de instruções. opcode pode indicar um grupo de instruções ou uma instrução somente
  • rd - 5 bits identificando um dos 32 registradores de destino do resultado da instrução
  • rs1 - 5 bits identificando um dos 32 registradores fonte (source) numero 1
  • rs2 - 5 bits identificando um dos 32 registradores fonte (source) numero 2
  • funct3 - 3 bits que junto de funct7 identifica a instrução a ser executada do tipo determinado por opcode
  • funct7 - 7 bits que junto de funct3 identifica a instrução a ser executada do tipo determinado por opcode
  • imm[:] ou [] - São os valores numéricos da instrução que serão usados em algum cálculo interno de deslocamento ou armazenamento.

Como se pode observar, as instruções são todas em 32 bits o que facilita no fetch e decodificação da instrução. Como toda arquitetura RISC pura, o campo rs1, rs2 indicam os registradores que contém os operandos das instruções e rd o destino do resultado; não há instruções que efetuam cálculos com operandos ou destinos direto na memória, esses valores devem antes ser carregados nos registradores rs1 ou rs2 e o resultado da operação será guardado em rd e quando então a instrução store poderá gravar o conteúdo de qualquer registrador na memória. Se fosse o caso de um processador CISC poderíamos ter instruções de outros tamanhos além dos 32 bits e imediatos que sirvam de endereços para execução da instrução direto na memória.

Agora exploraremos esses 6 tipos detalhadamente.

Tipo R

As instruções do tipo R são as mais fáceis de se entender e um ótimo ponto de partida. São denominadas R justamente por operarem em operandos contidos em registradores, que foram previamente carregados pelas instruções tipo I, U ou resultado de uma operação anterior tipo R.

Na ilustração acima, ALU é a unidade aritmética que faz diversas operações como soma, diferença, xor, and entre outras. O campo opcode da instrução em RV32I com valor 0110011identifica as instruções tipo R. Quem determinará qual operação a ALU efetuará será a combinação de funct7 e funct3 conforme tabela abaixo. rs1 e rs2 são o identificador de um dos 32 registradores que serão respectivamente o valor rs1 e rs2 da ALU e finalmente o resultado da operação aritmética será armazenado no registrador indexado por rd.

funct7 rs2 rs1 funct3 rd op instrução
0000000 rs2 rs1 000 rd 0110011 ADD
0100000 rs2 rs1 000 rd 0110011 SUB
0000000 rs2 rs1 001 rd 0110011 SLL
0000000 rs2 rs1 010 rd 0110011 SLT
0000000 rs2 rs1 011 rd 0110011 SLTU
0000000 rs2 rs1 100 rd 0110011 XOR
0000000 rs2 rs1 101 rd 0110011 SRL
0100000 rs2 rs1 101 rd 0110011 SRA
0000000 rs2 rs1 110 rd 0110011 OR
0000000 rs2 rs1 111 rd 0110011 AND

Veja que funct7 somente faz distinção das instruções ADD/SUB e SRL/SRA cujo funct3 são iguais.

Para extrair esses valores em Verilog utilize o código abaixo:

wire [6:0]  OPCODE          =   INS[6:0];
wire [2:0]  FUNCT3          =   INS[14:12];
wire [6:0]  FUNCT7          =   INS[31:25];
wire [4:0]  RS1_INDEX       =   INS[19:15];
wire [4:0]  RS2_INDEX       =   INS[24:20];
wire [4:0]  RD_INDEX        =   INS[11:7];

E no caso de uma emulador C/C++:

int opcode      = ins & 127;
int funct3      = (ins & 28672) >> 12;
int funct7      = (ins & 4261412864) >> 25;
int rs1_index   = (ins & 1015808) >> 15;
int rs2_index   = (ins & 32505856) >> 20;
int rd_index    = (ins & 3968) >> 7;  

Esses imediatos e indexadores serão utilizados em outras instruções, sendo então reaproveitados nos outros tipos. Sendo essa uma das vantagens de um processador RISC moderno como o RISC-V, os imediatos têm posições fixas que facilitam a decodificação sem precisar de máquinas de estado (micro-código) e lógicas adicionais, o que torna o uso de portas lógicas o mínimo possível no RISC-V, permitindo então, fazer vários processadores, chamados harts, em uma pastilha de silício ou sintetizados em FPGA, ou seja, um multi-processador.

Tipo I

Instruções tipo I ou Imediato permitem carregar os registradores com valores através da ALU. Todas as instruções do tipo I fazem alguma operação aritmética no imediato da instrução, porém se o programador não quiser efetuar operação alguma no valor, basta somar com o registrador zero, e como toda soma de um número com zero é ele mesmo, então esse valor numérico imediato será armazenado no registrador sem alteração alguma. O x86 tem as instruções MOV, porém o RISC-V não tem uma instrução de carga explicita, somente aritméticas! Sim! Se você quer carregar um valor some ele com zero!

Alguns acham estranho não ter uma instrução de carga explicita como é no x86 ou mesmo no ARM. No RISC-V não há instruções de Load ou Movimento, o mais próximo disso é a instrução tipo U que armazena o imediato nos 20 bits de cima de um registrador.

As instruções tipo I apenas diferem das tipo R por não usarem rs2 como operando, porém usam o valor imediato presente na instrução que deve ser extraído pelo decodificador de instruções.

Percebe-se na ilustração que a única diferença é a ausência de rs2 que foi substituído pela palavra de 32 bits montada através dos bits 31:20 da instrução que foram extraídos e concatenados nos bits 12:0 da palavra de 32 bits.

O sinal de imm[11:0], ou seja, o bit imm[11] é estendido para o restante dos 32 bits da palavra, na prática, se copia esse bit aos 20 bits restantes da palavra. Algo fácil de se fazer em Verilog, porém em software é custoso. Em seguida executa-se a operação da ALU com os operandos imm_i e rs1 com destino em rd.

imm[11:0] rs1 funct3 rd op instrução
xxxxxxxxxxxx rs1 000 rd 0010011 ADDI
xxxxxxxxxxxx rs1 010 rd 0010011 SLTI
xxxxxxxxxxxx rs1 011 rd 0010011 SLTIU
xxxxxxxxxxxx rs1 100 rd 0010011 XORI
xxxxxxxxxxxx rs1 110 rd 0010011 ORI
xxxxxxxxxxxx rs1 111 rd 0010011 ANDI

Para montar o Imediato segue código sugestivo em Verilog e C/C++:

wire [31:0]  IMM_I             =   {{20{INS[31]}},INS[31:20]};

E no caso de um emulador C/C++:

int32_t imm_i      = (ins >> 20) & 0xfff;
if (imm_i & 0x800)          // se o bit 11 tá setado
    imm_i |= 0xFFFFF000;    // preenche o restante com 1s

Há outras formas em C/C++ de se fazer isso e é possível ainda otimizar utilizando instruções assembly que extraem bits com sinal estendido se houver na CPU que se escreve o emulador.

A instrução de salto incondicional JALR também utiliza o Tipo I de codificação de imediato, porém será explicada posteriormente.

O caso SLLI, SRLI e SRAI

As instruções de deslocamento à direita e a esquerda são do tipo I, porém tem tratamento diferente. O imm[11:0] contém duas informações, o shamt localizado no imm[4:0] contendo a quantidade de deslocamento que pode ser 0 a 31 e a distinção entre SRLI e SRAI. Lembre-se de que não há sentido deslocar mais que 31 bits, pois a palavra tem somente 32 bits, logo shamt tem tamanho de 5 bits. Finalmente, imm[30]diferencia SRLIde SRAI já que ambos têm o mesmo funct3.

  shamt rs1 funct3 rd op instrução
0000000 xxxxx rs1 001 rd 0010011 SLLI
0000000 xxxxx rs1 101 rd 0010011 SRLI
0100000 xxxxx rs1 101 rd 0010011 SRAI

A extração de shamt pode ser otimizada utilizando o mesmo código de rs2 e o discriminador com o mesmo código funct7.

Load e Store

As instruções de movimento da memória para o registrador e do registrador para a memória, Load e Store, têm o endereço armazenado em um dos registradores, que será denominado base, com a adição do imediato, denominado offset, de 12 bits codificado na instrução, também com sinal estendido, o que permite, então, apontar o endereço para cima ou para baixo do endereço apontado pelo registrador base.

Load

Load tem formato igual ao tipo I visto anteriormente. O imediato (offset) imm[11:0] é somado ao registrador rs1 e o resultado é usado como endereço de leitura da memória para ser armazenado no registrador rd.

funct3 faz distinção de 5 tamanhos de leitura, sendo eles bytes (8 bits) LB e LBU, half-words (16 bits) LH e LHU e words (32 bits) LW e se os tipos bytes e half-words fazem extensão do sinal ou não (LBU e LHU) no armazenamento. Não confundir com o sinal estendido do imediato/offset, aqui me refiro ao valor carregado da memória que, se for ,por exemplo, um byte o bit de sinal desse byte ( bit 7 ) será estendido no registrador rd ou não.

imm[11:0] rs1 funct3 rd op instrução
xxxxxxxxxxxx rs1 000 rd 0000011 LB
xxxxxxxxxxxx rs1 001 rd 0000011 LH
xxxxxxxxxxxx rs1 010 rd 0000011 LW
xxxxxxxxxxxx rs1 100 rd 0000011 LBU
xxxxxxxxxxxx rs1 101 rd 0000011 LHU

Store

Store tem tipo próprio denominado tipo S. O imediato (offset) foi divido em duas partes na instrução e localiza-se nos bits ins[31:25] e ins[11:7] que juntos montam o imediato de 12 bits que novamente tem seu sinal estendido e é somado ao conteúdo do registrador rs1 para indicar o endereço da memória que será gravada com o conteúdo do registrador rs2.

Por ser uma CPU moderna e bem projetada, percebe-se que rd de outros tipos virou imm[4:0] o que facilita na extração desses bits, permitindo utilizar código já existente.

imm[11:5] rs2 rs1 funct3 imm[4:0] op instrução
xxxxxxx rs2 rs1 000 xxxxx 0100011 SB
xxxxxxx rs2 rs1 001 xxxxx 0100011 SH
xxxxxxx rs2 rs1 010 xxxxx 0100011 SW

Tipo B

Esse conjunto reúne as instruções de salto condicional. Novamente aqui temos algumas diferenças que podem surpreender aqueles acostumados com x86, m68k: não há flags condicionais, as operações lógicas são efetuadas entre rs1 e rs2. BEQ, por exemplo, compara rs1 e rs2, se forem iguais, então salta para o destino, se diferente, continua na próxima instrução.

As instruções B carregam um imediato de 12 bits com sinal e como se pode ver na figura acima eles estão espalhados pela instrução. Perceba que no deslocamento final (32 bits) o bit 0 tem valor 0; como as instruções no RISC-V tem tamanho 32 bits ou 16 bits, nenhuma instrução estará em endereço com bit[0] = 1, sempre será com bit[0] = 0, então justifica-se deslocar o deslocamento de 12 bits um bit a esquerda para aumentar o range de deslocamento do salto. Novamente, o sinal é estendido no imediato final.

Montado o deslocamento final, funct3 seleciona a operação lógica entre rs1 e rs2, o resultado da operação é descartado, porém, se for verdadeiro o deslocamento somado ao PC da instrução será o pŕoximo PC, caso contrário a próxima instrução será executada (PC+4). Como o sinal é estendido o deslocamento pode ocorrer para frente do PC ou para trás em uma faixa (range) de +-4KiB.

Na ilustração, eu não atribuo a soma do deslocamento à ALU, isso é uma decisão do projetista, ele pode usar a ALU para somar o deslocamento ou usar um circuito separado para essa soma. As operações lógicas também podem ser executadas pela ALU ou em circuito separado.

Em arquitetura com pipeline, saltar para uma instrução significa descartar as instruções que já estão no pipeline.

[12] imm[10:5] rs2 rs1 funct3 imm[4:1] [11] op instrução
x xxxxxx rs2 rs1 000 xxxx x 1100011 BEQ
x xxxxxx rs2 rs1 001 xxxx x 1100011 BNE
x xxxxxx rs2 rs1 100 xxxx x 1100011 BLT
x xxxxxx rs2 rs1 101 xxxx x 1100011 BGE
x xxxxxx rs2 rs1 110 xxxx x 1100011 BLTU
x xxxxxx rs2 rs1 111 xxxx x 1100011 BGEU

Em Verilog a extração dos bits do imediato pode ser feita facilmente com o código sugerido abaixo:

wire [31:0]  IMM_B             =   {{20{INS[31]}},INS[7],INS[30:25],INS[11:8],1'b0};

E no caso de um emulador C/C++:

int32_t imm_b = 0;
imm_b        |=  (INS & 0x80) << 4;  // imm11
imm_b        |=  (INS & 0xf00) >> 7; // imm4_1
imm_b        |=  (INS & 0x7E000000) >> 20; // imm4_1

// Sinaliza (sign-extend) se bit 12 estava setado
if (INS & 0x80000000) {             // se bit 12 está em 1
    imm_b |= 0xFFFFE000;             // extende o sinal até 32 bits
}

Tipo J, JAL e JALR

Há duas instruções de salto incondicional: JAL e JALR. JAL tem codificação do Tipo J e JALR do Tipo I. Ambas as instruções salvam o endereço da próxima instrução no registrador rd e somam um deslocamento ao PC atual.

JAL

O deslocamento de JAL é diretamente extraído do imediato de 20 bits contidos em sua instrução, enquanto na instrução JALR o deslocamento é uma soma do imediato de 12 bits e o registrador rs1. Dessa forma, JAL pode saltar em uma faixa (range) de até +-1MiB enquanto JALR pode saltar em toda a faixa de 32 bits desde que rs1 seja previamente carregado.

Observe que salvar a próxima instrução e saltar é uma chamada de sub-rotina com retorno, porém não há nenhum salvamento automático da próxima instrução na memória, a sub-rotina é que deverá preservar rd para poder retornar ao ponto posterior à chamada. Se o salto não for para uma sub-rotina com retorno, basta chamar JAL/JALR com rd=x0 descartando assim PC+4.

Como se pode ver na ilustração, a extração do imediato do JAL é complicada. O deslocamento consiste então em 20 bits com sinal somado ao PC atual. O PC+4 é gravado no registrador rd que poderá então ser utilizado como retorno na chamada da sub-rotina. Novamente percebe-se que a arquitetura RISC-V é mínima, não há instruções de retorno de sub-rotina nem de interrupção.

[20] imm[10:1] [11] imm[19:12] rd opcode instrução
x xxxxxxxxxx x xxxxxxxx xxxxx 1101111 JAL
wire [31:0]     imm_j   =   {{12{IF[31]}},IF[19:12],IF[20],IF[30:21],1'b0};

Em C/C++, a extração pode ser feita com código sugerido abaixo.

int32_t imm_b = 0;
imm_b        |= (INS & 0b01111111111000000000000000000000) >> 20; // imm10_1
imm_b        |= (INS & 0b00000000000011111111000000000000);       // imm19_12  
imm_b        |= (INS & 0b00000000000100000000000000000000) >> 9;  // imm11  

if ((INS & 0x80000000))
    imm_b |= 0xFFF00000;

JALR

Enquanto os saltos condicionais têm um range útil de 4KiB de salto e o JAL de 1MiB o JALR permite saltar por toda a faixa de 32 bits de endereçamento. Ela consegue fazer isso utilizando o registrador rs1 como endereço base que será somado ao imediato de 12 bits da instrução. Dessa forma, como o imediato só consegue um salto na faixa de 4KiB o conteúdo do registrador rs1 é que de fato permitirá saltos em toda a faixa de 32 bits.

Assim como JAL, JALR guarda PC+4 no registrador rd.

Como JALR é do Tipo I, a extração de seu imediato de 12 bits já foi explorada e o mesmo código poderá ser utilizado. Como toda a faixa de 32 bits é alcançável via rs1, ao contrário do JAL, JALR não tem o bit[0] == 0, podendo então inclusive dar saltos em endereços não múltiplos de 2.

No ARM em modo ARM (32 bits) se ocorrer um branch (salto) para um endereço que não seja múltiplo de 4, uma exceção de alinhamento (Alignment Fault) será disparada, no modo Thumb se o endereço não for par, ocorre a mesma exceção. E no RISC-V? Uma exceção instruction-address-misaligned é disparada caso um salto tenha endereço que não seja múltiplos de 4 ou par, no caso da extensão C (compressed) estar presente.
imm[11:0] rs1 funct3 rd op instrução
xxxxxxxxxxxx rs1 000 rd 1100111 JALR

Tipo U

Se você chegou até aqui, percebeu que a única forma de carregar um registrador até agora é com as instruções Tipo I que contêm um imediato de 12 bits. Mas como carregar o restante dos 20 bits de um registrador? Há duas instruções que complementam a carga dos registradores: LUI e AUIPC.

LUI

LUI (load upper immediate) carrega os 20 bits mais significativos do registrador rd com o valor imediato, zerando os 12 bits inferiores. Para carregar uma constante de 32 bits, a instrução LUI é usada em conjunto com uma instrução do Tipo I, como a ADDI, utilizando o registrador x0. De fato, é um casamento de instruções tão importante que a maioria dos compiladores utiliza uma macro chamada li (load immediate) para carregar um registrador. Se o compilador detectar que a constante cabe em 12 bits, apenas uma instrução ADDI será emitida. Caso contrário, ele usará uma combinação de LUI e ADDI para compor o valor completo.

AUIPC

A instrução AUIPC (Add Upper Immediate to PC) soma um imediato ao valor atual do contador de programa (PC) e armazena o resultado no registrador rd.

Essa instrução é fundamental para permitir que executáveis sejam independentes da posição de memória (PIE) — como acontece em bibliotecas dinâmicas (DLLs), plugins e sistemas com ASLR.

Suponha que você precise chamar uma sub-rotina que, no momento da compilação, está localizada K bytes abaixo da instrução atual. Como o programa poderá ser carregado em posições diferentes da memória, você não pode usar um endereço absoluto.

Com AUIPC, é possível gerar um endereço relativo ao PC, somando o deslocamento K e armazenando o resultado em um registrador. Em seguida, você pode usar uma instrução como JALR para efetuar o salto para a sub-rotina.

imm[31:12] (20 bits) rd (5 bits) opcode (7 bits) Instrução
xxxxxxxxxxxxxxxxxxxx xxxxx 0110111 LUI
xxxxxxxxxxxxxxxxxxxx xxxxx 0010111 AUIPC

A extração do imediato é direto:

Verilog

wire [31:0]     imm_u   =   {INS[31:12],12'b0};

C/C++

int32_t imm_u = (int32_t)(INS & 0xFFFFF000);

Tabela resumo das instruções

Opcode [6:0] Instrução

imm[31:12]

rd

0110111

LUI

imm[31:12]

rd

0010111

AUIPC

imm[20|10:1|11|19:12]

rd

1101111

JAL

imm[11:0]

rs1

000

rd

1100111

JALR

imm[12|10:5]

rs2

rs1

000

imm[4:1|11]

1100011

BEQ

imm[12|10:5]

rs2

rs1

001

imm[4:1|11]

1100011

BNE

imm[12|10:5]

rs2

rs1

100

imm[4:1|11]

1100011

BLT

imm[12|10:5]

rs2

rs1

101

imm[4:1|11]

1100011

BGE

imm[12|10:5]

rs2

rs1

110

imm[4:1|11]

1100011

BLTU

imm[12|10:5]

rs2

rs1

111

imm[4:1|11]

1100011

BGEU

imm[11:0]

rs1

000

rd

0000011

LB

imm[11:0]

rs1

001

rd

0000011

LH

imm[11:0]

rs1

010

rd

0000011

LW

imm[11:0]

rs1

100

rd

0000011

LBU

imm[11:0]

rs1

101

rd

0000011

LHU

imm[11:5]

rs2

rs1

000

imm[4:0]

0100011

SB

imm[11:5]

rs2

rs1

001

imm[4:0]

0100011

SH

imm[11:5]

rs2

rs1

010

imm[4:0]

0100011

SW

imm[11:0]

rs1

000

rd

0010011

ADDI

imm[11:0]

rs1

010

rd

0010011

SLTI

imm[11:0]

rs1

011

rd

0010011

SLTIU

imm[11:0]

rs1

100

rd

0010011

XORI

imm[11:0]

rs1

110

rd

0010011

ORI

imm[11:0]

rs1

111

rd

0010011

ANDI

0000000

shamt

rs1

001

rd

0010011

SLLI

0000000

shamt

rs1

101

rd

0010011

SRLI

0100000

shamt

rs1

101

rd

0010011

SRAI

0000000

rs2

rs1

000

rd

0110011

ADD

0100000

rs2

rs1

000

rd

0110011

SUB

0000000

rs2

rs1

001

rd

0110011

SLL

0000000

rs2

rs1

010

rd

0110011

SLT

0000000

rs2

rs1

011

rd

0110011

SLTU

0000000

rs2

rs1

100

rd

0110011

XOR

0000000

rs2

rs1

101

rd

0110011

SRL

0100000

rs2

rs1

101

rd

0110011

SRA

0000000

rs2

rs1

110

rd

0110011

OR

0000000

rs2

rs1

111

rd

0110011

AND

fm

pred

succ

rs1

000

rd

0001111

FENCE

1000

0011

0011

00000

000

00000

0001111

FENCE.TSO

0000

0001

0000

00000

000

00000

0001111

PAUSE

000000000000

00000

000

00000

1110011

ECALL

000000000001

00000

000

00000

1110011

EBREAK