Pwning N64 for fun & speedrun : Instruction Set
Io les mousdugenou,
Ouais ouais, je sais, la dernière fois, j’ai dit qu’on ferait du rom hacking sur ce blogpost. J’ai menti, et je suis un méchant pas beau, c’est pas une nouveauté.
Bref, souvenez vous, j’avais parlé de la N64 d’une manière globale, en présentant un peu tous les concepts. Mais je suis passé très très vite sur une partie importante, pour en faire un blogpost complet : les instructions du CPU MIPS. Ca va être un article chiant, pas super instructif (quoi que), et qui va pas plaire. MAIS. Il est important pour assimiler facilement la suite.
Today, on va donc parler de l’Instruction Set en détails, et je vais principalement expliquer les instructions les plus utilisées sur du MIPS*, avec un gros listing des milles-fa, et quelques commentaires.
So, have fun, dudes ! (ou pas. Même moi, j’aurais pas envie de le lire ce post)
Avant de commencer
Il est nécessaire, pour que vous compreniez tous -même pour les sagouins qui n’ont pas lu mon article précédent-, de donner quelques explications globales. On peut comprendre le jeu d’instructions sans ce paragraphe, mais c’est toujours intéressant à connaitre.
Les classes d’instructions
Les instructions MIPS ont été définies en plusieurs classes :
- I family : ici, ce sont les instructions utilisant un Immediate dans les paramètres.
- J family : dédié uniquement à J et JAL.
- R family : pour les instructions tapant les registres. Ce sont ces commandes qui utilisent le paramètre Func, dont je parle dans le paragraphe suivant.
En fait, comme chaque instruction est codée sur 32 bits, on a défini ces classes en fonction de la manière de lire les bits : selon les familles, le découpage et la lecture est fait différemment au niveau binaire, afin d’optimiser le nombre de bits utiles.
Classe I(mmediate)
opcode | Source | Target | Imm |
---|---|---|---|
6 bits | 5 bits | 5 bits | 16 bits |
Elle est représentée comme ci dessus. Les 6 bits d’opcode sont necessaires pour identifier l’instruction. Ensuite, ona a un registre source et destination de 5 bits chacun, puis un Immediate de 16 bits. Cette classe a été optimisée pour avoir le plus grand Immediate possible.
Classe J(ump)
opcode | Address |
---|---|
6 bits | 26 bits |
Comme précédemment, on a 6 bits d’opcode, puis uniquement une adresse codée sur 26 bits, afin de pouvoir aller le plus loin possible en un seul saut.
Dans cette classe, et ça peut paraître idiot, on ne trouve pas d’autre instruction de saut comme JR.
“Pourquoi ?” allez vous me demander ?
Tout simplement parce que JR utilise un registre comme paramètre, ce qui fait que d’une part, il n’y aucun intérêt à avoir 26 bits pour un registre, et d’autre part, la classe registre est faite pour ça. Ca évite donc ici d’avoir une exception du genre ‘Si c’est JR, alors c’est pas une adresse, mais un registre’.
Classe R(egister)
opcode | Source | Target | Dest | Shift | function |
---|---|---|---|---|---|
6 bits | 5 bits | 5 bits | 5 bits | 5 bits | 6 bits |
Dernière classe, mais la plus compliquée à apréhender, principalement en raison du paramètre Func : la famille Register.
Comme toujours, 6 bits pour l’opcode, puis 5 bits de source, 5 de cible, et 5 de destination (attention, faut pas les confondre).
On trouve ensuite 5 bits utilisés uniquement dans le cadre des instructions de bit shifting, puis 6 bits de Func, dont je parle plus loin.
Ces deux derniers paramètres ont l’air d’avoir été un peu “rajoutés” parce qu’on a pas trouvé moyen de faire autrement.
Cela dit, mettre le shift dans un paramètre à part est totalement logique : Source/Target/Destination doivent être des registres, et Func a une fonction totalement différente. Du coup, le seul endroit où mettre les infos de shift, c’était là :D.
Concernant Func, il est là pour palier au “manque de place” sur les opcodes. Ces derniers sont codés sur 6 bits, ce qui ne fait que 32 instructions possibles. Si on rajoute les bits de Func, on augmente considérablement le nombre total d’instructions. Et comme il n’y a besoin que de 26 bits pour contenir les autres informations nécessaires aux instructions de la famille R, le compte est bon.
Mais pourquoi ils ont fait çaaaaaa ?
En fait, tout ce découpage n’a pas réellement de sens niveau dev. Il est même carrément idiot car on se retouve avec des ADD et des Jump dans la même famille. C’est pour ça qu’on trouve plus souvent des listings comme celui que j’ai fait plus bas.
L’avantage principal, c’est que chaque classe est optimisée par rapport à ses paramètres, et on ne perd donc pas de bit inutilement.
Au passage, il existe plus ou moins d’autres familles pour les instructions dédiées aux co-processeurs, mais on va pas charger la mule, sinon vous risquez de péter une durite.
Les opcodes
La notion d’opcode en MIPS est un peu fucked up. Il existe en fait des instructions ayant un opcode unique (les classes I et J), et d’autres (la fameuse classe R) qui se partagent le même opcode (par exemple, ADD et Jump Return ont tous les deux l’opcode 0x00).
Si on regarde le détail du parsing de la classe R, on voit qu’on a 6 bits de Func, qui sont utilisés comme une espèce de “sous opcode” dégueulasse qui permet de distinguer les fonctions. Ainsi, même si ADD et JR ont pour opcode 0x00, le premier aura comme Func 0x20, et le second 0x08.
Au passage, quand je dis dégueulasse, c’est faux, les mecs qui ont pondu ça ont plutôt bien joué leur coup, car cela permet d’optimiser la place de chaque type d’instruction. Comme les instructions sur les registres sont les plus importantes, il n’a pas été jugé utile de mettre un paramètre Func ailleurs.
Le problème, c’est que pour parler de ces opcodes en hexa, c’est relou, car ce n’est pas “juste” l’opcode, mais des fois l’opcode ET une func. Faut aussi prendre en compte que ça sera plus galère de chercher un opcode hexa dans un dump. Et comme en plus ils sont codés sur 6 bits, on peut même pas se baser sur un octet comme recherche, puisque les 2 derniers bits sont utilisés pour des paramètres.
Les bits variables
Ok, ce titre n’a pas de sens, et ce paragraphe n’a d’ailleurs que peu d’utilité pour comprendre l’instruction set, mais j’ai de la confiture à étaler sur ma culture (ou un truc comme ça).
Certaines instructions, comme LUI, ont ce que j’appelle des bits variables, ou encore des “On s’en bat le bit”. En gros, peu importe les bits qu’on met, l’instruction sera la même.
Si on regarde par exemple le détail de LUI, on voit que c’est une instruction de la famille I (cf plus haut), famille qui attend comme argument une source, une cible, et un Immediate.
Mais LUI n’a pas besoin de paramètre Source. Du coup, peu importe ce qu’on met dans les 5 bits de ce champ, le proc ne s’en souciera pas…
Les instructions de calcul
Je met ça ici, mais ça vaut pour tous les paragraphes : dans mon listing, un élément faisant référence à un registre sera noté $XXX. Un élément renvoyant à une adresse mémoire sera noté *XXX. Une valeur fixe sera notée Imm.
Les additions et soustractions
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
ADD $dst, $src1, $src2 | $dst = $src1 + $src2 | 0x00 | 0x20 | Les opérandes sont signées |
ADDU $dst, $src1, $src2 | $dst = $src1 + $src2 | 0x00 | 0x21 | Les opérandes ne sont PAS signées |
ADDI $dst, $src, Imm | $dst = $src + Imm | 0x08 | N/A | Les opérandes sont signées |
ADDIU $dst, $src, Imm | $dst = $src + Imm | 0x09 | N/A | Les opérandes ne sont PAS signées |
SUB $dst, $src1, $src2 | $dst = $src1 - $src2 | 0x00 | 0x22 | Les opérandes sont signées |
SUBU $dst, $src1, $src2 | $dst = $src1 - $src2 | 0x00 | 0x23 | Les opérandes ne sont PAS signées |
Remarque :
- Non. Il n’y a pas de soustraction avec des Immediates. Dunno why.
- Pour les opérations signées, elles génèrent un overflow exception géré par le coprocesseur.
Les multiplications et divisions
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
MULT $src1, $src2 | $LO = $src1 * $src2 | 0x00 | 0x18 | Les registres sont signés |
MULTU $src1, $src2 | $LO = $src1 * $src2 | 0x00 | 0x19 | Les registres ne sont PAS signés |
DIV $src1, $src2 | $LO = $src1 * $src2 | 0x00 | 0x1A | Les registres sont signés |
DIVU $src1, $src2 | $LO = $src1 * $src2 | 0x00 | 0x1B | Les registres ne sont PAS signés |
Remarques :
- $LO est toujours le registre dans lequel on met le résultat d’une multiplication/division.
- $HI est toujours les registre dans lequel on met le reste d’une division.
Les sauts
Les sauts classiques
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
J Imm | Goto Imm | 0x02 | N/A | bahhh, c’est un Jump quoi… Il est aussi noté B des fois (pour Branch). Imm est codé sur 26 bits |
JAL Imm | $RA = addr JAL+8; Goto Imm | N/A | 0x03 | Jump And Link. J’en ai assez parlé dans l’article précédent. Imm est codé sur 26 bits |
JR $src | Goto $src | 0x00 | 0x08 | Jump Return. Saute vers le registre donné, usuellement $RA |
Les branches
Les branches, c’est comme des sauts, mais avec une condition. Il y en a un paquet, mais je ne vais pas mettre les moins utilisées, ça sert à rien.
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
BEQ $src1, $src2, Imm | if $src1 == $src2; Goto Imm | 0x04 | N/A | Branch on EQual. Offset sur 2 octets |
BNE $src1, $src2, Imm | if $src1 != $src2; Goto Imm | 0x05 | N/A | Branch on Not Equal. Offset sur 2 octets |
BGEZ $src, Imm | if $src >= 0; Goto Imm | 0x01 | N/A | Branch on Greater or Equal to Zero. Offset sur 2 octets |
BGTZ $src, Imm | if $src > 0; Goto Imm | 0x07 | N/A | Branch on Greater Than Zero. Offset sur 2 octets |
BLEZ $src, Imm | if $src <= 0; Goto Imm | 0x06 | N/A | Branch on Less or Equal to Zero. Offset sur 2 octets |
BLTZ $src, Imm | if $src < 0; Goto Imm | 0x01 | N/A | Branch on Less Than Zero. Offset sur 2 octets |
Remarque :
- Ces instructions ont une version Likely (eg. BEQL). En gros, la Branch va forcément charger l’instruction juste en dessous la branche, mais si la condition est fausse, le proc va invalider cette instruction, et on aura “juste” l’impression de sauter une instruction.
Les opérations sur les bits
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
AND $dst, $src1, $src2 | $dst = $src1 & $src2 | 0x00 | 0x24 | Un AND classique |
ANDI $dst, $src, Imm | $dst = $src & Imm | 0x0C | N/A | Un AND classique avec un Immediate |
OR $dst, $src1, $src2 | $dst = $src1 | $src2 | 0x00 | 0x25 | Un OR classique |
ORI $dst, $src, Imm | $dst = $src | Imm | 0x0D | N/A | Un OR classique avec un Immediate |
XOR $dst, $src1, $src2 | $dst = $src1 ^ $src2 | 0x00 | 0x26 | Un XOR classique |
XORI $dst, $src, Imm | $dst = $src ^ Imm | 0x0E | N/A | Un XOR classique avec un Immediate |
SLL $dst, $src, Imm | $dst = $src << Imm | 0x00 | 0x00 | Shift Left Logical. Shift codé sur 5 bits |
SRL $dst, $src, Imm | $dst = $src >> Imm | 0x00 | 0x02 | Shift Right Logical. Shift codé sur 5 bits |
SRA $dst, $src, Imm | $dst = $src >> Imm | 0x00 | 0x03 | Shift Right Arithmetic. Comme SRL, mais on garde le bit de signature |
Les mouvements d’information
C’est un titre un peu foireux, mais en gros, c’est tout ce qui permet de charger vers/depuis la heap, plus deux trois autres trucs du même genre.
Syntaxe | Description | Opcode | Func | Commentaires |
---|---|---|---|---|
LB $dst, Imm(*src), | $dst = *(*src+Imm) | 0x20 | N/A | Load Byte. On prend *src, auquel on ajoute Imm, et on met la valeur de l’adresse (*src+Imm) dans $dst |
LW $dst, Imm(*src) | $dst = *(*src+Imm) | 0x23 | N/A | Load Word. Comme LB, mais sur un Word |
SB $src, Imm(*dst) | *(*dst+Imm) = $src | 0x28 | N/A | Store Byte. C’est l’inverse de LB. On prend la valeur d’un registre, et on le met dans l’adresse spécifiée. |
SW $src, Imm(*dst) | *(*dst+Imm) = $src | 0x2B | N/A | Store Word. Commet SB, mais sur un Word |
LUI $dst, Imm | $dst = Imm << 16 | 0x0F | N/A | Load Upper Immediate. Ca permet de charger une valeur dans la partie haute d’un registre |
MFHI $dst | $dst = $HI | 0x00 | 0x10 | Move From HI (le reste d’une division) |
MFLO $dst | $dst= $LO | 0x00 | 0x12 | Move From LO (le réstultat d’une multiplication ou division) |
Remarque :
- Pour ceux qui se posent des questions sur tout le bazar sur les chargements/sauvegardes de données, go lire la partie “Quelques tricks” de mon blogpost précédent.
Les conditions
Ces instructions sont plutôt pratiques, elles permmettent de faire des if en une seule ligne :) tldr : ça remplace le flag Zero sur x86.
Syntaxe | Description | Commentaires |
SLT $dst $src1, $src2 | $dst = 0; if $src1 < $src2 ; $dst = 1 | Set if Less Than. Plutôt obvious comme nom |
SLTI $dst $src, Imm | $dst = 0; if $src1 < Imm ; $dst = 1 | Pareil, mais avec un Immediate |
Remarque :
- Il existe les versions unsigned, mais je ne les ai jamais croisées.
Le coprocesseur
Comme je l’ai dit dans mon précédent blogpost, il existe sur MIPS un coprocesseur qui gère tout ce qui est nombres flottants (FP). Ce coproc a un instruction set dédié, et totalement différent de ce qu’on croise d’habitude.
Globalement, il n’est pas là pour toute “l’intelligence” du programme, mais juste pour la partie calcul des floating. Du coup, le programme principal lui passe les datas, le coproc va effectuer ses calculs et redonner les infos au proc principal. Pour lui envoyer les données, on utilise les fonctions suivantes :
Syntaxe | Description | Commentaires |
---|---|---|
MTC1 $src, $dst | $dst = $src | Move To Coproc 1. La source est un registre général, et la destination un registre floating. Le 1 indique ici qu’on déplace vers le coproc 1. On pourrait virtuellement mettre mtc2 pour déplacer vers le coproc 2** |
MFC1 $src, $dst | $dst = $src | Move From Coproc 1. C’est l’inverse du MTC1 |
LWC1 $dst, Imm($src) | $dst = *(Imm+$src) | Load Word to Coproc 1. On met dans $dst (un registre Floating) la valeur mémoire de $src (un registre général) auquel on aura ajouté Imm. Ici, on charge un octet |
LDC1 $dst, Imm($src) | $dst = *(Imm+$src) | Load Double to Coproc 1. Comme LWC1, mais on charge ici un double |
SWC1 $dst, Imm($src) | $dst = *(Imm+$src) | Store Word from Coproc 1. On met dans $dst (un registre général) la valeur mémoire de $src (un registre floating) auquel on aura ajouté Imm. Ici, on charge un octet |
SDC1 $dst, Imm($src) | $dst = *(Imm+$src) | Store Double from Coproc 1. Comme SWC1, mais on charge ici un double |
Une fois que les données sont récupérées, ce petit coquinou peut effectuer quelques instructions.
Mais, si tout était simple, ça ne serait pas drôle… En fait ces instructions sont “variables”. Chaque instruction se compose d’une commande et d’un ou plusieurs paramètre(s), ce qui permet de donner une directive.
Les paramètres sont notés X et Y dans le listing suivant.
- X correspond à un format de nombre et peut prendre les valeurs
s
(pour single precision),d
(double precision),w
(word/integer) - Y correspond à des conditions de comparaisons, et des valeurs telles que
eq
(EQual),lt
(Less Than),le
(Less or Equal),gt
(Greater Than), ouge
(Greater or Equal)***.
Syntaxe | Description | Commentaire |
---|---|---|
ADD.X $dst, $src1, $src2 | X($dst) = X($src1) + X($src2) | Une addition quoi… |
CVT.X1.X2 $dst, $src | X1 $dst = X2($src) | conversion entre deux formats de nombres |
C.Y.X $src1, $src2 | $cc = (X($src1) Y X($src2)) | comparaison entre deux nombres. Le résultat est mis dans le registre spécial Condition Code |
BC1T $dst | if $cc == True; Goto $dst | On saute en $dst si le registre Condition Code est vrai |
BC1F $dst | if $cc == False; Goto $dst | On saute en $dst si le registre Condition Code est faux |
Pour que ça soit plus clair, et donner un exemple concret, voici un bout de code ASM :
MTC1 $1 $fp0 # on met le contenu du registre général $1 dans le registre $fp0
MTC1 $2 $fp2 # on met le contenu du registre général $2 dans le registre $fp2
CVT.d.w $fp0, $fp0 # on considère que $fp0 était un entier, on le passe en double precision
CVT.d.w $fp2, $fp2 # idem pour $fp2
ADD.d $fp0, $fp0, $fp2 # $fp0 et $fp2 sont considérés comme des double precisions, on les additionne et on met le résultat dans $fp0
C.lt.d $fp0, $fp2 # Si $fp0 est plus petit que $fp2, on met le registre cc a True. Sinon, à False. Dans cet exemple, $fp0 est forcément plus grand que $fp2 en raison de la ligne juste au dessus, donc $cc est égal à False
BC1F 0x1234 # Si $cc est à False, on saute en 0x1234
CVT.w.d $fp0, $fp0 # on repasse $fp0 en entier
MFC1 $1, $fp0 # on renvoie $fp0 dans $1, sous forme d'entier
Ce code n’a pas réellement de sens, mais il est là pour que le fonctionnement des instructions floating soit plus clair. J’espère que c’est le cas, parce que c’était un poil galère à expliquer -_-.
Remarques :
- Ce n’est pas super important pour lire l’ASM, mais le jour où vous devrez le modifier ou écrire un truc sur le coproc, il faut faire attention : un Floating en double precision peut être mis sur DEUX registres (en fonction di bit FR du registre spécial SR, mais j’en ai pas encore parlé de celui là ^^’).
- En fait, j’en parle ici, des instructions FP, mais on en croise pas non plus tous les 4 matins. Ca se voit de temps en temps, mais c’est pas non plus ce qu’il y a le plus souvent à reverse.
- J’ai mis le add, mais il en existe une palanquée d’autres, toutes sur le même principe : SUB, MUL, ABS, SQRT, etc. Je n’ai pas voulu alourdir encore plus le tableau.
Fun Fact
On connait tous l’instruction NOP. Et bien ici, elle n’existe techniquement… pas :D
En fait, un NOP en MIPS a un encoding 0000 0000 0000 0000 0000 0000 0000 0000.
L’instruction SLL a, elle, un encoding de 0000 00ss ssst tttt dddd dhhh hh00 0000.
Donc techniquement, un NOP correspond à un SLL avec comme destination, source et shift 0. Un 0, qu’on shifte de 0 bits, et qu’on met dans 0, bahhhh, ça ne fait rien d’utile, et ça matche avec la définition d’un NOP ^^.
It is the end, my friend
Voilà. Un article dans le genre pas super intéressant à lire. Moi même, l’Instruction set, je me le suis pas parsé entièrement avant de devoir écrire ce post.
Cela dit, il n’y a pas taaaaaaant d’instructions que ça (d’où le R de RISC), et une partie est similaire à du x86, même s’il y a quelques changements. Du coup, ça s’apprend vite, surtout que globalement, on retrouve toujours les mêmes.
Promis, le prochain article sera plus intéressant ^^’
Au passage, je remercie ibileleC pour sa relecture et les erreurs qu’il m’a remontées :)
Enjoy
The lsd
Sources
www.mrc.uidaho.edu : les instructions les plus utilisées. C’est là dessus que je me base principalement en cas de besoin.
hack64.net : la doc complète de la famille R43XX. Ca fait 600 et des bananes de pages. C’est plutôt long à lire ^^’
www.ece.lsu.edu : une doc sur la partie instructions des floating.
* Pour une liste exhaustive de toutes les instructions, ça se passe dans la doc présente sur hack64.net, à partir de la page 371. Bon courage.
** Qui n’est pas utilisé, la datasheet indique qu’ils sont “for future use”. Il existe au passage un maximum de 4 coproc, cela dit, jouer à envoyer des datas dans d’autres coprocs que le 1, c’est pas forcément la gloire assurée.
*** En fait, en potassant le doc complète, on voit qu’il en existe un gros paquet. Tableau 7-11 en page 228 de la doc sur hack64.net.