// Assembleur

Le langage assembleur : comprendre le plus bas niveau

Le langage assembleur est le langage le plus proche du processeur qu’un humain puisse raisonnablement écrire. Chaque instruction y correspond, ou presque, à une opération que le CPU exécute directement. Le comprendre, c’est lever le capot sur ce qui se passe réellement quand votre code Python ou C tourne. Ce tutoriel pose les fondations : registres, instructions, pile, avec un petit exemple x86-64 et, surtout, une réponse honnête à la question « pourquoi l’apprendre encore aujourd’hui ? ».

De quoi parle-t-on ?

Un processeur ne comprend que du code machine : des suites de bits. L’assembleur en est la représentation lisible : à chaque instruction machine correspond un mnémonique (mov, add, jmp…). Un programme appelé assembleur (au sens de l’outil, assembler) traduit ce texte en code machine.

Point crucial : l’assembleur est spécifique à une architecture. L’assembleur x86-64 (PC, serveurs) diffère de l’ARM (téléphones, Mac récents, Raspberry Pi). Apprendre l’un rend les autres plus faciles, mais ils ne sont pas interchangeables. Nous utiliserons ici x86-64.

Les registres

Les registres sont de minuscules cases mémoire situées dans le processeur. Ils sont extrêmement rapides — bien plus que la RAM — mais peu nombreux. C’est là que le CPU manipule ses données. Sur x86-64, on trouve notamment des registres généraux de 64 bits :

  • rax, rbx, rcx, rdx : usage général (calculs, valeurs temporaires) ;
  • rsi, rdi : souvent source et destination, notamment pour passer des arguments ;
  • rsp : le pointeur de pile (stack pointer) ;
  • rbp : le pointeur de base de la trame (base pointer) ;
  • r8 à r15 : registres supplémentaires.

Chaque registre 64 bits a des sous-parties accessibles : eax désigne les 32 bits de poids faible de rax, ax les 16 bits, al les 8 bits. Pratique pour travailler sur des données plus petites sans registre dédié.

Les instructions

Une instruction assembleur suit typiquement la forme opération destination, source (syntaxe Intel, la plus lisible pour débuter). Quelques instructions fondamentales :

  • mov — copie une valeur : mov rax, 5 place 5 dans rax ;
  • add / sub — addition / soustraction : add rax, rbx fait rax = rax + rbx ;
  • cmp — compare deux valeurs (en soustrayant sans stocker), ce qui met à jour les drapeaux ;
  • jmp, je, jne, jg — sauts conditionnels ou non, qui permettent boucles et branchements ;
  • call / ret — appeler une routine et en revenir ;
  • push / pop — empiler / dépiler une valeur sur la pile.

Il n’y a pas de if, de for, ni de fonction au sens des langages de haut niveau : on les reconstruit à partir de comparaisons et de sauts. C’est déroutant, puis révélateur : on voit enfin de quoi sont faites ces abstractions.

La pile

La pile (stack) est une zone de mémoire organisée en « dernier entré, premier sorti ». Le processeur s’en sert pour stocker les variables locales, sauvegarder des registres, et surtout mémoriser où revenir après un appel de fonction. Le registre rsp pointe en permanence sur son sommet.

  • push rax décrémente rsp et y écrit rax ;
  • pop rbx lit le sommet dans rbx et incrémente rsp ;
  • call empile automatiquement l’adresse de retour ; ret la dépile pour y sauter.

Comprendre la pile, c’est comprendre comment les fonctions s’imbriquent — et pourquoi une récursion infinie finit par la faire déborder (stack overflow).

Un petit exemple x86-64

Voici une fonction qui additionne deux entiers, en syntaxe AT&T (celle de GNU as, où l’ordre est source, destination et les registres sont préfixés de %). Elle suit la convention d’appel Linux : premier argument dans rdi, deuxième dans rsi, retour dans rax.

    .text
    .globl addition
addition:                 # int addition(int a, int b)
    movl %edi, %eax       # eax = a (1er argument)
    addl %esi, %eax       # eax = eax + b (2e argument)
    ret                   # la valeur de retour est dans eax

Chaque ligne fait exactement une chose. On copie le premier argument dans le registre de retour, on y ajoute le second, on revient. Il n’y a rien de caché : c’est là toute la force — et la lourdeur — de l’assembleur. Pour appeler ce genre de routine depuis du C, voyez notre tutoriel dédié à l’interfaçage entre assembleur et C.

Pourquoi l’apprendre aujourd’hui ?

Personne, ou presque, n’écrit d’application entière en assembleur en 2026. Les compilateurs modernes produisent du code souvent plus optimisé qu’un humain, et la productivité serait catastrophique. Alors pourquoi s’y intéresser ? Pour des raisons très concrètes :

  • Comprendre en profondeur — pointeurs, pile, débordements de tampon, passage d’arguments : ces notions deviennent limpides quand on a vu leur mécanique sous-jacente.
  • Déboguer et rétro-ingénierie — lire un désassemblage, analyser un crash, inspecter un binaire sans code source, en sécurité informatique notamment.
  • Optimiser des points critiques — quelques routines choisies (SIMD, cryptographie, systèmes embarqués contraints) peuvent justifier de l’assembleur, après mesure.
  • Systèmes embarqués et bas niveau — noyaux, pilotes, micro-contrôleurs frôlent parfois le métal.

Autrement dit, on apprend l’assembleur moins pour l’écrire au quotidien que pour mieux écrire tout le reste. C’est un investissement de compréhension, pas d’usage.

Par où commencer

Nul besoin de tout retenir. Installez GCC, écrivez de petites fonctions en C, puis regardez l’assembleur généré avec gcc -S -masm=intel fichier.c. Comparer votre C et l’assembleur produit est la façon la plus efficace de progresser : vous verrez concrètement comment une boucle for devient une suite de cmp et de jmp. Le reste vient avec la pratique et la curiosité.


À lire ensuite