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;
}
À 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
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;
}
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;
}
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
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("");
}
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);
}
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);
}
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);
}
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]);
}
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);
}
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);
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
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
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
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.
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
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
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
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
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
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.
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
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
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.
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
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
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é
}
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);
}
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);
}
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);
}
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
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
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)) { }
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
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));
}
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.
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) { ... }
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)
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.
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.
