--- title: Rapport OS --- # Introduction Dans le cadre du cours de conception OS de 3e année à la HE-Arc, un projet est réalisé par les étudiants. Ayant apprécié le cours d’assembleur de 1res, nous nous sommes tournés vers la création d’un assembleur pour un processeur virtuel. L’idée est donc de réaliser un programme simulant le processeur, qui utilise un fichier binaire comme instruction. Il faut donc également créer le programme qui génère le fichier d’instruction, donc l’assembleur. Il prend en entrée un fichier lisible par un humain et ressort le fichier binaire. Pour finir, on crée un outil de visualisation pas-à-pas de l’exécution d’un programme. Pour réaliser ce programme, nous allons voir plusieurs méthodes intéressantes pour d’autres projets, comme les opérations sur les bits en Python ou encore la création d’une interface en ligne de commande (CLI) avec curses. # Conception L’architecture CPU utilisé dans ce projet est basée sur une série d’exercices de M. Claudio Cortinovis. Le CPU possède 16 registres de 8 bits et une mémoire de 256 octets. Les instructions sont codées sur 16 bits, avec les 4 premiers bits comme opcode. Les différentes instructions du CPU et leur opcodes sont : - 0 : LOADM R A, qui charge la valeur de la mémoire à l’adresse A dans le registre R - 1 : STORE R A, qui enregistre la valeur du registre R dans la mémoire à l’adresse A - 2 : JUMPZ A, qui saute à l’adresse A si la dernière instruction a produit un zéro - 3 : ADD R1 R2 R3, qui additionne les registres R2 et R3 et enregistre le résultat dans R1 - 4 : SUB R1 R2 R3, qui soustrait le registre R3 à R2 et enregistre le résultat dans R1 - 5 : DEC R, qui décrémente le registre R - 6 : INC R, qui incrémente le registre R - 7 : LOADC R C, qui charge la constante C dans le registre R - F : STOP, qui arrête le programme Dans notre cas, nous avons décidé d’ajouter des labels pour indiquer les emplacements mémoires. ![Diagrammes des classes du projet](https://i.imgur.com/tdnYOZj.png) Pour ce simulateur, nous allons créer 3 classes. La première est le simulateur du CPU. Il contient un tableau pour les registres et un autre tableau pour la mémoire. Il possède également un pointeur d’instruction. Le CPU a deux méthodes principales. La première permet de charger un fichier d’octets dans la mémoire à une adresse. Le pointeur d’instruction est également mis à l’adresse donnée. La deuxième méthode permet de lancer l’instruction suivante. La mémoire à l’adresse du pointeur est lue et l’opération associée à l’opcode est exécutée. Le pointeur est alors incrémenté de 2. La classe Assembler permet d’assembler un fichier de code assembleur vers un fichier d’octets. Il est possible de définir l’adresse de départ du programme, afin que les labels soient justes. Enfin, la classe StepAssembly est l’interface CLI du projet. Elle prend en entrée le nom d’un fichier, l’assemble à l’aide de l’assembleur et le charge dans la mémoire du CPU. Elle permet elle aussi de définir la mémoire de départ pour les instructions. La méthode principale de la classe est la boucle principale. Elle affiche la mémoire et les registres du CPU et s’occupe des entrées de l’utilisateur. Elle s’arrête uniquement lorsque l’arrêt est demandé par l’utilisateur. Le but de l’affichage est d’afficher l’instruction actuelle et les prochaines. Il doit également permettre de voir la mémoire et les registres du CPU. Pour cela, la classe peut utiliser les attributs publics du CPU et de l’assembleur. Le CPU possède un tableau de 16 octets pour les registres, un tableau de 256 octets pour la mémoire et un pointeur d’un octet. L’assembleur possède un tableau des instructions dans l’ordre. Il possède également un tableau des labels et leur adresse. L’affichage constitue donc simplement en la lecture et la mise en forme de ses valeurs. La gestion des entrées de l’utilisateur consiste en une boucle infinie qui attend une entrée. Elle appelle la méthode de nouvelle instruction du CPU et rafraichie l’affichage. # Implémentation Le CPU et l’assembleur sont tous les deux codés en Python 3.10. Ce langage a été choisi pour principalement sa flexibilité, cependant il a été constaté que ce n’est pas le meilleur langage quand il est question de travailler avec des octets. La bibliothèque curses est utilisée pour l’affichage console. ## CPU Le CPU possède un tableau de 16 cases qui contient toutes les instructions sous forme de méthode. ``` self.operations = [None] * 16 self.operations[0] = self._loadm self.operations[1] = self._store self.operations[2] = self._jumpz self.operations[3] = self._add self.operations[4] = self._sub self.operations[5] = self._dec self.operations[6] = self._inc self.operations[7] = self._loadc self.operations[15] = self._stop ``` La méthode qui permet de charger le fichier dans le CPU prend en paramètre le chemin du fichier et l’adresse de départ (par défaut à 0). Une fois le fichier chargé, il faut rogner le surplus d’instruction (max : 256 - l’adresse de départ). Ensuite il faut charger les instructions dans la mémoire du CPU, à la bonne adresse. Pour finir, il suffit de placer le pointeur de lecture à l’adresse de départ. La lecture pas-à-pas des instructions en mémoire est définie par la méthode next_instruction qui, grâce au pointeur, récupère la prochaine instruction de 2 octets, puis l’exécute grâce au tableau d’instruction. Pour ce faire il récupère les 4 premiers bits qui définissent l’index de l’instruction à exécuter, puis exécute la méthode en lui passant en paramètre les 2 octets. ``` i = self.memory[self.pointer : self.pointer + 2] self.operations[i[0] >> 4]([ubyte(i[0]), ubyte(i[1])]) ``` ## Assembleur L’assembleur possède une liste de tuple, chaque tuple contient une expression régulière qui représente l’instruction en code assembleur et une méthode, cette liste représente toutes les instructions assembleur connues par ce dernier. Il possède également une autre liste de même tuple initialement vide qui représente les instructions qui seront lues. La méthode principale de l’assembleur commence par ouvrir le fichier passé en paramètre pour lire ligne par ligne le code assembleur, chaque ligne est une instruction assembleur, puis remplis la liste d’instruction avec comme première valeur du tuple le numéro de ligne et deuxième valeur de la représentation binaire (2 octets) de l’instruction pour qu’elle soit lisible par le CPU. Pour pouvoir implémenter les jumps, il est nécessaire de faire une deuxième lecture qui va s’occuper de remplacer les instructions jumps par les adresses des labels correspondants. Une fois toutes les instructions lues et enregistrées dans la liste, l’assembleur écrit les représentations binaires dans un nouveau fichier. ## Step Assembly Cette classe orchestre toute l’interaction entre le CPU et l’assembleur, elle s’occupe également de l’affichage. Son constructeur va s’occuper de donner le fichier en langage assembleur à l’assembleur puis une fois le fichier assemblé il donne le fichier de sortie au CPU. La boucle principale permet l'affichage, grâce à la bibliothèque curses, du déroulement du programme pas à pas. Elle gère les différentes actions disponibles pour l'utilisateur, tel que: - Activer/désactiver le défilement automatique (touche A) - Prochaine instruction (espace ou enter) - Faire défiler la mémoire (flèche haut et flèche bas) - Quitter (touche Q) # Problèmes rencontrés Le premier problème dans ce projet vient de l’utilisation de Python. Python est un langage qui marche bien pour la plupart des cas d’utilisations. C’est cependant plus compliqué de travailler avec des octets que dans des langages comme C. Python ne possède en effet pas un type char d’un octet. Il faut pour cela utiliser le type byte. Le type byte représente un tableau d’octets. Le problème est alors que la plupart des opérations comme l’addition ne sont pas définies. Il faut donc passer l’octet en entier, réaliser l’addition et passer le résultat en octet. Plusieurs problèmes provenaient de cette utilisation du type byte. Nous avons trouvé plus tard le type ubyte défini par la bibliothèque numpy. Il se comporte exactement comme un char de C, c’est donc idéal dans notre cas. Pour l’affichage, nous utilisons la bibliothèque curses. L’affichage est rafraichi à chaque entrée de l’utilisateur. Nous avons pour cela suivi le tutoriel proposé par la documentation Python. Le tutoriel utilise la méthode clear au début de la boucle et termine avec la méthode refresh. Le problème avec cette configuration est que l’écran scintille au rafraichissement, ce qui est très perturbant. Après plusieurs essais, il s’avère que l’utilisation de la méthode erase plutôt que clear n’a pas les mêmes problèmes, car elle efface tout l’écran avant l’affichage. Après ce changement, le rafraichissement est bien plus agréable. L’affichage a un autre problème que nous n’avons que partiellement résolu. Lors du redimensionnement du terminal, l’affichage doit être redessiné. Après avoir lu la documentation, nous avons vu que curses envoie une entrée KEY_RESIZE lors de l’évènement. Nous mettons alors à jour l’affichage. Le problème est que l’affichage n’est pas instantané, le redimensionnement est donc un peu en retard. Nous avons essayé d’utiliser une boucle minutée, plutôt que d’attendre à chaque fois l’entrée de l’utilisateur et le problème est le même. C’est probable que notre fonction d’affichage prenne trop de temps. # Résultats ## Code assembleur ```bash= LOADC A 0A INC A STORE A F1 STOP ``` Ce code enregistre la valeur `0x0A` dans le registre A, puis l’incrémente de 1 et pour finir enregistre la valeur du registre A dans la mémoire à l’adresse `F1`, donc `0x0B`. ## Code assemblé ![Code assemblé](https://i.imgur.com/fHxpH09.png) Ceci est la représentation en hexadécimal du code assembleur une fois assemblé. ## Exécution ![Début de programme](https://i.imgur.com/75NutNg.png) L’encadré violet représente les registres du CPU tandis que le vert représente la mémoire de ce dernier. Le rouge montre les instructions (passées et futures) et le pointeur, désignant l’instruction actuelle, est encadré d’orange. ![Modification de la mémoire](https://i.imgur.com/9wcpEV4.png) La case mémoire F1 est modifiée par l’instruction `STORE A F1`. Cette action est représentée par la mise en évidence de la case mémoire. ![Résultat final](https://i.imgur.com/py6cTiC.png) Une fois que le programme a fini d’être exécuté, l’adresse mémoire `F1` possède bien la valeur du registre A soit `0x0B`. # Conclusion Le résultat final de ce projet est 3 scripts Python. Le premier permet de simuler le fonctionnement d’un CPU simplifié. Le deuxième est un assembleur pour l’ensemble d’instruction du CPU. Enfin, le dernier script permet de visualiser le fonctionnement d’un programme pas à pas sur le CPU. Cela peut être un outil très utile pour comprendre les principes des langages d’assembleur ou même du fonctionnement d’un CPU. En effet, les architectures de processeurs modernes restent assez basiques dans leur fonctionnement, si ce n’est avec plus d’instructions. De notre côté, nous avons pu rafraichir nos connaissances concernant le fonctionnement d’un processeur et d’un assembleur. Nous avons également découvert la bibliothèque curses qui est le standard pour la création d’interfaces en ligne de commandes.