Skip to content

Niveau 2 · Chapitre 2.7

Flot de contrôle : if, boucles, switch

Comment le flot de contrôle structuré du C devient des comparaisons et des sauts : if et else, les trois boucles et l’endroit où les compilateurs placent le test, break et continue, l’évaluation court-circuit de && et ||, et les quatre façons de compiler un switch — chaîne de comparaisons, table de sauts, recherche dichotomique et table de valeurs —, avec les motifs à reconnaître dans un désassemblage.

Le CPU n’a ni if, ni for, ni switch. Il exécute les instructions l’une après l’autre, et le seul moyen de changer cet ordre est un saut, conditionnel ou non. Chaque instruction structurée du C est un motif de comparaisons et de sauts. Le chapitre sur les indicateurs a montré la brique de base : cmp positionne les indicateurs, un saut conditionnel les lit, et le saut teste en général l’inverse de la condition C, pour sauter par-dessus le bloc « alors ». Ce chapitre assemble ces briques en chacune des structures de contrôle du C, et montre ce qu’en fait un compilateur optimisant. Reconnaître ces formes représente l’essentiel du travail de lecture d’un désassemblage.

if et else

if (cond) A; else B;

devient :

    ; évaluer cond
    j<non cond> else     ; sauter A si la condition est fausse
    A
    jmp end              ; sauter B
else:
    B
end:

Sans else, le jmp end et le bloc else disparaissent. Une chaîne else if n’est qu’un autre if à l’intérieur du bloc else. Dans un désassemblage, un saut conditionnel vers l’avant par-dessus un bloc, suivi d’un saut inconditionnel par-dessus un second bloc, est la signature d’un if/else.

Les boucles

Une boucle est un if dont la dernière instruction remonte. La seule question est où placer le test. Tanenbaum décrit les deux options : le test au début (while, for), qui exécute correctement zéro fois le corps si la condition est fausse d’emblée, et le test à la fin (do … while), qui exécute toujours le corps au moins une fois. Sa figure 5-29 contient une petite erreur : la version avec test à la fin compte i à partir de 1 et reboucle tant que i < n, elle fait donc une itération de moins que la version avec test au début placée à côté, qui en fait n. Il faudrait tester i <= n.

Le compilateur du simulateur, comme clang -O0, compile un for avec le test au début et l’incrément en bas :

Live · Une boucle for : test, corps, incrément, retour

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int main() {
  2. int sum = 0;
  3. for (int i = 1; i <= 4; i++)
  4. sum += i;
  5. return sum;
  6. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Elle renvoie 10. Zoomez jusqu’à l’assembleur pour voir la forme : cmp [i], 4 / jg hors de la boucle en haut, le corps, add [i], 1, et un jmp qui remonte au test. Cela fait deux sauts par itération : le conditionnel en haut, et l’inconditionnel en bas. gcc -O0 fait l’inverse : un jmp direct vers un test placé après le corps, qui remonte tant que la condition est vraie.

Les compilateurs optimisants font pivoter la boucle (loop rotation) pour en économiser un : un seul test avant la boucle pour traiter le cas zéro itération, puis une boucle avec test à la fin. C’est la combinaison que recommande le livre quand le compilateur ne peut pas prouver que la boucle s’exécute au moins une fois. clang -O2 pour for (int i = 0; i < n; i++) work(i); :

loop:
    test edi, edi
    jle  .done            ; n <= 0 : ne pas entrer dans la boucle
    …                     ; sauvegarde des registres, i = 0 dans ebp, n dans ebx
.body:
    mov  edi, ebp
    call work
    inc  ebp              ; i++
    cmp  ebx, ebp
    jne  .body            ; test à la fin : un saut par itération
    …
.done:
    ret

Un saut conditionnel vers l’arrière — vers une adresse plus basse — est la façon de repérer une boucle dans du code machine. Le prédicteur de branchements s’appuie sur le même fait : les branchements vers l’arrière sont en général pris.

break, continue, goto

break est un saut vers l’instruction qui suit la boucle, et continue un saut vers l’incrément ou le test de la boucle. Ils n’ont pas de forme machine particulière, ce qui explique aussi que le C ait un goto : c’est la seule instruction C qui correspond à une seule instruction machine. Il reste utile pour sauter vers un bloc de nettoyage commun en fin de fonction, comme le fait partout le noyau Linux.

Live · break : quitter la boucle au premier nombre impair

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int main() {
  2. int found = -1;
  3. int a[6] = {4, 8, 15, 16, 23, 42};
  4. for (int i = 0; i < 6; i++) {
  5. if (a[i] % 2 == 1) {
  6. found = i;
  7. break;
  8. }
  9. }
  10. return found;
  11. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Elle renvoie 2 : la boucle s’arrête sur 15, et 23 n’est jamais examiné.

&& et || sont aussi des sauts

En C, a && b n’évalue pas b si a est faux, et a || b n’évalue pas b si a est vrai. C’est l’évaluation en court-circuit, et c’est ce qui rend p != NULL && p->x > 0 sûr. Cela signifie que && et || se compilent en sauts, pas en instruction and ou or. return x >= 10 && x <= 20; devient deux comparaisons, chacune sautant directement à « faux » si elle échoue :

    cmp DWORD PTR [rbp-4], 10
    jl  .false            ; x < 10 : tout le && est faux, sauter le second test
    cmp DWORD PTR [rbp-4], 20
    jg  .false
    mov eax, 1
    jmp .end
.false:
    mov eax, 0

On peut voir le second opérande être sauté :

Live · Court-circuit : le côté droit ne s’exécute que si nécessaire

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int calls;
  2. int check(int v) {
  3. calls++;
  4. return v;
  5. }
  6. int main() {
  7. if (check(0) && check(1))
  8. return 100;
  9. if (check(1) || check(0))
  10. return calls;
  11. return 0;
  12. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 2. check(0) && … s’arrête après son premier appel car le côté gauche est faux, et check(1) || … s’arrête après son premier appel car le côté gauche est vrai. Quatre appels sont écrits ; deux s’exécutent.

Quatre façons de compiler un switch

Un switch compare une valeur à de nombreuses constantes. Les compilateurs choisissent entre plusieurs stratégies selon la répartition des valeurs des case — et le choix se voit dans le binaire.

Une chaîne de comparaisons. Pour quelques cas, ou des valeurs très espacées, le compilateur les teste simplement un par un : cmp eax, 1 / je, cmp eax, 100 / je, et ainsi de suite. C’est ce que fait clang -O0 pour les cas 1, 100, 1000 et 10000.

Une table de sauts. Quand les cas sont denses — de 0 à 4, ou de 1 à 6 —, le compilateur construit dans .rodata une table d’adresses de code, une par valeur, et saute à travers elle en une seule étape, quel que soit le nombre de cas. clang le fait même en -O0, et le compilateur du simulateur aussi. Le seuil de gcc dépend de la cible et du niveau d’optimisation : pour le switch à cinq cas ci-dessous, gcc 12 en -O0 pour ARM64 produit encore une chaîne de comparaisons.

Live · Un switch dense devient une table de sauts

À essayer : Appuyez sur Step pour exécuter une instruction, Run pour animer ou Continue pour aller au bout ; les boutons L2 à L7 changent de niveau, vers le bas ou le haut.

C source — click a line number for a breakpoint
  1. int op(int k, int a, int b) {
  2. switch (k) {
  3. case 0: return a + b;
  4. case 1: return a - b;
  5. case 2: return a * b;
  6. case 3: return a & b;
  7. case 4: return a | b;
  8. default: return 0;
  9. }
  10. }
  11. int main() {
  12. return op(2, 6, 7);
  13. }
step 0
Loading emulator…
Your program as you wrote it: the current line, its variables by name, and its output.

Il renvoie 42. Zoomez jusqu’à l’assembleur pour voir la table :

    mov eax, DWORD PTR [rbp-4]     ; k
    cmp eax, 4
    ja  .default                   ; k < 0 ou k > 4 (la comparaison non signée attrape les deux)
    lea rdx, [rip+.table]
    jmp QWORD PTR [rdx+rax*8]      ; sauter à table[k]
    …
.table:
    .quad .case0, .case1, .case2, .case3, .case4

Une astuce mérite d’être remarquée : ja, une comparaison non signée, rejette aussi les valeurs négatives, puisque −1 vu comme non signé vaut 4 294 967 295. Si les cas commencent à 1 au lieu de 0, le compilateur soustrait d’abord 1. Les trous dans l’intervalle pointent vers default. Dans un désassemblage, cette séquence — une vérification de bornes, puis un jmp indirect à travers [table + index × 8] — signifie switch, et la table indique où se trouve chaque cas.

Une recherche dichotomique. Pour des valeurs clairsemées, les compilateurs optimisants trient les cas et les cherchent par dichotomie : clang -O2 compile le switch 1/100/1000/10000 en commençant par cmp edi, 999 / jg, qui coupe les cas en deux, puis teste dans chaque moitié. Cela fait log₂ n comparaisons au lieu de n.

Une table de valeurs. Quand chaque cas renvoie simplement une constante, clang -O2 ne saute pas du tout. Le switch des jours par mois ci-dessous devient une vérification de bornes et un chargement :

int days(int month) {
    switch (month) {
    case 1: return 31;  case 2: return 28;  case 3: return 31;
    case 4: return 30;  case 5: return 31;  case 6: return 30;
    default: return 0;
    }
}
days:
    dec  edi                                  ; month - 1
    xor  eax, eax                             ; par défaut : 0
    cmp  edi, 5
    ja   .done
    mov  eax, DWORD PTR [.table + 4*rdi]      ; table = {31, 28, 31, 30, 31, 30}
.done:
    ret

Parfois, aucun branchement

Les branchements coûtent peu quand ils sont bien prédits, mais un branchement mal prédit coûte une vidange du pipeline, 15 à 20 cycles sur les cœurs actuels. Quand les deux côtés d’un choix sont peu coûteux, les compilateurs optimisants calculent les deux et sélectionnent l’un d’eux, sans saut. return a > b ? a : b; avec clang -O2 :

max:
    mov    eax, esi       ; supposer b
    cmp    edi, esi
    cmovg  eax, edi       ; si a > b, prendre a à la place
    ret

cmovg — déplacement conditionnel si supérieur — lit les indicateurs comme le ferait un saut, mais ne change jamais le flot d’instructions : il n’y a rien à mal prédire. La table de valeurs de days relève de la même idée : transformer du flot de contrôle en flot de données. Pour un rétro-ingénieur, cela signifie qu’un if du source peut apparaître sans aucun saut dans le binaire.

À retenir

  • Chaque structure de contrôle du C est faite de comparaisons et de sauts. Le saut teste en général l’inverse de la condition C, pour sauter le bloc qu’elle garde.
  • if/else, c’est un saut vers l’avant par-dessus « alors » plus un saut par-dessus « sinon ». Un saut vers l’arrière signale une boucle.
  • Les boucles testent au début (while, for) ou à la fin (do … while). Les optimiseurs font pivoter les boucles : un test de garde, puis un test en bas, un seul saut par itération.
  • && et || court-circuitent : ils se compilent en sauts, et leur côté droit peut ne jamais s’exécuter.
  • Un switch devient une chaîne de comparaisons, une table de sauts (cas denses : vérification de bornes avec ja, puis jmp [table + index × 8]), une recherche dichotomique (cas clairsemés) ou une table de valeurs.
  • Les optimiseurs suppriment certains branchements avec cmov et des tables : le code machine peut contenir moins de sauts que le source.

Dans ce niveau

  1. 2.1Langages compilés, interprétés et compilés à la volée (JIT)
  2. 2.2Du code source au binaire qui s’exécute
  3. 2.3Variables, types et disposition en mémoire
  4. 2.4Pointeurs et tableaux
  5. 2.5Fonctions, appels et la pile
  6. 2.6Structures, malloc et le tas
  7. 2.7Flot de contrôle : if, boucles, switch
  8. 2.8Machines virtuelles à bytecode et ramasse-miettes