Programme d'étude sur le C++ bas niveau n° 6 : les conditions

L'objectif de cette série d'articles d'Alex Darby sur la programmation « bas-niveau » est de permettre aux développeurs ayant déjà des connaissances de la programmation C++ de mieux comprendre comment leurs programmes sont exécutés en pratique. Ce sixième article parle des conditions et de la manière dont un compilateur transforme une condition en C/C++ en son équivalent assembleur.

Retrouver l'ensemble des articles de cette série sur la page d'index.

N'hésitez pas à commenter cet article ! Commentez Donner une note à l'article (5).

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Salut les Terriens ! Comme le titre le suggère, il s'agit du sixième volet de la série du Programme d'étude sur le C++ bas niveau que j'écris. Dans cet épisode, nous allons commencer à regarder les instructions conditionnelles et à quoi ressemble le code généré par le compilateur (du moins avant que l'optimiseur ne le transforme).

Juste au cas où quelqu'un ne le saurait pas, les conditions sont des fonctionnalités de langage qui nous permettent de contrôler quelles parties de notre code doivent être exécutées. À première vue, ce sujet peut sembler simple mais c'est précisément parce que cela semble simple - et parce que tant de choses reposent sur ces conditions - que c'est le premier thème que j'ai choisi d'étudier en détail après les appels de fonction.

Bien que nous ne puissions pas les étudier tous dans cet article, nos investigations sur les conditions nous emmènera à travers un échantillon représentatif du code x86 généré pour une instruction if, l'opérateur conditionnel (« ternary operator » ou encore « question mark ») et l'instruction switch. En même temps, nous nous pencherons également sur le désassemblage généré par les opérateurs (intégrés) relationnels et logiques qui sont utilisés avec eux (==, =, <=, >, =>, <, && et ||).

II. Prologue

Tout d'abord, je voudrais présenter mes excuses à tous ceux qui lisent ces articles régulièrement pour le fait que mon taux d'écriture a ralenti. J'espère pouvoir accélérer à nouveau à un rythme d'un article toutes les deux semaines dans un proche avenir.

Ensuite, voici les liens pour tous ceux qui veulent commencer dès le début de cette série d'articles (attention : ça peut prendre un certain temps, les premiers sont assez longs) :

En général, j'éviterai de supposer trop de connaissances de la part du lecteur mais si quelque chose arrive que j'ai déjà expliqué précédemment ou si je connais un autre auteur qui a déjà couvert le problème alors je donnerai juste un lien vers l'article. Cela implique que vous, cher lecteur, devez supposer que je supposerai que vous allez lire tout ce que je vous indiquerai si vous voulez comprendre complètement cet article. :)

III. Compiler et exécuter le code de cet article

Je suppose que vous utilisez Windows, que vous connaissez VS2010, que vous êtes habitué à écrire, exécuter et déboguer des programmes C++.

Comme dans les articles précédents, je vais utiliser une application win32 en mode console construite avec l'assistant « New project » dans VS2010 avec les options par défaut (l'édition VS2010 Express est suffisante).

Le seul changement que je fais est de désactiver l'option « Basic Runtime Checks » pour rendre le code assembleur généré plus facile à lire (et aussi plus rapide…). Regardez Programme d'étude sur le C++ bas niveau n° 3 : la Pile pour plus de détails à ce sujet.

Pour exécuter le code de cet article dans VS2010 ouvrez le fichier .cpp qui ne s'appelle pas stdafx.cpp et remplacer tout le code juste en dessous de la ligne #include "stdafx.h" par le code copié/collé ci-dessous.

Le désassembleur que nous examinerons est celui de la version debug qui génère un code win32 x86 simple et non optimisé.

IV. Les instructions et les mnémoniques : soit dit en passant

Je viens de réaliser que, jusqu'ici dans cette série, j'ai typiquement utilisé le terme instruction quand je me référais à un mnémonique assembleur.

Je pense que je dois souligner que ce n'est pas précis à 100 %. Bien que les mnémoniques assembleur aient une correspondance un-pour-un avec des instructions CPU binaires, elles ne sont pas réellement des instructions.

En fait, en assembleur x86, les mnémoniques ont souvent une relation un-pour-plusieurs avec leurs opcode correspondants car chaque mnémonique dispose de multiples variantes qui diffèrent dans les types et tailles de leurs opérandes.

Ce n'est pas quelque chose de très important, car il s'agit d'un Kenobisme assez inoffensif (1), mais j'ai toujours pensé que je devais le signaler si je devais continuer comme cela. Image non disponible

V. Les conditions

La meilleure manière de débuter, comme quelqu'un l'a dit un jour, c'est par le début. Nous allons donc commencer par la forme la plus élémentaire de l'instruction if.

Avant que tout le monde le dise, je sais que j'aurais pu omettre les accolades autour de iLocal = 1; à la ligne 9. Si vous êtes le genre de personne qui est si paresseux que vous enlevez les accolades dans ces situations alors faites comme vous voulez. Je voudrais juste faire remarquer qu'il y a probablement une place spéciale dans l'un des plus profonds et des moins agréables cercles de l'Enfer, je pense que cela vous est réservé, juste quelques étages au-dessus de ceux qui font la même chose avec des boucles.

J'ai aussi laissé la ligne #include "stdafx.h" dans le code ci-dessous de sorte que vos numéros de ligne correspondent aux miens si vous le faites par vous-même.

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
#include "stdafx.h"
int main(int argc, char* argv[])
{
    int iLocal = 0;
    if( argc < 0 )
    {
        iLocal = 1;
    }
    return 0;
}

Quoi qu'il en soit, comme d'habitude, utilisez VS2010 puis copiez et collez le code ci-dessus dans le fichier principal de votre projet, puis mettez un point d'arrêt à la ligne 7, demandez à VS2010 de compiler et lancer le programme et attendez que le point d'arrêt soit atteint. Faites un clic droit dans la fenêtre du source et choisissez l'option « Go To Disassembly », vous devriez voir quelque chose comme cela :

Image non disponible

N'oubliez pas de faire un clic droit et de sélectionner les mêmes options que moi…

Comme nous le savions déjà, l'assembleur au-dessus de iLocal int = 0; est le prologue de la fonction (ou préambule) et l'assembleur après l'accolade fermante de la fonction main() est l'épilogue de la fonction.

Le code assembleur spécifique qui nous intéresse se situe entre les lignes 7 et 13 du code source qui est montré avec les lignes de désassemblage. Ici il est collé dans la fenêtre de code (N.B. les adresses correspondant aux instructions de désassemblage seront presque certainement différentes sur votre écran si vous le faites vous-même…).

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
     7:     if( argc < 0 )
010D20B0  cmp         dword ptr [argc],0
010D20B4  jge         main+1Dh (10D20BDh)
     8:     {
     9:         iLocal = 1;
010D20B6  mov         dword ptr [iLocal],1
    10:     }
    11:
    12:     return 0;
010D20BD  xor         eax,eax
    13: }

Immédiatement, il y a une paire de mnémoniques assembleur que nous n'avons pas rencontré jusqu'à présent dans les précédents articles. Nous allons en parler au fur et à mesure.

La ligne 2 compare argc avec 0. L'instruction cmp n'a pas d'effet immédiat sur l'exécution du code, il compare son premier et son deuxième opérande et stocke le résultat de cette comparaison dans un registre interne de la CPU connue sous le nom EFLAGS.

La ligne 3 utilise le mnémonique jge, ce qui signifie « sauter si plus grand ou égal » (2). Il fera un saut à l'adresse fournie comme opérande (0x010D20BD) si le résultat de l'instruction cmp précédente a fixé le contenu du registre EFLAGS pour indiquer que son premier opérande est supérieur ou égal à son second opérande, c'est-à-dire si argc est supérieur ou égale à 0. Si oui, alors l'exécution sautera juste après le bloc de code controlé par l'instruction if.

VI. Attendez une minute ?

Donc, nous n'avons vu que la forme la plus élémentaire d'une instruction if et nous avons déjà rencontré une grande différence entre ce que nous demandions de générer au compilateur et le code qu'il a généré.

La façon intuitive de penser à un bloc if dans un langage de haut niveau, c'est que si la condition de l'instruction if est remplie, alors l'exécution entrera dans les accolades délimitées par le bloc qu'il contrôle.

Cependant, l'assembleur est clairement en train de tester l'opposé logique de ce que nous avons demandé. Et si cette condition est remplie, alors il saute sur le bloc de code contrôlé par if.

C'est parce que, au niveau assembleur, les instructions sont exécutées dans un ordre séquentiel sauf si une instruction de saut lui dit de faire autrement. Et donc l'assembleur n'a pas d'équivalent au concept de haut niveau d'un « bloc de code » délimité par des accolades. Le résultat de ceci est que la notion de haut niveau « entrer dans » un bloc de code est mis en œuvre au niveau assembleur par « ne pas sauter » le code du bloc généré.

Manifestement, ces deux comportements sont logiquement isomorphes (c'est-à-dire qu'ils produisent le même résultat pour une même entrée), mais la version de haut niveau est plus facile intuitivement à comprendre pour l'esprit humain alors que la version générée par le compilateur convient mieux à « l'exécution séquentielle sauf si » de la machine sous-jacente.

Juste pour le besoin de clarté, on peut réécrire le code C++ dans une forme qui correspond mieux à ce que nous a montré l'assembleur en utilisant le mot-clé C++ goto :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
#include "stdafx.h"
int main(int argc, char* argv[])
{
    int iLocal = 0;
    // corresponding original code in comments to the right...
    if( argc >= 0 ) goto GreaterEqualZero;   //if( argc < 0 )
                                             //{
    iLocal = 1;                              //    iLocal = 1;
                                             //}
    GreaterEqualZero:
    return 0;
}

Ironie du sort (mais sans surprise) ce code C++ génère encore un code assembleur différent. Ne vous inquiétez pas à ce sujet.

VII. if ... else if ... else

Nous allons maintenant jeter un coup d'œil à une construction if plus compliquée :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
#include "stdafx.h"
int main(int argc, char* argv[])
{
    int iLocal = 0;
    if( argc == 0 )
    {
        iLocal = 13;
    }
    else if( argc != 42 )
    {
        iLocal = (6 * 9);
    }
    else
    {
        iLocal = 1066;
    }
    return 0;
}

Ce code génère l'assembleur suivant. Étant donné ce que nous avons vu dans l'exemple précédent, c'est plus ou moins exactement ce à quoi vous vous attendiez :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
     7:     if( argc == 0 )
002020B0  cmp         dword ptr [argc],0
002020B4  jne         main+1Fh (2020BFh)
     8:     {
     9:         iLocal = 13;
002020B6  mov         dword ptr [iLocal],0Dh
002020BD  jmp         main+35h (2020D5h)
    10:     }
    11:     else if( argc != 42 )
002020BF  cmp         dword ptr [argc],2Ah
002020C3  je          main+2Eh (2020CEh)
    12:     {
    13:         iLocal = (6 * 9);
002020C5  mov         dword ptr [iLocal],36h
    14:     }
    15:     else
002020CC  jmp         main+35h (2020D5h)
    16:     {
    17:         iLocal = 1066;
002020CE  mov         dword ptr [iLocal],42Ah
    18:     }
    19:
    20:     return 0;
002020D5  xor         eax,eax 

Les choses principales à noter à propos de ce code sont :

  • chaque condition if ou else if est implémentée comme un cmp suivi d'une instruction jxx - il y a deux nouveaux ici : je (« jump equal ») et jne (« jump not equal ») ;
    Comme dans le premier exemple, chaque condition if ou else if fait que le compilateur génère le test logique contraire à celui du langage de haut niveau et fait sauter le code assembleur du bloc contrôlé par la condition si elle réussit ;
  • le test pour la première condition if saute à l'autre condition else if lorsque sa condition n'est pas remplie. S'il y avait d'autres else if enchaînés alors ce schéma continuerait avec les autres ;
  • chaque bloc de code a, à la fin, un jmp inconditionnel qui reprend l'exécution après le bloc de code contrôlé par l'instruction else.

Ce fut tout assez simple pour une fois. Quel plaisir !

Maintenant, nous allons regarder les effets des opérateurs && et || :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
#include "stdafx.h"
int main(int argc, char* argv[])
{
    int iLocal = 0;
    if( ( argc >= 7 ) && ( argc <= 13 ) )
    {
        iLocal = 1024;
    }
    else if( argc || ( !argc )|| ( argc == 69 ) ) // deliberately nonsensical test
    {
        iLocal = 666;
    }
    return 0;
} 

Cela génère l'assembleur suivant qui est beaucoup plus intéressant que le premier exemple if ... else if :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
     7:     if( ( argc >= 7 ) && ( argc <= 13 ) )
00F120B0  cmp         dword ptr [argc],7
00F120B4  jl          main+25h (0F120C5h)
00F120B6  cmp         dword ptr [argc],0Dh
00F120BA  jg          main+25h (0F120C5h)
     8:     {
     9:         iLocal = 1024;
00F120BC  mov         dword ptr [iLocal],400h
00F120C3  jmp         main+3Eh (0F120DEh)
    10:     }
    11:     else if( argc || ( !argc ) || ( argc == 69 ) )
00F120C5  cmp         dword ptr [argc],0
00F120C9  jne         main+37h (0F120D7h)
00F120CB  cmp         dword ptr [argc],0
00F120CF  je          main+37h (0F120D7h)
00F120D1  cmp         dword ptr [argc],45h
00F120D5  jne         main+3Eh (0F120DEh)
    12:     {
    13:         iLocal = 666;
00F120D7  mov         dword ptr [iLocal],29Ah
    14:     }
    15:
    16:     return 0;
00F120DE  xor         eax,eax
    17: } 

Maintenant, je ne sais pas pour vous, mais la première fois que j'ai vu l'assembleur généré avec && et || j'ai été surpris par l'audace simplicité, je pense que c'est parce que je ne suis pas un programmeur assembleur, mais je m'attendais à ce que cela soit un peu plus compliqué et fastidieux que cela.

En examinant en détail le code généré pour l'instruction if utilisant && (lignes 2 à 5), on peut voir qu'il utilise deux autres instructions de saut conditionnel que nous n'avons pas encore vues : jl (« jump lower ») et jg (« jump greater ») et comme précédemment, il teste la condition logique contraire à celle spécifiée par le langage de haut niveau.

Plus intéressant encore, afin de mettre en œuvre l'opérateur &&, le compilateur enchaine simplement les tests distincts, si l'un de ces tests échoue, cela provoque l'exécution de sauter après le bloc de code contrôlé par l'instruction if. Cela signifie que le bloc de code contrôlé par l'instruction if ne sera exécuté que si les deux tests sont réussis, ce qui met clairement en œuvre un ET logique.

Si nous nous tournons maintenant vers sur le code généré par l'instruction if utilisant || (lignes 12 à 17), nous voyons un schéma similaire de tests conditionnels consécutifs, bien qu'il soit différent car il met en œuvre des conditions reliées par ||.

La première chose à remarquer est que les deux premiers tests réalisés par l'assembleur sont logiquement identiques à leurs équivalents de haut niveau. Cela va à l'encontre de ce que nous avons vu jusqu'à présent, mais pourquoi ?

De plus, l'adresse passée en opérandes aux sauts conditionnels sur les lignes 13 et 15 provoque l'exécution après la fin des tests, au début du bloc de code contrôlé. Sans surprise, le dernier test || (lignes 16 et 17) suit le standard « teste l'opposé et saute après » dont nous avons commencé à prendre l'habitude avec une instruction if.

Le comportement « saute dans le bloc de contrôle » de tous les tests || sauf le dernier signifie qu'aussitôt que l'une des conditions est vraie, le bloc de code contrôle sera exécuté, ce qui clairement indique un OU logique.

VIII. Et maintenant, l'évaluation paresseuse

Je suis sûr que la plupart d'entre vous, sinon la totalité, ont entendu dire que le C++ a une « évaluation paresseuse » des opérateurs && et ||. Si vous n'êtes pas sûr à 100 % de ce que cela signifie, vous venez de le voir en action dans ce bloc d'assembleur !

Le && échoue si l'un de ses opérandes échoue ; donc si le premier test échoue, il ne fera jamais le deuxième (ou troisième ou quatrième…).

De même, le || réussit si l'un de ses opérandes réussit, si le premier test passe, il ne fera jamais le deuxième (ou troisième ou quatrième…).

Comme il n'est pas nécessaire d'évaluer la totalité de ses opérandes, cela le rend techniquement « paresseux » (lazy en anglais), ce que, dans ce cas, vous pouvez comprendre comme « impressionnant », « élégant » ou « efficace » (pour certaines définitions de l'efficacité).

IX. Résumé

Les principaux points à relever à partir de l'assembleur que nous avons regardé dans cet article sont les suivants :

  • le test conditionnel que vous voyez dans le désassembleur est susceptible d'être l'opposé logique du test que vous écrivez dans le langage de haut niveau…
  • …et le saut conditionnel sautera généralement par-dessus l'assembleur qui est généré pour le « bloc de code » contrôlé par la condition dans le code du haut niveau ;
  • tout cela parce qu'il n'y a pas de concept d'un « bloc de code » au niveau de l'assembleur.

Plus ou moins, tout code de contrôle se résume à diverses combinaisons de conditions et de sauts au niveau de l'assembleur. Connaitre les mnémoniques assembleurs qui sont utilisés pour mettre en œuvre ces fonctionnalités C / C++ et les différentes façons dont ils sont utilisés peut être très probablement utile lorsque vous vous trouvez dans la situation peu enviable d'un crash au plus profond du code d'une bibliothèque dont vous n'avez pas les symboles (ou que votre débogueur ne peut les trouver).

Soit dit en passant, si vous vous retrouvez perdu dans du code dont vous devriez avoir les symboles mais que votre machine refuse de les trouver, vous pouvez lire cet article par Bruce Dawson pour voir si cela vous aide. Image non disponible

La prochaine fois, nous continuerons à examiner les conditions avec l'opérateur conditionnel (également connu sous le nom de « opérateur ternaire » ou plus communément « le point d'interrogation ») et l'instruction switch.

Merci également à Fabian et Bruce d'avoir accepté de jeter un œil à tout ceci et m'avoir offert de sages conseils sur le contenu.

X. Avertissements

Je suis à peu près sûr que le code de cet article ne montre pas tous les opérateurs relationnels, de sorte que je vous laisse, cher lecteur, le soin d'essayer ceux que j'ai laissés de côté pour voir ce qu'ils font. :)

J'ai aussi évité d'écrire des conditions avec l'instruction if qui contenait des appels de fonction. Clairement cela rend l'assembleur généré par le code de test très complexe et en supposant que vous ayez lu tous les articles précédents sur l'assembleur généré lors de l'appel de fonction, vous devriez être en mesure de comprendre par vous-même. Je dois admettre que j'ai aussi évité de le faire de manière à rester à l'écart de la surcharge d'opérateur. C'est pour plus tard. Probablement.

XI. Remerciements

Cet article est la traduction de l'article C / C++ Low Level Curriculum Part 6: Conditionals écrit en anglais par Alex Darby. Alex Darby a aimablement autorisé l'équipe C++ de Developpez.com à traduire et diffuser son article en français.

Je tiens à remercier ClaudeLELOUP pour la relecture orthographique de cette traduction.

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   


Note de traduction : voir les articles précédents pour le sens de Kenobisme.
Note de traduction : jump greater or equal en anglais

  

Copyright © 2012 Alex Darby. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.