3:Le code exécutable
Programmer, c’est produire du code, à savoir des instructions pouvant être comprises et exécutées, directement ou indirectement, par le processeur de l’ordinateur.Le processeur ne comprend directement que du code dit « natif », « machine » ou « binaire », qui peut être :
- écrit directement. Ceci ne se fait plus, ou alors en classe de microprocesseurs pour effrayer les étudiants une fois pour toutes.
- produit par un « compilateur », un programme qui convertit un programme écrit sous forme de texte.
- celui d’un « interpréteur » ou d’une « machine virtuelle » qui lit le texte d’un programme ou un « pseudo-code » et effectue une séquence d’instructions correspondant.
Le Chapitre 9 : Langages, Compilateurs et Interpréteurs aborde ces sujets en détail. Ici, nous nous intéressons essentiellement au code natif.
3.1 Les nucléotides
Les instructions « directement » exécutables par un processeur sont très peu nombreuses et résultent directement de l’architecture « Von Neumann » :
- lire des informations de la mémoire et les mettre dans les registres du processeur
- écrire le contenu des registres en mémoire
- calculer des opérations logiques (et, ou) et arithmétiques (addition, soustraction, multiplication, division) sur les registres
- faire un « test » : comparer des valeurs entre elles et déterminer la prochaine instruction à effectuer en fonction du résultat.
Dans le monde informatique, ce sont les nucléotides de l’ADN. Comme l’acétine, la cytosine, la thymine et la guanine (ACTG) , leur assemblage en séquence définit un code décrivant de façon mécanique ce qui doit être fait.
Les processeurs courants comme le Pentium d’Intel offre des instructions plus sophistiquées, mais qui peuvent toujours se ramener à des combinaisons de ces instructions fondamentales, par exemple : lire le contenu de la mémoire à l’adresse indiquée par un registre A et additionner cette valeur au contenu du registre D, puis ajouter 4 au registre A.
De tels processeurs sont dits « CISC » pour « complex instruction set computer », par opposition aux processeurs « RISC » (« reduced instruction set computer ») qui préfèrent effectuer plus rapidement des instructions plus simples. En fait, beaucoup de processeurs CISC ont un noyau RISC qui décompose chaque instruction en plusieurs instructions de « micro-code ». On trouve aussi des processeurs « vectoriels » ou « SIMD » (« single instruction multiple data ») capables d’effectuer la même opération sur plusieurs données « simultanément », mais là aussi, de telles opérations peuvent être ramenées à une séquence d’instruction simples, entre autres à l’aide de boucles.
3.2 Les boucles
Je ne sais pas s’il existe l’équivalent des boucles informatiques dans le code génétique, ni même dans la Nature. Peut-être que c’est là l’apport des logiciens humains à un univers quantique et parallèle. C’est certainement ce qui fait l’intérêt des ordinateurs par rapport au cerveau humain : la capacité de répéter des centaines, des milliers, des millions de fois la même opération sans se tromper, sans se lasser, sans avoir besoin de s’arrêter pour refaire le plein d’énergie, et en ne s’arrêtant que lorsque le travail est fini.Pierre angulaire des « algorithmes » qui occupent le Chapitre suivant, la boucle est fondamentalement une séquence d’instructions qui se termine par un test : si le résultat de la comparaison est vrai, on continue à l’instruction suivante, sinon on recommence la séquence d’instruction.
Exemple de boucle bête, en « pseudo-code » : a=0 ; tantque (a<1000) faire (a=a+1) ;
Les langages de programmation courants offrent diverses variantes de boucle, mais elles sont toutes fondamentalement équivalentes :
- la boucle « tant que » (« while ») répète une séquence tant qu’une condition est vraie.
- La boucle « repete … jusqu’à ce » (« repeat … until » ou « do … loop ») fait la même chose, mais effectue le test après la première itération, et en fait donc au moins une.
- La boucle « de … à » (« for ») compte les itérations effectuées, comme dans ce petit exemple en VisualBasic : for i=1 to 12 : print nom_du_mois(i) : next i
Von Neumann avait déjà identifié un problème ennuyeux des boucles des 2 premiers types : dans le cas général, on ne peut pas déterminer combien de fois elles vont s’exécuter, et on ne peut même pas dire si elles vont se terminer un jour. Ce problème sera abordé au chapitre suivant.
La boucle « for » à l’air d’avoir un avantage de ce point de vue : on sait à l’avance combien de fois elle va s’effectuer. Un programmeur de la NASA croyait le savoir en écrivant un boucle désormais célèbre en Fortran : for (x=0,10.2) do antenna_tracking(x)
Cette ligne dans le code pointant vers la Terre l’antenne de la sonde martienne Viking a causé l’échec de la mission. C’est même un point à la place d’une virgule qui a déçu les espoirs des scientifiques du monde entier : la boucle devait s’effectuer pour x prenant successivement les valeurs 0, 2, 4, 6, 8, 10, mais en écrivant « 10.2 « au lieu de « 10,2 », la boucle s’est faite pour x=0,1,2,3,4,5,6,7,8,9,10
3.3 Les sous-programmes
Le sous-programme est au code ce que le gène est à l’ADN : une séquence de code qui peut être exécutée plusieurs fois, à partir d’endroits différents d’un code « appelant ». L’informatique utilise ce principe beaucoup plus intensivement que la biologie, et le fonctionnement est bien mieux défini :
- le code appelant doit sauvegarder son « état », qui peut se résumer à l’ « adresse de retour » de la prochaine instruction à effectuer après la fin du sous-programme.
- le code appelant peut passer des paramètres au code appelé, soit en chargeant des registres, soit en les stockant en mémoire à un endroit précis
- le code appelant définit la première instruction du code appelé (sous-programme) comme étant la prochaine à exécuter
- le sous-programme s’exécute, éventuellement en appelant lui-même des sous-programmes de la même manière
- à la fin, le sous-programme peut passer des paramètres en retour au code appelant et restaure l’état sauvegardé sous 1, donc au minimum l’adresse de la prochaine instruction du code appelant
- le code appelant peut récupérer les résultats passés en paramètres, et continuer à s’exécuter.
Les seules difficultés pour réaliser tout ce mécanisme avec nos pauvres instructions binaires sont :
- où sauvegarder l’état et passer les paramètres pour pouvoir les retrouver ensuite ?
- comment permettre plusieurs appels de sous-programmes imbriqués, qui peuvent être très nombreux ?
- où stocker des données intermédiaires, propres à un sous-programme, pour éviter qu’elles ne soient corrompues si le sous-programme s’appelait lui-même ? (voir le paragraphe « Chapitre 5 : Un peu d’informatique théorique » au chapitre suivant)
3.4 La pile tombe à pic
La solution aux problèmes posés par les sous-programme est de gérer une partie de la mémoire en « pile », et de réserver un registre du processeur comme « pointeur de pile » indiquant l’adresse mémoire du dernier élément empilé. Le sous-programme peut lire des paramètres sur la pile, empiler ses propres données ou paramètres pour un sous-sous-programme, puis tout dépiler jusqu’à retrouver l’adresse de retour. Génial non ?
La pile apporte une importante leçon au programmeur-écologiste : une petite structure de données (Chapitre 7) bien pensée peut résoudre plusieurs problèmes d’un coup de manière très efficace.
Mais simultanément, nous nous retrouvons avec une ressource de plus à gérer : il faut réserver de la mémoire à la pile, et ne pas la gaspiller. Les deux possibilités extrêmes sont :
- Une toute petite pile, pouvant même être stockée entièrement dans le processeur pour aller très vite. Ceci se fait dans certains processeurs spéciaux comme les DSP (« Digital signal processor ») de nos cartes son ou les GPU (« Graphics Processing Units ») de nos cartes graphiques. Il faut alors s’assurer que la taille de la pile ne sera jamais dépassée, ce qui n’est pas possible si on laisse un utilisateur inconnu écrire des programmes sans garde-fou.
- Allouer toute la mémoire à la pile ! Il est tout à fait possible pour un sous-programme de n’utiliser que la pile pour stocker toutes les données nécessaires. . Le langage de programmation « Forth » est basé sur cette idée, tout comme la « notation polonaise inverse » (RPN en anglais) des calculatrices Hewlett-Packard. Pour calculer (2+3)*5 on fait 2 3 + 5 *, soit en pseudo-langage machine : empiler 2, empiler 3, appeler le sous-programme d’addition (qui dépile 2 nombres et empile leur somme) empiler 5, appeler la multiplication. Il existe même des processeurs fonctionnant sur ce principe, programmables en Forth, par exemple des « Programmable Interface Controllers » ou PIC, utilisés dans de nombreuses applications d’automatisation.
Dans les PC et autres ordinateurs à usage général, on utilise divers compromis :
- Chaque programme « principal », donc qui n’est pas appelé par un autre, dispose de sa propre pile
- La pile est allouée en mémoire au début de l’exécution du programme (voir le paragraphe « Chapitre 7 : Structures de données » du Chapitre suivant.) On lui réserve une taille fixe, typiquement entre 4K et 64K octets. Si le programme tente de dépasser cette taille, une erreur (« stack overflow ») se produit. Pour éviter un tel désagrément, 3 mesures peuvent être prises :
- Augmenter la taille de la pile. C’est en général un gaspillage de mémoire auquel un programmeur écologiste se refuse
- Ne pas empiler de données volumineuses. Les compilateurs se chargent d’économiser la pile en n’y mettant que de petites choses, ou des pointeurs sur de plus grosses données allouées sur le tas, comme on le verra au Chapitre 6 : Vie et mort des données
- Diviser le programme principal en petits programmes un peu moins principaux comme les processus ou les threads, chacun ayant sa pile à lui. Avant d’en parler, revenons sur un des usages principaux de la pile.
3.5 Le passage de paramètres
Une frontière pas si nette
Nos deux chapitres dédiés aux données et au code ne doivent pas faire oublier que la distinction entre les deux est purement sémantique : l’ordinateur ne fait aucune distinction entre ces deux notions si ce n’est que le processeur est capable d’exécuter des instructions de code. Mais données et programmes cohabitent, parfois si étroitement qu’il n’est pas facile de définir qui est quoi. Par exemple :
- Dans une ligne de programme comme « a=a+1 », le « 1 » est-il une donnée ? oui si on peut le changer en « 2 », non si l’on considère que beaucoup de compilateurs traduiront cette ligne en une seule instruction dite « d’incrémentation » pour le processeur.
- Nous verrons au Chapitre 9 : Langages, Compilateurs et Interpréteurs comment des programmes sont capables de transformer des données en d’autres programmes…
- Le « Chapitre 8 : Objets » montrera combiner données et code de la manière la plus intime et la plus propre possible.
Buffer overrun
Pour illustrer de manière percutante la familiarité entre données et code, nous allons terminer ce chapitre en exposant une technique sophistiquée de piratage baptisée « buffer overrun », qui exploite le fait que les programmeurs ont tendance a considérer le code et les données comme plus distincts qu’ils ne le sont en réalité.
De très nombreux programmes contiennent des lignes analogues à celles-ci : (c’est du C)
char[32] buffer ; // réserve 32 bytes en mémoire
input(buffer) ; // permet à l'utilisateur d'entrer une chaîne au clavier
utilise(buffer) ; // appelle une fonction utilisant la donnée entrée
Une fois compilé, le code ressemble à ceci :
04000010 00 00 00 00 00 00 00 00 00 00 00 00
04000020 00 00 00 00 00 00 00 00 00 00 00 00
04000030 push 04000010 ' met l'adresse du buffer sur la pile
04000034 jsr $input ' appelle la fonction input
04000038 push 04000010 ' remet le paramètre sur la pile
0400003C jsr $utilise ' appelle la fonction input
La fonction « input » place dans les octets successifs du « buffer » les caractères tapés au clavier, gère éventuellement la touche de correction, et se termine lorsque l’utilisateur frappe la touche « return », après avoir écrit un octet « 0 » après le dernier caractère valide du buffer.
Mais que se passe-t-il si l’utilisateur entre plus de 32 caractères ? Rien n’est prévu pour limiter le nombre de caractères autorisés : il aurait fallu que la fonction « input » prenne un second paramètre avec cette taille. Le 33ème caractère va donc être inscrit en mémoire à la suite du buffer, écrasant l’instruction « push »… Cela ne fera cependant pas de dégât immédiat, car les instructions d’appel de la fonction « input » ont déjà été exécutées.
Il en ira de même pour les 7 caractères suivants, mais pas pour les suivant, qui vont écraser les instructions qui seront effectuées après la fin de la fonction input, juste après le « return » !
Les lecteurs qui ne voient pas encore le problème n’ont décidément pas un profil de « hacker », voire de pirate. En frappant des caractères dont le code correspond à des instructions, par exemple « abcd » pour « jmp 30313233 », l’utilisateur avisé peut dévier l’exécution normale du programme vers une adresse de son choix, qui correspond par exemple à un programme « cheval de troie » préalablement chargé sous l’apparence d’une innocente image, ou même directement dans le buffer s’il est nettement plus grand que les 32 caractères utilisés dans cet exemple.
La simple idée que ceci puisse se faire sur un serveur à travers internet devrait terrifier tout administrateur système, et sonner comme une sirène d’alarme dans le cerveau de tout programmeur chaque fois qu’il code une entrée de données.