Systèmes d'exploitation · C1 Généralités · Chapitre 1 · 4 h
Rôle et structure d'un système
De la machine nue au besoin d'abstraction ; gestion des ressources, interface et protection ; noyau monolithique et micro-noyau ; mode utilisateur, mode noyau et appels système.
Écrivons un programme qui affiche une ligne de texte, sur une machine sans système d'exploitation. Il faudrait connaître le modèle exact du contrôleur d'écran, l'adresse de ses registres, la séquence de commandes qu'il attend. Changer d'écran, et le programme est à réécrire. Faire tourner deux programmes à la fois, et il faudrait qu'ils se soient mis d'accord à l'avance sur le partage de la mémoire.
Ce que le chapitre 5 d'architecture appelait la machine de von Neumann est en réalité inutilisable telle quelle. Entre elle et les programmes, il manque une couche : le système d'exploitation. Ce chapitre dit ce qu'elle fait, comment elle est bâtie, et par quelle frontière on y accède — une frontière matérielle, héritée directement des interruptions du chapitre 8 d'architecture.
Trois fonctions, pas une
On résume souvent le système à « il gère le matériel ». C'est trop vague pour être utile. Il remplit trois fonctions distinctes, et les confondre empêche de comprendre le reste du cours.
Abstraire. Le système transforme des dispositifs hétérogènes en objets uniformes. Un fichier est la même chose sur un disque magnétique, un SSD ou une clé USB. Un processus est la même chose quel que soit le processeur. Cette uniformité est ce qui rend un programme portable, et c'est le service que le programmeur perçoit en premier.
Gérer les ressources. Processeur, mémoire, disque et réseau sont en quantité finie, et plusieurs programmes les veulent en même temps. Le système arbitre : à qui donner le processeur, dans quel ordre (chapitre 4), quelle page garder en mémoire (chapitre 6), quel accès disque servir d'abord (chapitre 8). Ces arbitrages sont des décisions d'allocation, et ils forment l'essentiel du cours.
Protéger. Un programme fautif ou hostile ne doit pas pouvoir lire la mémoire d'un autre, ni écrire directement sur le disque, ni monopoliser le processeur. Cette fonction n'est pas purement logicielle : elle exige un appui matériel, et c'est tout l'objet de la dernière section.
Quelques familles de systèmes
Ces trois fonctions se pondèrent différemment selon l'usage, et cela explique des choix de conception qui paraîtraient sinon incohérents.
Le traitement par lots, historiquement premier, enchaîne des travaux sans interaction : il optimise le débit, c'est-à-dire le nombre de travaux par heure. Le temps partagé donne le processeur à tour de rôle à des utilisateurs interactifs : il optimise le temps de réponse, quitte à perdre du débit — le va-et-vient entre programmes coûte. C'est le modèle de tous les systèmes de bureau.
Le temps réel ajoute une contrainte de nature différente : une échéance à respecter. Un système de freinage doit réagir en moins de dix millisecondes, et un système temps réel dur qui rate cette échéance a échoué, même si sa réponse est correcte. Ce n'est plus une question de vitesse moyenne mais de garantie, ce qui interdit la plupart des optimisations statistiques du cours — à commencer par le cache et la mémoire virtuelle, dont les temps sont imprévisibles.
L'embarqué vise l'empreinte mémoire et la consommation ; le distribué, la coordination de plusieurs machines.
Monolithique ou micro-noyau
Le noyau est la partie du système qui s'exécute avec tous les privilèges. Sa frontière — ce qu'on met dedans, ce qu'on laisse dehors — est le grand choix d'architecture, et il oppose deux écoles.
Le noyau monolithique met tout dans le noyau : ordonnancement, mémoire, systèmes de fichiers, pilotes, pile réseau. Tout ce petit monde partage le même espace d'adressage, donc communique par simple appel de fonction, ce qui est rapide. Le revers est que le moindre défaut dans un pilote peut corrompre le noyau entier : un bogue de pilote graphique fait tomber la machine.
Le micro-noyau ne garde dans le noyau que le strict minimum — communication entre processus, ordonnancement, gestion élémentaire de la mémoire — et sort tout le reste en services ordinaires, en mode utilisateur. Un pilote qui plante ne fait alors tomber que lui-même, et peut être relancé. Le prix est le coût des échanges : ce qui était un appel de fonction devient un envoi de message entre deux processus, avec deux changements de contexte.
Aucune des deux écoles n'a gagné. Linux est monolithique, mais modulaire — les pilotes se chargent et se déchargent à chaud, ce qui récupère une partie de la souplesse. Windows et macOS sont hybrides. Les systèmes où la fiabilité prime sur le débit, comme QNX en automobile, restent à micro-noyau. C'est le premier compromis performance/robustesse du cours, et il n'est pas le dernier.
Quiz · 1 question
Dans un système à micro-noyau, un pilote de périphérique défectueux provoque une erreur fatale. Quelle est la conséquence, et à quel prix cette propriété est-elle obtenue ?
- La machine s'arrête, comme dans un noyau monolithique : un pilote a toujours tous les privilèges — même conséquence
- Seul le service pilote tombe et peut être relancé, car il s'exécute en mode utilisateur ; le prix est le coût des échanges par messages plutôt que par appels de fonction — isolation contre coût des messages
- Le micro-noyau corrige automatiquement l'erreur, ce qui le rend plus lent mais infaillible — correction automatique
Réponse : Le micro-noyau sort les pilotes, les systèmes de fichiers et la pile réseau du noyau : ce sont des processus ordinaires, en mode utilisateur, isolés par le matériel. Un défaut y reste confiné et le service peut être relancé sans redémarrer la machine — c'est exactement la propriété recherchée en automobile ou en aéronautique. Le coût est structurel : ce qui était un appel de fonction dans un noyau monolithique devient un envoi de message entre deux processus, donc deux changements de contexte et deux copies. Ce surcoût, sur un chemin emprunté des milliers de fois par seconde, explique que Linux soit resté monolithique — modulaire, mais monolithique.
La frontière : deux modes d'exécution
Voici le point central du chapitre, et celui sur lequel tout le reste du cours s'appuie.
La protection ne peut pas être assurée par le logiciel seul. Si un programme pouvait, par ses propres moyens, décider de ne pas être protégé, la protection n'existerait pas. Il faut donc un appui matériel, et il est d'une grande simplicité : le processeur possède un bit de mode.
En mode noyau (dit superviseur), tout est permis : toutes les instructions, toute la mémoire, tous les registres de périphériques. En mode utilisateur, certaines instructions sont privilégiées et refusées — modifier la table des pages, masquer les interruptions, accéder directement à un contrôleur, changer soi-même le bit de mode. Tenter l'une d'elles ne provoque pas une exécution partielle : le processeur déclenche une exception, et la main revient au noyau.
Une application s'exécute donc en mode utilisateur, dans une prison. Mais elle a besoin de services que seul le noyau peut rendre : lire un fichier, créer un processus, envoyer sur le réseau. Il faut donc une porte, et une seule.
L'appel système
Cette porte est l'appel système. Son mécanisme mérite d'être connu en détail, parce qu'il explique à la fois la sécurité du modèle et son coût.
1. L'application place un NUMÉRO d'appel dans un registre convenu, et ses arguments dans d'autres registres.2. Elle exécute une instruction spéciale — `syscall`, `int 0x80`, `svc` — qui déclenche une interruption LOGICIELLE.3. Le matériel bascule le bit de mode en mode noyau et saute à une adresse FIXE, définie par le noyau au démarrage. L'application ne choisit pas où elle atterrit : c'est là toute la sécurité du dispositif.4. Le noyau lit le numéro, valide les arguments, exécute le service.5. Il place le résultat, restaure le mode utilisateur et rend la main à l'instruction qui suivait l'appel.Trois remarques sur ce mécanisme.
Ce n'est pas un appel de fonction. Un appel de fonction saute où on lui dit ; un appel système saute où le noyau a décidé, et change de mode au passage. Le programme ne peut donc pas « appeler le milieu » d'un service pour en sauter les contrôles.
Il coûte cher. Changement de mode, sauvegarde d'état, validation des arguments, et
souvent perte du contenu du cache : de l'ordre de quelques centaines à quelques milliers de
cycles, contre une poignée pour un appel de fonction. D'où l'usage systématique de tampons
— la bibliothèque C accumule les écritures et n'appelle write qu'une fois le tampon plein,
ce qui explique qu'un affichage puisse apparaître en retard, ou disparaître si le programme
plante avant la purge.
On ne l'écrit presque jamais à la main. La bibliothèque standard offre des fonctions —
printf, fopen, malloc — qui enveloppent les appels système, en font moins que ce qu'on
croit, et parfois pas du tout : malloc ne demande de la mémoire au noyau que lorsque sa
réserve est épuisée.
Le lien avec l'architecture est direct. L'interruption matérielle du chapitre 8 vient d'un périphérique et arrive n'importe quand ; l'interruption logicielle de l'appel système vient du programme lui-même et arrive au moment qu'il choisit. Le traitement est le même : sauvegarde d'état, bascule en mode noyau, exécution d'une routine, restauration. Un seul mécanisme matériel, deux usages.
Quiz · 1 question
Pourquoi un appel système ne peut-il pas être remplacé par un simple appel de fonction vers le code du noyau, ce qui serait bien plus rapide ?
- Parce que le code du noyau est écrit dans un autre langage que les applications — langage différent
- Parce qu'un appel de fonction saute à l'adresse que l'appelant désigne et ne change pas de mode : l'application pourrait sauter au milieu d'un service, après ses contrôles, et s'exécuterait sans privilèges de toute façon — adresse choisie et bit de mode
- Parce que le noyau se trouve sur un autre processeur que les applications — processeur séparé
Réponse : Deux raisons, et elles se cumulent. D'abord le BIT DE MODE : un appel de fonction ordinaire ne le change pas, donc le code du noyau s'exécuterait sans privilèges et échouerait à la première instruction privilégiée. Ensuite, et c'est le point de sécurité, l'ADRESSE : un appel de fonction saute où l'appelant veut. Une application pourrait donc entrer dans un service APRÈS ses vérifications de droits et d'arguments. L'appel système supprime ce choix — le matériel saute à une adresse fixe que le noyau a installée au démarrage, et c'est le noyau qui décide ensuite du service à rendre d'après le numéro fourni. Le point d'entrée unique et contrôlé est ce qui fait tenir tout le modèle de protection.
À vous
L'exercice construit la frontière en miniature : un noyau qui expose une table d'appels système, une application qui s'exécute en mode utilisateur, et un répartiteur qui bascule le mode, valide, puis rend la main.
Trois choses à faire tomber, et ce sont les trois propriétés du modèle : une instruction privilégiée tentée en mode utilisateur doit lever une exception, un numéro d'appel invalide doit être rejeté sans planter le noyau, et un argument non validé — un descripteur de fichier qui n'appartient pas à l'appelant — doit être refusé.
Exercice de code
Faites tenir la frontière utilisateur/noyau : instruction privilégiée, numéro invalide, argument non validé.
Point de départ
// ── Le matériel ───────────────────────────────────────────────────────────
const machine = {
mode: "utilisateur",
memoire: { 0: "code noyau", 100: "données du processus 1" },
};
// Certaines instructions n'ont le droit de s'exécuter qu'en mode noyau.
const PRIVILEGIEES = ["ecrireTablePages", "masquerInterruptions", "accesControleur"];
function instruction(nom) {
if (PRIVILEGIEES.includes(nom) && machine.mode !== "noyau") {
// ← à écrire : lever une exception plutôt que d'exécuter
}
return nom + " exécutée en mode " + machine.mode;
}
// ── Le noyau ──────────────────────────────────────────────────────────────
// Chaque processus n'a le droit qu'à ses propres descripteurs de fichier.
const PROCESSUS = { pid: 1, descripteurs: [0, 1, 2, 7] };
const TABLE_APPELS = {
1: { nom: "ecrire", service: (fd, texte) => {
if (!PROCESSUS.descripteurs.includes(fd)) return "ERREUR: descripteur " + fd + " interdit";
return "écrit sur " + fd + " : " + texte;
} },
2: { nom: "obtenirPid", service: () => "pid = " + PROCESSUS.pid },
3: { nom: "terminer", service: (code) => "processus terminé, code " + code },
};
// Le répartiteur : le SEUL point d'entrée du noyau.
function appelSysteme(numero, ...args) {
machine.mode = "noyau"; // le matériel bascule le bit de mode
let resultat;
const entree = TABLE_APPELS[numero];
resultat = entree.service(...args); // ← et si le numéro n'existe pas ?
machine.mode = "utilisateur"; // on rend la main
return resultat;
}
// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. instruction() doit lever une exception sur une instruction privilégiée
// tentée en mode utilisateur.
// 2. appelSysteme() ne doit pas planter sur un numéro inconnu, et doit
// restaurer le mode utilisateur MÊME si le service échoue.
console.log(appelSysteme(2));
console.log(appelSysteme(1, 1, "bonjour"));
console.log(appelSysteme(1, 42, "sur un descripteur qui n'est pas à moi"));
console.log(appelSysteme(99, "numéro inconnu"));
try { console.log(instruction("masquerInterruptions")); }
catch (e) { console.log("exception :", e.message); }
console.log("mode final :", machine.mode, "(doit être utilisateur)");
Solution
const machine = {
mode: "utilisateur",
memoire: { 0: "code noyau", 100: "données du processus 1" },
};
const PRIVILEGIEES = ["ecrireTablePages", "masquerInterruptions", "accesControleur"];
function instruction(nom) {
if (PRIVILEGIEES.includes(nom) && machine.mode !== "noyau") {
// Le processeur n'exécute PAS partiellement : il déclenche une
// exception, et la main revient au noyau.
throw new Error("instruction privilégiée « " + nom + " » en mode utilisateur");
}
return nom + " exécutée en mode " + machine.mode;
}
const PROCESSUS = { pid: 1, descripteurs: [0, 1, 2, 7] };
const TABLE_APPELS = {
1: { nom: "ecrire", service: (fd, texte) => {
if (!PROCESSUS.descripteurs.includes(fd)) return "ERREUR: descripteur " + fd + " interdit";
return "écrit sur " + fd + " : " + texte;
} },
2: { nom: "obtenirPid", service: () => "pid = " + PROCESSUS.pid },
3: { nom: "terminer", service: (code) => "processus terminé, code " + code },
};
function appelSysteme(numero, ...args) {
machine.mode = "noyau";
try {
const entree = TABLE_APPELS[numero];
// Le numéro vient de l'application : il n'est PAS digne de confiance.
// Un noyau qui plante sur une entrée invalide est un noyau vulnérable.
if (!entree) return "ERREUR: appel système " + numero + " inconnu";
return entree.service(...args);
} finally {
// Le mode doit être restauré quoi qu'il arrive : une sortie par erreur
// qui laisserait l'application en mode noyau serait une élévation de
// privilèges — la classe de faille la plus grave d'un système.
machine.mode = "utilisateur";
}
}
console.log(appelSysteme(2));
console.log(appelSysteme(1, 1, "bonjour"));
console.log(appelSysteme(1, 42, "sur un descripteur qui n'est pas à moi"));
console.log(appelSysteme(99, "numéro inconnu"));
try { console.log(instruction("masquerInterruptions")); }
catch (e) { console.log("exception :", e.message); }
console.log(instruction("additionner"));
console.log("mode final :", machine.mode, "(doit être utilisateur)");
En travaux pratiques
Travaux pratiques 1 · 2 h
Voir la frontière
Rendre visible la ligne entre le mode utilisateur et le mode noyau, en observant les appels système d'un programme qu'on croit connaître.
Avant de commencer
- Une machine Linux, réelle ou virtuelle
- strace et gcc installés
Énoncé
- Espionner une commande — Lancez strace sur la commande ls et comptez les appels système. Repérez lesquels ouvrent des fichiers, lesquels écrivent, lesquels ne servent qu'à préparer le terrain.
- Le programme le plus simple possible — Écrivez un programme C qui ne fait rien du tout, compilez-le, tracez-le. Combien d'appels système avant votre première ligne de code ? Indice : Vous n'avez pas écrit la première ligne exécutée de votre programme.
- Un affichage — Ajoutez un printf. Comparez la trace, et identifiez l'appel système qui affiche réellement. Combien de printf faut-il pour deux appels write ?
- Sans bibliothèque — Écrivez le même affichage en appelant directement write, puis en déclenchant l'appel système à la main. Comparez les trois tailles d'exécutable.
- Franchir la frontière de force — Écrivez un programme qui tente de lire l'adresse mémoire 0, puis un autre qui tente d'écrire dans le fichier /etc/passwd. Notez les deux messages d'erreur et dites qui les produit.
- Mesurer la frontière — Chronométrez un million d'appels à getpid, puis un million d'appels à une fonction vide que vous écrivez. Le rapport est le prix du passage en mode noyau.
C'est réussi quand
- Vous savez nommer trois appels système exécutés avant votre main
- Vous expliquez pourquoi dix printf ne font pas dix write
- Vous mesurez le coût d'un appel système et vous le comparez à celui du TP 8 d'Architecture
Correction
strace ./vide
execve("./vide", …) = 0 ← le noyau charge le programme
brk(NULL) ← où commence le tas ?
access("/etc/ld.so.preload", R_OK) ← chargeur dynamique
openat(…, "/lib/libc.so.6", …) ← charge la bibliothèque C
mmap(…) ← la projette en mémoire
…
exit_group(0)
environ 25 appels système, zéro écrit par vousUn exécutable dit « dynamique » n'est pas complet : il faut charger la bibliothèque C, la placer en mémoire et résoudre ses symboles avant que main ne démarre. C'est ce que fait le chargeur, et c'est pour cela qu'un binaire statique démarre plus vite mais pèse plus lourd.
for (int i = 0; i < 10; i++) printf("x");
puts("");
strace : UN SEUL write("xxxxxxxxxx\n", 11)printf écrit dans un tampon de la bibliothèque C, vidé quand il est plein, quand on écrit un retour à la ligne sur un terminal, ou à la fin du programme. C'est exactement le mécanisme mesuré au TP 8 d'Architecture. Conséquence pratique : si votre programme plante, les printf non vidés sont PERDUS — d'où la règle de déboguer sur la sortie d'erreur, qui n'est pas tamponnée.
printf("Bonjour\n"); bibliothèque C → 16 Ko liés dynamiquement
write(1, "Bonjour\n", 8); appel POSIX → même taille
syscall(1, 1, "Bonjour\n", 8); appel direct → contourne la libc
statique : 800 Ko | dynamique : 16 KoChaque couche ajoute du confort et du coût : le formatage, le tampon, la portabilité. Descendre d'un cran fait gagner en contrôle et perdre en portabilité — le numéro 1 de write n'est valable que sur Linux x86-64.
*(int*)0 = 42;
→ Segmentation fault (core dumped)
refusé par la MMU, via le noyau : la page 0 n'est pas projetée
fopen("/etc/passwd", "w")
→ EACCES : Permission denied
refusé par le noyau, sur les DROITS du fichierDeux protections indépendantes. La première est matérielle — l'unité de gestion mémoire empêche un processus de toucher ce qui n'est pas à lui, c'est le chapitre 6. La seconde est logicielle — le noyau vérifie l'identité et les permissions, c'est le chapitre 8. Sans le mode noyau, aucune des deux ne serait applicable : un programme pourrait simplement les désactiver.
1 000 000 appels à getpid() : 0,42 s → 420 ns par appel 1 000 000 appels à fonction_vide() : 0,003 s → 3 ns par appel rapport : 140
getpid ne fait que lire un entier — tout son coût est le franchissement de la frontière. C'est ce facteur qui explique l'architecture de tout le système : on regroupe les entrées-sorties, on met en cache côté utilisateur, et on invente des mécanismes comme vDSO pour que des appels très fréquents comme gettimeofday n'aient plus à traverser du tout.
Ce que la suite en fait
Le chapitre 2 prend l'autre porte d'entrée : le shell, qui n'est lui-même qu'une application ordinaire enchaînant des appels système. Le voir sous cet angle évite de le prendre pour une partie du système.
Puis le bloc II ouvre le vrai sujet. Le processus du chapitre 3 est l'objet que le mode
utilisateur protège ; fork et exec y sont les appels système les plus caractéristiques du
modèle Unix ; et l'ordonnancement du chapitre 4 repose entièrement sur l'interruption d'horloge
qui, seule, permet au noyau de reprendre la main sans la demander.
À retenir
Flashcards · 4 cartes
- Quelles sont les trois fonctions d'un système d'exploitation ?
- ABSTRAIRE : transformer des dispositifs hétérogènes en objets uniformes (fichier, processus), ce qui rend les programmes portables. GÉRER LES RESSOURCES : arbitrer entre programmes concurrents l'accès au processeur, à la mémoire, au disque — c'est l'essentiel du cours. PROTÉGER : empêcher un programme de nuire aux autres, ce qui exige un appui matériel et ne peut pas être purement logiciel.
- Qu'oppose le noyau monolithique au micro-noyau, et où en est-on aujourd'hui ?
- Le monolithique met tout dans le noyau (pilotes, fichiers, réseau) : communication par appel de fonction, donc rapide, mais un défaut de pilote corrompt le noyau entier. Le micro-noyau ne garde que le minimum et sort le reste en services utilisateur : un pilote qui tombe est isolé et relançable, au prix d'échanges par messages. Aucune école n'a gagné : Linux est monolithique mais modulaire, Windows et macOS sont hybrides, QNX reste à micro-noyau.
- Qu'est-ce que le bit de mode, et quelles instructions sont privilégiées ?
- Un bit du processeur qui distingue le mode NOYAU, où tout est permis, du mode UTILISATEUR, où certaines instructions sont refusées : modifier la table des pages, masquer les interruptions, accéder aux registres d'un contrôleur, changer soi-même le bit de mode. Une tentative depuis le mode utilisateur ne s'exécute pas partiellement : elle déclenche une exception et rend la main au noyau. C'est l'appui matériel sans lequel la protection n'existerait pas.
- Décrivez le mécanisme d'un appel système et ce qui le distingue d'un appel de fonction.
- L'application place un numéro et des arguments dans des registres, puis exécute une instruction spéciale qui déclenche une interruption logicielle. Le matériel bascule en mode noyau et saute à une adresse FIXE choisie par le noyau ; celui-ci valide, exécute, puis restaure le mode utilisateur. Différence essentielle : un appel de fonction saute où l'appelant veut et ne change pas de mode — l'appel système impose un point d'entrée unique et contrôlé. Il coûte des centaines à des milliers de cycles, d'où l'usage de tampons.