I. Contexte▲
Dans mon article précédent Pourquoi je suis devenu formateur, je déplorais le manque d'attention portée à la compréhension de la programmation bas niveau qui semble manquer aux récents diplômés en informatique (du moins en Grande-Bretagne…)
J'ai donc reçu un commentaire d'un certain Travis Woodward qui disait :
« Il y a de nombreux étudiants qui souhaitent se lancer dans du bas niveau, mais il est difficile de savoir par où commencer et quoi apprendre (le bon vieux problème de « vous ne savez pas ce que vous ignorez »).
J'ai recherché un programme pour appréhender le bas niveau, mais n'ai trouvé que des listes de sujets qui n'aident pas à cause de l'absence de contexte ou de ressources avec lesquelles commencer. La meilleure introduction que j'ai trouvée est un cours intitulé CS107: Programming Paradigms from Standford sur iTunes U, qui traite en grande partie de comment le C et le C++ sont vus du point de vue du compilateur.
Donc si des programmeurs bas niveau souhaitent se regrouper afin de créer un tel cours, incluant des ressources, s'il vous plaît faites-le ! »
Comme il s'agit d'une idée louable, j'ai décidé de m'y mettre…
II. Programme d'étude bas niveau ?▲
Avant d'aller plus loin, je souhaiterais mettre au clair ce que j'entends par « Programme d'étude bas niveau ».
Lorsque je travaillais dans l'industrie, j'ai aidé au développement de l'architecture et au déploiement multiplateformes d'un moteur Next-Gen (qui est désormais la génération actuelle), j'ai écrit de nombreux shaders, résolu un grand nombre de bugs en étudiant le code assembleur et l'état de la mémoire de la machine, chassé les problèmes d'interblocage en milieu threadé et régulièrement travaillé sur des fichiers core dump de jeux de tests de PS3/Xbox 360 afin de résoudre des bugs impossibles à reproduire ; mais ça ne fait pas de moi un programmeur bas niveau - c'est le genre de choses que j'attends de toute personne ayant mon expérience.
Je ne suis jamais resté assis des heures devant des captures GPad ou Pix, je ne me suis jamais vraiment soucié de corriger un fragment shader ou des calculs parallèles sur SPU ou comment tirer le meilleur de mes AltiVec et je n'ai certainement jamais recodé une partie de code en assembleur en tirant parti de techniques de caches ou de mode DMA sournois pour grappiller quelques FPS - c'est ce que font les programmeurs bas niveau, du code spécifique plateforme et hardware, optimisé afin de tirer les meilleures performances d'une machine.
Ce programme d'étude n'a pas pour but de faire de vous un programmeur bas niveau.
Ce programme d'étude a pour objectif de vous faire acquérir de solides connaissances sur les fondements d'une implémentation bas niveau en C et C++ (1) - une compréhension qui selon moi devrait être la base de tout programmeur évoluant dans le jeu vidéo.
Au cours des éventuels nombreux billets de blog, j'aborderais :
- Les types de données ;
- Les pointeurs, tableaux et références ;
- Les fonctions et la pile ;
- Le modèle objet C++ ;
- La mémoire ;
- La mise en cache, les différents caches.
En supposant que vous lisiez et compreniez tous les articles de cette série - et que je parvienne à communiquer les informations correctement - vous devriez atteindre un niveau qui vous permettra de connaître chaque « faiblesse » du langage, mais aussi - et c'est plus important - vous comprendrez pourquoi elles existent.
Par exemple, vous savez (peut-être pas) que les fonctions virtuelles ne peuvent pas être appelées dans un constructeur, mais avant la fin de cette série d'articles vous comprendrez pourquoi elles ne peuvent l'être.
Juste pour clarifier les choses à nouveau, je ne parle pas forcément du niveau de compréhension d'une personne qui écrit un compilateur professionnel ; mais d'un niveau qui vous donne une meilleure idée de ce qu'il se passe après l'écriture du code dans la chaîne de compilation et par conséquent vous permet de comprendre ce que le code que vous avez écrit peut impliquer.
III. Il n'y a pas de code source disponible pour l'emplacement en cours▲
Je suis sûr que la vaste majorité des programmeurs qui utilisent Visual Studio ont paniqué les premières fois qu'ils ont vu cette boîte de dialogue.
Ce fut mon cas.
J'ai appris à programmer sur un écran vert (ou orange si les écrans verts étaient déjà pris), sur un environnement Unix mainframe. Vous savez, les mêmes qu'ils ont dans les vieux films comme Alien. Les étudiants de seconde et troisième année avaient priorité pour utiliser les machines XWindows (et le peu de machines Silicon Graphics étaient réservées aux projets graphiques des étudiants de troisième année), donc j'ai appris mon métier grâce à ces Unix.
Même les machines XWindows ne possédaient pas d'EDI - j'utilisais Emacs et les makefile GNU - et le seul débogueur était GDB en ligne de commande, qui n'est pas vraiment ce qu'on appellerait « user-friendly ». J'utilisais finalement std::
cout.
Quand j'ai été diplômé, je suis passé d'un monde aux claviers en bakélite, d'images rémanentes et lignes de commandes, au monde de Windows 95 et Visual Studio 4 (juste avant que naissent Direct X et les accélérations graphiques matérielles).
Quand j'ai vu cette boîte de dialogue pour la première fois, j'ai paniqué - et ça n'aurait pas dû arriver !
Grâce à l'enseignement focalisé sur les éléments haut niveau comme la syntaxe du langage et l'architecture du code, je n'avais aucune idée de ce qui se cachait derrière le compilateur à part ce que mes brèves incursions avec GDB m'avaient offert.
Je venais d'être diplômé d'une université très respectée où ils ne m'avaient rien enseigné du tout sur l'assembleur dans le cadre du cursus principal et j'avais supposé que c'était parce qu'ils pensaient que c'était trop pour mon esprit chétif.
Assez parlé, j'ai surmonté ma peur - mais j'ai pris cette boîte de dialogue comme un signe d'absence d'informations plus longtemps que je ne voudrais l'admettre.
Je n'ai vraiment commencé à la surmonter que quelques années plus tard, alors que je travaillais en collaboration avec quelqu'un qui avait obtenu un emploi dans l'industrie du jeu vidéo par sa connaissance de l'assembleur.
J'ai eu un crash et il a juste cliqué sur le bouton « Voir le code machine ». Il m'a alors montré, de manière naturelle, exactement pourquoi mon code plantait - en me l'expliquant en termes de comment le C++ se traduit en code machine - et m'a indiqué comment le réparer.
Ceci m'a ouvert l'esprit sur trois points :
- Cette personne était très décontractée face à la boîte de dialogue ;
- Le code machine n'était clairement pas la magie noire qu'il semblait être ;
- Ça paraissait si simple que je ne comprenais pas pourquoi ça ne nous avait pas été enseigné à l'université dans le cursus C++.
IV. Déchirer le voile du code machine▲
Je ne réalisais pas l'importance que ça avait jusqu'à ce que je rencontre un certain Andy Yelland. Si vous connaissez Andy, vous savez exactement de quoi je parle, mais pour ceux qui ne l'ont pas rencontré, je vais expliquer.
Andy est une de ces personnes qui change votre point de vue. Il est plus ou moins l'exact opposé du stéréotype du programmeur de jeu vidéo : bien habillé, professionnel, toujours bien informé, amical, drôle et socialement intégré.
Cependant, son talent le plus extraordinaire est la vitesse à laquelle il peut disséquer un fichier core dump de console. Il s'assoit calmement et étudie calmement sa pile, prenant de temps en temps des notes sur la valeur de certains registres, regardant l'adresse de la fonction dans un fichier de symbole, et au bout d'un certain temps - pouvant aller de 5 min à quelques heures selon la difficulté du problème - se retourne et vous explique exactement quel était le problème.
Non seulement il peut réaliser cet exploit sur du code qu'il n'a jamais vu auparavant - mais mieux encore, il est toujours heureux de s'asseoir et de vous expliquer toutes les actions qu'il a accomplies.
Après plusieurs dissections d'erreurs avec Andy, j'ai réalisé que ce que je prenais pour de la magie noire était en fait tout l'inverse.
Il s'agit d'avoir une connaissance experte du fonctionnement du C++ au niveau assembleur/machine et d'appliquer ces connaissances méthodiquement via l'ingénierie inversée à partir de l'état actuel (i.e. quand l'application a planté) jusqu'au point où les données incorrectes ont été introduites.
Ça demande clairement beaucoup de pratique et pour atteindre le niveau d'Andy, il faut des années (sauf pour Rain Man).
Je ne dis pas que tout le monde devrait être capable de déchiffrer le code machine d'un code écrit par quelqu'un d'autre - je n'en suis certainement pas capable.
Je pense que tout programmeur de jeu vidéo devrait être capable de regarder le code machine et être capable d'au moins comprendre ce qu'il se passe et en s'appuyant sur leur compréhension de comment le C++ est implémenté à bas niveau - peut-être avec des manuels sur le matériel à un moment donné - devrait alors être capable de comprendre l'erreur.
La première règle du cours bas niveau est : tu ne craindras pas le code machine.
Supposons que vous soyez d'accord avec moi, par où commencer ?
Je pense que le meilleur moyen est de regarder un peu de code machine, alors commençons par ça.
Faites-vous un projet de test dans votre EDI C++ préféré et écrivez ce simple code dans la fonction main.
Pour ma part j'utilise Visual Studio 2010 sur PC Windows.
int
x =
1
;
int
y =
2
;
int
z =
0
;
z =
x +
y;
Mettez-vous en configuration « Debug » et placez un point d'arrêt sur la ligne 5 (z = x + y), puis exécutez le programme.
Quand le point d'arrêt est rencontré, faites un clic droit dans la fenêtre d'édition du code et choisissez « Code machine ».
NE PANIQUEZ PAS !
Vous devriez avoir un affichage proche de l'image ci-dessus. Le texte en noir avec le numéro de ligne est notre code compilé, le code en gris sous chaque ligne montre le code assembleur généré pour chaque ligne.
V. Alors qu'est-ce que tout ça signifie ?▲
Le numéro en hexadécimal en début de chaque ligne d'assembleur (grise) est l'adresse mémoire de la ligne - souvenez-vous que le code n'est qu'un flux de données qui indique au CPU ce qu'il doit faire, donc logiquement il doit avoir une adresse mémoire. Ne vous inquiétez pas trop à ce sujet, je voulais juste vous montrer que les instructions sont également en mémoire.
mov
et add
sont des mnémoniques (mots-clés) assembleur - chacun représente une instruction, une par ligne avec ses arguments.
eax
et ebp
sont deux registres dans un CPU x86 (32 bits). Les registres sont des zones mémoires pour le CPU : des fragments de mémoire dans le CPU lui-même et auxquels le CPU peut accéder instantanément. Plutôt que de parler d'adresse comme pour la mémoire, les registres sont nommés en assembleur parce qu'il en existe généralement un (relativement) petit nombre.
Le registre eax
est à usage général, mais est principalement utilisé pour les opérations mathématiques.
Le registre ebp
est le « pointeur de base ». En assembleur x86, les variables locales seront généralement accédées via un offset à partir de ce registre. Nous verrons l'utilisation du registre ebp plus tard.
Comme mentionné dans le paragraphe précédent, ebp
-
8
, ebp
-
14
, et ebp
-
20
sont les adresses mémoires des variables locales, respectivement x, y et z, auxquelles on accède via offset à partir de ebp
.
dword
ptr
[...] signifie « la valeur codée sur 32 bits, stockée à l'adresse entre crochets » (ceci est vrai pour de l'assembleur Win32, ça peut différer en Win64 - je n'ai pas vérifié (2)).
VI. Comment ça marche ?▲
Nous savons que le code assembleur généré par notre code C++ initialisera les trois variables x, y et z ; puis ajoutera x et y et stockera le résultat dans z.
Regardons chaque ligne assembleur de manière isolée (on ignorera l'adresse de la ligne).
mov
dword
ptr
[ebp
-
8
],1
Cette instruction initialise x en déplaçant la valeur 1 à l'adresse mémoire ebp
-
8
.
mov
dword
ptr
[ebp
-
14h
],2
Cette instruction initialise y en déplaçant la valeur 2 à l'adresse mémoire ebp
-
14h
- note : le « h » est nécessaire parce que 14 en décimal est différent de 14 en hexadécimal - ce n'était pas nécessaire pour x car 8 en décimal est également 8 en hexadécimal.
mov
dword
ptr
[ebp
-
20h
],0
Cette instruction initialise la valeur de z.
Maintenant nous sommes rendus à la partie intéressante, l'arithmétique pour assigner le résultat à z.
mov
eax
, dword
ptr
[ebp
-
8
]
Cette instruction déplace la valeur à l'adresse ebp-8 (soit x) dans le registre eax
…
add
eax
, dword
ptr
[ebp
-
14h
]
… cette instruction ajoute la valeur à l'adresse ebp
-
14h
(soit y) au registre eax
…
mov
dword
ptr
[ebp
-
20h
],eax
… et cette instruction déplace la valeur du registre eax
à l'adresse ebp
-
20h
(soit z).
Donc, comme vous le voyez, alors que le code assembleur a l'air totalement différent, il est logiquement isomorphe au code C++ dont il est généré (i.e. son comportement peut être un peu différent, mais il fournira la même sortie pour une entrée donnée).
VII. Allons, pourquoi a-t-on revu ça ?▲
Ceux d'entre vous qui ont le cerveau connecté à leurs yeux auront remarqué que le code machine que je viens de fournir est « un peu ringard ».
Honnêtement, c'était le but en choisissant un tel exemple. Le but était de montrer comment quelque chose d'aussi simple qu'ajouter deux entiers et de stocker le résultat dans un troisième en C++ se traduisait en assembleur.
Vous pouvez utiliser cette technique pour voir comment la majorité du code C++ est traduit et généré en code machine, et le but était de montrer que c'est plutôt simple à réaliser.
Évidemment cet exemple ne montre que deux mnémoniques de l'assembleur x86 et il y en a beaucoup plus.
Si vous rencontrez des mnémoniques que vous ne connaissez pas, il suffit généralement de les rechercher sur Google afin de connaître leur utilité et fonctionnement. C'est ce que j'ai toujours fait et il y a tellement d'informations sur l'assembleur x86 sur le web que vous ne devriez avoir aucun souci à déchiffrer du code.
J'ai trouvé une page très utile qui regroupe les registres x86 en détail : http://www.swansontec.com/sregisters.html
Voici un lien vers une page pour télécharger un fichier PDF « antisèche» sur l'assembleur x86 : Intel Assembler 80x86 CodeTable
Bien sûr la page Wikipédia: http://en.wikipedia.org/wiki/X86_instruction_listings
Et un lien tiré de la page Wikipédia : http://home.comcast.net/~fbui/intel.html
VIII. Résumé▲
Alors que peu de programmeurs auront besoin d'écrire de l'assembleur, tout programmeur de jeu vidéo aura - tôt ou tard - compris l'avantage de comprendre et déchiffrer celui-ci. C'est incroyable tout ce que vous pouvez comprendre avec une faible connaissance de l'assembleur et de sa façon de traduire le code C++.
L'exemple que nous avons vu, comme je l'ai déjà concédé, était très simple.
Le but de ce premier article n'était pas de vous donner des réponses, mais de vous montrer que le code assembleur n'est intimidant que si vous le laissez l'être ; et vous encouragez à explorer ce que le compilateur fait à partir du code que vous lui fournissez.
N'abandonnez pas parce que vous ne comprenez pas ce que vous voyez ; utiliser Google ou poser des questions spécifiques aux bons endroits comme http://stackoverflow.com (3).
IX. Épilogue▲
Il y a d'autres points sur lesquels je voudrais attirer votre attention à partir de ce simple exemple :
- Le principe de variable en C++ (ou de tout autre langage qui utilise des variables) n'existe pas au niveau de la machine. En code machine, les valeurs de x, y et z sont stockées à des adresses mémoires spécifiques, et le CPU y accède via leur adresse mémoire respective. Une variable est un concept haut niveau du langage, qui est plus simple à manipuler, mais est déjà un concept abstrait d'identification d'une variable dans une adresse mémoire ;
- Notez que pour réaliser toute action « intéressante » (comme ajouter une valeur à une autre), le CPU doit avoir au moins une partie de ses données dans un registre (je suis sûr que certains CPU doivent être capables d'opérer directement sur la mémoire, mais ce n'est certainement pas la façon usuelle de les réaliser).
Finalement, je pense que cet exemple extrêmement simple illustre ce qui est pour ma part un des faits les plus importants en programmation :
- Les langages haut niveau existent pour nous faciliter la vie, ils ne sont pas du tout représentatifs de la façon de fonctionner du CPU - en fait, même l'assembleur est une commodité à côté du code binaire que les mnémoniques (
mov
,add
, etc.) représentent.
Je vous conseille de ne pas trop vous inquiéter du code binaire, et souciez-vous encore moins des électrons et du silicium.
X. Remerciements▲
Cet article est une traduction autorisée de A Low Level Curriculum for C and C++ par Alex Darby.
Merci à Obsidian, à LittleWhite et à cob59 pour leur relecture technique, à f-leb et à _Max_ pour sa relecture orthographique.