#Introduction :
Pour ce projet j'ai préféré travailler seul (comme sur tout les autres projets) car cela me force a être autonome et a maitriser le projet de A à Z. Je n'ai n'y essayé de faire au plus vite ni d'implémenter le plus de fonction possible, mais plutôt de faire une architecture la plus optimisée et concise possible, même si cela m'a forcé forcé à recommencer plusieurs fois (parfois de zéro).
Plutôt que de ré-expliquer la partie commune a tout les projet (Qu'est-ce qu'un pipeline, un chemin de données, un aléa ...), ce rapport se concentrera sur les points qui le différencie des autres ainsi que les choix d'implémentations qui ont étaient fait et leur raisons.
La présentation des différents travaux réalisés sera répartie selon leur emplacement dans le chemin de données. Enfin, nous terminerons, avec les travaux qui ne sont pas en rapport avec un étage spécifique.
Ce projet a été synchronisé avec github.com/yne/R3K il est donc possible d'en afficher toute les versions afin de voir en détail les différentes itérations que j'ai faites sur le code.
Cet étage contient que 3 entités (ei_pc_mux, reg_pc, icache). Leurs rôles se limitent à modifier le PC et à fetcher depuis le cache d'instruction l'instruction pointée par le PC.
Le registre PC peut être modifié par plusieurs sources d'adresse :
- Son adresse suivante
- Adresse d'un saut par registre (JR/JALR)
- Adresse d'un saut par valeur immédiate (J/JAL)
- Adresse d'un branchement immédiat/par registre (B*)
J'ai décidé d'implémenter ces modifications au plus tôt dans le pipeline. Les valeurs sont donc disponibles à différents étages dans le pipeline :
- CP+4 : disponible en EI
- JR/JALR : disponible en DI
- J/JAL : disponible en DI
- BNE,BLE... : disponible en EX
Il nous faut donc un mux avec plusieurs entrées et plusieurs flags en paramètre et qui prendrait l'adresse la plus avancée parmi toutes celles valides :
with(ei_pc_mux) select ei_pc_next <=
reg_PC(PC'range)+1 when PC_NEXT, -- "+" surchargé par l'entity adder
di_pc_branch when PC_BRANCH,-- BEQ,BNE,BLEZ,BGTZ,BLTZ,BGEZ,BLTZAL,BGEZAL
ei_pc_jump when PC_JUMP, -- J/JAL
di_qa(PC'range) when PC_JUMP_R;-- JR/JALR
Voici l'agorithme de décision du chemin à emprunter :
ei_pc_mux <= PC_BRANCH when
((reg_EX_ME.me_ctrl.BRANCH=B_NE) and (not(reg_EX_ME.ual_Z='1') ))
or ((reg_EX_ME.me_ctrl.BRANCH=B_EQ) and ( (reg_EX_ME.ual_Z='1') ))
or ((reg_EX_ME.me_ctrl.BRANCH=B_LE) and ( (reg_EX_ME.ual_Z='1') or (reg_EX_ME.ual_V='1' xor reg_EX_ME.ual_N='1')))
or ((reg_EX_ME.me_ctrl.BRANCH=B_LT) and (not(reg_EX_ME.ual_Z='1') and (reg_EX_ME.ual_V='1' xor reg_EX_ME.ual_N='1')))
or ((reg_EX_ME.me_ctrl.BRANCH=B_GT) and (not(reg_EX_ME.ual_Z='1') and not(reg_EX_ME.ual_V='1' xor reg_EX_ME.ual_N='1')))
or ((reg_EX_ME.me_ctrl.BRANCH=B_GE) and ( (reg_EX_ME.ual_Z='1') or not(reg_EX_ME.ual_V='1' xor reg_EX_ME.ual_N='1')))
else PC_JUMP when di_ctrl_di.JUMP_IMM
else PC_JUMP_R when di_ctrl_di.JUMP_REG
else PC_NEXT;
On remarque que le cas du BRANCH est le plus prioritaire (car disponible tardivement en EX), suivi par les JUMP (disponible en DI) et en dernier le PC+4 si aucune des conditions précédentes n'est remplie.
Au fur et à mesure que j'implémentais les différents op-codes de l'unité de contrôle, j’avais la sensation grandissante que cette unité pouvait être factorisable en fonction des bits des instructions qu'elle traitait. Ce sont surtout les instructions SIGNED/UNSIGNED (avec seulement leurs derniers bits qui varient) qui m'ont mis la puce à l'oreille.
A ma première tentative, l'unité de contrôle complète tenais dans (180 lignes). Et voici la version après réécriture complète en 40 lignes (soit 5 fois plus légère):
di_ctrl_di <= DI_DEFL; -- IMM_UNSIGNED,'0','0'
di_ctrl_ex <= EX_DEFL; -- SUB,ALU_SIGNED,REGS_QA,REGS_QB,REG_RT
di_ctrl_me <= ME_DEFL; -- MEM_32,B_NULL,MEM_READ,MEM_UNSIGNED,'0'
di_ctrl_er <= ER_DEFL; -- REG_WRITE,ALU_S
if(OP=TYPE_R)then -- REG ARITHMETIC
di_ctrl_di.JUMP_REG <= '1' when F=JR or F=JALR;
di_ctrl_ex.REG_DST <= REG_RD;--
di_ctrl_ex.ALU_SIGNED<= F(0);-- 1=unsigned
di_ctrl_ex.ALU_SRCA <= REGS_QB when F=LSL or F=LSR;
di_ctrl_ex.ALU_SRCB <= VAL_DEC when F=LSL or F=LSR;
di_ctrl_er.REGS_SRCD <= NextPC when F=JR or F=JALR;
di_ctrl_er.REGS_WE <= REG_RDONLY when F=JR;
di_ctrl_ex.ALU_OP <= LSR when F=JR or F=JALR else F;
if(f=SYNC)then report "BREAK INSTR" severity FAILURE;end if;
elsif(OP=TYPE_B)then -- REG BRANCH
di_ctrl_ex.ALU_SRCB <= IMM_0;
with(B) select di_ctrl_me.branch <=
B_LT when BLTZ|BLTZAL,B_GE when BGEZ|BGEZAL,B_NULL when others;
elsif OP(5 downto 4) = "10" then -- MEMOIRE L*/S*
di_ctrl_ex.REG_DST <= REG_RD when OP(3)='1' else REG_RT;
di_ctrl_me.DC_RW <= OP(3);
di_ctrl_me.DC_DS <= OP(1 downto 0);
di_ctrl_me.DC_SIGNED <= OP(2);
di_ctrl_er.REGS_WE <= OP(3);
elsif OP(5 downto 3) = "001" then -- IMM ARITHMETIC (REG_RT=REGS_QA <?> IMMD)
di_ctrl_er.REGS_SRCD <= ALU_S;
di_ctrl_ex.ALU_SRCA <= IMMD when OP=LUI;
di_ctrl_ex.ALU_SRCB <= IMM_16 when OP=LUI else IMMD;
di_ctrl_ex.ALU_SIGNED <= OP(0);-- 1=unsigned
di_ctrl_di.SIGNED_EXT <= IMM_SIGNED when OP=ADDI or OP=ADDIU or OP=SLTI;
di_ctrl_ex.ALU_OP <= OP;
elsif OP(5 downto 2) = "0001" then -- IMM BRANCH (JUMP_BRA = r[rs]<?>r[rt/0])
with(OP) select di_ctrl_me.branch <=
B_EQ when BEQ,B_NE when BNE,B_LE when BLEZ, B_GT when BGTZ,B_NULL when others;
elsif OP(5 downto 1) = "00001" then -- JUMP (J/JAL)
di_ctrl_di.JUMP_IMM <= '1';
di_ctrl_er.REGS_WE <= REG_WRITE when OP = JAL else REG_RDONLY;
di_ctrl_ex.REG_DST <= REG_31 when OP = JAL else REG_RD;
end if;
On remarque que :
- Les attributs les plus utilisés par les différents étages sont mis dans la valeur par défaut de l'étage (valeurs qui sont assignées dès le début de la fonction). Ainsi, même si aucun ALU_OP n'est spécifié (comme dans les étages REG BRANCH_R et IMM BRANCH) c'est l'opération SUB qui sera effectuée (car la plus utilisée par l'ensemble des étages).
- Le choix du code opération de l'ALU est déporté dans l'ALU elle même (dont nous verrons en détail la raison dans le chapitre EX).
- Le code des instructions contient souvent les attributs à certain offset. Par exemple, pour les instructions de type registre, on a les codes fonctions suivants :
Name | Code |
---|---|
ADD | 10000 0 |
ADDU | 10000 1 |
SUB | 10001 0 |
SUBU | 10001 1 |
SLT | 10101 0 |
SLTU | 10101 1 |
LSL | 00000 0 |
LSR | 00001 0 |
JR | 00100 0 |
JALR | 00100 1 |
iAND | 10010 0 |
iOR | 10010 1 |
iXOR | 10011 0 |
iNOR | 10011 1 |
On remarque que le bit de poids faible des opérations arithmétiques contient le signe de l'opération (signed/unsigned).
On peut donc simplement écrire di_ctrl_ex.ALU_SIGNED<= F(0);
pour mettre l'attribut à la bonne valeur.
Cela signifie que les autres instructions (sauts, opérations booléennes et opérations décalages) auront cet attribut de signe positionné a un étant qui ne leur correspond pas. Mais ces derniers ne prennent pas en compte cet attribut donc ce n'est pas un problème
Un exemple plus flagrant avec les instructions de type mémoires :
Name | Code |
---|---|
LB | 10 0 0 00 |
LH | 10 0 0 01 |
LW | 10 0 0 11 |
LBU | 10 0 1 00 |
LHU | 10 0 1 01 |
SB | 10 1 0 00 |
SH | 10 1 0 01 |
SW | 10 1 0 11 |
On remarque que les bits sont de la forme :
Bits | Description | valeurs |
---|---|---|
5-4 | toujours | 10 |
3 | Type | 0 =Load, 1 =Store |
2 | Signe | 0 =signed, 1 =unsigned |
1-0 | Taille | nb d'octets 00 =1, 01 =2, 10 =3, 11 =4 |
Cette unité de contrôle est valide, et le processeur produit bien successivement les valeurs 0,1,-1,-1,2,-2,0 et 8 dans les registres.
00100000000000000000000000000001;ADDI r0,0001=> r0
00100000000000010000000000000001;ADDI r0,0001=> r1
00100000000000101111111111111111;ADDI r0,FFFF=> r2
00100100000000111111111111111111;ADDIU r0,FFFF=> r3
00000000001000010010011111100000;ADD r1,r1 => r4
00000000010000100010111111100001;ADDU r2,r2 => r5
00000000000000000000000000000000;LSL r0,0 => r0 (NOP)
00000011111000010011100011000000;LSL r1,4 => r7
00000000000000000000000000000000;NOP
00000000000000000000000000000000;NOP
00000000000000000000000000000000;NOP
00000000000000000000000000001111;SYNC
00000000000000000000000000001000;JR r0
Nous pouvons optimiser encore plus et directement envoyer la partie de l'instruction représentant la taille de l'accès mémoire en entrée de notre banc mémoire générique.
C'est ce qui est fait dans la ligne di_ctrl_me.DC_DS <= OP(1 downto 0)
Il serait possible d'optimiser encore plus l'unité en déportant le switch qui assigne le ALU_OP dans l'alu en créant un ALU_CTRL
à coté de l'alu et en faisant suivre l'instruction jusqu'à
l'étage de l'ALU (EX) pour que l'ALU_CTRL fasse elle-même le switch pour déterminer l'opération.
L'étage EX posede 1 ALU est 3 mux :
Qui met en entrée A de l'ALU, les signaux :
- QA pour les opérations type registre (excepté LSL/LSR)
- QB pour les opérations LSL/LSR
- IMMD pour les opérations arithmétiques immédiates
Qui met en entrée B de l'ALU, les signaux :
- QB : pour la plupart des opérations type registre
- IMM : pour toutes les opérations de type arithmétique à valeur immédiate
- VALDEC : pour les instructions LSL et LSR qui contiennent le nombre de décalage dans leur zone "valdec"
- 16 : pour l'instruction LUI qui nécessite un décalage à gauche de 16 bits
- 0 : pour les comparaisons type BNEZ (comparaison "QA vs 0")
Il serait possible de diminuer le nombre d'entrée de ce mux (actuelement de 5) en redirigeant le mux vers le signal VALDEC qui contiendrait la valeur constante (0, 16) mise en place par l'unité de contrôle. Mais cela sous-entendrait que l'unité de contrôle aurait accès à ces signaux en "out" ce qui n'est pas le cas actuellement, et qui serait impossible sans ajouter une grande quantité de fil, alors que notre but est d'en économiser...
Ce registre est en charge de définir le registre de destination où sera stocké le résultat (qui peut-être soit une lecture mémoire, la sortie de l'ALU, le prochain emplacement du PC)
Comme nous l'avons vue précédemment, le contrôle de l'opération de l'ALU a été déplacé dans l'ALU elle-même. La raison est simple, cela permet d'éviter 2 switch dans le code de l'unité de contrôle et de les combiner à la place du switch existant dans l'ALU. L'ALU ne switchera donc plus sur ALU_OP mais sur l'OPCODE ou le FCODE de l'instruction :
when iAND|ANDI => tmp_S := A and B;
when iOR |ORI => tmp_S := A or B;
when iNOR => tmp_S := A nor B;
when iXOR|XORI => tmp_S := A xor B;
when LSL |LUI => tmp_S := shl(A,B);
when LSR => tmp_S := shr(A,B);
Les instructions type registre
JR
etJALR
entre respectivement en conflit avecADDI
etADDIU
. Donc à la réception d'unJR
l'ALU pensera avoir à faire à unADDI
or le JR doit faire effectuer unLSR
à l'ALU. La solution est donc de ne pas envoyer le FCODE pour leJR
et leJALR
mais directement l'LSR
:
di_ctrl_ex.ALU_OP <= LSR when F=JR or F=JALR else F;
Le registre mémoire a été l'une des premières entités sur laquelle j'ai compris qu'il y avait un potentiel d'optimisation.
J'ai donc décidé de la modifier pour qu'elle puisse accepter n'importe quel taille de donnée en entrée en utilisant un natural
comme attribut du DS.
Puis, dans une seconde itération j'ai décidé de n'utiliser que des std_logic_vector
car cela permettait de lui mettre en entrée directement les bits des opcodes sans avoir à passer par un conv_integer
.
P_CACHE: process(CLK, RST, ADR, AS, RW, DS, SIGN) begin
if RST = '0' then
REGS <= (others=>(others=>(others=>'0'))) when STRING'(FILENAME)="" else LOAD_FILE(STRING'(FILENAME));
else
if AS = '0' then
Q <= (others => 'Z');
else
if (DS/=MEM_8 and ADR(natural(log2(real(conv_integer(DS)+1)))-1 downto 0) /= ZERO)then
Berr <= '0';
else
Berr <= '1';
if RW = MEM_READ then
Q <= (others => '0');
for i in 0 to conv_integer(DS) loop
Q((i+1)*8 - 1 downto i*8) <= REGS(conv_integer(ADR(I_ADR'range)))(conv_integer(ADR(B_ADR'range))+i);
end loop;
if SIGN = MEM_SIGNED then
Q(DBUS_WIDTH-1 downto (conv_integer(DS)+1)*8) <= (others=>REGS(conv_integer(ADR(I_ADR'range)))(conv_integer(ADR(B_ADR'range)) + conv_integer(DS))(0));
end if;
else -- MEM_WRITE
if CLK'event and CLK=ACTIVE_FRONT then
for i in 0 to (conv_integer(DS)) loop
REGS(conv_integer(ADR(I_ADR'range)))(conv_integer(ADR(B_ADR'range)) + i) <= D((i+1) * 8 - 1 downto i*8);
end loop;
end if;
end if;
end if;
end if;
end if;
end process;
Cette dernière modification permet à l'entité memory d'obtenir directement la taille à copier à partir du code de l'instruction : di_ctrl_me.DC_DS <= OP(1 downto 0);
L'étage ER ne possède qu'un mux servant a rediriger la source du résultat (REGS_SRCD) vers le banc de registre. Les différentes sources de résultats sont :
- PC : le prochain PC (utilisé pour stocker dans un registre, le plus souvent le 31, l'adresse de retour de la fonction)
- Q : sortie mémoire (opération de Load)
- S : sortie de l'UAL (la plupart des opérations en général)
er_adrw<=reg_ME_ER.reg_dst;
with(reg_ME_ER.er_ctrl.REGS_SRCD) select er_regd <=
reg_ME_ER.mem_Q when MEM_Q,
reg_ME_ER.ual_S when ALU_S,
conv_std_logic_vector(conv_integer(reg_ME_ER.pc_next), DATA'length) when NextPC;
end behavior;
Il doit y avoir un meilleur moyen d'arriver a maper deux std_logic_vector de taille différentes peut être en utilisant
my_vec & "00"
?
La sortie du MUX_SRCD
est stoquée dans la banc de registre indiqué par REG_W
a condition que er_reg_w
autoriser l'ecriture dans le bac.
Une optimisation possible serait de se passer totalement du
er_reg_w
et de laisser toujours l'ecriture possible et de pointerREG_W
versR0
lorsque l'on ne veu pas ecrire le resultat dans un registre puisque le registreR0
registre reste a 0 quoi que l'on y ecrive.
Je trouvais frustrant de ne pas avoir de contrôle sur l'arrêt de la simulation autre que le timeout, j'ai donc décidé d'ajouter une instruction SYNC (famille des instructions TYPE_R, FCODE=001111) Cette instruction provoque le code VHDL suivant :
if(f=SYNC)then
report "BREAK INSTR" severity FAILURE;
end if;
Cela permet de mettre un terme à la simulation proprement (à l'image d'un exit() en C).
J'ai supprimé toute trace de la fonction cpu_package.log2
au profit de la librairie IEEE.math_real.log2
car :
- Si ça existe déjà, pourquoi alourdir le code avec des fonctions doublon ?
- elle est surement bien faite (c'est l'IEEE quand même !)
Pour m'occuper (et parce-que M Thiebolt nous avais proposé de le faire) j'ai décidé de créer mon propre assembleur R3000 en me basant sur un de mes anciens projets d'assembleur MIPS. Le résulta est un assembleur codé en langage web capable de générer du code à la volée (génération en parallèle de la saisie de l'utilisateur) Sa force réside dans le fait qu'il est syntaxiquement beaucoup moins strict que l'assembleur r3kasm2 mais il ne gère pas (encore) les labels.
Pour m'aider lors de la conception du R3K j'utilisais très souvent le schémas du pipeline fourni par M Thiebolt.
Mais pour vérifier que mon code suivait bien mes dessins, je devais faire de fastidieuses vérifications de code.
J'ai donc décidé de faire un outil capable de régénérer le graphique des étages du pipeline directement à partir du code source risc.vhd
Le résultat est assez impressionnant et plus satisfaisant que je ne l'espérais :
Même si je n'ai pas tout fait (cache,envoie,...) je me suis bien amusé sur ce projet. Cela a été pour moi un excellent exercice qui m'a permis de bien maitriser VHDL (ce qui pour moi etais un des objectifs principaux de ce projet) ainsi que l'outil ModelSim que l'on adore détester mais qui au final s'est avéré être extrêmement puissant et personnalisable.