L'assembleur portable n'existe pas
On entend parfois dire qu’un même fichier assembleur se recompilerait et tournerait partout, pourvu qu’on reste sur une architecture x86-64 commune. C’est une jolie idée et elle se heurte à la réalité dès que l’on regarde ce qui entoure vraiment l’instruction machine. Le jeu d’instructions, lui, est effectivement partagé. Tout le reste, l’outillage qui assemble, le format qui emballe, la convention qui fait dialoguer les fonctions et la porte par laquelle on entre dans le noyau, tout cela diverge d’un système à l’autre.
Je ne prétends pas être exhaustif, on pourrait écrire un livre là-dessus, mais voici les points concrets qui condamnent le mythe.
Les assembleurs ne parlent pas tous la même langue
Avant même de produire quoi que ce soit, il faut choisir son assembleur et la syntaxe diverge déjà. Sous Linux, BSD et macOS on dispose de NASM et de GAS, ce dernier acceptant aussi bien la syntaxe AT&T que la syntaxe Intel. Sous Linux on peut ajouter FASM et YASM. Sous Windows on retrouve MASM, NASM et GAS.
Rien que ce tableau devrait suffire à doucher les enthousiasmes : le même algorithme s’écrit différemment selon l’outil, les directives ne se recouvrent pas et l’édition de liens se pilote autrement d’une chaîne à l’autre.
Trois formats d’exécutables, trois mondes
Une fois le code assemblé, il faut l’emballer et chaque famille d’OS impose le sien. ELF règne sur Linux et les BSD, Mach-O habille macOS et Windows s’appuie sur PE et son cousin objet COFF.
Ces formats ne se contentent pas d’avoir des en-têtes différents. Les mécanismes de résolution d’adresse changent eux aussi, de sorte qu’un symbole externe ne se retrouve pas de la même manière selon qu’on soit dans un ELF, un Mach-O ou un PE. La gestion de la pile et du runtime, très proche entre Linux et BSD, se distingue nettement sur les deux autres.
Les ABI : qui met quoi dans quel registre
Supposons même qu’on ait franchi ces obstacles. Reste la convention d’appel, c’est-à-dire l’accord tacite sur la façon de passer les arguments, de ranger la valeur de retour et de préserver les registres.
Linux, les BSD et macOS suivent l’ABI SysV, encore que macOS la décline à sa sauce. Windows, lui, impose l’ABI Microsoft x64, qui ne place pas les arguments dans les mêmes registres et ne gère pas l’espace réservé sur la pile de la même façon.
Autant dire qu’une routine écrite pour l’une appellera de travers une fonction compilée pour l’autre et que l’erreur ne se verra qu’à l’exécution, souvent de la pire des manières.
Les appels système : la vraie frontière
C’est au moment de parler au noyau que le rêve de portabilité s’effondre pour de bon.
Entre Linux et BSD les appels système se ressemblent dans l’esprit, mais ils n’ont pas les mêmes numéros, si bien qu’un même syscall ne déclenche pas le même service d’un système à l’autre.
macOS, du fait de son architecture hybride XNU, mélange d’héritage Mach et de BSD, a sa propre tambouille et l’on n’est pas censé invoquer ses syscalls directement : le passage recommandé se fait par libSystem.
Windows a également ses propres appels, les NtCreateFile, NtAllocateVirtualMemory et consorts, que l’on n’appelle pas non plus à la main : le chemin officiel va de l’API Win32 vers ntdll.dll, qui seule franchit la limite vers le noyau.
Dans les deux cas, viser la couche basse directement revient à écrire un code fragile, dépendant d’un ordre de numérotation que l’éditeur ne s’est jamais engagé à figer.
Une parenthèse pour les anciens
Pour qui a connu le DOS, rien de tout cela n’étonne. À l’époque on s’amusait bien avec MASM, le mode réel 16 bits restant limité mais nettement plus confortable qu’un Z80 8 bits de CPC.
Les interruptions passaient par le DOS et le BIOS, les registres et les segments tenaient sur 16 bits, AX là où l’on écrirait aujourd’hui EAX en 32 bits puis RAX en 64. Pas de pagination, pas de multitâche, aucune protection mémoire. Et pour les plus curieux, un 486DX2 permettait déjà de basculer en mode protégé 32 bits, histoire de goûter à autre chose.
Le décor a changé, la leçon demeure : la portabilité au niveau machine a toujours été une affaire de couches, jamais un acquis.
Portable, mon oeil
- Le jeu d’instructions x86-64 est partagé, mais tout ce qui l’entoure diverge d’un OS à l’autre.
- Assembleur, format d’exécutable, résolution d’adresse, ABI et appels système forment autant de frontières indépendantes.
- SysV d’un côté, Microsoft x64 de l’autre : les conventions d’appel ne sont pas interchangeables.
- On ne tape pas les syscalls en direct sous Windows ni sous macOS, on passe par ntdll.dll ou libSystem.
- “Assembleur portable” est un oxymore tant qu’on ne s’astreint pas à une couche d’abstraction qui, elle, a un coût.
Pour prolonger la descente, voir les syscalls de Linux qui détaillent l’interface noyau évoquée ici, puis les sockets en C et les raw sockets pour voir ce que la libc absorbe quand on reste un cran au-dessus.