ARCHITECTURE DES SYSTÈMES — 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 DES SYSTÈMES — PARTIE 2

Post by Hydraxx »

ARCHITECTURE DES SYSTÈMES — PARTIE 2

expériences sur l’exécution du CPU, caches, fichiers, pilotes et système d’exploitation. Les extraits « pseudo-code » illustrent une relation matérielle et ne sont pas des programmes à compiler.

7. À L'INTÉRIEUR DU CPU : FETCH, DECODE, EXECUTE, RETIRE

7.1 Expérience : dérouler une suite d'instructions

Code: Select all

; x86-64, exemple conceptuel
mov eax, 3
add eax, 4
imul eax, eax, 2
; résultat EAX = (3 + 4) * 2 = 14
Pourquoi ces étapes ? Le CPU récupère un flux d'octets d'instructions (fetch), les décode (decode), prépare les opérations et leurs dépendances, les exécute (execute), puis valide leur état architectural dans l'ordre requis (retire). Piège : ces étapes se chevauchent dans un vrai pipeline ; ce n'est pas nécessairement « trois instructions × quatre cycles ». Un CPU peut décomposer des instructions en micro-opérations et exécuter des instructions indépendantes en parallèle.

7.2 Expérience : dépendance vraie versus opérations indépendantes

Code: Select all

; cas A : dépendance en chaîne
add eax, 1
add eax, 1
add eax, 1
; chaque résultat dépend du précédent
;
; cas B : indépendance logique
add eax, 1
add ebx, 1
add ecx, 1
; les opérations peuvent être mises en parallèle
Pourquoi cela compte ? Deux programmes effectuant trois additions peuvent prendre des temps différents : les unités disponibles et les dépendances limitent le parallélisme au niveau des instructions (ILP). Mais : tous les cœurs ne disposent pas des mêmes ressources ; les latences et débits d'instructions varient par microarchitecture. Utilité : comprendre pourquoi restructurer un calcul en plusieurs accumulateurs indépendants peut améliorer un traitement intensif.

7.3 Expérience : boucle, saut et prédiction

Code: Select all

#include <stddef.h>
int somme_positifs(const int *a, size_t n) {
    int total = 0;
    for (size_t i = 0; i < n; ++i)
        if (a[i] > 0) total += a[i];
    return total;
}
Pourquoi un branchement peut coûter cher ? Si une condition ne peut pas être prédite, le cœur peut commencer des instructions du mauvais chemin ; il devra abandonner le travail spéculatif. Piège : l'optimiseur peut remplacer le if par des instructions conditionnelles sans saut, ou vectoriser ; le comportement du CPU ne se déduit pas du seul texte C. Manipulation : compare des données toutes positives avec des signes alternés ou pseudo-aléatoires dans un benchmark sérieux ; désassemble d'abord pour vérifier ce qui est effectivement exécuté.

7.4 Expérience : le programme source a plusieurs traductions

Code: Select all

int choisir(int a, int b, int c) { return c ? a : b; }
// gcc -O0 -S choisir.c -o choisir0.s
// gcc -O2 -S choisir.c -o choisir2.s
Que peut-on voir ? Des sauts conditionnels, un cmov ou un autre calcul, selon les options et la cible. Pourquoi ? Le compilateur choisit une implémentation qui respecte la sémantique observable, pas un calque instruction-par-instruction du code C. À quoi sert cette observation ? ne pas supposer qu'une branche source provoque forcément une mauvaise prédiction matérielle.

7.5 Expérience : accès mémoire et dépendance sur la donnée chargée

Code: Select all

int lire_plus_un(const int *p) {
    int v = *p;
    return v + 1;
}
// Instructions logiques : load -> addition -> résultat.
Pourquoi la somme peut attendre ? Si la valeur pointée n'est pas disponible dans les caches proches, l'addition dépendante ne peut pas produire son résultat final avant le retour du load. Ce que le CPU peut faire : exécuter d'autres instructions indépendantes entre-temps. Piège : les « accès mémoire » du code machine concernent des adresses virtuelles dans le contexte de l'OS ; ce n'est pas une adresse DRAM donnée directement par ton int*.

7.6 Expérience : ordre logique et ordre microarchitectural

Code: Select all

int x = 1;
int y = 2;
int resultat = x + y;
// À l'échelle du langage C, résultat == 3.
Pourquoi parler de réordonnancement ? Compilateur et CPU peuvent changer l'ordre interne d'opérations indépendantes tout en respectant les garanties observables. Pourquoi faut-il être prudent avec les threads ? Les relations entre lectures et écritures partagées exigent une synchronisation explicite ; la simple intuition « la ligne A précède la ligne B » ne suffit pas entre threads. Utilité : comprendre les atomiques, locks, barrières et bugs de concurrence.

7.7 Expérience : superscalaire ne veut pas dire deux cœurs

Code: Select all

// Un seul cœur peut traiter plusieurs micro-opérations par cycle,
// dans la limite de ses unités d'exécution et de ses dépendances.
// Deux cœurs exécutent en parallèle des threads distincts,
// sous réserve de l'ordonnanceur et des ressources partagées.
Pourquoi distinguer les deux ? L'ILP exploite l'indépendance à l'intérieur d'un flot d'instructions ; le parallélisme multicœur répartit des flots de travail. Le SMT (par exemple Hyper-Threading sur certaines machines) partage des ressources d'un cœur entre contextes logiques ; ce n'est pas équivalent au doublement des unités physiques.

7.8 Exercice d'analyse

Code: Select all

int traiter(int a, int b) {
    int c = a + 2;
    int d = b + 5;
    return c * d;
}
Questions : quelles opérations sont indépendantes ? À partir de quel moment la multiplication peut-elle s'exécuter ? Si la fonction est appelée avec des constantes connues du compilateur, quelles opérations peuvent disparaître ? Réponse guidée : calcul de c et de d indépendants ; la multiplication a besoin de leurs deux résultats ; une propagation de constantes peut évaluer le tout à la compilation si les conditions s'y prêtent.

8. SUIVRE UN PROGRAMME DU DISQUE JUSQU'AU TERMINAL

8.1 Expérience : Hello World complet, étape par étape

Code: Select all

// hello.c
#include <stdio.h>
int main(void) {
    puts("Salut");
}
// gcc -O0 -g hello.c -o hello
// ./hello
Étape 1, avant exécution : le texte du programme a été compilé ; les instructions, les chaînes constantes et les métadonnées nécessaires se trouvent dans l'exécutable. Étape 2, lancement : le shell demande au système d'exploitation de créer/exécuter un processus ; le chargeur prépare un espace d'adressage et les bibliothèques nécessaires. Étape 3, CPU : le thread exécute des instructions au sein de ce processus. Étape 4, bibliothèque : puts fournit les caractères et ajoute un saut de ligne. Étape 5, sortie : au besoin, une opération d'écriture système transmet des octets vers stdout. Étape 6, terminal : l'émulateur de terminal dessine les glyphes. Pourquoi le CPU ne dessine-t-il pas directement les pixels ? Afficher du texte repose sur des conventions de terminaux, d'encodage et une pile logicielle ; puts ne commande pas la carte graphique pixel par pixel.

8.2 Expérience : l'exécutable contient déjà des caractères

Code: Select all

# Linux :
strings -a hello | grep Salut
readelf -S hello
objdump -s -j .rodata hello
Pourquoi voir « Salut » avant même de lancer le programme ? La chaîne littérale est intégrée dans les données de l'exécutable. Mais : ce n'est pas encore le texte affiché à l'écran ; le programme doit s'exécuter et demander une sortie. Piège : certaines optimisations modifient, fusionnent ou éliminent les chaînes ; observer une chaîne dans un binaire ne prouve pas qu'un chemin d'exécution l'affichera.

8.3 Expérience : suivre les écritures du processus

Code: Select all

# Linux :
strace -e trace=write ./hello
strace -e trace=write ./hello > resultat.txt
od -An -tx1 resultat.txt
Résultat logique : la sortie standard reçoit « Salut\n », ou en octets ASCII/UTF-8 : 53 61 6c 75 74 0a. Pourquoi la trace peut-elle se retrouver sur le terminal alors que stdout est redirigé ? strace imprime normalement ses messages sur stderr (descripteur 2), distinct de stdout (descripteur 1). Utilité : diagnostiquer redirections et tubes sans supposer que stdout est l'écran.

8.4 Expérience : écrire sans printf sous Linux

Code: Select all

#include <unistd.h>
#include <errno.h>
#include <stddef.h>
int main(void) {
    const char texte[] = "SALUT\n";
    size_t fait = 0, total = sizeof texte - 1;
    while (fait < total) {
        ssize_t n = write(STDOUT_FILENO, texte + fait, total - fait);
        if (n < 0 && errno == EINTR) continue;
        if (n <= 0) return 1;
        fait += (size_t)n;
    }
    return 0;
}
Pourquoi une boucle ? write peut écrire moins d'octets que demandé ou être interrompu. À quoi servent les paramètres ? 1 = descripteur de stdout, adresse du buffer à partir de l'octet restant, nombre d'octets encore à envoyer. Piège : write est une interface POSIX qui peut être implémentée par une enveloppe de bibliothèque autour d'un appel noyau ; la fonction C et l'instruction syscall ne sont pas synonymes.

8.5 Expérience : sortie vers fichier avec un vrai buffer de sortie

Code: Select all

#include <stdio.h>
int main(void) {
    FILE *f = fopen("sortie.bin", "wb");
    if (!f) return 1;
    unsigned char b[] = {0x12, 0x34, 0x00, 0xFF};
    size_t n = fwrite(b, 1, sizeof b, f);
    int fermeture = fclose(f);
    if (n != sizeof b || fermeture != 0) return 2;
    return 0;
}
Résultat : le fichier contient exactement 12 34 00 FF en hexadécimal s'il n'y a pas d'erreur. Pourquoi un fichier binaire ? On veut transmettre des octets bruts sans formater des nombres en texte. Pourquoi vérifier fwrite et fclose ? Une opération peut échouer, parfois lors du vidage de buffers au moment de la fermeture. Piège : sous Windows, « wb » évite les transformations de sauts de ligne du mode texte C ; fclose ne promet pas à lui seul une persistance physique après coupure de courant.

8.6 Expérience : un enfant reçoit une sortie redirigée

Code: Select all

# Linux :
./hello | wc -c
./hello > resultat.txt
./hello 2> erreurs.txt
Sortie wc -c attendue : 6, parce que Salut comporte 5 lettres ASCII plus le saut de ligne. Pourquoi le pipe change-t-il le comportement visible ? Le shell connecte stdout du producteur à stdin du consommateur. Piège : le pipe transporte un flux d'octets, pas des appels printf individuels ; le buffering de la bibliothèque peut changer lorsque stdout n'est plus un terminal.

8.7 Expérience : distinguer programme, processus et thread

Code: Select all

// Le fichier hello sur disque est un programme.
// ./hello puis ./hello lancés séparément -> deux exécutions
// avec des contextes de processus distincts.
// Chaque processus a au moins un thread exécutant des instructions.
Pourquoi distinguer ? L'exécutable est un ensemble d'octets persistants ; le processus regroupe espace d'adressage, handles, contexte de sécurité et ressources gérées ; le thread est une unité d'exécution ordonnancée. Conséquence : une variable globale ordinaire n'est pas partagée spontanément entre deux processus indépendants.

9. CACHE CPU : COMPRENDRE PAR DES MESURES

9.1 Expérience : parcours contigu et parcours à grand pas

Code: Select all

// Bench C11 (Linux/Windows) :
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void) {
    enum { N = 1 << 20, PAS = 64 };
    int *a = malloc((size_t)N * sizeof *a);
    if (!a) return 1;
    for (int i=0; i<N; ++i) a[i] = i;
    volatile uint64_t somme = 0;
    clock_t t = clock();
    for (int repetition=0; repetition<200; ++repetition)
        for (int i=0; i<N; ++i) somme += (unsigned)a[i];
    double t1=(double)(clock()-t)/CLOCKS_PER_SEC;
    t=clock();
    for (int repetition=0; repetition<200*PAS; ++repetition)
        for (int i=0; i<N; i+=PAS) somme += (unsigned)a[i];
    double t2=(double)(clock()-t)/CLOCKS_PER_SEC;
    printf("seq=%.3f s pas=%.3f s checksum=%llu\n",t1,t2,
           (unsigned long long)somme);
    free(a);
}
Pourquoi ces boucles ? Le nombre d'éléments additionnés est similaire : dans la deuxième, on visite un élément sur 64, mais on répète 64 fois plus de passages. Les parcours ne sont pas parfaitement équivalents en bytes utiles, réutilisation et volume distinct de données : il s'agit d'une expérience de comportement mémoire, pas d'une preuve universelle. Résultat attendu ? dépend du CPU, du compilateur et des caches ; le parcours séquentiel profite habituellement de la localité spatiale. Piège : volatile sur somme empêche certaines optimisations mais augmente aussi le coût des boucles ; pour comparer précisément les caches, utiliser des benchmarks spécialisés, de grandes tailles de travail, plusieurs mesures et des compteurs matériels.

9.2 Expérience : pourquoi la ligne de cache est importante

Code: Select all

// Si la ligne matérielle fait 64 octets (courant sur x86-64) :
// lire a[0], un int de 4 octets, peut faire entrer
// dans un niveau de cache d'autres int voisins.
// a[1] peut alors être déjà dans la ligne chargée.
Pourquoi ne charge-t-on pas toujours seulement 4 octets ? Un cache manipule des blocs (lignes) et amortit ainsi le coût d'accès aux niveaux plus éloignés. Piège : « 64 octets » est un cas courant, pas une taille imposée par le langage C. Un cache hit dans L1 ne garantit pas la même latence pour toutes les opérations.

9.3 Expérience : localité temporelle

Code: Select all

#include <stdint.h>
void travailler(uint32_t *a) {
    for (int r = 0; r < 1000; ++r)
        for (int i = 0; i < 64; ++i) a[i] += 1;
}
Pourquoi les mêmes 64 éléments ? Les données récemment utilisées ont des chances de rester dans les caches ; les réutiliser rapidement améliore la localité temporelle. Mais : le compilateur peut vectoriser et transformer le calcul ; un autre thread peut évincer les données et modifier les mesures. Utilité : traiter un petit bloc plusieurs fois avant de passer au suivant dans certains algorithmes.

9.4 Expérience : ligne chaînée contre tableau

Code: Select all

// Tableau : chaque index pointe vers une position prévisible.
for (size_t i = 0; i < n; ++i) total += a[i];
// Liste : chaque nœud indique l'adresse du suivant.
for (struct Node *p = tete; p; p = p->suivant) total += p->v;
Pourquoi la liste peut être plus lente ? Les nœuds peuvent être dispersés ; l'adresse du prochain chargement dépend du pointeur lu au nœud courant, ce qui limite le préfetch et la parallélisation des misses. Quand une liste reste utile ? Des insertions/suppressions fréquentes à partir de nœuds déjà localisés, des contraintes de stabilité d'adresses, certaines structures spécialisées. Piège : la complexité asymptotique O(n) ne révèle pas toute la différence de localité.

9.5 Expérience : mauvais partage de ligne (false sharing)

Code: Select all

// Schéma de données partagé par deux threads :
struct Compteurs { long a, b; } c;
// Thread 1 modifie surtout c.a ; thread 2 surtout c.b.
// Même si les variables sont distinctes, elles peuvent occuper
// la même ligne de cache et causer de la contention de cohérence.
Pourquoi « false » ? Les threads ne veulent pas modifier le même objet, mais leurs écritures concernent une même unité de cohérence de cache. Pourquoi le simple padding peut aider ? Éloigner les champs dans des lignes séparées peut réduire la circulation des états de cohérence. Piège majeur : il faut quand même synchroniser les accès à des objets effectivement partagés ; une ligne séparée n'enlève pas les data races. Les tailles de lignes diffèrent ; alignement et placement doivent être vérifiés.

9.6 Expérience : une mesure de cache n'est pas universelle

Code: Select all

# Linux, si perf est disponible et autorisé :
perf stat -e cache-references,cache-misses ./benchmark
# Comparer plusieurs exécutions et tailles de jeu de données.
Pourquoi plusieurs tailles ? Un jeu de travail petit peut tenir en L1/L2 ; un plus grand peut solliciter L3 ou RAM. Attention : les événements perf disponibles et leur sémantique varient selon CPU ; cache-misses ne correspond pas forcément à « tous les misses L1 ». Utilité : confronter l'hypothèse de localité à des observations plutôt qu'à une règle absolue.

9.7 Cas réel : matrice parcourue dans le mauvais sens

Code: Select all

#define N 512
static int matrice[N][N];
long par_lignes(void) {
    long s=0;
    for (int i=0; i<N; ++i)
        for (int j=0; j<N; ++j) s+=matrice[i][j];
    return s;
}
long par_colonnes(void) {
    long s=0;
    for (int j=0; j<N; ++j)
        for (int i=0; i<N; ++i) s+=matrice[i][j];
    return s;
}
Pourquoi traverser par lignes est souvent plus rapide en C ? Les éléments d'une ligne sont contigus (row-major). Le parcours par colonnes peut sauter de plusieurs kilo-octets entre éléments successifs. Résultats : les deux fonctions retournent la même somme si aucune écriture concurrente ; le temps mesuré peut être très différent. Piège : l'optimiseur et les caches peuvent réduire ou modifier l'écart ; mesurer les deux sur le même environnement.

9.8 Exercices de prédiction
E1 : a[0] et a[1] sont voisins : pourquoi le second peut-il être rapide ? E2 : une liste chaînée a des nœuds éparpillés : pourquoi la somme est-elle souvent plus lente ? E3 : une ligne est dans L2 mais pas L1 : est-ce un hit ou un miss ? Réponse : un miss pour L1 et un hit pour L2 ; précise toujours le niveau. E4 : si une fonction devient plus rapide après 100 répétitions, explique les hypothèses (cache, instruction cache, fréquence, planification) avant de conclure.


10. HIÉRARCHIE DE STOCKAGE : LATENCE, DÉBIT, PERSISTANCE

10.1 Expérience : RAM et fichier ne sont pas équivalents

Code: Select all

#include <stdio.h>
int main(void) {
    unsigned char ram[4] = {0x12,0x34,0x56,0x78};
    FILE *f = fopen("octets.bin", "wb");
    if (!f) return 1;
    size_t n = fwrite(ram, 1, sizeof ram, f);
    int etat = fclose(f);
    return n == sizeof ram && etat == 0 ? 0 : 2;
}
Pourquoi une opération explicite ? Le tableau ram est une donnée volatile liée à l'exécution du processus ; un fichier est une abstraction de stockage nommée et potentiellement persistante. Résultat observable : 12 34 56 78 dans le fichier. Piège : fwrite peut d'abord copier les données dans un buffer de bibliothèque ; fclose tente de terminer les écritures mais ne suffit pas à garantir la résistance à toute panne électrique. Utilité : sérialiser proprement un format binaire.

10.2 Expérience : persistance réelle sous POSIX

Code: Select all

// Linux/POSIX, création et écriture d'un fichier :
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stddef.h>
int main(void) {
    int fd = open("etat.bin", O_WRONLY|O_CREAT|O_TRUNC, 0600);
    if (fd < 0) return 1;
    const char msg[] = "OK";
    size_t fait = 0;
    while (fait < sizeof msg - 1) {
        ssize_t n = write(fd, msg + fait, sizeof msg - 1 - fait);
        if (n < 0 && errno == EINTR) continue;
        if (n <= 0) { close(fd); return 2; }
        fait += (size_t)n;
    }
    int a = fsync(fd);  // demande de synchronisation vers stockage
    int b = close(fd);
    return a == 0 && b == 0 ? 0 : 3;
}
Pourquoi open ? Il produit un descripteur représentant un fichier ouvert, avec droits d'écriture, création et troncature. Pourquoi 0600 ? Permissions demandées pour un nouveau fichier : lecture et écriture pour le propriétaire, sous réserve de l'umask. Pourquoi fsync ? Les buffers noyau et caches peuvent différer l'écriture vers le stockage. fsync demande la synchronisation des données et métadonnées requises selon le contrat POSIX de la plateforme. Piège : le système de fichiers et le matériel peuvent avoir des limites de garanties ; un remplacement atomique et durable d'un fichier demande souvent de synchroniser aussi son répertoire parent selon le scénario. Pourquoi close ? Libérer la référence du processus à cet objet ouvert.

10.3 Expérience : tampon C, cache noyau, cache CPU

Code: Select all

// fputs("abc", f);
// fflush(f);  -> vide le tampon de la bibliothèque C vers l'OS
// fsync(fd);  -> demande au noyau de synchroniser le fichier
// cache CPU   -> accélère l'accès aux lignes mémoire, autre mécanisme
Pourquoi trois noms de cache ? Ils opèrent à des couches différentes. Le buffer de stdio regroupe des opérations utilisateurs. Le cache de pages noyau conserve des données de fichiers en RAM. Les caches CPU accélèrent l'accès à des données/instructions. Piège : fflush ne signifie pas fsync. Même si printf s'est achevé, cela ne veut pas dire qu'un secteur a été écrit sur SSD.

10.4 Expérience : latence versus débit

Code: Select all

// Scénario A : 1000 opérations de 1 octet
// Scénario B : 1 opération de 1000 octets
// Nombre total d'octets identique, nombre d'appels différent.
Pourquoi B peut être plus rapide ? Chaque opération peut avoir un coût fixe d'appel, de validation et de planification. Regrouper les données amortit ces frais. Pourquoi n'est-ce pas toujours vrai ? La destination, l'alignement, la concurrence, la taille des tampons et la nécessité de répondre rapidement peuvent imposer d'autres compromis. À quoi sert la distinction ? Conception de protocoles, fichiers journaux, transferts réseau et stockage NVMe.

10.5 Expérience : accès aléatoire et accès séquentiel

Code: Select all

// Comparer conceptuellement :
// lire les blocs 0, 1, 2, 3 ... (séquentiel)
// lire les blocs 205, 7, 602, 12 ... (éparpillé)
// Sur HDD, les déplacements physiques coûtent cher.
// Sur SSD, pas de tête mécanique, mais lecture aléatoire
// et petites requêtes conservent un coût de traitement.
Pourquoi la hiérarchie existe ? Une mémoire rapide et petite est un compromis différent d'un stockage grand et moins cher. Attention : les chiffres précis de latence ne sont pas des constantes universelles. Utilité : éviter des milliers de petites lectures indépendantes lorsqu'une lecture groupée et un index suffisent.

10.6 Expérience : mémoire mappée versus lecture explicite

Code: Select all

// POSIX, exemple explicatif du principe :
// read(fd, buffer, taille) : le noyau copie des octets vers buffer.
// mmap(..., MAP_PRIVATE, fd, ...) : zone virtuelle associée au fichier.
// Un accès à cette zone peut provoquer un défaut de page,
// puis un chargement depuis le cache de pages ou le stockage.
Pourquoi mmap peut être utile ? L'application accède à des données du fichier comme à une plage d'adresses virtuelles. Piège : mmap ne charge pas nécessairement tout le fichier immédiatement, ne rend pas les accès gratuits et introduit une gestion des fautes de pages, de synchronisation et de durée de validité des mappings. Quand préférer read ? flux, buffers réutilisables, contrôle explicite de la lecture. Quand envisager mmap ? accès structurés et aléatoires à de grandes zones, avec précautions.

11. ENTRÉES/SORTIES : POLLING, INTERRUPTIONS ET DMA

11.1 Expérience : attente active contre notification

Code: Select all

// PSEUDO-CODE de pilote :
while (!(LireRegistreEtat() & PRET)) {
    /* polling : on consulte en boucle */
}
// Alternative conceptuelle :
// lancer une commande, dormir ou faire autre chose,
// puis être notifié par interruption/événement d'achèvement.
Pourquoi ne pas vérifier en continu ? Le CPU dépense du temps à consulter un état inchangé. Quand le polling reste pertinent ? Très faibles latences attendues, pilote à haut débit, attente brève et contrôlée. Piège : une interruption matérielle ne signifie pas que le code utilisateur exécute immédiatement un callback ; le noyau peut différer le traitement et réveiller un thread ensuite.

11.2 Expérience : transfert programmé versus DMA

Code: Select all

// Sans DMA (modèle simplifié) :
// CPU : lire des registres du périphérique et copier les données.
// Avec DMA (modèle simplifié) :
// CPU : préparer des descriptors / buffers et déclencher l'opération.
// Périphérique/contrôleur : transférer des blocs vers/de la RAM.
// CPU : traiter une notification d'achèvement.
Pourquoi DMA ? Éviter que le CPU ne copie lui-même chaque unité lors de transferts importants. Ce qui ne disparaît pas : la configuration, la gestion des buffers, les droits d'accès, la cohérence et les notifications. Piège : « DMA » n'implique pas « aucune activité CPU » ni « copie directement dans le tableau utilisateur sans aucune contrainte ».

11.3 Expérience : un SSD NVMe reçoit une demande de lecture

Code: Select all

// 1. Application : ReadFile / read
// 2. Noyau : valide, route, peut satisfaire via cache de pages
// 3. Si E/S requise : pilote prépare une commande NVMe
// 4. Contrôleur : accède aux buffers autorisés via DMA
// 5. Achèvement : notification, pilote met à jour la requête
// 6. Application : reçoit ses octets ou une erreur
Pourquoi la lecture peut-elle être rapide sans activité disque ? Le cache de pages peut déjà contenir les données. Pourquoi la pile est-elle longue ? Isoler la sécurité, les systèmes de fichiers, le cache et les matériels spécifiques. Piège : c'est un chemin type, pas la description de chaque lecture ni la garantie d'une interruption par commande.

11.4 Expérience : écrire des octets en Win32 et expliquer chaque paramètre

Code: Select all

// Windows, code complet, MSVC : cl /W4 fichier.c
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
int main(void) {
    HANDLE f = CreateFileW(L"demo.bin", GENERIC_WRITE, 0, NULL,
                           CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
    if (f == INVALID_HANDLE_VALUE) {
        printf("CreateFile: erreur %lu\n", (unsigned long)GetLastError());
        return 1;
    }
    const BYTE octets[4] = {0x12,0x34,0x56,0x78};
    DWORD ecrits = 0;
    BOOL ok = WriteFile(f, octets, sizeof octets, &ecrits, NULL);
    DWORD erreur = ok ? 0 : GetLastError();
    if (!CloseHandle(f)) return 4;
    if (!ok) {
        printf("WriteFile: erreur %lu\n", (unsigned long)erreur);
        return 2;
    }
    if (ecrits != sizeof octets) return 3;
    printf("%lu octets ecrits\n", (unsigned long)ecrits);
    return 0;
}
Résultat : demo.bin contient 12 34 56 78 dans cet ordre. Pourquoi pas 78 56 34 12 ? Le tableau contient quatre objets BYTE individuels, placés successivement ; l'endianness concerne la représentation des valeurs de plusieurs octets comme uint32_t, pas l'ordre des éléments du tableau. Si à la place on écrit uint32_t x=0x12345678 et WriteFile(f, &x, 4, ...), sur x86-64 le fichier contient 78 56 34 12. C'est une démonstration fondamentale : WriteFile écrit les octets du buffer dans leur ordre d'adresses, sans les convertir en ordre réseau.
Pourquoi CreateFileW ? Windows doit associer un nom de chemin à un objet ouvert et retourner un HANDLE. Le suffixe W signifie chemin en chaînes larges UTF-16 Windows. Pourquoi CloseHandle ? Libérer l'accès à cet objet ; sans cette fermeture, le processus garde le handle ouvert jusqu'à la terminaison (ou fermeture ultérieure).

11.5 CreateFileW : les SEPT paramètres, aucun laissé flou

Code: Select all

HANDLE CreateFileW(
  LPCWSTR lpFileName,
  DWORD dwDesiredAccess,
  DWORD dwShareMode,
  LPSECURITY_ATTRIBUTES lpSecurityAttributes,
  DWORD dwCreationDisposition,
  DWORD dwFlagsAndAttributes,
  HANDLE hTemplateFile
);
1. lpFileName = L"demo.bin" : chemin à ouvrir ou à créer. Pourquoi L ? Le paramètre W attend une chaîne de wchar_t Windows. Un chemin relatif dépend du répertoire courant du processus, pas forcément du répertoire de l'exécutable.
2. dwDesiredAccess = GENERIC_WRITE : droit demandé. GENERIC_READ pour lire, GENERIC_WRITE pour écrire, combinaison OR pour les deux. Piège : obtenir un handle n'accorde pas tous les droits ; ReadFile sur un handle sans droit de lecture peut échouer.
3. dwShareMode = 0 : accès exclusif selon les règles de partage Windows. FILE_SHARE_READ et FILE_SHARE_WRITE autorisent des ouvertures compatibles ultérieures. Pourquoi c'est utile ? contrôle des accès concurrents. Piège : le partage doit être compatible avec les accès des handles déjà ouverts ; c'est un contrat bilatéral, pas juste « j'autorise quelqu'un à lire ».
4. lpSecurityAttributes = NULL : attributs de sécurité par défaut et handle non héritable par défaut. Une SECURITY_ATTRIBUTES peut spécifier un descripteur de sécurité et l'héritabilité du handle ; ce n'est pas un buffer de données.
5. dwCreationDisposition = CREATE_ALWAYS : crée si absent ; si un fichier existant est accessible, le remplace logiquement par une version tronquée (son ancien contenu est perdu). OPEN_EXISTING exige un fichier existant ; OPEN_ALWAYS ouvre ou crée ; CREATE_NEW échoue si déjà présent ; TRUNCATE_EXISTING exige un fichier et le vide. Pourquoi distinguer ? ouvrir un journal ne doit pas forcément détruire son contenu.
6. dwFlagsAndAttributes = FILE_ATTRIBUTE_NORMAL : attributs et indicateurs. FILE_FLAG_OVERLAPPED pour des E/S asynchrones, FILE_FLAG_SEQUENTIAL_SCAN comme indication d'usage, FILE_FLAG_NO_BUFFERING avec contraintes strictes. Pourquoi ? demander des politiques d'E/S adaptées à une utilisation précise.
7. hTemplateFile = NULL : handle optionnel d'un fichier modèle lors de certaines créations ; NULL quand aucun modèle n'est utilisé. Piège : ce n'est pas le handle du fichier qu'on essaie d'ouvrir.
Retour : HANDLE valide en cas de succès ; INVALID_HANDLE_VALUE en cas d'échec. Il faut immédiatement consulter GetLastError après l'échec si l'on veut diagnostiquer la cause.

11.6 WriteFile : les CINQ paramètres

Code: Select all

BOOL WriteFile(
  HANDLE hFile,
  LPCVOID lpBuffer,
  DWORD nNumberOfBytesToWrite,
  LPDWORD lpNumberOfBytesWritten,
  LPOVERLAPPED lpOverlapped
);
hFile : handle ouvert pour l'écriture. lpBuffer : adresse des octets à envoyer ; l'API ne fait pas de magie d'encodage. nNumberOfBytesToWrite : nombre d'octets demandés (pas de caractères Unicode). lpNumberOfBytesWritten : sortie indiquant le nombre réellement écrit en mode synchrone ; comparer au nombre demandé. lpOverlapped : NULL dans notre exemple synchrone ; une structure OVERLAPPED valide pour le mode asynchrone, avec règles de durée de vie, d'offset et d'achèvement. Retour : BOOL ; FALSE appelle GetLastError pour obtenir le code. Piège : en mode asynchrone, FALSE avec ERROR_IO_PENDING indique une opération en attente et non forcément un échec final ; il faut attendre ou récupérer l'achèvement via les API adaptées. Le buffer et OVERLAPPED doivent rester valides jusque-là.

11.7 Expérience : exclusivité de partage et erreurs Windows

Code: Select all

// Extrait à lancer alors que premier handle n'est pas fermé :
HANDLE a = CreateFileW(L"demo.bin", GENERIC_READ, 0, NULL,
                       OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (a == INVALID_HANDLE_VALUE) return 1;
HANDLE b = CreateFileW(L"demo.bin", GENERIC_READ, FILE_SHARE_READ,
                       NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
if (b == INVALID_HANDLE_VALUE) {
    DWORD err = GetLastError(); // souvent ERROR_SHARING_VIOLATION
    /* exploiter err dans votre diagnostic */
} else CloseHandle(b);
CloseHandle(a);
Pourquoi b échoue-t-il généralement ? Le premier handle a été ouvert avec partage 0 ; il interdit des ouvertures supplémentaires demandant des accès incompatibles tant qu'il reste ouvert. Pourquoi le b partageant FILE_SHARE_READ ne suffit-il pas ? Parce que l'ouverture précédente ne l'autorise pas. Utilité : comprendre pourquoi un fichier « existe » mais ne peut pas être ouvert par ton application.

11.8 Expérience : lire un fichier et vérifier le nombre d'octets

Code: Select all

// Windows, fonction autonome à appeler depuis un programme :
#include <windows.h>
int lire_quatre(void) {
    HANDLE h = CreateFileW(L"demo.bin", GENERIC_READ, FILE_SHARE_READ,
                           NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
    if (h == INVALID_HANDLE_VALUE) return 1;
    BYTE buf[4] = {0};
    DWORD recu = 0;
    BOOL ok = ReadFile(h, buf, sizeof buf, &recu, NULL);
    DWORD err = ok ? 0 : GetLastError();
    CloseHandle(h);
    if (!ok) return (int)(err ? err : 2);
    if (recu != sizeof buf) return 3; // fichier plus court que prévu
    return buf[0] == 0x12 && buf[1] == 0x34 &&
           buf[2] == 0x56 && buf[3] == 0x78 ? 0 : 4;
}
Paramètres ReadFile : handle d'entrée, adresse du buffer, capacité demandée, adresse du compteur d'octets réellement reçus, pointeur OVERLAPPED (NULL ici). Pourquoi vérifier recu ? une lecture peut réussir en retournant moins d'octets, par exemple en atteignant la fin du fichier. Pourquoi ne pas supposer un fichier toujours complet ? Les données peuvent être tronquées, modifiées ou absentes ; un parseur robuste traite explicitement toutes ces situations.

11.9 Expérience : faire varier un octet et prédire le fichier

Code: Select all

// Cas A :
BYTE tab[] = {0xAB, 0xCD};
// WriteFile(..., tab, 2, ...): AB CD.
// Cas B, sur x86-64 little-endian :
WORD n = 0xABCD;
// WriteFile(..., &n, sizeof n, ...): CD AB.
// Cas C, encodage texte :
char texte[] = "AB";
// WriteFile(..., texte, 2, ...): 41 42 en ASCII/UTF-8.
Pourquoi trois réponses ? Le premier buffer est deux octets choisis ; le second est un entier 16 bits stocké selon l'ordre natif ; le troisième est du texte encodé. Même API d'écriture, trois représentations différentes. Piège : écrire deux caractères « AB » ne transmet pas l'octet numérique 0xAB.

12. SYSTÈME D'EXPLOITATION : APPELS SYSTÈME, PROTECTION, PROCESSUS

12.1 Expérience : pourquoi le code utilisateur ne peut pas tout faire

Code: Select all

// Windows :
// CreateFileW ouvre un objet du système via les API fournies.
// WriteFile transmet les données à cet objet.
// Une application ordinaire ne lit pas un disque physique
// via une instruction arbitraire qui contourne le noyau.
Pourquoi cette séparation ? Le noyau fait respecter droits et isolation entre processus, système de fichiers et périphériques. Ce qu'une API fournit : des paramètres contrôlés et un résultat vérifiable ; le noyau et les pilotes peuvent refuser les opérations dangereuses ou non autorisées. Piège : un appel à une fonction Win32 n'est pas nécessairement un syscall par appel ; certaines opérations se traitent en espace utilisateur, d'autres passent par des services système.

12.2 Expérience : processus ≠ fichier exécutable ≠ thread

Code: Select all

// Windows :
// Deux lancements de programme.exe -> deux processus distincts.
// Chaque processus peut créer plusieurs threads.
// Le code (.text) provient du même fichier sur disque,
// mais les variables ordinaires de chaque processus sont isolées.
Pourquoi un espace d'adressage virtuel par processus ? Les mêmes adresses numériques peuvent désigner des objets différents dans deux processus. La protection empêche normalement un programme de modifier arbitrairement la mémoire d'un autre. Quand partager ? Des objets IPC explicites, mémoire partagée, pipes, sockets ou fichiers. Piège : deux threads d'un même processus partagent normalement les mêmes variables globales, mais possèdent des piles et des états d'exécution distincts.

12.3 Expérience : taille mémoire demandée et adresse virtuelle obtenue

Code: Select all

// Windows, programme complet : cl /W4 reserve.c
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
int main(void) {
    SIZE_T taille = 4096;
    BYTE *p = VirtualAlloc(NULL, taille,
                           MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
    if (!p) {
        printf("Erreur VirtualAlloc %lu\n", (unsigned long)GetLastError());
        return 1;
    }
    p[0] = 0xA1;
    p[taille - 1] = 0xB2;
    printf("adresse virtuelle=%p valeurs=%02X %02X\n",
           (void*)p, (unsigned)p[0], (unsigned)p[taille - 1]);
    return VirtualFree(p, 0, MEM_RELEASE) ? 0 : 2;
}
Pourquoi VirtualAlloc ? Demander au gestionnaire mémoire Windows une plage d'adresses virtuelles, la réserver et la rendre accessible. Paramètre 1 = NULL : laisser l'OS choisir l'adresse de base. Paramètre 2 = 4096 : taille en octets demandée, arrondie selon les règles de la plateforme. Paramètre 3 = MEM_RESERVE | MEM_COMMIT : réserver la plage d'adresses puis engager le stockage de sauvegarde nécessaire à l'utilisation ; cela ne signifie pas que toute la RAM physique est immédiatement allouée. Paramètre 4 = PAGE_READWRITE : autoriser lecture et écriture. Retour : pointeur virtuel ou NULL en erreur.
VirtualFree : base retournée, taille 0, MEM_RELEASE pour libérer toute cette réservation. Piège : la valeur numérique de p peut varier entre lancements ; afficher une adresse virtuelle ne révèle pas l'adresse physique des cellules DRAM.

12.4 Expérience : protection d'une page virtuelle

Code: Select all

// Windows, après VirtualAlloc et accès contrôlés :
DWORD ancienne = 0;
if (!VirtualProtect(p, 4096, PAGE_READONLY, &ancienne)) {
    /* traiter GetLastError() */
}
// Lire p[0] est permis si la protection a réussi.
// Écrire p[0] = 10 n'est plus autorisé.
Pourquoi la MMU ? Les entrées de tables de pages et les protections matérielles permettent au noyau d'appliquer les droits. Une tentative d'écriture interdite peut entraîner une exception d'accès, plutôt qu'une modification silencieuse. Paramètres VirtualProtect : adresse de départ, taille, nouvelle protection, pointeur recevant l'ancienne protection. Piège : ne lance pas volontairement une écriture interdite hors d'un environnement de test isolé ; l'exemple illustre le résultat attendu du contrôle d'accès.

12.5 Expérience : comparer adresse virtuelle et taille de page

Code: Select all

// Windows :
SYSTEM_INFO si;
GetSystemInfo(&si);
printf("taille page=%lu allocation granularity=%lu\n",
       si.dwPageSize, si.dwAllocationGranularity);
Pourquoi deux valeurs ? Les pages sont des unités de protection/traduction mémoire ; la granularité de réservation d'adresse n'est pas nécessairement égale à la taille d'une page. Piège : « page = 4096 octets » est courant, mais il faut demander au système la taille effective au lieu de l'imposer dans tous les programmes.

12.6 Expérience : créer un thread sous Windows

Code: Select all

#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
DWORD WINAPI travail(LPVOID param) {
    int v = *(int*)param;
    printf("travail : %d\n", v);
    return 0;
}
int main(void) {
    int n = 42;
    HANDLE th = CreateThread(NULL, 0, travail, &n, 0, NULL);
    if (!th) return 1;
    DWORD attente = WaitForSingleObject(th, INFINITE);
    CloseHandle(th);
    return attente == WAIT_OBJECT_0 ? 0 : 2;
}
Pourquoi CreateThread ? Créer une nouvelle unité d'exécution dans le même processus. Paramètres : attributs de sécurité NULL ; taille de pile initiale 0 (défaut) ; adresse de fonction de départ ; pointeur d'argument &n ; flags 0 (démarrage normal) ; dernier paramètre NULL (pas de récupération d'ID). Pourquoi attendre ? n est sur la pile de main ; on le garde vivant jusqu'à la fin du thread et on évite que le programme se termine trop tôt. Pourquoi CloseHandle ? fermer la référence au thread, ce qui n'est pas la même chose que le terminer. Piège : pour du C/C++ utilisant intensivement le runtime C, les abstractions std::thread ou _beginthreadex peuvent être plus appropriées selon les besoins du programme.

12.7 Expérience : une data race en deux lignes

Code: Select all

// DANGER : si deux threads font simultanément :
compteur++;
// sur un int ordinaire partagé, sans synchronisation,
// le programme présente une data race en C/C++.
// Ce n'est pas juste une erreur de valeur : comportement indéfini.
Pourquoi ? compteur++ est une opération de lecture-modification-écriture qui n'est pas atomique par simple présence de ++. Solution : InterlockedIncrement sur un LONG Windows correctement partagé, std::atomic en C++, ou protection par mutex/section critique. Utilité : statistiques, compteurs, file de tâches, communication interthreads.

12.8 Expérience : appeler une API ne signifie pas « toujours passer dans le noyau »

Code: Select all

// Exemple :
// strlen("ABC") peut être entièrement réalisé en code utilisateur.
// WriteFile sur un vrai fichier sollicite normalement des mécanismes
// d'E/S système, mais des couches et buffers interviennent.
// malloc peut servir une allocation depuis un pool utilisateur ;
// il ne déclenche pas forcément une allocation noyau à chaque appel.
Pourquoi ces abstractions ? Les bibliothèques offrent des interfaces stables et évitent des transitions noyau inutiles lorsque c'est possible. Piège : le mot API ne dit pas qui l'implémente, ni si elle demande une transition de privilège à chaque utilisation.

12.9 Expérience : Windows NT et Linux, mêmes besoins, interfaces différentes

Code: Select all

// Objectif : ouvrir un fichier et écrire 2 octets
// Linux : open(...), write(...), close(...)
// Windows : CreateFileW(...), WriteFile(...), CloseHandle(...)
// Objectif : réserver des pages mémoire
// Linux : mmap(...), munmap(...)
// Windows : VirtualAlloc(...), VirtualFree(...)
Pourquoi pas la même API ? Chaque OS expose son contrat de programmation et ses objets (descripteurs numériques et handles notamment) ; ils s'appuient tous deux sur les mécanismes matériels de traduction mémoire et de privilèges, mais leur organisation interne et leurs garanties API peuvent différer. Piège : « équivalent » ne signifie pas strictement identique pour les flags, partage, erreurs ou synchronisation.

12.10 Étude de cas complète : qu'arrive-t-il à 0x12345678 dans un fichier ?
Situation A : uint32_t x = 0x12345678 ; on écrit ses 4 octets natifs en mémoire x86-64 via WriteFile(f, &x, 4, ...). Octets du fichier : 78 56 34 12. Pourquoi ? x86-64 range les octets d'un entier multi-octet avec l'octet de poids faible à l'adresse basse.
Situation B : BYTE buf[] = {0x12,0x34,0x56,0x78} ; on écrit buf. Octets du fichier : 12 34 56 78. Pourquoi ? l'ordre du tableau a été défini explicitement.
Situation C : on exécute printf("%X", x) en redirigeant stdout. Octets du fichier sur une machine ASCII/UTF-8 : 31 32 33 34 35 36 37 38. Pourquoi ? printf convertit le nombre en caractères hexadécimaux (« 12345678 »), puis transmet leurs codes textuels. Aucun de ces huit octets n'est la représentation binaire brute de x.
Conclusion pratique : pour un fichier ou protocole binaire interopérable, définis explicitement l'endianness, la taille des champs et l'encodage des chaînes. Ni le nom du fichier, ni le type du pointeur passé à WriteFile ne fournissent automatiquement ce format.

12.11 Série d'exercices pièges, corrigés dans l'explication
1. CreateFileW(..., OPEN_EXISTING, ...) crée-t-il un fichier absent ? Non : il échoue. 2. CREATE_ALWAYS préserve-t-il toujours le contenu ? Non : il tronque le fichier existant si l'ouverture réussit. 3. Un WriteFile réussi écrit-il du texte Unicode ? Non : il écrit les octets fournis. 4. Un ReadFile réussi a-t-il obligatoirement rempli le buffer ? Non : vérifier recu. 5. VirtualAlloc renvoie-t-il une adresse physique ? Non : une adresse virtuelle. 6. Deux threads peuvent-ils utiliser la même adresse virtuelle dans le même processus ? Oui, l'espace d'adressage est partagé. 7. Deux processus avec la même adresse virtuelle accèdent-ils au même objet ? Pas sans configuration appropriée de partage. 8. fsync suffit-il à garantir une transaction applicative multi-fichiers ? Non : il faut concevoir le protocole et l'atomicité. 9. Un cache miss L1 implique-t-il forcément la RAM ? Non : L2/L3 peuvent répondre. 10. send TCP conserve-t-il la séparation de deux appels en deux messages reçus ? Non : TCP est un flux d'octets.

ANNEXE — RÉFLEXES DU DÉVELOPPEUR SYSTÈME
Avant une API : décrire la ressource visée (fichier, mémoire, thread), les droits nécessaires, l'unité des tailles (octets, caractères, pages), la durée de vie des buffers, les codes d'erreur et les obligations de fermeture.
Avant une écriture binaire : faire un exemple avec une valeur hexadécimale non symétrique, comme 0x12345678, et prédire tous les octets.
Avant un benchmark : identifier le travail réellement exécuté, la taille du jeu de données, les caches possibles, les optimisations, la synchronisation et les biais de mesure.
Avant d'accuser le noyau : vérifier si l'opération est réalisée par une bibliothèque en mémoire utilisateur, si stdout est redirigé et si des buffers regroupent les écritures.

Références API pour vérifier les contrats exacts :
Microsoft CreateFileW : https://learn.microsoft.com/windows/win ... reatefilew
Microsoft WriteFile : https://learn.microsoft.com/windows/win ... -writefile
Microsoft ReadFile : https://learn.microsoft.com/windows/win ... i-readfile
Microsoft VirtualAlloc : https://learn.microsoft.com/windows/win ... rtualalloc
Linux POSIX : man 2 open ; man 2 write ; man 2 fsync ; man 2 mmap.

Who is online

Users browsing this forum: No registered users and 1 guest