C1 — GénéralitésDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

Licence 1 · Systèmes d'exploitation

Cours 1Généralités

Situer le système entre le matériel et les programmes, et savoir lui parler par le shell avant d'apprendre à le programmer.

2 chapitres · 8 h de travail estimé

  1. 1. Rôle et structure d'un système4 h
  2. 2. Prise en main du shell4 h

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ègesmê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 fonctionisolation contre coût des messages
  • Le micro-noyau corrige automatiquement l'erreur, ce qui le rend plus lent mais infailliblecorrection 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 applicationslangage 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çonadresse choisie et bit de mode
  • Parce que le noyau se trouve sur un autre processeur que les applicationsprocesseur 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é

  1. Espionner une commandeLancez 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.
  2. 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.
  3. Un affichageAjoutez un printf. Comparez la trace, et identifiez l'appel système qui affiche réellement. Combien de printf faut-il pour deux appels write ?
  4. 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.
  5. 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.
  6. Mesurer la frontièreChronomé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

Ce qui précède votre code
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 vous

Un 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.

printf n'est pas un appel système
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.

Les trois façons d'écrire une ligne
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 Ko

Chaque 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.

Les deux refus, et qui les prononce
*(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 fichier

Deux 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.

Le prix du passage
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.

Chapitre 2 · 4 h

Prise en main du shell

Arborescence et commandes de base, redirections et tubes, variables d'environnement, premiers scripts.

En 1986, une revue demande à Donald Knuth d'illustrer la programmation lettrée. L'exercice : lire un texte, trouver les nn mots les plus fréquents, les afficher triés. Knuth rend un programme en Pascal d'une dizaine de pages, avec sa propre structure de données de hachage. Douglas McIlroy — l'inventeur des tubes Unix — est chargé de la critique. Il répond par six commandes :

tr -cs A-Za-z '\n' | tr A-Z a-z | sort | uniq -c | sort -rn | head -n 10

L'anecdote n'est pas là pour humilier Knuth, dont le propos était autre. Elle dit ce qu'est un système Unix : non pas un gros programme qui sait tout faire, mais une collection de petits outils qui font une chose, et un moyen de les brancher les uns aux autres.

Ce chapitre est placé avant toute théorie parce que les TP des quatre blocs suivants se font sous Linux. On ne programme pas un système dans lequel on ne sait pas se déplacer.

Une seule arborescence

Contrairement aux systèmes à lettres de lecteur, Unix n'a qu'un seul arbre, dont la racine est /. Un second disque, une clé USB, un partage réseau : tous apparaissent comme un répertoire quelque part dans cet arbre, opération appelée montage et étudiée au chapitre 7.

RépertoireContenu
/bin, /usr/binles exécutables
/etcla configuration, en fichiers texte
/home/nomle répertoire personnel d'un utilisateur
/tmples fichiers temporaires, effacés au redémarrage
/devles périphériques, vus comme des fichiers
/procl'état du noyau, vu comme des fichiers

Les deux dernières lignes méritent qu'on s'y arrête, parce qu'elles illustrent le principe « tout est fichier ». Le disque est accessible par /dev/sda, la carte son par /dev/snd, et lire /proc/cpuinfo affiche les caractéristiques du processeur — alors qu'aucun de ces fichiers n'existe sur un disque. Ce sont des vues, produites par le noyau au moment de la lecture. L'intérêt est immense : les mêmes commandes, les mêmes appels système et les mêmes droits s'appliquent aux fichiers réels et aux périphériques.

Un chemin absolu part de la racine ; un chemin relatif part du répertoire courant. Trois raccourcis servent constamment : . désigne le répertoire courant, .. son parent, ~ le répertoire personnel.

Les commandes qu'il faut avoir dans les doigts

CommandeRôle
pwd, cd, ls -loù suis-je, aller, lister avec les droits
cat, less, head, tailafficher en entier, page à page, le début, la fin
cp, mv, rm, mkdircopier, déplacer ou renommer, supprimer, créer
grep motif fichierne garder que les lignes contenant le motif
wc -lcompter les lignes
sort, uniq -ctrier, compter les doublons consécutifs
find . -name "*.c"chercher des fichiers dans une arborescence
ps, kill, toplister les processus, en tuer un, les observer
chmod, chownchanger droits et propriétaire (chapitre 7)
man commandele manuel, à consulter avant de chercher ailleurs

Deux remarques d'usage. rm ne demande pas confirmation et il n'existe pas de corbeille : la suppression est immédiate et définitive. Et uniq ne supprime que les doublons consécutifs, d'où le sort qui le précède toujours dans la ligne de McIlroy.

Redirections et tubes

C'est le cœur du chapitre, et il se comprend beaucoup mieux avec le chapitre 1 en tête.

Tout processus démarre avec trois descripteurs de fichier ouverts, désignés par un numéro — exactement les descripteurs que l'exercice du chapitre 1 validait :

0  entrée standard    (stdin)   par défaut : le clavier1  sortie standard    (stdout)  par défaut : l'écran2  sortie d'erreur    (stderr)  par défaut : l'écran aussi

Un programme bien écrit ne sait pas d'où viennent ses données ni où vont ses résultats : il lit 0 et écrit 1. C'est le shell qui décide à quoi ces numéros sont raccordés, avant de lancer le programme.

commande > fichier      la sortie va dans le fichier (écrase)commande >> fichier     la sortie s'ajoute à la fincommande < fichier      l'entrée vient du fichiercommande 2> erreurs     seules les erreurs vont dans le fichiercommande > tout 2>&1    les erreurs suivent la sortie

La séparation de 1 et de 2 n'est pas un détail : elle permet de rediriger un résultat dans un fichier tout en continuant à voir les messages d'erreur à l'écran. Un programme qui écrit ses erreurs sur la sortie standard les mélange aux données, et pollue le fichier produit.

Le tube (|) va plus loin : il branche la sortie standard d'une commande directement sur l'entrée standard de la suivante, sans fichier intermédiaire. Les deux processus s'exécutent en même temps, et le noyau régule leur allure — si le lecteur est plus lent, l'écrivain est suspendu jusqu'à ce que le tampon se vide. C'est déjà du producteur-consommateur, celui du chapitre 5, réalisé par le système.

D'où la relecture de la ligne de McIlroy :

tr -cs A-Za-z '\n'   remplace tout ce qui n'est pas une lettre par un saut de lignetr A-Z a-z           passe en minusculessort                 regroupe les mots identiques (uniq exige des doublons voisins)uniq -c              compte chaque groupesort -rn             trie par nombre décroissanthead -n 10           garde les dix premiers

Six outils qui ignorent tout les uns des autres, assemblés en une phrase.

Quiz · 1 question

Quelle est la différence entre commande > sortie.txt et commande | autre ?

  • Aucune : les deux redirigent la sortie standard, l'un vers un fichier, l'autre vers une commandeéquivalents
  • La redirection écrit dans un fichier, que la suite lira éventuellement plus tard ; le tube branche la sortie standard du premier processus sur l'entrée standard du second, qui s'exécutent SIMULTANÉMENT sans fichier intermédiairefichier contre communication directe
  • Le tube est une redirection plus rapide, mais il crée aussi un fichier, dans /tmpfichier temporaire caché

Réponse : Le tube ne passe par aucun fichier. Le noyau crée un tampon en mémoire, raccorde le descripteur 1 du premier processus à son entrée et le descripteur 0 du second à sa sortie, puis lance LES DEUX en même temps. Trois conséquences pratiques : aucune écriture disque, donc c'est bien plus rapide ; la consommation mémoire reste bornée quelle que soit la quantité de données, puisque le tampon est petit et régulé — si le lecteur est plus lent, l'écrivain est suspendu, ce qui est déjà un producteur-consommateur au sens du chapitre 5 ; et un tube fonctionne sur un flux infini, ce qu'un fichier intermédiaire ne permettrait pas.

L'environnement, et comment une commande est trouvée

Chaque processus porte un ensemble de variables appelé son environnement, hérité de son parent. Le shell les affiche par env et en définit par export.

Les plus utiles sont HOME, le répertoire personnel, PWD, le répertoire courant, USER, LANG — et surtout PATH, qui mérite une explication détaillée parce qu'elle éclaire ce que fait le shell.

Quand vous tapez ls, le shell ne cherche pas partout. Il parcourt dans l'ordre les répertoires listés dans PATH, séparés par des deux-points, et prend le premier exécutable portant ce nom. D'où trois conséquences quotidiennes : une commande installée dans un répertoire absent de PATH reste introuvable alors que le fichier existe ; l'ordre décide laquelle de deux versions homonymes l'emporte ; et un programme du répertoire courant ne s'exécute que par ./programme, parce que . ne figure volontairement pas dans PATH — sans quoi un fichier nommé ls déposé dans un répertoire partagé s'exécuterait à la place de la vraie commande.

Une distinction revient sans cesse en TP : variable=valeur ne crée la variable que pour le shell courant, export variable=valeur la transmet aussi aux processus enfants. Le chapitre 3 expliquera pourquoi — l'environnement est copié au moment du fork, donc ce qui n'est pas exporté n'est pas dans la copie.

Premiers scripts

Un script shell est un fichier de commandes, avec quelques constructions de programmation.

#!/bin/bash# Compte les fichiers .c d'un répertoire donné en argument. repertoire=${1:-.}          # premier argument, ou "." s'il manque if [ ! -d "$repertoire" ]; then    echo "erreur : $repertoire n'est pas un répertoire" >&2    exit 1fi n=$(find "$repertoire" -name "*.c" | wc -l)echo "$n fichiers C dans $repertoire" for f in "$repertoire"/*.c; do    echo "  $(wc -l < "$f") lignes  $f"done

Quatre points à retenir de ce squelette. La première ligne, le shebang, dit au noyau quel interpréteur lancer — c'est lui qui permet d'exécuter le script directement. Les arguments s'appellent $1, $2, et $0 est le nom du script. Le code de retour compte : 0 pour succès, autre chose pour un échec, et c'est cette valeur que le shell teste dans un if ou après un &&. Enfin, les guillemets autour des variables ne sont pas facultatifs : sans eux, un nom de fichier contenant une espace est découpé en deux arguments, et c'est la source d'erreur numéro un des scripts débutants.

Quiz · 1 question

Un étudiant compile un programme dans son répertoire courant, tape monprog et obtient « commande introuvable », alors que le fichier est bien là et exécutable. Pourquoi, et pourquoi ce comportement est-il volontaire ?

  • Le fichier n'a pas les droits d'exécution : il faut un chmod +xdroits manquants
  • Le répertoire courant ne figure pas dans PATH ; il faut écrire ./monprog. C'est délibéré : sinon un fichier piégé nommé ls déposé dans un répertoire partagé s'exécuterait à la place de la vraie commandePATH sans le point
  • Il faut redémarrer le shell pour qu'il détecte les nouveaux exécutablescache du shell

Réponse : Le shell ne cherche un exécutable que dans les répertoires listés par PATH, et le point n'y figure pas sur un système correctement configuré. Écrire ./monprog donne un chemin explicite, ce qui court-circuite la recherche. L'omission est un choix de sécurité : si le répertoire courant était dans PATH, il suffirait de déposer un fichier nommé ls ou cd dans un répertoire partagé, ou dans /tmp, pour que le premier utilisateur qui s'y déplace et tape la commande exécute le programme de l'attaquant avec SES droits. Le placer en fin de PATH réduit le risque sans le supprimer, puisque restent les fautes de frappe — d'où l'usage de ne pas l'y mettre du tout.

À vous

L'exercice reconstruit le tube. Vous disposez d'une poignée de commandes écrites comme des fonctions qui prennent un tableau de lignes et en rendent un autre — c'est exactement le contrat d'un outil Unix — et vous devez écrire l'évaluateur qui les enchaîne.

L'objectif final est de faire tourner la ligne de McIlroy sur un texte, et d'obtenir les mots les plus fréquents. Deux commandes manquent volontairement, uniq -c et sort -rn : ce sont celles qui font comprendre pourquoi sort doit précéder uniq.

Exercice de code

Écrivez uniq -c et sort -rn, puis faites tourner la ligne de McIlroy.

Point de départ

// Chaque commande : (lignes, arguments) -> lignes. C'est tout le contrat.
const COMMANDES = {
  // Découpe sur tout ce qui n'est pas une lettre : un mot par ligne.
  mots:   (l) => l.join(" ").split(/[^A-Za-zÀ-ÿ]+/).filter(Boolean),
  minus:  (l) => l.map((x) => x.toLowerCase()),
  sort:   (l) => [...l].sort(),
  head:   (l, n) => l.slice(0, Number(n)),
  grep:   (l, motif) => l.filter((x) => x.includes(motif)),
  wc:     (l) => [String(l.length)],

  // uniq -c : compte les répétitions CONSÉCUTIVES et préfixe le compte.
  uniqc:  (l) => {
    return l;   // ← à écrire
  },

  // sort -rn : trie par le nombre placé en tête de ligne, décroissant.
  sortrn: (l) => {
    return l;   // ← à écrire
  },
};

// L'évaluateur : "mots | minus | sort" appliqué à un texte.
function tube(entree, ligneDeCommande) {
  const etapes = ligneDeCommande.split("|").map((s) => s.trim().split(/\s+/));
  let flux = entree;
  for (const [nom, ...args] of etapes) {
    if (!COMMANDES[nom]) throw new Error("commande inconnue : " + nom);
    flux = COMMANDES[nom](flux, ...args);
  }
  return flux;
}

// ── À VOUS ────────────────────────────────────────────────────────────────
// 1. Écrivez uniqc et sortrn.
// 2. Expliquez pourquoi « mots | minus | uniqc » sans sort donne un résultat
//    faux, et vérifiez-le en lançant les deux versions.

const TEXTE = [
  "Le système gère la mémoire et le processeur",
  "le processeur exécute le programme que le système a chargé",
  "la mémoire contient le programme et ses données",
];

console.log("— sans sort —");
console.log(tube(TEXTE, "mots | minus | uniqc | sortrn | head 5").join("\n"));

console.log("— avec sort, la ligne de McIlroy —");
console.log(tube(TEXTE, "mots | minus | sort | uniqc | sortrn | head 5").join("\n"));

Solution

const COMMANDES = {
  mots:   (l) => l.join(" ").split(/[^A-Za-zÀ-ÿ]+/).filter(Boolean),
  minus:  (l) => l.map((x) => x.toLowerCase()),
  sort:   (l) => [...l].sort(),
  head:   (l, n) => l.slice(0, Number(n)),
  grep:   (l, motif) => l.filter((x) => x.includes(motif)),
  wc:     (l) => [String(l.length)],

  // uniq ne regarde QUE la ligne précédente : c'est ce qui le rend utilisable
  // sur un flux infini, et c'est aussi ce qui impose un sort en amont.
  uniqc: (l) => {
    const sortie = [];
    for (const ligne of l) {
      const dernier = sortie[sortie.length - 1];
      if (dernier && dernier.mot === ligne) dernier.n++;
      else sortie.push({ mot: ligne, n: 1 });
    }
    return sortie.map((e) => String(e.n).padStart(4) + " " + e.mot);
  },

  sortrn: (l) =>
    [...l].sort((a, b) => parseInt(b, 10) - parseInt(a, 10)),
};

function tube(entree, ligneDeCommande) {
  const etapes = ligneDeCommande.split("|").map((s) => s.trim().split(/\s+/));
  let flux = entree;
  for (const [nom, ...args] of etapes) {
    if (!COMMANDES[nom]) throw new Error("commande inconnue : " + nom);
    flux = COMMANDES[nom](flux, ...args);
  }
  return flux;
}

const TEXTE = [
  "Le système gère la mémoire et le processeur",
  "le processeur exécute le programme que le système a chargé",
  "la mémoire contient le programme et ses données",
];

console.log("— sans sort : « le » est compté plusieurs fois —");
console.log(tube(TEXTE, "mots | minus | uniqc | sortrn | head 5").join("\n"));

console.log("");
console.log("— avec sort, la ligne de McIlroy —");
console.log(tube(TEXTE, "mots | minus | sort | uniqc | sortrn | head 5").join("\n"));

console.log("");
console.log("— et parce que c'est gratuit : combien de mots distincts ? —");
console.log(tube(TEXTE, "mots | minus | sort | uniqc | wc").join("\n"));

// La leçon : uniq est volontairement myope. Il ne mémorise qu'une ligne, ce
// qui lui permet de traiter un flux plus grand que la mémoire — un tri
// complet, lui, doit tout garder. Le pipeline paie ce tri une fois, en amont.

En travaux pratiques

Travaux pratiques 2 · 3 h

Une chaîne de traitement, sans écrire de programme

Traiter un vrai fichier de journaux uniquement en composant des commandes, et comprendre en quoi la redirection est une opération du système et non du langage.

Avant de commencer

  • Le TP 1
  • Un fichier de journaux d'au moins 100 000 lignes — le vôtre, ou un généré

Énoncé

  1. Explorer sans tout lireSans ouvrir le fichier dans un éditeur, donnez son nombre de lignes, ses dix premières, ses dix dernières, et sa taille en octets.
  2. Les dix adresses les plus activesÉcrivez une seule ligne de commandes qui extrait le premier champ, compte les occurrences et affiche les dix premières. Chronométrez-la. Indice : Compter des doublons demande de les rendre voisins d'abord.
  3. Filtrer et compterComptez les réponses en erreur, puis la part qu'elles représentent, puis leur répartition par heure. Une ligne par question.
  4. Les trois fluxLancez une commande qui échoue sur la moitié de ses arguments. Redirigez la sortie normale vers un fichier et les erreurs vers un autre, puis les deux vers le même. Vérifiez l'ordre des lignes obtenues.
  5. Un script réutilisableTransformez votre chaîne en script prenant le fichier en argument, avec une valeur par défaut, un contrôle des arguments et un code de retour correct.
  6. Les droitsRendez le script exécutable pour vous seul. Testez-le sous un autre utilisateur. Puis expliquez pourquoi chmod 744 sur un RÉPERTOIRE ne fait pas ce qu'on croit.
  7. Ce que le shell fait avant de lancerCréez des fichiers nommés a.txt, b.txt, puis lancez une commande avec l'argument étoile point txt entre guillemets et sans guillemets. Expliquez la différence avec echo.

C'est réussi quand

  • Votre chaîne des dix adresses tient sur une ligne et tourne en moins de deux secondes
  • Votre script renvoie un code non nul quand le fichier n'existe pas
  • Vous savez dire quel programme développe l'étoile, et quand

Correction

La chaîne, et pourquoi le tri d'abord
cut -d' ' -f1 acces.log | sort | uniq -c | sort -rn | head -10

cut    extrait le champ
sort   rend les doublons VOISINS
uniq -c compte les répétitions consécutives — d'où le sort obligatoire
sort -rn trie par nombre décroissant

uniq ne connaît que la ligne précédente : sans le premier tri, il ne compterait que les répétitions déjà adjacentes. C'est le piège classique, et il illustre la philosophie de l'ensemble — chaque outil fait UNE chose, avec le moins d'état possible, et la composition fait le reste.

Les trois questions
# nombre d'erreurs
awk '$9 >= 500' acces.log | wc -l

# proportion
awk '$9 >= 500 {e++} END {printf "%.2f %%\n", 100*e/NR}' acces.log

# répartition horaire
awk '$9 >= 500' acces.log | cut -d: -f2 | sort | uniq -c

La chaîne se lit de gauche à droite comme la phrase qui décrit le traitement. Rien n'est chargé en mémoire : chaque programme lit un flux et en écrit un autre, ce qui permet de traiter un fichier plus gros que la mémoire. C'est aussi pour cela que la chaîne tourne en parallèle — les processus s'exécutent simultanément, chacun consommant ce que le précédent produit.

Les trois flux, et l'ordre trompeur
commande > sortie.txt 2> erreurs.txt      # séparés
commande > tout.txt 2>&1                 # fusionnés
commande 2>&1 > tout.txt                 # PAS la même chose !

/* dans le second cas, 2>&1 est évalué AVANT la redirection de 1 :
 les erreurs partent vers le terminal, pas vers le fichier */

Les redirections s'appliquent de gauche à droite, et 2>&1 signifie « copie la destination ACTUELLE de 1 », pas « suis 1 partout ». La seconde forme est l'un des pièges les plus coûteux du shell, parce qu'elle échoue silencieusement. Autre conséquence : la sortie normale étant tamponnée et l'erreur non, les lignes fusionnées peuvent arriver dans le désordre.

Le scripttop-adresses.sh
#!/bin/sh
set -eu                       # échoue à la première erreur

fichier="${1:-acces.log}"

if [ ! -r "$fichier" ]; then
  echo "fichier illisible : $fichier" >&2   # message sur STDERR
  exit 1                                     # code non nul
fi

cut -d' ' -f1 "$fichier" | sort | uniq -c | sort -rn | head -10

Trois habitudes à prendre dès maintenant : set -eu, qui arrête à la première erreur et refuse les variables non définies ; les messages d'erreur sur la sortie d'erreur, pour qu'ils ne polluent pas une chaîne ; et un code de retour non nul, sans lequel aucun script appelant ne peut savoir que ça a échoué.

chmod 744 sur un répertoire
fichier   : r = lire le contenu | w = modifier | x = exécuter
répertoire: r = LISTER les noms | w = créer/supprimer | x = TRAVERSER

chmod 744 dossier
→ les autres peuvent faire ls dossier  (r)
→ mais PAS cd dossier ni ouvrir dossier/fichier  (pas de x)
→ ls -l dossier affiche des « ? » partout

Sur un répertoire, x n'est pas « exécuter » mais « traverser », et c'est le droit le plus important des trois. D'où la combinaison utile 711 : les autres peuvent accéder à un fichier dont ils connaissent le nom, sans pouvoir lister le contenu. C'est ainsi que sont protégés les répertoires personnels sur beaucoup de serveurs.

Qui développe l'étoile
echo *.txt      → a.txt b.txt     le SHELL a remplacé avant de lancer echo
echo "*.txt"    → *.txt           les guillemets ont empêché le développement

/* echo n'a jamais vu d'étoile dans le premier cas :
 il a reçu deux arguments déjà développés */

Le développement est fait par le shell, AVANT l'exécution — le programme appelé ne sait même pas qu'une étoile a été écrite. C'est la clé de la moitié des surprises du shell, y compris le cas où le motif ne correspond à rien et où le programme reçoit l'étoile littérale. Corollaire immédiat : toute variable qui peut contenir un espace se met entre guillemets.

Ce que la suite en fait

Tout ce que le shell fait, il le fait par appels système, et le chapitre 3 va les nommer. Lancer une commande, c'est fork puis exec ; attendre sa fin, c'est wait ; le code de retour testé par un if est l'argument passé à exit. Le shell n'a aucun privilège particulier : c'est un programme ordinaire, et vous pourriez en écrire un.

Le tube, lui, revient deux fois. Au chapitre 3 comme moyen de communication entre processus, et au chapitre 5 comme cas d'école du producteur-consommateur — avec son tampon borné, son écrivain suspendu quand c'est plein et son lecteur suspendu quand c'est vide. Vous l'aurez utilisé cent fois avant de le programmer.

À retenir

Flashcards · 4 cartes

Que sont les descripteurs 0, 1 et 2, et pourquoi séparer 1 de 2 ?
Tout processus démarre avec l'entrée standard (0), la sortie standard (1) et la sortie d'erreur (2). Un programme bien écrit ignore d'où viennent ses données et où vont ses résultats : il lit 0 et écrit 1, et c'est le shell qui raccorde ces numéros avant de le lancer. Séparer 2 permet de rediriger un résultat dans un fichier tout en voyant les erreurs à l'écran — mélanger les deux pollue le fichier produit.
Qu'est-ce qu'un tube, et en quoi diffère-t-il d'une redirection vers un fichier ?
Le tube branche la sortie standard d'un processus sur l'entrée standard du suivant, via un tampon en mémoire, sans aucun fichier. Les deux processus s'exécutent SIMULTANÉMENT et le noyau régule leur allure : si le lecteur est plus lent, l'écrivain est suspendu. D'où trois avantages : pas d'écriture disque, mémoire bornée quelle que soit la quantité de données, et fonctionnement sur un flux infini.
Comment le shell trouve-t-il la commande que vous tapez, et pourquoi faut-il écrire ./monprog ?
Il parcourt DANS L'ORDRE les répertoires listés dans PATH et prend le premier exécutable de ce nom. Le répertoire courant n'y figure volontairement pas : sinon un fichier piégé nommé ls déposé dans un répertoire partagé s'exécuterait à la place de la vraie commande, avec les droits de celui qui la tape. Écrire ./monprog donne un chemin explicite et court-circuite la recherche.
Quelles sont les quatre choses à savoir sur un script shell ?
Le SHEBANG en première ligne (#!/bin/bash) dit au noyau quel interpréteur lancer. Les ARGUMENTS sont $1, $2… et $0 est le nom du script. Le CODE DE RETOUR vaut 0 pour un succès et autre chose pour un échec ; c'est lui que testent if et &&. Et les GUILLEMETS autour des variables ne sont pas facultatifs : sans eux, un nom de fichier contenant une espace est découpé en plusieurs arguments.