Séance 1 — Syntaxe C complète (fondamentaux)

Types, variables, opérateurs, contrôle, fonctions, tableaux, entrées-sorties, compilation

Séance 1 de Programmation C avancée : types, variables, opérateurs, contrôle, fonctions, tableaux, entrées-sorties, compilation.
Auteur

Dr. El Hadji Bassirou TOURÉ · DMI/FST/UCAD

NoteNavigation conceptuelle

Amont. L’algorithmique (variables, affectation, conditions, boucles, sous-programmes) et un premier contact avec le C.

Aval. La Séance 2 explique les octets derrière les valeurs (binaire, complément à deux, IEEE 754), donc les conversions et les débordements rencontrés ici ; la Séance 3 montre où ces octets sont rangés : adresses, pointeurs, tableaux et chaînes en mémoire.

ImportantObjectifs
  • Donner la taille et l’intervalle des types de base sur la machine du cours (x86-64, modèle LP64), et prévoir les conversions implicites d’une expression.
  • Distinguer déclaration, définition et initialisation ; dire la portée et la durée de vie d’une variable.
  • Évaluer une expression : priorités, associativité, court-circuit, pré- et post-incrémentation.
  • Suivre l’exécution d’un if, d’un switch et d’une boucle, ligne par ligne, dans une table de trace.
  • Suivre des appels de fonctions (passage par valeur, récursion), et lire un tableau et une chaîne dans un dump.
  • Compiler avec gcc -std=c17 -Wall -Wextra, lire les messages du compilateur et de l’éditeur de liens, et reconnaître un comportement indéfini.
NoteLes programmes de ce document

Tous les programmes ont été compilés et exécutés sur la même machine : x86-64, Linux 6.18, gcc 13.3.0 avec les options -std=c17 -Wall -Wextra -O0 -g, glibc 2.39, gdb 15.1, valgrind 3.22.0 ; -fsanitize=undefined s’y ajoute quand le texte l’indique. L’aléa des adresses est désactivé (setarch -R, environnement vide), si bien que les adresses affichées se répètent d’une exécution à l’autre. Les sorties reproduites sont celles de ces exécutions ; dans les messages longs, les lignes retirées sont remplacées par « ... ». Les tables des variables des exemples pas à pas donnent les valeurs relevées sous gdb, ligne après ligne ; ·· y marque une valeur indéterminée. Dans les grilles mémoire, les adresses de la pile sont simplifiées (0x10, 0x18, …) en gardant la disposition réelle ; l’adresse réelle est donnée dans le texte.

Types, variables et conversions

En C, chaque valeur a un type, fixé à la compilation : le type dit combien d’octets la valeur occupe, quelles valeurs elle peut prendre, et quelles opérations s’y appliquent. Un même calcul ne donne pas le même résultat sur des int et sur des double ; une même affectation peut perdre de l’information si le type de destination est plus petit. Avant d’écrire une ligne, il faut donc savoir quels types elle manipule.

Les types de base et leurs tailles

La norme C ne fixe pas les tailles des types entiers : elle impose des minimums (char au moins 8 bits, short et int au moins 16, long au moins 32, long long au moins 64) et l’ordre sizeof(char) \(\le\) sizeof(short) \(\le\) sizeof(int) \(\le\) sizeof(long) \(\le\) sizeof(long long). Le reste est fixé par la plateforme. Le programme suivant affiche les tailles, et les bornes que donne <limits.h>, sur la machine du cours :

printf("char   %zu %d %d\n", sizeof(char), CHAR_MIN, CHAR_MAX);
printf("short  %zu %d %d\n", sizeof(short), SHRT_MIN, SHRT_MAX);
printf("int    %zu %d %d\n", sizeof(int), INT_MIN, INT_MAX);
printf("unsig. %zu 0 %u\n", sizeof(unsigned), UINT_MAX);
printf("long   %zu\n", sizeof(long));
printf("l.long %zu\n", sizeof(long long));
printf("float  %zu\n", sizeof(float));
printf("double %zu\n", sizeof(double));
printf("l.dbl  %zu\n", sizeof(long double));
printf("size_t %zu\n", sizeof(size_t));

Sortie :

char   1 -128 127
short  2 -32768 32767
int    4 -2147483648 2147483647
unsig. 4 0 4294967295
long   8
l.long 8
float  4
double 8
l.dbl  16
size_t 8

Sur Linux x86-64, le modèle est dit LP64 : int mesure 4 octets, long, les pointeurs et size_t 8. Sous Windows 64 bits (modèle LLP64), long ne mesure que 4 octets : un programme qui suppose sizeof(long) == 8 n’est pas portable. long double occupe 16 octets ici, dont 10 utiles (format étendu du x87). Chaque type entier existe aussi en version unsigned, de même taille, qui ne représente que des valeurs positives ou nulles : unsigned int va de 0 à 4294967295.

NoteL’opérateur sizeof

sizeof est un opérateur, pas une fonction : sizeof(type) ou sizeof expression donne la taille en octets, sous la forme d’un size_t (entier non signé, format %zu). Il est évalué à la compilation et n’évalue pas son opérande : après sizeof(n++), n n’a pas changé (sauf pour un tableau de longueur variable, Séance 3). sizeof(char) vaut 1 par définition.

Le type char et son signe

char est le plus petit type entier, un octet, qui sert à ranger un caractère par son code ('A' vaut 65 en ASCII). La norme laisse à l’implémentation le choix de son signe : char est signé sur x86-64 avec gcc, non signé sur ARM sous Linux. Le programme suivant range 200 dans un char et dans un unsigned char :

char c = 200;
unsigned char uc = 200;
signed char sc = -56;
int somme = c + uc;
printf("%d %d %d %d\n", c, uc, sc, somme);

Sortie :

-56 200 -56 144

Sortie, avec -funsigned-char :

200 200 -56 400

Un char signé va de \(-128\) à 127 : 200 n’y tient pas. La conversion d’une valeur hors intervalle vers un type signé est définie par l’implémentation (C17 6.3.1.3 §3) : gcc réduit la valeur modulo 256, \(200-256=-56\), sans aucun message avec les options du cours. Avec -funsigned-char, qui rend char non signé, c vaut 200, et la somme change, 400 au lieu de 144. Pour manipuler des octets, il faut écrire unsigned char ; pour de petits entiers signés, signed char.

Exemple pas à pas : les conversions implicites

Dans une expression, le C convertit les opérandes sans qu’aucun cast soit écrit. Deux règles couvrent l’essentiel.

NotePromotion entière et conversions arithmétiques usuelles

Promotion entière. Un opérande d’un type de rang inférieur à int (char, short, leurs versions unsigned, _Bool) est converti en int si int peut représenter toutes ses valeurs, sinon en unsigned int (C17 6.3.1.1).

Conversions arithmétiques usuelles. Pour un opérateur binaire, les deux opérandes sont ramenés à un type commun (C17 6.3.1.8) : long double si l’un est long double, sinon double, sinon float ; entre entiers, après promotion : si les deux ont le même signe, le type de plus grand rang ; si le non signé a un rang supérieur ou égal, le non signé (int et unsigned int donnent unsigned int) ; sinon, le signé s’il peut représenter toutes les valeurs du non signé (long et unsigned int donnent long en LP64), et à défaut la version non signée du signé (long long et unsigned long, tous deux de 64 bits, donnent unsigned long long). Le résultat d’une affectation est ensuite converti dans le type de la variable.

short a = 30000;
short b = 10000;
int s = a + b;
short t = a + b;
int d = -1;
unsigned int u = 1;
if (d < u)
    printf("d < u\n");
else
    printf("d >= u\n");
double m = s / 3;
printf("%d %d %.2f\n", s, t, m);

Après la ligne 7

Après la ligne 10 (test)

Après la ligne 14

À la ligne 6, a et b sont promus en int : la somme 40000 est calculée en int, où elle tient, et s la reçoit telle quelle. La ligne 7 fait le même calcul, mais range le résultat dans un short, dont le maximum est 32767 : la conversion est définie par l’implémentation, et gcc réduit modulo \(2^{16}\), \(40000-65536=-25536\). La Séance 2 dira pourquoi ce calcul correspond aux octets du résultat.

La ligne 10 compare un int et un unsigned int. Les conversions usuelles convertissent d en unsigned int : \(-1\) devient 4294967295, plus grand que 1, et le test est faux. La branche else s’exécute (cases orange : les deux valeurs comparées), contre l’intuition ; gcc avertit (-Wsign-compare). Enfin, à la ligne 14, s / 3 divise deux int : la division est entière (13333), et c’est ce quotient qui est converti en double. Pour une division flottante, il faut qu’un opérande soit flottant : s / 3.0.

Compilateur :

v_conversions.c:10:11: warning: comparison of integer expressions of different signedness: 'int' and 'unsigned int' [-Wsign-compare]

Sortie :

d >= u
40000 -25536 13333.00
AvertissementMélanger signé et non signé

Une comparaison entre un int négatif et un unsigned (ou un size_t, comme le résultat de sizeof ou de strlen) se fait en non signé : -1 < 1u est faux. Les avertissements -Wsign-compare de -Wextra le signalent ; il faut les lire, et convertir explicitement quand le mélange est voulu.

Déclaration, définition, initialisation

NoteTrois notions

Une déclaration annonce un nom et son type au compilateur ; une définition est une déclaration qui réserve aussi la mémoire de l’objet (ou donne le corps d’une fonction) ; une initialisation donne sa première valeur à l’objet, dans la définition. int prix = 1250; est les trois à la fois ; extern int total; n’est qu’une déclaration : l’objet est défini ailleurs (section Section 6.2).

int total;
int main(void)
{
    int stock;
    int prix = 1250;
    static int ventes;
    printf("%d %d %d\n", total, ventes, prix);
    printf("%d\n", stock);
    return 0;
}

Compilateur :

s2_init.c:9:5: warning: 'stock' is used uninitialized [-Wuninitialized]

Sortie :

0 0 1250
32767

total, défini hors de toute fonction, et ventes, défini static dans main, ont une durée de vie statique : ils existent pendant tout le programme et sont initialisés à 0 avant que main commence. stock est une variable automatique, créée à l’entrée dans le bloc, sans valeur initiale : sa valeur est indéterminée, et la lire est un comportement indéfini (section Section 7). Ici, la ligne 9 affiche 32767, ce qui restait dans la pile à cet endroit ; une autre exécution, une autre option de compilation ou une ligne ajoutée peut donner autre chose. gcc le signale (-Wuninitialized).

Exemple pas à pas : portée et masquage

NotePortée

La portée d’un nom est la partie du texte du programme où il est visible. Un nom déclaré hors de toute fonction a une portée de fichier : il est visible de sa déclaration à la fin du fichier. Un nom déclaré dans un bloc { …} a une portée de bloc : de sa déclaration à l’accolade fermante. Un nom déclaré dans un bloc intérieur masque le même nom déclaré à l’extérieur.

int taux = 18;
void afficher(void)
{
    int taux = 10;
    {
        int taux = 5;
        printf("%d\n", taux);
    }
    printf("%d\n", taux);
}
int main(void)
{
    afficher();
    printf("%d\n", taux);
}

Après la ligne 5

Après la ligne 7

Après la ligne 10

Les tables présentent les portées du bloc le plus intérieur (en haut) à la portée de fichier (en bas) : c’est l’ordre dans lequel le compilateur cherche un nom. L’appel de la ligne 14 crée le taux de afficher, qui reçoit 10 à la ligne 5 et masque le taux global, grisé. La ligne 7, dans un bloc intérieur, crée un troisième taux, qui masque les deux autres : la ligne 8 affiche 5. À l’accolade de la ligne 9, ce taux cesse d’exister ; à la ligne 10, le premier taux trouvé est celui de afficher, 10. Dans main enfin, seul le taux de portée de fichier est visible : 18. Trois objets distincts portent le même nom ; gdb les place à trois adresses différentes. La fonction main se termine ici sans return : atteindre la fin de main équivaut à return 0; (C17 5.1.2.2.3), et à elle seule.

Sortie :

5
10
18

Durée de vie : automatique et statique

NoteDurée de vie

La durée de vie d’un objet est la période de l’exécution pendant laquelle il existe en mémoire. Un objet automatique (variable locale) vit de l’entrée dans son bloc à sa sortie ; un objet statique (portée de fichier, ou static) vit pendant tout le programme. Portée et durée de vie sont deux notions différentes : une variable locale static a une portée de bloc et une durée de vie statique.

void compter(void)
{
    static int appels = 0;
    int local = 0;
    appels++;
    local++;
    printf("appel %d, local %d\n", appels, local);
}
int main(void)
{
    compter();
    compter();
    compter();
}

Sortie :

appel 1, local 1
appel 2, local 1
appel 3, local 1

appels est initialisée une seule fois, avant le début du programme, et garde sa valeur d’un appel à l’autre : 1, 2, 3. local est recréée et remise à 0 à chaque appel : elle vaut toujours 1 à l’affichage. La Séance 3 situe chaque catégorie d’objet en mémoire (pile, segments .data et .bss, tas).

Constantes : const et #define

#define est une directive du préprocesseur, la première phase de la compilation (section Section 6.1) : elle remplace un nom par un texte, avant toute analyse du C. const qualifie une variable : elle a un type, une portée, une adresse, et ne peut plus être modifiée après son initialisation.

#define TVA 18
#define CARRE(x) x * x
#define CARRE_SUR(x) ((x) * (x))
int main(void)
{
    const int plafond = 50000;
    int a = CARRE(2 + 3);
    int b = CARRE_SUR(2 + 3);
    printf("%d %d %d %d\n", TVA, plafond, a, b);
}

Sortie :

18 50000 11 25

La sortie du préprocesseur (gcc -E -P) montre le texte réellement compilé :

Sortie de gcc -E -P :

int main(void)
{
    const int plafond = 50000;
    int a = 2 + 3 * 2 + 3;
    int b = ((2 + 3) * (2 + 3));
    printf("%d %d %d %d\n", 18, plafond, a, b);
}

CARRE(2 + 3) devient 2 + 3 * 2 + 3 : la multiplication passe avant l’addition, et a vaut 11. Les parenthèses de CARRE_SUR protègent l’argument et le résultat : 25. Elles ne protègent pas des effets de bord : CARRE_SUR(n++) incrémente n deux fois (comportement indéfini, section Section 7.3). Pour une constante, const (ou une énumération) est préférable : typée, limitée à sa portée, visible sous gdb ; #define reste utile pour la compilation conditionnelle.

Opérateurs et évaluation

Opérateurs arithmétiques : division entière et modulo

int duree = 135;
int ecart = -135;
printf("%d h %d min\n", duree / 60, duree % 60);
printf("%d h %d min\n", ecart / 60, ecart % 60);
printf("%d %d\n", duree / -60, duree % -60);
double h1 = duree / 60;
double h2 = duree / 60.0;
printf("%.2f %.2f\n", h1, h2);

Sortie :

2 h 15 min
-2 h -15 min
-2 15
2.00 2.25

Entre deux entiers, / est la division entière : depuis C99, le quotient est tronqué vers zéro, et le reste % vérifie (a / b) * b + a % b == a, si bien qu’il a le signe du dividende : \(-135 / 60 = -2\) et \(-135 \% 60 = -15\), \(135 / -60 = -2\) et \(135 \% -60 = 15\). % n’est défini que pour les entiers. La ligne 9 montre l’erreur classique : duree / 60 est calculé en int (2), puis converti en double ; il faut un opérande flottant, 60.0, pour obtenir 2,25. Une division ou un reste par 0 est un comportement indéfini (section Section 7).

Exemple pas à pas : incrémentation et affectation composée

NotePré- et post-incrémentation

++n incrémente n et vaut la nouvelle valeur ; n++ vaut l’ancienne valeur, et incrémente n. De même pour --. n += 10 équivaut à n = n + 10, n n’étant évalué qu’une fois ; il en va de même pour -=, *=, /=, %=, <<=, &=, …

int n = 5;
int a = ++n;
int b = n++;
int c = n--;
n += 10;
printf("%d %d %d %d\n", n, a, b, c);

Après la ligne 5

Après la ligne 6

Après la ligne 7

À la ligne 5, ++n fait passer n à 6 et vaut 6 : a reçoit 6. À la ligne 6, n++ vaut 6, la valeur avant incrémentation, que reçoit b, et n passe à 7. À la ligne 7, c reçoit 7 et n redescend à 6. La ligne 8 ajoute 10 : 16. Chaque ligne ne modifie n qu’une fois ; écrire n++ + ++n dans une même expression serait un comportement indéfini (section Section 7.3).

Sortie :

16 6 6 7

Exemple pas à pas : opérateurs logiques et court-circuit

NoteÉvaluation en court-circuit

&& (et), || (ou) et ! (non) rendent 1 ou 0. && et || évaluent leur opérande gauche d’abord, et l’opérande droit seulement si nécessaire : a && b s’arrête si a est faux, a || b s’arrête si a est vrai. L’ordre est garanti, et les effets de l’opérande gauche sont achevés avant l’évaluation du droit.

int stock = 0;
int demande = 12;
int servis = 0;
if (stock != 0 && demande / stock > 2)
    printf("forte demande\n");
if (stock == 0 || ++servis > 0)
    printf("rupture\n");
if (demande > 10 && ++servis > 0)
    printf("servis = %d\n", servis);

Après la ligne 7

Après la ligne 9

Après la ligne 11

À la ligne 7, stock != 0 est faux : && rend 0 sans évaluer demande / stock, et la division par zéro n’a jamais lieu. Le court-circuit sert ici de garde : le test de droite n’est évalué que s’il a un sens. À la ligne 9, stock == 0 est vrai : || est vrai sans évaluer ++servis, qui reste à 0. À la ligne 11, demande > 10 est vrai : && doit évaluer l’opérande droit, et ++servis s’exécute ; servis vaut 1. Écrire & au lieu de && évaluerait les deux côtés : à la ligne 7, la division par zéro aurait lieu.

Sortie :

rupture
servis = 1

Opérateurs bit à bit et décalages

& (et), | (ou), ^ (ou exclusif), ~ (complément) agissent bit par bit sur des entiers ; << et >> décalent les bits. Le programme suivant code des droits d’accès sur trois bits (lecture 4, écriture 2, exécution 1) :

unsigned int d = 0x5;
unsigned int w = 0x2;
printf("%x %x %x\n", d | w, d & 0x4, d ^ 0x1);
printf("%x\n", ~d);
printf("%u %u\n", d << 3, 0xb4u >> 2);
printf("%u\n", 1u << 31);
int neg = -20;
printf("%d\n", neg >> 2);

Sortie :

7 4 4
fffffffa
40 45
2147483648
-5

d | w vaut 0x7 : les trois droits. d & 0x4 vaut 0x4, non nul : le droit de lecture est présent. d ^ 0x1 inverse le bit d’exécution. ~d inverse les 32 bits : fffffffa. d << 3 multiplie par 8 (40), 0xb4u >> 2 divise 180 par 4 (45). Pour un entier signé négatif, >> est défini par l’implémentation : gcc recopie le bit de signe, et \(-20 >> 2\) vaut \(-5\) ; un décalage d’un nombre de positions négatif ou supérieur ou égal à la largeur du type est un comportement indéfini. Pour un opérande gauche signé, E1 << E2 est aussi indéfini si \(E1 \times 2^{E2}\) ne tient pas dans le type (C17 6.5.7 §4) : 1 << 31 l’est en int, alors que 1u << 31 vaut 2147483648 (quatrième ligne de la sortie). Les manipulations de bits se font donc sur des types non signés ; la Séance 2 en donne la représentation.

Priorités et associativité

La priorité dit quel opérateur s’applique d’abord quand une expression en mélange plusieurs ; l’associativité règle le cas de deux opérateurs de même priorité. Le tableau suivant les donne du plus prioritaire au moins prioritaire (K&R, § 2.12).

opérateurs associativité
() [] -> . x++ x– de gauche à droite
!   ++x –x +x -x (type) *p &x sizeof de droite à gauche
* / % de gauche à droite
+ - de gauche à droite
<< >> de gauche à droite
< <= > >= de gauche à droite
== != de gauche à droite
& de gauche à droite
^ de gauche à droite
&& de gauche à droite
?: de droite à gauche
= += -= *= /= %= <<= >>= &= ^= =
, de gauche à droite
int masque = 6;
int r1 = masque & 4 == 4;
int r2 = (masque & 4) == 4;
int r3 = 1 << 2 + 1;
int r4 = 10 - 4 - 3;
printf("%d %d %d %d\n", r1, r2, r3, r4);

Compilateur :

s3_priorites.c:5:21: warning: suggest parentheses around comparison in operand of '&' [-Wparentheses]
s3_priorites.c:7:16: warning: suggest parentheses around '+' inside '<<' [-Wparentheses]

Sortie :

0 1 8 3

Les opérateurs bit à bit &, ^, | sont moins prioritaires que les comparaisons : masque & 4 == 4 se lit masque & (4 == 4), soit \(6\,\&\,1 = 0\), alors que l’intention était (masque & 4) == 4, qui vaut 1. De même, << est moins prioritaire que + : 1 << 2 + 1 vaut \(1 << 3 = 8\). - associe à gauche, \((10-4)-3=3\) ; l’affectation associe à droite : a = b = 7 se lit a = (b = 7). gcc signale les deux premiers pièges (-Wparentheses) : des parenthèses explicites lèvent l’ambiguïté.

AvertissementPriorité n’est pas ordre d’évaluation

La priorité dit comment l’expression est groupée, pas dans quel ordre les opérandes sont calculés : dans f() + g() * h(), le produit est groupé avant la somme, mais rien n’impose que f soit appelée avant ou après g (section Section 7.3). Seuls &&, ||, ?: et la virgule opérateur fixent l’ordre.

L’opérateur conditionnel c ? x : y vaut x si c est non nul, y sinon ; seule l’expression choisie est évaluée : dans m = (a > b) ? ++c : ++a;, un seul des deux incréments a lieu.

Structures de contrôle

Conditions : if, else et else pendant

if (condition) instruction exécute l’instruction si la condition, une expression scalaire quelconque, est non nulle ; le type _Bool (bool avec <stdbool.h>) existe depuis C99, mais n’est pas requis. Quand un else suit deux if imbriqués sans accolades, il se rattache au if sans else le plus proche, quelle que soit l’indentation :

int age = 17;
int inscrit = 1;
if (age >= 18)
    if (inscrit)
        printf("vote\n");
else
    printf("mineur\n");
printf("fin\n");

Compilateur :

s4_ifelse.c:6:8: warning: suggest explicit braces to avoid ambiguous 'else' [-Wdangling-else]

Sortie :

fin

L’indentation suggère que else répond à age >= 18 ; en réalité il répond à if (inscrit). age >= 18 étant faux, aucune des deux branches ne s’exécute, et seul « fin » s’affiche. gcc le signale (-Wdangling-else). Des accolades autour du if intérieur rattachent else au premier if.

Exemple pas à pas : switch et fall-through

Noteswitch

switch (expression) évalue une expression entière une fois, puis saute à l’étiquette case de même valeur, ou à default. Les étiquettes sont des constantes entières connues à la compilation. L’exécution continue ensuite en séquence, à travers les case suivants (fall-through), jusqu’à un break ou la fin du switch.

int zone = 2;
int tarif = 0;
switch (zone) {
case 1:
    tarif += 500;
case 2:
    tarif += 1000;
case 3:
    tarif += 1500;
    break;
default:
    tarif = -1;
}
printf("tarif = %d\n", tarif);

Après la ligne 6 (switch)

Après la ligne 10

Après la ligne 12

zone vaut 2 : la ligne 6 saute directement à case 2:, sans exécuter la ligne 8. La ligne 10 ajoute 1000. Faute de break, l’exécution continue à la ligne 12, celle du case 3 : 1500 de plus, tarif \(=2500\). Le break de la ligne 13 sort du switch. gcc signale les deux chutes, lignes 8 et 10 (-Wimplicit-fallthrough de -Wextra) ; une chute voulue se marque par un commentaire /* fall through */, que gcc reconnaît. Le regroupement case 1: case 2: sur un même code est l’usage ordinaire du fall-through.

Compilateur :

v_switch.c:8:15: warning: this statement may fall through [-Wimplicit-fallthrough=]
v_switch.c:10:15: warning: this statement may fall through [-Wimplicit-fallthrough=]

Sortie :

tarif = 2500

Les trois boucles

int n = 0;
while (n > 0) {
    printf("while %d\n", n);
    n--;
}
do {
    printf("do-while %d\n", n);
    n--;
} while (n > 0);
for (int k = 3; k > 0; k--)
    printf("for %d\n", k);

Sortie :

do-while 0
for 3
for 2
for 1

while teste sa condition avant chaque tour : n valant 0, le corps ne s’exécute pas. do … while la teste après : le corps s’exécute au moins une fois, même si la condition est fausse dès le départ. for (init; test; pas) regroupe les trois éléments d’une boucle à compteur ; il équivaut à init; while (test) { corps; pas; }, au continue près, qui exécute le pas. Une variable déclarée dans l’init (int k) n’existe que dans la boucle. Chacune des trois parties peut être vide : for (;;) est une boucle sans fin, dont on sort par break.

Exemple pas à pas : une boucle avec continue et break

continue termine le tour en cours et passe au suivant : dans un for, c’est le pas (k++) qui s’exécute ensuite. break quitte la boucle sur-le-champ, sans exécuter le pas. Pour suivre une boucle, la table de trace note, à chaque ligne exécutée, les valeurs des variables et le résultat du test évalué.

int somme = 0;
int k;
for (k = 1; k <= 10; k++) {
    if (k % 3 == 0)
        continue;
    if (k * k > 10)
        break;
    somme += k;
}
printf("k = %d, somme = %d\n", k, somme);
pas ligne k somme test
1 4 ·· 0
2 6 1 0 vrai
3 7 1 0 faux
4 9 1 0 faux
5 11 1 1
6 6 2 1 vrai
7 7 2 1 faux
8 9 2 1 faux
9 11 2 3
10 6 3 3 vrai
11 7 3 3 vrai
12 8 3 3
13 6 4 3 vrai
14 7 4 3 faux
15 9 4 3 vrai
16 10 4 3
17 13 4 3

La ligne 6 apparaît à chaque tour : la première fois pour k = 1 et le test, ensuite pour k++ et le test. Aux tours 1 et 2, les deux tests du corps sont faux, et somme reçoit 1 puis 3. Au tour 3, k % 3 == 0 est vrai : continue (pas 12) saute la ligne 11, et l’exécution reprend à la ligne 6 avec k++. Au tour 4, k * k vaut 16, supérieur à 10 : break (pas 16) quitte la boucle sans incrémenter k, qui garde la valeur 4. La boucle a donc fait quatre tours, et non dix ; elle ne s’arrête pas par son test, mais par break.

Sortie :

k = 4, somme = 3

Boucles imbriquées et goto

for (int i = 1; i <= 3; i++) {
    for (int j = 1; j <= 3; j++) {
        if (j == 2)
            break;
        printf("(%d,%d) ", i, j);
    }
}
printf("\n");
int i;
int j;
for (i = 1; i <= 9; i++)
    for (j = i; j <= 9; j++)
        if (i * j == 24)
            goto trouve;
printf("aucun\n");
return 0;
trouve:
printf("%d x %d = 24\n", i, j);

Sortie :

(1,1) (2,1) (3,1) 
3 x 8 = 24

break ne quitte que la boucle la plus intérieure qui le contient : pour chaque i, la boucle sur j s’arrête à j == 2, et la boucle sur i continue. Pour quitter deux boucles d’un coup, le C offre goto, saut vers une étiquette de la même fonction : dès que \(i \times j = 24\) (\(3 \times 8\)), goto trouve sort des deux boucles. Sans goto, il faut un indicateur testé par les deux boucles.

AvertissementUsage de goto

Depuis l’article de Dijkstra, Go To Statement Considered Harmful (1968), goto est réservé à deux usages : sortir de boucles imbriquées, et rejoindre un code de nettoyage commun en cas d’erreur (forme courante dans le noyau Linux). Un goto qui remonte pour faire une boucle, ou qui saute dans un bloc par-dessus des déclarations, rend le flot de contrôle illisible ; for et while le remplacent.

Fonctions

Prototype et définition

Une fonction se déclare par son prototype (type du résultat, nom, types des paramètres) et se définit par son corps. Le prototype doit précéder les appels : c’est lui qui permet au compilateur de vérifier le nombre et les types des arguments.

int ancien();
int moderne(void);
int prix_ttc(int ht);
int main(void)
{
    printf("%d\n", prix_ttc(5000));
    printf("%d\n", ancien(1, 2, 3));
}
int prix_ttc(int ht)
{
    return ht + ht * 18 / 100;
}
int ancien()
{
    return 7;
}

Sortie :

5900
7

int ancien(); est une déclaration sans prototype, forme héritée du C d’avant 1989 : elle ne dit rien des paramètres, et l’appel ancien(1, 2, 3) passe sans le moindre message, alors que la fonction n’en attend aucun. Le nombre d’arguments diffère de celui de la définition : c’est un comportement indéfini (C17 6.5.2.2 §6) ; ici, la fonction rend 7. int moderne(void); déclare explicitement « aucun paramètre » ; le même appel est refusé :

Compilateur :

s5_prototype_err.c:8:20: error: too many arguments to function 'moderne'

Il faut donc écrire (void) pour une fonction sans paramètre, y compris int main(void). (C23 aligne () sur (void) ; le cours compile en C17.)

Exemple pas à pas : le passage par valeur

NotePassage par valeur

À l’appel, chaque argument est évalué, et sa valeur est copiée dans le paramètre correspondant, qui est une variable locale de la fonction appelée. En C, tous les arguments sont passés ainsi. Une modification du paramètre ne change que la copie.

void crediter(int solde, int montant)
{
    solde = solde + montant;
    printf("dans crediter : %d\n", solde);
}
int main(void)
{
    int compte = 7500;
    crediter(compte, 2500);
    printf("apres crediter : %d\n", compte);
    return 0;
}

Après la ligne 10 (appel)

Après la ligne 4

Après la ligne 6 (fin de l’appel)

L’appel de la ligne 10 crée les deux paramètres de crediter, solde et montant, et y copie 7500 et 2500. La table montre deux bandes : crediter, l’appel en cours, au-dessus de main, qui attend. La ligne 4 modifie solde : 10000, et compte reste 7500. Sous gdb, par exemple, compte est en 0x7fffffffebec et solde en 0x7fffffffebcc, 32 octets plus bas : deux emplacements distincts. À la fin de l’appel (ligne 6), les variables de crediter disparaissent avec la modification ; main affiche toujours 7500.

Sortie :

dans crediter : 10000
apres crediter : 7500

Pour transmettre un résultat, la fonction le rend par return, et l’appelant l’affecte ; pour qu’une fonction modifie une variable de l’appelant, il faut lui passer l’adresse de cette variable, ce que fait scanf (section Section 5.5) et qu’explique la Séance 3.

Valeur de retour : return

int credite(int solde, int montant)
{
    return solde + montant;
}
int signe(int x)
{
    if (x > 0)
        return 1;
    if (x < 0)
        return -1;
}
int main(void)
{
    int compte = credite(7500, 2500);
    printf("%d %d %d\n", compte, signe(9), signe(-4));
    printf("%d\n", signe(0));
}

Compilateur :

s5_retour.c:12:1: warning: control reaches end of non-void function [-Wreturn-type]

Sortie :

10000 1 -1
11

return expression; termine la fonction et rend la valeur, convertie dans le type du résultat : credite rend 10000. Une fonction peut contenir plusieurs return ; un seul est exécuté à chaque appel. Pour signe(0), aucun des deux return n’est atteint, et l’exécution arrive à l’accolade finale : utiliser alors la valeur de l’appel est un comportement indéfini (C17 6.9.1 §12). Ici, signe(0) affiche 11 : c’est la valeur rendue par le printf précédent (les 11 caractères de « 10000 1 -1 » et du saut de ligne), restée dans le registre qui porte le résultat ; une autre compilation peut donner autre chose. gcc avertit (-Wreturn-type). main fait exception : atteindre sa fin équivaut à return 0; (C17 5.1.2.2.3). Une fonction void ne rend rien, et return; y est facultatif.

Exemple pas à pas : une fonction récursive

Une fonction peut s’appeler elle-même. Chaque appel a ses propres paramètres et variables locales ; il faut un cas de base qui ne fait pas d’appel, sans quoi les appels s’empilent jusqu’à épuiser la pile (Séance 3).

int puissance(int base, int n)
{
    if (n == 0)
        return 1;
    return base * puissance(base, n - 1);
}
int main(void)
{
    int r = puissance(5, 2);
    printf("%d\n", r);
    return 0;
}

Après le troisième appel

Retour dans l’appel 2

Après la ligne 10

puissance(5, 2) appelle puissance(5, 1), qui appelle puissance(5, 0) : trois appels en cours, chacun avec son n (2, 1 et 0), l’appel le plus récent en haut, comme les affiche la commande backtrace de gdb. n == 0 est vrai dans le troisième appel, qui rend 1 sans nouvel appel : c’est le cas de base. Les appels se terminent alors dans l’ordre inverse de leur création : l’appel 2 calcule \(5 \times 1 = 5\) et le rend, l’appel 1 calcule \(5 \times 5 = 25\) et le rend à main, qui le range dans r.

Sortie :

25

Tableaux, chaînes et entrées-sorties

Exemple pas à pas : un tableau

NoteTableau

int sacs[4]; définit quatre int contigus en mémoire, sacs[0] à sacs[3] : l’élément d’indice \(i\) est à \(i \times \texttt{sizeof(int)}\) octets du début. Les indices valides vont de 0 à \(n-1\) ; accéder à sacs[4] est un comportement indéfini. sizeof sacs donne la taille du tableau entier, et sizeof sacs / sizeof sacs[0] son nombre d’éléments.

int sacs[4] = {120, 85, 60, 0};
sacs[3] = sacs[1] + 15;
size_t n = sizeof sacs / sizeof sacs[0];
printf("%zu %zu %d\n", sizeof sacs, n, sacs[3]);

Après la ligne 4

Après la ligne 5

Après la ligne 6

Les figures montrent les cases du tableau, avec leur indice au-dessus ; les valeurs sont relevées sous gdb. La ligne 4 initialise les quatre éléments. La ligne 5 lit sacs[1] (case orange) et écrit \(85+15=100\) dans sacs[3]. La ligne 6 calcule à la compilation \(16/4=4\) et le range dans n, un size_t. Les quatre int sont contigus : sacs[i] est à \(4i\) octets du début du tableau. L’ordre des octets de chaque int et la place du tableau dans la pile sont l’objet des Séances 2 et 3.

Sortie :

16 4 100

Un tableau en paramètre

int somme(int t[], int n)
{
    int s = 0;
    for (int i = 0; i < n; i++)
        s += t[i];
    printf("dans somme : %zu\n", sizeof t);
    return s;
}
int main(void)
{
    int recolte[5] = {12, 30, 7, 18, 25};
    printf("dans main : %zu\n", sizeof recolte);
    printf("total : %d\n", somme(recolte, 5));
    return 0;
}

Compilateur :

s5_parametre.c:7:41: warning: 'sizeof' on array function parameter 't' will return size of 'int *' [-Wsizeof-array-argument]

Sortie :

dans main : 20
dans somme : 8
total : 92

Un tableau n’est pas copié quand il est passé à une fonction : le paramètre int t[] reçoit l’adresse du premier élément, et s’écrit aussi int *t. Dans main, sizeof recolte vaut \(5 \times 4 = 20\) ; dans somme, sizeof t donne la taille d’une adresse, 8, et gcc avertit (-Wsizeof-array-argument). La fonction ne peut donc pas retrouver le nombre d’éléments : il se passe en paramètre (n). Comme le tableau n’est pas copié, une fonction qui écrit t[i] modifie le tableau de l’appelant ; la Séance 3 explique ce lien entre tableaux et adresses.

Exemple pas à pas : une chaîne de caractères

NoteChaîne de caractères

Une chaîne est une suite de char terminée par le caractère nul '\0' (l’octet 0). char ville[10] = "Diourbel"; copie les 8 caractères et le '\0' dans le tableau, et met à 0 les cases restantes. strlen (de <string.h>) compte les caractères avant le '\0' ; %s affiche jusqu’au '\0'.

char ville[10] = "Diourbel";
size_t lg = strlen(ville);
printf("%s %zu %zu\n", ville, lg, sizeof ville);
ville[4] = '\0';
printf("%s %zu\n", ville, strlen(ville));

Après la ligne 5

Après la ligne 6

Après la ligne 8

Chaque case contient un caractère, et son code ASCII en hexadécimal est noté dessous : 44 pour 'D', 69 pour 'i', …, puis 00 pour '\0' ; la dernière case, non couverte par la chaîne, est mise à 0 par l’initialisation. strlen parcourt le tableau jusqu’au premier '\0' (cases orange) : 8 ; sizeof ville vaut 10, la taille du tableau. La ligne 8 remplace le 'r' (72) par '\0' : la chaîne s’arrête désormais après « Diou », de longueur 4, même si « bel » reste dans le tableau.

Sortie :

Diourbel 8 10
Diou 4
AvertissementPlace du caractère nul

Un tableau de \(n\) char contient au plus \(n-1\) caractères et le '\0'. strcpy(dest, src) copie src jusqu’à son '\0' compris, sans vérifier la taille de dest : une source trop longue écrit au-delà du tableau, un buffer overflow (Séance 3). strcmp(s1, s2) compare deux chaînes et rend 0 si elles sont égales ; == entre deux tableaux compare leurs adresses, pas leur contenu.

Affichage formaté : printf

int qte = 42;
long habitants = 18000000L;
double prix = 1250.5;
printf("[%d] [%5d] [%-5d] [%05d]\n", qte, qte, qte, qte);
printf("[%x] [%X] [%o] [%u]\n", qte, 255, 8, 3000000000u);
printf("[%ld] [%zu]\n", habitants, sizeof habitants);
printf("[%f] [%.2f] [%9.1f] [%e]\n", prix, prix, prix, prix);
printf("[%c] [%s] [%%]\n", 'J', "Joal");
printf("[%d]\n", prix);

Compilateur :

s5_printf.c:12:15: warning: format '%d' expects argument of type 'int', but argument 2 has type 'double' [-Wformat=]

Sortie :

[42] [   42] [42   ] [00042]
[2a] [FF] [10] [3000000000]
[18000000] [8]
[1250.500000] [1250.50] [   1250.5] [1.250500e+03]
[J] [Joal] [%]
[-136292928]

Chaque spécification % prend l’argument suivant et l’affiche selon son type : %d (int), %u (unsigned), %ld (long), %zu (size_t), %x et %X (hexadécimal), %o (octal), %c (caractère), %s (chaîne), %f et %e (double, notation décimale ou scientifique), %% (le caractère %). Entre % et la lettre, une largeur (%5d), un cadrage à gauche (-), un remplissage par des zéros (0) et une précision (%.2f, %9.1f). Un argument qui ne correspond pas à son format est un comportement indéfini : lu par %d, le double de la dernière ligne n’est même pas pris au bon endroit (il est passé dans un registre flottant), et la valeur affichée n’a pas de sens. gcc vérifie les formats littéraux et le signale (-Wformat, ci-dessus).

Lecture formatée : scanf

int sacs = 0;
double prix = 0;
int lus = scanf("%d %lf", &sacs, &prix);
printf("lus = %d : %d, %.1f\n", lus, sacs, prix);
lus = scanf("%d", &sacs);
printf("lus = %d : %d\n", lus, sacs);

Entrée standard :

25 3500.5
abc

Sortie :

lus = 2 : 25, 3500.5
lus = 0 : 25

scanf lit l’entrée standard selon un format et range les valeurs aux adresses reçues : &sacs est l’adresse de sacs (passage par valeur oblige : une copie de sacs ne permettrait pas de la modifier). Les formats diffèrent de ceux de printf pour les flottants : %lf pour un double, %f pour un float. scanf rend le nombre de valeurs lues et rangées : 2 pour « 25 3500.5 ». Au second appel, « abc » ne commence pas par un entier : rien n’est lu, scanf rend 0, sacs garde 25, et « abc » reste dans l’entrée, où le prochain scanf("%d", …) butera encore. En fin d’entrée, scanf rend EOF (\(-1\) avec la glibc). Le résultat de scanf se teste donc avant d’utiliser les variables.

Compilation

Les quatre phases

La commande gcc prog.c -o prog enchaîne quatre phases. Le préprocesseur traite les lignes # : inclusion de fichiers, substitution des macros, compilation conditionnelle ; il produit un texte C pur (.i). Le compilateur proprement dit traduit ce texte en assembleur (.s). L’assembleur produit un fichier objet (.o) : du code machine, et une table des symboles définis et utilisés. L’éditeur de liens réunit les fichiers objets et les bibliothèques en un exécutable. Les options -E, -S et -c arrêtent la chaîne après chacune des trois premières phases.

#define PORT 300
#define TTC(x) ((x) + (x) * 18 / 100)
int paquets = 3;
int main(void)
{
    int total = paquets * TTC(PORT);
    printf("%d : %d\n", paquets, total);
}

Terminal :

$ gcc -std=c17 -E s6_phases.c -o s6_phases.i
$ wc -l s6_phases.i
550 s6_phases.i
$ gcc -std=c17 -S s6_phases.c
$ grep call s6_phases.s
call printf@PLT
$ gcc -std=c17 -c s6_phases.c
$ nm s6_phases.o
0000000000000000 T main
0000000000000000 D paquets
                 U printf
$ gcc s6_phases.o -o s6_phases && ./s6_phases
3 : 1062

Le .i compte 550 lignes : presque toutes viennent de <stdio.h> (déclarations de printf et de dizaines d’autres fonctions) ; il ne contient plus aucune directive, seulement des marqueurs de ligne (# 1 "s6_phases.c"), et PORT et TTC ont été remplacés par leur texte. Le .s contient l’appel de printf ; le calcul \(3 \times 354\) est fait par des instructions de main. nm liste les symboles du .o : T pour du code (main), D pour une donnée initialisée (paquets), U pour un symbole utilisé mais non défini ici (printf), que l’éditeur de liens trouve dans la bibliothèque C.

ImportantLes options du cours

-std=c17 : le langage C17 ; les extensions de gcc qui contredisent la norme sont désactivées, et -pedantic fait signaler les autres. -Wall -Wextra : les avertissements utiles (variables non initialisées, comparaisons signé/non signé, formats, priorités, chutes de switch, retour manquant ; tous ceux de cette séance). -O0 : pas d’optimisation, chaque ligne correspond à du code. -g : informations de débogage pour gdb. -Werror transforme les avertissements en erreurs ; -fsanitize=undefined et -fsanitize=address ajoutent des contrôles à l’exécution.

Compilation séparée et édition de liens

Un programme se répartit en plusieurs fichiers .c, compilés séparément. Un fichier d’en-tête (.h) rassemble les déclarations partagées ; chaque objet et chaque fonction n’est défini qu’une fois, dans un seul .c.

taxe.h :

extern int taux_tva;
int ttc(int ht);

taxe.c :

#include "taxe.h"
int taux_tva = 18;
int ttc(int ht)
{
    return ht + ht * taux_tva / 100;
}

facture.c :

#include <stdio.h>
#include "taxe.h"
int main(void)
{
    printf("%d %d\n", taux_tva, ttc(6000));
}

facture2.c est facture.c avec une ligne de plus, int taux_tva = 20;, en ligne 3.

Terminal :

$ gcc -std=c17 -Wall -c taxe.c facture.c
$ gcc taxe.o facture.o -o facture && ./facture
18 7080
$ gcc facture.o -o facture
facture.c:(.text+0xe): undefined reference to `ttc'
...
collect2: error: ld returned 1 exit status
$ gcc -std=c17 -Wall -c facture2.c
$ gcc taxe.o facture2.o -o facture2
/usr/bin/ld: facture2.o:(.data+0x0): multiple definition of `taux_tva'; taxe.o:(.data+0x0): first defined here
collect2: error: ld returned 1 exit status

extern int taux_tva; déclare la variable sans la définir : facture.o l’utilise sans la contenir, et nm la marquerait U, comme ttc. Liés à taxe.o, qui les définit, les deux fichiers donnent un exécutable : \(6000 + 6000 \times 18/100 = 7080\). Sans taxe.o, l’éditeur de liens ne trouve pas les définitions : undefined reference ; ce n’est pas une erreur du compilateur, chaque fichier s’est compilé. Avec facture2.c, taux_tva est défini deux fois : multiple definition. Un en-tête réel est protégé contre une double inclusion par #ifndef TAXE_H / #define TAXE_H / #endif. Un static devant une variable ou une fonction de portée de fichier lui donne une liaison interne : elle devient invisible des autres fichiers, et un extern d’un autre fichier ne la trouve pas (undefined reference).

Comportement indéfini

NoteTrois catégories de la norme

Défini par l’implémentation : la norme laisse un choix, que le compilateur documente (signe de char, conversion d’une valeur hors intervalle vers un type signé, >> d’un négatif, tailles des types).

Non spécifié : plusieurs comportements sont permis, sans obligation de documenter (ordre d’évaluation des opérandes de +, des arguments d’un appel).

Indéfini : la norme n’impose rien (C17 3.4.3, annexe J.2) : débordement d’un entier signé, lecture d’une variable automatique non initialisée, division par zéro, accès hors d’un tableau, deux modifications d’une même variable sans ordre entre elles, valeur d’une fonction qui n’a pas exécuté de return, format de printf incorrect.

Un comportement indéfini ne signifie pas « un résultat imprévisible », mais « aucune exigence » : le compilateur produit son code en supposant que le programme n’exécute jamais une telle construction. Le programme peut sembler fonctionner, s’arrêter sur un signal, ou changer de résultat avec une option ou une version du compilateur.

Débordement d’un entier signé

int deborde(int x)
{
    return x + 1 < x;
}
int main(void)
{
    int max = INT_MAX;
    int suivant = max + 1;
    printf("%d %d\n", suivant, deborde(max));
    return 0;
}

Sortie :

-2147483648 0

INT_MAX + 1 ne tient pas dans un int : comportement indéfini. Ici, suivant vaut \(-2147483648\), la valeur qu’expliquera la Séance 2 ; mais deborde(INT_MAX) rend 0, alors que \(x + 1\) venait de « devenir négatif » à la ligne précédente. gcc a remplacé x + 1 < x par « faux », même sans optimisation : un débordement étant exclu par hypothèse, \(x+1\) est toujours plus grand que \(x\). Le même programme compilé avec -fsanitize=undefined vérifie chaque opération :

Sortie avec -fsanitize=undefined :

-2147483648 1

Sortie d’erreur avec -fsanitize=undefined :

s7_debord.c:10:9: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'
s7_debord.c:5:14: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'

Le calcul est alors fait réellement, et deborde rend 1 ; chaque débordement est signalé avec sa ligne. Sur les types unsigned, en revanche, le calcul est défini : modulo \(2^{32}\) pour unsigned int, et UINT_MAX + 1 vaut 0.

Variable non initialisée, division par zéro

int quota;
if (quota > 100)
    printf("grand quota\n");
printf("%d\n", quota);

Compilateur :

s7_noninit.c:5:8: warning: 'quota' is used uninitialized [-Wuninitialized]

Sortie :

grand quota
32767

quota n’a pas de valeur : le test de la ligne 5 dépend de ce que contenait la pile, ici 32767, si bien que « grand quota » s’affiche. Sous valgrind, l’exécution diffère (31), et valgrind signale le test sur une valeur non initialisée :

Sortie sous valgrind :

31

Sortie d’erreur sous valgrind :

Conditional jump or move depends on uninitialised value(s)
   at 0x109179: main (s7_noninit.c:5)
int lots = 250;
int familles = 0;
printf("debut\n");
fflush(stdout);
printf("%d\n", lots / familles);

Sortie avec -fsanitize=undefined :

debut

Sortie d’erreur avec -fsanitize=undefined :

s7_zero.c:8:25: runtime error: division by zero

Fin du processus : signal SIGFPE (Floating point exception)

Une division entière par zéro est un comportement indéfini ; sur x86-64, l’instruction de division déclenche une exception, et le processus reçoit le signal SIGFPE. fflush(stdout) force l’affichage de « debut » avant l’arrêt : sans lui, le texte resterait dans le tampon de printf et serait perdu avec le processus. Avec -fsanitize=undefined, la ligne et la colonne de la division sont données.

Ordre d’évaluation

int trace(int k)
{
    printf("appel %d\n", k);
    return k;
}
int main(void)
{
    int s = trace(1) + trace(2);
    int i = 5;
    i = i++ + 1;
    printf("%d %d\n", s, i);
    return 0;
}

Compilateur :

s7_ordre.c:11:7: warning: operation on 'i' may be undefined [-Wsequence-point]

Sortie :

appel 1
appel 2
3 6

L’ordre dans lequel sont évalués les opérandes de + est non spécifié : gcc appelle ici trace(1) puis trace(2), un autre compilateur pourrait faire l’inverse ; la somme, 3, ne change pas, seul l’ordre des affichages pourrait changer. i = i++ + 1 est d’une autre nature : i est modifié deux fois (par ++ et par =) sans que la norme les ordonne : comportement indéfini, signalé par -Wsequence-point ; ici 6, sans garantie.

NotePoints de séquence

Un point de séquence sépare des effets ordonnés : la fin d’une expression complète (instruction-expression terminée par ;, condition d’un if, d’un switch, d’un while ou d’un do, chacune des trois expressions d’un for, expression d’un return, initialiseur d’une déclaration), l’opérande gauche de &&, de || et de la virgule opérateur, la condition de ?:, l’appel d’une fonction (après l’évaluation du désignateur et des arguments) et le retour de la fonction. Les effets qui précèdent sont achevés avant ce qui suit ; entre deux de ces points, une variable ne doit être modifiée qu’une fois, et ne pas être lue pour un autre usage que le calcul de sa nouvelle valeur (C17 5.1.2.3, 6.5 §2, où la norme parle de relation « séquencé avant »).

ImportantBonnes pratiques
  • Compiler avec -std=c17 -Wall -Wextra, lire chaque avertissement, et en venir à bout.
  • Initialiser chaque variable à sa définition ; tester le résultat de scanf.
  • Manipuler les bits sur des types non signés ; ne pas mélanger signé et non signé dans une comparaison.
  • Une seule modification d’une variable par expression ; des parenthèses dès qu’un doute sur la priorité est possible.
  • Un break à la fin de chaque case, sauf chute voulue et commentée.
  • Passer la taille d’un tableau avec le tableau ; réserver la place du '\0'.

Exercices corrigés

Exercice 1 : état des variables ligne par ligne

int a = 4;
int b = 0;
int c = 9;
a += b++ * 2;
c = a > 3 && ++b > 1;
b = c || a++;
a = a << 1 | 1;
c = a % 4 == 1 ? a / 2 : -1;
  1. Remplir le tableau des valeurs de a, b et c après chacune des lignes 4 à 8.
  2. Pour chaque ligne, nommer la règle qui décide du résultat (post-incrémentation, court-circuit, priorité).
ligne a b c
début 4 0 9
4
5
6
7
8

Correction. La méthode : pour chaque ligne, parenthéser l’expression selon les priorités (tableau de la section 2), puis l’évaluer de l’intérieur vers l’extérieur, en appliquant le court-circuit de && et ||, et en notant chaque modification d’une variable au moment où elle a lieu.

  • Ligne 4. a += (b++) * 2. b++ vaut l’ancienne valeur, 0, et b passe à 1 ; \(0 \times 2 = 0\), et a += 0 laisse a à 4. État : 4, 1, 9.
  • Ligne 5. c = ((a > 3) && ((++b) > 1)) : > est plus prioritaire que &&, lui-même plus prioritaire que =. a > 3 est vrai, donc l’opérande droit est évalué : ++b fait passer b à 2, et \(2 > 1\) est vrai ; c reçoit 1. État : 4, 2, 1.
  • Ligne 6. b = (c || (a++)). c vaut 1, vrai : || est vrai sans évaluer a++, et a reste 4. b reçoit 1. État : 4, 1, 1.
  • Ligne 7. a = ((a << 1) | 1) : << est plus prioritaire que |. \(4 << 1 = 8\), et \(8\,|\,1 = 9\). État : 9, 1, 1.
  • Ligne 8. c = (((a % 4) == 1) ? (a / 2) : -1). \(9 \% 4 = 1\), et \(1 == 1\) est vrai : seule la branche a / 2 est évaluée, division entière, \(9/2 = 4\). État : 9, 1, 4.
ligne a b c
début 4 0 9
4 4 1 9
5 4 2 1
6 4 1 1
7 9 1 1
8 9 1 4

Les cases colorées sont celles qui changent à la ligne.

AvertissementErreur fréquente

Exécuter a++ à la ligne 6 (a \(=5\), puis 11 à la ligne 7) : le court-circuit de || empêche l’évaluation dès que l’opérande gauche est vrai. Autre erreur : lire la ligne 7 comme a << (1 | 1), soit 8 au lieu de 9 ; | est l’un des opérateurs les moins prioritaires.

Exercice 2 : appels de fonctions et pile d’appels

int f(int x)
{
    x = x * 2;
    return x + 1;
}
int g(int n)
{
    if (n <= 1)
        return n;
    return g(n - 1) + n;
}
/* dans main */
int v = 5;
int w = f(v);
printf("%d %d %d\n", v, w, g(4));
  1. Que valent v et w après l’appel de f ?
  2. Pour chaque appel de g déclenché par g(4), donner n et la valeur rendue (tableau ci-dessous).
  3. Combien d’appels de g sont en cours au plus au même moment ? Qu’affiche la dernière ligne ?
appel 1 2 3 4
n
rend

Correction. La méthode : à chaque appel, créer une nouvelle bande (l’appel et ses variables, avec les valeurs copiées des arguments), la poser au-dessus de celle de l’appelant ; à chaque return, calculer la valeur, la rendre à l’appelant et retirer la bande.

  • (a) L’appel f(v) copie 5 dans x, variable de f. x = x * 2 modifie la copie, 10 ; f rend \(10 + 1 = 11\). v n’a pas changé : v \(=5\), w \(=11\).
  • (b) g(4) : \(n = 4 > 1\), il faut g(3) ; de même g(3) attend g(2), qui attend g(1). g(1) est le cas de base : il rend 1 sans appel. Les appels en attente se terminent alors dans l’ordre inverse : g(2) rend \(1 + 2 = 3\), g(3) rend \(3 + 3 = 6\), g(4) rend \(6 + 4 = 10\). g(n) calcule \(1 + 2 + \dots + n\).
  • (c) Au moment où g(1) s’exécute, g(4), g(3), g(2) et g(1) sont en cours : 4 appels de g, chacun avec son propre n, au-dessus de main. La ligne affiche 5 11 10.
appel 1 2 3 4
n 4 3 2 1
rend 10 6 3 1
AvertissementErreur fréquente

Penser que x = x * 2 modifie v : x est une copie (passage par valeur). Autre erreur : dérouler la récursion dans l’ordre des appels pour les valeurs rendues ; la première valeur rendue est celle du dernier appel, le cas de base.

Exercice 3 : un tableau et une chaîne dans un dump

int pluie[3] = {14, 9, 17};
char quartier[10] = "Medina";
quartier[3] = '\0';
pluie[1] = pluie[0] + pluie[2];
printf("%s %d\n", quartier, pluie[1]);

Voici le dump de la pile avant la ligne 3 (adresses abrégées : 0x00 est l’adresse de pluie). Machine little-endian ; codes ASCII : M 4d, e 65, d 64, i 69, n 6e, a 61.

Addr +0 +1 +2 +3 +4 +5 +6 +7
0x00 0e 00 00 00 09 00 00 00
0x08 11 00 00 00 ff 7f 4d 65
0x10 64 69 6e 61 00 00 00 00
  1. À quelles adresses sont pluie[0], pluie[1] et pluie[2] ? Lire leurs valeurs.
  2. Où commence quartier ? Lire la chaîne ; que valent strlen(quartier) et sizeof quartier ?
  3. Quelles cases modifient les lignes 3 et 4, et quelles sont leurs nouvelles valeurs ?
  4. Qu’affiche la ligne 5 ?

Correction. La méthode : un élément d’indice \(i\) d’un tableau de int est à \(4i\) octets du début ; ses 4 octets se lisent de droite à gauche (little-endian). Une chaîne se lit octet par octet, en codes ASCII, jusqu’au premier 00.

  • (a) pluie[0] en 0x00 : 0e 00 00 00, soit 0x0000000e \(=14\) ; pluie[1] en 0x04 : 09, 9 ; pluie[2] en 0x08 : 11, \(16+1=17\). Le tableau occupe 12 octets, de 0x00 à 0x0b.
  • (b) Les octets 0x0c et 0x0d (ff 7f) n’appartiennent à aucune des deux variables : ce sont des restes de la pile. quartier commence en 0x0e : 4d 65 64 69 6e 61, « Medina », puis 00 ; les trois octets suivants, jusqu’à 0x17, sont aussi à 0, l’initialisation complétant le tableau. strlen vaut 6 et sizeof 10.
  • (c) quartier[3] est en \(\texttt{0x0e} + 3 = \texttt{0x11}\) : 69 ('i') devient 00. pluie[1] reçoit \(14 + 17 = 31 = \texttt{0x1f}\) : l’octet en 0x04 devient 1f, les trois suivants restent à 0.
  • (d) %s s’arrête au premier 00, en 0x11 : « Med ». La ligne affiche Med 31.
Addr +0 +1 +2 +3 +4 +5 +6 +7
0x00 0e 00 00 00 1f 00 00 00
0x08 11 00 00 00 ff 7f 4d 65
0x10 64 00 6e 61 00 00 00 00

Dans le dump coloré, après la ligne 4 : bleu, pluie ; vert, quartier ; rose, les deux octets modifiés, avec leurs nouvelles valeurs.

AvertissementErreur fréquente

Lire 0e 00 00 00 comme 0x0e000000 : sur une machine little-endian, le premier octet est celui de poids faible. Autre erreur : compter strlen jusqu’à la fin du tableau (10), ou supposer que quartier suit pluie sans écart ; c’est le dump qui donne l’emplacement.

À retenir

  • Le type fixe la taille et l’intervalle : sur Linux x86-64 (LP64), char 1, short 2, int 4, long, size_t et double 8 octets ; le signe de char dépend de l’implémentation.
  • Dans une expression, les petits types sont promus en int ; entre int et unsigned, l’int est converti (-1 devient 4294967295) ; le type des opérandes décide du calcul : 7 / 2 vaut 3.
  • Une variable automatique non initialisée est indéterminée ; une variable statique part de 0. La portée dit où un nom est visible, la durée de vie combien de temps l’objet existe.
  • & et | sont moins prioritaires que == ; << l’est moins que + ; && et || évaluent de gauche à droite, en court-circuit ; la priorité ne fixe pas l’ordre d’évaluation.
  • Un case sans break continue dans le suivant ; break et continue agissent sur la boucle la plus intérieure.
  • Les arguments sont passés par valeur ; un tableau passé à une fonction n’est pas copié et perd sa taille ; une chaîne finit par '\0'.
  • gcc -std=c17 -Wall -Wextra : préprocesseur, compilation, assemblage, édition de liens ; une définition par objet, des déclarations dans le .h.
  • Un comportement indéfini (débordement signé, variable non initialisée, division par zéro, double modification) n’a aucun résultat garanti.

Prochaine séance

La Séance 2 (représentation numérique) montre les octets derrière les valeurs : binaire et hexadécimal, entiers non signés et en complément à deux, conversions entre signé et non signé, extension de signe, débordements, flottants IEEE 754 ; elle explique les \(-56\), \(-25536\), 4294967295 et \(-2147483648\) de cette séance.

Références

  • R. E. Bryant, D. R. O’Hallaron, Computer Systems: A Programmer’s Perspective, 3e éd., Pearson, 2016 : § 1.2 (système de compilation), § 2.1.2 (tailles des types), § 2.1.7 à 2.1.9 (opérateurs bit à bit, logiques, décalages), § 7.1 à 7.6 (édition de liens, symboles).
  • B. W. Kernighan, D. M. Ritchie, The C Programming Language, 2e éd., Prentice Hall, 1988 : chap. 2 (types, opérateurs, expressions ; priorités § 2.12), chap. 3 (contrôle), chap. 4 (fonctions, portée, static, préprocesseur), § 7.2 et 7.4 (printf, scanf).
  • K. N. King, C Programming: A Modern Approach, 2e éd., Norton, 2008 : chap. 4 à 10 (expressions, contrôle, types de base, tableaux, fonctions, organisation d’un programme), chap. 13 (chaînes).
  • ISO/IEC 9899:2018 (C17) : 3.4.1 à 3.4.4 (comportements), 5.1.2.3 (exécution, points de séquence), 6.2.1 (portées), 6.2.4 (durées de vie), 6.3.1 (conversions arithmétiques), 6.5 (expressions), 6.8.4 et 6.8.5 (sélection, itération), 6.9.1 (définition de fonction).

Pour cette séance

Les slides

À lire en ligne

À télécharger

Retour au sommet