|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Ceci est un cours d'initiation à l'utilisation de l'assembleur 68020 sur Amiga écrit en 2003 : contrairement à des cours des années 1990, on utilisera uniquement AmigaOS, et on n'utilisera jamais directement d'adresses matérielles, ce qui promet a priori plus de compatibilité pour nos programmes quelle que soit la configuration Amiga. L'assembleur est le langage le plus bas niveau avant le langage machine compris par le processeur. Il est en fait une image compréhensible de celui-ci. Un processeur n'exécute que du langage machine. Tous les autres langages doivent d'une façon ou d'une autre traduire leurs algorithmes en langage machine pour s'exécuter. Il sera parfois fait allusion au langage C dans ce cours car les compilateurs C traduisent d'abord le programme C en assembleur avant d'en faire des exécutables (on peut demander à un compilateur C de faire apparaître la version assembleur d'un source .c), ce qui permet aussi de comprendre comment marche la création d'exécutable. Cela dit, nous ferons des programmes 100% assembleur pour comprendre les subtilités de l'assembleur, ses avantages, ses pièges et comment bien travailler avec. Quel environnement de développement et comment ça marche ? L'assembleur se présente sous la forme de fichiers textes contenant le programme écrit en assembleur, nous verrons plus tard sa forme. Ces fichiers sont d'habitude terminés par ".asm" ou ".s", parfois ".i" pour les fichiers d'inclusion. Ils peuvent être "assemblés" (on compile un programme C, on assemble de l'assembleur). Dans un monde "standard" dont fait partie le C, on n'assemble pas d'exécutable, on assemble un ou plusieurs ".o" (o pour objet) à partir des .asm, qu'on lie ensuite ensemble pour créer un exécutable. Par exemple, l'assembleur gratuit PhxAss peut créer directement des exécutables, mais aussi des .o à partir de .asm : si on tape la commande DOS :
...on vient d'assembler "monfichier.o" à partir de "monfichier.asm". Les .o ne sont pas des exécutables. Ils contiennent effectivement du code machine, mais il manque des relations de noms qui ne sont résolues qu'à l'édition de liens, par une autre commande DOS (comme parfois "ld" utilisé par le compilateur C GCC, ou "link"). Les commandes DOS d'éditions de liens d'exécutables prennent en paramètres une série de fichiers .o, par exemple : startup.o monfichier.o machin.o truc.o. L'éditeur de liens résout alors les liens de noms : les .o contiennent des noms de variables et de fonctions qui peuvent être demandé dans un autre .o. par exemple, un startup.o demande toujours une fonction "main" qui est forcément fourni par un des autres .o (voir commande assembleur XDEF et XREF plus tard). Si les résolutions de noms sont complétées, un exécutable est créée, et celui-ci ne contient plus que du code machine. Mais tout ceci ne constitue que la partie "finale" de ce que réalise un compilateur C (PhxAss est d'ailleurs souvent utilisé par les compilateurs C 680x0 sur Amiga). Cette méthode consistant à créer plusieurs .o (objet donc) pour ensuite créer son programme permet de bien s'organiser et séparer des parties de codes indépendantes, Mais il serait bien pratique de créer directement un exécutable à partir d'un fichier assembleur court : c'est tout à fait possible avec Devpac ou ASMOne, qui ont une option pour spécifier si on crée un .o, ou un exécutable à partir du source assembleur. Où ça commence ? Où ça fini un programme assembleur ? Peut-être savez-vous déjà ce qu'est une sous-routine, ou un appel de fonction en algorithmique : à un moment dans un programme, on "saute" vers une autre fonction, qui une fois terminée, reviendra d'où elle est partie (juste après le saut). Eh bien AmigaOS traite tout lancement d'exécutable (on parlera de tâche) exactement comme s'il s'agissait d'un saut en sous-routines dans une fonction : la tâche commence quand elle y rentre et termine sa vie quand elle en sort. L'analogie marche aussi pour les passages de paramètres, en entrée et en sortie : AmigaOS donne des paramètres en entrée au programme, et le programme donne un "code d'erreur" en sortie (voir précision ci-après). Mais alors, où se situe cette fonction qui "fait" le programme ? Eh bien, c'est la première fonction au début de l'exécutable. Dans le cas d'un programme relié, startup.o est toujours en premier dans la liste des .o : pas de mystère, c'est parce que cela doit être la première fonction lancée. Habituellement, un startup.o mâche le travail bas niveau d'entrée/sortie des programmes C, réalise quelques initialisations, puis lance la fonction "main" que les programmeurs de C connaissent bien et qui est le début du programme C (et qui, elle, peut se trouver plus loin dans l'exécutable). En assembleur pur, il est tout à fait possible de se passer de startup.o : réalisons notre premier programme assembleur sous Devpac, qui permet de créer des exécutables directement (ne pas activer l'option "créer .o"). Notez qu'on peut aussi créer le programme juste en mémoire avec Devpac, sans créer l'image sur le disque, puis l'exécuter depuis la mémoire. Attention alors : si on assemble en mémoire et qu'on exécute plus d'une fois, la valeur des variables n'est pas remise à jour (ceci n'est vrai que pour Devpac). Pour tous les exemples qui suivront, réglez votre compilateur sur "68020" ou plus, car l'assembleur 68000 comprend moins d'instructions. Tapez ces quatre lignes : (n'omettez pas les espaces quand il y en a, et n'en rajoutez pas quand il n'y en a pas en début de ligne !)
Il n'y a presque rien, pas d'édition de liens, pourtant Devpac compile un exécutable. Lancez-le depuis le Workbench : vous aurez un code de retour d'erreur 844. Ça y est, vous codez assembleur, ouaaaah que c'est beau. En même temps, difficile de faire plus court. Ces quatre lignes seront expliquées plus loin : la question pour l'instant est pourquoi ce code accompagné par presque aucune déclaration s'exécute-t-il ? Parce que, comme je l'ai dit, c'est la première fonction en entrée dans l'exécutable. Mais la réalité assemblée est un peu plus complexe : bienvenue dans le monde des "sections" qui composent un exécutable, et qui vont se poser dans la mémoire au chargement de celui-ci. Quand Devpac constate qu'aucune section n'est déclarée dans un source assembleur, il agit comme s'il s'agissait d'une section code par défaut. Pour être clair, il aurait fallu écrire par exemple :
Ici, on voit que le code se trouve dans une section nommée "nomdesectioncode" : c'est la première de l'exécutable, donc l'entrée du programme. on peut en définir plusieurs (c'est même conseillé) mais attention aux restrictions d'accès entre sections (voir différences entre instructions bra et jmp, et entre bsr et jsr). Sur Amiga, la commande "section" permet aussi de demander des préférences sur la façon dont la section va être chargée en mémoire au lancement et s'il s'agit de donnée ou de code (en assembleur, on assemble du code et des données) :
Si vous connaissez l'Amiga Classic, vous savez qu'il existe deux types de mémoire accessible : la mémoire Fast, accessible uniquement depuis le 680x0 et la mémoire Chip, plus lente mais accessible à la fois par le 680x0 et par tous les jeux de puces graphiques. Si nous utilisions certaines bidouilles avec des listes Copper ou avec le Blitter, il faudrait forcer des données en mémoire Chip. Le code 68k peut s'exécuter en mémoire Chip comme en Fast, donc il n'y a aucune raison de le forcer en Fast, ce qui empêcherait le code de s'exécuter sur des Amiga non pourvus de cette mémoire. Youpi ! J'apprends la syntaxe de l'assembleur Expliquons le code minimal écrit plus haut, Cela va nous familiariser à la syntaxe magique de l'assembleur : Dans un source assembleur, on a des instructions (move.l, add.l, etc.), des labels, qui sont des noms donnés arbitrairement à un endroit du code pour le repérer (comme "debut" dans l'exemple), et des commandes (comme "section" dans l'exemple), et des commentaires : on ne peut avoir qu'une instruction ou commande par ligne. On peut mettre des commentaires après un point virgule ; sur des lignes où il n'y a pas d'instruction, ou sur la même ligne qu'une instruction, après l'instruction (le point virgule est alors facultatif). Toute instruction assembleur doit commencer après au minimum un espace ou une tabulation sur sa ligne. Tout label commence toujours collé au début de la ligne, et peut ou non être suivi du caractère deux points ":". On peut mettre une instruction après un label puis un espace, sur la même ligne. Attention : un assembleur peut, selon les options, faire la différence ou pas entre majuscule et minuscule pour reconnaître les instructions et les références aux labels. Je vous conseille de toujours faire comme si la différence existait, ainsi, votre code passera partout. Notez que dans ce cas "Move" sera accepté comme "move". Pour la même raison, je conseille de ne pas mettre de ":" après les labels : certains assembleurs ne veulent pas de ":" sauf option, ce qui est gênant quand on inclut un fichier ".i" formaté différemment. Voici une version à peine plus riche de l'exemple précédent :
Que se passe-t-il dans cet exemple ? La valeur "844" est placée dans les 32 bits du registre volatile d0 de manière immédiate, Puis, la valeur 22 est placée dans les 32 bits du registre d1 par lecture dans la mémoire. Une addition de d1 sur lui-même permet de passer sa valeur à 44, puis d0 est soustrait de 44, et la commande "rts" ferme le programme. Tout cela n'est peut-être pas clair pour vous. Expliquons plus en détail ce qu'il se passe dans ces lignes : L'instruction move L'instruction "move" (déplacer en anglais) réalise de manière générale la copie d'un bloc de mémoire vers un autre (en BASIC ou en C, on pourrait la comparer à l'opération "="). Ce bloc peut avoir trois "tailles" différentes. Move a toujours la forme : crix move.? source, destination Ce qui copie les bits de la source dans la destination. Les bits ("bit" in english) sont la plus petite donnée possible en informatique : un bit peut être égal à 0 ou à 1 (deux valeurs possibles pour un bit). En en regroupant plusieurs, plus de valeurs sont possibles : avec deux bits, quatre valeurs sont possibles, avec huit bits, 256 valeurs différentes sont possibles selon que chacun soit égal à 0 ou 1. En fait, "2 puissance le nombre de bits" sont possibles. La mémoire de l'ordinateur et les registres sont uniquement constitués de bits. Quand on copie une série de bits avec "move", on copie au minimum huit bits à la fois, ce qu'on appelle un octet ("byte" in english, don't confondre bite and byte). La mémoire pouvant être représentée par une suite d'octets linéaire, on utilise des "adresses" pour désigner tel emplacement d'un octet ou le début d'une suite d'octet dans la mémoire (les labels que nous avons vus représentent une adresse). Avec "move", on peut aussi copier 16 bits (deux octets, ou "mot" au sens 68000, en anglais "word") ou enfin 32 bits (quatre octets, ou "long" au sens 68000, en anglais "long"). Dans la forme ci-dessus, ".?" peut être ".l" (4 octets, 32 bits), ".w" (2 octets, 16 bits) ou ".b" (1 octet, 8 bits). Exemple d'instructions possibles :
Les registres internes du processeur. Qu'est-ce qu'un registre ? Dans une instruction comme "move", on peut utiliser un registre (d0...d7,a0...a6,...). Ce sont des "emplacements" de 32 bits chacun, qui résident dans le processeur et pas dans la mémoire. Ils ne sont pas dans l'espace adressable et sont juste référencés par leurs noms. Ils réagissent comme des variables toujours accessibles et c'est avec eux que le processeur réalise ses opérations de calcul, ses comparaisons, etc. Un calcul réalisé entre registres, sans accès à la RAM (mémoire externe au processeur), sera toujours beaucoup plus rapide qu'un calcul qui fait des écritures et lectures en mémoire (à cause des accès sur le bus entre processeur et mémoire). Les voici :
Note : d'une manière générale, un source assembleur consiste presque toujours à donner des valeurs initiales à ces registres, puis à faire un calcul ou un algorithme avec, puis finir par recopier le résultat en mémoire. L'adressage de valeur dans la mémoire. En lecture ou en écriture ! Autre forme possible pour une source ou une destination d'un "move" : un adressage en mémoire permet de lire ou d'écrire une valeur définie dans un emplacement de la mémoire adressable depuis un registre, ou même de réaliser une copie de mémoire à mémoire sans même passer par un registre. Attention ici, beaucoup de syntaxes sont possibles pour réaliser la même opération :
"move.l 64( a0 , d0.l*8 ),d1" signifie que les 32 bits contenus en mémoire à l'adresse "a0 + 64 octets + (la valeur de d0 multipliée par 8)" sont copiés dans d1 (on peut spécifier *2,*4 ou *8 sur ce registre de données, cette multiplication est gratuite en temps machine). Nous y reviendrons, mais sachez que parfois une forme d'adressage est possible ou pas, il est parfois nécessaire de se munir d'une bonne documentation 68000 ou 68020 où figurent des tableaux indiquant quel adressage est possible selon qu'on utilise un registre de donnée ou d'adressage en source ou destination, ou dans un "move" ou une autre instruction. Dans les faits, essayez toujours, votre assembleur vous dira si c'est possible ou non ! (attention, le 68000 plantera devant certains modes d'adressages disponibles seulement depuis le 68020. Réglez bien les options de vos assembleurs !). L'assignation immédiate ! Dernière forme possible dans un "move", mais uniquement avant la virgule (là où on indique la source copiée) : l'assignation immédiate, ou directe, toujours représentée par un dièse "#" précédent une valeur écrite telle quelle. C'est une façon très simple de donner une valeur à un registre ou dans un emplacement mémoire :
Cette écriture mettra "170" dans d0 (les registres représentent des valeurs entières). Attention : il est fondamental de comprendre que :
...va aller lire la mémoire à l'adresse 340 (Dieu sait ce qu'il s'y trouve !), cet espace mémoire n'est pas à vous. Si nous avions un label à l'adresse 340, cette écriture serait équivalente à :
Tout ceci est tout à fait différent de :
...qui va juste mettre la valeur 340 dans d0. Dans ce cas, le nombre "340" est contenu dans l'instruction même du "move" (la valeur se trouve donc dans le cache instruction et non donnée, pour ceux qui ont une idée de ce qu'est un cache). Qu'est-ce qu'une variable globale ? Vous devez maintenant avoir une idée claire et nette de ce qu'est un registre et ce qu'est la mémoire, et un adressage... Dans un exemple plus haut, nous avons créé une variable globale nommée "variable1". Une variable globale est un emplacement dans la mémoire, qui prend un nombre d'octets donné dans celle-ci et qui est accessible depuis n'importe quel endroit du code, par son label qui indique sa position en mémoire. En assembleur 68000, on peut en déclarer n'importe où dans son code, pourvu qu'on soit sûr qu'il ne figure pas au beau milieu d'une fonction qui sera exécutée (le code assembleur contient du code et des données). L'instruction :
...va réserver quatre octets (32 bits, .l) à l'endroit où on la déclare et l'initialiser d'après la valeur ("dc" signifie "declare constant"). On pourra ensuite lire ou écrire des valeurs à cet endroit avec un "move.l" par exemple.
Une des grandes différences entre l'assembleur et des langages comme le C est qu'en assembleur, les données ne sont déclarées que par la taille qu'elles prennent en octet et leur adresse, alors que dans les autres langages, on définit une donnée par son "type" (entier, flottant, caractères, pointeurs...) dont la taille en octet est implicite. Ici, en assembleur, si nous avons besoin d'un entier 32 bits, on déclarera un espace de quatre octets. Si on a besoin d'une donnée d'un autre type qui nécessite quatre octets aussi, on déclarera quatre octets de la même façon. Pour déclarer des données dans un source, on peut aussi utiliser l'instruction "ds" (declare storage) :
Dernière variante : "dcb" (declare constant bloc) va déclarer une table de "n" éléments, comme "ds", mais va initialiser chacun de ces éléments avec "valeur" :
Alignement de la mémoire Nous voyons dans l'exemple ci-dessus que des variables ont été déclarées en réservant de la place dans le source, et que des labels désignent ces emplacements pour y faire référence. Mais des éléments nouveaux apparaissent : d'abord les commandes "even" (signifie "pair") et "cnop". Ce sont des commandes qui forcent un alignement de mémoire. Nous avons vu qu'un emplacement dans la mémoire s'adresse (se désigne) par une valeur entière désignant un emplacement mémoire, et que l'unité utilisée est l'octet. Ceci implique que :
Sachant ceci, voici une mauvaise nouvelle. Retenez bien ça : en 680x0, réaliser une lecture ou une écriture en temps que ".l" ou ".w" sur une adresse mémoire impaire est interdit. Au mieux, ça fait tout planter la machine dans 100% des cas (c'est une "exception" au sens processeur, comme les divisions par zéro). Avec des instructions ".b", la mémoire peut être lue sur des adresses paires ou impaires. Voilà, j'espère que c'est bien retenu ! Donc si vous avez compris, si on a par exemple :
L'autre nouveauté est la chaîne de caractères :
Un caractère ASCII étant un octet, on peut écrire des suites des phrases en tant que tableau d'octets. Une valeur 0 indique la fin de la chaîne. Comme la taille de cette chaîne peut varier, une commande "even" viendra assurer un recalage par la suite. Allez, pour votre culture, cette dernière instruction très utile pour construire ses données globales :
"incbin" permet en effet d'inclure tel quel un fichier binaire du disque dans le source (très utile pour des tables mathématiques par exemple, ou des images).
|