ARCHITECTURE ET FONCTIONNEMENT INTERNE DES SYSTÈMES INFORMATIQUES — PARTIE 2

Ce Forum est dédié au lien entre la machine et les langage de porogrammation

Moderator: Rick

Post Reply
Hydraxx
Site Admin
Posts: 148
Joined: Mon Jan 12, 2026 4:04 pm
Location: France
Contact:

ARCHITECTURE ET FONCTIONNEMENT INTERNE DES SYSTÈMES INFORMATIQUES — PARTIE 2

Post by Hydraxx »

ARCHITECTURE DES SYSTÈMES — PARTIE 2)

Une règle pour tous les exemples : prédire la sortie et les octets, exécuter, puis expliquer les différences. Les sections « conceptuelles » sont des schémas et non des programmes à compiler.

1. UN PROGRAMME TRAVERSE PLUSIEURS COUCHES

1.1 Expérience : une ligne affichée n'est pas une instruction du processeur
Objectif : expliquer pourquoi une fonction C peut demander une opération matérielle sans contrôler l'écran directement. Sous Linux, compile avec cc exemple.c -o exemple.

Code: Select all

#include <stdio.h>
int main(void) {
    printf("Bonjour\n");
    return 0;
}
Résultat : un terminal affiche « Bonjour ». Pourquoi ? Le compilateur transforme le code C en instructions machine. À l'exécution, la bibliothèque C formate les caractères et écrit dans le flux stdout, éventuellement en utilisant un tampon. Quand ce tampon doit être vidé, la bibliothèque demande au noyau d'écrire sur la destination de stdout. Si c'est un pseudo-terminal, le pilote de terminal et le processus d'émulation de terminal prennent le relais pour afficher les glyphes. « printf est un appel système » est faux : c'est une fonction de bibliothèque. « Un printf = un syscall » est faux aussi : le tampon peut regrouper des écritures.
À quel moment chaque chose est-elle codée ? La chaîne littérale du programme est encodée dans les octets du binaire lors de la compilation/édition de liens ; au runtime, printf peut produire des octets supplémentaires (conversion de nombres, par exemple). Le terminal interprète les octets suivant son encodage, souvent UTF-8. Le processeur ne cherche pas une lettre dans une table ASCII pour choisir un pixel.

1.2 Expérience : la sortie n'est pas forcément l'écran

Code: Select all

# Terminal Linux :
./exemple
./exemple > journal.txt
wc -c journal.txt
od -An -tx1 journal.txt
Résultat attendu : après redirection, aucun « Bonjour » n'est affiché par ce programme dans le terminal ; le fichier contient 8 octets : 42 6f 6e 6a 6f 75 72 0a. Chaque caractère ASCII prend ici un octet ; 0a est le saut de ligne. Pourquoi ? Le shell ouvre le fichier et connecte le descripteur standard 1 à cette destination avant de lancer le programme. Le programme continue d'écrire vers stdout sans nécessairement connaître la destination. Le shell ne retransmet pas chaque octet après coup : il a configuré la redirection avant l'exécution.

1.3 Expérience : buffering et nombre d'appels système

Code: Select all

#include <stdio.h>
int main(void) {
    printf("A");
    printf("B");
    printf("C");
    fflush(stdout);  // demande de vider le tampon de la bibliothèque
    return 0;
}
Résultat visible : ABC. Les trois appels ne signifient pas trois appels système. Quand stdout utilise un tampon, les caractères peuvent être mis bout à bout, puis écrits ensemble à l'étape fflush. Pourquoi un tampon ? Un appel vers le noyau coûte davantage qu'une simple copie de quelques octets en mémoire utilisateur. Piège : fflush peut produire zéro, un ou plusieurs appels système selon bibliothèque, destination et état ; il ne garantit pas que des données soient physiquement persistées sur un disque.
Manipulation : sur Linux, teste strace -e write ./exemple si strace est disponible ; compare ./exemple > sortie.txt. Hypothèse à tester : un seul write peut transporter « ABC ». Ne transforme pas cette observation en règle universelle.

1.4 Expérience : API utilisateur, noyau et pilote

Code: Select all

// Windows, fichier app.c, compilation MSVC : cl /W4 app.c
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
int main(void) {
    const char x[] = "OK\r\n";
    DWORD n = 0;
    HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
    if (out == INVALID_HANDLE_VALUE || out == NULL) return 1;
    return WriteFile(out, x, (DWORD)(sizeof x - 1), &n, NULL)
           && n == sizeof x - 1 ? 0 : 2;
}
Pourquoi GetStdHandle ? Le processus reçoit un handle de sortie standard : celui-ci peut désigner une console, un fichier ou un tube selon le lancement. Pourquoi WriteFile ? L'application demande à Windows d'écrire les octets ; un chemin d'E/S noyau traite la destination. Pourquoi sizeof x - 1 ? Le tableau contient aussi l'octet terminal 00, destiné aux fonctions de chaînes C, pas à cette sortie. Piège Windows : pour les handles de console, l'encodage et l'interprétation des caractères méritent une attention particulière ; WriteConsoleW est adapté à l'écriture Unicode en console, mais ne fonctionne pas sur un handle redirigé vers un fichier.

1.5 Application pratique : déterminer quelle couche est en cause
Situation : une console affiche des symboles à la place de « é ». Les octets écrits sont-ils mauvais ? Ou bien le terminal les décode-t-il avec le mauvais encodage ? Méthode : redirige la sortie vers un fichier, inspecte les octets avec un éditeur hexadécimal, puis vérifie l'encodage de la source, la bibliothèque et le terminal. Si les octets sont c3 a9, ils représentent « é » en UTF-8. Un affichage incorrect ne prouve pas que le programme a mal transmis les octets.
À retenir : demande toujours « qui produit les octets, qui les transporte, qui les interprète ? » ; ce sont trois opérations différentes.

2. BITS, OCTETS, SIGNES, ENCODAGES ET ENDIANNESS

2.1 Expérience : lire du binaire sans deviner

Code: Select all

// Valeur 0x2D :
// hexadecimal : 2      D
// binaire     : 0010   1101
// decimal     : 2*16 + 13 = 45
// Bits à 1 (poids) : 32 + 8 + 4 + 1 = 45
Pourquoi l'hexadécimal ? Quatre bits correspondent exactement à un chiffre hexadécimal : la conversion depuis une séquence d'octets est facile, contrairement au décimal. Quand est-ce utile ? Débogage de structures, observation de dumps mémoire, valeurs de masques et protocoles.

2.2 Expérience : inspecter les octets réels d'un uint32_t

Code: Select all

#include <stdint.h>
#include <stdio.h>
int main(void) {
    uint32_t n = UINT32_C(0x12345678);
    const unsigned char *p = (const unsigned char *)&n;
    for (size_t i = 0; i < sizeof n; ++i)
        printf("%02X ", (unsigned)p[i]);
    puts("");
}
Sortie sur un PC x86-64 little-endian : 78 56 34 12.
Explication octet par octet : l'octet 78 porte les 8 bits les moins significatifs, puis 56, puis 34, puis 12. Sur une machine big-endian, la représentation usuelle serait 12 34 56 78. Pourquoi lire via unsigned char ? Le langage C autorise l'inspection de la représentation objet au moyen d'un type caractère ; cette technique évite de fabriquer un pointeur de type entier potentiellement mal aligné. Attention : endianness = organisation des octets d'une valeur multi-octet ; les bits d'un octet ne sont pas inversés.

2.3 Expérience : les mêmes bits, deux valeurs numériques

Code: Select all

#include <stdint.h>
#include <inttypes.h>
#include <stdio.h>
int main(void) {
    uint32_t u = UINT32_C(0x80000001);
    int32_t s = -2147483647; // sa représentation en complément à deux
    printf("non signe = %" PRIu32 "\n", u);
    printf("signe     = %" PRId32 "\n", s);
}
Résultats : 2147483649 et -2147483647. Pourquoi ? Sur 32 bits non signés, le bit 31 vaut 2 puissance 31 ; sur 32 bits signés en complément à deux, il contribue pour moins 2 puissance 31. Les octets du nombre 0x80000001 sur little-endian sont 01 00 00 80. Le bit de signe est dans l'octet 80, conservé à l'adresse la plus haute des quatre.
Piège important : convertir en C une valeur non représentable dans un type signé a des règles dépendant du langage et de la version ; pour comprendre les bits ici, on compare deux valeurs construites séparément plutôt que d'affirmer qu'un cast suffit partout.

2.4 Expérience : décomposer et reconstruire sans dépendre de l'endianness

Code: Select all

#include <stdint.h>
#include <stdio.h>
int main(void) {
    uint32_t x = UINT32_C(0x12345678);
    unsigned char b[4];
    for (unsigned i = 0; i < 4; ++i)
        b[i] = (unsigned char)(x >> (24 - 8*i));
    uint32_t y = ((uint32_t)b[0] << 24) |
                 ((uint32_t)b[1] << 16) |
                 ((uint32_t)b[2] << 8) | b[3];
    printf("%02X %02X %02X %02X -> %08X\n",
           b[0], b[1], b[2], b[3], (unsigned)y);
}
Sortie : 12 34 56 78 -> 12345678. Pourquoi ? Ici les décalages et masques imposent explicitement l'ordre des octets dans le tableau, quel que soit l'ordre natif en mémoire. C'est le principe de sérialisation : un protocole définit une suite d'octets indépendante de la machine qui la produit.
Cas réel : pour un champ entier de 32 bits en « ordre réseau » (big-endian), on veut envoyer 12 puis 34 puis 56 puis 78 ; envoyer directement &x sur x86-64 donnerait l'ordre natif 78 56 34 12. Avec TCP, send n'inverse pas automatiquement l'ordre de ta structure applicative ; il faut le définir ou utiliser les conversions adaptées, p. ex. htonl dans les cas conventionnels.

2.5 Expérience : le caractère '1' n'est pas l'entier 1

Code: Select all

#include <stdio.h>
int main(void) {
    char a = '1';
    unsigned char b = 1;
    printf("a = %d, b = %u\n", (unsigned char)a, (unsigned)b);
    printf("a affiché comme caractère : %c\n", a);
}
Sortie sur environnement ASCII compatible : a = 49, b = 1, puis « 1 ». Pourquoi ? Le caractère « 1 » possède le code 0x31 (49) en ASCII/UTF-8 ; l'entier 1 est la valeur numérique 0x01. Utilité : parser une chaîne issue d'un fichier ou d'un paquet réseau : "123" n'est pas l'entier stocké en binaire 123.

2.6 Expérience : strlen, sizeof, NUL, UTF-8

Code: Select all

#include <stdio.h>
#include <string.h>
int main(void) {
    const char s[] = "ABC";
    const char utf8[] = "\xC3\xA9"; // 'é' en UTF-8 explicite
    printf("sizeof(s)=%zu strlen(s)=%zu\n", sizeof s, strlen(s));
    printf("utf8: %02X %02X %02X\n",
           (unsigned char)utf8[0],
           (unsigned char)utf8[1], (unsigned char)utf8[2]);
}
Sortie : sizeof(s)=4 strlen(s)=3 ; utf8: C3 A9 00.
Pourquoi ? sizeof mesure ici le tableau en octets, y compris l'octet nul ; strlen parcourt jusqu'au premier 00 sans le compter. Le caractère « é » en UTF-8 prend deux octets, mais représente un seul point de code Unicode. Piège : sizeof(pointer) mesure la taille du pointeur, pas celle de la chaîne ciblée ; une chaîne de longueur 3 non terminée par 00 rend strlen dangereux.

2.7 Expérience : mettre un indicateur dans un bit sans détruire les autres

Code: Select all

#include <stdint.h>
#include <stdio.h>
int main(void) {
    uint8_t flags = 0;
    flags |= (uint8_t)(1u << 2);   // active bit 2 (0x04)
    flags |= (uint8_t)(1u << 5);   // active bit 5 (0x20)
    flags &= (uint8_t)~(1u << 2);  // efface bit 2
    printf("0x%02X\n", (unsigned)flags);
}
Résultat : 0x20. Pourquoi ? OR conserve les bits déjà actifs ; AND avec complément efface un bit précis. Utilité : registres de statut, droits d'accès, options d'API comme GENERIC_READ | GENERIC_WRITE. Attention : les masques de flags ne sont pas interchangeables avec des valeurs ordinaires de constantes énumérées.

2.8 Cas piège : reinterpret_cast d'une structure envoyée sur le réseau

Code: Select all

// Exemple de ce qu'il NE FAUT PAS considérer comme portable :
struct Paquet { unsigned short type; unsigned int taille; };
// send(fd, (char *)&paquet, sizeof paquet, 0);
Problème : padding entre champs, tailles des types, endianness, ABI et ordre des champs sur le fil peuvent varier. Méthode sûre : utiliser des types de largeur fixée et écrire explicitement les octets du protocole dans un buffer. Question réflexe : « mon paquet est-il un format défini ou seulement une photographie de ma RAM ? »

3. DU SOURCE À L'EXÉCUTABLE : VOIR CHAQUE ÉTAPE

3.1 Expérience : préprocesseur versus exécution

Code: Select all

// fichier macro.c
#include <stdio.h>
#define DOUBLE(x) ((x) * 2)
int main(void) { printf("%d\n", DOUBLE(3 + 1)); }
// Linux : gcc -E macro.c -o macro.i
// puis : gcc macro.c -o macro && ./macro
Résultat : 8. Pourquoi ? Avant la compilation proprement dite, le préprocesseur remplace une macro par du texte. Les parenthèses évitent que DOUBLE(3+1) devienne une expression équivalente à 3+1*2. Danger : DOUBLE(i++) peut modifier i deux fois : les macros ne sont pas des fonctions et ne préservent pas automatiquement l'évaluation unique.

3.2 Expérience : obtenir le code assembleur

Code: Select all

// fichier somme.c
int somme(int a, int b) { return a + b; }
// gcc -O0 -S somme.c -o somme_O0.s
// gcc -O2 -S somme.c -o somme_O2.s
Qu'observer ? Le fichier .s contient des mnémoniques assembleur et des directives ; il ne contient pas encore un fichier objet prêt à lier. À -O0 on peut voir plus d'accès à la pile, à -O2 souvent moins d'instructions. Pourquoi ? Le compilateur doit respecter le comportement défini du programme, pas sa forme syntaxique. Précision : les instructions exactes varient selon version de GCC, cible et ABI ; ne mémorise pas une séquence d'assembleur comme unique traduction du C.

3.3 Expérience : fichier objet, symboles, liaison

Code: Select all

// maths.c
int carre(int x) { return x * x; }
// main.c
#include <stdio.h>
int carre(int);
int main(void) { printf("%d\n", carre(6)); }
// gcc -c maths.c -o maths.o
// gcc -c main.c -o main.o
// nm maths.o
// nm main.o
// gcc main.o maths.o -o demo && ./demo
Résultat : 36. Dans nm : maths.o définit carre (souvent symbole T) ; main.o l'utilise sans le définir (souvent U). Pourquoi compiler séparément ? Chaque .o contient du code, des données, des symboles et des relocalisations ; l'éditeur de liens résout les références intermodules. Expérience : retire maths.o de la dernière commande : la liaison échoue sur la référence à carre. C'est une erreur de liaison, pas de syntaxe C.

3.4 Expérience : erreur d'en-tête contre erreur de liaison

Code: Select all

// compile sans inclure le bon prototype de fonction :
// -> diagnostic de compilation.
// déclare int calcul(int); mais n'en fournis aucune définition liée :
// -> référence non résolue lors de la liaison.
// référence une DLL absente à l'exécution :
// -> échec du chargement ou de la résolution dynamique.
Pourquoi séparer les trois ? Une définition visible au compilateur n'est pas nécessairement un symbole disponible à la liaison, et un symbole importé n'assure pas la présence de sa bibliothèque au runtime. Utilité : diagnostiquer les erreurs C/C++ et les dépendances de DLL ou .so.

3.5 Expérience : examiner un ELF plutôt que deviner

Code: Select all

# Sur Linux (outils binutils) :
gcc -O0 -g main.c maths.c -o demo
file demo
readelf -h demo
readelf -S demo
objdump -d demo
nm -C demo
Que chercher ? ELF header (architecture, type), sections telles que .text (instructions), .rodata (constantes), .data (données initialisées), .bss (stockage nul à l'initialisation) ; les segments utilisés par le chargeur ne coïncident pas un-pour-un avec les sections. Pourquoi .bss ne contient-elle pas toutes ses données dans le fichier ? Le loader peut initialiser en mémoire une zone à zéro sans stocker des milliers d'octets nuls dans l'exécutable.

3.6 Expérience : PE Windows et dépendance aux DLL

Code: Select all

// Terminal développeur Visual Studio :
// cl /W4 /Zi app.c
// dumpbin /headers app.exe
// dumpbin /imports app.exe
// dumpbin /disasm app.exe
Pourquoi PE et ELF ? Un chargeur Windows traite un exécutable PE, un chargeur Linux traite usuellement ELF. Même si le CPU supporte les mêmes instructions x86-64, les conventions d'appel, importations, chargement et API diffèrent. Pourquoi les DLL ? Partager des bibliothèques et déléguer des services sans incorporer toutes les implémentations au binaire. Piège : un nom d'API vu dans la table des imports ne prouve pas un appel noyau direct à cette API.

3.7 Expérience : optimiseur et fonction pure

Code: Select all

// optim.c
int f(void) {
    int x = 10;
    int y = 20;
    return x * y + 2;
}
// gcc -O2 -S optim.c -o optim.s
Résultat logique : f retourne 202. Pourquoi peut-on voir une constante immédiate plutôt qu'une multiplication ? Le compilateur connaît les opérandes à la compilation et peut effectuer le calcul avant l'exécution. Cas différent : si les valeurs viennent d'une entrée, cette simplification n'est pas forcément possible. Application : comprendre pourquoi un petit benchmark naïf peut mesurer zéro travail utile.

3.8 Exercices guidés
E1 : fais manquer maths.o au linker ; identifie le symbole non résolu. E2 : change -O0 en -O2 et repère les instructions supprimées. E3 : trouve .text, .rodata et .bss avec readelf. E4 : compare un symbole défini avec un symbole importé. Réponses à expliquer : quelle étape du pipeline détient l'information nécessaire pour corriger le problème ?


4. INSTRUCTIONS MACHINE, REGISTRES ET APPELS DE FONCTION

4.1 Expérience : que signifie « une instruction » ?

Code: Select all

; Assembleur x86-64, syntaxe Intel, exemple conceptuel
mov eax, 7       ; EAX <- 7
add eax, 5       ; EAX <- 12
cmp eax, 12      ; compare EAX à 12, met à jour des flags
jne erreur       ; saut si égalité non vérifiée
Résultat logique : le saut jne n'est pas pris, car le résultat de la soustraction conceptuelle EAX - 12 est nul et le flag ZF est actif. Pourquoi ? « cmp » modifie les indicateurs, pas EAX ; « jne » consulte ZF, il n'effectue pas lui-même la comparaison. À quoi sert le couple cmp/jne ? Implémenter du contrôle de flux : validation de paramètres, tests, boucles. Précision : le code machine contient des octets d'opcode ; « mov » est une écriture lisible par l'humain, pas une chaîne de lettres exécutée par le CPU.

4.2 Expérience : afficher les vrais octets d'instruction

Code: Select all

// fichier add.c
int addition(int a, int b) { return a + b; }
// gcc -O2 -c add.c -o add.o
// objdump -d -Mintel add.o
Observation attendue : objdump imprime à gauche des octets hexadécimaux et à droite des mnémoniques, par exemple une instruction lea ou add selon compilateur et cible. Pourquoi ces octets ne sont-ils pas le texte "add" ? Le décodeur du processeur consomme un encodage binaire, pas un langage de programmation. Pourquoi lea peut calculer une somme ? LEA forme une adresse arithmétiquement sans lire cette adresse en mémoire ; l'optimiseur peut exploiter cette opération pour additionner des entiers.

4.3 Expérience : taille et alias des registres x86-64

Code: Select all

; x86-64, résultat illustratif sur RAX
mov rax, 0x1122334455667788
mov eax, 0xAABBCCDD
; RAX = 0x00000000AABBCCDD
; Écrire EAX remet les 32 bits hauts de RAX à zéro.
Pourquoi cette règle ? L'ISA x86-64 définit explicitement l'effet d'une écriture de registre 32 bits sur le registre 64 bits contenant. Piège : écrire AL ou AX n'efface pas de la même manière les autres bits de RAX. Utilité : lire un désassemblage et détecter une valeur tronquée à 32 bits.

4.4 Expérience : pointeur, load, store

Code: Select all

// pointeur.c
void incrementer(int *p) { ++*p; }
// gcc -O2 -S pointeur.c -o pointeur.s
Pourquoi des accès mémoire ? p contient une adresse ; ++*p signifie : retrouver l'objet pointé et modifier son contenu. Sur x86-64, une instruction à opérande mémoire peut faire l'incrémentation ; sur une architecture load/store, le compilateur doit souvent charger, ajouter, puis stocker. Piège : une instruction « sur mémoire » ne signifie pas forcément un aller-retour direct vers la RAM physique : les caches et les buffers interviennent.

4.5 Expérience : registre versus pile et ABI

Code: Select all

// fichier somme.c
long somme(long a, long b, long c) { return a + b + c; }
// gcc -O0 -S somme.c -o somme.s
// gcc -O2 -S somme.c -o somme_opt.s
Linux x86-64 System V : les premiers arguments entiers/pointeurs passent habituellement par RDI, RSI, RDX, RCX, R8, R9 ; la valeur de retour entière va généralement dans RAX. Windows x64 : premiers arguments entiers/pointeurs généralement RCX, RDX, R8, R9 ; retour entier via RAX ; l'appelant réserve aussi une zone d'espace d'accueil (shadow space) de 32 octets. Pourquoi deux conventions ? L'ABI est un contrat entre code appelant, fonction appelée et outils. Conséquence : un fragment assembleur copié de Linux vers MSVC peut recevoir ses arguments dans les mauvais registres.
Piège : des fonctions variadiques, les nombres flottants, les structures, les appels spéciaux et les optimisations ont des règles complémentaires. Ne réduis pas toute l'ABI à quatre registres.

4.6 Expérience : la pile lors d'un appel

Code: Select all

; Schéma logique simplifié x86-64 :
; call fonction -> dépose une adresse de retour sur la pile
; fonction ... -> exécute ses instructions
; ret          -> récupère l'adresse de retour
;
; RSP désigne le sommet courant de la pile.
Pourquoi une adresse de retour ? L'exécution doit reprendre à l'instruction suivant l'appel après avoir terminé la fonction. Piège : un compilateur peut appliquer une optimisation d'appel terminal (tail call), employer des fonctions inline ou omettre un frame pointer ; ne t'attends pas à voir call/ret pour toute fonction source.

4.7 Expérience : un if sans saut conditionnel

Code: Select all

// C :
int valeur_absolue(int x) { return x < 0 ? -x : x; }
// gcc -O2 -S abs.c -o abs.s
Pourquoi ce code peut devenir un cmov ou une suite arithmétique ? Un CPU peut calculer un résultat conditionnel sans brancher. Cas piège : INT_MIN ne possède pas d'opposé représentable en int signé usuel ; -INT_MIN provoque un dépassement signé en C. Le compilateur peut raisonner sur le comportement défini et produire un résultat surprenant en cas d'overflow. Utilité : relier optimisation, branches et garanties du langage.

4.8 Manipulation : registre et mémoire au débogueur

Code: Select all

# Linux, compiler avec symboles :
gcc -O0 -g pointeur.c -c -o pointeur.o
# Pour un programme complet, ajouter un main() de test.
# Sous gdb : break incrementer ; run ; info registers ; x/wx p
Ce qu'on doit pouvoir expliquer : pourquoi une adresse virtuelle (p) et la valeur située à cette adresse (*p) sont deux informations différentes ; pourquoi le pointeur peut occuper 8 octets en environnement x86-64 alors que int en occupe généralement 4 ; pourquoi l'adresse affichée change d'une exécution à l'autre (ASLR et placement mémoire).

5. COMPILATION, CORRECTION, SÉCURITÉ ET PERFORMANCES

5.1 Expérience : un benchmark peut être optimisé jusqu'à disparaître

Code: Select all

// bench_mauvais.c
#include <stdint.h>
int main(void) {
    uint64_t total = 0;
    for (uint64_t i = 0; i < 100000000ULL; ++i)
        total += i;
    return 0; // total n'est jamais observé
}
Pourquoi le CPU ne fait-il pas nécessairement 100 millions d'additions ? Comme total n'influence pas le comportement observable du programme, un compilateur optimisant peut supprimer entièrement la boucle. Utilité : ne jamais conclure qu'un algorithme est « ultra rapide » sur une mesure où le compilateur n'a plus rien à exécuter. Remède : consommer le résultat dans une sortie ou un protocole de benchmark conçu pour ne pas être optimisé ; faire aussi des mesures répétées.

5.2 Expérience : ordre d'évaluation et effets de bord

Code: Select all

#include <stdio.h>
int main(void) {
    int i = 1;
    int a = i++;
    int b = i;
    printf("a=%d b=%d\n", a, b);
}
Résultat : a=1 b=2. Pourquoi ? post-incrémentation : on récupère d'abord l'ancienne valeur de i dans a, puis i devient 2 avant l'instruction suivante. À éviter : des expressions qui modifient plusieurs fois un scalaire sans séquencement défini, comme i++ + i++, ont un comportement indéfini en C : les résultats observés ne permettent pas d'en déduire une règle d'évaluation.

5.3 Expérience : taille, pointeur et dépassement de buffer

Code: Select all

#include <stdio.h>
#include <string.h>
int main(void) {
    char nom[8];
    const char *src = "abcdef";
    if (strlen(src) >= sizeof nom) return 1;
    memcpy(nom, src, strlen(src) + 1); // copie aussi le NUL
    puts(nom);
}
Pourquoi le test est-il nécessaire ? La capacité du tableau est de 8 octets et une chaîne C doit contenir son terminateur 00. Changer src en "abcdefgh" : le test refuse la copie au lieu d'écrire hors limites. Pourquoi est-ce un problème de sécurité ? Un accès hors objet est un comportement indéfini, potentiellement exploitable, même si un essai « semble fonctionner ». Piège : comparer strlen(src) à sizeof d'un pointeur plutôt que du vrai tableau ne vérifie pas la capacité cible.

5.4 Expérience : entiers non signés et boucle inversée

Code: Select all

#include <stddef.h>
#include <stdio.h>
int main(void) {
    size_t n = 3;
    for (size_t i = n; i > 0; --i)
        printf("%zu ", i - 1);
}
Sortie : 2 1 0. Pourquoi ne pas écrire i >= 0 ? size_t est non signé, donc i >= 0 est toujours vrai ; après 0, i-- donnerait la valeur maximale de size_t. Cas réel : boucle inverse sur un tableau, calcul de longueur ou de différence de tailles.

5.5 Expérience : avertissements, sanitizer et débogage

Code: Select all

# Linux :
cc -std=c11 -Wall -Wextra -Wpedantic -g -O1 \
   -fsanitize=address,undefined programme.c -o programme
./programme
# MSVC : cl /W4 /Zi /Od programme.c
Pourquoi ces options ? -Wall/-Wextra activent des diagnostics supplémentaires, -g ajoute des informations de débogage, AddressSanitizer peut détecter des accès mémoire invalides, UBSan peut diagnostiquer certains comportements indéfinis. Piège : aucun outil ne prouve l'absence de bug ; un crash différent avec -O2 peut être la manifestation d'un bug de ton programme, pas forcément du compilateur.

5.6 Expérience : static et inline dans les fichiers C

Code: Select all

// a.c
static int interne(void) { return 3; }
int publique(void) { return interne(); }
// b.c
int publique(void);
int main(void) { return publique() != 3; }
// gcc a.c b.c -o prog
Pourquoi static ici ? La fonction interne a une liaison interne au fichier de traduction : elle ne devient pas un symbole externe à utiliser directement depuis b.c. Pourquoi le compilateur peut supprimer l'appel à interne ? La fonction est petite et l'optimisation peut l'intégrer dans publique. Piège : « static » dans une fonction, au niveau fichier et dans une classe C++ n'a pas une signification unique ; le contexte change la sémantique.

5.7 Expérience : volatile ne remplace pas atomics

Code: Select all

// Exemple C11, deux threads partageant un état :
#include <stdatomic.h>
atomic_int pret = 0;
// Thread producteur après préparation des données :
atomic_store_explicit(&pret, 1, memory_order_release);
// Thread consommateur :
while (!atomic_load_explicit(&pret, memory_order_acquire)) { }
Pourquoi des opérations atomiques ? Des accès concurrents non synchronisés à un objet ordinaire dont au moins un est une écriture produisent une data race (comportement indéfini en C). volatile n'est pas un substitut aux synchronisations entre threads. Pourquoi release/acquire ? Correctement appariés, ils établissent une relation permettant au consommateur de voir les données préparées avant la publication. Précision : extrait conceptuel, nécessite un vrai programme multithread pour être exécuté ; le busy-wait pur n'est pas une bonne stratégie générale d'attente.

6. ORGANISATION PHYSIQUE : QUI PARLE À QUI ?

6.1 Expérience de pensée : lire un entier, qu'est-ce qui travaille ?

Code: Select all

int calcul(const int *p) { return *p + 1; }
// chemin conceptuel :
// instruction du cœur -> traduction d'adresse -> cache de données
// -> si absent, autre niveau de cache / mémoire
// -> valeur disponible -> unité arithmétique -> résultat
Pourquoi distinguer adresse et donnée ? L'adresse du pointeur est virtuelle dans un processus ; la MMU traduit cette adresse selon les tables de pages et les caches de traduction. Les données peuvent être servies par L1 sans solliciter la RAM. Pourquoi l'ALU n'est-elle pas « la mémoire » ? Elle calcule, pendant que la hiérarchie mémoire fournit des opérandes. Piège : une instruction arithmétique n'est pas forcément « un cycle », même si elle est simple.

6.2 Expérience : taille d'un type et taille du bus

Code: Select all

#include <stdio.h>
#include <stdint.h>
int main(void) {
    printf("char=%zu int=%zu void*=%zu uint64_t=%zu\n",
           sizeof(char), sizeof(int), sizeof(void*), sizeof(uint64_t));
}
Sortie fréquente sur x86-64 : 1 4 8 8. Pourquoi un int 32 bits sur un CPU 64 bits ? La taille des types est fixée par le modèle de données/ABI et le compilateur, pas simplement par la largeur des registres du CPU. Windows 64 bits (LLP64) : long reste généralement 32 bits ; Linux x86-64 (LP64) : long est généralement 64 bits. Utilité : structures binaires, fichiers partagés et interopérabilité.

6.3 Expérience : deux périphériques, une même API

Code: Select all

// Windows : GetStdHandle + WriteFile
// Linux : write(1, buffer, len)
// À l'exécution, sortie 1 peut viser console, pipe ou fichier.
Pourquoi une interface commune ? Le programme décrit une opération d'écriture ; le noyau route les requêtes à l'objet approprié. Mais attention : la sémantique de certaines fonctions dépend de la destination (terminal, pipe, fichier ordinaire). Utilité : écrire des programmes utilisables aussi bien dans un terminal que dans un pipeline automatisé.

6.4 Expérience : mapping d'un registre de périphérique

Code: Select all

// PSEUDO-CODE de pilote, pas à exécuter en application utilisateur :
// volatile uint32_t *status = adresse_mmio_mappee;
// uint32_t valeur = lire_registre_mmio(status);
// if (valeur & BIT_READY) { ... }
Pourquoi un registre MMIO ? Le périphérique expose un emplacement d'adressage que le CPU peut consulter via des opérations adaptées. Pourquoi pas un simple int* quelconque ? Mappage privilégié, règles de cache, ordre mémoire, barrières et fonctions fournies par l'OS conditionnent la validité de ces opérations. Sur Windows : un driver utilise les mécanismes WDM/WDF appropriés, pas un pointeur d'application vers une adresse physique arbitraire. Piège : volatile seul ne garantit pas une synchronisation matérielle complète.

6.5 Expérience : bus système et PCI Express ne sont pas la même chose

Code: Select all

// Schéma simplifié moderne :
// cœur CPU -- caches -- contrôleur mémoire -- DRAM
//                  \
//                   interconnexion -- PCIe -- contrôleur NVMe / NIC
//                   (topologie variable selon plate-forme)
Pourquoi PCIe ? Relier des contrôleurs à la machine via une interface de communication standard à haut débit. Pourquoi le CPU ne copie-t-il pas chaque octet d'un SSD lui-même ? Des mécanismes de files de commandes et de DMA permettent à des périphériques de transférer des données directement entre mémoire et contrôleurs, sous supervision de l'OS. Piège : le schéma est logique ; les voies, caches et contrôleurs réels varient selon les machines.

6.6 Expérience : pourquoi des niveaux de cache ?

Code: Select all

// 64 octets contigus lus dans cet ordre :
// 00 01 02 03 ... 3F
// L'accès à l'octet 00 peut amener toute une ligne dans un cache.
// L'accès à 01 juste après peut profiter de la localité spatiale.
Pourquoi ne pas équiper le CPU de plusieurs gigaoctets de L1 ? Les mémoires les plus rapides sont coûteuses en surface, énergie et complexité ; plusieurs niveaux représentent un compromis. Question pratique : pourquoi la traversée d'un tableau peut battre celle d'une liste chaînée de même quantité de données ? Les adresses contiguës favorisent la localité, le préchargement et des accès prévisibles.

6.7 Mini-cas : une carte réseau interrompt-elle le CPU pour chaque octet ?
Non, pas en règle générale. Une interface réseau performante peut déposer des paquets reçus en mémoire via DMA et utiliser interruptions, modération d'interruptions ou polling selon son pilote et sa configuration. Pourquoi ? Une interruption par octet saturerait inutilement le CPU. Ce que le programme voit : des octets remontent via les API socket et leurs buffers, sans qu'il gère directement chaque transfert matériel. Cela ne garantit pas qu'un recv correspond à un send : TCP présente un flux d'octets et peut fractionner ou regrouper les lectures.

6.8 Exercices de liaison entre couches
E1 : un load attend une donnée absente de L1 : quelle partie traite le calcul et quelle partie recherche la donnée ? E2 : une application veut lire un registre physique d'une carte : pourquoi un driver/mapping est-il nécessaire ? E3 : un PC x64 utilise un int de 32 bits : est-ce incohérent ? E4 : une carte NVMe transfère 4 KiB : qui prépare la commande, qui transporte les octets et pourquoi DMA ?


Suite du cours : voir le message suivant, avec la même numérotation de chapitres.

Who is online

Users browsing this forum: No registered users and 1 guest