Systèmes d'exploitation · C1 Généralités · 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 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 10L'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épertoire | Contenu |
|---|---|
/bin, /usr/bin | les exécutables |
/etc | la configuration, en fichiers texte |
/home/nom | le répertoire personnel d'un utilisateur |
/tmp | les fichiers temporaires, effacés au redémarrage |
/dev | les périphériques, vus comme des fichiers |
/proc | l'é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
| Commande | Rôle |
|---|---|
pwd, cd, ls -l | où suis-je, aller, lister avec les droits |
cat, less, head, tail | afficher en entier, page à page, le début, la fin |
cp, mv, rm, mkdir | copier, déplacer ou renommer, supprimer, créer |
grep motif fichier | ne garder que les lignes contenant le motif |
wc -l | compter les lignes |
sort, uniq -c | trier, compter les doublons consécutifs |
find . -name "*.c" | chercher des fichiers dans une arborescence |
ps, kill, top | lister les processus, en tuer un, les observer |
chmod, chown | changer droits et propriétaire (chapitre 7) |
man commande | le 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 aussiUn 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 sortieLa 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 premiersSix 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édiaire — fichier contre communication directe
- Le tube est une redirection plus rapide, mais il crée aussi un fichier, dans /tmp — fichier 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"doneQuatre 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 +x — droits 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 commande — PATH sans le point
- Il faut redémarrer le shell pour qu'il détecte les nouveaux exécutables — cache 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é
- Explorer sans tout lire — Sans ouvrir le fichier dans un éditeur, donnez son nombre de lignes, ses dix premières, ses dix dernières, et sa taille en octets.
- 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.
- Filtrer et compter — Comptez les réponses en erreur, puis la part qu'elles représentent, puis leur répartition par heure. Une ligne par question.
- Les trois flux — Lancez 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.
- Un script réutilisable — Transformez 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.
- Les droits — Rendez 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.
- Ce que le shell fait avant de lancer — Cré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
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.
# 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 -cLa 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.
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.
#!/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 -10Trois 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é.
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.
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.