Extraction automatique des instructions du CPU SH4
Posté le 12/11/2023 18:40
Partie 1 - Extraction
Je suis actuellement en train de bosser sur la création d'un
émulateur pour casio monochrome 35+/75, et je réfléchissais en parallèle à une façon d'extraire automatiquement les instructions de la doc. En effet, il y a des centaines d'instructions, et bien que j'aie implémenté les 27 premières manuellement pour comprendre leur fonctionnement, cela commençait à devenir long et redondant. Je pense avoir désormais compris la base nécessaire pour mettre en place une technique qui le fait à ma place.
Je vais expliquer ici comment je m'y suis pris, sachant que tout le code javascript est disponible dans
le repo du projet dans le dossier "script".
La toute première étape consiste à extraire les informations de
la doc officielle du CPU SH4 dans un format qui peut être lu par un algorithme. Il existe des bibliothèques qui permettent de lire des PDFs via du code, mais la toute première chose que j'ai tenté de faire est un bête copié/collé (ctrl+a, ctrl+c, ctrl+v) de tout le pdf dans un fichier texte. Les seuls critères étant que les mots devaient apparaître dans l'ordre dans lequel ils apparaissent dans le pdf, c'est-à-dire de gauche à droite puis de haut en bas (ce qui n'est pas garanti avec ce genre de copié/collé bourrin). Heureusement, le document semble bien formaté (enfin presque, on verra pourquoi après), et je peux donc me servir de cette base pour en extraire les informations, il s'agit du fichier
raw_instructions.txt.
Ensuite, il faut analyser le pdf et essayer de trouver un pattern qui permettrait d'extraire toutes les instructions. Il y a un gros bloc au début qui contient toutes les instructions à la suite ainsi que leur code, ça avait l'air prometteur, mais leur implémentation ne se situe que bien plus tard dans le document, et pas dans le même ordre, donc il fallait trouver autre chose :
Il y a un deuxième endroit où ces instructions apparaissent, c'est juste avant leur implémentation vers la fin du document, mais elles sont séparées en plusieurs groupes. Pour chaque groupe, il y a d'abord un tableau contenant les informations sur les instructions, toutes à la suite, et un peu plus tard leur implémentation, cette fois-ci dans le même ordre que le tableau ! Ce genre de pattern (tableau + implémentations) est répété plusieurs fois à la suite pour couvrir toutes les instructions. Ce sera le départ de mon algorithme !
Le principe va être de charger le fichier texte en tant que chaîne de caractères, et d'effectuer plein d'opérations dessus (découpage, recherche, trim) pour en isoler certaines parties et en extraire des informations. Le code de cette partie est situé dans le fichier
extract_instructions.js.
La première étape est donc d'isoler chacun de ces groupes d'instructions (appelés instruction set dans mon code). Il s'agit de la fonction
extractNextInstructionSet() dans mon code. Pour cela, je calcule la position du mot-clé "T Bit" qui représente la fin du header du tableau et donc le début de la toute première instruction (voir image ci-dessus). De façon similaire, je calcule la position de "Format Operation" qui représente le début du prochain tableau, et donc la fin de ce groupe d'instructions
Cela nous donne un tableau de plusieurs chaînes de caractères, contenant chacune un groupe d'instruction avec leur définition et leur implémentation. Pour chacune de ces chaînes, deux algorithmes sont nécessaires :
- extractInstructions() : Tout d'abord, je dois déterminer le nombre d'instructions présentes dans ce groupe. Pour cela, je vais utiliser des expressions régulières (Regexp), un outil très puissant pour chercher des patterns très spécifiques dans des chaînes de caractères. Et ici, on a un pattern qui saute aux yeux, le code de chaque instruction est toujours composé de 16 caractères avec des lettres minuscules ou des 0 ou 1. Le cas de figure parfait pour du regex ! Voici le regex qui permet d'isoler ces instructions :
const matches = input_str.match(/[01imnd]{16}/gm) // Match tokens like 0100nnnnmmmm1100, 1010dddddddddddd or 0000000000001011
Ici, le regex est /[01imnd]{16}/, qui signifie "cherche une chaîne de 16 caractères de long contenant uniquement des caractères dans cet ensemble : {0, 1, i, m, n, d}". gm sont deux attributs qui veulent dire (g)lobal - cherche TOUTES les occurrences, (m)ultiline - cherche sur plusieurs lignes.
Cela me permet d'isoler chacune des instructions en découpant la chaîne à l'emplacement de ces codes.
Puis pour extraire la famille de l'instruction, j'utilise le regex suivant :
const name = str.match(/[A-Z.01268\/]{2,}/)[0] // Match tokens like JSR, LDRS, MOV.L, DIV0S, CMP/EQ
Je peux aussi isoler la description de l'instruction, elle se trouve simplement entre la famille et le code.
Cette fonction retourne un tableau d’éléments de la sorte:
{
code: '0000nnnnmmmm1110',
desc: '@(R0,Rm),Rn',
family: 'MOV.L'
},
{
code: '1110nnnniiiiiiii',
desc: '#imm,Rn',
family: 'MOV'
},
...
- extractImplementations() : La fonction précédente retourne un tableau contenant pour chaque instruction sa famille, sa description et son code. Maintenant, il faut relier ces instructions à leur implémentations.
Le nom de fonction de chaque instruction n'est indiqué nulle part autre que lors de l'implémentation, donc il n'est pas possible de faire une simple recherche pour isoler la fonction correspondante à une instruction. Heureusement, les implémentations sont toutes dans le même ordre que celui du tableau, donc il suffit juste de les lire une par une et de les associer à l'instruction correspondante, dans l'ordre. J'ai remarqué que toutes les implémentations se situent dans une section qui commence par "Operations:", donc je peux déjà couper la chaîne jusqu'à cet endroit pour simplifier les choses.
Ensuite, il faut savoir que le regex permet de créer des groupes de recherche, et ainsi retourner plusieurs matchs correspondants à plusieurs groupes. Ma toute première tentative était de créer un regex qui isole ces quatre groupes :
FUNCTION_NAME (OPERANDS) /* DESCRIPTION /* { IMPLEMENTATION }
Cela marchait presque bien, mais j'avais des difficultés à détecter la fin du groupe "IMPLEMENTATION", car je me basais sur le caractère "}", mais dans le cas d'un bloc imbriqué (un if { } par exemple) cela coupait la fonction trop tôt. Du coup j'ai modifié un peu les choses pour seulement détecter les 3 premiers groupes. Et ensuite j'utilise un petit algorithme javascript (
findLastFunctionBracket()) qui démarre de la fin du dernier groupe (donc juste avant l'implémentation) et calcule la position de l'accolade fermante de la fonction.
Voici le regex, celui-ci est un peu satanique je n'expliquerai pas les détails
const matches = input_str.matchAll(/\n {0,1}([A-Znm_012468]{2,}) {0,1}(\([^)]*\))[^\/]*(\/*.*\/)/gm)
// Utilisation:
for (let [_, name, operands, desc] of matches) {
Cet algorithme permet donc d'extraire le nom de la fonction, ses arguments, sa description (redondante) et surtout tout le code de son implémentation ! Je peux combiner ces infos avec celles précédentes (la famille et le code de l'instruction) de façon à créer un objet complet et utile informatif sur chacune des instructions :
{
"code": "0011nnnnmmmm1100",
"desc": "Rm,Rn",
"family": "ADD",
"name": "ADD",
"operands": "(long m, long n)",
"implementation": "ADD(long m, long n) /* ADD Rm,Rn */\r\n{\r\n R[n] += R[m];\r\n PC += 2;\r\n}"
},
Après avoir effectué ce processus pour toutes les instructions de tous les instruction sets, je peux sauvegarder ces informations sous un format .json (
instructions.json) qui contient les memes informations que le fichier original raw.txt, mais dans un format bien plus pratique pour la suite de l'implémentation !
Il y a juste un détail à prendre en compte:
Drakalex007 a écrit :
Heureusement, le document semble bien formaté (enfin presque, on verra pourquoi après)
En fait, ma technique ne marche que pour la moitié du document environ. La seconde moitié des instructions sont liées au DSP (de ce que j'ai compris c'est une unité pour faire des opérations avec des doubles ou fixed-point). Pour une raison quelconque, bien que le format soit exactement le même pour les instructions DSP et non DSP dans le PDF, ma technique de copier/coller tout le document ne fonctionne plus car certains tableaux relatifs aux instructions DSP apparaissent désormais par colonne et non par rangée dans le fichier texte.
Le SDK Casio de base n'utilise jamais ces instructions, cependant gint le fait. Donc je me pencherai sur cette seconde partie plus tard, mon algorithme peut être légèrement réarrangé pour pouvoir gérer ce cas de figure!
Citer : Posté le 12/11/2023 18:43 | #
Partie 2 - Implémentation
Maintenant, je vais vouloir créer du code C valide à partir de ces instructions (write_instructions.js). Il y a plusieurs fichiers à générer : un fichier .h contenant les définitions de chaque fonction, un .c contenant leurs implémentations et finalement il faut aussi générer du code C qui permet d'assigner une instruction (désignée par son code 16 bits) à son implémentation.
Commençons par le plus simple : le header. Il s'agit juste de convertir chaque nom de fonction sous le format suivant :
Un jeu d'enfant avec notre fichier json actuel:
const header = instructions.map(instruction => `void ${instruction.name}(cpu_t* cpu, uint16_t instruction);\n`).join('')
fs.writeFileSync('generated/instruction.h', header)
Ce qui nous genere le code suivant, disponible dans instruction.h :
void ADDI(cpu_t* cpu, uint16_t instruction);
void ADDC(cpu_t* cpu, uint16_t instruction);
void ADDV(cpu_t* cpu, uint16_t instruction);
void AND(cpu_t* cpu, uint16_t instruction);
...
Ensuite, pour le "sélecteur d'instruction", c'est un peu plus complexe. Dans chaque code, il y a une partie fixe, qui désigne l'instruction, et une partie variable, qui désigne ses paramètres. Pour convertir un code en instruction, il faut faire un if qui isole la partie fixe de chaque instruction pour la tester avec le code de l'instruction actuellement lue par le CPU. Cette partie est un peu plus brute-force, j'ai tout d'abord isolé 5 patterns uniques dans lesquels les parties fixes pouvaient être disposées, que je peux extraire grâce à des regex :
const reg8 = /[01]{8}[a-z]{8}/ // 00000000nnnnmmmm
const reg4 = /[01]{4}[a-z]{12}/ // 0000nnnnmmmmdddd
const reg48 = /[01]{4}[a-z]{4}[0-1]{8}/ // 0100nnnn00010010
const reg44 = /[01]{4}[a-z]{8}[01]{4}/ // 0110nnnnmmmm1000
Je peux ensuite grouper les instructions par pattern, et pour chaque pattern je genere la condition qui teste la partie fixe du code. Voici un exemple:
for (const instruction of filterInstructions(reg8)) {
str += `\tif (high8 == 0b${instruction.code.slice(0, 8)}) return ${instruction.name};\n`
}
Cela permet de générer la fonction suivante, inclue dans instructions.c et qui retourne un pointeur de fonction :
void (*get_instruction_impl(uint16_t instruction))(cpu_t*, uint16_t) {
uint8_t high = (instruction >> 12) & 0x0F;
uint8_t high8 = instruction >> 8;
uint8_t low = instruction & 0x0F;
uint8_t low8 = instruction & 0xFF;
if (instruction == 0b0000000000101000) return CLRMAC;
if (instruction == 0b0000000000011001) return DIV0U;
if (high8 == 0b11000000) return MOVBSG;
if (high8 == 0b11000001) return MOVWSG;
if (high == 0b0111) return ADDI;
if (high == 0b1110) return MOVI;
if (high == 0b0100) {
if (low8 == 0b00010101) return CMPPL;
if (low8 == 0b00101011) return JMP;
...
Finalement, il faut créer l'implémentation de chacune de ces instructions. Pour cela, il va falloir modifier un peu le code de la doc. La doc passe chaque paramètre du code comme argument aux fonctions, cependant le processus qui extrait les variables depuis le code n'est pas inclus. Il faut transformer cette syntaxe :
{
R[n] += R[m];
PC += 2;
}
Pour arriver à la syntaxe suivante (qui dépend de la position et de la taille des paramètres de l'instruction) :
void ADD(cpu_t* cpu, uint16_t instruction) {
long n = (instruction >> 8) & 0xF;
long m = (instruction >> 4) & 0xF;
R[n] += R[m];
PC += 2;
}
Pour cela, j'utilise d'abord un premier algorithme qui va lire le code de l'instruction, et calculer la position de départ et de fin de chaque caractère du code :
1110nnnniiiiiiii -> [ { char: 'n', start: 4, end: 8 }, { char: 'i', start: 8, end: 16 } ]
1101nnnndddddddd -> [ { char: 'n', start: 4, end: 8 }, { char: 'd', start: 8, end: 16 } ]
11000100dddddddd -> [ { char: 'd', start: 8, end: 16 } ]
Grâce à ces informations, un autre algorithme crée la définition de chaque variable en fonction de son type, de sa taille et de sa position:
1) 0010nnnnmmmm1101 + (long n, long m)
// Extract variable position and type
2) [
{ char: 'n', start: 4, end: 8, type: 'long' },
{ char: 'm', start: 8, end: 12, type: 'long' }
]
// Create variable definition
3) long n = (instruction >> 8) & 0xF;
long m = (instruction >> 4) & 0xF;
Finalement, l'instruction est combinée de la façon suivante avant d'être ajoutée au fichier :
FUNCTION_NAME(cpu_t* cpu, uint16_t instruction) {
VARIABLE_DEFINITION
IMPLEMENTATION
}
Il y a une dernière chose à prendre en compte, la doc utilise des noms de variable simplifiés pour faire ses opérations (PC, PR, MACL, T...). Heureusement, il n'y a pas besoin de s'embêter à remplacer ces noms de variable dans l'algorithme, il suffit simplement de créer les #define appropriés au début du fichier. Par exemple :
#define SPC cpu->spc
#define DBR cpu->dbr
#define PC cpu->pc
#define PR cpu->pr
...
De plus, j'ai pu convertir les fonctions plus complexes telles que les memory read/write et delay slot toujours en utilisant des define:
#define Read_Word(addr) mem_read(cpu, addr, 2)
#define Read_Long(addr) mem_read(cpu, addr, 4)
#define Write_Byte(addr, data) mem_write(cpu, addr, data, 1)
#define Write_Word(addr, data) mem_write(cpu, addr, data, 2)
#define Write_Long(addr, data) mem_write(cpu, addr, data, 4)
#define Delay_Slot(pc_next) \
uint32_t pc_save = cpu->pc; \
cpu->pc = pc_next; \
run_next_instruction(cpu); \
cpu->pc = pc_save;
Tous ces define sont définis dans ce fichier et ajoutés lors de la création du code C.
Pour implémenter toutes les instructions, j'ai aussi dû ajouter les registres suivants (contrairement aux autres, je n'ai pas vraiment vu/approfondi leur utilisation dans le code):
struct cpu_t {
// ...
uint32_t vbr; // Vector base register
uint32_t ssr; // Saved status register
uint32_t sgr; // Saved general register
uint32_t spc; // Saved program counter
uint32_t dbr; // Debug base register
};
Au final, tout ce travail nous produit le fameux Graal qui est le fichier instructions.c contenant la fidèle reproduction de 157 instructions de la doc ! S'il y a des bugs dans le code, on ne peut plus remettre en question mon implémentation des instructions, ce sera la faute de la doc
Citer : Posté le 12/11/2023 18:49 | #
Bonus - Les incohérences de la documentation
Pour finir, j'aimerais faire une petite compilation de toutes les incohérences de la doc auxquelles j'ai dû faire face lors de l'implémentation d'un tel outil très sensible au format et à la régularité des patterns. Il s'agit de la partie la plus compliquée et frustrante d'un tel projet x)
Le seul header qui contient une faute:
La seule implémentation contenant une accolade inversée:
} // <---
R[n] = Read_Long(R[m]);
PC += 2;
}
(long i) au lieu de (long) i :
Les petits espaces vicieux
Les oublis du type des arguments:
unsigned int disp;
disp = (unsigned int)(0x000000FF & d);
R[n] = (int)Read_Word(PC+4+(disp<<1));
if ((R[n]&0x8000)==0) R[n] &= 0x0000FFFF;
else R[n] |= 0xFFFF0000;
PC += 2;
}
Les oublis de points-virgule:
{
...
temp2 = RmL*RnH;
temp3 = RmH*RnH;
Res2 = 0 // <-----------
Res1 = temp1 + temp2;
La seule occurrence de la typo "lomg" au lieu de "long":
{
MACL = Read_Long(R[m]);
R[m] += 4;
PC += 2;
}
SYNCO, la seule fonction qui n'a pas de parenthèses après son nom:
{
synchronize_data_operaiton();
PC += 2;
}
Et j'en passe!
Cela me fait craindre qu'il puisse y avoir ne serait-ce qu'une petite faute (qui ne gênerait pas la compilation) quelque part dans une des implémentations, ce qui pourrait entraîner des crash impromptus de l’émulateur...
Et grâce à ce nouveau panel d'instructions, j'ai pu grandement avancer dans mon émulateur et afficher l’écran initial de trois programmes en plus du Sample Add-in: AsteroFall, Mandelbrot et Jetpack Joyride!
Fun fact d'ailleurs, tous ces programmes ont besoin de moins de 1000 instructions pour afficher l'écran initial.
Sauf Mandelbrot qui en a besoin de 15 millions
Citer : Posté le 12/11/2023 20:00 | #
Je suis assez surpris que ça marche. Je m'attends à des subtilités, sauf si le proco était généré par HLS et que la doc était le code source (j'en doute). En tous cas je ne l'ai pas vu venir...
Seul inconvénient, ça doit être assez lent sauf si le compilo carry à mort
Citer : Posté le 12/11/2023 20:24 | #
J'avoue que je n’étais pas totalement sur de la réussite de cette méthode, mais jusqu'ici les 4 add-ins semblent s’être initialisés a merveille!
De quel genre de subtilités parles-tu?
Par contre je ne vois pas en quoi ce serait plus lent ? Les #define ne devraient absolument rien changer au code généré.
Citer : Posté le 12/11/2023 20:35 | #
Quelles subtilités ? Oh ben le fait que le pseudo-code fourni par la doc n'est pas une description complète, exacte et exhaustive du comportement du processeur.
Pour les performances, c'est parce que le pseudo-code fourni n'est pas écrit d'une façon très optimale (eg. extensions de signe manuelles au lieu d'utiliser le cast, multiplications multi-précision esquivées, ce genre de choses). Et le décodage que tu fais semble aussi très linéaire, et c'est surtout ça qui joue je pense.
Citer : Posté le 12/11/2023 21:08 | #
Ah oui, effectivement!
Et le décodage que tu fais semble aussi très linéaire, et c'est surtout ça qui joue je pense.
Tu parles de la ribambelle de if pour choisir l'instruction ?
Ou bien de l'extraction des arguments :
long m = (instruction >> 4) & 0xF;
Comment serait-il possible d’améliorer le code généré selon toi?
Citer : Posté le 12/11/2023 21:29 | #
La ribambelle de if. À mon sens il faudrait une table de saut de 256 entrées pour le premier octet, ce qui t'identifie déjà plein d'instructions, et ensuite du if/else dans chaque cas, un truc comme ça.
Citer : Posté le 13/11/2023 10:57 | #
Pour avoir les meilleurs performances, est ce que ce ne serait pas mieux de le faire en WebAssembly ?EDIT : ah et bien il semblerait que c'est déja le cas ...
Citer : Posté le 13/11/2023 11:10 | #
Webasm ? Pourquoi pas directement en LLVM voire asm amd64 vu que c'est une cible pc ?
Et puis aussi moin que je le sache c'est en C pour la portabilité hein.
Caltos : G35+EII, G90+E (briquée )