Prise en main du shellDans le dialogue d’impression, choisissez « Enregistrer au format PDF » comme destination.
Retour

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