Introduction

Outre des compétences théoriques qui sont abordées dans d’autres parties de ce document, la maîtrise d’une système informatique implique également une excellente connaissance des outils informatiques qui sont inclus dans ce système. Cette connaissance se construit bien entendu en utilisant activement ces logiciels. Cette section comprend les informations de bases sur quelques logiciels importants qui sont utilisés dans le cadre du cours. Elle est volontairement réduite car de nombreux logiciels pourraient faire l’objet de livres complets. Les étudiants sont invités à proposer des améliorations à cette section sous la forme de pull-requests via https://github.com/obonaventure/SyllabusC.

Éditeurs

De nombreux éditeurs sont utilisables pour manipuler efficacement du code source en langage C. Chaque étudiant doit choisir l’éditeur qui lui convient le mieux. En voici quelques uns :

vi(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/vi.1.html] est un des premiers éditeurs à avoir été écrit pour Unix. Il a fortement évolué, et reste un outil de choix pour de nombreux administrateurs systèmes. De nombreux tutoriels permettent d’apprendre rapidement vi(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/vi.1.html], dont https://developer.ibm.com/tutorials/l-vi/

emacs [https://www.gnu.org/software/emacs/] est un autre éditeur fréquemment utilisé sous Unix. Il existe de très nombreuses extensions à emacs qui lui permettent de faire tout ou presque, y compris de jouer à des jeux comme Tetris. Son extensibilité peut rebuter certains utilisateurs. De nombreux tutoriels sont disponibles sur Internet, dont https://www.gnu.org/software/emacs/tour/

gedit [https://doc.ubuntu-fr.org/gedit] est l’éditeur de base dans l’environnement graphique GNOME [https://www.gnome.org] utilisé dans les distributions Linux.

Sublime Text [https://www.sublimetext.com/] est un éditeur de texte léger et facile d’utilisation. La version complète est payante, mais il est possible d’utiliser la version gratuite à volonté. Cet éditeur de texte est un bon choix pour les débutants qui n’auraient jamais touché à la programmation.

Atom [https://atom.io/] est un éditeur de texte facile d’utilisation et proposant de nombreuses extensions. Il a été développé par l’équipe de GitHub, et profite donc d’une interface simple pour le version control des projets en utilisant Git (voir la section consacrée à Git).

Visual Studio Code [https://code.visualstudio.com/] est un éditeur de texte proposant également de nombreuses extensions, et régulièrement utilisé dans l’entreprise. Il permet, entre autres, de profiter d’un terminal de commandes sur la même fenêtre que l’éditeur de texte.

eclipse [https://www.eclipse.org] est un environnement complet de développement écrit en Java pour supporter initialement ce langage. De nombreuses extensions à eclipse [https://www.eclipse.org] existent, dont CDT [https://www.eclipse.org/cdt/] qui permet la manipulation efficace de code source en langages C et C++.

Pour les débutants, les éditeurs conseillés sont Sublime Text pour sa simplicité, Atom pour son intégration de Git (qui sera largement utilisé lors du projet), ou Visual Studio Code pour sa popularité et son accès direct à un terminal.

Gestionnaires de code partagé

Dans de nombreux projets informatiques, il est nécessaire d’utiliser des outils qui permettent d’organiser efficacement le partage du code source entre plusieurs développeurs. On parle de version control. Les plus anciens gestionnaires de code sont cvs [http://cvs.nongnu.org/] ou rcs [https://www.gnu.org/software/rcs/]. Ces logiciels ont été créés lorsque Unix était utilisé sur des mini-ordinateurs qui servaient tout un département. Aujourd’hui, les logiciels de gestion de code source s’utilisent en combinaison avec des serveurs Internet pour permettre un partage large du code source. Les plus connus sont :

Git [https://git-scm.com/] qui est décrit dans ce syllabus. Il s’agit de l’outil de version control le plus utilisé, et donc également celui que vous utiliserez. Il est aussi décrit dans plein d’autres ressources comme git-scm [https://git-scm.com/] contenant le livre Pro Git, une vidéo [https://www.youtube.com/watch?v=ZDR433b0HJY] faite par le créateur de ce sites, ou encore la documentation de GitHub [https://docs.github.com/en/get-started/quickstart].

subversion [https://subversion.apache.org/] qui est décrit dans ce syllabus également.

mercurial [https://www.mercurial-scm.org/]

bazaar [https://bazaar.canonical.com/en/]

Compilateurs

Le compilateur C utilisé dans de nombreuses distributions Linux est gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]. C’est un compilateur open-source développé activement dans le cadre du projet GNU par la Free Software Foundation [https://www.fsf.org]. Nous utiliserons principalement gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] dans le cadre de ce cours.

Il existe des alternatives à gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] comme llvm [https://llvm.org] que nous utiliserons lorsque nous analyserons le code assembleur généré par un compilateur C. Les variantes commerciales de Unix utilisent généralement des compilateurs propriétaires, dont par exemple Oracle Studio [https://www.oracle.com/application-development/technologies/developerstudio.html] ou la suite de compilateurs [https://software.intel.com/content/www/us/en/develop/tools/oneapi/components/dpc-compiler.html] développée par intel [https://www.intel.fr/content/www/fr/fr/homepage.html].

Disposer d’une machine Linux

Pour les cours LEPL1503 et LINFO1252, chaque étudiant doit avoir accès à une machine tournant sous un système d’exploitation Linux. Ceci est nécessaire pour les exercices et projets de ces cours, qui utilisent le langage C, et savoir utiliser une machine Linux, notamment en ligne de commande, est un acquis d’apprentissage. Ce document va donc détailler 4 manières d’avoir accès à une machine Linux, chacune avec un niveau différent de compromis entre facilité d’installation et confort d’utilisation. Ces 4 possibilités sont les suivantes, depuis la plus simple d’installation (mais la moins confortable d’utilisation) à la plus complexe:

Accéder à l’image d’une machine des salles informatiques de l’université depuis le navigateur.

Utiliser Windows Subsystem for Linux

Installer une machine virtuelle Linux

Installer un dual-boot Linux

Ces solutions sont toutes possibles pour un ordinateur utilisant Windows, mais seules les première et troisième sont disponibles pour MacOS (du moins, de manière simple).

Accéder à UDS depuis le navigateur

UDS est un service en ligne proposé par l’université, qui permet d’accéder, grâce à une connexion internet, à une machine équivalente à celles des machines Linux des salles informatiques, depuis son navigateur. Cette solution a l’avantage de ne nécessiter aucune installation, mais elle nécessite une bonne connexion Internet pour tourner de manière stable et fluide. Elle est donc conseillée pour les étudiants ayant une bonne connexion Internet. Pour les autres, les solutions suivantes sont plus indiquées.

Pour accéder à UDS, commençons par se rendre sur l’URL https://uds.siws.ucl.ac.be. Connectez-vous en indiquant votre adresse email UCLouvain, et votre mot de passe:

Page de connexion d’UDS

Ensuite, sélectionnez la machine Didac-ingi (pas Didac-Générique !):

Choix de la machine UDS

Connectez-vous à la machine en indiquant votre identifiant UCLouvain (pas l’adresse email !) et votre mot de passe:

Connexion à la machine UDS

Vous avez désormais accès à une machine Linux (pour être plus précis, Fedora) !

Machine Linux (Fedora) depuis le navigateur

Note

Sauvegarder des fichiers sur UDS

Lorsqu’on utilise UDS, la machine est réinitialisée entre chaque connexion. Cela signifie qu’à chaque connexion, la machine est remise à son état initial, et tous les fichiers créés sont supprimés. Pour sauvegarder les fichiers, il faut donc les enregistrer sur son espace personnel UCLouvain. Cet espace est disponible depuis toutes les machines de l’UCLouvain, et son contenu est sauvegardé entre chaque connexion. Il se trouve à l’emplacement /home/user/oasis sur UDS.

Dossier oasis sur la machine UDS

Utiliser Windows Subsystem for Linux

Sur les ordinateurs Windows, il existe un moyen d’obtenir un terminal Linux léger, sur lequel on peut exécuter des commandes bash comme sur une machine Linux classique. Ce terminal, qui s’appelle Windows Subsystem for Linux (WSL), ne propose cependant pas d’interface graphique, on ne peut donc interagir qu’en ligne de commande. Cela peut rebuter certains utilisateurs, mais cela permet d’avoir un système très léger et donc très rapide, et dont l’installation est très facile. De plus, cette solution ne fonctionne que sur les machines Windows. Les instructions d’installation mentionnées ci-après proviennent de https://lecrabeinfo.net/installer-wsl-windows-subsystem-for-linux-sur-windows-10.html

Pour installer WSL, il faut d’abord activer la fonctionnalité dans les paramètres Windows. Pour ce faire, commencez par ouvrir l’outil Fonctionnalités de Windows, depuis Paramètres > Applications > Applications et fonctionnalités > Fonctionnalités facultatives > Plus de fonctionnalités Windows. Cochez la case « Sous-système Windows pour Linux » puis cliquez sur OK. Vous devrez redémarrer pour finaliser l’installation de la fonctionnalité.

Il est également recommandé de définir WSL 2 comme version par défaut. Pour ce faire, dans PowerShell, entrez la commande suivante:

PS > wsl --set-default-version 2

Il ne reste plus qu’à installer la distribution Linux voulue depuis le Windows Store. Il est conseillé de choisir « Ubuntu », car c’est une distribution de Linux très répandue et facile d’utilisation. Depuis le Windows Store, rechercher « Ubuntu », et l’installer.

Application « Ubuntu » depuis le Windows Store

Une fois l’installation de l’application terminée, Ubuntu doit encore installer toutes les librairies nécessaires, et ne peut donc pas être utilisé directement. Un peu de patience !

Une fois l’installation réellement terminée, vous aurez accès à un terminal Ubuntu, comme si vous étiez sur une machine Linux. Toutes les commandes bash vous seront donc accessibles, notamment les commandes de gestion des dossiers et de compilation de programmes C.

Note

Accéder aux fichiers de Windows depuis WSL

Lorsqu’on utilise WSL, il n’y a pas d’interface graphique pour, par exemple, utiliser un éditeur de texte tel que Atom pour coder. On peut cependant produire les fichiers textes nécessaires depuis Windows directement, puis y accéder depuis WSL pour la compilation et l’exécution. Or, puisque WSL possède son propre système de fichiers Linux, il faut donc creuser un peu pour retrouver les fichiers Windows. Les disques Windows sont accessibles depuis le dossier /mnt, suivi de la lettre du disque. Par exemple, le disque C est accessible depuis /mnt/c. Depuis ces disques, vous pouvez retrouver tous les fichiers disponibles sur Windows. Pour plus de facilité, il est conseillé de créer un raccourci depuis le dossier d’accueil de WSL vers les dossiers Windows désirés. Pour ce faire, on utilise la commande ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html]:

$ ln -s TARGET_FILE LINK_NAME

Dans ce cas, TARGET_FILE sera le fichier ou dossier Windows voulu (accessible à partir de /mnt), et LINK_NAME sera l’emplacement voulu du raccourci (pour le mettre sur le dossier d’accueil de WSL, ce sera ~/target_file).

Machine virtuelle Linux

Une des façons les plus populaires d’avoir accès à une autre machine depuis sa propre machine, est d’utiliser une machine virtuelle [https://fr.wikipedia.org/wiki/Machine_virtuelle], ou VM (pour virtual machine). Il s’agit d’une machine émulée, grâce à un logiciel dédié, sur une machine physique. Cela permet d’avoir accès à certains systèmes d’exploitations ou certaines machines sans devoir les installer de manière physique. Ce genre d’installation est donc plus facile, mais puisque la machine est émulée, la performance est moins élevée. Il s’agit d’un bon compromis entre performance et facilité d’installation, si on ne veut pas installer complètement un nouveau système d’exploitation sur sa machine.

Pour disposer d’une machine virtuelle, il faut tout d’abord obtenir le logiciel d’émulation. Le plus populaire est VirtualBox [https://www.virtualbox.org/]. Il est open-source, et disponible sur Windows et MacOS. La première étape est donc d’aller sur la page Downloads du site de VirtualBox, puis de télécharger et installer la dernière version du logiciel. Ensuite, sur la même page, il faut télécharger le VirtualBox Extension Pack, qui permet d’étendre les capacités des VMs, et sera nécessaire pour celle que nous utiliserons. Une fois téléchargé, il suffit de double-cliquer sur le fichier, pour que VirtualBox l’installe automatiquement. Plus d’informations sur l’installation de VirtualBox sont disponibles à l’adresse suivante: https://wiki.student.info.ucl.ac.be/Logiciels/VirtualBox.

La prochaine étape est de télécharger la machine virtuelle en elle-même. Nous fournissons l’image des machines Fedora disponibles en salles informatiques, qui peut être installée sur VirtualBox. Cette image est la même que celle accessible via UDS, comme décrit plus tôt. Elle peut être téléchargée avec le lien suivant: https://wiki.student.info.ucl.ac.be/uploads/Mat%C3%A9riel/Mat%C3%A9riel/fedora32.ova (Attention, le fichier fait plus de 5 GB !). Une fois téléchargée, il suffit de double-cliquer sur le fichier, et VirtualBox ouvrira le fichier pour l’installation. Sur la fenêtre s’étant ouverte, les paramètres de base peuvent être laissés. Cliquez sur Importer pour créer la machine virtuelle. La procédure d’importation prend un certain temps, patience !

Importation d’une VM dans VirtualBox

Une fois que la VM est importée, démarrez la. Sur l’écran noir vous demandant de choisir entre 2 versions de Fedora, choisissez la première. Ensuite, sélectionnez l’utilisateur « Tux », qui est l’utilisateur par défaut. Vous avez désormais une machine virtuelle Linux fonctionnelle, à l’intérieur de votre machine !

Dual-boot Linux

La dernière option, qui est la plus radicale, est d’installer directement Linux en tant que système d’exploitation sur sa machine, de manière à pouvoir choisir au démarrage entre le système d’exploitation natif et Linux. Ceci s’appelle un dual-boot, car on a le choix entre deux systèmes d’exploitation (dual) lors du démarrage (boot). Cette possibilité est la plus compliquée en terme d’installation, mais permet le plus de confort d’utilisation et de performance une fois l’installation effectuée. Elle est conseillée pour les étudiants en filière informatique (car Linux pourra être utilisé par la suite dans d’autres cours), mais peut sembler trop lourde pour les étudiants qui n’utiliseront Linux que pour le cours LEPL1503.

Il existe de nombreuses manières d’installer un dual-boot sur sa machine. Le kot à projet Louvain-li-Nux [https://www.louvainlinux.org/] propose généralement une « Install Party », où ils peuvent vous aider à installer Linux en dual-boot. De nombreux tutoriels sont également disponibles en ligne. L’installation dépend évidemment de la version de Linux que vous souhaitez. Un tutoriel pour l’installation d’Ubuntu, qui est une des versions les plus populaires de Linux, est disponible à l’URL https://lecrabeinfo.net/installer-ubuntu-20-04-lts-le-guide-complet.html. Pour Fedora, la version de Linux disponible sur les machines des salles informatiques (et sur UDS, comme expliqué précédemment), un tutoriel est disponible à l’URL https://www.tecmint.com/install-fedora-with-windows-dual-boot/.

Shell

L’interpréteur de commande, ou shell, est l’interface de communication entre l’utilisateur et le système d’exploitation. C’est un exécutable chargé d’interpréter les commandes écrites par l’utilisateur et de les exécuter.

Dans le cadre de ce cours nous utiliserons l’interpréteur bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html]. Cet exécutable est généralement placé dans le fichier /bin/bash.

Le shell est un outil très puissant. Il permet d’effectuer de nombreuses opérations qui peuvent difficilement être réalisées manuellement ou via une interface graphique.

Note

Astuces pour utiliser le shell rapidement

Lors de la frappe d’un nom de fichier, d’un chemin d’accès ou même d’une commande tapez sur la touche <tab> pour « compléter » le mot que vous écrivez. Cela vous évite de devoir écrire les noms des commandes ou des fichiers en entier à chaque fois. Si rien ne se passe tapez deux fois <tab> pour obtenir la liste des possibilités.

De plus, vous pouvez accéder à l’historique des commandes en utilisant la flèche du haut.

Note

Chemin absolu et relatif

Pour écrire la position d’un fichier (son chemin), il y a deux manières de faire :

Le chemin absolu : il fait référence au chemin qu’il faut parcourir dans le système de fichier en démarrant de la racine, représentée par le caractère /.

Le chemin relatif: il fait référence au chemin à parcourir depuis le dossier courant.

Expressions régulières

Avant de commencer à voir les commandes utiles avec le shell, il est important de définir ce qu’est une expression régulière (regex(3)). Les expressions régulières caractérisent des chaînes de caractères et elles sont utiles pour de nombreuses commandes. Nous l’utiliserons notamment pour faire une recherche dans un fichier.

Dans une regex, certains caractères ont une signification particulière :

Expression

Explication

Exemple

\

Caractère d’échappement

[\.] contient un « . »

^

Début de ligne

^b commence par b

.

N’importe quel caractère

^.$ contient un seul caractère

$

Fin de ligne

er$ finit par « er »

|

Alternative

^(a|A) commence par a ou A

( )

Groupement

^((a)|(er)) commence par a ou er

-

Intervalle de caractères

^[a-d] commence par a,b,c ou d

[ ]

Ensemble de caractères

[0-9] contient un chiffre

[^]

Tout sauf un ensemble de caractères

^[^a] ne commence pas par a

+

1 fois ou plus

^(a)+ commence par un ou plusieurs a

?

0 ou 1 fois

^(a)? commence ou non par un a

*

0 fois ou plus

^(a)* peut ou non commencer par a

{x}

x fois exactement

a{2} deux fois « a »

{x,}

x fois au moins

a{2,} deux fois « a » au moins

{x, y}

x fois minimum, y maximum

a{2,4} deux, trois ou quatre fois « a »

Notes :

^b$ = contient uniquement le caractère b

^$ = la ligne est vide

Nous verrons plus en détail leur utilisation avec les commandes plus complexes.

Manipulation des répertoires

Chaque processus travaille dans un répertoire dit courant. C’est le répertoire dans lequel le processus accède pour lire ou écrire des fichiers lorsque le processus utilise un nom relatif. La commande pwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/pwd.1.html] affiche le chemin du répertoire courant.

Il est possible de changer le répertoire courant du processus ou du shell en utilisant la commande cd(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/cd.1posix.html]. Exemples :

cd(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/cd.1posix.html] chemin : change le répertoire courant par celui de « chemin ».

cd(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/cd.1posix.html] : change le répertoire courant par le répertoire de login de l’utilisateur courant.

cd(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/cd.1posix.html] .. : remonte dans le répertoire prédécesseur dans l’arborescence des fichiers.

La commande mkdir(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/mkdir.1.html] permet de créer un répertoire. Elle prend comme argument le nom du répertoire à créer. La commande rmdir(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/rmdir.1.html] supprime un répertoire qui doit être vide. Pour effacer un répertoire et tous les fichiers qu’il contient, il faut utiliser la commande rm(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/rm.1.html] avec l’option -r. Ainsi, rm -r /tmp/t supprime le répertoire /tmp/t ainsi que tous les fichiers et sous-répertoires se trouvant dans ce répertoire.

La commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html] permet de connaître l’ensemble des fichiers et répertoires contenus dans le répertoire courant. Elle supporte plusieurs options dont les plus utiles sont :

-F : Positionne à la fin des noms de fichier un / pour les répertoires et * pour les fichiers exécutables

-a : Affiche tous les fichiers, y compris les fichiers cachés (ceux qui commencent par le caractère . )

-d : Ne liste pas le contenu d’un répertoire : si rep est un répertoire, ls -l rep listera le contenu du répertoire rep, alors que ls -ld rep listera la description du répertoire

-l : Description complète du contenu d’un répertoire (une ligne par fichier)

Avec l’option -l, le premier caractère de la ligne indique le type du fichier. Le caractère - correspond à un fichier standard et d à un répertoire. Il est aussi possible de connaître le contenu d’un autre répertoire que le répertoire courant en fournissant le nom de ce répertoire comme argument à la commande ls.

/repertoiretest $ ls
file.txt  repertoiresoustest/

/repertoiretest $ l repertoiresoustest/
first.txt  log.log  second.txt

Manipulation de fichiers

Créer et détruire

> filename crée un fichier vide.

touch(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/touch.1.html] filename crée un fichier vide.

echo(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/echo.1.html] mon_texte > filename crée un fichier avec « mon_texte » dedans.

rm(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/rm.1.html] [-irf] files efface les fichiers

-i : interactif, demande une confirmation sur chaque fichier

-f : force la suppression du fichier

-r : efface un répertoire et son contenu

Visualiser

nl(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/nl.1.html] [-opt] file affiche le contenu d’un fichier et en numérote les lignes.

-bt

: numérote les lignes non vides (par défaut)

-ba

: numérote toutes les lignes

-bpXXX

: numérote seulement les lignes qui contiennent la chaîne de caractères XXX

-sX     : supprime le décalage dû à la numérotation et utilise le séparateur X

-s’XXX” : supprime le décalage dû à la numérotation et utilise la chaîne “XXX”

paste(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/paste.1.html] [-opt] f1 f2 concatène horizontalement et affiche les deux fichiers.

-s : copie les lignes d’un fichier sur une ligne

more(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/more.1.html] file visualise le contenu du ou des fichiers par page.

Si il contient plus d’une page :

q ou Q : pour terminer la visualisation

RETURN : pour visualiser une ligne supplémentaire

ESPACE : pour visualiser la page suivante

h : pour obtenir de l’aide

Modifier

touch(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/touch.1.html] filename met à jour les dates d’accès et de modification du fichier. Crée le fichier si il n’existe pas.

-c : empêche la création du fichier si celui ci n’existe pas

-m : change uniquement la date de modification du fichier

-a : change uniquement la date d’accès du fichier

split(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/split.1.html] [-opt] file [out] coupe le fichier en plusieurs petites parties

-b nbr : découpe selon un nombre d’octets

-n nbr : découpe selon un nombre de lignes

Extraction de données

grep(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/grep.1.html] [-opt] regex file recherche l’expression dans les fichiers.

-i : ignore la casse

-v : affiche les lignes ne contenant pas l’expression.

-c : compte les lignes ne contenant pas la chaîne

-n : numérote chaque ligne contenant la chaîne

-x : affiche les lignes correspondant exactement à la chaîne

uniq(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/uniq.1.html] [-opt] filename affiche le fichier en supprimant les lignes qui se répètent successivement.

-u : Affiche seulement les lignes n’apparaissant qu’une seule fois

-d : Affiche seulement les lignes répétées

-cEn plus de l’affichage standard, chaque ligne est précédée du nombre de répétitions

Si cette option est utilisée, alors les options -u et -d sont ignorées.

-i : ignore la casse

-s N : ne compare pas les N premiers caractères de chaque ligne

-w N : ne compare pas plus de N caractères de chaque ligne

sort(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sort.1.html] [-opt] filename trie les lignes par ordre alphabétique.

-f : ignore la casse

-r : inverse l’ordre de tri

-o : modifie la sortie standard

-t : modifie le caractère séparateur. Par défaut c’est une chaîne de blancs

-n : compare selon la valeur arithmétique

-k : spécifie la colonne utilisée pour le tri

uniq(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/uniq.1.html] et sort(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sort.1.html] sont souvent utilisés ensemble. Par exemple, cette commande trie les lignes de file.txt selon leur nombre d’apparitions.

$ cat file.txt
une fois
deux fois
deux fois
trois fois
encore une fois
trois fois
toujours une fois
trois fois

$ sort file.txt | uniq -c | sort -n
        1 encore une fois
        1 toujours une fois
        1 une fois
        2 deux fois
        3 trois fois

Une autre utilisation possible est de pouvoir trier un fichier, par exemple CSV, sur une colonne particulière. Tout d’abord, il faut modifier le séparateur de colonne avec -t, puis spécifier la colonne

$ cat file.txt
pcr,01,3
pcr,1,3
pcr,04,5
pcr,03,6
alex,03,6
zorro,01,20
zorro,5,4

$ cat file.txt | sort -t, -k2n
zorro,01,20
pcr,01,3
pcr,1,3
alex,03,6
pcr,03,6
pcr,04,5
zorro,5,4

diff(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/diff.1.html] [-opt] f1 f2 compare le contenu de deux fichiers.

-i : ignore la casse

-c : rapport plus clair

-q : indique uniquement si les fichiers sont différents

-b : ignore les différences dues à des espaces blancs

-B : ignore les différences dues à des lignes blanches

$ cat test.txt
premiere ligne similaire

deuxieme differente
et moi pareil
troisieme comme la deuxieme

et enfin la quatrieme est la meme!
$ cat testbis.txt
premiere ligne similaire
en effet, je ne lui ressemble pas..
et moi pareil
moi non plus, tres cher.

et enfin la quatrieme est la meme!

$ diff test.txt testbis.txt
2,3c2                                   =  Les lignes 2,3 du premier fichier et 2 du second sont différentes
<                                       _
< deuxieme differente                    \
---                                       >  Affichage des lignes différentes
> en effet, je ne lui ressemble pas..   _/
5c4                                     _
< troisieme comme la deuxieme            \
---                                       > Même réflexion
> moi non plus, tres cher.              _/

Obtenir des informations

wc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/wc.1.html] [-opt] filename donne sur stdout des informations au sujet de l’entrée standard ou d’une liste de fichiers.

Première colonne est le nombre de lignes, deuxième le nombre de mots et en dernier le nombre d’octets.

-l : nombre de lignes

-c : nombre d’octets

-m : nombre de caractères

-L : la longueur de la plus longue ligne

-w : le nombre de mots

Manipulations communes aux répertoires et fichiers

Copier

cp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cp.1.html] [-opt] src dst copie la src dans le fichier dst.

Si dst n’existe pas, il est créé. Sinon, si c’est un fichier, son contenu est écrasé.

-r : spécifie la copie d’un répertoire

-u : copie uniquement si src est plus récent que dst ou si il est manquant dans dst

Note : Si la destination est un répertoire, alors la source peut être une liste de fichiers.

$ cp test.txt ./testbis/
$ cp test.txt btest.txt ../
$ cp -r repertoire ../repertoirebis

Déplacer ou renommer

mv(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/mv.1.html] [-opt] src dst renomme ou déplace src en dst.

-f : écrase les fichiers existants

-i : demande confirmation avant d’écraser un fichier existant

-n : n’écrase aucun fichier déjà existant

Note : Si la destination est un répertoire, alors la source peut être une liste de fichiers.

$ mv test.txt testrename.txt
$ mv test.txt ./testbis/
$ mv repertoire ./repertoirebis

Rechercher

Pour les critères de recherche :

critère1 critère2 = et logique

!critère = non logique

critère1 -a critère2 = ou logique

find(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/find.1.html] chemin regex recherche les fichiers/répertoires caractérisés par nom, à partir du répertoire rep et affiche le résultat.

-name

: sur le nom du fichier

-perm

: sur les droits d’accès du fichier

-links : sur le nombre de liens du fichier

-user

: sur le propriétaire du fichier

-group : sur le groupe auquel appartient le fichier

-type

: sur le type (d=répertoire, c=caractère, f=fichier normal)

-size

: sur la taille du fichier en nombre de blocs (1 bloc=512octets)

-atime : par date de dernier accès en lecture du fichier

-mtime : par date de dernière modification du fichier

-ctime : par date de création du fichier

-print : affiche les fichiers sur stdout

$ find ./ -name "*fi*" -print           = contenant fi
$ find ./ -mtime "3" -print             = modifié dans les trois derniers jours
$ find ./ -name "*s*" -a -name "f*"     = contenant s et commençant par f

Note : « ./ » représente le répertoire courant

Il y a trois remarques à faire sur la commande find :

Il est parfois nécessaire de mettre -print dans la commande pour afficher le résultat

Lors de larges recherches, il peut y avoir un message d’erreur pour chaque tentative d’accès à un fichier où vous n’avez pas d’autorisation d’accès, par exemple des fichiers système. Pour éviter que ces messages d’erreur ne polluent la recherche, il faut rediriger la sortie d’erreur standard dans « un puits sans fond ». Pour cela, rajoutez 2>/dev/null

Il est parfois très utile de pouvoir exécuter une commande sur les fichiers trouvés. La solution la plus légère est de rediriger la sortie et de lui attribuer une commande. Pour cela, il faut faire : « find rep -name expr| xargs commande ». Cette commande est expliquée dans la section « Commandes plus complexes ».

Pour cet exemple, le résultat est tous les fichiers dont le nom contient « mon test », et donc le fichier contient « supertab ». .. code-block:: console

$ find /testdirectory -name mon test -type f | xargs grep supertab

Création de lien

ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] [-opt] src dst création d’un lien (raccourci) sur un fichier ou un répertoire. Attention un lien n’est pas une copie.

Il existe deux sortes de liens:

le lien physique : uniquement des fichiers

le lien symbolique (avec l’option -s) : fichiers et répertoires

« SHEMA »

Dans le cas de lien physique, on supprime le fichier en supprimant tous les liens qui pointent sur ce fichier. Par contre pour des liens symboliques, vous pouvez effacer le fichier sans effacer les liens, mais alors ceux-ci seront invalides.

Archivage et compression

Il est important de noter qu’une archive n’est pas forcément compressée.

tar(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/tar.1.html] [-opt] tarname.tar files crée une archive à partir d’une liste de fichiers ou de répertoires.

f : argument obligatoire, sauf si l’on veut lire ou écrire vers/depuis un lecteur de bande

c : crée une archive

z : compresse l’archive créée, en utilisant gzip. (Attention, l’extension doit être « tar.gz »)

j : compresse mieux l’archive mais prend plus de temps. (Attention, l’extension doit être « tar.bz2 »)

x : désarchive

t : inspection de l’archive

$ tar cf monarchive.tar firstfile.c  secondfile.c        =      crée une archive contenant deux fichiers
$ tar cfz monarchive.tar.gz firstfile.c  secondfile.c    =      crée une archive compressée
$ tar tf monarchive.tar                                  =      inspecte l'archive créée
firstfile.c
secondfile.c
$ tar xf monarchive.tar.gz                               =      désarchive
$ tar xf monarchive.tar -C /home                         =      désarchive monarchive.tar dans /home

gzip(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gzip.1.html] file compresse un fichier ou une archive

-c

: la compression est effectuée sur la sortie standard au lieu du fichier lui-même

-c1 : compression plus rapide

-c9 : meilleur compression

$ gzip secondfile.c             =       compresse un fichier et produit un fichier .gz
$ gzip monarchive.tar           =       compresse une archive

$ ls
monarchive.tar                  =       compresse monarchive.tar vers monarchive.tar.gz
$ gzip monarchive.tar
ls
monarchive.tar  monarchive.tar.gz

Permissions

Pour chaque fichier, il y a trois classes d’utilisateurs

user : le propriétaire du fichier

groupe : le groupe auquel appartient le fichier

autre : tous les autres

Les permissions accordées à ces trois classes sont :

r : lecture

w : écriture

x : exécution (Un fichier peut être exécuté et un répertoire peut devenir répertoire courant)

chmod(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chmod.1.html] mode files change les permissions du ou des fichiers/répertoires.

                                user    group   other
mode désiré : rwxr-xr--         rwx      r-x     r--
                                111      101     100     (en binaire)
                                 7        5       4      (en hexadecimal)

d'où la commande ``chmod 754 fichier``

chown(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chown.1.html] owner files change le propriétaire du fichier.

chgrp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chgrp.1.html] grp files change le groupe du fichier.

Obtenir des informations

stat(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/stat.1.html] [-opt] filename donne des informations sur les métadonnées associées au fichier

-f : affiche l’état du système de fichiers plutôt que celui du fichier

-L : suit les liens du fichier

-t : affiche les informations de façon concise

–format=FORMAT : affiche les informations selon le format choisi

Séquences de format valables pour les fichiers :
        %a droits d'accès en octal
        %A droits d'accès dans un format lisible par un humain
        %b nombre de blocs alloués (voir << %B >>)
        %B taille, en octets, de chaque bloc rapporté par %b
        %d numéro de péripherique en décimal
        %D numéro de péripherique en hexadécimal
        %f mode brut en hexadécimal
        %F type de fichier
        %g identifiant de groupe du propriétaire
        %G nom de groupe du propriétaire
        %h nombre de liens directs (<< hard >>)
        %i numéro d'inode
        %m point de montage
        %n nom de fichier
        %N nom du fichier cité, déréférencé s'il s'agit d'un lien symbolique
        %o taille de bloc d'entrée/sortie
        %s taille totale, en octets
        %u identifiant du propriétaire
        %U nom d'utilisateur du propriétaire
        %w date de création au format lisible, ou << - >> si elle n'est pas connue
        %x date du dernier accés au format lisible
        %y date de la dernière modification au format lisible
        %z date du dernier changement au format lisible

 Séquences de format valables pour les systèmes de fichiers :
        %a nombre de blocs libres disponibles pour les utilisateurs normaux
        %b nombre total de blocs de données dans le système de fichiers
        %c nombre total d'inodes dans le système de fichiers
        %d nombre d'inodes libres dans le système de fichiers
        %f nombre de blocs libres dans le système de fichiers
        %i identifiant du système de fichier en hexadécimal
        %l longueur maximale des noms de fichier
        %n nom de fichier
        %s taille des blocs (pour des transferts plus rapides)
        %S taille fondamentale des blocs (pour le décompte des blocs)
        %t type en hexadecimal
        %T type dans un format lisible par un humain

Gestion des processus

top(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/top.1.html] affiche les processus en cours d’exécution. pstree(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/pstree.1.html] affiche l’arbre des processus.

strace(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/strace.1.html] [-opt] cmd trace les appels systèmes et la création de signaux effectués par une commande

-c : collecte quelques statistiques de base concernant les appels système tracés

-o : redirige la sortie standard

-p : avec cette option, cmd est remplacé par le PID d’un processus, et celui ci est tracé

-T : indique le temps passé dans chaque appel système

-t : indique l’heure au début de chaque ligne. -tt comprend les microsecondes

-r : donne le temps entre deux appels systèmes successifs

$ strace -c ./monexecutable -o fichierRecoltantLesInformations.log

lsof(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/lsof.8.html] [-opt] affiche les fichiers ouverts.

-p PID : uniquement les fichiers ouverts du processus

-i : affiche les connexions réseau ouvertes

$ lsof -i -p 2735         =  Les connexions ouvertes ET les fichiers ouverts par le processus 2735
$ lsof -i -a -p 2735      =  Les connexions ouvertes par le processus 2735

kill(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/kill.1.html] pid supprime le processus spécifié. Si malgré la commande, le processus n’est pas détruit, essayez kill -9 pid.

Symboles utiles

Redirection de l’entrée, sortie et erreur standard

Lors de l’exécution d’une commande, un processus est créé et celui-ci va ouvrir trois flux : l’entrée, la sortie et l’erreur standard. Par défaut lorsque l’on exécute un programme, les données sont donc lues à partir du clavier et le programme envoie sa sortie et ses erreurs sur l’écran. toutefois, il est possible de rediriger ces flux.

< l’entrée standard est lue à partir d’un fichier

> La sortie standard est redirigée dans un fichier. Si le fichier existe, il est vidé avant d’écrire.

>> La sortie standard est redirigée dans un fichier. Si le fichier existe, la sortie standard est ajoutée à la fin de celui ci.

2> La sortie d’erreur standard est redirigée

cmd1 | cmd2 La sortie standard de cmd1 devient l’entrée standard de cmd2

Symboles pour les commandes

? caractère joker remplaçant un seul caractère

! inverse le sens d’un test ou l’état de sortie d’une commande.

* caractère joker remplaçant une chaîne de caractères

& exécute une commande en arrière-plan

; sépare des instructions sur une seule ligne

cmd1 && cmd 2 cmd2 n’est exécuté que si cmd1 réussit

cmd1 || cmd 2 cmd2 n’est exécuté que si cmd1 échoue

\ annule l’effet du caractère spécial suivant

" " annule l’effet de tous les caractères spéciaux entre les guillemets, sauf $ et \

Commandes utiles

Pour effectuer des chaînes

xargs(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/xargs.1.html] permet d’appliquer une commande à l’entrée standard.

Pour cet exemple, le résultat est tous les fichiers dont le nom contient « mon test », et dont le fichier contient « supertab ».

$ find /testdirectory -name *mon test* -type f | xargs grep supertab

tee(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/tee.1.html] file lit depuis l’entrée standard, écrit dans la sortie standard et dans le fichier. Elle est utilisée pour continuer une chaîne tout en faisant une sauvegarde des informations.

% echo "Les tubes sont un mécanisme puissant." | tee fichier.txt | wc
         1       6      39
% cat fichier.txt
Les tubes sont un mécanisme puissant.

On peut voir que le texte a bien été relayé vers la commande « wc » et qu’en même temps, ce texte a été écrit dans fichier.txt

Informations générales

su(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/su.1.html] passe en mode « root », c’est à dire administrateur

whatis(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/whatis.1.html] cmd explique brièvement l’utilité d’une commande

apropos(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/apropos.1.html] [-opt] mot-clé recherche dans les man pages les commandes correspondants aux mots clés.

-aAffiche seulement les résultats répondant à tout les mots clés.

L’inverse est le fonctionnement par défaut

date(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/date.1.html] donne l’heure, selon l’horloge de votre ordinateur

cal(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cal.1.html] affiche un calendrier du mois courant

halt(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/halt.8.html] éteint l’ordinateur.

reboot(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/reboot.8.html] redémarre l’ordinateur

Informations système

time(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/time.1posix.html] programme permet de calculer le temps d’exécution d’un programme

df(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/df.1.html] [-opt] [file] indique l’espace disque utilisé et disponible sur tous les systèmes de fichiers.

Si des fichiers sont passés en argument, seul les systèmes de fichiers contenant un des fichiers sont montrés.

-h

Imprime les dimensions dans un format lisible par l’utilisateur

-H

Idem que -h, mais il utilise des puissances de 1000 au lieu de 1024

-i

Affiche l’information i-node au lieu de l’utilisation des blocs

-l

Limite l’affichage aux systèmes de fichiers locaux

-P

Utilise le format de résultat POSIX

-T

Imprime le type de système de fichiers

Maniement des jobs

La plupart des commandes en console sont exécutées rapidement, mais ce n’est pas le cas de toutes. Certaines commandes, que l’on va appeler jobs, prennent plus de temps (comme par exemple copier un très gros fichier), et d’autres encore tournent indéfiniment.

Évidemment, quand un job est en cours d’exécution à la console, plus aucune action ne peut être faite sur celle-ci. Unix nous vient en aide dans ce cas-là avec le raccourci Ctrl+z et les commandes jobs(1) [https://www.manpagez.com/man/1/jobs/], bg(1) [https://linux.die.net/man/1/bg] et fg(1) [https://linux.die.net/man/1/fg].

Ctrl+z : Le job passe dans l’état suspended. Il est en pause, et placé en background.

jobs : Affiche à la console la liste des jobs présents en background

bg : Passe un job mis en background de l’état suspended à l’état running. Le job reste en background, mais il continue à s’exécuter

fg : Passe un job du background à l’avant-plan

Exemples :

$ yes > \dev\null
#nous lançons la commande yes

^Z
#nous la suspendons avec Ctrl+z
[1]+  Stopped                 yes > \dev\null
#elle est placée en arrière-plan

$ jobs
#nous regardons la liste des jobs en arrière plan
[1]+  Stopped                 yes > \dev\null
#chaque job à un numéro qui lui est attribué. ici 1

$ bg 1
#nous relançons yes en arrière-plan. On peut utiliser son nom comme son numéro avec la commande bg et fg
[1]+ yes > \dev\null &
#yes s'est remis en route

$ jobs
#nous vérifions le statut de yes avec jobs
[1]+  Running                 yes > \dev\null &
#il est en cours d'exécution

$ fg yes
#nous remettons yes en avant-plan
yes > \dev\null

^Z
#nous le suspendons à nouveau
[1]+  Stopped                 yes > \dev\null

$ kill %1
#nous terminons yes avec la commande kill %[numJob]
[1]+  Stopped                 yes > \dev\null

$ jobs
#nous vérifions les jobs
[1]+  Terminated: 15          yes > \dev\null
#yes est marqué Terminated

$ jobs
#un deuxième appel à jobs nous affiche une liste vide

Commandes complexes

Modification d’un fichier

sed(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sed.1.html] [-n] [-e “prog”] [-f cmdfile] [file] applique des commandes de “prog” sur un fichier

-n : n’affiche aucune ligne, sauf celle spécifiée avec la commande p

-especifie les commandes à appliquer sur le fichier

Note : Il vaut mieux encadrer la commande avec des “ ou des « 

-f : les commandes sont lues à partir d’un fichier

Pour bien comprendre la puissance de sed, il est important de comprendre son fonctionnement. sed fonctionne en 4 étapes :

Lecture d’une ligne sur le flux d’entrée, et stockage dans l’espace de travail

Exécute les commandes sur l’espace de travail

Envoie la ligne au flux de sortie en lui rajoutant un “n”

Recommence avec la ligne suivante …

Une commande d’un “prog” est constituée d’un adressage, c-à-d les lignes sur lesquelles la commande est appliquée, et de l’action à exécuter.

L’adressage est décomposé en deux catégories.

: toutes les lignes

num : la ligne « num ». La dernière ligne est symbolisée par $

num1, num2 : les lignes entre num1 et num2

/regex/ : les lignes correspondant à l’expression régulière regex

/regex1/, /regex2/les lignes entre la première ligne correspondant à regex1 et la première ligne correspondant à regex2

Si regex2 est vide, la commande sera appliquée jusqu’à la fin du fichier.

Note : Le ! représente la négation. Mettez le après votre spécification des lignes pour prendre la négation

RAPPEL sur les regex :

Expression

Explication

Exemple

\

Caractère d’échappement

[\.] contient un « . »

^

Début de ligne

^b commence par b

.

N’importe quel caractère

^.$ contient un seul caractère

$

Fin de ligne

er$ finit par « er »

|

Alternative

^(a|A) commence par a ou A

( )

Groupement

^((a)|(er)) commence par a ou er

-

Intervalle de caractères

^[a-d] commence par a,b,c ou d

[ ]

Ensemble de caractères

[0-9] contient un chiffre

[^]

Tout sauf un ensemble de caractères

^[^a] ne commence pas par a

+

1 fois ou plus

^(a)+ commence par un ou plusieurs a

?

0 ou 1 fois

^(a)? commence ou non par un a

*

0 fois ou plus

^(a)* peut ou non commencer par a

{x}

x fois exactement

a{2} deux fois « a »

{x,}

x fois au moins

a{2,} deux fois « a » au moins

{x, y}

x fois minimum, y maximum

a{2,4} deux, trois ou quatre fois « a »

Notes :

^b$ = contient uniquement le caractère b

^$ = la ligne est vide

Les actions

p : affiche les lignes

d : supprime les lignes

y/l1/l2 : remplace les caractères de la première liste par les caractères de la seconde

s/mtf/sbst/substitue le mtf par le sbst

NotePar défaut seule la première occurrence est remplacée.

Pour toutes les remplacer : /s/motif/substitut/g

Pour en remplacer 4 : /s/motif/substitut/4

N : charge une ligne supplémentaire dans l’espace de travail

D : efface l’espace de travail jusqu’au premier saut de ligne incorporé

b : revient

Pour faire des commandes groupées, placez vos commandes entre {} séparées par « ; ».

Quelques illustrations basiques :

$ sed '' test.txt                       = Le script est vide, il renvoie simplement le fichier

$ sed -n '/Ici/p' test.txt              = Affiche les lignes contenant Ici
$ sed 'p' test.txt                      = Double toutes les lignes

$ sed -e '4d; 7d' test.txt              = Supprime les lignes 4 et 7
$ sed -e '4,7d' test.txt                = Supprime les lignes entre 4 et 7

$ sed '/^#/ d' test.txt                 = Supprime les lignes commencant par #
$ sed '/e$/ d' test.txt                 = Supprime les lignes se terminant par e
$ sed '/#/,/@/d' test.txt               = Supprime les lignes comprises entre le premier # et le premier @

$ sed -e 's/^#//' test.txt              = Supprime le commentaire en début de ligne, puisqu'il
                                          est remplacé par ''

$ sed -e 'y/éèê/eee/' test.txt          = Retire les accents, puisqu'ils sont remplacés par 'e'

$ sed -e ' 4,7 {y/éèê/eee/;s/e/[]/} test.txt    = Remplace les accents, puis remplace les "e" par "[]"

$ sed -e '/^$/ {N; D}' test.txt         = Supprime les sauts de ligne

Explication : Pour les lignes vides, on charge la ligne suivante, on envoie ce qui se trouve dans l’espace de travail jusqu’au premier “n”, puis on continue le traitement du texte. Pour continuer le traitement, une nouvelle ligne est chargée et va donc « écraser » les “n” qui sont toujours présents dans l’espace de travail.

Lors du remplacement d’un mot par un autre, il peut survenir un problème de taille. En effet, le remplacement n’est effectué que sur le premier mot de la ligne trouvé.

$ sed -e ' s/[oe]/[/' test.txt
B[njour,

C[ci est un fichier de test.
Ici la lign[ numéro 4.

# c[ci pourrait être un commentaire
Ici la lign[ numéro 7.I

Au r[voir

On remarque que tout les “e” et “o” n’ont pas été remplacés…

Pour contrecarrer ce problème, il est possible de placer dans le script un label et de revenir dessus, comme un goto en C. Pour effectuer ce retour utilisez la commande “b”.

$ sed -re ':start {s/[eo]/[/g; /[eo]/ b start}' test.txt
B[nj[ur,

C[ci [st un fichi[r d[ t[st.
Ici la lign[ numér[ 4.

# c[ci p[urrait êtr[ un c[mm[ntair[
Ici la lign[ numér[ 7.I

Au r[v[ir

Explication : Un label est placé au début des commandes. La première commande remplace le premier [eo] trouvé. La seconde retourne au label si il reste encore un [eo] dans la ligne. Une fois qu’il n’y a plus de [eo], la ligne suivante est chargée.

Appliquer des actions à un fichier

awk(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/awk.1.html] [-Fs] [-v variable] [-f fichier de commandes] “program” fichier

-F : Spécifie les séparateurs de champ

-v : Définit une variable utilisée à l’intérieur du programme.

-f : Les commandes sont lues à partir d’un fichier.

Note : awk est une commande extrêmement puissante, elle permet d’effectuer une multitude d’opérations. Son utilisation est complexe et elle est bien détaillée sur ce site : https://www.shellunix.com/awk.html. Je vous encourage à le lire.

Redirection nommée

mkfifo(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/mkfifo.1.html] nom crée un tube nommé

ls | less est donc similaire à  mkfifo /tmp/tempfifo
                                ls > /tmp/tempfifo
                                less < /tmp/tempfifo

Bash

Taper des commandes dans la console est inévitable lors d’opérations avancées sur un système Unix, et peut devenir très vite répétitif et fastidieux pour l’utilisateur. Le Bash est justement là pour éviter ces répétitions et automatiser certaines tâches à l’aide de scripts, qui sont des fichiers texte composés de différentes commandes Unix, lus, interprétés et exécutés par Bash.

Premier script

Nous allons écrire un premier script bash pour présenter la manière générale de procéder avec un tel outil. Les scripts commencent toujours par la ligne #!/bin/bash qui indique à l’exécution qu’il s’agit d’un script et avec quel interpréteur le lire (ici bash).

#!/bin/bash
echo "Hello, 1252"

Nous allons enregistrer ce texte sous le nom hello.sh [https://raw.githubusercontent.com/HappyRave/SystInfo1/master/valgrind/hello.sh], puis changer ses permissions pour le rendre exécutable.

$ chmod 700 hello.sh

Après il ne reste plus qu’à l’exécuter et observer le résultat.

$ ./hello.sh
Hello, 1252

Les variables

Bash permet l’utilisation de variables dans les scripts. Il peut s’agir de simples variables ou de tableaux. Bash n’est pas un langage typé, les entiers ou les String n’existent pas, toutes les variables sont traitées de la même façon. Pour illustrer ceci nous allons écrire le script variables.sh [https://raw.githubusercontent.com/HappyRave/SystInfo1/master/valgrind/variables.sh]

#!/bin/bash

bonjour='Hello, '
#il est important de ne pas mettre d'espaces autour du =
nombre[0]=12
nombre[1]=52

echo $bonjour${nombre[0]}${nombre[1]}
#on accède à une variable simple avec un $ devant son nom
#on accède à un élément d'un tableau avec un $ devant et des {} autour
echo $bonjour${nombre[*]}
#le caractère * indique qu'on veut utiliser tous les éléments du tableau (séparés
#par un espace à chaque fois)

Ce script produit comme résultat

$ ./variables.sh
Hello,1252
Hello,12 52

Les structures de contrôle

Comme dans chaque langage de programmation, bash offre les structures de contrôle habituelles telles que les boucles if, for ou encore while que nous allons démontrer maintenant.

Comme dit précédemment, il n’y a pas de type en bash, true et false n’existent pas. Les conditions que les boucles vont utiliser seront les valeurs renvoyées par l’exécution d’une commande. Un 0 renvoyé correspond à un true, tandis que tout le reste est considéré comme un false.

Dans le but de tester ces boucles nous utiliserons un petit programme en C, return.c [https://raw.githubusercontent.com/HappyRave/SystInfo1/master/valgrind/return.c], qui va renvoyer la valeur qu’il reçoit en argument. Le script de test est structures.sh [https://raw.githubusercontent.com/HappyRave/SystInfo1/master/valgrind/structures.sh].

#!/bin/bash

if ./return 0; then
#la valeur de renvoi sera 0 quand la boucle aura été exécutée
echo "Hello"
fi

if ./return 1; then
#ici c'est la condition else qui sera remplie
echo "Hello"
else
echo "Bye"
fi

for i in 1 2 5 2
#les boucles for peuvent s'écrire de cette façon
do
echo $i
done

echo Hello again!

for (( j=1; j<=5; j++))
#ou encore utiliser la synthaxe classique comme en C ou Java
do
echo $j
done

k=4
while ((k>0))
do
echo $k
k=$((k-1))
done

Le résultat à l’exécution est

$ ./structures.sh
Hello
Bye
1
2
5
2
Hello again!
1
2
3
4
5
4
3
2
1

Gestion des processus

Les systèmes d’exploitation de type Unix sont multitâches et multi-utilisateurs. Cela signifie qu’il est possible d’exécuter simultanément plusieurs programmes qui appartiennent potentiellement à différents utilisateurs. Sous Unix, l’unité d’exécution d’un programme est appelée un processus [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-processus]. Lorsque vous exécutez un programme C que vous avez compilé depuis la ligne de commande, le shell lance un nouveau processus [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-processus]. Chaque processus est identifié par le système d’exploitation via son pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid] ou process identifier [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-process-identifier]. Ce pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid] est alloué par le système d’exploitation au moment de la création du processus. À tout instant, le système d’exploitation maintient une table des processus [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-table-des-processus] qui contient la liste de tous les processus qui sont en cours d’exécution. Comme nous aurons l’occasion de nous en rendre compte plus tard, cette table contient énormément d’informations qui sont utiles au système. À ce stade, l’information importante qui se trouve dans la table des processus est le pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid] de chaque processus et l’utilisateur qui possède le processus. La commande ps(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ps.1.html] permet de consulter de façon détaillée la table des processus sur un système Unix. Voici un exemple d’utilisation de cette commande sur un système Linux.

$ ps u
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
obo      16272  0.0  0.0 110464  1884 pts/1    Ss   11:35   0:00 -bash
obo      16353  0.0  0.0 110184  1136 pts/1    R+   11:43   0:00 ps u

Dans cet exemple, l’utilisateur obo possède actuellement deux processus. Le premier est l’interpréteur de commande bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] et le second le processus ps(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ps.1.html]. L’interpréteur de commande a 16272 comme pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid] tandis que le pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid] de ps(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ps.1.html] est 16353.

ps(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ps.1.html] n’est pas la seule commande permettant de consulter la table des processus. Parmi les autres commandes utiles, on peut mentionner top(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/top.1.html] qui permet de visualiser les processus qui s’exécutent actuellement et le temps CPU qu’ils consomment ou pstree(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/pstree.1.html] qui présente les processus sous la forme d’un arbre. Sous Linux, le répertoire /proc, qui est documenté dans proc(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/proc.5.html] contient de nombreux pseudos fichiers avec énormément d’informations relatives aux processus qui sont en cours d’exécution. Parcourir le répertoire /proc et visualiser avec less(1) les fichiers qui s’y trouvent est une autre façon de consulter la table des processus sous Linux.

Pour comprendre le fonctionnement des processus, il est intéressant d’expérimenter avec le processus ci-dessous. Celui-ci utilise l’appel système getpid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getpid.2.html] pour récupérer son pid [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-pid], l’affiche et utilise la fonction sleep(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sleep.3.html] de la librairie pour se mettre en veille pendant trente secondes avant de se terminer.

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[]) {

  unsigned int sec=30;
  int pid=(int) getpid();

  printf("Processus : %d\n",pid);
  printf("[pid=%d] Sleep : %d secondes\n",pid, sec);
  sec=sleep(sec);
  if(sec==0) {
    printf("[pid=%d] Fin du processus\n",pid );
    return(EXIT_SUCCESS);
  }
  else {
    printf("[pid=%d] Interrompu alors qu'il restait %d secondes\n",pid,sec);
    return(EXIT_FAILURE);
  }
}

Ce programme peut être compilé avec gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] pour produire un exécutable.

$ ls -l getpid*
-rw-r--r--  1 obo  obo   608 10 fév 12:11 getpid.c
$ gcc -Wall -o getpid getpid.c
$ ls -l getpid*
-rwxr-xr-x  1 obo  obo  8800 10 fév 12:12 getpid
-rw-r--r--  1 obo  obo   608 10 fév 12:11 getpid.c

Cet exemple utilise la commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html] pour lister le contenu d’un répertoire. L’argument -l permet de d’obtenir pour chaque fichier son nom, sa date de modification, sa taille, l’utilisateur et le groupe auquel il appartient ainsi que ses permissions. Sous Unix, les permissions associées à un fichier sont divisées en trois blocs. Le premier bloc correspond aux permissions qui sont applicables à l’utilisateur qui possède le fichier. Pour l’exécutable getpid, les permissions du propriétaire sont rwx. Elles indiquent que le propriétaire peut lire le fichier (permission r), l’écrire ou l’effacer (permission w) et l’exécuter (permission x). Sous Unix, seuls les fichiers qui possèdent la permission à l’exécution peuvent être lancés depuis l’interpréteur. Ces permissions peuvent être modifiées en utilisant la commande chmod(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chmod.1.html]. Les deux autres blocs de permissions sont relatifs aux membres du même groupe que le propriétaire et à un utilisateur quelconque. Nous y reviendrons plus en détail lorsque nous abordons les systèmes de fichiers. En pratique, il est important de savoir qu’un fichier shell ou un fichier compilé qui n’a pas le bit de permission x ne peut pas être exécuté par le système. Ceci est illustré par l’exemple ci-dessous.

$ chmod -x getpid
$ ls -l getpid*
-rw-r--r--  1 obo  obo  8800 10 fév 12:12 getpid
-rwxr-xr-x  1 obo  obo  8800 10 fév 12:11 getpid.o
$ ./getpid
-bash: ./getpid: Permission denied
$ chmod +x getpid
$ ./getpid
Processus : 11147

L’interpréteur de commande bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] permet lancer plusieurs processus en tâche de fond. Cela se fait en suffixant la commande avec &. Des détails complémentaires sont disponibles dans la section JOB CONTROL du manuel de bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html]. Lorsqu’un processus est lancé en tâche de fond, il est détaché et n’a plus accès à l’entrée standard. Par contre, il continue à pouvoir écrire sur la sortie standard et la sortie d’erreur standard. L’exemple ci-dessous illustre l’exécution de deux instances de getpid.

$ ./getpid &
[1] 10975
$ Processus : 10975
[pid=10975] Sleep : 30 secondes
$ ./getpid &
[2] 10976
$ Processus : 10976
[pid=10976] Sleep : 30 secondes
ps u
USER   PID  %CPU %MEM      VSZ    RSS   TT  STAT STARTED      TIME COMMAND
obo   8361   0,0  0,0  2435548    208 s003  S+    9:24     0:00.14 -bash
obo  10975   0,0  0,0  2434832    340 s000  S    12:05     0:00.00 ./getpid
obo  10976   0,0  0,0  2434832    340 s000  S    12:05     0:00.00 ./getpid
[pid=10975] Fin du processus
[pid=10976] Fin du processus
[1]-  Done                    ./getpid
[2]+  Done                    ./getpid

Ces deux instances partagent la même sortie standard. En pratique, lorsque l’on lance un processus en tâche de fond, il est préférable de rediriger sa sortie et son erreur standard. Lorsque l’on développe de premiers programmes en C, il arrive que celui-ci se lance dans une boucle infinie. Deux techniques sont possibles pour interrompre un tel processus qui consomme inutilement les ressources de la machine et peut dans certains cas la surcharger fortement.

Si le programme a été lancé depuis un shell, il suffit généralement de taper sur Ctrl-C pour interrompre son exécution, comme dans l’exemple ci-dessous.

$ ./getpid
Processus : 11281
[pid=11281] Sleep : 30 secondes
^C

Parfois cependant Ctrl-C n’est pas suffisant. C’est le cas notamment lorsqu’un processus a été lancé en tâche de fond. Dans ce cas, la meilleure technique est d’utiliser ps(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ps.1.html] pour trouver l’identifiant du processus et l’interrompre via la commande kill(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/kill.1.html]. Cette commande permet d’envoyer un signal [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-signal] au processus. Nous verrons plus tard le fonctionnement des signaux sous Unix. À ce stade, le signal permettant de terminer avec certitude un processus est le signal KILL. C’est celui qui est utilisé dans l’exemple ci-dessous.

$ ./getpid &
[1] 11285
$ Processus : 11285
[pid=11285] Sleep : 30 secondes
ps
PID TTY           TIME CMD
384 ttys000      0:00.32 -bash
11285 ttys000    0:00.00 ./getpid
$ kill -KILL 11285
$ ps
 PID TTY           TIME CMD
 384 ttys000    0:00.33 -bash
 [1]+  Terminated              ./getpid

GCC

Le compilateur gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] est un compilateur largement utilisé pour les programmes écrits en C. Il permet de produire un fichier exécutable, qui pourra être exécuté pour effectuer ses opérations, à partir des fichiers source, qui contiennent le programme écrit en C. Les fichiers source ont en général l’extension .c, alors que le fichier exécutable n’a généralement pas d’extension (du moins dans le cadre de ce cours).

Soit un fichier source helloworld.c contenant le code C suivant:

#include <stdlib.h>
#include <stdio.h>

// Main function, prints "Hello world !"
int main(int argc, char const *argv[]) {
    printf("Hello world !\n");

    return EXIT_SUCCESS;
}

Pour compiler ce programme et produire l’exécutable prog, il suffit d’utiliser gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]:

$ gcc helloworld.c -o prog

Discutons de chacune des parties de cette commande:

gcc indique l’outil shell utilisé, ici gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]

helloworld.c indique le fichier source, en C

l’option -o permet de spécifier le nom du fichier produit, qui dans ce cas est le fichier exécutable

prog est le nom du fichier exécutable

On peut maintenant exécuter le programme prog:

$ ./prog
Hello world !

Cette description basique de gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] est suffisante pour le début de ce cours. Pour savoir comment compiler des programmes constitués de plusieurs fichiers sources, veuillez vous référer à la partie De grands programmes en C de la partie Théorie du syllabus.

Compléments

De manière technique, le travail du compilateur peut être découpé selon 4 étapes distinctes:

Appel du préprocesseur cpp: Supprime les commentaires, inclus les #include et évalue les macros

Appel du compilateur cc1: Génère un fichier assembleur (.as)

Appel de l’assembleur as : Génère le fichier objet (.o)

Appel du de l’éditeur de liens ld : Génère l’exécutable

Différentes options peuvent être utilisé avec gcc :

-E

: Appelle uniquement le préprocesseur

-S

: Appelle uniquement le préprocesseur et le compilateur

-C

: Appelle le préprocesseur, le compilateur et l’assembleur

-o nom

: Détermine le nom du fichier de sortie

-g

: Option nécessaire pour générer les informations symboliques de débogage avec gdb

-On

: Indique le niveau d’optimisation où n est compris entre 0 et 3

-Wall

: Active tout les warnings

-Werror

: Considère tout les warnings comme des erreurs

--help

: Messages d’aide

Notons que les trois premières options ne présentent pas d’intérêt pour ce cours.

GDB

Liste des commandes

gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] permet de débugger vos programmes plus facilement en permettant d’analyser l’état durant l’exécution. Pour pouvoir analyser votre exécutable avec gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html], vous devez ajouter les symboles de débuggage lors de la compilation en utilisant l’option -g de gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]:

gcc -g gdb.c -o my_program

L’option -g de gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] place dans l’exécutable les informations sur les noms de variables, mais aussi tout le code source.

Lancez gdb avec la commande gdb my_program. Ceci va vous ouvrir la console de gdb qui vous permet de lancer, le programme et de l’analyser. Pour démarrer le programme, tapez run. gdb va arrêter l’exécution au premier problème trouvé. Votre programme tourne encore pour l’instant. Arrêtez-le avec la commande kill.

Breakpoint

Pour analyser un programme, vous pouvez y placer des breakpoints. Un breakpoint permet de mettre en pause l’exécution d’un programme à un endroit donné pour pouvoir afficher l’état des variables et faire une exécution pas-à-pas. Pour mettre un breakpoint, vous avez plusieurs choix:

break [function] met en pause l’exécution à l’appel de la fonction passée en argument à la commande

break [filename:linenumber] spécifie le fichier du code source et la ligne à laquelle l’exécution doit s’arrêter

delete [numberbreakpoint] supprime le breakpoint spécifié

Note : Chaque breakpoint est caractérisé par un numéro. Pour obtenir la liste des breakpoints utilisés info break

Informations à extraire

Une fois un breakpoint placé, plusieurs informations peuvent être extraites via gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] :

print [variablename] affiche la valeur de la variable dans son format de base. Il est possible de connaître la valeur pointée en utilisant * ainsi que l’adresse de la variable avec &.

Il est aussi possible de modifier une variable avec ``set variable [nom_variable] = [valeur]``.
De façon similaire avec ``print [nom_variable] = [valeur]``.

info reg [registre] affiche les informations sur tous les registres si aucun registre n’est explicitement spécifié. info reg eax donne le même résultat que print $eax.

Il est intéressant de noter qu'il est possible d'afficher une variable sous le format spécifié. Pour cela, remplacer ``print`` par :
* ``p/x`` - affiche en format hexadécimal la variable spécifiée
* ``p/d`` - en format entier signé
* ``p/f`` - en format floating point
* ``p/c`` - affiche un caractère.

backtrace ou bt affiche la pile des appels de fonctions.

Il est possible de naviguer dans la pile des appels à l'aide de ``up`` et ``down``. Ces deux commandes montent et descendent respectivement dans la pile. C'est très utile car il est possible de modifier le contexte dans lequel on se trouve pour afficher les variables.

info frame donne des informations sur la frame actuelle.

list affiche les lignes de codes entourant le break. On peut donc facilement voir le code posant un problème ou analyser le code avant de faire une avancée pas à pas.

show args affiche les arguments passés au programme.

info breakpoints affiche les breakpoints

info diplays affiche les displays

info func [fonctionname] affiche le prototype d’une fonction

Avancement de l’exécution

Quand vous avez acquis suffisamment d’informations sur le programme, vous avez plusieurs choix pour continuer son exécution :

next exécute la prochaine instruction de votre code source, mais sans rentrer dans des fonctions externes.

step exécute la prochaine instruction de votre code source, mais en entrant dans le code des fonctions appelées.

continue continue le reste de l’exécution jusqu’au prochain breakpoint.

Automatisation

Lors d’un débuggage long et fastidieux, il est parfois nécessaire d’exécuter certaines commandes à chaque breakpoint.

commands [numerobreakpoint] définit une liste de commandes associées à un breakpoint. Celles ci seront exécutées quand on s’arrêtera sur ce breakpoint. Il suffit de taper les commandes à effectuer les unes après les autres et de terminer par end. Si vous ne fournissez pas de numéro, les commandes sont assignées au dernier breakpoint créé.

display [variablename] affiche la variable à chaque breakpoint.

Gestion des Signaux

En plus des breakpoints, gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] interrompt l’exécution du programme en cours lorsqu’il intercepte certains signaux d’erreurs comme les signaux SIGSEGV et SIGINT. gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] permettra alors de corriger plus facilement certaines erreurs comme les erreurs de segmentation ou les problèmes de deadlocks.

Il est possible de gérer le comportement de gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] lorsque des signaux sont interceptés. Tout d’abord, la commande info signals permet d’afficher la liste des signaux reconnus par gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] ainsi que la façon dont il les traite (par exemple interrompre le programme en cours ou non). On peut changer la façon de traiter un signal avec la commande handle [SIGNAL] [HANDLING...] où [SIGNAL] est le signal à intercepter (son numéro ou son nom complet) et [HANDLING] la façon de traiter ce signal par gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] 1. Par exemple, la commande handle SIGALRM stop print permet d’interrompre le programme et d’afficher un message quand gdb intercepte le signal SIGALRM.

Localiser un signal

Avec gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html], il est possible de localiser un signal et de débugger certaines erreurs comme une erreur de segmentation. En effet, lorsque gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] interrompt le programme en cours après l’interception d’un signal d’erreur comme SIGSEGV, il est possible de trouver la ligne du programme à laquelle le signal a été intercepté en tapant le mot-clé where une fois le programme interrompu (il est cependant nécessaire d’avoir compilé le programme avec l’option -g de gcc pour trouver la ligne précise). Ensuite, grâce aux commandes expliquées plus tôt, il est possible de vérifier les valeurs des variables lors de l’interception du signal pour trouver l’origine du problème.

En plus de localiser facilement les erreurs de segmentation dans un programme, vous pourrez analyser plus aisément les problèmes de deadlock des threads. En effet, lorsque le programme est lancé sur le shell et que vous remarquez un deadlock, vous pouvez appuyer sur CTRL + C pour lancer le signal SIGINT au programme. Cela permettra de trouver les endroits où bloquent les différents threads du programme à l’aide des commandes décrites dans la section de débuggage des threads ci-dessous.

Extraction de code assembleur

disas affiche le code assembleur

disas /m blah met en correspondance le code assembleur et le code source

Pour arrêter la console de gdb, tapez quit.

Illustration avec des exemples

Premier programme

Le premier programme est src/calc.c. Compilez-le et exécutez le pour vous apercevoir que le programme est erroné. A priori vous avez peu, ou pas, d’informations sur l’erreur. Lancez donc gdb à l’aide de gdb calc puis lancez le programme avec run.

Program received signal SIGFPE, Arithmetic exception.   => Exception arithmetique
0x0000000000400553 in calc (a=165, b=4) at calc.c:10    => Dans la fonction calc du fichier calc.c à la ligne 10

10                      res = (a*5 -10) / (b-i);        => Affichage de la ligne problématique

Le premier réflexe doit être list pour observer le code. Puisque le problème vient de la ligne 10 dans la boucle, nous allons nous arrêter à la ligne 10 avec break 10 et relancer le programme. Le programme va s’arrêter avant le début de la boucle. Utilisez print a et print b pour connaître les arguments reçus par calc.

Il est intéressant de noter une particularité du language C par rapport à java : une variable déclarée n'est pas initialisée à 0 par défaut, elle reprend juste la valeur de la mémoire avant son affectation. ``print i`` et ``print res`` vous donneront donc des résultats aléatoires.

Puisque le problème vient du calcul arithmétique, placez un break sur cette ligne pour pouvoir observer à chaque itération les variables. break 9 puis commands qui permet d’automatiser des commandes. Nous rajouterons comme commandes :

* ``echo i : ``
* ``print i``
* ``echo b : ``
* ``print b``
* ``echo numerateur : ``
* ``print a*5 -10``
* ``echo denominateur : ``
* ``print b-i``
* et enfin ``end`` pour terminer la liste de commandes.

Il ne reste plus qu’à avancer avec continue pour aller de breakpoint en breakpoint et d’observer les variables pour comprendre le problème. On va pouvoir deviner que le problème vient d’un dénominateur nul. Pour résoudre ce problème, il faut passer une valeur plus grande que 6 à calc lors de son appel depuis la fonction main. list main suivi de plusieurs list permet de visualiser la main. On peut repérer l’appel de la fonction calc à la ligne 18.

Supprimez les anciens break avec delete [numerobreakpoint] le numéro du breakpoint est connu via info break. Rajoutez un break à la ligne 18, break 18 et lancez le programme. set variable m = 10 pour assigner la valeur 10 à la variable m. Puis continuez l’exécution du programme. Celui se terminera normalement puisque il n’y a plus de division par zéro.

Deuxième programme

Le deuxième programme est appelé src/recursive.c. Celui ne présente aucun bug et se déroulera normalement. Toutefois, il est intéressant d’utiliser gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] pour bien comprendre les différents contextes au sein d’un programme. Mettez un break sur la fonction factTmp avec break factTmp et ajoutez automatiquement à ce breakpoint la commande backtrace, via commands. Ensuite, lancez le programme. backtrace vous permet de visualiser les appels de fonction effectués. Nous pouvons voir que la fonction factTmp a été appelée par factTerminal, elle même appelée par la fonction main.

#0  factTmp (acc=1, nbr=6) at recursive.c:8
#1  0x000000000040057d in factTerminal (a=6) at recursive.c:17
#2  0x0000000000400598 in main (argc=1, argv=0x7fffffffe1b8) at recursive.c:23

Essayez d’afficher les variable globalVar puis localVar. Vous remarquerez qu’il n’est pas possible d’afficher localVar puisque cette variable ne fait pas partie de l’environnement contextuel de factTmp. Pour afficher cette variable, il faut remonter la liste des appels. up permettra de remonter les appels pour pouvoir afficher localVar. Une fois la variable affichée, redescendez avec down et continuez 4 fois le programme après le breakpoint. Vous remarquerez que la liste des appels s’allonge à chaque appel récursif, ce qui est tout à fait normal.

Naviguez dans les appels récursifs de factTmp en affichant les valeur de globalTmp, tmp, acc et nbr. Il est important de bien comprendre que la variable statique globalTmp est commune à tous les appels de la fonction factTmp et un changement de cette variable dans un des appels récursifs modifie la variable des autres appels. A contrario, la variable local ainsi que les arguments sont propres à chaque appel.

Vous pouvez maintenant terminer le programme.

Troisième programme

Le troisième programme est src/tab.c. Compilez-le. Ce programme s’exécute correctement, et pourtant, il y contient une erreur. Lancez le programme avec gdb et mettez un breakpoint sur la première instruction, à savoir la ligne 9. Pour comprendre un problème sans savoir où commencer, il est utile de suivre l’évolution des variables.

Il est important de savoir que ``print``, ainsi que ``display``, supportent les expressions telles que :
        * tab[1], tab[i],...
        * &i, *i,...

Avancez instruction par instruction, avec step ou next et portez attention aux valeurs de tab[i] par rapport à i. Une fois le problème trouvé avec gdb, solutionnez le.

Plus d’informations sur gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] peuvent être trouvées sur:

https://www.cprogramming.com/gdb.html

https://developer.ibm.com/articles/l-gdb/

https://www.rocq.inria.fr/secret/Anne.Canteaut/COURS_C/gdb.html

Débuggage des threads avec GDB

gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] est aussi utile pour débugger des programmes avec des threads. Il permet de faire les opérations suivantes sur les threads:

Recevoir une notification lors de la création d’un nouveau thread.

Afficher la liste complète des threads avec info threads.

Placer un breakpoint dans un thread. En effet, si vous placez un breakpoint dans une certaine fonction, et un thread passe lors de son exécution à travers ce breakpoint, gdb va mettre l’exécution de tous les threads en pause et changer le contexte de la console gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] vers ce thread.

Lorsque les threads sont en pause, vous pouvez manuellement donner la main à un thread en faisant thread [thread_no] avec thread_no étant l’indice du thread comme indiqué par info threads

D’autres commandes pour utiliser gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] avec les threads:

https://sourceware.org/gdb/current/onlinedocs/gdb/Threads.html

Footnotes

1

Une liste plus complète des mots-clés utilisables pour modifier le comportement de gestion des signaux peut-être consultée ici : ftp://ftp.gnu.org/old-gnu/Manuals/gdb/html_node/gdb_38.html .

Valgrind

Plusieurs outils en informatique peuvent vous aider à localiser des bugs dans vos programmes. Parmi ceux-ci, voici deux outils particulièrement utiles pour les problèmes liés à la mémoire :

valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] vous permet de détecter des erreurs liés à la gestion de la mémoire (malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html], free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html],…)

gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] permet de voir ce qui se passe « à l’intérieur » de votre programme et comment les variables évoluent.

Le site https://www.cprogramming.com/debugging/ vous donne des techniques de débuggage plus détaillées et explique valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] et gdb(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gdb.1.html] plus en détails.

Valgrind permet de détecter des erreurs liées à la gestion de la mémoire dans vos programmes. Pour utiliser valgrind, lancez la commande valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] avec votre exécutable comme argument:

Parmi les erreurs que valgrind est capable de détecter nous avons:

Mémoire non-désallouée: Lors d’un appel à malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html], vous obtenez un pointeur vers une zone de mémoire allouée. Si vous « perdez » la valeur de ce pointeur, vous n’avez plus le moyen de libérer cette zone de mémoire. Essayez valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] avec le petit programme src/nofree.c

Désallouer deux fois la même zone de mémoire: Si vous appelez deux fois free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] sur la même zone de mémoire, valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] va détecter cette erreur. Essayez-le avec le petit programme src/twofree.c

Accès en dehors des limites d’une zone mémoire: Si vous allouez une zone de mémoire d’une certaine taille (par exemple un table de 10 chars) et que vous accédez à une adresse qui excède cette zone (par exemple vous accédez au 11ième élément) vous aurez probablement une Segmentation fault. Valgrind permet de détecter ces erreurs et indique l’endroit dans votre code où vous faites cet accès. Essayez-le avec le petit programme src/outofbounds.c

On vous encourage à lancer valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] sur votre projet pour vérifier que vous n’avez pas introduit de memory-leaks sans le vouloir. valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] ne remplace pas une écriture attentive du code mais peut permettre de détecter rapidement certaines erreurs courantes. Vous trouverez plus de détails sur les liens suivants:

https://www.cprogramming.com/debugging/valgrind.html

https://valgrind.org

Les bases de valgrind

Commençons par le programme le plus simple possible que nous allons tester à l’aide de valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html]:

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
   printf("Hello, 1252 !\n");
   return EXIT_SUCCESS;
}

Après compilation et l’exécutions avec valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] nous obtenons :

$ gcc -o hello hello.c
$ ./hello
Hello, 1252 !
$ valgrind ./hello
==13415== Memcheck, a memory error detector
==13415== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==13415== Using Valgrind-3.6.0 and LibVEX; rerun with -h for copyright info
==13415== Command: ./hello
==13415==
Hello, 1252 !
==13415==
==13415== HEAP SUMMARY:
==13415==     in use at exit: 0 bytes in 0 blocks
==13415==   total heap usage: 0 allocs, 0 frees, 0 bytes allocated
==13415==
==13415== All heap blocks were freed -- no leaks are possible
==13415==
==13415== For counts of detected and suppressed errors, rerun with: -v
==13415== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 6 from 6)

Nous pouvons lire dans ce rapport plusieurs informations importante comme le HEAP SUMMARY qui résume l’utilisation du tas. Dans notre cas particulier, on peut voir que rien n’a été alloué (en effet, il n’y a pas eu de malloc) et rien n’a été libéré.

L” ERROR SUMMARY indique le nombre d’erreurs détectées.

La phrase que nous voulons voir après chaque exécution de valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] est:

All heap blocks were freed -- no leaks are possible

Ce qui indique qu’aucun memory leak ne peut avoir lieu dans notre programme.

Détecter les memory leaks

A présent nous allons montrer comment détecter des fuites de mémoire dans un programme à l’aide de valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html]. Testons le programme src/nofree.c:

$ gcc -o nofree nofree.c
$ valgrind ./nofree
==13791== Memcheck, a memory error detector
==13791== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==13791== Using Valgrind-3.6.0 and LibVEX; rerun with -h for copyright info
==13791== Command: ./nofree
==13791==
==13791==
==13791== HEAP SUMMARY:
==13791==     in use at exit: 6 bytes in 1 blocks
==13791==   total heap usage: 1 allocs, 0 frees, 6 bytes allocated
==13791==
==13791== LEAK SUMMARY:
==13791==    definitely lost: 6 bytes in 1 blocks
==13791==    indirectly lost: 0 bytes in 0 blocks
==13791==      possibly lost: 0 bytes in 0 blocks
==13791==    still reachable: 0 bytes in 0 blocks
==13791==         suppressed: 0 bytes in 0 blocks
==13791== Rerun with --leak-check=full to see details of leaked memory
==13791==
==13791== For counts of detected and suppressed errors, rerun with: -v
==13791== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 6 from 6)

Nous remarquons directement que cette fois ci des leaks ont été trouvés par valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html]. Celui ci indique en effet la perte de 6 bytes de mémoire sur le tas qui ont été alloués par 1 malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et qui n’ont pas été libérés avant le return.

Maintenant nous savons que nous avons un memory leak, mais valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] peut faire plus que seulement les détecter, il peut aussi trouver où ont ils lieu. Nous remarquons dans le rapport qu’il conseil de relancer le test avec cette fois ci l’option --leak-check=full pour avoir plus de détails sur notre fuite. Nous avons dés lors de nouvelles informations dans HEAP SUMMARY :

==13818== 6 bytes in 1 blocks are definitely lost in loss record 1 of 1
==13818==    at 0x4A05FDE: malloc (vg_replace_malloc.c:236)
==13818==    by 0x4004DC: main (nofree.c:5)

La fuite a donc lieu à la ligne 5 de notre programme qui correspond à:

char *ptrChars = (char *)malloc(6 * sizeof(char));

On sait maintenant quel est le malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] responsable du leak, et il est facile de l’éviter en écrivant free(ptrChars); avant le return.

Double free

valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] ne se contente pas seulement de trouver des memory leaks, il est aussi capable de détecter des doubles free qui peuvent engendrer des corruptions de mémoire. Pour montrer cette fonction de valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] nous utilisons le petit programme src/twofree.c.

$ valgrind ./twofree
==13962== Memcheck, a memory error detector
==13962== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==13962== Using Valgrind-3.6.0 and LibVEX; rerun with -h for copyright info
==13962== Command: ./twofree
==13962==
==13962== Invalid free() / delete / delete[]
==13962==    at 0x4A0595D: free (vg_replace_malloc.c:366)
==13962==    by 0x40053F: main (in twofree.c:8)
==13962==  Address 0x4c2d040 is 0 bytes inside a block of size 6 free'd
==13962==    at 0x4A0595D: free (vg_replace_malloc.c:366)
==13962==    by 0x400533: main (in twofree.c:8)
==13962==
==13962==
==13962== HEAP SUMMARY:
==13962==     in use at exit: 0 bytes in 0 blocks
==13962==   total heap usage: 1 allocs, 2 frees, 6 bytes allocated
==13962==
==13962== All heap blocks were freed -- no leaks are possible
==13962==
==13962== For counts of detected and suppressed errors, rerun with: -v
==13962== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 6 from 6)

Ici valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] nous indique qu’il a trouver une erreur et qu’il s’agit d’un Invalid free() à la ligne 8 de notre programme. Facilement trouvé et corrigé!

Segmentation Fault

Les segmentation faults sont des erreurs courantes lors de la programmation en C/C++. Elles ont lieu lors de l’accès à des zones de mémoire non-allouées. valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] permet de facilement trouver l’origine des segfaults et de les corriger. Démonstration avec src/outofbounds.c:

$ gcc -g -o outofbounds outofbounds.c

Il est important de compiler avec le drapeau -g pour dire au compilateur de garder les informations de débuggage.

$ ./outofbounds
Segmentation fault
$ gcc -g -o outofbounds outofbounds.c
$ ./outofbounds
$ valgrind ./outofbounds
==14236== Memcheck, a memory error detector
==14236== Copyright (C) 2002-2010, and GNU GPL'd, by Julian Seward et al.
==14236== Using Valgrind-3.6.0 and LibVEX; rerun with -h for copyright info
==14236== Command: ./outofbounds
==14236==
==14236== Invalid write of size 1
==14236==    at 0x400530: main (outofbounds.c:7)
==14236==  Address 0x4c2d04c is 6 bytes after a block of size 6 alloc'd
==14236==    at 0x4A05FDE: malloc (vg_replace_malloc.c:236)
==14236==    by 0x40051C: main (outofbounds.c:5)
==14236==
==14236==
==14236== HEAP SUMMARY:
==14236==     in use at exit: 0 bytes in 0 blocks
==14236==   total heap usage: 1 allocs, 1 frees, 6 bytes allocated
==14236==
==14236== All heap blocks were freed -- no leaks are possible
==14236==
==14236== For counts of detected and suppressed errors, rerun with: -v
==14236== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 6 from 6)

valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] trouve bien une erreur, à la ligne 7 de notre petit programme.

Détecter les deadlocks avec valgrind

valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] contient des outils qui vont au-delà des simples tests de l’allocation de la mémoire. Notamment l’outil helgrind permet de détecter des deadlocks. Utilisez helgrind sur le petit programme ./src/thread_crash.c en faisant:

$ valgrind --tool=helgrind [my binary]

==24314== Helgrind, a thread error detector
==24314== Copyright (C) 2007-2010, and GNU GPL'd, by OpenWorks LLP et al.
==24314== Using Valgrind-3.6.1-Debian and LibVEX; rerun with -h for copyright info
==24314== Command: ./thread_crash
==24314==
==24314== Thread #2 was created
==24314==    at 0x512E85E: clone (clone.S:77)
==24314==    by 0x4E36E7F: do_clone.constprop.3 (createthread.c:75)
==24314==    by 0x4E38604: pthread_create@@GLIBC_2.2.5 (createthread.c:256)
==24314==    by 0x4C29B23: pthread_create_WRK (hg_intercepts.c:257)
==24314==    by 0x4C29CA7: pthread_create@* (hg_intercepts.c:288)
==24314==    by 0x400715: main (in /home/christoph/workspace/SINF1252/SINF1252/2012/S6/src/thread_crash)
==24314==
==24314== Thread #2: Exiting thread still holds 1 lock
==24314==    at 0x4E37FB6: start_thread (pthread_create.c:430)
==24314==    by 0x512E89C: clone (clone.S:112)

Plus d’informations sur:

https://valgrind.org/docs/manual/hg-manual.html

Introduction aux Makefiles

Les Makefiles sont des fichiers utilisés par le programme make(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/make.1.html] afin d’automatiser un ensemble d’actions permettant la génération de fichiers, la plupart du temps résultant d’une compilation.

Un Makefile est composé d’un ensemble de règles de la forme:

target [target ...]: [component ...]
    [command]
    ...
    [command]

Chaque règle commence par une ligne de dépendance qui définit une ou plusieurs cibles (target) suivies par le caractère : et éventuellement une liste de composants (components) dont dépend la cible. Une cible ou un composant peut être un fichier ou un simple label. Chacune des commandes (command) est simplement une ligne de commande shell. Les commandes seront exécutées dans l’ordre de la cible du Makefile. Les commandes peuvent donc être n’importe quelle ligne de commande shell, mais les Makefiles sont en général utilisés pour automatiser la compilation de projets informatiques, et dans ce cas les lignes de commandes s’occuperont de la compilation (par exemple avec la commande `gcc).

Il est important de se rendre compte que l’espacement derrière les command doit impérativement commencer par une tabulation. Ça ne peut pas commencer par des espaces. Il ne faut pas non plus confondre la touche tabulation du clavier qui est souvent interprétée par les éditeurs de texte par une indentation et le caractère de tabulation (souvent écrit \t comme en C ou en bash) qui sont souvent affichés avec 2, 3, 4 ou 8 espacements en fonction des préférences de l’utilisateur. On parle bien ici du caractère de tabulation. Heureusement, bien que beaucoup de gens configurent leur éditeur de texte pour indenter avec des espaces, la plupart des bons éditeurs reconnaissent que c’est un Makefile et indentent avec des tabulations.

Le fichier suivant reprend un exemple de règle où la cible et le composant sont des fichiers.

text.txt: name.txt
    echo "Salut, " > text.txt
    cat name.txt >> text.txt

Pour exécuter les commandes fournies dans un Makefile, il suffit d’appeler la commande shell make dans le dossier où se situe le Makefile. Lorsque make est exécuté en utilisant ce Makefile, on obtient:

$ make
make: *** No rule to make target `name.txt', needed by `text.txt'.  Stop.

Comme text.txt dépend de name.txt, il faut que ce dernier soit défini comme cible dans le Makefile ou existe en tant que fichier. Si nous créons le fichier name.txt contenant Tintin et que make est ré-exécuté, on obtient la sortie suivante :

$ make
echo "Salut, " > text.txt
cat name.txt >> text.txt
$ cat text.txt
Salut,
Tintin

Si les fichiers de dépendance n’ont pas été modifiés, une prochaine exécution de la commande make ne fera rien, comme montré ci-dessous. Puisque les Makefiles sont en général utiliser pour compiler des projets, cela permet de ne pas recompiler un projet qui n’aurait pas été modifié.

$ make
make: `text.txt' is up to date.

Lorsqu’une dépendance change, make le détecte et ré-exécute les commandes associées à la cible. Dans le cas suivant, le fichier name.txt est modifié, ce qui force une nouvelle génération du fichier text.txt.

$ echo Milou > name.txt
$ make
echo "Salut, " > text.txt
cat name.txt >> text.txt
$ cat text.txt
Salut,
Milou

Comme spécifié précédemment, les Makefiles sont principalement utilisés pour automatiser la compilation de projets. Si un projet dépend d’un fichier source test.c, le Makefile permettant d’automatiser sa compilation peut s’écrire de la façon suivante:

test: test.c
    gcc -o test test.c

Ce Makefile permettra de générer un binaire test à chaque fois que le fichier source aura changé.

Les cibles (targets)

Comme indiqué ci-dessus, une règle d’un Makefile commence par une cible ou target. Cette cible peut indiquer le nom du fichier qui sera créé par la règle, ou simplement un nom simple pour la règle.

Soit un fichier Makefile contenant 2 règles:

target1:
    echo "Target 1"

target2:
    echo "Target 2"

En utilisant la commande make sans préciser de cible, c’est la première cible du Makefile qui est exécutée:

$ make
echo "Target 1"
Target 1

Il est également possible de préciser quelle cible exécuter, en donnant la cible en argument lorsqu’on appelle make:

$ make target1
echo "Target 1"
Target 1
$ make target2
echo "Target 2"
Target 2

Les variables

Les fichiers Makefile permettent d’utiliser des variables, qui permettent de stocker potentiellement n’importe quelle valeur. Ces variables peuvent être de deux types différents:

Les variables personnalisées, définies par l’utilisateur, et qui peuvent prendre n’importe quelle valeur.

Les variables automatiques, qui sont des raccourcis pour des valeurs déjà présentes dans le fichier.

Les deux types de variable seront présentés ci-après.

Variables personnalisées

Les variables personnalisées permettent d’associer un nom à potentiellement n’importe quelle valeur. Elles permettent de faciliter l’évolution du fichier, car si une valeur doit changer, on peut se contenter de modifier la variable associée, au lieu de devoir modifier toutes les règles. Celles-ci sont généralement définies au début du fichier, une par ligne comme :

CC = GCC
OPT = -ansi
VARIABLE_AU_NOM_TRES_LONG = 1

Notez que les noms sont écrits en majuscule par convention. Leur appel est semblable à celui en script shell (bash) excepté les parenthèses après le symbole $. On écrit par exemple $(CC), $(CFLAGS), $(VARIABLE_AU_NOM_TRES_LONG). Make autorise de remplacer les parenthèses par des accolades mais cette pratique est moins répandue.

CC = GCC
CFLAGS = -ansi

build:
    $(CC) $(CFLAGS) foo.c -o foo

Vous aurez compris qu’ici, la cible build effectue la commande gcc -ansi foo.c -o foo. Il est très intéressant de savoir que toutes les variables d’environnement présentes lors de l’appel au Makefile sont également disponibles avec la même notation. Vous pouvez donc très bien utiliser la variable $(HOME) indiquant le répertoire attribué à l’utilisateur sans la définir.

Il existe six différentes manières d’assigner une valeur à une variable. Nous ne nous intéresserons qu’à quatre d’entre elles.

La première permet de lier la variable à une valeur (ici value). Mais celle-ci ne sera évaluée qu’à son appel.

La seconde permet de déclarer une variable et de l’évaluer directement en même temps.

La troisième permet d’assigner une valeur à la variable uniquement si celle-ci n’en a pas encore.

La quatrième permet d’ajouter une valeur à une autre déjà déclarée.

Une description détaillée de ces méthodes d’assignation et des deux autres restantes se trouve à l’adresse suivante https://www.gnu.org/software/make/manual/make.html#Setting

Variables automatiques

Les variables automatiques sont des raccourcis, propres à la syntaxe des fichiers Makefile, qui permettent d’exprimer succinctement des valeurs déjà présentes dans le fichier. Elles sont utilisées dans les commandes formant les différentes règles. Elles sont en général formées de deux caractères spéciaux, le premier étant toujours $. Les plus utilisées seront présentées dans cette section.

La variable $@ référence le nom de la cible. Par exemple, pour compiler un exécutable prog, on peut utiliser la règle suivante:

prog: src.c
  gcc -o $@ src.c

La variable $< référence le nom de la première dépendance. Par exemple, pour compiler un exécutable prog, on peut utiliser la règle suivante:

prog: src.c
  gcc -o prog $<

La variable $^ référence la liste des dépendances. Par exemple, pour compiler un exécutable prog basé sur deux fichiers objets, on peut utiliser la règle suivante:

prog: src_1.o src_2.o
  gcc -o prog $^

D’autres variables existent, mais sont moins utilisées en pratique. Plus d’informations sont disponibles à l’adresse suivante: https://www.gnu.org/software/make/manual/html_node/Automatic-Variables.html.

Les conditions

Les variables ne servent pas uniquement à éviter la redondance d’écriture dans votre fichier. On peut aussi les utiliser pour réaliser des opérations conditionnelles comme :

DEBUG = 1

build:
ifeq ($(DEBUG), 1)
    gcc -Wall -Werror -o foo foo.c
else
    gcc -o foo foo.c
endif

Ici ifeq permet de tester un « si égal ». Il existe aussi l’opération opposée ifneq pour « si non-égal ». Remarquez que les conditions ne doivent pas être tabulées au risque d’obtenir une erreur de syntaxe incompréhensible. Les conditions peuvent avoir différentes syntaxes. Vous pouvez les trouver sur cette page https://www.gnu.org/software/make/manual/make.html#Conditional-Syntax

Avec les sections précédentes et la suivante nous allons pouvoir nous aventurer dans la création de Makefiles plus complexes. On peut vouloir effectuer des compilations différentes suivant l’environnement de l’utilisateur comme son OS, son matériel ou juste son nom. Encore une fois Make nous gâte en nous offrant la possibilité d’exécuter des commandes shell dans nos Makefiles. Imaginez avoir besoin d’options de compilation supplémentaires à cause de votre OS que seul vous avez besoin. Vous pouvez effectuer une compilation conditionnelle sur votre nom.

USER := $(shell whoami)

build:
ifeq ($(USER), sfeldman)
    gcc -I($HOME)/local/include -o foo foo.c
else
    gcc -o foo foo.c
endif

Ici $(shell whoami) est un appel à la fonction shell (de Make) qui nous permet d’assigner à la variable USER, en évaluant immédiatement l’appel, le résultat de la commande shell (bash) whoami renvoyant le nom de l’utilisateur actuel. Cela ne fonctionnera que si la commande whoami est disponible dans le shell évidemment.

La cible .PHONY

Make compare les dates de modification des fichiers produits avec les dates de leur(s) source(s) pour savoir si celles-ci ont été modifiées depuis leur dernière compilation. Cela lui permet de ne pas devoir recompiler des fichiers qui n’auraient pas changé d’un appel à l’autre. Malheureusement ce comportement qui peut sembler avantageux amène aussi des problèmes, en l’occurrence pour des règles ne produisant aucun fichier. Une solution pour pallier le problème consiste à indiquer que la règle ne crée rien. Pour faire cela il existe une cible spéciale .PHONY permettant de définir quelles règles doivent toujours être exécutées à nouveau. Ainsi une règle .PHONY ne rencontrera jamais le problème d’être déjà à jour. Une bonne pratique est de déclarer dans .PHONY toutes les règles de nettoyage de votre projet.

build:
    gcc -o foo foo.c

.PHONY: clean

clean:
    rm -f *.o

Cela est aussi pratique pour forcer une nouvelle compilation.

build:
    gcc -o foo foo.c

.PHONY: clean rebuild

clean:
    rm -f *.o foo

rebuild: clean build

Compléments

Cette section propose quelques compléments, utiles pour la création de fichiers Makefile plus complexes.

Règles d’inférence

Il est possible de définir des règles génériques, qui fonctionneront pour tous les fichiers qui correspondent à un pattern. Le pattern est alors exprimé avec le caractère %. Par exemple, pour compiler tous les fichiers sources, possédant l’extension .c, en fichiers objets correspondant, on peut utiliser la règle suivante:

%.o: %.c
    gcc -o $@ -c $^

Remarquez que cette règle utilise les variables automatiques, décrites plus haut.

Commentaires

Afin de rendre vos Makefiles plus lisibles, vous pouvez y insérer des commentaires en plaçant un croisillon en début de ligne. Cette syntaxe est semblable au script shell.

# Commentaire sur
# plusieurs lignes
build:
    gcc -o foo foo.c # commentaire en fin de ligne

Commandes silencieuses

Corriger les erreurs de vos Makefiles peut sembler difficile lorsque vous êtes baignés dans un flux d’instructions. Vous pouvez néanmoins régler leur verbosité. Il est possible de rendre silencieuse une commande en plaçant une arobase devant. Ceci indique juste à Make de ne pas imprimer la ligne de commande. La sortie standard de cette commande restera visible.

build:
    @echo "Building foo"
    @gcc -o foo foo.c

Pour plus d’informations en français sur l’écriture ou utilisation des Makefiles voir [DeveloppezMake].

Documentation complète en anglais sur le site officiel [GNUMake].

CUnit: librairie de tests

CUnit [http://cunit.sourceforge.net] est une librairie de tests unitaires en C. Cette librairie vous sera utile lors de développement de projets en C.

Installation

CUnit n’est pas installé par défaut sur les machines des salles. Vous devez donc l’installer par vous-même. Le reste de cette section a pour but de vous aider dans l’installation de celle-ci.

La première étape consiste à récupérer les sources de CUnit sur https://sourceforge.net/projects/cunit/files/. Les sources se trouvent dans une archive CUnit-*-src.tar.bz2 et la dernière version devrait se nommer CUnit-2.1-3-src.tar.bz2. Une fois l’archive téléchargée, ouvrez un terminal et placez-vous dans le dossier où se trouve celle-ci. Exécutez:

$ tar xjvf CUnit-2.1.-3.tar.bz2
$ cd CUnit-2.1-3
$ ./bootstrap
$ ./configure --prefix=$HOME/local
$ make
$ make install

Une fois ces commandes exécutées, la librairie ainsi que ses fichiers d’entête sont installés dans le dossier $HOME/local ($HOME est en fait une variable bash qui définit votre répertoire principal). Comme vous n’avez pas les droits administrateur, vous ne pouvez pas installer d’application ni de librairie dans les chemins classiques (c.-à-d., par exemple dans /usr/lib, /usr/include, /usr/bin). C’est pour cela que nous installons la librairie dans un dossier local.

Compilation, édition des liens et exécution

Comme la librairie n’est pas installée dans les chemins classiques, il faut pouvoir dire à gcc où se trouvent les fichiers d’entête ainsi que la librairie afin d’éviter les erreurs de compilation. Pour cela, il faut spécifier à la compilation l’argument -I${HOME}/local/include afin de lui dire qu’il doit également aller chercher des fichiers d’entête dans le dossier $HOME/local/include en plus des chemins classiques tels que /usr/include et /usr/local/include.

Lors de l’édition des liens avec le linker, il faut spécifier où se trouve la librairie dynamique afin de résoudre les symboles. Pour cela, il faut passer l’argument -lcunit pour effectuer la liaison avec la librairie CUnit ainsi que lui spécifier -L${HOME}/local/lib afin qu’il cherche également des librairies dans le dossier $HOME/local/lib.

Lors de l’exécution, il faut également spécifier où se trouvent les librairies. Par exemple pour un binaire test qui utilise la librairie CUnit, on peut exécuter:

$ export LD_LIBRARY_PATH=$HOME/local/lib:$LD_LIBRARY_PATH
$ ./test

Utilisation

Dans CUnit, on retrouve toutes les fonctions nécessaire pour gérer un ensemble de suites de tests. Cet ensemble forme un catalogue; il est composé d’une ou plusieurs suite(s) de tests; chaque suite est composée d’un ou plusieurs tests.

Pour pouvoir concrètement exécuter un ensemble de tests, il est nécessaire de réaliser les différentes étapes suivantes:

Programmer les tests

Initialiser le catalogue

Ajouter les suites de tests dans le catalogue

Ajouter les tests dans les suites de tests

Exécuter les tests

Terminer proprement l’exécution des tests

La suite de la section détaille chacune de ces étapes.

Tout d’abord, il est nécessaire d’écrire les tests. Aucune libraire ne peut les écrire pour vous. Toutefois, CUnit vient avec un certain nombre de macros permettant de vérifier les propriétés qui nous intéressent. Pour pouvoir utiliser ces macros, il est nécessaire d’importer CUnit.h. La table suivante récapitule les principales macros. Il est important d’appeler ces macros lorsque l’on rédige les tests, ce sont ces appels qui détermineront si oui ou non, le test est fructueux.

Assertion

Définition

CU_ASSERT(int expression)

Vérifie que la valeur est non-nulle (true).

CU_ASSERT_TRUE(value)

Vérifie que la valeur est non-nulle (true).

CU_ASSERT_FALSE(value)

Vérifie que la valeur est nulle (false).

CU_ASSERT_EQUAL(actual, expected)

Vérifie que actual est égal à expected.

CU_ASSERT_NOT_EQUAL(actual, expected))

Vérifie que actual n’est pas égal à expected.

CU_ASSERT_PTR_EQUAL(actual, expected)

Vérifie que le pointeur actual est égal au pointeur expected.

CU_ASSERT_PTR_NOT_EQUAL(actual, expected)

Vérifie que le pointeur actual est différent du pointeur expected.

CU_ASSERT_PTR_NULL(value)

Vérifie que le pointeur est NULL.

CU_ASSERT_PTR_NOT_NULL(value)

Vérifie que le pointeur n’est pas NULL.

CU_ASSERT_STRING_EQUAL(actual, expected)

Vérifie que la chaîne de caractère actual est égale à la chaîne de caractère expected.

CU_ASSERT_STRING_NOT_EQUAL(actual, expected)

Vérifie que la chaîne de caractère actual n’est pas égale à la chaîne de caractère expected.

CU_ASSERT_NSTRING_EQUAL(actual, expected, count)

Vérifie que les count premiers caractères de la chaîne actual sont égaux aux count premiers caractères de la chaîne expected.

CU_ASSERT_NSTRING_NOT_EQUAL(actual, expected, count)

Vérifie que les count premiers caractères de la chaîne actual ne sont pas égaux aux count premiers caractères de la chaîne expected.

CU_ASSERT_DOUBLE_EQUAL(actual, expected, granularity)

Vérifie que actual et expected ne diffèrent pas plus que granularity (\(|actual-expected| \leq |granularity|\))

CU_ASSERT_DOUBLE_NOT_EQUAL(actual, expected, granularity)

Vérifie que actual et expected diffèrent de plus que granularity (\(|actual-expected| > |granularity|\))

CU_PASS(message)

Ne vérifie rien mais notifie que le test est réussis

CU_FAIL(message)

Ne vérifie rien mais notifie que le test est raté

Par exemple, les méthodes ci-dessous vérifie chacune certaines propriétés.

void test_assert_true(void)
{
  CU_ASSERT(true);
}

void test_assert_2_not_equal_minus_1(void)
{
  CU_ASSERT_NOT_EQUAL(2, -1);
}

void test_string_equals(void)
{
  CU_ASSERT_STRING_EQUAL("string #1", "string #1");
}

void test_failure(void)
{
  CU_ASSERT(false);
}

void test_string_equals_failure(void)
{
  CU_ASSERT_STRING_EQUAL("string #1", "string #2");
}

Une fois les tests écrits, il faut initialiser le catalogue (et donc l’infrastructure de tests) en appelant la méthode CU_initialize_registry(). Cette méthode retourne un code d’erreur qu’il est impératif de vérifier pour s’assurer du bon fonctionnement de la vérification des tests. Par exemple,

if (CUE_SUCCESS != CU_initialize_registry())
  return CU_get_error();

Pour ajouter les suites de tests au catalogue, il faut faire appel à la méthode CU_add_suite(const char* strName, CU_InitializeFunc pInit, CU_CleanupFunc pClean). Comme on peut le voir, cette méthode demande un nom (qui doit être unique pour un catalogue) ainsi que deux pointeurs de fonction. Ces pointeurs de fonction permettent d’exécuter du code avant (typiquement appelé setup) ou après (typiquement teardown) l’exécution des tests de la suite. Ces méthodes sont utiles pour initialiser un environnement d’exécution pour des tests le nécessitants (par exemple, s’assurer de la présence de fichier, initialiser certaines variables, etc.). Ces méthodes sont bien sûr optionnelles, si aucune n’est nécessaire, il suffit alors de passer NULL en paramètre. Par ailleurs, notons que ces méthodes doivent retourner 0 si tout c’est bien passé, un chiffre positif dans le cas contraire. Comme pour l’initialisation du catalogue, il est bien entendu nécessaire de vérifier le code retourné par la méthode. La table suivante décrit les codes d’erreurs.

Code d’erreur

Définition

CUE_SUCCESS

Aucune erreur

CUE_NOREGISTRY

Erreur d’initialisation

CUE_NO_SUITENAME

Nom manquant

CUE_DUP_SUITE

Nom non unique

CUE_NOMEMORY

Pas de mémoire disponible

Par exemple, le code suivant crée une nouvelle suite de test nommée ma_suite, avec une fonction d’initialisation et une fonction de terminaison.

int setup(void)  { return 0; }
int teardown(void) { return 0; }
// ...
CU_pSuite pSuite = NULL;
// ...
pSuite = CU_add_suite("ma_suite", setup, teardown);
if (NULL == pSuite) {
  CU_cleanup_registry();
  return CU_get_error();
}

Les tests peuvent ensuite être ajoutés à la suite de test. Pour cela, il faut faire appel à la méthode CU_add_test(CU_pSuite pSuite, const char* strName, CU_TestFunc pTestFunc). Comme pour une suite de tests, il est nécessaire de préciser un nom. Ce nom doit être unique pour la suite de test. Le second paramètre est un pointeur vers la fonction de test. A nouveau, il est important de vérifier la valeur de retour de la méthode.

Code d’erreur

Définition

CUE_SUCCESS

Aucune erreur

CUE_NOSUITE

Suite de tests NULL

CUE_NO_TESTNAME

Nom manquant

CUE_DUP_TEST

Nom non unique

CUE_NO_TEST

Pointeur de fonction NULL ou invalide

CUE_NOMEMORY

Pas de mémoire disponible

Le code suivant ajoute les tests décrits ci-dessus à la suite de test que nous avons créé juste avant.

if ((NULL == CU_add_test(pSuite, "Test assert true", test_assert_true)) ||
    (NULL == CU_add_test(pSuite, "Test assert 2 not equal -1", test_assert_2_not_equal_minus_1)) ||
    (NULL == CU_add_test(pSuite, "Test string equals", test_string_equals)) ||
    (NULL == CU_add_test(pSuite, "Test failure", test_failure)) ||
    (NULL == CU_add_test(pSuite, "Test string equals failure", test_string_equals_failure)))
{
  CU_cleanup_registry();
  return CU_get_error();
}

Maintenant que le catalogue est initialisé, qu’il contient des suites de tests et que les tests ont été ajoutés à ces suites, il nous est possible d’exécuter ces tests. Il existe plusieurs moyens d’exécuter les tests CUnit, nous présentons uniquement le mode de base, non interactif. Pour les autres modes, referez-vous à la documentation [http://cunit.sourceforge.net/doc/running_tests.html]. Pour faire tourner les tests, il suffit d’appeler la méthode CU_basic_run_tests() qui appellera tous les tests dans toutes les suites des catalogues référencés. Ensuite, on peut afficher le rapport à l’aide de CU_basic_show_failures(CU_pFailureRecord pFailure) et CU_get_failure_list().

CU_basic_run_tests();
CU_basic_show_failures(CU_get_failure_list());

Avec le programme illustré ci-dessous, la console nous affiche les messages suivants :

     CUnit - A unit testing framework for C - Version 2.1-2
     http://cunit.sourceforge.net/

Suite ma_suite, Test Test failure had failures:
    1. cunit.c:24  - false
Suite ma_suite, Test Test string equals failure had failures:
    1. cunit.c:29  - CU_ASSERT_STRING_EQUAL("string #1","string #2")

Run Summary:    Type  Total    Ran Passed Failed Inactive
              suites      1      1    n/a      0        0
               tests      5      5      3      2        0
             asserts      5      5      3      2      n/a

Elapsed time =    0.000 seconds

  1. cunit.c:24  - false
  2. cunit.c:29  - CU_ASSERT_STRING_EQUAL("string #1","string #2")

Enfin, il est nécessaire de libérer les ressources en appelant CU_cleanup_registry().

Introduction à Git

git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] est un outil de version control, c’est-à-dire de gestion de code sous formes de versions. Il est utilisé pour sauvegarder chaque version d’un code source, depuis sa création jusqu’à la dernière version. Il est également très utile dans le cas où plusieurs programmeurs travaillent sur le même projet, car il permet de fusionner les modifications apportées par chaque membre de l’équipe et d’éviter les conflits de code, par exemple un fichier qui aurait été modifié conjointement par 2 personnes. Il est extrêmement utilisé en pratique, et permet de faciliter grandement la gestion du code source lors de projets de taille relativement grande, ou comprenant plusieurs programmeurs.

git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] a été développé initialement pour la gestion du code source du kernel Linux. Il est aussi utilisé pour la gestion des sources de ce document depuis https://github.com/obonaventure/SyllabusC. On l’utilise le plus souvent à l’aide de l’utilitaire git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] mais il existe aussi des applications graphiques [https://git-scm.com/downloads/guis].

Le code source est sauvegardé dans un dépôt, ou repository, qui est simplement un dossier contenant le code, et auquel chaque développeur a accès. Ce repository contient un historique de toutes les versions du code, depuis sa création. Chacune des différentes versions est enregistrée dans un commit, qui représente les modifications apportées aux différents fichiers du projet depuis la version précédente. On sait ainsi facilement voir ce qui a changé entre deux versions (pas spécialement une version et la suivante) et même restaurer l’état de certains fichiers à une version sauvegardée dans un commit. Du coup, si vous utilisez git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] pour un projet, vous ne pouvez jamais perdre plus que les changements que vous n’avez pas encore committé. Toutes les versions du codes déjà committées sont sauvegardées et facilement accessibles. Cette garantie est extrêmement précieuse et constitue à elle seule une raison suffisante d’utiliser git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] pour tous vos projets.

git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] est souvent utilisé conjointement avec une plateforme en ligne, comme GitHub [https://github.com] ou GitLab (via la Forge UCLouvain [https://forge.uclouvain.be/]), qui permet de centraliser le code source, pour que tous les collaborateurs puissent récupérer la dernière version, et qui permet également de consulter le code source en ligne.

Configuration de l’utilisateur

Lorsqu’on utilise git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html], chaque commit est documenté en fournissant le nom de l’auteur, son email, un commentaire et une description (optionnelle). Pour ne pas devoir spécifier le nom et l’email à chaque fois, il est possible de configurer git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] pour qu’il les stocke et les utilise pour chaque commit. Pour ce faire, il suffit d’utiliser la commande git-config(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-config.1.html]:

$ git config --global user.name "Your name"
$ git config --global user.email name@domain.com

L’option --global signifie que ces données seront utilisées pour chaque repository sur la machine. En enlevant l’option, les données seront donc uniquement utilisées pour le repository courant.

Création d’un repository

La première étape pour profiter des capacités de git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] pour un projet, est de créer un repository qui contiendra le code source du projet. La façon la plus simple de faire est de créer le repository depuis la plateforme en ligne (GitHub ou GitLab), puis le cloner en local. Pour ce faire, la première étape est de créer le repository sur la plateforme en ligne. Cela est relativement simple et ne sera pas décrit dans ce document. Ce repository sera appelé remote, car il n’est pas situé en local, mais sur un serveur distant accessible depuis l’Internet, ce qui permet à chaque utilisateur de le consulter pour obtenir la dernière version du code source. Une fois créé, il faut récupérer le lien du repository sur la page web du projet. Le lien peut être sous forme HTTPS ou SSH. Le premier est le choix de base, et le second est choisi pour utiliser une clé ssh pour s’identifier (voir la section SSH du syllabus pour plus d’informations). Ensuite, il faut cloner le repository en local, avec la commande git-clone(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-clone.1.html]:

$ git clone LIEN_DU_REPOSITORY
Cloning into 'NOM_DU_REPOSITORY'...

Cette commande va cloner le repository dans le dossier courant, de manière à avoir une copie locale du code source sur laquelle travailler.

Utilisation linéaire de Git

La manière la plus simple d’utiliser git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] est de façon linéaire, c’est-à-dire que chaque version du code (chaque commit) sera une modification de la précédente, par addition, modification, ou suppression de fichiers. Dans ce cas, après la création du repository contenant le projet, le travail sur le code source suit un schéma, qui est répété pour chaque modification, et qui est le suivant:

Récupération du dernier commit (git pull)

Modification du code source

Ajout des modifications au commit (git add)

Sauvegarde du commit (git commit)

Publication des changements sur le remote (git push)

Chacune de ces étapes sera décrite ci-après.

Récupération du dernier commit

Avant de travailler sur le code, il faut récupérer en local toutes les modifications qui auraient été apportées au remote entre temps. En effet, si on ne récupère pas ces modifications, des conflits peuvent apparaître, car des fichiers auraient été modifiées en même temps dans deux copies du repository.

Pour récupérer la dernière version du remote, il suffit d’exécuter la commande git-pull(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-pull.1.html]:

$ git pull

Cette commande va appliquer les derniers commits du remote à la copie locale du repository. Ensuite, on peux travailler sur le code et modifier les fichiers.

Ajout des modifications au commit

Lorsque des modifications ont été apportées au code, et qu’on veut les publier sur le remote pour que tous les développeurs aient accès à la dernière version, la première étape est de créer un commit contenant ces modifications.

Imaginons que le repository contient un fichier main.c (qui calcule la somme des entiers de 0 à n) qui a été modifié. On peut voir les fichiers qui ont été modifiés avec la commande git-status(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-status.1.html]:

$ git status
# On branch master
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#     modified:   main.c
#
no changes added to commit (use "git add" and/or "git commit -a")

Avec git-diff(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-diff.1.html], on peut voir quelles sont les lignes qui ont été retirées (elles commencent par un -) et celles qui ont été ajoutées (elles commencent par un +).

$ git diff
diff --git a/main.c b/main.c
index 86601ed..a9e4c4a 100644
--- a/main.c
+++ b/main.c
@@ -2,7 +2,12 @@
 #include <stdlib.h>

 int main (int argc, char *argv[]) {
-  long int sum = 0, i, n = 42;
+  long int sum = 0, i, n;
+  char *end = NULL;
+  n = strtol(argv[1], &end, 10);
+  if (*end != '\0') {
+    return EXIT_FAILURE;
+  }
   for (i = 1; i <= n; i++) {
     sum += i;
   }

Si les modifications nous conviennent, il suffit ensuite d’ajouter les fichiers modifiés au commit, avec la commande git-add(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-add.1.html]:

$ git add main.c

Il est également possible d’ajouter d’un coup tous les fichiers modifiés au commit en utilisant l’option --all de git-add(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-add.1.html]:

$ git add --all

Le commit a été crée, il faut maintenant le sauvegarder, puis le publier sur le remote.

Sauvegarde du commit

Une fois que le commit a été crée, il faut le sauvegarder, pour indiquer au repository qu’on est passé à une nouvelle version. Pour ce faire, on utilise la commande git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html]:

$ git commit

Cette commande va ouvrir un éditeur de texte pour indiquer un message décrivant le commit. Par défaut, l’éditeur est vim(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/vim.1.html]. Il s’agit d’un éditeur en ligne de commande, puissant mais très compliqué à utiliser pour les débutants. Il est possible de modifier l’éditeur par défaut en utilisant la commande git-config(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-config.1.html], déjà mentionnée plus haut. Un autre éditeur en ligne de commande, plus simple d’utilisation, est nano(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/nano.1.html]. Pour choisir nano(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/nano.1.html] comme éditeur par défaut, il suffit d’exécuter la commande suivante:

$ git config --global core.editor nano

Cependant, ouvrir un éditeur de texte à chaque commit peut vite devenir laborieux. En utilisant l’option -m de git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html], il est possible de spécifier le message décrivant le commit directement lors de l’appel à la commande git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html]:

$ git commit -m "Commit message"
[master 56ce59c] Commit message
 1 file changed, 6 insertions(+), 1 deletion(-)

Parmi les options de git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html], il existe aussi l’option -a qui peut s’avérer très utile. Cette option permet d’ajouter directement, lors de l’appel à git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html], toutes les modifications qui auraient été apportées à des fichiers déjà enregistrés dans le repository. Si de nouveaux fichiers ont été créés, l’option -a ne les prendra pas en compte, et il faudra alors passer par la commande git-add(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-add.1.html].

Il est finalement possible de combiner les options -m et -a, en utilisant l’option -am. Cette option permet donc, en une seule commande, d’ajouter toutes les modifications apportées aux fichiers déjà suivis, et de préciser le message du commit, de la façon suivante:

$ git commit -am "Commit message"
[master 56ce59c] Commit message
 1 file changed, 6 insertions(+), 1 deletion(-)

Il est alors possible de voir le nouveau commit dans l’historique du repository, en utilisant la commande git-log(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-log.1.html]:

$ git log
commit 56ce59c54726399c18b3f87ee23a45cf0d7f015d
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 25 15:37:51 2013 +0200

    Commit message

commit 3d18efe4df441ebe7eb2b8d0b78832a3861dc05f
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 25 15:32:42 2013 +0200

    First commit

Une fois que le commit a été enregistré, il reste à le publier sur le remote, pour que tous les développeurs du projet y aient accès.

Publication du commit sur le remote

Pour que tous les développeurs soient en mesure de voir les dernières modifications qui auraient été apportées en local, il faut que chaque développeur, après avoir créé et enregistré un commit, le publie sur le remote, qui est accessible par tous les développeurs via l’Internet. Pour ce faire, on utilise la commande git-push(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-push.1.html]:

$ git push
Counting objects: 5, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (2/2), done.
Writing objects: 100% (3/3), 291 bytes, done.
Total 3 (delta 1), reused 0 (delta 0)
To github.com:user/projectname.git
   80507e3..205842a  master -> master

De cette manière, chaque développeur qui voudrait à son tour apporter des modifications au projet, peut appliquer les mêmes étapes, et le remote contiendra toujours la dernière version du code. En résumé, les étapes sont:

git pull

Modification du code

git add

git commit

git push

Résolution de conflits

Lorsque plusieurs développeurs travaillent sur un même projet, il est possible qu’il apportent des modifications au code en même temps. Dans ce cas, pour le second développeur voulant push ses modifications, le push sera rejeté:

$ git push
To github.com:user/projectname.git
 ! [rejected]        master -> master (non-fast-forward)
error: failed to push some refs to 'github.com:user/projectname.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. Merge the remote changes (e.g. 'git pull')
hint: before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.

Cela est dû a fait que le remote a été modifié entre temps par un autre développeur, et donc que le dernier commit n’est pas le même sur le repository local et le remote.

Pour régler ce problème, on commence par faire un git pull. Deux cas de figure peuvent alors apparaître. Le premier cas, le plus simple, arrive lorsque les deux développeurs ont modifié des fichiers différents. Dans ce cas, le pull va réussir à fusionner les deux versions du repository, et produire un merge (une fusion). Un éditeur de texte s’ouvrira pour indiquer un message relatif au merge, et une fois ce message écrit, le merge sera effectué:

$ git pull
remote: Enumerating objects: 4, done.
remote: Counting objects: 100% (4/4), done.
remote: Total 4 (delta 3), reused 4 (delta 3), pack-reused 0
Unpacking objects: 100% (4/4), done.
From github.com:user/projectname
   4d38eb9..617618b  master     -> origin/master
Merge made by the 'recursive' strategy.
 main.c | 3 +++
 1 file changed, 3 insertions(+)

Il ne reste plus qu’à faire un git push pour que le merge soit publié sur le remote.

Le deuxième cas possible arrive lorsque les deux développeurs ont modifié le même fichier (par exemple main.c). Dans ce cas, le git pull n’arrivera pas à merge automatiquement:

$ git pull
remote: Counting objects: 5, done.
remote: Compressing objects: 100% (1/1), done.
remote: Total 3 (delta 1), reused 3 (delta 1)
Unpacking objects: 100% (3/3), done.
From github.com:user/projectname
   80507e3..205842a  master     -> origin/master
Auto-merging main.c
CONFLICT (content): Merge conflict in main.c
Automatic merge failed; fix conflicts and then commit the result.

git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] marque alors dans le fichier main.c la ligne en conflit et ce qu’elle vaut dans les deux commits:

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[]) {
<<<<<<< HEAD
  return EXIT_SUCCESS;
=======
  return 0;
>>>>>>> 205842aa400e4b95413ff0ed21cfb1b090a9ef28
}

La ligne située entre le marqueur HEAD et la ligne de séparation est la version présente en local, tandis que la ligne située après la ligne de séparation est celle présente sur le remote. Il est possible de retrouver quels sont les fichiers en conflit en utilisant git-status(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-status.1.html]:

$ git status
# On branch master
# You have unmerged paths.
#   (fix conflicts and run "git commit")
#
# Unmerged paths:
#   (use "git add <file>..." to mark resolution)
#
#    both modified:      main.c
#
no changes added to commit (use "git add" and/or "git commit -a")

Il suffit alors d’éditer le fichier en question, et de ne garder que le contenu voulu dans le fichier:

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[]) {
  return EXIT_SUCCESS;
}

Il faut ensuite commit et push les modifications pour sauvegarder la fusion:

$ git commit -am "Merge conflict"
[master eede1c8] Merge conflict
$ git push
Counting objects: 8, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (4/4), 478 bytes, done.
Total 4 (delta 2), reused 0 (delta 0)
To github.com:user/projectname.git
   205842a..eede1c8  master -> master

Le conflit sera alors résolu, et la dernière version du code sera disponible sur le remote.

Utilisation non-linéaire de Git

La puissance de git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] vient du fait qu’il est possible de créer des historiques non-linéaires, plus complexes que l’historique linéaire simple décrit jusqu’à présent. Pour cela, on utilise le concept de branches, qui représentent différentes modifications en parallèle du code source.

Branches

Un repository git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] est divisé en branches, qui représentent des évolutions différentes en parallèle du repository. Chaque commit est appliqué sur une seule branche. De cette manière, les branches sont une bonne manière de développer de nouvelles fonctionnalités, sans compromettre une version fonctionnelle du code.

Lors de l’utilisation linéaire de git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] décrite ci-dessus, toutes les modifications apportées au code se faisaient sur une seule branche, la branche master. Il s’agit de la branche de base, sur laquelle toutes les modifications sont apportées, si on ne créé pas explicitement de nouvelle branche. De base, l’historique d’un repository est donc le suivant:

Historique initial d’un repository

Les commits sont représentés en bleu, et les branches en rouge. L’indication HEAD représente l’état actuel du repository sur la copie locale.

Pour créer une nouvelle branche, on utilise la commande git-branch(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-branch.1.html], en spécifiant le nom de la nouvelle branche. On peut aussi utiliser cette commande sans argument pour montrer toutes les branches existantes, avec un symbole * pour indiquer la branche active (donc là où est situé le marqueur HEAD):

$ git branch branch_1
$ git branch
  branch_1
* master

L’historique est désormais le suivant:

Historique après création de branch_1

Pour supprimer une branche, on utilise la commande git-branch(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-branch.1.html], avec l’option -d, et en spécifiant le nom de la branche à supprimer:

$ git branch -d branch_1
$ git branch
* master

La création d’une branche ne change pas la branche active, ce qui signifie que les modifications apportées au code le seront toujours sur la branche master. Pour changer de branche active, il faut utiliser la commande git-checkout(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-checkout.1.html]:

$ git checkout branch_1
Switched to branch 'branch_1'
$ git branch
* branch_1
  master

Avec cette commande, le pointeur HEAD a été modifié, et pointe maintenant vers la branche branch_1. Désormais, les modifications seront bien apportées sur la branche branch_1.

Attention, lorsqu’on travaille sur une branche autre que master, les simples commandes git push ou git pull ne fonctionneront pas. A la place, il faut utiliser les commandes suivantes:

git push origin branch

git pull origin branch

Ces commandes fonctionnent également avec la branche master, en remplaçant le nom de la branche par master.

Fusionner des branches

En général, on utilise les branches pour développer de nouvelles fonctionnalités sans risquer de compromettre la base fonctionnelle du code. Lorsque la fonctionnalité est finie et est fonctionnelle, on veut pouvoir fusionner la branche de base (master) avec la branche utilisée pour développer la fonctionnalité (soit branch), en appliquant un merge. Pour ce faire, il y a deux possibilités:

Utiliser l’interface web de la plateforme (GitLab ou GitHub). Cette possibilité est la plus simple.

Utiliser la ligne de commande.

Fusionner des branches depuis l’interface web

La première possibilité est très simple. Un exemple sera donné ici avec GitLab, et est très similaire avec GitHub. Tout d’abord, depuis la page du repository, aller sur la page « Merge requests » (« Pull requests » sur GitHub):

Menu du repository sur GitLab

Créez une nouvelle merge/pull request. Il faut ensuite choisir les branches source et cible (target). La branche source sera celle avec la nouvelle fonctionnalité, dans notre cas la branche branch, tandis que la branche cible sera la branche de base, dans notre cas la branche master.

Sélection des branches source et cible

Il est possible d’inclure une description à la merge request, et de configurer plusieurs options, comme la personne qui doit s’occuper de la merge request, ou le fait que la branche source sera supprimée ou pas après la fusion. Une fois la merge request créée, s’il n’y a pas de conflit, les branches peuvent être fusionnées automatiquement:

Fusion automatique

Si les mêmes fichiers ont été modifiés sur les deux branches, il y a conflit, et il est donc impossible de fusionner les branches automatiquement:

Conflits lors de la fusion

Ces conflits peuvent être résolus directement depuis l’interface web, ou en fusionnant les branches localement, puis en réglant les conflits comme expliqué précédemment.

Fusionner des branches localement

Il est également possible de fusionner des branches localement, en utilisant la ligne de commande. Pour cela, on va utiliser la commande git-merge(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-merge.1.html], depuis la branche cible (master), pour la fusionner avec la branche source (branch). Si il n’y a pas de conflit, la fusion est automatique:

$ git merge branch
Updating 1f939f3..62cf363
Fast-forward
 branch.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

Si les mêmes fichiers ont été modifiés sur les deux branches, il y a conflit, et il faut donc résoudre ces conflits comme expliqué précédemment. Ici, il y a conflit sur le fichier branch.txt:

$ git merge branch
Auto-merging branch.txt
CONFLICT (content): Merge conflict in branch.txt
Automatic merge failed; fix conflicts and then commit the result.

Le fichier branch.txt a donc été marqué pour la résolution:

<<<<<<< HEAD
Test 1
=======
Test 2
>>>>>>> branch

Autres commandes utiles

Cette section présente d’autres commandes plus avancées de git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html], qui peuvent s’avérer utile.

Afficher l’historique

Pour afficher l’historique, outre l’outil utilisé pour faire les illustrations de ce cours que vous pouvez retrouver https://github.com/blegat/git-dot, il existe la commande git-log(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-log.1.html]. Elle est très flexible comme on va le voir. git log affiche simplement l’historique à partir de HEAD

$ git log
commit 0dd6cd7e6ecf01b638cd631697bf9690baedcf20
Merge: eda36d7 6fd2e9b
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 15:29:53 2013 +0200

    Merge branch 'universal'

    Conflicts:
        main.c

commit 6fd2e9bfa199fc3dbca4df87d225e35553d6cd79
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 15:06:14 2013 +0200

    Fix SIGSEV without args

commit eda36d79fd48561dce781328290d40990e74a758
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 14:58:29 2013 +0200

    Add pid/ppid info

...

Mais on peut aussi demander d’afficher les modifications pour chaque commit avec l’option -p

$ git log -p
commit 0dd6cd7e6ecf01b638cd631697bf9690baedcf20
Merge: eda36d7 6fd2e9b
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 15:29:53 2013 +0200

    Merge branch 'universal'

    Conflicts:
        main.c

commit 6fd2e9bfa199fc3dbca4df87d225e35553d6cd79
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 15:06:14 2013 +0200

    Fix SIGSEV without args

diff --git a/main.c b/main.c
index 8ccfa11..f90b795 100644
--- a/main.c
+++ b/main.c
@@ -9,7 +9,7 @@

 // main function
 int main (int argc, char *argv[]) {

 // main function
 int main (int argc, char *argv[]) {
-  if (strncmp(argv[1], "--alien", 8) == 0) {
+  if (argc > 1 && strncmp(argv[1], "--alien", 8) == 0) {
     printf("Hello universe!\n");
   } else {
     printf("Hello world!\n");

commit eda36d79fd48561dce781328290d40990e74a758
Author: Benoît Legat <benoit.legat@gmail.com>
Date:   Sun Aug 18 14:58:29 2013 +0200

    Add pid/ppid info

diff --git a/main.c b/main.c
index 8381ce0..b9043af 100644
--- a/main.c
+++ b/main.c
@@ -5,9 +5,11 @@
 // includes
 #include <stdio.h>
 #include <stdlib.h>
+#include <unistd.h>

 // main function
 int main () {
+  printf("pid: %u, ppid: %u\n", getpid(), getppid());
   printf("Hello world!\n");
   return EXIT_SUCCESS;
 }

Il existe encore plein d’autres options comme --stat qui se contente de lister les fichiers qui ont changés. En les combinant on peut obtenir des résultats intéressants comme ci-dessous

$ git log  --graph --decorate --oneline
*   0dd6cd7 (HEAD, master) Merge branch 'universal'
|\
| * 6fd2e9b Fix SIGSEV without args
| *   88d2c61 Merge branch 'master' into universal
| |\
| * | e0c317a Make it universal
* | | eda36d7 Add pid/ppid info
| |/
|/|
* | c35a8c3 Add Makefile
|/
* c1f2163 Add intro
* b14855e Add .gitignore
* bc620ce Add return
* 76c1677 First commit

On ajoute d’ailleurs souvent un raccourci pour avoir ce graphe avec git lol.

$ git config --global alias.lol "log --graph --decorate --oneline"

Sauvegarder des modifications hors de l’historique

On a vu que certaines opérations comme git-checkout(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-checkout.1.html] nécessitent de ne pas avoir de modifications en conflit avec l’opération.

git-stash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-stash.1.html] permet de sauvegarder ces modifications pour qu’elles ne soient plus dans le working directory mais qu’elles ne soient pas perdues. On peut ensuite les appliquer à nouveau avec git stash apply puis les effacer avec git stash drop.

Reprenons notre exemple de Changer la branche active illustré par la figure suivante

Historique après avoir ajouté un commentaire d’introduction

$ git checkout pid
Switched to branch 'pid'
$ echo "42" >> main.c
$ echo "42" >> .gitignore
$ git stash
Saved working directory and index state WIP on pid: b14855e Add .gitignore
HEAD is now at b14855e Add .gitignore
$ git checkout master
Switched to branch 'master'
$ git stash apply
Auto-merging main.c
# On branch master
# Changes not staged for commit:
#   (use "git add <file>..." to update what will be committed)
#   (use "git checkout -- <file>..." to discard changes in working directory)
#
#    modified:   .gitignore
#    modified:   main.c
#
no changes added to commit (use "git add" and/or "git commit -a")

On voit que les changements on été appliqués

$ git diff
diff --git a/.gitignore b/.gitignore
index cba7efc..5df1452 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,2 @@
 a.out
+42
diff --git a/main.c b/main.c
index 8381ce0..eefabd7 100644
--- a/main.c
+++ b/main.c
@@ -11,3 +11,4 @@ int main () {
   printf("Hello world!\n");
   return EXIT_SUCCESS;
 }
+42

On peut alors supprimer le stash

$ git stash drop
Dropped refs/stash@{0} (ae5b4fdeb8bd751449d73f955f7727f660708225)

Modifier un commit récent

Si on a oublié d’ajouter des modifications dans le dernier commit et qu’on ne l’a pas encore pushé, on peut facilement les rajouter. Il suffit de donner l’option --amend à git-commit(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-commit.1.html]. Il ajoutera alors les modifications au commit actuel au lieu d’en créer un nouveau.

On peut aussi annuler le dernier commit avec git reset HEAD^. git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] permet aussi de construire un commit qui a l’effet inverse d’un autre avec git-revert(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-revert.1.html]. Ce dernier construit un commit qui annulera l’effet d’un autre commit. Voyons tout ça par un exemple qui pourrait être le code de Deep Thought.

On a un fichier main.c contenant

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[]) {
  int *n = (int*) malloc(sizeof(int));
  *n = 42;
  printf("%d\n", *n);
  return EXIT_SUCCESS;
}

un Makefile contenant

run: answer
    echo "The answer is `./answer`"

answer: main.c
    gcc -o answer main.c

si bien qu’on a

$ make
gcc -o answer main.c
echo "The answer is `./answer`"
The answer is 42
$ make
echo "The answer is `./answer`"
The answer is 42
$ touch main.c
$ make
gcc -o answer main.c
echo "The answer is `./answer`"
The answer is 42

et un fichier .gitignore avec comme seul ligne answer.

Commençons par committer main.c et .gitignore en oubliant le Makefile.

$ git init
Initialized empty Git repository in /path/to_project/.git/
$ git status
# On branch master
#
# Initial commit
#
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#    .gitignore
#    Makefile
#    main.c
nothing added to commit but untracked files present (use "git add" to track)
$ git add .gitignore main.c
$ git commit -m "First commit"
[master (root-commit) 54e48c9] First commit
 2 files changed, 10 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 main.c
$ git log --stat --oneline
54e48c9 First commit
 .gitignore | 1 +
 main.c     | 9 +++++++++
 2 files changed, 10 insertions(+)
$ git status
# On branch master
# Untracked files:
#   (use "git add <file>..." to include in what will be committed)
#
#    Makefile
nothing added to commit but untracked files present (use "git add" to track)

On pourrait très bien faire un nouveau commit contenant le Makefile mais si, pour une quelconque raison, on aimerait l’ajouter dans le commit précédent, on peut le faire comme suit

$ git add Makefile
$ git commit --amend
[master 1712853] First commit
 3 files changed, 15 insertions(+)
 create mode 100644 .gitignore
 create mode 100644 Makefile
 create mode 100644 main.c
$ git log --stat --oneline
1712853 First commit
 .gitignore | 1 +
 Makefile   | 5 +++++
 main.c     | 9 +++++++++
 3 files changed, 15 insertions(+)

On voit qu’aucun commit n’a été créé mais c’est le commit précédent qui a été modifié. Ajoutons maintenant un check de la valeur retournée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] pour gérer les cas limites

$ git diff
diff --git a/main.c b/main.c
index 39d64ac..4864e60 100644
--- a/main.c
+++ b/main.c
@@ -3,6 +3,10 @@

 int main (int argc, char *argv[]) {
   int *n = (int*) malloc(sizeof(int));
+  if (*n == NULL) {
+    perror("malloc");
+    return EXIT_FAILURE;
+  }
   *n = 42;
   printf("%d\n", *n);
   return EXIT_SUCCESS;

et committons le

$ git add main.c
$ git commit -m "Check malloc output"
[master 9e45e79] Check malloc output
 1 file changed, 4 insertions(+)
$ git log --stat --oneline
9e45e79 Check malloc output
 main.c | 4 ++++
 1 file changed, 4 insertions(+)
1712853 First commit
 .gitignore | 1 +
 Makefile   | 5 +++++
 main.c     | 9 +++++++++
 3 files changed, 15 insertions(+)

Essayons maintenant de construire un commit qui retire les lignes qu’on vient d’ajouter avec git-revert(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-revert.1.html]

$ git revert 9e45e79
[master 6c0f33e] Revert "Check malloc output"
 1 file changed, 4 deletions(-)
$ git log --stat --oneline
6c0f33e Revert "Check malloc output"
 main.c | 4 ----
 1 file changed, 4 deletions(-)
9e45e79 Check malloc output
 main.c | 4 ++++
 1 file changed, 4 insertions(+)
1712853 First commit
 .gitignore | 1 +
 Makefile   | 5 +++++
 main.c     | 9 +++++++++
 3 files changed, 15 insertions(+)

Le contenu de main.c est alors

#include <stdio.h>
#include <stdlib.h>

int main (int argc, char *argv[]) {
  int *n = (int*) malloc(sizeof(int));
  *n = 42;
  printf("%d\n", *n);
  return EXIT_SUCCESS;
}

Comme c’est une bonne pratique de vérifier la valeur de retour de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html], supprimons ce dernier commit

$ git reset HEAD^
Unstaged changes after reset:
M    main.c
$ git log --oneline
9e45e79 Check malloc output
1712853 First commit

Corriger des bugs grâce à Git

git(1) permet de garder des traces des nombreux changements qui ont été effectué au cours de l’évolution d’un programme. Il contient d’ailleurs un outil très puissant vous permettant de retrouver la source de certaines erreurs, pourvu que les changements soient faits par petits commits : git-bisect(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git-bisect.1.html].

Supposez que vous ayez introduit une fonctionnalité dans votre programme. Tout allait alors pour le mieux. Quelques semaines plus tard, à votre grand dam, vous vous rendez compte qu’elle ne fonctionne plus. Vous sillonnez tous les fichiers qui pourraient interagir avec cette fonction, en vain. Dans le désespoir, à l’approche de la deadline, vous succombez au nihilisme.

Avant de tout abandonner, pourtant, vous réalisez quelque chose de très important. Ce que vous cherchez, c’est la source de l’erreur ; cela fait, la corriger sera sans l’ombre d’un doute une tâche aisée. Si seulement il était possible de voir à partir de quel changement le bug a été introduit…

C’est là que vous repensez à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] ! git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] connaît tous les changements qui ont été effectués, et vous permet facilement de revenir dans le passé pour vérifier si le bug était présent à un moment donné. En outre, vous vous rappelez vos cours d’algorithmiques et vous rendez compte que, puisque vous connaissez un point où le bug était présent et un autre ou il ne l’était pas, vous pouvez à l’aide d’une recherche binaire déterminer en un temps logarithmique (par rapport aux nombres de révisions comprises dans l’intervalle) quelle révision a introduit l’erreur.

C’est exactement l’idée derrière git-bisect(1) : vous donnez un intervalle de commits dans lequel vous êtes certains de pouvoir trouver le vilain commit responsable de tous vos maux, pour ensuite le corriger. Vous pouvez même entièrement automatiser cette tâche si vous pouvez, excellent programmeur que vous êtes, écrire un script qui renvoie 1 si le bug est présent et 0 si tout va bien.

Pour vous montrez comme utiliser cette fonctionnalité, et vous convaincre que cela marche vraiment, et pas seulement dans des exemples fabriqués uniquement dans un but de démonstration, nous allons l’appliquer à un vrai programme C : mruby, une implémentation d’un langage correspondant à un sous-ensemble de Ruby.

Intéressons nous à un des problèmes qui a été rapporté par un utilisateur [https://github.com/mruby/mruby/issues/1583]. Si vous lisez cette page, vous verrez qu’en plus de décrire le problème, il mentionne le commit à partir duquel il rencontre l’erreur. Si vous regardez aussi le commit qui l’a corrigée, vous verrez que le développeur a bien dû changer une ligne introduite dans le commit qui avait été accusé par l’utilisateur.

Mettons nous dans la peau de l’utilisateur qui a trouvé le bug, et tentons nous aussi d’en trouver la cause, en utilisant git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] . D’abord, il nous faut obtenir le dépôt sur notre machine (vous aurez besoin de Ruby afin de pouvoir tester), et revenir dans le passé puisque, depuis, l’erreur a été corrigée.

$ git clone git@github.com:mruby/mruby.git
(...)
$ cd mruby
$ git checkout 5b51b119ca16fe42d63896da8395a5d05bfa9877~1
(...)

Sauvegardons aussi le fichier de test proposé, par exemple dans ~/code/rb/test.rb :

class A
  def a
    b
  end
  def b
    c
  end
  def c
    d
  end
end
x = A.new.a

Vous devriez maintenant être capable de vérifier que la méthode A.a n’est pas incluse dans la backtrace :

$ make && ./bin/mruby ~/code/rb/test.rb
(...)
trace:
        [3] /home/kilian/code/rb/test.rb:9:in A.c
        [2] /home/kilian/code/rb/test.rb:6:in A.b
        [0] /home/kilian/code/rb/test.rb:13
/home/kilian/code/rb/test.rb:9: undefined method 'd' for #<A:0xdf1000> (NoMethodError)

C’est le moment de commencer. Il faut d’abord dire à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] que nous désirons démarrer une bissection et que le commit actuel est « mauvais », c’est à dire que le bug est présent. Ceci est fait en utilisant les deux lignes suivantes, dans l’ordre :

$ git bisect start
$ git bisect bad

Regardons ce qu’il en était quelque mois auparavant (remarquez qu’il faut utiliser make clean pour s’assurer de tout recompiler ici) :

$ git checkout 3a27e9189aba3336a563f1d29d95ab53a034a6f5
Previous HEAD position was 7ca2763... write_debug_record should dump info recursively; close #1581
HEAD is now at 3a27e91... move (void) cast after declarations
$ make clean && make && ./bin/mruby ~/code/test.rb
(...)
trace:
        [3] /home/kilian/code/rb/test.rb:9:in A.c
        [2] /home/kilian/code/rb/test.rb:6:in A.b
        [1] /home/kilian/code/rb/test.rb:3:in A.a
        [0] /home/kilian/code/rb/test.rb:13
/home/kilian/code/rb/test.rb:9: undefined method 'd' for #<A:0x165d2c0> (NoMethodError)

Cette fois-ci, tout va bien. Nous pouvons donc en informer git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] :

$ git bisect good
Bisecting: 116 revisions left to test after this (roughly 7 steps)
[fe1f121640fbe94ad2e7fabf0b9cb8fdd4ae0e02] Merge pull request #1512 from wasabiz/eliminate-mrb-intern

Ici, git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] nous dit combien de révisions il reste à vérifier dans l’intervalle en plus de nous donner une estimation du nombre d’étapes que cela prendra. Il nous informe aussi de la révision vers laquelle il nous a déplacé. Nous pouvons donc réitérer notre test et en communiquer le résultat à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] :

$ make clean && make && ./bin/mruby ~/code/test.rb
(...)
trace:
        [3] /home/kilian/code/rb/test.rb:9:in A.c
        [2] /home/kilian/code/rb/test.rb:6:in A.b
        [1] /home/kilian/code/rb/test.rb:3:in A.a
        [0] /home/kilian/code/rb/test.rb:13
/home/kilian/code/rb/test.rb:9: undefined method 'd' for #<A:0x165d2c0> (NoMethodError)
$ git bisect good
Bisecting: 58 revisions left to test after this (roughly 6 steps)
[af03812877c914de787e70735eb89084434b21f1] add mrb_ary_modify(mrb,a); you have to ensure mrb_value a to be an array; ref #1554

Si nous réessayons, nous allons nous rendre compte que notre teste échoue à présent (il manque la ligne [1]): nous somme allés trop loin dans le futur. Il nous faudra donc dire à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] que la révision est mauvaise.

$ make clean && make && ./bin/mruby ~/code/test.rb
(...)
trace:
        [3] /home/kilian/code/rb/test.rb:9:in A.c
        [2] /home/kilian/code/rb/test.rb:6:in A.b
        [0] /home/kilian/code/rb/test.rb:13
/home/kilian/code/rb/test.rb:9: undefined method 'd' for #<A:0x165d2c0> (NoMethodError)
$ git bisect bad
Bisecting: 28 revisions left to test after this (roughly 5 steps)
[9b2f4c4423ed11f12d6393ae1f0dd4fe3e51ffa0] move declarations to the beginning of blocks

Si vous continuez à appliquer cette procédure, vous allez finir par trouver la révision fautive, et git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] nous donnera l’information que nous recherchions, comme par magie :

$ git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[a7c9a71684fccf8121f16803f8e3d758f0dea001] better error position display
$ make clean && make && ./bin/mruby ~/code/rb/test.rb
(...)
trace:
        [3] /home/kilian/code/rb/test.rb:9:in A.c
        [2] /home/kilian/code/rb/test.rb:6:in A.b
        [0] /home/kilian/code/rb/test.rb:13
/home/kilian/code/rb/test.rb:9: undefined method 'd' for #<A:0x1088160> (NoMethodError)
$ git bisect bad
a7c9a71684fccf8121f16803f8e3d758f0dea001 is the first bad commit
commit a7c9a71684fccf8121f16803f8e3d758f0dea001
Author: Yukihiro "Matz" Matsumoto <matz@ruby-lang.org>
Date:   Tue Oct 15 12:49:41 2013 +0900

    better error position display

:040000 040000 67b00e2d4f6acadc0474e00fc0f5e6e13673c64a 036eb9c3b9960613bde3882b7a88ac6cabc56253 M      include
:040000 040000 5040dd346fea4d8f476d26ad2ede0dc49ca368cd 903f2d954d8686e7bfa7bcf5d83b80b5beb4899f M      src

Maintenant que nous connaissons la source du problème, il ne faut pas oublier de confirmer à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] que la recherche est bien terminée, et que nous désirons remettre le dépôt dans son état normal.

$ git bisect reset
Previous HEAD position was a7c9a71... better error position display
HEAD is now at 7ca2763... write_debug_record should dump info
recursively; close #1581

Automatisation de la procédure

Exécuter ce test à la main est cependant répétitif, prône aux erreurs d’inattention, et surtout très facile à automatiser. Écrivons donc un script qui vérifie que la ligne mentionnant A.a est bien présente à chaque fois, appelons le par exemple ~/code/sh/Iznogoud.sh. Il s’agit de renvoyer 0 si tout se passe bien et une autre valeur s’il y a un problème.

#!/usr/bin/env bash
make clean && make && ./bin/mruby ~/code/rb/test.rb 2>&1 | grep A\.a

Puisque grep renvoie 1 quand il ne trouve pas de ligne contenant le motif qu’on lui passe en argument et 0 sinon, notre script renvoie bien 1 si la sortie de mruby ne contient pas la ligne mentionnant A.a et 0 sinon.

N’oubliez pas de changer les permissions du script pour en permettre l’exécution :

$ chmod +x ~/code/sh/Iznogoud.sh

Ce test n’est en bien sûr pas infaillible, mais sera suffisant ici. Il faut d’abord redonner à git(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/git.1.html] l’intervalle dans lequel se trouve la révision fautive.

$ git bisect start
$ git bisect bad
$ git checkout 3a27e9189aba3336a563f1d29d95ab53a034a6f5
Previous HEAD position was 7ca2763... write_debug_record should dump info recursively; close #1581
HEAD is now at 3a27e91... move (void) cast after declarations
$ git bisect good
Bisecting: 116 revisions left to test after this (roughly 7 steps)
[fe1f121640fbe94ad2e7fabf0b9cb8fdd4ae0e02] Merge pull request #1512 from wasabiz/eliminate-mrb-intern

Il suffit maintenant d’utiliser git bisect run avec le nom du script pour l’utiliser. Il est possible de rajouter d’autres arguments après le nom du script, qui seront passés au script lors de chaque exécution. Par exemple, si vous avez dans votre Makefile une tâche test qui renvoie 0 si tous les tests passent et 1 si certains échouent, alors git bisect run make test permettrait de trouver à partir de quand les tests ont cessé de fonctionner.

Si vous exécutez la ligne suivante, vous devriez bien trouver, après quelques compilations, le même résultat qu’avant :

$ git bisect run ~/code/sh/Iznogoud.sh
(...)
a7c9a71684fccf8121f16803f8e3d758f0dea001 is the first bad commit
commit a7c9a71684fccf8121f16803f8e3d758f0dea001
Author: Yukihiro "Matz" Matsumoto <matz@ruby-lang.org>
Date:   Tue Oct 15 12:49:41 2013 +0900

    better error position display

:040000 040000 67b00e2d4f6acadc0474e00fc0f5e6e13673c64a 036eb9c3b9960613bde3882b7a88ac6cabc56253 M      include
:040000 040000 5040dd346fea4d8f476d26ad2ede0dc49ca368cd 903f2d954d8686e7bfa7bcf5d83b80b5beb4899f M      src
bisect run success

À nouveau, n’oubliez pas d’utiliser git bisect reset avant de continuer à travailler sur le dépôt.

SSH

ssh(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ssh.1.html] (Secure Shell) est un outil qui permet de se connecter depuis l’Internet à la console d’une autre machine et donc d’y exécuter des commandes. Dans l’infrastructure INGI vous pouvez vous connecter via ssh aux différents machines des salles en utilisant votre login et mot de passe INGI. Pour savoir les noms de machines, visitez le student-wiki [https://wiki.student.info.ucl.ac.be/Mat%C3%A9riel/Mat%C3%A9riel].

Depuis l’extérieur vous devez passer via studssh.info.ucl.ac.be pour ensuite pouvoir vous connecter sur les machines des salles.

Quelques exemples d’utilisation de ssh(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ssh.1.html] qui vous seront utiles:

ssh [username]@[hostname]: Avec ceci vous pouvez vous connecter à la machine hostname. Exemple: ssh myUserName@yunaska.info.ucl.ac.be pour vous connecter à la machine yunaska de la salle intel. Il faut d’abord se connecter à sirius avant de se connecter aux machines des salles.

ssh -X [username]@[hostname]: L’option -X vous permet d’exécuter des programmes sur la machine distante mais en voyant l’interface graphique en local sur votre machine (pour autant qu’elle supporte X11 [https://sites.uclouvain.be/SyllabusC/notes/Theorie/glossaire.html#term-x11]). Exemple: ssh -X myUserName@yunaska.info.ucl.ac.be et ensuite dans le terminal gedit test.c pour ouvrir l’éditeur de texte.

ssh [username]@[hostname] [commande]: Pour exécuter la commande sur la machine distante. Exemple: ssh myUserName@sirius.info.ucl.ac.be cc test.c -o test pour exécuter cc test.c -o test sur sirius.

scp [local_file] [username]@[hostname]:[path]: scp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/scp.1.html] permet de copier des fichiers locaux vers un répertoire distant (et l’inverse). Exemple: scp test.c myUserName@sirius.info.ucl.ac.be:INFO1252/projet_S2/ copie test.c vers le dossier INFO1252/projet_S2/ de la machine sirius.

Le site Getting started with SSH [https://developer.ibm.com/articles/au-sshsecurity/] contient une bonne description de l’utilisation de ssh. Notamment l’utilisation de ssh sur des machines UNIX/Linux. Si vous utilisez Windows, il existe des clients ssh(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ssh.1.html] comme putty [https://www.putty.org/]

Authentification par clé

Face à la faiblesse au niveau sécurité de l’authentification par mot de passe, l’authentification par clé se révèle être un moyen nettement plus efficace.

L’authentification par clé consiste en un premier temps à générer une paire de clés et son mot de passe:

La clé publique que l’on exporte vers chaque hôte sur lequel on veut se connecter.

La clé privée que l’on garde précieusement sur notre ordinateur, et qui sert à prouver à l’hôte notre identité. Attention: ne jamais dévoiler sa clé privée !

Le mot de passe permet de sécuriser sa clé privée.

Le mot de passe ne servant à rien sans les clé et vice versa, on devine aisément que le niveau de sécurité d’une telle connexion est largement accru par rapport à une simple authentification par mot de passe.

Pour générer ces clés et choisir votre mot de passe, il suffit d’entrer la commande

$ ssh-keygen -t rsa -C "login"
# remplacer "login" par votre nom d'utilisateur

Generating public/private rsa key pair.
Enter file in which to save the key (~/.ssh/id_rsa):
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in ~/.ssh/id_rsa.
Your public key has been saved in ~/.ssh/id_rsa.pub.
The key fingerprint is:
17:bc:98:ab:39:f6:a2:db:1d:07:9a:63:d7:c7:9b:e0 "login"

Maintenant que les clés ont été crées, il faut communiquer votre clé publique à l’hôte sur lequel vous voulez vous connecter. Pour cela, le plus simple est d’utiliser la commande suivante

$ cat ~/.ssh/id_rsa.pub | ssh [username]@[hostname] "cat - >> ~/.ssh/authorized_keys"

Cette commande va récupérer votre clé publique dans votre dossier ~/.ssh, se connecter en ssh à l’hôte et va placer votre clé dans son répertoire de clés autorisées. Maintenant nous pouvons utilisez en toute sérénité votre connexion ssh sécurisée!

Petit mot sur les permissions du dossier ~/.ssh où sont stockées les clés:

.ssh user$ ls -ld
drwx------  6 user  staff  204 22 aoû 10:29 .

Les bits de permissions sont définis comme drwx------ ce qui fait du propriétaire de ce dossier la seul personne capable de lire, d’écrire et d’exécuter le contenu de se dossier. La clé privée est donc belle et bien privée!

Utiliser Git avec ssh

Il est également possible de s’authentifier auprès de Git en utilisant une clé ssh à la place de la traditionnelle combinaison nom d’utilisateur et mot de passe. L’avantage de ceci est qu’il n’est plus nécessaire de fournir ses identifiants à chaque push ou pull. L’identité est directement vérifiée depuis les clés ssh présentes sur la machine.

Pour activer l’identification avec ssh, il faut fournir sa clé publique sur la plateforme en ligne utilisée avec Git, par exemple GitHub ou GitLab. Pour ce faire, il suffit de suivre les étapes suivantes:

Ouvrir les paramètres du profil.

Ouvrir la page de gestion des clés SSH.

Sur GitLab, il s’agit de SSH Keys. Sur GitHub, il s’agit de SSH and GPG keys.

Copier la clé publique à l’endroit indiqué. Attention à ne pas copier la clé privée !

La clé publique se trouve généralement dans le fichier ~/.ssh/id_rsa.pub. Il faut copier tout le contenu de ce fichier pour que la clé soit correcte.

Donner un titre à la clé, qui permet d’identifier la clé parmi plusieurs sur la plateforme.

Finaliser en ajoutant la clé.

A titre d’exemple, voici la page permettant d’ajouter une clé SSH sur GitLab:

Une fois que la clé publique est ajoutée sur son profil, il est possible d’utiliser ssh pour s’identifier auprès de Git. Pour ce faire, lorsqu’un projet est cloné depuis la plateforme (GitHub ou GitLab), il faut choisir le lien Clone with SSH. De cette manière, c’est la clé ssh qui sera utilisée pour s’identifier, et il ne faudra plus indiquer le nom d’utilisateur et le mot de passe à chaque opération.

Synchronisation de fichiers entre ordinateurs

Quand nous avons besoin de synchroniser des fichiers entre 2 ordinateurs différents, Unix nous vient en aide avec l’utilitaire rsync [https://www.lifewire.com/copying-directories-with-rsync-3971105].

L’utilisation la plus basique de rsync [https://www.lifewire.com/copying-directories-with-rsync-3971105] est:

rsync *.c [hostname]:src/

rsync [https://www.lifewire.com/copying-directories-with-rsync-3971105] va copier tout les fichiers qui correspondent au pattern *.c du répertoire courant vers le dossier src/ sur la machine hôte. De plus, si certains ou tout les fichiers sont déjà présents chez l’hôte, rsync [https://www.lifewire.com/copying-directories-with-rsync-3971105] va procéder à une mise à jour différentielle de ces fichiers (seuls les changements sont transférés).

L’ajout du drapeau -avz permet de synchroniser les fichiers en mode archive. Cela veut dire que tous les liens, permissions, propriétaires, etc de ces fichiers seront préservés durant le transfert.

Nous pouvons aussi utiliser rsync [https://www.lifewire.com/copying-directories-with-rsync-3971105] dans l’autre sens:

rsync -avz [hostname]:src/bar /data/tmp

Maintenant tous les fichiers de la machine hôte, dans le dossier src/bar vont être copiés vers le répertoire local /data/tmp.

Jenkins

Jenkins [https://www.jenkins.io/] est un outil d’intégration continue pour un projet informatique. L”intégration continue est le fait de, lors de l’évolution d’un projet informatique, vérifier que les nouvelles modifications s’intègrent bien au programme existant, et n’introduisent pas de bug ou ne supprime pas de fonctionnalité.

Pour ce faire, Jenkins [https://www.jenkins.io/] permet d’enregistrer des commandes (généralement des commandes terminal) qui seront automatiquement exécutées lorsqu’une modification est apportée au projet. En pratique, Jenkins [https://www.jenkins.io/] peut être lié à un projet GitLab, et donc exécuter les commandes à chaque commit. Les commandes en question peuvent être de différents types:

Lancer une suite de tests unitaires CUnit.

Utiliser valgrind(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/valgrind.1.html] pour détecter les fuites de mémoire (voir la section dédiée pour plus d’informations).

Utiliser cppcheck [http://cppcheck.sourceforge.net/] pour analyser le code et détecter de potentiels bugs.

La suite de ce document détaille comment inclure Jenkins [https://www.jenkins.io/] à un projet GitLab, pour profiter de l’automatisation des tests à chaque commit, et éviter les bugs et la perte de fonctionnalité.

Création du projet sur Jenkins

Se connecter sur https://jenkins.student.info.ucl.ac.be et cliquer sur le bouton S’identifier.

S’identifier à l’aide du compte UCLouvain.

Une fois connecté, dans le menu à gauche, cliquer sur Nouveau Item.

Saisir le nom du projet, choisir Construire un projet free-style, et cliquer sur OK.

Le menu de configuration s’ouvre. Cocher la case Activer la sécurité basée projet.

Ajouter les membres du projet a l’aide de leur Jenkins User ID, visible sur leur profil Jenkins.

Donner tous les droits aux membres du groupe (cocher toutes les cases). Attention à ne pas donner tous les droits aux anonymes !

Dans le menu déroulant GitLab Connection, choisir Forge UCLouvain.

Dans Gestion de code source, choisir l’option Git.

Récupérer l’URL du dépôt git sur GitLab, avec l’option Clone with SSH.

Indiquer l’URL du dépôt git récupérée à l’étape précédente, et choisir git (SSH key - jenkins_ingi - forge.uclouvain.be) comme identifiant.

Dans Ce qui déclenche le build, cocher Build when a change is pushed to GitLab. Noter quelque part le web hook URL précisé juste à côté.

Un cadre s’ouvre, cliquer sur le bouton Avancé. En dessous du champ Secret token se trouve un bouton Generate. Générer un token et le noter aussi.

Dans Build, ajouter une étape Exécuter un script shell. Y introduire :

#!/bin/bash

exit 0

Dans Actions à la suite du build, ajouter une action Publish build status to GitLab.

Cliquer sur Sauver pour construire le projet.

Déclenchement automatique des builds

Sur GitLab, dans la vue du projet, aller dans le menu Settings => Webhooks

Copier le web hook URL ainsi que le Secret token mis de côté au point précédent. Cocher Push events et finalement cliquer sur Add webhook.

Au prochain commit, un build sera déclenché automatiquement. Comme le script shell retourne 0, le build passera tout le temps. Si le lien est fonctionnel, une pastille verte s’affichera à côté de la description du commit dans GitLab.

Modifier le script sur Jenkins

Sur Jenkins, dans la vue du projet cliquer sur le menu Configurer

Aller jusqu’à la section Build et modifier le script shell

Rajouter les commandes nécessaires à l’exécution des tests de votre projet (make tests par exemple)

Si le code ne passe pas tous les tests, la valeur de retour de la commande sera différente de 0 et le build sera marqué comme Failed, une pastille rouge apparaîtra dans GitLab.

Projet d’exemple pour Jenkins

Nous vous fournissons un projet donnant un exemple plus complet de configuration d’un projet GitLab avec Jenkins. Il est disponible à l’adresse suivante : https://forge.uclouvain.be/alegay/jenkinslepl1503/. Le projet contient les fichiers suivants :

ex-lepl1503.c: un fichier C qui contient:

int maxi(int, int): une fonction qui calcule le maximum entre deux entiers

void test_maxi(void): un test correct (oui le maximum entre 0 et 2 est bien 2)

void test_maxifailed(void): un test incorrect (non le maximum entre 0 et 2 n’est pas 0!)

void erreurmalloc(void): une fonction qui fait une assignation à une case mémoire non allouée

Une procédure main qui :

définit une suite de tests CUnit basée sur test_maxi et test_maxifailed. Pour plus de détails sur CUnit, reportez-vous à la section dédiée.

appelle la fonction erreurmalloc.

Un fichier Makefile qui comprend plusieurs règles. Pour plus de détails sur la conception du makefile, reportez-vous à la section dédiée.

Voici quelques exemples d’utilisation du projet :

make : compile le programme avec l’option -lcunit et appelle les outils valgrind et cppcheck. Les résultats sont sauvés dans des fichiers xml.

make clean : efface l’exécutable et les .xml.

Ne pas hésiter à faire un clone de ce projet sur votre machine, et jouer avec la commande make pour bien comprendre comment le programme et ses différents outils fonctionnent. Comme vous êtes un·e utilisateur·rice guest, vous ne pourrez pas faire de commit dans ce projet. Attention : si vous n’avez pas installé CUnit, valgrind ou cppcheck sur votre machine, vous ne pourrez pas compiler le programme ou effectuer les tests.

Vous pouvez voir sur la page principale du projet sur GitLab une croix rouge à côté du dernier commit effectué. Cela vient du projet Jenkins lié à ce GitLab, et montre que ce commit n’a pas réussi tous les tests. Ce projet Jenkins est disponible à l’adresse suivante : https://jenkins.student.info.ucl.ac.be/job/ex-lepl1503/. Attention : si vous n’êtes pas connecté·e sur Jenkins, vous arriverez sur une page avec une erreur 404. Dans ce cas, retournez sur la page d’accueil de Jenkins (https://jenkins.student.info.ucl.ac.be), connectez-vous et réessayez.

Lors de votre connexion, vous verrez qu’un certain nombre de commits ont été faits. Certains avec succès (les bleus) d’autres pas (les rouges). Vous pourrez aussi observer les résultats de CUnit, valgrind et cppcheck. Rendez-vous dans le menu Configurer. Vous y trouverez les actions faites avant et après le build : Ce qui déclenche le build, Build et Actions à la suite du build. Vous verrez par exemple dans Actions à la suite du build, l’utilisation de plugins pour afficher les rapports XML générés par make.

Notez que vous n’avez pas le droit de modifier cette configuration. Cela se vérifie dans l’onglet Activer la sécurité basée projet du menu configure où seul l’utilisateur Axel Legay a tous les droits. Lorsque vous lierez votre Jenkins à votre projet GitLab, pensez à donner tous les droits à tous les utilisateur·rices mainteneur·ses de votre projet en utilisant Add user or group.

Profiling de code

Lorsque l’on cherche à optimiser les performances de programmes écrits en C, il est utile de les exécuter à travers un profiler tel que gprof(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gprof.1.html]. Ce profiler permet de collecter des statistiques sur les fonctions les plus utilisées à l’intérieur du programme. Ce sont les fonctions qu’il faudra optimiser pour améliorer les performances. De nombreux articles décrivent les principes de base de l’utilisation de gprof(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gprof.1.html]. Un tutoriel est disponible à l’adresse https://www.thegeekstuff.com/2012/08/gprof-tutorial/. oprofile [https://oprofile.sourceforge.io/news/] est un profiler plus puissant que gprof(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gprof.1.html] mais malheureusement plus difficile à utiliser.

Bibliographie

DeveloppezMake

Introduction à Makefile, https://gl.developpez.com/tutoriel/outil/makefile/

GNUMake

The GNU Make Manual, https://www.gnu.org/software/make/manual/make.html

Index

Glossaire

/proc

Sous Linux, représentation de l'information stockée dans la table des processus sous la forme d'une arborescence directement accessible via les commandes du shell. Voir proc(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/proc.5.html]

adresse

Position d'un donnée en mémoire.

AND

conjonction logique

Opération binaire logique.

Andrew Tanenbaum

Andrew Tanenbaum est professeur à la VU d'Amsterdam.

appel système

Fonction primitive fournie par le noyau du système d'exploitation et pouvant être appelée directement par les programmes applicatifs.

appel système bloquant

Appel système qui ne retourne par de résultat immédiat. Dans ce cas, le noyau du système d'exploitation sélectionne un autre processus via le scheduler en attendant que le résultat de l'appel système soit disponible.

appel système lent

Un appel système lent est un appel système qui peut attendre un temps indéfini pour se terminer. Par exemple, l'appel read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] sur l'entrée standard ne retournera de résultat que lorsque l'utilisateur aura pressé une touche sur le clavier.

Application Programming Interface

API

Un API est généralement un ensemble de fonctions et de structures de données qui constitue l'interface entre deux composants logiciels qui doivent collaborer. Par exemple, l'API du noyau d'un système Unix est composée de ses appels systèmes. Ceux-ci sont décrits dans la section 2 des pages de manuel (voir intro(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/intro.2.html]).

Aqua

Aqua est une interface graphique spécifique à MacOS. Voir la page Wikipédia [https://en.wikipedia.org/wiki/Aqua_(user_interface)].

assembleur

Programme permettant de convertir un programme écrit en langage d'assemblage dans le langage machine correspondant à un processeur donné.

benchmark

Ensemble de programmes permettant d'évaluer les performances d'un système informatique.

big endian

Ordre dans lequel les octets correspondants à des mots de plusieurs octets sont stockés en mémoire. Voir https://fr.wikipedia.org/wiki/Boutisme#Gros-boutisme

bit

Plus petite unité d'information. Par convention, un bit peut prendre les valeurs 0 et 1.

bit de poids faible

Par convention, bit le plus à droite d'une séquence de n bits.

bit de poids fort

Par convention, le bit le plus à gauche d'une séquence de n bits.

BSD Unix

Variante de Unix développée à l'Université de Californie à Berkeley.

buffer overflow

Erreur dans laquelle un programme informatique cherche à stocker plus de données en mémoire que la capacité de la zone réservée en mémoire. Donne généralement lieu à des problèmes, parfois graves, de sécurité. https://en.wikipedia.org/wiki/Buffer_overflow

byte

octet

Un bloc de huit bits consécutifs.

C

Langage de programmation permettant d'interagir facilement avec le matériel.

C99

Standard international définissant le langage C [C99]

changement de contexte

Passage de l'exécution du programme A au programme B.

CISC

Complex Instruction Set Computer

contexte

Structure de données maintenue pas le noyau du système d'exploitation qui contient toutes les informations nécessaires pour poursuivre l'exécution d'un programme.

cpp

préprocesseur

Le préprocesseur C est un programme de manipulation de texte sur base de macros qui est utilisé avec le compilateur. Le préprocesseur de gcc est https://gcc.gnu.org/onlinedocs/cpp/

CPU

Central Processing Unit. Voir microprocesseur.

deadlock

Voir https://en.wikipedia.org/wiki/Deadlock

debugger

Logiciel

descripteur de fichier

Identifiant (entier) retourné par le noyau du système d'exploitation lors de l'ouverture d'un fichier par l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html].

double précision

Représentation de nombre réels en virgule flottante (type double en C). La norme IEEE754 [https://fr.wikipedia.org/wiki/IEEE_754] définit le format de ces nombres sur 64 bits.

DRAM

dynamic RAM

Un des deux principaux types de mémoire. Dans une DRAM, l'information est mémorisée comme la présence ou l'absence de charge dans un minuscule condensateur. Les mémoires DRAM sont plus lentes que les SRAM mais ont une plus grande capacité.

eip

pc

compteur de programme

instruction pointer

Registre spécial du processeur qui contient en permanence l'adresse de l'instruction en cours d'exécution. Le contenu de ce registre est incrémenté après chaque instruction et modifié par les instructions de saut.

errno

Variable globale mise à jour par certains appels systèmes et fonctions de la librairie standard en cas d'erreur. Voir errno(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/errno.3.html]

exclusion mutuelle

Zone d'un programme multithreadé qui ne peut pas être exécutée par plus d'un thread à la fois.

fichier

Une séquence composée d'un nombre entier d'octets stockée sur un dispositif de stockage. Un fichier est identifié par son nom et sa position dans l'arborescence du système de fichiers.

fichier header

Fichier contenant des signatures de fonctions, des déclarations de types de données, des variables globales, permettant d'utiliser une librairie ou un API.

fichier objet

Fichier résultat de la compilation d'une partie de programme. Ce fichier contient les instructions en langage machine à exécuter ainsi que les informations relatives aux différents symboles (variables, fonctions, ...) qui y sont définis.

fichier source

Fichier contenant l'implémentation des fonctions définies dans le fichier header correspondant, ainsi que de potentielles autres variables ou fonctions utiles.

FreeBSD

Variante de BSD Unix disponible depuis https://www.freebsd.org

FSF

Free Software Foundation, https://www.fsf.org

garbage collector

Algorithme permettant de libérer la mémoire qui n'est plus utilisée notamment dans des langages tels que Java

gcc

Compilateur pour la langage C développé par un groupe de volontaires qui est diffusé depuis https://gcc.gnu.org gcc est utilisé dans plusieurs systèmes d'exploitation de type Unix, comme MacOS, Linux ou FreeBSD. Il existe d'autres compilateurs C. Une liste non-exhaustive est maintenue sur https://en.wikipedia.org/wiki/List_of_compilers#C_compilers

GHz

Mesure de fréquence en milliards de répétitions par seconde.

Gnome

Environnement graphique utilisé par de nombreuses distributions Linux. Voir https://en.wikipedia.org/wiki/GNOME

GNU is not Unix

GNU

GNU est un projet open-source de la Free Software Foundation qui a permis le développement d'un grand nombre d'utilitaires utilisés par les systèmes d'exploitation de la famille Unix actuellement.

GNU/Linux

Nom générique donné à un système d'exploitation utilisant les utilitaires GNU notamment et le noyau Linux .

heap

tas

Partie de la mémoire d'un programme gérée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html].

hiérarchie de mémoire

Ensemble des mémoires utilisées sur un ordinateur. Depuis les registres jusqu'à la mémoire virtuelle en passant par la mémoire centrale et les mémoires caches.

inode

structure de données utilisée par le système de fichiers Unix pour représenter un fichier/répertoire

interruption

Signal extérieur (horloge, opération d'entrée/sortie, ...) qui force le processeur à arrêter l'exécution du programme en cours pour exécuter une routine du système d'exploitation et traiter l'interruption.

kHz

Mesure de fréquence en milliers de répétitions par seconde.

libc

Librairie C standard. Contient de nombreuses fonctions utilisables par les programmes écrits en langage C et décrites dans la troisième section des pages de manuel. Linux utilise la librairie GNU glibc [https://www.gnu.org/software/libc/manual/] qui contient de nombreuses extensions par rapport à la libraire standard.

lien symbolique

Unix supporte deux types de liens. Les liens durs créés par ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] et les liens symboliques créés par ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] avec l'argument -s.

linker

Editeur de liens. Partie du compilateur c permettant de combiner plusieurs fichiers objet en un exécutable.

Linus Torvalds

Linus Torvalds est le créateur et le mainteneur principal du noyau Linux.

Linux

Noyau de système d'exploitation compatible Unix développé initialement par Linus Torvalds.

little endian

Ordre dans lequel les octets correspondants à des mots de plusieurs octets sont stockés en mémoire. Voir https://fr.wikipedia.org/wiki/Boutisme#Petit-boutisme

livelock

Voir https://en.wikipedia.org/wiki/Deadlock#Livelock

liveness

vivacité

Propriété d'un programme informatique. Dans le problème de l'exclusion mutuelle, une propriété de vivacité est qu'un thread qui souhaite entrer en section critique finira par y accéder.

llvm

Ensemble de compilateurs pour différents langages de programmation et différents processeurs développé par un groupe de volontaire. llvm est distribué depuis https://llvm.org/

loi de Amdahl

Voir https://fr.wikipedia.org/wiki/Loi_d%27Amdahl

loi de Moore

Voir https://fr.wikipedia.org/wiki/Loi_de_Moore

MacOS

Système d'exploitation développé par Apple Inc. comprenant de nombreux composantes provenant de FreeBSD.

makefile

Fichier décrivant la façon dont make(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/make.1.html] doit compiler un programme.

memory leak

Fuite de mémoire. Erreur concernant un programme qui a alloué de la mémoire avec malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et ne l'utilise plus sans avoir fait appel à free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html]

mémoire

Dispositif électronique permettant de stocker

mémoire cache

Mémoire rapide de faible capacité. La mémoire cache peut stocker des données provenant de mémoires de plus grande capacité mais qui sont plus lentes, et exploite le principe de localité en stockant de manière transparente les instructions et les données les plus récemment utilisées. Elle fait office d'interface entre le processeur et la mémoire principale et toutes les demandes d'accès à la mémoire principale passent par la mémoire cache, ce qui permet d'améliorer les performances de nombreux systèmes informatiques.

MHz

Mesure de fréquence en millions de répétitions par seconde.

microprocesseur

processeur

Unité centrale de l'ordinateur qui exécute les instructions en langage machine et interagit avec la mémoire.

Minix

Famille de noyaux de systèmes d'exploitation inspiré de Unix développée notamment par Andrew Tanenbaum. Voir https://www.minix3.org pour la dernière version de Minix.

MIPS

Million d'instructions par seconde

multi-threadé

multi-coeurs

Processeur contenant plusieurs unités permettant d'exécuter simultanément des instructions de programmes différents.

multitâche

multitasking

Capacité d'exécuter plusieurs programmes simultanément.

multithreadé

Programme utilisant plusieurs threads.

mutex

Primitive de synchronisation permettant d'empêcher que deux threads accèdent simultanément à une même section critique.

nibble

Un bloc de quatre bits consécutifs.

NOT

négation

Opération binaire logique.

offset pointer

Position de la tête de lecture associée à un fichier ouvert.

OpenBSD

Variante de BSD Unix disponible depuis https://www.openbsd.org

opération atomique

Opération ne pouvant être interrompue.

OR

disjonction logique

Opération binaire logique.

pid

process identifier

identifiant de processus. Sous Unix, chaque processus est identifié par un entier unique. Cet identifiant sert de clé d'accès à la table des processus. Voir getpid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getpid.2.html] pour récupérer l'identifiant du processus courant.

pipe

Mécanisme de redirection des entrées-sorties permettant de relier la sortie standard d'un programme à l'entrée standard d'un autre pour créer des pipelines de traitement.

pointeur

Adresse d'une variable ou fonction en mémoire.

portée

Zone d'un programme dans laquelle une variable est déclarée.

portée globale

Une variable ayant une portée globale est accessible dans tout le programme.

portée locale

Une variable ayant une portée locale est accessible uniquement dans le bloc dans laquelle elle est définie.

principe de localité

principe de fonctionnement de la mémoire indiquant que lorsqu'un programme accède à une adresse à un temps t, il accédera encore à des adresses proches dans les prochains instants

processus

Ensemble cohérent d'instructions utilisant une partie de la mémoire, initié par le système d'exploitation et exécuté sur un des processeurs du système. Le système d'exploitation libère les ressources qui lui sont allouées à la fin de son exécution.

RAM

Random Access Memory

Mémoire à accès aléatoire. Mémoire permettant au processeur d'accéder à n'importe quelle donnée en connaissant son adresse. Voir DRAM et SRAM.

raspberry pi

Systèmes informatiques développés par la Raspberry Pi Foundation, voir https://www.raspberrypi.org

registre

Unité de mémoire intégrée au processeur. Les registres sont utilisés comme source ou destination pour la plupart des opérations effectuées par un processeur.

répertoire

Branche de l'arborescence du système de fichiers. Un répertoire contient un ou plusieurs fichiers.

répertoire courant

Répertoire dans lequel l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] cherchera à ouvrir les fichiers do

RISC

Reduced Instruction Set Computer

root

Racine de l'arborescence des fichiers mais aussi utilisateur ayant les privilèges les plus élevés sur un ordinateur utilisant Unix.

round-robin

Voir Round-robin sur Wikipédia [https://fr.wikipedia.org/wiki/Round-robin_(informatique)]

scheduler

Ordonnanceur. Algorithme utilisé par le noyau du système d'exploitation pour sélectionner le prochain programme à exécuter après une interruption d'horloge ou un appel système bloquant.

section critique

Partie de programme ne pouvant pas être exécutée simultanément par deux threads différents.

segment de données

Partie de la mémoire comprenant les segments des données initialisées et non-initialisées

segment des données initialisées

Partie de la mémoire d'un programme contenant les données initialisées dans le code source du programme ainsi que les chaînes de caractères.

segment des données non-initialisées

Partie de la mémoire d'un programme contenant les données (tableaux notamment) qui sont déclarés mais pas explicitement initialisés dans le code source du programme.

segmentation fault

Erreur à l'exécution causée par un accès à une adresse mémoire non-autorisée pour le programme.

sémaphore

Primitive de synchronisation permettant notamment l'exclusion mutuelle. Voir notamment [Downey2008]

shared library

librairie dynamique

librairie partagée

Lorsqu'un librairie est dynamiquement liée à un programme exécutable, le code de celui-ci ne contient pas les instructions de la librairie, mais celle-ci est automatiquement chargée lors de chaque exécution du programme. Cela permet d'avoir une seule copie de chaque librairie. C'est la solution utilisée par défaut sous Linux.

shell

Interpréteur de commandes sur un système Unix. bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] est l'interpréteur de commandes le plus utilisé de nos jours.

signal

mécanisme permettant la communication entre processus. Utilisé notamment pour arrêter un processus via la commande kill(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/kill.1.html]

simple précision

Représentation de nombre réels en virgule flottante (type float en C). La norme IEEE754 [https://fr.wikipedia.org/wiki/IEEE_754] définit le format de ces nombres sur 32 bits.

Solaris

Système d'exploitation compatible Unix développé par Sun Microsystems et repris par Oracle.

SRAM

static RAM

Un des deux principaux types de mémoire. Dans une SRAM, l'information est mémorisée comme la présence ou l'absence d'un courant électrique. Les mémoires SRAM sont généralement assez rapides mais de faible capacité. Elles sont souvent utilisées pour construire des mémoires caches.

SSD

Solid State Drive

Système de stockage de données s'appuyant uniquement sur de la mémoire flash.

stack

pile

Partie de la mémoire d'un programme contenant les variables locales et adresses de retour des fonctions durant leur exécution.

static library

librairie statique

Une librairie est statiquement liée à un programme exécutable lorsque tout son code est intégré dans l'exécutable. Voir les arguments static dans gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]

stderr

Sortie d'erreur standard sur un système Unix (par défaut l'écran)

stdin

Entrée standard sur un système Unix (par défaut le clavier)

stdout

Sortie standard sur un système Unix (par défaut l'écran)

sûreté

safety

Propriété d'un programme informatique. Dans le problème de l'exclusion mutuelle, une propriété de sûreté est que deux threads ne seront jamais dans la même section critique.

table des processus

Table contenant les identifiants (pid) de tous les processus qui s'exécutent à ce moment sur un système Unix. Outre les identifiants, cette table contient de nombreuses informations relatives à chaque processus. Voir également /proc

text

segment text

Partie de la mémoire d'un programme contenant les instructions en langage machine à exécuter.

thread-safe

Une fonction est dite thread-safe si elle peut être simultanément exécutée sans contrainte par différents threads d'un même programme.

Unicode

Norme d'encodage de caractères supportant l'ensemble des langues écrites, voir notamment https://en.wikipedia.org/wiki/Unicode

Unix

Système d'exploitation développé initialement par AT&T Bell Labs.

userid

Identifiant d'utilisateur. Sous Unix, un entier unique est associé à chaque utilisateur.

von Neumann

Un des inventeurs des premiers ordinateurs. A défini l'architecture de base des premiers ordinateurs qui est maintenant connue comme le modèle de von Neumann [Krakowiak2011]

warning

Message d'avertissement émis par un compilateur C. Un warning n'empêche pas la compilation et la génération du code objet. Cependant, la plupart des warnings indiquent un problème dans le programme compilé et il est nettement préférable de les supprimer du code.

X11

Interface graphique développée au MIT pour Unix. Voir https://en.wikipedia.org/wiki/X_Window_System

x86

Famille de microprocesseurs développée par intel [https://www.intel.fr/content/www/fr/fr/homepage.html]. Le 8086 est le premier processeur de cette famille. Ses successeurs (286, 386, Pentium, Centrino, Xeon, ...) sont restés compatibles avec lui tout en introduisant chacun de nouvelles instructions et de nouvelles fonctionnalités. Aujourd'hui, plusieurs fabricants développent des processeurs qui supportent le même langage machine que les processeurs de cette famille.

XOR

ou exclusif

Opération binaire logique.

Introduction

Les systèmes informatiques jouent un rôle de plus en plus important dans notre société. Depuis les premiers calculateurs à la fin de la seconde guerre mondiale, les ordinateurs se sont rapidement améliorés et démocratisés. Aujourd'hui, notre société est de plus en plus dépendante des systèmes informatiques.

Composition d'un système informatique

Le système informatique le plus simple est composé d'un processeur (CPU en anglais) ou unité de calcul et d'une mémoire. Le processeur est un circuit électronique qui est capable d'effectuer de nombreuses tâches :

lire de l'information en mémoire

écrire de l'information en mémoire

réaliser des calculs

L'architecture des ordinateurs est basée sur l'architecture dite de Von Neumann. Suivant cette architecture, un ordinateur est composé d'un processeur qui exécute un programme se trouvant en mémoire. La mémoire contient à la fois le programme à exécuter et les données qui sont manipulées par le programme.

L'élément de base pour stocker et représenter de l'information dans un système informatique est le bit. Un bit (binary digit en anglais) peut prendre deux valeurs qui par convention sont représentées par :

1

0

Physiquement, un bit est représenté sous la forme d'un signal électrique ou optique lorsqu'il est transmis et d'une charge électrique ou sous forme magnétique lorsqu'il est stocké. Nous n'aborderons pas ces détails technologiques dans le cadre de ce cours. Ils font l'objet de nombreux cours d'électronique.

Le bit est l'unité de base de stockage et de transfert de l'information. En général, les systèmes informatiques ne traitent pas des bits individuellement 1.

La composition de plusieurs bits donne lieu à des blocs de données qui peuvent être utiles dans différentes applications informatiques. Ainsi, un nibble est un bloc de 4 bits consécutifs tandis qu'un octet (ou byte en anglais) est un bloc de 8 bits consécutifs. On parlera de mots (word en anglais) pour des groupes comprenant généralement 32 bits et de long mot pour des groupes de 64 bits.

Le processeur et la mémoire ne sont pas les deux seuls composants d'un système informatique. Celui-ci doit également pouvoir interagir avec le monde extérieur, ne fut-ce que pour pouvoir charger le programme à exécuter et les données à analyser. Cette interaction se réalise grâce à un grand nombre de dispositifs d'entrées/sorties et de stockage. Parmi ceux-ci, on peut citer :

le clavier qui permet à l'utilisateur d'entrer des caractères

l'écran qui permet à l'utilisateur de visualiser le fonctionnement des programmes et les résultats qu'ils produisent

l'imprimante qui permet à l'ordinateur d'écrire sur papier les résultats de l'exécution de programmes

le disque-dur, les clés USB, les CDs et DVDs qui permettent de stocker les données sous la forme de fichiers et de répertoires

la souris ou la tablette graphique qui permettent à l'utilisateur de fournir à l'ordinateur des indications de positionnement

le scanner qui permet à l'ordinateur de transformer un document en une image numérique

le haut-parleur avec lequel l'ordinateur peut diffuser différentes sortes de son

le microphone et la caméra qui permettent à l'ordinateur de capturer des informations sonores et visuelles pour les stocker ou les traiter

Les systèmes informatiques peuvent prendre différentes formes, allant de minuscules systèmes embarqués à de gigantesques supercalculateurs. Les raspberry pi sont un exemple d'un système embarqué. Il s'agit de nano-ordinateurs, de la taille d'une carte de crédit. Possédant les mêmes composants que décrits ci-dessus, ils fonctionnent de la même façon que des systèmes plus imposants comme les ordinateurs personnels que l'on utilise au quotidien, seulement avec moins de ressources.

Unix

Unix est aujourd'hui un nom générique 2 correspondant à une famille de systèmes d'exploitation. La première version de Unix a été développée pour faciliter le traitement de documents sur mini-ordinateur.

Quelques variantes de Unix

De nombreuses variantes de Unix ont été produites durant les quarante dernières années. Il est impossible de les décrire toutes, mais en voici quelques unes.

Unix. Initialement développé aux AT&T Bell Laboratories, Unix a été ensuite développé par d'autres entreprises. C'est aujourd'hui une marque déposée par The Open group, voir https://www.opengroup.org/membership/forums/platform/unix

BSD Unix. Les premières versions de Unix étaient librement distribuées par Bell Labs. Avec le temps, des variantes de Unix sont apparues. La variante développée par l'université de Berkeley en Californie a été historiquement importante car c'est dans cette variante que de nombreuses innovations ont été introduites dont notamment les piles de protocoles TCP/IP utilisés sur Internet. Aujourd'hui, FreeBSD et OpenBSD sont deux descendants de BSD Unix. Ils sont largement utilisés dans de nombreux serveurs et systèmes embarqués. MacOS, développé par Apple, s'appuie fortement sur un noyau et des utilitaires provenant de FreeBSD.

Minix est un système d'exploitation développé initialement par Andrew Tanenbaum à l'université d'Amsterdam. Minix est fréquemment utilisé pour l'apprentissage du fonctionnement des systèmes d'exploitation.

Linux est un noyau de système d'exploitation largement inspiré de Unix et Minix. Développé par Linus Torvalds durant ses études d'informatique, il est devenu la variante de Unix la plus utilisée à travers le monde. Il est maintenant développé par des centaines de développeurs qui collaborent via Internet.

Solaris est le nom commercial de la variante Unix de Oracle.

Dans le cadre de ce cours, nous nous focaliserons sur le système GNU/Linux, c'est-à-dire un système qui intègre le noyau Linux et les librairies et utilitaires développés par le projet GNU de la FSF.

Un système Unix est composé de trois grands types de logiciels :

Le noyau du système d'exploitation qui est chargé automatiquement au démarrage de la machine et qui prend en charge toutes les interactions entre les logiciels et le matériel.

De nombreuses librairies qui facilitent l'écriture et le développement d'applications

De nombreux programmes utilitaires simples qui permettent de résoudre un grand nombre de problèmes courants. Certains de ces utilitaires sont chargés automatiquement lors du démarrage de la machine. La plupart sont exécutés uniquement à la demande des utilisateurs.

Le rôle principal du noyau du système d'exploitation est de gérer les ressources matérielles (processeur, mémoire, dispositifs d'entrées/sorties et de stockage) de façon à ce qu'elles soient accessibles à toutes les applications qui s'exécutent sur le système. Gérer les ressources matérielles nécessite d'inclure dans le systèmes d'exploitation des interfaces programmatiques (Application Programming Interfaces en anglais - API) qui facilitent leur utilisation par les applications. Les dispositifs de stockage sont une belle illustration de ce principe. Il existe de nombreux dispositifs de stockage (disque dur, clé USB, CD, DVD, mémoire flash, ...). Chacun de ces dispositifs a des caractéristiques électriques et mécaniques propres. Ils permettent en général la lecture et/ou l'écriture de blocs de données de quelques centaines d'octets. Nous reviendrons sur leur fonctionnement ultérieurement. Peu d'applications sont capables de piloter directement de tels dispositifs pour y lire ou y écrire de tels blocs de données. Par contre, la majorité des applications sont capables de les utiliser par l'intermédiaire du système de fichiers. Le système de fichiers (arborescence des fichiers) et l'API associée (open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html], close(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/close.2.html], read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] ) sont un exemple des services fournis par le système d'exploitation aux applications. Le système de fichiers regroupe l'ensemble des fichiers qui sont accessibles depuis un système sous une arborescence unique, quel que soit le nombre de dispositifs de stockage utilisé. La racine de cette arborescence est le répertoire / par convention. Ce répertoire contient généralement une dizaine de sous répertoires dont les noms varient d'une variante de Unix à l'autre. Généralement, on retrouve dans la racine les sous-répertoires suivants :

/usr : sous-répertoire contenant la plupart des utilitaires et librairies installées sur le système

/bin et /sbin : sous-répertoire contenant quelques utilitaires de base nécessaires à l'administrateur du système

/tmp : sous-répertoire contenant des fichiers temporaires. Son contenu est généralement effacé au redémarrage du système.

/etc : sous-répertoire contenant les fichiers de configuration du système

/home : sous-répertoire contenant les répertoires personnels des utilisateurs du système

/dev : sous-répertoire contenant des fichiers spéciaux

/root: sous-répertoire contenant des fichiers propres à l'administrateur système. Dans certains variantes de Unix, ces fichiers sont stockés dans le répertoire racine.

Un autre service est le partage de la mémoire et du processus. La plupart des systèmes d'exploitation supportent l'exécution simultanée de plusieurs applications. Pour ce faire, le système d'exploitation partage la mémoire disponible entre les différentes applications en cours d'exécution. Il est également responsable du partage du temps d'exécution sur le ou les processeurs de façon à ce que toutes les applications en cours puissent s'exécuter.

Unix s'appuie sur la notion de processus. Une application est composée de un ou plusieurs processus. Un processus peut être défini comme un ensemble cohérent d'instructions qui utilisent une partie de la mémoire et sont exécutées sur un des processeurs du système. L'exécution d'un processus est initiée par le système d'exploitation (généralement suite à une requête faite par un autre processus). Un processus peut s'exécuter pendant une fraction de secondes, quelques secondes ou des journées entières. Pendant son exécution, le processus peut potentiellement accéder aux différentes ressources (processeurs, mémoire, dispositifs d'entrées/sorties et de stockage) du système. A la fin de son exécution, le processus se termine 3 et libère les ressources qui lui ont été allouées par le système d'exploitation. Sous Unix, tout processus retourne au processus qui l'avait initié le résultat de son exécution qui est résumée en un nombre entier. Cette valeur de retour est utilisée en général pour déterminer si l'exécution d'un processus s'est déroulée correctement (zéro comme valeur de retour) ou non (valeur de retour différente de zéro).

Dans le cadre de ce cours, nous aurons l'occasion de voir en détails de nombreuses librairies d'un système Unix et verrons le fonctionnement d'appels systèmes qui permettent aux logiciels d'interagir directement avec le noyau. Le système Unix étant majoritairement écrit en langage C, ce langage est le langage de choix pour de nombreuses applications. Nous le verrons donc en détails.

Pour vous permettre de mettre vos apprentissages en pratique, vous recevrez durant le quadrimestre un raspberry pi [https://www.raspberrypi.org/]. Il est possible d'installer différents systèmes d'exploitation sur celui-ci. Nous utiliserons raspbian [https://www.raspberrypi.org/software/operating-systems/] qui est lui aussi une variante de Unix.

Utilitaires

Unix a été conçu à l'époque des mini-ordinateurs. Un mini-ordinateur servait plusieurs utilisateurs en même temps. Ceux-ci y étaient connectés par l'intermédiaire d'un terminal équipé d'un écran et d'un clavier. Les programmes traitaient les données entrées par l'utilisateur via le clavier ou stockées sur le disque. Les résultats de l'exécution des ces programmes étaient affichés à l'écran, sauvegardés sur disque ou parfois imprimés sur papier. Le fonctionnement de nombreux utilitaires Unix a été influencé par cet environnement. A tout processus Unix, on associe :

une entrée standard (stdin en anglais) qui est un flux d'informations par lequel le processus reçoit les données à traiter. Par défaut, l'entrée standard est associée au clavier.

une sortie standard (stdout en anglais) qui est un flux d'informations sur lequel le processus écrit le résultat de son traitement. Par défaut, la sortie standard est associée au terminal.

une sortie d'erreur standard (stderr en anglais) qui est un flux de données sur lequel le processus écrira les messages d'erreur éventuels. Par défaut, la sortie d'erreur standard est associée à l'écran.

Unix ayant été initialement développé pour manipuler des documents contenant du texte, il comprend de nombreux utilitaires facilitant ces traitements. Une description détaillée de l'ensemble de ces utilitaires sort du cadre de ce cours. De nombreux livres et ressources Internet fournissent une description détaillée. Voici cependant une brève présentation de quelques utilitaires de manipulation de texte qui peuvent s'avérer très utiles en pratique.

cat(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cat.1.html] : utilitaire permettant notamment d'afficher le contenu d'un fichier sur la sortie standard

echo(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/echo.1.html] : utilitaire permettant d'afficher sur la sortie standard une chaîne de caractères passée en argument

head(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/head.1.html] et tail(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/tail.1.html] : utilitaires permettant respectivement d'extraire le début ou la fin d'un fichier

wc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/wc.1.html] : utilitaire permettant de compter le nombre de caractères et de lignes d'un fichier

grep(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/grep.1.html] : utilitaire permettant notamment d'extraire d'un fichier texte les lignes qui contiennent ou ne contiennent pas une chaîne de caractères passée en argument

sort(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sort.1.html] : utilitaire permettant de trier les lignes d'un fichier texte

uniq(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/uniq.1.html] : utilitaire permettant de filtrer le contenu d'un fichier texte afin d'en extraire les lignes qui sont uniques ou dupliquées (requiert que le fichier d'entrée soit trié, car ne compare que les lignes consécutives)

more(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/more.1.html] : utilitaire permettant d'afficher page par page un fichier texte sur la sortie standard (less(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/less.1.html] est une variante courante de more(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/more.1.html])

gzip(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gzip.1.html] et gunzip(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gunzip.1.html] : utilitaires permettant respectivement de compresser et de décompresser des fichiers. Les fichiers compressés prennent moins de place sur le disque que les fichiers standard et ont par convention un nom qui se termine par .gz.

tar(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/tar.1.html] : utilitaire permettant de regrouper plusieurs fichiers dans une archive. Souvent utilisé en combinaison avec gzip(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gzip.1.html] pour réaliser des backups ou distribuer des logiciels.

sed(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sed.1.html] : utilitaire permettant d'éditer, c'est-à-dire de modifier les caractères présents dans un flux de données.

awk(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/awk.1.html] : utilitaire incluant un petit langage de programmation et qui permet d'écrire rapidement de nombreux programmes de manipulation de fichiers textes

La plupart des utilitaires fournis avec un système Unix ont été conçus pour être utilisés en combinaison avec d'autres. Cette combinaison efficace de plusieurs petits utilitaires est un des points forts des systèmes Unix par rapport à d'autres systèmes d'exploitation.

Shell

Avant le développement des interfaces graphiques telles que X11, Gnome ou Aqua, l'utilisateur interagissait exclusivement avec l'ordinateur par l'intermédiaire d'un interpréteur de commandes. Dans le monde Unix, le terme anglais shell est le plus souvent utilisé pour désigner cet interpréteur et nous ferons de même. Avec les interfaces graphiques actuelles, le shell est accessible par l'intermédiaire d'une application qui est généralement appelée terminal ou console.

Un shell est un programme qui a été spécialement conçu pour faciliter l'utilisation d'un système Unix via le clavier. De nombreux shells Unix existent. Les plus simples permettent à l'utilisateur de taper une série de commandes à exécuter en les combinant. Les plus avancés sont des interpréteurs de commandes qui supportent un langage complet permettant le développement de scripts plus ou moins ambitieux. Dans le cadre de ce cours, nous utiliserons bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] qui est un des shells les plus populaires et les plus complets. La plupart des commandes bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] que nous utiliserons sont cependant compatibles avec de nombreux autres shells tels que zsh [https://www.zsh.org] ou csh [https://www.tcsh.org/].

Lorsqu'un utilisateur se connecte à un système Unix, en direct ou à travers une connexion réseau, le système vérifie son mot de passe puis exécute automatiquement le shell qui est associé à cet utilisateur depuis son répertoire par défaut. Ce shell permet à l'utilisateur d'exécuter et de combiner des commandes. Un shell supporte deux types de commande : les commandes internes qu'il implémente directement et les commandes externes qui font appel à un utilitaire stocké sur disque. Les utilitaires présentés dans la section précédente sont des exemples de commandes externes. Voici quelques exemples d'utilisation de commandes externes.

$ cat exemple.txt
Un simple fichier de textes
aaaaaaaaaa bbbbbb
bbbbb ccccccccccc
eeeee fffffffff
aaaaaaaaaa bbbbbb
$ grep fichier exemple.txt
Un simple fichier de textes
$ wc exemple.txt
       5      13      98 exemple.txt

La puissance du shell ne vient pas de sa capacité d'exécuter des commandes individuelles telles que ci-dessus. Elle vient de la possibilité de combiner ces commandes en redirigeant les entrées et sorties standards. Les shells Unix supportent différentes formes de redirection. Tout d'abord, il est possible de forcer un programme à lire son entrée standard depuis un fichier plutôt que depuis le clavier. Cela se fait en ajoutant à la fin de la ligne de commande le caractère < suivi du nom du fichier à lire. Ensuite, il est possible de rediriger la sortie standard vers un fichier. Cela se fait en utilisant > ou >>. Lorsqu'une commande est suivie de > file, le fichier file est créé si il n'existait pas et remis à zéro si il existait et la sortie standard de cette commande est redirigée vers le fichier file. Lorsqu'un commande est suivie de >> file, la sortie standard est sauvegardée à la fin du fichier file (si file n'existait pas, il est créé). Des informations plus complètes sur les mécanismes de redirection de bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] peuvent être obtenues dans le chapitre 20 [https://tldp.org/LDP/abs/html/io-redirection.html] de [ABS].

$ echo "Un petit fichier de textes" > file.txt
$ echo "aaaaa bbbbb" >> file.txt
$ echo "bbbb ccc" >> file.txt
$ grep -v bbbb < file.txt > file.out
$ cat file.out
Un petit fichier de textes

Les shells Unix supportent un second mécanisme qui est encore plus intéressant pour combiner plusieurs programmes. Il s'agit de la redirection de la sortie standard d'un programme vers l'entrée standard d'un autre sans passer par un fichier intermédiaire. Cela se réalise avec le symbole | (pipe en anglais). L'exemple suivant illustre quelques combinaisons d'utilitaires de manipulation de texte.

$ echo "Un petit texte" | wc -c
      15
$ echo "bbbb ccc" >> file.txt
$ echo "aaaaa bbbbb" >> file.txt
$ echo "bbbb ccc" >> file.txt
$ cat file.txt
bbbb ccc
aaaaa bbbbb
bbbb ccc
$ cat file.txt | sort | uniq
aaaaa bbbbb
bbbb ccc

Le premier exemple est d'utiliser echo(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/echo.1.html] pour générer du texte et le passer directement à wc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/wc.1.html] qui compte le nombre de caractères. Le deuxième exemple utilise cat(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cat.1.html] pour afficher sur la sortie standard le contenu d'un fichier. Cette sortie est reliée à sort(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/sort.1.html] qui trie le texte reçu sur son entrée standard en ordre alphabétique croissant. Cette sortie en ordre alphabétique est reliée à uniq(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/uniq.1.html] qui la filtre pour en retirer les lignes dupliquées.

Tout shell Unix peut également s'utiliser comme un interpréteur de commande qui permet d'interpréter des scripts. Un système Unix peut exécuter deux types de programmes :

des programmes exécutables en langage machine. C'est le cas de la plupart des utilitaires dont nous avons parlé jusqu'ici.

des programmes écrits dans un langage interprété. C'est le cas des programmes écrits pour le shell, mais également pour d'autres langages interprétés comme python [https://www.python.org] ou perl [https://www.perl.org].

Lors de l'exécution d'un programme, le système d'exploitation reconnaît 4 si il s'agit d'un programme directement exécutable ou d'un programme interprété en analysant les premiers octets du fichier. Par convention, sous Unix, les deux premiers caractères d'un programme écrit dans un langage qui doit être interprété sont #!. Ils sont suivis par le nom complet de l'interpréteur qui doit être utilisé pour interpréter le programme.

Le programme bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] le plus simple est le suivant :

#!/bin/bash
echo "Hello, world"

L'exécution de ce script shell retourne la sortie suivante :

Hello, world

Par convention en bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html], le caractère # marque le début d'un commentaire en début ou en cours de ligne. Comme tout langage, bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] permet à l'utilisateur de définir des variables. Celles-ci peuvent contenir des chaînes de caractères ou des nombres. Le script ci-dessous utilise deux variables, PROG et COURS et les utilise pour afficher un texte avec la commande echo.

#!/bin/bash
PROG="LEPL"
COURS=1503
echo $PROG$COURS

Un script bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] peut également prendre des arguments passés en ligne de commande. Par convention, ceux-ci ont comme noms $1, $2, $3, ... L'exemple ci-dessous illustre l'utilisation de ces arguments.

#!/bin/bash
# $# nombre d'arguments
# $1 $2 $3 ... arguments
echo "Vous avez passe" $# "arguments"
echo "Le premier argument est :" $1
echo "Liste des arguments :" $@

L'exécution de ce script produit la sortie suivante :

Vous avez passe 2 arguments
Le premier argument est : LEPL
Liste des arguments : LEPL 1503

Concernant le traitement des arguments par un script bash, il est utile de noter que lorsque l'on appelle un script en redirigeant son entrée ou sa sortie standard, le script n'est pas informé de cette redirection. Ainsi, si l'on exécute le script précédent en faisant args.sh arg1 > args.out, le fichier args.out contient les lignes suivantes :

Vous avez passe 2 arguments
Le premier argument est : LEPL
Liste des arguments : LEPL 1503

bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] supporte la construction if [ condition ]; then ... fi qui permet notamment de comparer les valeurs de variables. bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] définit de nombreuses conditions différentes, dont notamment :

$i -eq $j est vraie lorsque les deux variables $i et $j contiennent le même nombre.

$i -lt $j est vraie lorsque la valeur de la variable $i est numériquement strictement inférieure à celle de la variable $j

$i -ge $j est vraie lorsque la valeur de la variable $i est numériquement supérieure ou égale à celle de la variable $j

$s = $t est vraie lorsque la chaîne de caractères contenue dans la variable $s est égale à celle qui est contenue dans la variable $t

-z $s est vraie lorsque la chaîne de caractères contenue dans la variable $s est vide

D'autres types de test sont définis dans la page de manuel : bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html]. Le script ci-dessous fournit un premier exemple d'utilisation de tests avec bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html].

#!/bin/bash
# Vérifie si les deux nombres passés en arguments sont égaux
if [ $# -ne 2 ]; then
    echo "Erreur, deux arguments sont nécessaires" > /dev/stderr
    exit 2
fi
if [ $1 -eq $2 ]; then
	echo "Nombres égaux"
else
	echo "Nombres différents"
fi
exit 0

Tout d'abord, ce script vérifie qu'il a bien été appelé avec deux arguments. Vérifier qu'un programme reçoit bien les arguments qu'il attend est une règle de bonne pratique qu'il est bon de respecter dès le début. Si le script n'est pas appelé avec le bon nombre d'arguments, un message d'erreur est affiché sur la sortie d'erreur standard et le script se termine avec un code de retour. Ces codes de retour sont importants car ils permettent à un autre programme, par exemple un autre script bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] de vérifier le bon déroulement d'un programme appelé. Le script src/eq.sh utilise des appels explicites à exit(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/exit.1posix.html] même si par défaut, un script bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] qui n'en contient pas retourne un code de retour nul à la fin de son exécution.

Un autre exemple d'utilisation des codes de retour est le script src/wordin.sh repris ci-dessous qui utilise grep(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/grep.1.html] pour déterminer si un mot passé en argument est présent dans un fichier texte. Pour cela, il exploite la variable spéciale $? dans laquelle bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] sauve le code de retour du dernier programme exécuté par le script.

#!/bin/bash
# wordin.sh
# Vérifie si le mot passé en premier argument est présent
# dans le fichier passé comme second argument
if [ $# -ne 2 ]; then
    echo "Erreur, deux arguments sont nécessaires" > /dev/stderr
    exit 2
fi
grep $1 $2 >/dev/null
# $? contient la valeur de retour de grep
if [ $? -eq 0 ]; then
	echo "Présent"
	exit 0
else
	echo "Absent"
	exit 1
fi

Ce programme utilise le fichier spécial /dev/null. Celui-ci est en pratique l'équivalent d'un trou noir. Il accepte toutes les données en écriture mais celles-ci ne peuvent jamais être relues. /dev/null est très utile lorsque l'on veut ignorer la sortie d'un programme et éviter qu'elle ne s'affiche sur le terminal. bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] supporte également /dev/stdin pour représenter l'entrée standard, /dev/stdout pour la sortie standard et /dev/stderr pour l'erreur standard.

Une description complète de bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] sort du cadre de ce cours. De nombreuses références à ce sujet sont disponibles [Cooper2011].

Footnotes

1

Dans certaines applications, par exemple dans les réseaux informatiques, il peut être utile d'accéder à la valeur d'un bit particulier qui joue par exemple le rôle d'un drapeau. Celui-ci se trouve cependant généralement à l'intérieur d'une structure de données comprenant un ensemble de bits.

2

Formellement, Unix est une marque déposée par l'Open Group [https://www.opengroup.org], un ensemble d'entreprises qui développent des standards dans le monde de l'informatique. La première version de Unix écrite en C date de 1973.

3

Certains processus sont lancés automatiquement au démarrage du système et ne se terminent qu'à son arrêt. Ces processus sont souvent appelés des daemons. Il peut s'agir de services qui fonctionnent en permanence sur la machine, comme par exemple un serveur web ou un daemon d'authentification.

4

Sous Unix et contrairement à d'autres systèmes d'exploitation, le suffixe d'un nom de fichier ne joue pas de rôle particulier pour indiquer si un fichier contient un programme exécutable ou non. Comme nous le verrons ultérieurement, le système de fichiers Unix contient des bits de permission qui indiquent notamment si un fichier est exécutable ou non.

Types de données

Dans les sections précédentes, nous avons abordé quelques types de données de base dont les int et les char. Pour utiliser ces types de données à bon escient, il est important de comprendre en détail la façon dont ils sont supportés par le compilateur et leurs limitations. Celles-ci dépendent souvent de leur représentation en mémoire et durant cette semaine nous allons commencer à analyser de façon plus détaillée comment la mémoire d'un ordinateur est structurée.

Nombres entiers

Toutes les données stockées sur un ordinateur sont représentées sous la forme de séquences de bits. Ces séquences de bits peuvent d'abord permettre de représenter des nombres entiers. Un système informatique peut travailler avec deux types de nombres entiers :

les nombres entiers signés (int notamment en C)

les nombres entiers non-signés (unsigned int notamment en C)

Une séquence de \(n\) bits \(b_0 ... b_i ... b_{n-1}\) peut représenter le nombre entier \(\sum_{i=0}^{n-1} b_i \times 2^i\). Par convention, le bit \(b_{n-1}\), associé au facteur du plus grand indice \(2^{n-1}\), est appelé le bit de poids fort tandis que le bit \(b_0\), associé à \(2^0\), est appelé le bit de poids faible. Les suites de bits sont communément écrites dans l'ordre descendant des indices \(b_{n-1} ... b_i ... b_0\). À titre d'exemple, la suite de bits 0101 correspond à l'entier non signé représentant la valeur cinq. Le bit de poids fort de cette séquence de quatre bits (ou nibble) est 0. Le bit de poids faible est 1. La table ci-dessous reprend les différentes valeurs décimales correspondant à toutes les séquences de quatre bits consécutifs.

binaire

octal

hexadécimal

décimal

0000

00

0

0

0001

01

1

1

0010

02

2

2

0011

03

3

3

0100

04

4

4

0101

05

5

5

0110

06

6

6

0111

07

7

7

1000

10

8

8

1001

11

9

9

1010

12

A

10

1011

13

B

11

1100

14

C

12

1101

15

D

13

1110

16

E

14

1111

17

F

15

Écrire une séquence de bits sous la forme d'une suite de 0 et de 1 peut s'avérer fastidieux. La représentation décimale traditionnelle n'est pas pratique (optimale) non plus car il faut un ou deux chiffres pour représenter une séquence de quatre bits (ou nibble) en fonction de la valeur de ces bits. En pratique, de nombreux systèmes informatiques utilisent une représentation hexadécimale pour afficher des séquences de bits. Cette notation hexadécimale est définie sur base de la table ci-dessus en utilisant des lettres pour représenter chaque séquence de quatre bits dont la valeur numérique est supérieure à 9. Tout comme avec la représentation décimale habituelle, il est possible d'utiliser la représentation hexadécimale pour de longues séquences de bits. La notation octale est parfois utilisée et est supportée par les compilateurs C. Elle utilise un chiffre pour représenter trois bits consécutifs. A titre d'exemple, voici quelques conversions de nombres en notation décimale vers les notations hexadécimales et binaires.

L'entier décimal 123 s'écrit 0x7b en notation hexadécimale et 0b000000000000000000000000001111011 en notation binaire

L'entier décimal 7654321 s'écrit 0x74cbb1 en notation hexadécimale et 0b000000000011101001100101110110001 en notation binaire

Dans ces exemples, nous avons pris la convention de représentation des nombres en langage C. En C, un nombre décimal s'écrit avec la représentation standard. Un nombre entier en notation hexadécimale est par convention préfixé par 0x tandis qu'un nombre entier en notation binaire est préfixé par 0b. Ainsi, les déclarations ci-dessous correspondent toutes à la même valeur.

  int i;
  i = 123;   // décimal
  i = 0x7b;  // hexadécimal
  i = 0173;  // octal !!
  //  i = 0b1111011; // binaire, seulement certains compilateurs

Certains compilateurs permettent d'entrer des constantes en binaire directement en préfixant le nombre avec 0b comme dans i=0b1111011;. Cependant, cette notation n'est pas portable sur tous les compilateurs. Elle doit donc être utilisée avec précaution, contrairement à la notation hexadécimale qui fait partie du langage.

Note

Notation octale

La notation octale peut poser des surprises désagréables au programmeur C débutant. En effet, pour des raisons historiques, les compilateurs C considèrent qu'un entier dont le premier chiffre est 0 est écrit en représentation octale et non en représentation décimale.

Ainsi, le fragment de code ci-dessous affichera à l'écran le message 65 et 53 sont différents car le compilateur C interprète la ligne j=065; comme contenant un entier en notation octale et non décimale.

  int i, j;
  i = 65;   // décimal
  j = 065;  // octal !!!
  if (i == j)
    printf("%d et %d sont égaux\n", i, j);
  else
    printf("%d et %d sont différents\n", i, j);

Le langage C supporte différents types de données qui permettent de représenter des nombres entiers non signés. Les principaux sont repris dans le tableau ci-dessous.

Type

Explication

unsigned short

Nombre entier non signé représenté sur au moins 16 bits

unsigned int

Nombre entier non signé représenté sur au moins 16 bits

unsigned long

Nombre entier non signé représenté sur au moins 32 bits

unsigned long long

Nombre entier non signé représenté sur au moins 64 bits

Le nombre de bits utilisés pour stocker chaque type d'entier non signé peut varier d'une implémentation à l'autre. Le langage C permet de facilement déterminer le nombre de bits utilisés pour stocker un type de données particulier en utilisant l'expression sizeof. Appliquée à un type de données, celle-ci retourne le nombre d'octets que ce type occupe. Ainsi, sur de nombreuses plateformes, sizeof(int) retournera la valeur 4.

Les systèmes informatiques doivent également manipuler des nombres entiers négatifs. Cela se fait en utilisant des nombres dits signés. Au niveau binaire, il y a plusieurs approches possibles pour représenter des nombres signés. La première est de réserver le bit de poids fort dans la représentation du nombre pour stocker le signe et stocker la valeur absolue du nombre dans les bits de poids faible. Mathématiquement, un nombre de n bits utilisant cette notation pourrait se convertir via la formule \((-1)^{b_{n-1}} \times \sum_{i=0}^{n-2} b_i \times 2^i\).

En pratique, cette notation est rarement utilisée pour les nombres entiers car elle rend la réalisation des circuits électroniques de calcul plus compliquée. Un autre inconvénient de cette notation est qu'elle utilise deux séquences de bits différentes pour représenter la valeur zéro (00...0 et 10...0). La représentation la plus courante pour les nombres entiers signés est la notation en complément à 2. Avec cette notation, une séquence de n bits correspond au nombre entier \(-(b_{n-1})\times 2^{n-1} + \sum_{i=0}^{n-2} b_i \times 2^i\). Avec cette notation, le nombre négatif de 4 bits le plus petit correspond à la valeur -8. En notation en complément à deux, il n'y a qu'une seule représentation pour le nombre zéro, la séquence dont tous les bits valent 0. Par contre, il existe toujours un nombre entier négatif qui n'a pas d'équivalent positif.

binaire

décimal signé

0000

0

0001

1

0010

2

0011

3

0100

4

0101

5

0110

6

0111

7

1000

-8

1001

-7

1010

-6

1011

-5

1100

-4

1101

-3

1110

-2

1111

-1

En C, les types de données utilisés pour représenter des entiers sont signés par défaut. Ils ont la même taille que leurs équivalents non signés et sont repris dans la table ci-dessous.

Type

Explication

short

Nombre entier signé représenté sur au moins 16 bits

int

Nombre entier signé représenté sur au moins 16 bits

long

Nombre entier signé représenté sur au moins 32 bits

long long

Nombre entier signé représenté sur au moins 64 bits

Dans de nombreux systèmes Unix, on retrouve dans le fichier stdint.h [https://sites.uclouvain.be/SystInfo/usr/include/stdint.h.html] les définitions des types d'entiers supportés par le système avec le nombre de bits et les valeurs minimales et maximales pour chaque type. La table ci-dessous reprend à titre d'exemple l'information relative aux types short (16 bits) et unsigned int (32 bits).

Type

Bits

Minimum

Maximum

short

16

-32768

32767

unsigned int

32

0

4294967295

Le fichier stdint.h [https://sites.uclouvain.be/SystInfo/usr/include/stdint.h.html] contient de nombreuses constantes qui doivent être utilisées lorsque l'on a besoin des valeurs minimales et maximales pour un type donné. Voici à titre d'exemple quelques unes de ces valeurs :

#define INT8_MAX         127
#define INT16_MAX        32767
#define INT32_MAX        2147483647
#define INT64_MAX        9223372036854775807LL

#define INT8_MIN          -128
#define INT16_MIN         -32768
#define INT32_MIN        (-INT32_MAX-1)
#define INT64_MIN        (-INT64_MAX-1)

#define UINT8_MAX         255
#define UINT16_MAX        65535
#define UINT32_MAX        4294967295U
#define UINT64_MAX        18446744073709551615ULL

L'utilisation d'un nombre fixe de bits pour représenter les entiers peut causer des erreurs dans certains calculs. Par exemple, voici un petit programme qui affiche les 10 premières puissances de cinq et dix.

  short int i = 1;
  unsigned short j = 1;
  int n;

  printf("\nPuissances de 5 en notation signée\n");
  for (n = 1; n < 10; n++) {
    i = i * 5;
    printf("5^%d=%d\n", n, i);
  }

  printf("\nPuissances de 10 en notation non signée\n");
  for (n = 1; n < 10; n++) {
    j = j * 10;
    printf("10^%d=%d\n", n, j);
  }

Lorsqu'il est exécuté, ce programme affiche la sortie suivante.

Puissances de 5 en notation signée
5^1=5
5^2=25
5^3=125
5^4=625
5^5=3125
5^6=15625
5^7=12589
5^8=-2591
5^9=-12955

Puissances de 10 en notation non signée
10^1=10
10^2=100
10^3=1000
10^4=10000
10^5=34464
10^6=16960
10^7=38528
10^8=57600
10^9=51712

Il est important de noter que le langage C ne contient aucun mécanisme d'exception qui permettrait au programmeur de détecter ce problème à l'exécution. Lorsqu'un programmeur choisit une représentation pour stocker des nombres entiers, il est essentiel qu'il ait en tête l'utilisation qui sera faite de cet entier et les limitations qui découlent du nombre de bits utilisés pour représenter le nombre en mémoire. Si dans de nombreuses applications ces limitations ne sont pas pénalisantes, il existe des applications critiques où un calcul erroné peut avoir des conséquences énormes [Bashar1997].

Nombres réels

Outre les nombres entiers, les systèmes informatiques doivent aussi pouvoir manipuler des nombres réels. Ceux-ci sont également représentés sous la forme d'une séquence fixe de bits. Il existe deux formes de représentation pour les nombres réels :

la représentation en simple précision dans laquelle le nombre réel est stocké sous la forme d'une séquence de 32 bits;

la représentation en double précision dans laquelle le nombre réel est stocké sous la forme d'une séquence de 64 bits.

La plupart des systèmes informatiques qui permettent de manipuler des nombres réels utilisent le standard IEEE-754. Un nombre réel est représenté en virgule flottante et la séquence de bits correspondante est décomposée en trois parties 2 :

le bit de poids fort indique le signe du nombre. Par convention, 0 est utilisé pour les nombres positifs et 1 pour les nombres négatifs.

e bits sont réservés pour stocker l'exposant 1.

Les f bits de poids faible servent à stocker la partie fractionnaire du nombre réel.

Exemple de nombre en virgule flottante (simple précision). (source : Wikipedia)

En simple (resp. double) précision, 8 (resp. 11) bits sont utilisés pour stocker l'exposant et 23 (resp. 52) bits pour la partie fractionnaire. L'encodage des nombres réels en simple et double précision a un impact sur la précision de divers algorithmes numériques. Une analyse détaillée de ces problèmes sort du cadre de ce cours, mais il est important de noter deux propriétés importantes de la notation en virgule flottante utilisée actuellement. Ces deux propriétés s'appliquent de la même façon à la simple qu'à la double précision.

une représentation en virgule flottante sur n bits ne permet jamais de représenter plus de \(2^n\) nombres réels différents;

les représentations en virgule flottante privilégient les nombres réels compris dans l'intervalle \([-1,1]\). On retrouve autant de nombres réels représentables dans cet intervalle que de nombres dont la valeur absolue est supérieure à 1.

En C, ces nombres en virgule flottante sont représentés en utilisant les types float (simple précision) et double (double précision). Les fichiers float.h [https://sites.uclouvain.be/SystInfo/usr/include/float.h.html] et math.h [https://sites.uclouvain.be/SystInfo/usr/include/math.h.html] définissent de nombreuses constantes relatives à ces types. Voici, à titre d'exemple, les valeurs minimales et maximales pour les float et les double ainsi que les constantes associées. Pour qu'un programme soit portable, il faut utiliser les constantes définies dans float.h [https://sites.uclouvain.be/SystInfo/usr/include/float.h.html] et math.h [https://sites.uclouvain.be/SystInfo/usr/include/math.h.html] et non leurs valeurs numériques.

#define FLT_MIN 1.17549435e-38F
#define FLT_MAX 3.40282347e+38F

#define DBL_MIN 2.2250738585072014e-308
#define DBL_MAX 1.7976931348623157e+308

Les tableaux

En langage C, les tableaux permettent d'agréger des données d'un même type. Il est possible de définir des vecteurs et des matrices en utilisant la syntaxe ci-dessous.

#define N 10
int vecteur[N];
float matriceC[N][N];
float matriceR[N][2*N];

Les premières versions du langage C ne permettaient que la définition de tableaux dont la taille est connue à la compilation. Cette restriction était nécessaire pour permettre au compilateur de réserver la zone mémoire pour stocker le tableau. Face à cette limitation, de nombreux programmeurs définissaient la taille du tableau via une directive #define du pré-processeur comme dans l'exemple ci-dessus. Cette directive permet d'associer une chaîne de caractères quelconque à un symbole. Dans l'exemple ci-dessus, la chaîne 10 est associée au symbole N. Lors de chaque compilation, le préprocesseur remplace toutes les occurrences de N par 10. Cela permet au compilateur de ne traiter que des tableaux de taille fixe.

Un tableau à une dimension peut s'utiliser avec une syntaxe similaire à celle utilisée par Java. Dans un tableau contenant n éléments, le premier se trouve à l'indice 0 et le dernier à l'indice n-1. L'exemple ci-dessous présente le calcul de la somme des éléments d'un vecteur.

  int i;
  int sum = 0;
  for (i = 0; i < N; i++) {
    sum += v[i];
  }

Le langage C permet aussi la manipulation de matrices carrées ou rectangulaires qui sont composées d'éléments d'un même type. L'exemple ci-dessous calcule l'élément minimum d'une matrice rectangulaire. Il utilise la constante FLT_MAX qui correspond au plus grand nombre réel représentable avec un float et qui est définie dans float.h [https://sites.uclouvain.be/SystInfo/usr/include/float.h.html].

  #define L 2
  #define C 3
  float matriceR[L][C] = { {1.0,2.0,3.0},
                           {4.0,5.0,6.0} };
  int i, j;
  float min = FLT_MAX;
  for (i = 0; i < L; i++)
    for (j = 0; j < C; j++)
      if (matriceR[i][j] < min)
        min=matriceR[i][j];

Les compilateurs récents qui supportent [C99] permettent l'utilisation de tableaux dont la taille n'est connue qu'à l'exécution. Nous en reparlerons ultérieurement.

Caractères et chaînes de caractères

Historiquement, les caractères ont été représentés avec des séquences de bits de différentes longueurs. Les premiers ordinateurs utilisaient des blocs de cinq bits. Ceux-ci permettaient de représenter 32 valeurs différentes. Cinq bits ne permettent pas facilement de représenter à la fois les chiffres et les lettres et les premiers ordinateurs utilisaient différentes astuces pour supporter ces caractères sur 5 bits. Ensuite, des représentations sur six puis sept et huit bits ont été utilisées. Au début des années septante, le code de caractères ASCII sur 7 et 8 bits s'est imposé sur un grand nombre d'ordinateurs et a été utilisé comme standard pour de nombreuses applications et notamment sur Internet [RFC20]. La table de caractères ASCII définit une correspondance entre des séquences de bits et des caractères. [RFC20] contient la table des caractères ASCII représentés sur 7 bits. À titre d'exemple, le chiffre 0 correspond à l'octet 0b00110000 et le chiffre 9 à l'octet 0b00111001. La lettre a correspond à l'octet 0b01100001 et la lettre A à l'octet 0b01000001. De nombreux détails sur la table ASCII sont disponibles sur la page Wikipedia: https://fr.wikipedia.org/wiki/American_Standard_Code_for_Information_Interchange

Les inventeurs du C se sont appuyés sur la table ASCII et ont choisi de représenter un caractère en utilisant un octet. Cela correspond au type char que nous avons déjà évoqué.

Concernant le type char, il est utile de noter qu'un char est considéré en C comme correspondant à un entier. Cela implique qu'il est possible de faire des manipulations numériques sur les caractères. À titre d'exemple, une fonction toupper(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/toupper.3.html] permettant de transformer un caractère représentant une minuscule dans le caractère représentant la majuscule correspondante peut s'écrire :

// conversion de minuscules en majuscules
int toUpper(char c) {
  if (c >= 'a' && c <= 'z')
    return c + ('A' - 'a');
  else
    return c;
}

En pratique, l'utilisation de la table ASCII pour représenter des caractères souffre d'une limitation majeure. Avec 7 ou 8 bits il n'est pas possible de représenter exactement tous les caractères écrits de toutes les langues. Une table des caractères sur 7 bits est suffisante pour les langues qui utilisent peu de caractères accentués comme l'anglais. Pour le français et de nombreuses langues en Europe occidentale, la table sur 8 bits est suffisante et la norme ISO-8859 [https://en.wikipedia.org/wiki/ISO/IEC_8859] contient des tables de caractères 8 bits pour de nombreuses langues. La norme Unicode va plus loin en permettant de représenter les caractères écrits de toutes les langues connues sur Terre. Une description détaillée du support de ces types de caractères sort du cadre de ce cours sur les systèmes informatiques. Il est cependant important que vous soyez conscient de cette problématique pour pouvoir la prendre en compte lorsque vous développerez des applications qui doivent traiter du texte dans différentes langues.

À titre d'exemple, la fonction toupper(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/toupper.3.html] qui est implémentée dans les versions récentes de Linux est nettement plus complexe que celle que nous avons vue ci-dessus. Tout d'abord, la fonction toupper(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/toupper.3.html] prend comme argument un int et non un char. Cela lui permet d'accepter des caractères dans n'importe quel encodage. Ensuite, le traitement qu'elle effectue dépend du type d'encodage qui a été défini via setlocale(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setlocale.3.html] (voir locale(7) [https://sites.uclouvain.be/SystInfo/manpages/man7/locale.7.html]).

Dans la suite de ce document, nous supposerons qu'un caractère est toujours représentable en utilisant le type char permettant de stocker un octet.

En C, les chaînes de caractères sont représentées sous la forme d'un tableau de caractères. Une chaîne de caractères peut être initialisée de différentes façons reprises ci-dessous.

char name1[] = { 'U', 'n', 'i', 'x' };
char name2[] = { "Unix" };
char name3[] = "Unix";

Lorsque la taille de la chaîne de caractères n'est pas indiquée à l'initialisation (c'est-à-dire dans les deux dernières lignes ci-dessus), le compilateur C la calcule et alloue un tableau permettant de stocker la chaîne de caractères suivie du caractère \0 qui par convention termine toujours les chaînes de caractères en C. En mémoire, la chaîne de caractères correspondant à name3 occupe donc cinq octets. Les quatre premiers contiennent les caractères U, n, i et x et le cinquième le caractère \0. Il est important de bien se rappeler cette particularité du langage C car comme nous le verrons plus tard ce choix a de nombreuses conséquences.

Cette particularité permet d'implémenter facilement des fonctions de manipulation de chaînes de caractères. À titre d'exemple, la fonction ci-dessous calcule la longueur d'une chaîne de caractères.

int length(char str[])
{
  int i = 0;
  while (str[i] != 0) {// '\0' et 0 sont égaux
    i++;
  }
  return i;
}

Contrairement à des langages comme Java, C ne fait aucune vérification sur la façon dont un programme manipule un tableau. En C, il est tout à fait légal d'écrire le programme suivant :

char name[5] = "Unix";
printf("%c", name[6]);
printf("%c", name[12345]);
printf("%c", name[-1]);

En Java, tous les accès au tableau name en dehors de la zone mémoire réservée provoqueraient une ArrayIndexOutOfBoundsException. En C, il n'y a pas de mécanisme d'exception et le langage présuppose que lorsqu'un programmeur écrit name[i], il a la garantie que la valeur i sera telle qu'il accédera bien à un élément valide du tableau name. Ce choix de conception du C permet d'obtenir du code plus efficace qu'avec Java puisque l'interpréteur Java doit vérifier tous les accès à chaque tableau lorsqu'ils sont exécutés. Malheureusement, ce choix de conception du C est aussi à l'origine d'un très grand nombre de problèmes qui affectent la sécurité de nombreux logiciels. Ces problèmes sont connus sous la dénomination buffer overflow. Nous aurons l'occasion d'y revenir plus tard.

Les pointeurs

Une différence majeure entre le C et la plupart des langages de programmation actuels est que le C est proche de la machine (langage de bas niveau) et permet au programmeur d'interagir directement avec la mémoire où les données qu'un programme manipule sont stockées. En Java, un programme peut créer autant d'objets qu'il souhaite (ou presque 3). Ceux-ci sont stockés en mémoire et le garbage collector retire de la mémoire les objets qui ne sont plus utilisés. En C, un programme peut aussi réserver des zones pour stocker de l'information en mémoire. Cependant, comme nous le verrons plus tard, c'est le programmeur qui doit explicitement allouer et libérer la mémoire.

Les pointeurs sont une des caractéristiques principales du langage C par rapport à de nombreux autres langages. Un pointeur est défini comme étant une variable contenant l'adresse d'une autre variable. Pour bien comprendre le fonctionnement des pointeurs, il est important d'avoir en tête la façon dont la mémoire est organisée sur un ordinateur. D'un point de vue abstrait, la mémoire d'un ordinateur peut être vue sous la forme d'une zone de stockage dans laquelle il est possible de lire ou d'écrire de l'information. Chaque zone permettant de stocker de l'information est identifiée par une adresse. La mémoire peut être vue comme une implémentation de deux fonctions C :

data read(addr) est une fonction qui, sur base d'une adresse, retourne la valeur stockée à cette adresse.

void write(addr, data) est une fonction qui écrit la donnée data à l'adresse addr en mémoire.

Ces adresses sont stockées sur un nombre fixe de bits qui dépend en général de l'architecture du microprocesseur. Les valeurs les plus courantes aujourd'hui sont 32 et 64. Par convention, les adresses sont représentées sous la forme d'entiers non signés. Sur la plupart des architectures de processeurs, une adresse correspond à une zone mémoire permettant de stocker un octet. Lorsque nous utiliserons une représentation graphique de la mémoire, nous placerons toujours les adresses numériquement basses en bas de la figure et elles croîtront vers le haut.

Considérons l'initialisation ci-dessous et supposons qu'elle est stockée dans une mémoire où les adresses sont encodées sur 3 bits. Une telle mémoire dispose de huit zones permettant chacune de stocker un octet.

char name[] = "Unix";
char c = 'Z';

Après exécution de cette initialisation et en supposant que rien d'autre n'est stocké dans cette mémoire, celle-ci contiendra les informations reprises dans la table ci-dessous.

Adresse

Contenu

111

0

110

0

101

Z

100

0

011

x

010

i

001

n

000

U

En langage C, l'expression &var permet de récupérer l'adresse à laquelle une variable a été stockée. Appliquée à l'exemple ci-dessus, l'expression &(name[0]) retournerait la valeur 0b000 tandis que &c retournerait la valeur 0b101.

L'expression & peut s'utiliser avec n'importe quel type de donnée. Les adresses de données en mémoire sont rarement affichées, mais quand c'est le cas, on utilise la notation hexadécimale comme dans l'exemple ci-dessous.

  int i = 1252;
  char str[] = "sinf1252";
  char c = 'c';

  printf("i vaut %d, occupe %ld bytes et est stocké à l'adresse : %p\n",
         i, sizeof(i), &i);
  printf("c vaut %c, occupe %ld bytes et est stocké à l'adresse : %p\n",
         c, sizeof(c), &c);
  printf("str contient \"%s\" et est stocké à partir de l'adresse : %p\n",
         str, &str);

L'exécution de ce fragment de programme produit la sortie suivante.

i vaut 1252, occupe 4 bytes et est stocké à l'adresse : 0x7fff89f99cbc
c vaut c, occupe 1 bytes et est stocké à l'adresse : 0x7fff89f99caf
str contient "sinf1252" et est stocké à partir de l'adresse : 0x7fff89f99cb0

L'intérêt des pointeurs en C réside dans la possibilité de les utiliser pour accéder et manipuler des données se trouvant en mémoire de façon efficace. En C, chaque pointeur a un type et le type du pointeur indique le type de la donnée qui est stockée dans une zone mémoire particulière. Le type est associé au pointeur lors de la déclaration de celui-ci.

int i = 1;      // entier
int *ptr_i;     // pointeur vers un entier
char c = 'Z';   // caractère
char *ptr_c;    // pointeur vers un char

Grâce aux pointeurs, il est possible non seulement d'accéder à l'adresse où une donnée est stockée, mais aussi d'accéder à la valeur qui est stockée dans la zone mémoire pointée par le pointeur en utilisant l'expression *ptr. Il est également possible d'effectuer des calculs sur les pointeurs comme représenté dans l'exemple ci-dessous.

  int i = 1;           // entier
  int *ptr_i;          // pointeur vers un entier
  char str[] = "Unix";
  char *s;             // pointeur vers un char

  ptr_i = &i;
  printf("valeur de i : %d, valeur pointée par ptr_i : %d\n", i, *ptr_i);
  *ptr_i = *ptr_i + 1252;
  printf("valeur de i : %d, valeur pointée par ptr_i : %d\n", i, *ptr_i);
  s = str;
  for (i = 0; i < strlen(str); i++){
    printf("valeur de str[%d] : %c, valeur pointée par *(s+%d) : %c\n",
           i, str[i], i, *(s+i));
  }

L'exécution de ce fragment de programme produit la sortie suivante.

valeur de i : 1, valeur pointée par ptr_i : 1
valeur de i : 1253, valeur pointée par ptr_i : 1253
valeur de str[0] : U, valeur pointée par *(s+0) : U
valeur de str[1] : n, valeur pointée par *(s+1) : n
valeur de str[2] : i, valeur pointée par *(s+2) : i
valeur de str[3] : x, valeur pointée par *(s+3) : x

En pratique en C, les notations char* et char[] sont équivalentes et l'une peut s'utiliser à la place de l'autre. En utilisant les pointeurs, la fonction de calcul de la longueur d'une chaîne de caractères peut se réécrire comme suit.

int length(char *s)
{
  int i = 0;
  while (*(s+i) != '\0')
    i++;
  return i;
}

Les pointeurs sont fréquemment utilisés dans les programmes écrits en langage C et il est important de bien comprendre leur fonctionnement. Un point important à bien comprendre est ce que l'on appelle l'arithmétique des pointeurs, c'est-à-dire la façon dont les opérations sur les pointeurs sont exécutées en langage C. Pour cela, il est intéressant de considérer la manipulation d'un tableau d'entiers à travers des pointeurs.

#define SIZE 3
unsigned int tab[3];
tab[0] = 0x01020304;
tab[1] = 0x05060708;
tab[2] = 0x090A0B0C;

En mémoire, ce tableau est stocké en utilisant trois mots consécutifs de 32 bits comme le montre l'exécution du programme ci-dessous :

int i;
for (i = 0; i < SIZE; i++) {
  printf("%X est à l'adresse %p\n", tab[i], &(tab[i]));
}

1020304 est à l'adresse 0x7fff5fbff750
5060708 est à l'adresse 0x7fff5fbff754
90A0B0C est à l'adresse 0x7fff5fbff758

La même sortie est produite avec le fragment de programme suivant qui utilise un pointeur.

unsigned int* ptr = tab;
for (i = 0; i < SIZE; i++) {
  printf("%X est à l'adresse %p\n", *ptr, ptr);
  ptr++;
}

Ce fragment de programme est l'occasion de réfléchir sur la façon dont le C évalue les expressions qui contiennent des pointeurs. La première est l'assignation ptr=tab. Lorsque tab est déclaré par la ligne unsigned int tab[3], le compilateur considère que tab est une constante qui contiendra toujours l'adresse du premier élément du tableau. Il faut noter que puisque tab est considéré comme une constante, il est interdit d'en modifier la valeur en utilisant une assignation comme tab=tab+1. Le pointeur ptr, par contre, correspond à une zone mémoire qui contient une adresse. Il est tout à fait possible d'en modifier la valeur. Ainsi, l'assignation ptr=tab (ou ptr=&(tab[0])) place dans ptr l'adresse du premier élément du tableau. Les pointeurs peuvent aussi être modifiés en utilisant des expressions arithmétiques.

ptr = ptr + 1;  // ligne 1
ptr++;          // ligne 2
ptr = ptr - 2;  // ligne 3

Après l'exécution de la première ligne, ptr va contenir l'adresse de l'élément 1 du tableau tab (c'est-à-dire &(tab[1])). Ce résultat peut surprendre car si l'élément tab[0] se trouve à l'adresse 0x7fff5fbff750 c'est cette adresse qui est stockée dans la zone mémoire correspondant au pointeur ptr. On pourrait donc s'attendre à ce que l'expression ptr+1 retourne plutôt la valeur 0x7fff5fbff751. Il n'en est rien. En C, lorsque l'on utilise des calculs qui font intervenir des pointeurs, le compilateur prend en compte le type du pointeur qui est utilisé. Comme ptr est de type unsigned int*, il pointe toujours vers une zone mémoire permettant de stocker un entier non signé sur 32 bits. L'expression ptr+1 revient en fait à calculer la valeur ptr+sizeof(unsigned int) et donc ptr+1 correspondra à l'adresse 0x7fff5fbff754. Pour la même raison, l'exécution de la deuxième ligne placera l'adresse 0x7fff5fbff758 dans ptr. Enfin, la dernière ligne calculera 0x7fff5fbff758-2*sizeof(unsigned int), ce qui correspond à 0x7fff5fbff750.

Il est intéressant pour terminer cette première discussion de l'arithmétique des pointeurs, de considérer l'exécution du fragment de code ci-dessous.

unsigned char* ptr_char = (unsigned char *) tab;
printf("ptr_char contient %p\n", ptr_char);
for (i = 0; i < SIZE + 1; i++) {
  printf("%X est à l'adresse %p\n", *ptr_char, ptr_char);
  ptr_char++;
}

L'exécution de ce fragment de code produit une sortie qu'il est intéressant d'analyser.

ptr_char contient 0x7fff5fbff750
4 est à l'adresse 0x7fff5fbff750
3 est à l'adresse 0x7fff5fbff751
2 est à l'adresse 0x7fff5fbff752
1 est à l'adresse 0x7fff5fbff753

Tout d'abord, l'initialisation du pointeur ptr_char a bien stocké dans ce pointeur l'adresse en mémoire du premier élément du tableau. Ensuite, comme ptr_char est un pointeur de type unsigned char *, l'expression *ptr_char a retourné la valeur de l'octet se trouvant à l'adresse 0x7fff5fbff750. Le pointeur ptr_char a été incrémenté en respectant l'arithmétique des pointeurs. Comme sizeof(unsigned char) retourne 1, la valeur stockée dans ptr_char a été incrémentée d'une seule unité par l'instruction ptr_char++. En analysant les quatre unsigned char se trouvant aux adresses 0x7fff5fbff750 à 0x7fff5fbff753, on retrouve bien l'entier 0x01020304 qui avait été placé dans tab[0].

Les structures

Outre les types de données décrits ci-dessus, les programmes informatiques doivent souvent pouvoir manipuler des données plus complexes. À titre d'exemples, un programme de calcul doit pouvoir traiter des nombres complexes, un programme de gestion des étudiants doit traiter des fiches d'étudiants avec nom, prénom, numéro de matricule,... Dans les langages orientés objet comme Java, cela se fait en encapsulant des données de différents types avec les méthodes permettant leur traitement. C n'étant pas un langage orienté objet, il ne permet pas la création d'objets et de méthodes directement associées. Par contre, C permet de construire des types de données potentiellement complexes.

C permet la définition de structures qui combinent différents types de données simples ou structurés. Contrairement aux langages orientés objet, il n'y a pas de méthode directement associée aux structures qui sont définies. Une structure est uniquement un type de données. Voici quelques exemples de structures simples en C.

// structure pour stocker une coordonnée 3D
struct coord {
  int x;
  int y;
  int z;
};

struct coord point = {1, 2, 3};
struct coord p;

// structure pour stocker une fraction
struct fraction {
  int numerator;
  int denominator;
};

struct fraction demi = {1, 2};
struct fraction f;

// structure pour représenter un étudiant
struct student {
  int matricule;
  char prenom[20];
  char nom[30];
};

struct student s = {1, "Linus", "Torvalds"};

Le premier bloc définit une structure dénommée coord qui contient trois entiers baptisés x, y et z. Dans une structure, chaque élément est identifié par son nom et il est possible d'y accéder directement. La variable point est de type struct coord et son élément x est initialisé à la valeur 1 tandis que son élément z est initialisé à la valeur 3. La variable p est également de type struct coord mais elle n'est pas explicitement initialisée lors de sa déclaration.

La structure struct fract définit une fraction qui est composée de deux entiers qui sont respectivement le numérateur et le dénominateur. La structure struct student, elle, définit un type de données qui comprend un numéro de matricule et deux chaînes de caractères.

Les structures permettent de facilement regrouper des données qui sont logiquement reliées entre elles et doivent être manipulées en même temps. C permet d'accéder facilement à un élément d'une structure en utilisant l'opérateur '.'. Ainsi, la structure point dont nous avons parlé ci-dessus aurait pu être initialisée par les trois expressions ci-dessous :

point.x = 1;
point.y = 2;
point.z = 3;

Dans les premières versions du langage C, une structure devait nécessairement contenir uniquement des données qui ont une taille fixe, c'est-à-dire des nombres, des caractères, des pointeurs ou des tableaux de taille fixe. Il n'était pas possible de stocker des tableaux de taille variable comme une chaîne de caractères char []. Les compilateurs récents [C99] permettent de supporter des tableaux flexibles à l'intérieur de structures. Nous ne les utiliserons cependant pas dans le cadre de ce cours.

Les structures sont utilisées dans différentes librairies et appels système sous Unix et Linux. Un exemple classique est la gestion du temps sur un système Unix. Un système informatique contient généralement une horloge dite temps-réel qui est en pratique construite autour d'un cristal qui oscille à une fréquence fixée. Ce cristal est piloté par un circuit électronique qui compte ses oscillations, ce qui permet de mesurer le passage du temps. Le système d'exploitation utilise cette horloge temps réel pour diverses fonctions et notamment la mesure du temps du niveau des applications.

Un système de type Unix maintient différentes structures qui sont associées à la mesure du temps 4. La première sert à mesurer le nombre de secondes et de microsecondes qui se sont écoulées depuis le premier janvier 1970. Cette structure, baptisée struct timeval est définie dans sys/time.h [https://sites.uclouvain.be/SystInfo/usr/include/sys/time.h.html] comme suit :

struct timeval {
  time_t       tv_sec;   /* seconds since Jan. 1, 1970 */
  suseconds_t  tv_usec;  /* and microseconds */
};

Cette structure est utilisée par des appels système tels que gettimeofday(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/gettimeofday.2.html] pour notamment récupérer l'heure courante ou les appels de manipulation de temporisateurs (timers en anglais) tels que getitimer(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getitimer.2.html] / setitimer(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/setitimer.2.html]. Elle est aussi utilisée par la fonction time(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/time.3posix.html] de la librairie standard et est très utile pour mesurer les performances d'un programme.

Les structures sont également fréquemment utilisées pour représenter des formats de données spéciaux sur disque comme le format des répertoires 5 ou les formats de paquets qui sont échangés sur le réseau 6.

La définition de struct timeval utilise une fonctionnalité fréquemment utilisée du C : la possibilité de définir des alias pour des noms de type de données existants. Cela se fait en utilisant l'opérateur typedef. En C, il est possible de renommer des types de données existants. Ainsi, l'exemple ci-dessous utilise typedef pour définir Entier comme alias pour le type int et Fraction pour la structure struct fraction.

// structure pour stocker une fraction
typedef struct fraction {
  double numerator;
  double denominator;
} Fraction ;

typedef int Entier;

int main(int argc, char *argv[])
{
  Fraction demi = {1, 2};
  Entier i = 2;
  // ...
  return EXIT_SUCCESS;
}

Les types Entier et int peuvent être utilisés de façon interchangeable à l'intérieur du programme une fois qu'ils ont été définis.

Note

typedef en pratique

Renommer des types de données a des avantages et des inconvénients dont il faut être conscient pour pouvoir l'utiliser à bon escient. L'utilisation de typedef peut faciliter la lecture et la portabilité de certains programmes. Lorsqu'un typedef est associé à une structure, cela facilite la déclaration de variables de ce type et permet le cas échéant de modifier la structure de données ultérieurement sans pour autant devoir modifier l'ensemble du programme. Cependant, contrairement aux langages orientés objet, des méthodes ne sont pas directement associées aux structures et la modification d'une structure oblige souvent à vérifier toutes les fonctions qui utilisent cette structure. L'utilisation de typedef permet de clarifier le rôle de certains types de données ou valeurs de retour de fonctions. À titre d'exemple, l'appel système read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] qui permet notamment de lire des données dans un fichier retourne le nombre d'octets qui ont été lus après chaque appel. Cette valeur de retour est de type ssize_t. L'utilisation de ces types permet au compilateur de vérifier que les bons types de données sont utilisés lors des appels de fonctions.

typedef est souvent utilisé pour avoir des identifiants de types de données plus courts. Par exemple, il est très courant de remplacer le types unsigned par les abréviations ci-dessous.

typedef unsigned int u_int_t;
typedef unsigned long u_long_t;

Soyez prudents si vous utilisez des typedef pour redéfinir des pointeurs. En C, il est tout à fait valide d'écrire les lignes suivantes.

typedef int * int_ptr;
typedef char * string;

Malheureusement, il y a un risque dans un grand programme que le développeur oublie que ces types de données correspondent à des pointeurs qui doivent être manipulés avec soin. Le Linux kernel coding style [https://www.kernel.org/doc/Documentation/CodingStyle] contient une discussion intéressante sur l'utilisation des typedef.

Les pointeurs sont fréquemment utilisés lors de la manipulation de structures. Lorsqu'un pointeur pointe vers une structure, il est utile de pouvoir accéder facilement aux éléments de la structure. Le langage C supporte deux notations pour représenter ces accès aux éléments d'une structure. La première notation est (*ptr).elem où ptr est un pointeur et elem l'identifiant d'un des éléments de la structure pointée par ptr. Cette notation est en pratique assez peu utilisée. La notation la plus fréquente est ptr->elem dans laquelle ptr et ->elem sont respectivement un pointeur et un identifiant d'élément. L'exemple ci-dessous illustre l'initialisation de deux fractions en utilisant ces notations.

struct fraction demi, quart;
struct fraction *demi_ptr;
struct fraction *quart_ptr;

demi_ptr = &demi;
quart_ptr = &quart;

(*demi_ptr).num = 1;
(*demi_ptr).den = 2;

quart_ptr->num = 1;
quart_ptr->den = 4;

Les pointeurs sont fréquemment utilisés en combinaison avec des structures et on retrouve très souvent la seconde notation dans des programmes écrits en C.

Les fonctions

Comme la plupart des langages, le C permet de faciliter la compréhension d'un programme en le découpant en de nombreuses fonctions. Chacune réalise une tâche simple. Tout comme Java, C permet la définition de fonctions qui ne retournent aucun résultat. Celles-ci sont de type void comme l'exemple trivial ci-dessous.

void usage()
{
  printf("Usage : ...\n");
}

La plupart des fonctions utiles retournent un résultat qui peut être une donnée d'un des types standard ou une structure. Cette utilisation est similaire à ce que l'on trouve dans des langages comme Java. Il faut cependant être attentif à la façon dont le langage C traite les arguments des fonctions. Le langage C utilise le passage par valeur des arguments. Lorsqu'une fonction est exécutée, elle reçoit les valeurs de ces arguments. Ces valeurs sont stockées dans une zone mémoire qui est locale à la fonction. Toute modification faite sur la valeur d'une variable à l'intérieur d'une fonction est donc locale à cette fonction. Les deux fonctions ci-dessous ont le même résultat et aucune des deux n'a d'effet de bord.

int twotimes(int n)
{
  return 2 * n;
}

int two_times(int n)
{
  n = 2 * n;
  return n;
}

Il faut être nettement plus attentif lorsque l'on écrit des fonctions qui utilisent des pointeurs comme arguments. Lorsqu'une fonction a un argument de type pointeur, celui-ci est passé par valeur, mais connaissant la valeur du pointeur, il est possible à la fonction de modifier le contenu de la zone mémoire pointée par le pointeur. Ceci est illustré par l'exemple ci-dessous.

int times_two(int *n)
{
  return (*n) + (*n);
}

int timestwo(int *n)
{
  *n = (*n) + (*n);
  return *n;
}

void f()
{
  int i = 1252;
  printf("i:%d\n", i);
  printf("times_two(&i)=%d\n", times_two(&i));
  printf("après times_two, i:%d\n", i);
  printf("timestwo(&i)=%d\n", timestwo(&i));
  printf("après timestwo, i:%d\n", i);
}

Lors de l'exécution de la fonction f, le programme ci-dessus affiche à la console la sortie suivante :

i:1252
times_two(&i)=2504
après times_two, i:1252
timestwo(&i)=2504
après timestwo, i:2504

Cet exemple illustre aussi une contrainte imposée par le langage C sur l'ordre de définition des fonctions. Pour que les fonctions times_two et timestwo puissent être utilisées à l'intérieur de la fonction f, il faut qu'elles aient été préalablement définies. Dans l'exemple ci-dessus, cela s'est fait en plaçant la définition des deux fonctions avant leur utilisation. C'est une règle de bonne pratique utilisable pour de petits programmes composés de quelques fonctions. Pour des programmes plus larges, il est préférable de placer au début du code source la signature des fonctions qui y sont définies. La signature d'une fonction comprend le type de valeur de retour de la fonction, son nom et les types de ses arguments. Généralement, ces déclarations sont regroupées à l'intérieur d'un fichier header dont le nom se termine par .h.

int times_two(int *);
int timestwo(int *);

Les fonctions peuvent évidemment recevoir également des tableaux comme arguments. Cela permet par exemple d'implémenter une fonction qui calcule la longueur d'une chaîne de caractères en itérant dessus jusqu'à trouver le caractère de fin de chaîne.

int length(char *s)
{
  int i = 0;
  while (*(s+i) != '\0')
    i++;
  return i;
}

Tout comme cette fonction peut accéder au ième caractère de la chaîne passée en argument, elle peut également et sans aucune restriction modifier chacun des caractères de cette chaîne. Par contre, comme le pointeur vers la chaîne de caractères est passé par valeur, la fonction ne peut pas modifier la zone mémoire qui est pointée par l'argument.

Un autre exemple de fonctions qui manipulent les tableaux sont des fonctions mathématiques qui traitent des vecteurs par exemple.

void plusun(int size, int *v)
{
  int i;
  for (i = 0; i < size; i++)
    v[i]++;
}

void print_vecteur(int size, int*v) {
  int i;
  printf("v={");
  for (i = 0;i < size - 1; i++)
    printf("%d,", v[i]);

  if (size > 0)
    printf("%d}", v[size - 1]);
  else
    printf("}");
}

Ces deux fonctions peuvent être utilisées par le fragment de code ci-dessous :

int vecteur[N] = {1, 2, 3, 4, 5};
plusun(N, vecteur);
print_vecteur(N, vecteur);

Note

Attention à la permissivité du compilateur C

Certains langages comme Java sont fortement typés et le compilateur contient de nombreuses vérifications, notamment sur les types de données utilisés, qui permettent d'éviter un grand nombre d'erreurs. Le langage C est lui nettement plus libéral. Les premiers compilateurs C étaient très permissifs notamment sur les types de données passés en arguments. Ainsi, un ancien compilateur C accepterait probablement sans broncher les appels suivants :

  plusun(vecteur,N);
  print_vecteur(N,vecteur);

Dans ce fragment de programme, l'appel à print_vecteur est tout à fait valide. Par contre, l'appel à plusun est lui erroné puisque le premier argument est un tableau d'entiers (ou plus précisément un pointeur vers le premier élément d'un tableau d'entiers) alors que la fonction plusun attend un entier. Inversement, le second argument est un entier à la place d'un tableau d'entiers. Cette erreur n'empêche pas le compilateur gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] de compiler le programme correspondant. Il émet cependant le warning suivant :

warning: passing argument 1 of ‘plusun’ makes integer from pointer without a cast
warning: passing argument 2 of ‘plusun’ makes pointer from integer without a cast

De nombreux programmeurs débutants ignorent souvent les warnings émis par le compilateur et se contentent d'avoir un programme compilé. C'est la source de nombreuses erreurs et de nombreux problèmes. Dans l'exemple ci-dessus, l'exécution de l'appel plusun(vecteur,N) provoquera une tentative d'accès à la mémoire dans une zone qui n'est pas allouée au processus. Dans ce cas, la tentative d'accès est bloquée par le système et provoque l'arrêt immédiat du programme sur une segmentation fault. Dans d'autres cas, des erreurs plus subtiles mais du même type ont provoqué des problèmes graves de sécurité dans des programmes écrits en langage C. Nous y reviendrons ultérieurement.

Pour terminer, mentionnons que les fonctions écrites en C peuvent utiliser des structures et des pointeurs vers des structures comme arguments. Elles peuvent aussi retourner des structures comme résultat. Ceci est illustré par deux variantes de fonctions permettant d'initialiser une fraction et de déterminer si deux fractions sont égales 7.

struct fraction init(int num, int den)
{
  struct fraction f;
  f.numerator = num;
  f.denominator = den;
  return f;
}

int equal(struct fraction f1, struct fraction f2)
{
  return ((f1.numerator == f2.numerator)
          && (f1.denominator == f2.denominator));
}

int equalptr(struct fraction *f1, struct fraction *f2)
{
  return ((f1->numerator==f2->numerator)
          && (f1->denominator==f2->denominator));
}

void initptr(struct fraction *f, int num, int den)
{
  f->numerator = num;
  f->denominator = den;
}

Considérons d'abord les fonctions init et equal. init est une fonction qui construit une structure sur base d'arguments entiers et retourne la valeur construite. equal quant à elle reçoit comme arguments les valeurs de deux structures. Elle peut accéder à tous les éléments des structures passées en argument. Comme ces structures sont passées par valeur, toute modification aux éléments de la structure est locale à la fonction equal et n'est pas répercutée sur le code qui a appelé la fonction.

Les fonctions initptr et equalptr utilisent toutes les deux des pointeurs vers des struct fraction comme arguments. Ce faisant, elles ne peuvent modifier la valeur de ces pointeurs puisqu'ils sont passés comme valeurs. Par contre, les deux fonctions peuvent bien entendu modifier les éléments de la structure qui se trouvent dans la zone de mémoire pointée par le pointeur. C'est ce que initptr fait pour initialiser la structure. equalptr par contre se contente d'accéder aux éléments des structures passées en argument sans les modifier. Le fragment de code ci-dessous illustre comment ces fonctions peuvent être utilisées en pratique.

struct fraction quart;
struct fraction tiers;
quart = init(1, 4);
initptr(&tiers, 1, 3);
printf("equal(tiers,quart)=%d\n", equal(tiers, quart));
printf("equalptr(&tiers,&quart)=%d\n", equalptr(&tiers, &quart));

Les expressions de manipulation de bits

La plupart des langages de programmation sont spécialisés dans la manipulation des types de données classiques comme les entiers, les réels et les chaînes de caractères. Comme nous l'avons vu, le langage C permet de traiter ces types de données. En outre, il permet au programmeur de pouvoir facilement manipuler les bits qui se trouvent en mémoire. Pour cela, le langage C définit des expressions qui correspondent à la plupart des opérations de manipulation de bits que l'on retrouve dans les langages d'assemblage. Les premières opérations sont les opérations logiques.

La première opération logique est la négation négation (NOT en anglais). Elle prend comme argument un bit et retourne le bit inverse. Comme toutes les opérations logiques, elle peut se définir simplement sous la forme d'une table de vérité. Dans des formules mathématiques, la négation est souvent représentée sous la forme \(\neg{A}\).

A

NOT(A)

0

1

1

0

La deuxième opération est la conjonction logique (AND en anglais). Cette opération prend deux arguments binaires et retourne un résultat binaire. Dans des formules mathématiques, la conjonction logique est souvent représentée sous la forme \(A \wedge B\). Elle se définit par la table de vérité suivante :

A

B

A AND B

0

0

0

0

1

0

1

0

0

1

1

1

La troisième opération est la disjonction logique (OR en anglais). Cette opération prend deux arguments binaires. Dans des formules mathématiques, la disjonction logique est souvent représentée sous la forme \(A \vee B\). Elle se définit par la table de vérité suivante.

A

B

A OR B

0

0

0

0

1

1

1

0

1

1

1

1

Enfin, une dernière opération logique intéressante est le ou exclusif (XOR en anglais). Celle-ci se définit par la table de vérité ci-dessous. Cette opération est parfois représentée mathématiquement comme \(A \oplus B\).

A

B

A XOR B

0

0

0

0

1

1

1

0

1

1

1

0

Ces opérations peuvent être combinées entre elles. Pour des raisons technologiques, les circuits logiques implémentent plutôt les opérations NAND (qui équivaut à AND suivi de NOT) ou NOR (qui équivaut à OR suivi de NOT). Il est également important de mentionner les lois formulées par De Morgan qui peuvent se résumer par les équations suivantes :

\(\neg{(A \wedge B)}=\neg{A} \vee \neg{B}\)

\(\neg{(A \vee B)}=\neg{A} \wedge \neg{B}\)

Ces opérations binaires peuvent s'étendre à des séquences de bits. Voici quelques exemples qui permettent d'illustrer ces opérations sur des octets.

~ 00000000 = 11111111
11111010 & 01011111 = 01011010
11111010 | 01011111 = 11111111
11111010 ^ 01011111 = 10100101

En C, ces expressions logiques s'utilisent comme dans le fragment de code suivant. En général, elles s'utilisent sur des représentations non signées, souvent des unsigned char ou des unsigned int.

r = ~a;    // négation bit à bit
r = a & b; // conjonction bit à bit
r = a | b; // disjonction bit à bit
r = a ^ b; // xor bit à bit

En pratique, les opérations logiques sont utiles pour effectuer des manipulations au niveau des bits de données stockées en mémoire. Une utilisation fréquente dans certaines applications réseaux ou systèmes est de forcer certains bits à prendre la valeur 0 ou 1. La conjonction logique permet de forcer facilement un bit à zéro tandis que la disjonction logique permet de forcer facilement un bit à un. L'exemple ci-dessous montre comment forcer les valeurs de certains bits dans un unsigned char. Il peut évidemment se généraliser à des séquences de bits plus longues.

r = c & 0x7E; // 0b01111110 force les bits de poids faible et fort à 0
r = d | 0x18; // 0b00011000 force les bits 4 et 3 à 1

L'opération XOR joue un rôle important dans certaines applications. La plupart des méthodes de chiffrement et de déchiffrement utilisent de façon extensive cette opération. Une des propriétés intéressantes de l'opération XOR est que \((A \oplus B) \oplus B=A\). Cette propriété est largement utilisée par les méthodes de chiffrement. La méthode développée par Vernam au début du vingtième siècle s'appuie sur l'opération XOR. Pour transmettre un message M de façon sûre, elle applique l'opération XOR bit à bit entre tous les bits du message M et une clé K doit avoir au moins le même nombre de bits que M. Si cette clé K est totalement aléatoire et n'est utilisée qu'une seule fois, alors on parle de one-time-pad. On peut montrer que dans ce cas, la méthode de chiffrement est totalement sûre. En pratique, il est malheureusement difficile d'avoir une clé totalement aléatoire qui soit aussi longue que le message à transmettre. Le programme ci-dessous implémente cette méthode de façon triviale. La fonction memfrob(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/memfrob.3.html] de la librairie GNU utilise également un chiffrement via un XOR.

int main(int argc, char* argv[])
{
  if (argc != 2)
    usage("ce programme prend une clé comme argument");

  char *key = argv[1];
  char c;
  int i = 0;
  while (((c = getchar()) != EOF) && (i < strlen(key))) {
    putchar(c ^ *(key + i));
    i++;
  }
  return EXIT_SUCCESS;
}

Note

Ne pas confondre expressions logiques et opérateurs binaires.

En C, les symboles utilisés pour les expressions logiques (|| et &&) sont très proches de ceux utilisés pour représenter les opérateurs binaires (| et &). Il arrive parfois qu'un développeur confonde & avec &&. Malheureusement, le compilateur ne peut pas détecter une telle erreur car dans les deux cas le résultat attendu est généralement du même type.

0b0100 & 0b0101 = 0b0100
0b0100 && 0b0101 = 0b0001
0b0100 | 0b0101 = 0b0101
0b0100 || 0b0101 = 0b0001

Un autre point important à mentionner concernant les expressions logiques est qu'en C celles-ci sont évaluées de gauche à droite. Cela implique que dans l'expression ( expr1 && expr2 ), le compilateur C va d'abord évaluer l'expression expr1. Si celle-ci est évaluée à la valeur 0, la seconde expression ne sera pas évaluée. Cela peut être très utile lorsque l'on doit exécuter du code si un pointeur est non-NULL et qu'il pointe vers une valeur donnée. Dans ce cas, la condition sera du type ((ptr != NULL) && (ptr->den > 0)).

Pour terminer, le langage C supporte des expressions permettant le décalage à gauche ou à droite à l'intérieur d'une suite de bits non signée.

a = n >> B décale les bits représentants n de B bits vers la droite et place le résultat dans la variable a.

a = n << B décale les bits représentants n de B bits vers la gauche et place le résultat dans la variable a.

Ces opérations de décalage permettent différentes manipulations de bits. À titre d'exemple, la fonction int2bin utilise à la fois des décalages et des masques pour calculer la représentation binaire d'un entier non signé et la placer dans une chaîne de caractères.

#define BITS_INT 32
// str[BITS_INT]
void int2bin(unsigned int num, char *str)
{
  int i;
  str[BITS_INT] = '\0';
  for (i = BITS_INT - 1; i >= 0; i--) {
    if ((num & 1) == 1)
      str[i] = '1';
    else
      str[i] = '0';
    num = num >> 1;
  }
}

Footnotes

1

En pratique, le format binaire contient \(127+exp\) en simple précision et non l'exposant exp. Ce choix facilite certaines comparaisons entre nombres représentés en virgule flottante. Une discussion détaillée de la représentation binaire des nombres en virgule flottante sort du cadre de ce cours dédié aux systèmes informatiques. Une bonne référence à ce sujet est [Goldberg1991].

2

Source : https://en.wikipedia.org/wiki/Single-precision_floating-point_format

3

En pratique, l'espace mémoire accessible à un programme Java est limité par différents facteurs. Voir notamment le paramètre -Xm de la machine virtuelle Java https://docs.oracle.com/javase/6/docs/technotes/tools/solaris/java.html

4

Une description plus détaillée des différentes possibilités de mesure du temps via les fonctions de la librairie standard est disponible dans le chapitre 21 [https://www.gnu.org/software/libc/manual/html_node/Date-and-Time.html] du manuel [https://www.gnu.org/software/libc/manual/html_node/Date-and-Time.html] de la libc.

5

Voir notamment fs(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/fs.5.html] pour des exemples relatifs aux systèmes de fichiers. Une analyse détaillée des systèmes de fichiers sort du cadre de ce cours.

6

Parmi les exemples simples, on peut citer la structure struct ipv6hdr qui correspond à l'entête du protocole IP version 6 et est définie dans linux/ipv6.h [https://sites.uclouvain.be/SystInfo/usr/include/linux/ipv6.h.html].

7

Cette définition de l'égalité entre fractions suppose que les fractions à comparer sont sous forme irréductible. Le lecteur est invité à écrire la fonction générale permettant de tester l'égalité entre fractions réductibles.

Le langage C

Différents langages permettent au programmeur de construire des programmes qui seront exécutés par le processeur. En réalité, le processeur ne comprend qu'un langage : le langage machine. Ce langage est un langage binaire dans lequel toutes les commandes et toutes les données sont représentés sous la forme de séquences de bits.

Le langage machine est peu adapté aux humains et il est extrêmement rare qu'un informaticien doive manipuler des programmes directement en langage machine. Par contre, pour certaines tâches bien spécifiques, comme par exemple le développement de routines spéciales qui doivent être les plus rapides possibles ou qui doivent interagir directement avec le matériel, il est important de pouvoir efficacement générer du langage machine. Cela peut se faire en utilisant un langage d'assemblage. Chaque famille de processeurs a un langage d'assemblage qui lui est propre. Le langage d'assemblage permet d'exprimer de façon symbolique les différentes instructions qu'un processeur doit exécuter. Nous aurons l'occasion de traiter à plusieurs reprises des exemples en langage d'assemblage dans le cadre de ce cours. Cela nous permettra de mieux comprendre la façon dont le processeur fonctionne et exécute les programmes. Le langage d'assemblage est converti en langage machine grâce à un assembleur.

Le langage d'assemblage est le plus proche du processeur. Il permet d'écrire des programmes compacts et efficaces. C'est aussi souvent la seule façon d'utiliser des instructions spéciales du processeur qui permettent d'interagir directement avec le matériel pour par exemple commander les dispositifs d'entrée/sortie. C'est essentiellement dans les systèmes embarqués qui disposent de peu de mémoire et pour quelques fonctions spécifiques des systèmes d'exploitation que le langage d'assemblage est utilisé de nos jours. La plupart des programmes applicatifs et la grande majorité des systèmes d'exploitation sont écrits dans des langages de plus haut niveau.

Le langage C [KernighanRitchie1998], développé dans les années 70 pour écrire les premières versions du système d'exploitation Unix, est aujourd'hui l'un des langages de programmation les plus utilisés pour développer des programmes qui doivent être rapides ou doivent interagir avec le matériel. La plupart des systèmes d'exploitation sont écrits en langage C.

Le langage C a été conçu à l'origine comme un langage proche du processeur qui peut être facilement compilé, c'est-à-dire traduit en langage machine, tout en conservant de bonnes performances.

La plupart des livres qui abordent la programmation en langage C commencent par présenter un programme très simple qui affiche à l'écran le message Hello, world!.

/**************************************
 * Hello.c
 *
 * Programme affichant sur la sortie
 * standard le message "Hello, world!"
 *
 **************************************/

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
   // affiche sur la sortie standard
   printf("Hello, world!\n");

   return EXIT_SUCCESS;
}

Pour être exécuté, ce programme doit être compilé. Il existe de nombreux compilateurs permettant de transformer le langage C en langage machine. Dans le cadre de ce cours, nous utiliserons gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html]. Dans certains cas, nous pourrons être amenés à utiliser d'autres compilateurs comme llvm.

La compilation du programme src/hello.c peut s'effectuer comme suit sur une machine de type Unix :

$ gcc -Wall -o hello hello.c
$ ls -l
total 80
-rwxr-xr-x  1 obo  obo  8704 15 jan 22:32 hello
-rw-r--r--  1 obo  obo   288 15 jan 22:32 hello.c

gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] supporte de très nombreuses options et nous aurons l'occasion de discuter de plusieurs d'entre elles dans le cadre de ce cours. Pour cette première utilisation, nous avons choisi l'option -Wall qui force gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] à afficher tous les messages de type warning (dans cet exemple il n'y en a pas) et l'option -o suivie du nom de fichier hello qui indique le nom du fichier dans lequel le programme exécutable doit être sauvegardé par le compilateur 1.

Lorsqu'il est exécuté, le programme hello affiche simplement le message suivant sur la sortie standard :

$ ./hello
Hello, world!
$

Même si ce programme est très simple, il illustre quelques concepts de base en langage C. Tout d'abord comme en Java, les compilateurs récents supportent deux façons d'indiquer des commentaires en C :

un commentaire sur une ligne est précédé des caractères //

un commentaire qui comprend plusieurs lignes débute par /* et se termine par */

Ensuite, un programme écrit en langage C comprend principalement des expressions en langage C mais également des expressions qui doivent être traduites par le préprocesseur. Lors de la compilation d'un fichier en langage C, le compilateur commence toujours par exécuter le préprocesseur. Celui-ci implémente différentes formes de macros qui permettent notamment d'inclure des fichiers (directives #include), de compiler de façon conditionnelle certaines lignes ou de définir des constantes. Nous verrons différentes utilisations du préprocesseur C dans le cadre de ce cours. À ce stade, les trois principales fonctions du préprocesseur sont :

définir des substitutions via la macro #define. Cette macro est très fréquemment utilisée pour définir des constantes ou des substitutions qui sont valables dans l'ensemble du programme.

#define ZERO  0
#define STRING "LEPL1503"

importer (directive #include) un fichier. Ce fichier contient généralement des prototypes de fonctions et des constantes. En langage C, ces fichiers qui sont inclus dans un programme sont appelés des header files et ont par convention un nom se terminant par .h. Le programme src/hello.c ci-dessus importe deux fichiers headers standards :

<stdio.h> : contient la définition des principales fonctions de la librairie standard permettant l'interaction avec l'entrée et la sortie standard, et notamment printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html]

<stdlib.h>: contient la définition de différentes fonctions et constantes de la librairie standard et notamment EXIT_SUCCESS et EXIT_FAILURE. Ces constantes sont définies en utilisant la macro #define du préprocesseur

#define EXIT_FAILURE    1
#define EXIT_SUCCESS    0

inclure du code sur base de la valeur d'une constante définie par un #define. Ce contrôle de l'inclusion de code sur base de la valeur de constantes est fréquemment utilisé pour ajouter des lignes qui ne doivent être exécutées que lorsque l'on veut déboguer un programme. C'est aussi souvent utilisé pour faciliter la portabilité d'un programme entre différentes variantes de Unix, mais cette utilisation sort du cadre de ce cours.

#define DEBUG
/* ... */
#ifdef DEBUG
printf("debug : ...");
#endif /* DEBUG */

Il est également possible de définir des macros qui prennent un ou plusieurs paramètres [CPP].

Les headers standards sont placés dans des répertoires bien connus du système. Sur la plupart des variantes de Unix ils se trouvent dans le répertoire /usr/include/. Nous aurons l'occasion d'utiliser régulièrement ces fichiers standards dans le cadre du cours.

Le langage Java a été largement inspiré du langage C et de nombreuses constructions syntaxiques sont similaires en Java et en C. Un grand nombre de mots clés en C ont le même rôle qu'en Java. Les principaux types de données primitifs supportés par le C sont :

int et long : utilisés lors de la déclaration d'une variable de type entier

char : utilisé lors de la déclaration d'une variable permettant de stocker un caractère

double et float : utilisés lors de la déclaration d'une variable permettant de stocker un nombre représenté en virgule flottante.

Notez que dans les premières versions du langage C, contrairement à Java, il n'y avait pas de type spécifique permettant de représenter un booléen. Dans de nombreux programmes écrits en C, les booléens sont représentés par des entiers et les valeurs booléennes sont définies 2 comme suit.

#define false   0
#define true    1

Les compilateurs récents qui supportent le type booléen permettent de déclarer des variables de type bool et contiennent les définitions suivantes 2 dans le header standard stdbool.h [https://sites.uclouvain.be/SystInfo/usr/include/stdbool.h.html] de [C99].

#define false   (bool)0
#define true    (bool)1

Au-delà des types de données primitifs, Java et C diffèrent et nous aurons l'occasion d'y revenir dans un prochain chapitre. Le langage C n'est pas un langage orienté objet et il n'est donc pas possible de définir d'objet avec des méthodes spécifiques en C. C permet la définition de structures, d'unions et d'énumérations sur lesquelles nous reviendrons.

En Java, les chaînes de caractères sont représentées grâce à l'objet String. En C, une chaîne de caractères est représentée sous la forme d'un tableau de caractères dont le dernier élément contient la valeur \0. Alors que Java stocke les chaînes de caractères dans un objet avec une indication de leur longueur, en C il n'y a pas de longueur explicite pour les chaînes de caractères mais le caractère \0 sert de marqueur de fin de chaîne de caractères. Lorsque le langage C a été développé, ce choix semblait pertinent, notamment pour des raisons de performance. Avec le recul, ce choix pose question [Kamp2011] et nous y reviendrons lorsque nous aborderons certains problèmes de sécurité.

  char string[10];
  string[0] = 'j';
  string[1] = 'a';
  string[2] = 'v';
  string[3] = 'a';
  string[4] = '\0';
  printf("String : %s\n", string);

L'exemple ci-dessus illustre l'utilisation d'un tableau de caractères pour stocker une chaîne de caractères. Lors de son exécution, ce fragment de code affiche String : java sur la sortie standard. Le caractère spécial \n indique un passage à la ligne. printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html] supporte d'autres caractères spéciaux qui sont décrits dans sa page de manuel.

Au niveau des constructions syntaxiques, on retrouve les mêmes boucles et tests en C et en Java :

test if (condition)  { ... } else { ... }

boucle while (condition) { ... }

boucle do { ... } while (condition);

boucle for (init; condition; incr) { ... }

En Java, les conditions sont des expressions qui doivent retourner un résultat de type boolean. Le langage C est beaucoup plus permissif puisqu'une condition est une expression qui retourne un nombre entier.

La plupart des expressions et conditions en C s'écrivent de la même façon qu'en Java.

Après ce rapide survol du langage C, revenons à notre programme src/hello.c. Tout programme C doit contenir une fonction nommée main dont la signature 3 est :

int main(int argc, char *argv[])

Lorsque le système d'exploitation exécute un programme C compilé, il démarre son exécution par la fonction main et passe à cette fonction les arguments fournis en ligne de commande 4. Comme l'utilisateur peut passer un nombre quelconque d'arguments, il faut que le programme puisse déterminer combien d'arguments sont utilisés. En Java, la méthode main a comme signature public static void main(String args[]) et l'attribut args.length permet de connaître le nombres de paramètres passés en arguments d'un programme. En C, le nombre de paramètres est passé dans la variable entière argc et le tableau de chaînes de caractères char *argv[] contient tous les arguments. Le programme src/cmdline.c illustre comment un programme peut accéder à ses arguments.

/**************************************
 * cmdline.c
 *
 * Programme affichant ses arguments
 * sur la sortie standard
 *
 **************************************/

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
  int i;
  printf("Ce programme a %d argument(s)\n", argc);
  for (i = 0; i < argc; i++)
    printf("argument[%d] : %s\n", i, argv[i]);
  return EXIT_SUCCESS;
}

Par convention, en C le premier argument (se trouvant à l'indice 0 du tableau argv) est le nom du programme qui a été exécuté par l'utilisateur. Une exécution de ce programme est illustrée ci-dessous.

Ce programme a 5 argument(s)
argument[0] : ./cmdline
argument[1] : 1
argument[2] : -list
argument[3] : abcdef
argument[4] : lepl1503

Outre le traitement des arguments, une autre différence importante entre Java et C est la valeur de retour de la fonction main. En C, la fonction main retourne un entier. Cette valeur de retour est passée par le système d'exploitation au programme (typiquement un shell ou interpréteur de commandes) qui a demandé l'exécution du programme. Grâce à cette valeur de retour il est possible à un programme d'indiquer s'il s'est exécuté correctement ou non. Par convention, un programme qui s'exécute sous Unix doit retourner EXIT_SUCCESS lorsqu'il se termine correctement et EXIT_FAILURE en cas d'échec. La plupart des programmes fournis avec un Unix standard respectent cette convention. Dans certains cas, d'autres valeurs de retour non nulles sont utilisées pour fournir plus d'informations sur la raison de l'échec. En pratique, l'échec d'un programme peut être dû aux arguments incorrects fournis par l'utilisateur ou à des fichiers qui sont inaccessibles.

À titre d'illustration, le programme src/failure.c est le programme le plus simple qui échoue lors de son exécution.

/**************************************
 * failure.c
 *
 * Programme minimal qui échoue toujours
 *
 **************************************/

#include <stdlib.h>

int main(int argc, char *argv[])
{
  return EXIT_FAILURE;
}

Enfin, le dernier point à mentionner concernant notre programme src/hello.c est la fonction printf. Cette fonction de la librairie standard se retrouve dans la plupart des programmes écrits en C. Elle permet l'affichage de différentes formes de textes sur la sortie standard. Comme toutes les fonctions de la librairie standard, elle est documentée dans sa page de manuel printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html]. printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html] prend un nombre variable d'arguments. Le premier argument est une chaîne de caractères qui spécifie le format de la chaîne de caractères à afficher. Une présentation détaillée de printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html] prendrait de nombreuses pages. À titre d'exemple, voici un petit programme utilisant printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html]

  char weekday[] = "Monday";
  char month[] = "April";
  int day = 1;
  int hour = 12;
  int min = 42;
  char str[] = "SINF1252";
  int i;

  // affichage de la date et l'heure
  printf("%s, %s %d, %d:%d\n", weekday, month, day, hour, min);

  // affichage de la valeur de PI
  printf("PI = %f\n", 4 * atan(1.0));

  // affichage d'un caractère par ligne
  for(i = 0; str[i] != '\0'; i++)
    printf("%c\n", str[i]);

Lors de son exécution, ce programme affiche :

Monday, April 1, 12:42
PI = 3.141593
L
E
P
L
1
5
0
3

Le langage C permet bien entendu la définition de fonctions. Outre la fonction main qui doit être présente dans tout programme, le langage C permet la définition de fonctions qui retournent ou non une valeur. En C, comme en Java, une fonction de type void ne retourne aucun résultat tandis qu'une fonction de type int retournera un entier. Le programme ci-dessous présente deux fonctions simples. La première, usage ne retourne aucun résultat. Elle affiche un message d'erreur sur la sortie d'erreur standard et termine le programme via exit(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/exit.2.html] avec un code de retour indiquant un échec. La seconde, digit prend comme argument un caractère et retourne 1 si c'est un chiffre et 0 sinon. Le code de cette fonction peut paraître bizarre à un programmeur habitué à Java. En C, les char sont représentés par l'entier qui correspond au caractère dans la table des caractères utilisées (une table ASCII simple est disponible à cette adresse [https://upload.wikimedia.org/wikipedia/commons/thumb/1/1b/ASCII-Table-wide.svg/2560px-ASCII-Table-wide.svg.png]). Toutes les tables de caractères placent les chiffres 0 à 9 à des positions consécutives. En outre, en C une expression a priori booléenne comme a < b est définie comme ayant la valeur 1 si elle est vraie et 0 sinon. Il en va de même pour les expressions qui sont combinées en utilisant && ou ||. Enfin, les fonctions getchar(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getchar.3.html] et putchar(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/putchar.3.html] sont des fonctions de la librairie standard qui permettent respectivement de lire (écrire) un caractère sur l'entrée (la sortie) standard.

/**************************************
 * filterdigit.c
 *
 * Programme qui extrait de l'entrée
 * standard les caractères représentant
 * des chiffres
 **************************************/

#include <stdio.h>
#include <stdlib.h>

// retourne vrai si c est un chiffre, faux sinon
// exemple simplifié, voir isdigit dans la librarire standard
// pour une solution complète
int digit(char c)
{
  return ((c >= '0') && (c <= '9'));
}

// affiche un message d'erreur
void usage()
{
  fprintf(stderr, "Ce programme ne prend pas d'argument\n");
  exit(EXIT_FAILURE);
}

int main(int argc, char *argv[])
{
  char c;

  if (argc > 1)
    usage();

  while ((c = getchar()) != EOF) {
    if (digit(c))
	    putchar(c);
  }

  return EXIT_SUCCESS;
}

Pages de manuel

Les systèmes d'exploitation de la famille Unix contiennent un grand nombre de librairies, d'appels systèmes et d'utilitaires. Toutes ces fonctions et tous ces programmes sont documentés dans des pages de manuel qui sont accessibles via la commande man. Les pages de manuel sont organisées en 8 sections.

Section 1: Utilitaires disponibles pour tous les utilisateurs

Section 2: Appels systèmes en C

Section 3: Fonctions de la librairie

Section 4: Fichiers spéciaux

Section 5: Formats de fichiers et conventions pour certains types de fichiers

Section 6: Jeux

Section 7: Utilitaires de manipulation de fichiers textes

Section 8: Commandes et procédure de gestion du système

Dans le cadre de ce cours, nous aborderons principalement les fonctionnalités décrites dans les trois premières sections des pages de manuel. L'accès à une page de manuel se fait via la commande man avec comme argument le nom de la commande concernée. Vous pouvez par exemple obtenir la page de manuel de gcc en tapant man gcc. man supporte plusieurs paramètres qui sont décrits dans sa page de manuel accessible via man man. Dans certains cas, il est nécessaire de spécifier la section du manuel demandée. C'est le cas par exemple pour printf qui existe comme utilitaire (section 1) et comme fonction de la librairie (section 3 - accessible via man 3 printf).

Outre ces pages de manuel locales, il existe également de nombreux sites web où l'on peut accéder aux pages de manuels de différentes versions de Unix dont notamment :

les pages de manuel de Debian GNU/Linux [http://manpages.debian.net/]

les pages de manuel de FreeBSD [https://www.freebsd.org/cgi/man.cgi]

Dans la version en-ligne de ces notes, toutes les références vers un programme Unix, un appel système ou une fonction de la librairie pointent vers la page de manuel Linux correspondante.

Il existe de nombreux livres consacrés au langage C. La référence la plus classique est [KernighanRitchie1998], mais certains éléments commencent à dater. Un tutoriel intéressant a été publié par Brian Kernighan [Kernighan]. [King2008] propose une présentation plus moderne du langage C.

Footnotes

1

Si cette option n'était pas spécifiée, le compilateur aurait placé le programme compilé dans le fichier baptisé a.out.

2(1,2)

Formellement, le standard [C99] ne définit pas de type bool mais un type _Bool qui est en pratique renommé en type bool dans la plupart des compilateurs. La définition précise et complète se trouve dans stdbool.h [https://sites.uclouvain.be/SystInfo/usr/include/stdbool.h.html]

3

Il est également possible d'utiliser dans un programme C une fonction main qui ne prend pas d'argument. Sa signature sera alors int main (void).

4

En pratique, le système d'exploitation passe également les variables d'environnement à la fonction main. Nous verrons plus tard comment ces variables d'environnement sont passées du système au programme et comment celui-ci peut y accéder. Sachez cependant que sous certaines variantes de Unix, et notamment Darwin/MacOS ainsi que sous certaines versions de Windows, le prototype de la fonction main inclut explicitement ces variables d'environnement (int main(int argc, char *argv[], char *envp[]))

Compléments de C

Dans les sections précédentes, nous n'avons pas pu couvrir l'ensemble des concepts avancés qui sont relatifs à une bonne utilisation du langage C. Cette section contient quelques notions plus avancées qui sont importantes en pratique.

Pointeurs

Les pointeurs sont très largement utilisés dans les programmes écrits en langage C. Nous avons utilisé des pointeurs vers des types de données primitifs tel que les int, char ou float et des pointeurs vers des structures. En pratique, il est possible en C de définir des pointeurs vers n'importe quel type d'information qui est manipulée par un programme C.

Un premier exemple sont les pointeurs vers des fonctions. Comme nous l'avons vu dans le chapitre précédent, une fonction est une séquence d'instructions assembleur qui sont stockées à un endroit bien précis de la mémoire. Cette localisation précise des instructions qui implémentent la fonction permet d'appeler une fonction avec l'instruction calll. En C, il est parfois aussi souhaitable de pouvoir appeler une fonction via un pointeur vers cette fonction plutôt qu'en nommant la fonction directement. Cela peut rendre le code plus flexible et plus facile à adapter. Nous avons déjà utilisé des pointeurs vers des fonctions sans le savoir lorsque nous avons utilisé printf("fct : %p\n",f) où f est un nom de fonction. L'exemple ci-dessous montre une autre utilisation intéressante des pointeurs vers des fonctions. Lorsque l'on écrit du code C, il est parfois utile d'ajouter des commandes qui permettent d'afficher à l'écran des informations de débogage. L'exemple ci-dessous est une application qui supporte trois niveaux de débogage. Rien n'est affiché au niveau 0, une ligne s'affiche au niveau 1 et des informations plus détaillées sont affichées au niveau 2. Lors de son exécution , l'application affiche la sortie suivante.

$./fctptr 0
fct debug_print : 0x100000d28
$ ./fctptr 1
fct debug_print : 0x100000d32
debug: Hello
$ ./fctptr 2
fct debug_print : 0x100000d5f
debug: Hello
g=1

Cette application qui supporte plusieurs niveaux de débogage utilise pourtant toujours le même appel pour afficher l'information de débogage : (debug_print[debug_level])(...);. Cet appel profite des pointeurs vers les fonctions. Le tableau debug_print est un tableau de pointeurs vers des fonctions qui chacune prend comme argument un char *. La variable globale debug_level est initialisée sur base de l'argument passé au programme.

int g=1;
int debug_level;

void empty (char *str) {
  return;
}

void oneline(char *str) {
  fprintf(stderr,"debug: %s\n",str);
}

void detailed(char *str) {
  fprintf(stderr, "debug: %s\n",str);
  fprintf(stderr,"g=%d\n",g);
}

void (* debug_print[])(char *) = { empty,
				   oneline,
				   detailed };

int main(int argc, char *argv[]) {

  if(argc!=2)
    return(EXIT_FAILURE);

  debug_level=atoi(argv[1]);
  if((debug_level<0) || (debug_level>2) )
    return(EXIT_FAILURE);
  printf("fct debug_print : %p\n",debug_print[debug_level]);
  (debug_print[debug_level])("Hello");

   return(EXIT_SUCCESS);
}

Ce n'est pas la seule utilisation des pointeurs vers des fonctions. Il y a notamment la fonction de la librairie qsort(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/qsort.3.html] qui permet de trier un tableau contenant n'importe quel type d'information. Cette fonction prend plusieurs arguments :

void qsort(void *base, size_t nel, size_t width,
           int (*compar)(const void *, const void *));

Le premier est un pointeur vers le début de la zone mémoire à trier. Le second est le nombre d'éléments à trier. Le troisième contient la taille des éléments stockés dans le tableau. Le quatrième argument est un pointeur vers la fonction qui permet de comparer deux éléments du tableau. Cette fonction retourne un entier négatif si son premier argument est inférieur au second et positif ou nul sinon. Un exemple de fonction de comparaison est la fonction strcmp(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strcmp.3.html] de la librairie standard. Un autre exemple est repris ci-dessous avec une fonction de comparaison simple qui permet d'utiliser qsort(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/qsort.3.html] pour trier un tableau de double.

#define SIZE 5
double array[SIZE]= { 1.0, 7.32, -3.43, 8.7, 9.99 };

void print_array() {
  for(int i=0;i<SIZE;i++)
    printf("array[i]:%f\n",array[i]);
}

int cmp(const void *ptr1, const void *ptr2) {
  const double *a=ptr1;
  const double *b=ptr2;
  if(*a==*b)
    return 0;
  else
    if(*a<*b)
      return -1;
    else
      return +1;
}

int main(int argc, char *argv[]) {

  printf("Avant qsort\n\n");
  print_array();
  qsort(array,SIZE,sizeof(double),cmp);
  printf("Après qsort\n\n");
  print_array();

  return(EXIT_SUCCESS);
}

Il est utile d'analyser en détails les arguments de la fonction de comparaison utilisée par qsort(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/qsort.3.html]. Celle-ci prend deux arguments de type const void *. L'utilisation de pointeurs void * est nécessaire car la fonction doit être générique et pouvoir traiter n'importe quel type de pointeurs. void * est un pointeur vers une zone quelconque de mémoire qui peut être casté vers n'importe quel type de pointeur par la fonction de comparaison. const indique que la fonction n'a pas le droit de modifier la donnée référencée par ce pointeur, même si elle reçoit un pointeur vers cette donnée. On retrouvera régulièrement cette utilisation de const dans les signatures des fonctions de la librairie pour spécifier des contraintes sur les arguments passés à une fonction 1.

Le second type de pointeurs que nous n'avons pas encore abordé en détails sont les pointeurs vers des pointeurs. En fait, nous les avons utilisés sans vraiment le savoir dans la fonction main. En effet, le second argument de cette fonction est un tableau de pointeurs qui pointent chacun vers des chaînes de caractères différentes. La notation char *argv[] est équivalente à la notation char **argv. **argv est donc un pointeur vers une zone qui contient des pointeurs vers des chaînes de caractères. Ce pointeur vers un pointeur doit être utilisé avec précaution. argv[0] est un pointeur vers une chaîne de caractères. La construction &(argv[0]) permet donc d'obtenir un pointeur vers un pointeur vers une chaîne de caractères, ce qui correspond bien à la déclaration char **. Ensuite, l'utilisation de *p pourrait surprendre. *p est un pointeur vers une chaîne de caractères. Il peut donc être comparé à NULL qui est aussi un pointeur, incrémenté et la chaîne de caractères qu'il référence peut être affichée par printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html].

int main(int argc, char **argv) {

  char **p;
  p=argv;
  printf("Arguments :");
  while(*p!=NULL) {
    printf(" %s",*p);
    p++;
  }
  printf("\n");
  return(EXIT_SUCCESS);
}

En pratique, ces pointeurs vers des pointeurs se retrouveront lorsque l'on doit manipuler des structures multidimensionnelles, mais aussi lorsqu'il faut qu'une fonction puisse modifier une adresse qu'elle a reçue en argument.

Un autre exemple d'utilisation de pointeurs vers des pointeurs est la fonction strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] de la librairie standard. Cette fonction est une généralisation des fonctions comme atoi(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/atoi.3.html]. Elle permet de convertir une chaîne de caractères en un nombre. La fonction strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] prend trois arguments et retourne un long. Le premier argument est un pointeur vers la chaîne de caractères à convertir. Le troisième argument est la base utilisée pour cette conversion.

#include <stdlib.h>
long
strtol(const char *restrict str, char **restrict endptr, int base);

L'utilisation principale de strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] est de convertir une chaîne de caractères en un nombre. La fonction atoi(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/atoi.3.html] fait de même et l'expression atoi("1252") retourne l'entier 1252. Malheureusement, la fonction atoi(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/atoi.3.html] ne traite pas correctement les chaînes de caractères qui ne contiennent pas un nombre. Elle ne retourne pas de code d'erreur et ne permet pas de savoir quelle partie de la chaîne de caractères passée en argument était en erreur.

strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] est un exemple de fonction qui doit retourner deux types d'informations. Tout d'abord, strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] retourne un résultat (dans ce cas un nombre). Si la chaîne de caractères à convertir est erronée, strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] convertit le début de la chaîne et retourne un pointeur indiquant le premier caractère en erreur. Pour bien comprendre le fonctionnement de strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html], considérons l'exemple ci-dessous.

#include <stdlib.h>
#include <stdio.h>

int main(int argc, char *argv[]) {

  char *p, *s;
  long li;
  s = "1252";
  li = strtol(s,&p,10);
  if(*p != '\0') {
    printf("Caractère erronné : %c\n",*p);
      // p pointe vers le caractère en erreur
  }
  printf("Valeur convertie : %s -> %ld\n",s,li);

  s = "12m52";
  li = strtol(s,&p,10);
  if(*p != '\0') {
    printf("Caractère erronné : %c\n",*p);
  }
  printf("Valeur convertie : %s -> %ld\n",s,li);

  return(EXIT_SUCCESS);
}

Lors de son exécution, ce programme affiche la sortie suivante.

Valeur convertie : 1252 -> 1252
Caractère erronné : m
Valeur convertie : 12m52 -> 12

L'appel à strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] prend trois arguments. Tout d'abord un pointeur vers la chaîne de caractères à convertir. Ensuite l'adresse d'un pointeur vers une chaîne de caractères. Enfin la base de conversion. La première chaîne de caractères est correcte. Elle est convertie directement. La seconde par contre contient un caractère erroné. Lors de son exécution, strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] va détecter la présence du caractère m et placera un pointeur vers ce caractère dans *p. Pour que la fonction strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] puisse retourner un pointeur de cette façon, il est nécessaire que son second argument soit de type char **. Si le second argument était de type char *, la fonction strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] recevrait l'adresse d'une zone mémoire contenant un caractère. Comme le langage C utilise le passage par valeur, strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] pourrait modifier la caractère pointé par ce pointeur mais pas son adresse. En utilisant un second argument de type char **, strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] a la possibilité de modifier la valeur pointée par ce pointeur.

Une implémentation partielle de strtol(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtol.3.html] pourrait être la suivante.

#include <stdlib.h>
#include <stdio.h>
#include <ctype.h>
#include <stdbool.h>

int mystrtol(const char *restrict str,
	     char **restrict endptr,
	     int base) {

  int val;
  int i=0;
  int err=false;
  while(!err && *(str+i)!='\0')
    {
      if(!isdigit(*(str+i))) {
	err=true;
	*endptr=(char *)(str+i);
      }
      i++;
    }
  // ...
  return val;
}

Cette partie de code utilise la fonction isdigit(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/isdigit.3.html] pour vérifier si les caractères présents dans la chaîne de caractères sont des chiffres. Sinon, elle fixe via son second argument la valeur du pointeur vers le caractère en erreur. Cela est réalisé par l'expression *endptr=(char *)(str+i);. Il faut noter que *endptr est bien une zone mémoire pointée par le pointeur endptr reçu comme second argument. Cette valeur peut donc être modifiée.

Il existe d'autres fonctions de la librairie standard qui utilisent des pointeurs vers des pointeurs comme arguments dont notamment strsep(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strsep.3.html] et strtok_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strtok_r.3.html].

De grands programmes en C

Dès que le programme que l'on développe en C est un programme non trivial d'une certaine taille, il est préférable de découper le programme en modules. Chaque module contient des fonctions qui traitent d'un même type de problème et sont fortement couplées. A titre d'exemple, un module stack pourrait regrouper différentes fonctions de manipulation d'une pile. Un autre module pourrait regrouper les fonctions relatives au dialogue avec l'utilisateur, un autre les fonctions de gestion des fichiers, etc. Ces modules peuvent en quelque sorte être comparés aux classes d'un programme Java.

Pour comprendre l'utilisation de ces modules, considérons d'abord un programme trivial composé de deux modules. Le premier module est celui qui contient la fonction main. Tout programme C doit contenir une fonction main pour pouvoir être exécuté. C'est en général l'interface avec l'utilisateur. Le second module contient une fonction générique qui est utilisée par le module principal.

/**************************************
 * main.c
 *
 * Programme d'exemple pour le linker
 *
 **************************************/

#include "min.h"
#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[]) {
  float f1=3.45;
  float f2=-4.12;
  printf("Minimum(%f,%f)=%f\n",f1,f2,min(f1,f2));
  return(EXIT_SUCCESS);
}

Un module d'un programme C est en général décomposé en deux parties. Tout d'abord, le fichier fichier header contient les définitions de certaines constantes et les signatures des fonctions exportées par ce module. Ce fichier est en quelque sorte un résumé du module, ou plus précisément de son interface externe. Il doit être inclus dans tout fichier qui utilise les fonctions du module correspondant. Dans un tel fichier fichier header, on retrouve généralement trois types d'informations:

Les signatures des fonctions qui sont définies dans le module. En général, seules les fonctions qui sont destinées à être utilisées par des modules extérieurs sont reprises dans le fichier header, c'est-à-dire que les fonctions utilisées uniquement pour des opérations internes ne sont pas présentes.

Les constantes qui sont utilisées à l'intérieur du module et doivent être visibles en dehors de celui-ci, notamment par les modules qui utilisent les fonctions du module. Ces constantes peuvent être définies en utilisant des directives #define du préprocesseur

Les variables globales qui sont utilisées par les fonctions du module et doivent être accessibles en dehors de celui-ci

/**************************************
 * min.h
 *
 **************************************/

#ifndef _MIN_H_
#define _MIN_H_

float min(float, float);

#endif /* _MIN_H */

Note

Un fichier header ne doit être inclus qu'une seule fois

L'exemple de fichier header ci-dessus illustre une convention courante dans l'écriture de ces fichiers. Parfois, il est nécessaire d'inclure un fichier header dans un autre fichier header. Suite à cela, il est possible que les mêmes définitions d'un fichier header soient incluses deux fois ou plus dans le même module. Cela peut causer des erreurs de compilation qui risquent de perturber certains programmeurs. Une règle de bonne pratique pour éviter ce problème est d'inclure le contenu du fichier header de façon conditionnelle comme présenté ci-dessus. Une constante, dans ce cas _MIN_H_, est définie pour le fichier header concerné. Cette constante est définie dans la première ligne effective du fichier header. Celui-ci n'est inclus dans un module que si cette constante n'a pas été préalablement définie. Si cette constante est connue par le préprocesseur, cela indique qu'un autre fichier header a déjà inclus les définitions de ce fichier et qu'elles ne doivent pas être incluses une seconde fois.

La seconde partie d'un module est son fichier source, qui contient l'implémentation en C des fonctions définies dans le fichier header correspondant, ainsi que toute variable ou fonction supplémentaire, qui ne sont donc pas visibles sur l'interface externe. Le fichier source contient donc le code C correspondant à l'interface externe. Un fichier source doit contenir une directive #include pour inclure le fichier header correspondant, et porte également le même nom, avec l'extension .c.

/**************************************
 * min.c
 *
 * Programme d'exemple pour le linker
 *
 **************************************/

#include "min.h"

float min(float a, float b) {
  if(a<b)
    return a;
  else
    return b;
}

Note

Localisation des fichiers header

Un programmeur C peut utiliser deux types de fichiers header. Il y a tout d'abord les fichiers headers standards qui sont fournis avec le système. Ce sont ceux que nous avons utilisés jusque maintenant. Ces headers standards se reconnaissent car ils sont entourés des caractères < et > dans la directive #include. Ceux-ci se trouvent dans des répertoires connus par le compilateur, normalement /usr/include. Les fichiers headers qui accompagnent un module se trouvent eux généralement dans le même répertoire que le module. Dans l'exemple ci-dessus, le header min.h est inclus via la directive #include "min.h". Lorsque le préprocesseur rencontre une telle directive, il cherche le fichier dans le répertoire courant. Il est possible de spécifier des répertoires qui contiennent des fichiers headers via l'argument -I de gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] ou en utilisant les variables d'environnement GCC_INCLUDE_DIR ou CPATH.

Lorsque l'on doit compiler un programme qui fait appel à plusieurs modules, quelle que soit sa taille, il est préférable d'utiliser make(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/make.1.html] pour automatiser sa compilation. Le fichier ci-dessous est un petit exemple de Makefile utilisable pour un tel projet.

myprog: main.o min.o
	gcc -std=c99 -o myprog main.o min.o

main.o: main.c min.h
	gcc -std=c99 -c main.c

min.o: min.c min.h
	gcc -std=c99 -c min.c

La compilation d'un tel programme se déroule en plusieurs étapes. La première étape est celle du préprocesseur. Celui-ci est directement appelé par le compilateur gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] mais il est également possible de l'invoquer directement via cpp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cpp.1.html]. Le préprocesseur remplace toutes les macros telles que les #define et insère les fichiers headers sur base des directives #include. La sortie du préprocesseur est utilisée directement par le compilateur. Celui-ci transforme le module en langage C en langage assembleur. Ce module en assembleur est ensuite assemblé par as(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/as.1.html] pour produire un fichier objet. Ce fichier objet n'est pas directement exécutable. Il contient les instructions en langage machine pour les différentes fonctions définies dans le module, les définitions des constantes et variables globales ainsi qu'une table reprenant tous les symboles (noms de fonction, noms de variables globales, ...) définis dans ce module. Ces phases sont exécutées pour chaque module utilisé. Par convention, les fichiers objets ont en général l'extension .o. Ces fichiers objet sont créés par les deux dernières cibles du fichier Makefile ci-dessus. L'option -c passée à gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] indique à gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] qu'il doit générer un fichier objet sans le transformer en exécutable. Cette dernière opération est réalisée par la première cible du Makefile. Dans cette cible, gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] fait office d'éditeur de liens ou de linker en anglais. Le linker combine différents fichiers objets en faisant les liens nécessaires entre les fichiers. Dans notre exemple, le fichier main.o contient une référence vers la fonction min qui n'est pas connue lors de la compilation de main.c. Par contre, cette référence est connue dans le fichier min.o. L'éditeur de liens va combiner ces références de façon à permettre aux fonctions d'un module d'exécuter n'importe quelle fonction définie dans un autre module.

La figure ci-dessous représente graphiquement les différentes étapes de compilation des modules min.c et main.c.

Etapes de compilation

Lorsque plusieurs modules, potentiellement développés par des programmeurs qui ne se sont pas concertés, doivent être intégrés dans un grand programme, il y a des risques de conflits entre des variables et fonctions qui pourraient être définies dans plusieurs modules différents. Ainsi, deux modules pourraient définir la fonction int min(int, int) ou la variable globale float dist. Le langage C intègre des facilités qui permettent d'éviter ou de contrôler ces problèmes.

Tout d'abord, les variables locales sont locales au bloc dans lequel elles sont définies. Ce principe permet d'utiliser le même nom de variable dans plusieurs blocs d'un même fichier. Il s'étend naturellement à l'utilisation de variables locales dans des fichiers différents.

Pour les variables globales, la situation est différente. Si une variable est définie en dehors d'un bloc dans un fichier, cette variable est considérée comme étant globale. Par défaut, elle est donc accessible depuis tous les modules qui composent le programme. Cela peut en pratique poser des difficultés si le même nom de variable est utilisé dans deux modules différents. Pour contourner ce problème, le langage C utilise static. Lorsque static est placé devant une déclaration de variable en dehors d'un bloc dans un module, il indique que la variable doit être accessible à toutes les fonctions du module mais pas en dehors du module. Lorsqu'un module utilise des variables qui sont communes à plusieurs fonctions mais ne doivent pas être visibles en dehors du module, il est important de les déclarer comme étant static. Lorsqu'une déclaration de variable globale est préfixée par extern, cela indique au compilateur que la variable est définie dans un autre module qui sera lié ultérieurement. Le compilateur réserve une place pour cette variable dans la table des symboles du fichier objet, mais cette place ne pourra être liée à la zone mémoire qui correspond à cette variable que lorsque l'éditeur de liens combinera les différents fichiers objet entre eux.

Note

Les deux utilisations de static pour des variables

static peut être utilisé à la fois pour des variables qui sont définies en dehors d'un bloc et dans un bloc. Lorsqu'une variable est définie comme étant static hors d'un bloc dans un module, elle n'est accessible qu'aux fonctions de ce module. Par contre, lorsqu'une variable est définie comme étant static à l'intérieur d'un bloc, par exemple dans une fonction, cela indique que cette variable doit toujours se trouver à la même localisation en mémoire, quel que soit le moment où elle est appelée. Ces variables static sont placées par le compilateur dans le bas de la mémoire, avec les variables globales. Contrairement aux variables locales traditionnelles, une variable locale static garde sa valeur d'une invocation de la fonction à l'autre. En pratique, les variables locales static doivent être utilisées avec précaution et bien documentées. Un de leurs intérêt est qu'elles ne sont initialisées qu'au lancement du programme et pas à chaque invocation de la fonction où elles sont définies.

Il faut noter que static peut aussi précéder des déclarations de fonctions. Dans ce cas, il indique que la fonction ne doit pas être visible en dehors du module dans lequel elle est définie. Sans static, une fonction déclarée dans un module est accessible depuis n'importe quel autre module.

Afin d'illustrer l'utilisation de static et extern, considérons le programme prog.c ci-dessous qui inclut le module module.c et également le module min.c présenté plus haut.

/**************************************
 * module.h
 *
 **************************************/
#ifndef _MODULE_H_
#define _MODULE_H_

float vmin(int, float *);

#endif /* _MODULE_H */

/**************************************
 * module.c
 *
 **************************************/

#include "module.h"

static float min(float, float);

int num1=0;  // accessible hors de module.c
extern int num2; // définie dans un autre module
static int num3=1252; // accessible uniquement dans ce module

float vmin(int n, float *ptr) {
  float *p=ptr;
  float m=*ptr;
  for(int i=1;i<n;i++) {
    m=min(m,*p);
    p++;
  }
  return m;
}

static float min(float a, float b) {
  if(a<b)
    return a;
  else
    return b;
}

Ce module contient deux fonctions, vmin et min. vmin est accessible depuis n'importe quel module. Sa signature est reprise dans le fichier header module.h. La fonction min par contre est déclarée comme étant static. Cela implique qu'elle n'est utilisable qu'à l'intérieur de ce module et invisible de tout autre module. La variable globale num1 est accessible depuis n'importe quel module. La variable num2 également, mais elle est initialisée dans un autre module. Enfin, la variable num3 n'est accessible qu'à l'intérieur de ce module.

#include "min.h"
#include "module.h"

#define SIZE 4

extern int num1; // définie dans un autre module
int num2=1252;   // accessible depuis un autre module
static int num3=-1; // accessible uniquement dans ce module

void f() {
  static int n=0;
  int loc=2;
  if(n==0)
    printf("n est à  l'adresse %p et loc à l'adresse %p\n",&n,&loc);
  printf("f, n=%d\n",n);
  n++;
}

int main(int argc, char* argv[]) {

  float v[SIZE]={1.0, 3.4, -2.4, 9.9};
  printf("Minimum: %f\n",vmin(SIZE,v));
  f();
  f();
  printf("Minimum(0.0,1.1)=%f\n",min(0.0,1.1));
  return(EXIT_SUCCESS);
}

Ce module inclus les fichiers min.h et module.h qui contiennent les signatures des fonctions se trouvant dans ces deux modules. Trois variables globales sont utilisées par ce module. num1 est définie dans un autre module (dans ce cas module.c). num2 est initialisée dans ce module mais accessible depuis n'importe quel autre module. num3 est une variable globale qui est accessible uniquement depuis le module prog.c. Même si cette variable porte le même nom qu'une autre variable déclarée dans module.c, il n'y aura pas de conflit puisque ces deux variables sont static.

La fonction f mérite que l'on s'y attarde un peu. Cette fonction contient la définition de la variable static n. Même si cette variable est locale à la fonction f et donc invisible en dehors de cette fonction, le compilateur va lui réserver une place dans la même zone que les variables globales. La valeur de cette variable static sera initialisée une seule fois : au démarrage du programme. Même si cette variable parait être locale, elle ne sera jamais réinitialisée lors d'un appel à la fonction f. Comme cette variable est stockée en dehors de la pile, elle conserve sa valeur d'une invocation à l'autre de la fonction f. Ceci est illustré par l'exécution du programme qui produit la sortie suivante.

Minimum: -2.400000
n est à  l'adresse 0x100001078 et loc à l'adresse 0x7fff5fbfe1cc
f, n=0
f, n=1
Minimum(0.0,1.1)=0.000000

Le dernier point à mentionner concernant cet exemple est relatif à la fonction min qui est utilisée dans la fonction main. Le module prog.c étant lié avec module.c et min.c, le linker associe à ce nom de fonction la déclaration qui se trouve dans le fichier min.c. La déclaration de la fonction min qui se trouve dans module.c est static, elle ne peut donc pas être utilisée en dehors de ce module.

Traitement des erreurs

Certaines fonctions de la librairie et certains appels systèmes réussissent toujours. C'est le cas par exemple pour getpid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getpid.2.html]. D'autres fonctions peuvent échouer et il est important de tester la valeur de retour de chaque fonction/appel système utilisé pour pouvoir réagir correctement à toute erreur. Pour certaines fonctions ou appels systèmes, il est parfois nécessaire de fournir à l'utilisateur plus d'information sur l'erreur qui s'est produite. La valeur de retour utilisée pour la plupart des fonctions de la libraire et appels systèmes (souvent un int ou un pointeur), ne permet pas de fournir de l'information précise sur l'erreur qui s'est produite.

Les systèmes Unix utilisent la variable globale errno pour résoudre ce problème et permettre à une fonction de la librairie ou un appel système qui a échoué de donner plus de détails sur les raisons de l'échec. Cette variable globale est définie dans errno.h [https://sites.uclouvain.be/SystInfo/usr/include/errno.h.html] qui doit être inclus par tout programme voulant tester ces codes d'erreur. Cette variable est de type int et errno.h contient les définitions des constantes correspondants aux cas d'erreurs possibles. Il faut noter que la librairie standard fournit également les fonctions perror(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/perror.3.html] et strerror(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror.3.html] qui facilitent l'écriture de messages d'erreur compréhensibles pour l'utilisateur.

A titre d'exemple, le programme ci-dessous utilise strerror(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror.3.html] pour afficher un message d'erreur plus parlant lors d'appels erronés à la fonction setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html].

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(int argc, char *argv[]) {

  if(setenv(NULL,NULL,1)!=0) {
    fprintf(stderr,"Erreur : errno=%d %s\n",errno,strerror(errno));
  }
  if(setenv("PATH=","/usr/bin",1)!=0) {
    fprintf(stderr,"Erreur : errno=%d %s\n",errno,strerror(errno));
   }

}

Note

La valeur de errno n'indique pas la réussite ou l'échec d'une fonction

Il faut noter que la variable errno n'est modifiée par les fonctions de la librairie ou les appels systèmes que si l'appel échoue. Si l'appel réussit, la valeur de errno n'est pas modifiée. Cela implique qu'il ne faut surtout pas tester la valeur de errno pour déterminer si une fonction de la librairie a échoué ou réussi. Il ne faut surtout pas utiliser le pattern suivant :

setenv("PATH","/usr/bin",1);
if(errno!=0) {
  fprintf(stderr,"Erreur : errno=%d %s\n",errno,strerror(errno);
}

Le code ci-dessus est erroné car il ne teste pas la valeur de retour de setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html]. Comme les fonctions de la librairie et les appels systèmes ne modifient errno que lorsqu'une erreur survient, le code ci-dessus pourrait afficher un message d'erreur relatif à un appel système précédent qui n'a absolument rien à voir avec l'appel à la fonction setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html]. Le code correct est évidemment de tester la valeur de retour de setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html] :

err=setenv("PATH","/usr/bin",1);
if(err!=0) {
  fprintf(stderr,"Erreur : errno=%d %s\n",errno,strerror(errno));
}

Footnotes

1

restrict est également parfois utilisé pour indiquer des contraintes sur les pointeurs passés en argument à une fonction [Walls2006].

Déclarations

Durant les chapitres précédents, nous avons principalement utilisé des variables locales. Celles-ci sont déclarées à l'intérieur des fonctions où elles sont utilisées. La façon dont les variables sont déclarées est importante dans un programme écrit en langage C. Dans cette section nous nous concentrerons sur des programmes C qui sont écrits sous la forme d'un seul fichier source. Nous verrons plus tard comment découper un programme en plusieurs modules qui sont répartis dans des fichiers différents et comment les variables peuvent y être déclarées.

La première notion importante concernant la déclaration des variables est leur portée. La portée d'une variable peut être définie comme étant la partie du programme où la variable est accessible et où sa valeur peut être modifiée. Le langage C définit deux types de portée à l'intérieur d'un fichier C. La première est la portée globale. Une variable qui est définie en dehors de toute définition de fonction a une portée globale. Une telle variable est accessible dans toutes les fonctions présentes dans le fichier. La variable g dans l'exemple ci-dessous a une portée globale.

float g;   // variable globale

int f(int i) {
int n;   // variable locale
// ...
for(int j=0;j<n;j++) {  // variable locale
  // ...
  }
//...
for(int j=0;j<n;j++) {  // variable locale
  // ...
  }
}

Dans un fichier donné, il ne peut évidemment pas y avoir deux variables globales qui ont le même identifiant. Lorsqu'une variable est définie dans un bloc, la portée de cette variable est locale à ce bloc. On parle dans ce cas de portée locale. La variable locale n'existe pas avant le début du bloc et n'existe plus à la fin du bloc. Contrairement aux identifiants de variables globales qui doivent être uniques à l'intérieur d'un fichier, il est possible d'avoir plusieurs variables locales qui ont le même identifiant à l'intérieur d'un fichier. C'est fréquent notamment pour les définitions d'arguments de fonction et les variables de boucles. Dans l'exemple ci-dessus, les variables n et j ont une portée locale. La variable j est définie dans deux blocs différents à l'intérieur de la fonction f.

Le programme ./S3-src/portee.c illustre la façon dont le compilateur C gère la portée de différentes variables.

int g1;
int g2=1;

void f(int i) {
  int loc;    //def1a
  int loc2=2; //def2a
  int g2=-i*i;
  g1++;

  printf("[f-%da] \t\t %d \t %d \t %d \t %d\n",i,g1,g2,loc,loc2);
  loc=i*i;
  g1++;
  g2++;
  printf("[f-%db] \t\t %d \t %d \t %d \t %d\n",i,g1,g2,loc,loc2);

}

int main(int argc, char *argv[]) {
  int loc; //def1b
  int loc2=1; //def2b

  printf("Valeurs de : \t g1 \t g2\t loc\t loc2\n");
  printf("===================================================\n");

  printf("[main1] \t %d \t %d \t %d \t %d\n",g1,g2,loc,loc2);

  loc=1252;
  loc2=1234;
  g1=g1+1;
  g1=g1+2;

  printf("[main2] \t %d \t %d \t %d \t %d\n",g1,g2,loc,loc2);

  for(int i=1;i<3;i++) {
    int loc=i; //def1c
    int g2=-i;
    loc++;
    g1=g1*2;
    f(i);
    printf("[main-for-%d] \t %d \t %d \t %d \t %d\n",i,g1,g2,loc,loc2);
  }
  f(7);
  g1=g1*3;
  g2=g2+2;
  printf("[main3] \t %d \t %d \t %d \t %d\n",g1,g2,loc,loc2);

  return(EXIT_SUCCESS);
}

Ce programme contient deux variables qui ont une portée globale : g1 et g2. Ces deux variables sont définies en dehors de tout bloc. En pratique, elles sont généralement déclarées au début du fichier, même si le compilateur C accepte un définition en dehors de tout bloc et donc par exemple en fin de fichier. La variable globale g1 n'est définie qu'une seule fois. Par contre, la variable g2 est définie avec une portée globale et est redéfinie à l'intérieur de la fonction f ainsi que dans la boucle for de la fonction main. Redéfinir une variable globale de cette façon n'est pas du tout une bonne pratique, mais cela peut arriver lorsque par mégarde on importe un fichier header qui contient une définition de variable globale. Dans ce cas, le compilateur C utilise la variable qui est définie dans le bloc le plus proche. Pour la variable g2, c'est donc la variable locale g2 qui est utilisée à l'intérieur de la boucle for ou de la fonction f.

Lorsqu'un identifiant de variable locale est utilisé à plusieurs endroits dans un fichier, c'est la définition la plus proche qui est utilisée. L'exécution du programme ci-dessus illustre cette utilisation des variables globales et locales.

Valeurs de : 	 g1 	 g2	 loc	 loc2
===================================================
[main1] 	 0 	 1 	 0 	 1
[main2] 	 3 	 1 	 1252 	 1234
[f-1a] 		 7 	 -1 	 0 	 2
[f-1b] 		 8 	 0 	 1 	 2
[main-for-1] 	 8 	 -1 	 2 	 1234
[f-2a] 		 17 	 -4 	 0 	 2
[f-2b] 		 18 	 -3 	 4 	 2
[main-for-2] 	 18 	 -2 	 3 	 1234
[f-7a] 		 19 	 -49 	 0 	 2
[f-7b] 		 20 	 -48 	 49 	 2
[main3] 	 60 	 3 	 1252 	 1234

Note

Utilisation des variables

En pratique, les variables globales doivent être utilisées de façon parcimonieuse et il faut limiter leur utilisation aux données qui doivent être partagées par plusieurs fonctions à l'intérieur d'un programme. Lorsqu'une variable globale a été définie, il est préférable de ne pas réutiliser son identifiant pour une variable locale. Au niveau des variables locales, les premières versions du langage C imposaient leur définition au début des blocs. Les standards récents [C99] autorisent la déclaration de variables juste avant leur première utilisation un peu comme en Java.

Les versions récentes de C [C99] permettent également de définir des variables dont la valeur sera constante durant toute l'exécution du programme. Ces déclarations de ces constants sont préfixées par le mot-clé const qui joue le même rôle que le mot clé final en Java.

// extrait de <math.h>
#define M_PI   3.14159265358979323846264338327950288;

const double pi=3.14159265358979323846264338327950288;

const struct fraction {
  int num;
  int denom;
}  demi={1,2};

Il y a deux façons de définir des constantes dans les versions récentes de C [C99]. La première est via la macro #define du préprocesseur. Cette macro permet de remplacer une chaîne de caractères (par exemple M_PI qui provient de math.h [https://sites.uclouvain.be/SystInfo/usr/include/math.h.html]) par un nombre ou une autre chaîne de caractères. Ce remplacement s'effectue avant la compilation. Dans le cas de M_PI ci-dessus, le préprocesseur remplace toute les occurrences de cette chaîne de caractères par la valeur numérique de \(\pi\). Lorsqu'une variable const est utilisée, la situation est un peu différente. Le préprocesseur n'intervient pas. Par contre, le compilateur réserve une zone mémoire pour la variable qui a été définie comme constante. Cela a deux avantages par rapport à l'utilisation de #define. Premièrement, il est possible de définir comme constante n'importe quel type de données en C, y compris des structures ou des pointeurs alors qu'avec un #define on ne peut définir que des nombres ou des chaînes de caractères. Ensuite, comme une const est stockée en mémoire, il est possible d'obtenir son adresse et de l'examiner via un debugger.

Unions et énumérations

Les structures que nous avons présentées précédemment permettent de combiner plusieurs données de types primitifs différents entre elles. Outre ces structures (struct), le langage C supporte également les enum et les union. Le mot-clé enum est utilisé pour définir un type énuméré, c'est-à-dire un type de donnée qui permet de stocker un nombre fixe de valeurs. Quelques exemples classiques sont repris dans le fragment de programme ci-dessous :

// les jours de la semaine
typedef enum  {
      monday, tuesday, wednesday, thursday, friday, saturday, sunday
   } day;

// jeu de carte
typedef enum {
  coeur, carreau, trefle, pique
} carte;

// bits
typedef enum {
        BITRESET = 0,
        BITSET = 1
} bit_t;

Le premier enum permet de définir le type de données day qui contient une valeur énumérée pour chaque jour de la semaine. L'utilisation d'un type énuméré rend le code plus lisible que simplement l'utilisation de constantes définies via le préprocesseur.

  bit_t bit=BITRESET;
  day jour=monday;
  if(jour==saturday||jour==sunday)
    printf("Congé\n");

En pratique, lors de la définition d'un type énuméré, le compilateur C associe une valeur entière à chacune des valeurs énumérées. Une variable permettant de stocker la valeur d'un type énuméré occupe la même zone mémoire qu'un entier.

Outre les structures, le langage C supporte également les unions. Alors qu'une structure permet de stocker plusieurs données dans une même zone mémoire, une union permet de réserver une zone mémoire pour stocker une données parmi plusieurs types possibles. Une union est parfois utilisée pour minimiser la quantité de mémoire utilisée pour une structure de données qui peut contenir des données de plusieurs types. Pour bien comprendre la différence entre une union et une struct, considérons l'exemple ci-dessous.

struct s_t {
  int i;
  char c;
} s;

union u_t {
  int i;
  char c;
} u;

Une union, u et une structure, s sont déclarées dans ce fragment de programme.

  // initialisation
  s.i=1252;
  s.c='A';
  u.i=1252;
  // u contient un int
  u.c='Z';
  // u contient maintenant un char (et u.i est perdu)

La structure s peut contenir à la fois un entier et un caractère. Par contre, l'union u, peut elle contenir un entier (u.i) ou un caractère (u.c), mais jamais les deux en même temps. Le compilateur C alloue la taille pour l'union de façon à ce qu'elle puisse contenir le type de donnée se trouvant dans l'union nécessitant le plus de mémoire. Si les unions sont utiles dans certains cas très particulier, il faut faire très attention à leur utilisation. Lorsqu'une union est utilisée, le compilateur C fait encore moins de vérifications sur les types de données et le code ci-dessous est considéré comme valide par le compilateur :

  u.i=1252;
  printf("char : %c\n", u.c);

Lors de son exécution, la zone mémoire correspondant à l'union u sera simplement interprétée comme contenant un char, même si on vient d'y stocker un entier. En pratique, lorsqu'une union est vraiment nécessaire pour des raisons d'économie de mémoire, on préférera la placer dans une struct en utilisant un type énuméré qui permet de spécifier le type de données qui est présent dans l'union.

typedef enum { INTEGER, CHAR } Type;

typedef struct
{
  Type type;
  union {
  int i;
  char c;
  } x;
} Value;

Le programmeur pourra alors utiliser cette structure en indiquant explicitement le type de données qui y est actuellement stocké comme suit.

Value v;
v.type=INTEGER;
v.x.i=1252;

Organisation de la mémoire

Lors de l'exécution d'un programme en mémoire, le système d'exploitation charge depuis le système de fichier le programme en langage machine et le place à un endroit convenu en mémoire. Lorsqu'un programme s'exécute sur un système Unix, la mémoire peut être vue comme étant divisée en six zones principales. Ces zones sont représentées schématiquement dans la figure ci-dessous.

Organisation d'un programme Linux en mémoire

La figure ci-dessus présente une vision schématique de la façon dont un processus Linux est organisé en mémoire centrale. Il y a d'abord une partie de la mémoire qui est réservée au système d'exploitation (OS dans la figure). Cette zone est représentée en grisé dans la figure.

Le segment text

La première zone est appelée par convention le segment text. Cette zone se situe dans la partie basse de la mémoire 3. C'est dans cette zone que sont stockées toutes les instructions qui sont exécutées par le micro-processeur. Elle est généralement considérée par le système d'exploitation comme étant uniquement accessible en lecture. Si un programme tente de modifier son segment text, il sera immédiatement interrompu par le système d'exploitation. C'est dans le segment text que l'on retrouvera les instructions de langage machine correspondant aux fonctions de calcul et d'affichage du programme. Nous en reparlerons lorsque nous présenterons le fonctionnement du langage d'assemblage.

Le segment des données initialisées

La deuxième zone, baptisée segment des données initialisées, contient l'ensemble des données et chaînes de caractères qui sont utilisées dans le programme. Ce segment contient deux types de données. Tout d'abord, il comprend l'ensemble des variables globales explicitement initialisées par le programme (dans le cas contraire, elles sont initialisées à zéro par le compilateur et appartiennent alors au segment des données non-initialisées). Ensuite, les constantes et les chaînes de caractères utilisées par le programme.

#define MSG_LEN 10
int g;   // initialisé par le compilateur
int g_init=1252;
const int un=1;
int tab[3]={1,2,3};
int array[10000];
char cours[]="SINF1252";
char msg[MSG_LEN]; // initialisé par le compilateur

int main(int argc, char *argv[]) {
  int i;
  printf("g est à l'adresse %p et initialisée à %d\n",&g,g);
  printf("msg est à l'adresse %p contient les caractères :",msg);
  for(i=0;i<MSG_LEN;i++)
    printf(" %x",msg[i]);
  printf("\n");
  printf("Cours est à l'adresse %p et contient : %s\n",&cours,cours);
  return(EXIT_SUCCESS);
}

Dans le programme ci-dessus, la variable g_init, la constante un et les tableaux tab et cours sont dans la zone réservée aux variables initialisées. En pratique, leur valeur d'initialisation sera chargée depuis le fichier exécutable lors de son chargement en mémoire. Il en va de même pour toutes les chaînes de caractères qui sont utilisées comme arguments aux appels à printf(3).

L'exécution de ce programme produit la sortie standard suivante.

g est à l'adresse 0x60aeac et initialisée à 0
msg est à l'adresse 0x60aea0 contient les caractères : 0 0 0 0 0 0 0 0 0 0
Cours est à l'adresse 0x601220 et contient : SINF1252

Cette sortie illustre bien les adresses où les variables globales sont stockées. La variable globale msg fait notamment partie du segment des données non-initialisées.

Le segment des données non-initialisées

La troisième zone est le segment des données non-initialisées, réservée aux variables non-initialisées. Cette zone mémoire est initialisée à zéro par le système d'exploitation lors du démarrage du programme. Dans l'exemple ci-dessus, c'est dans cette zone que l'on stockera les valeurs de la variable g et des tableaux array et msg.

Note

Initialisation des variables

Un point important auquel tout programmeur C doit faire attention est l'initialisation correcte de l'ensemble des variables utilisées dans un programme. Le compilateur C est nettement plus permissif qu'un compilateur Java et il autorisera l'utilisation de variables avant qu'elles n'aient été explicitement initialisées, ce qui peut donner lieu à des erreurs parfois très difficiles à corriger.

En C, par défaut les variables globales qui ne sont pas explicitement initialisées dans un programme sont initialisées à la valeur zéro par le compilateur. Plus précisément, la zone mémoire qui correspond à chaque variable globale non-explicitement initialisée contiendra des bits valant 0. Pour les variables locales, le langage C n'impose aucune initialisation par défaut au compilateur. Par souci de performance et sachant qu'un programmeur ne devrait jamais utiliser de variable locale non explicitement initialisée, le compilateur C n'initialise pas par défaut la valeur de ces variables. Cela peut avoir des conséquences ennuyeuses comme le montre l'exemple ci-dessous.

#define ARRAY_SIZE 1000

// initialise un tableau local
void init(void) {
  long a[ARRAY_SIZE];
  for(int i=0;i<ARRAY_SIZE;i++) {
    a[i]=i;
  }
}

// retourne la somme des éléments
// d'un tableau local
long read(void) {
  long b[ARRAY_SIZE];
  long sum=0;
  for(int i=0;i<ARRAY_SIZE;i++) {
    sum+=b[i];
  }
  return sum;
}

Cet extrait de programme contient deux fonctions erronées. La seconde, baptisée read(void) déclare un tableau local et retourne la somme des éléments de ce tableau sans l'initialiser. En Java, une telle utilisation d'un tableau non-initialisé serait détectée par le compilateur. En C, elle est malheureusement valide (mais fortement découragée évidemment). La première fonction, init(void) se contente d'initialiser un tableau local mais ne retourne aucun résultat. Cette fonction ne sert a priori à rien puisqu'elle n'a aucun effet sur les variables globales et ne retourne aucun résultat. L'exécution de ces fonctions via le fragment de code ci-dessous donne cependant un résultat interpellant.

  printf("Résultat de read() avant init(): %ld\n",read());
  init();
  printf("Résultat de read() après init() : %ld\n",read());

Résultat de read() avant init(): 7392321044987950589
Résultat de read() après init() : 499500

Le tas (ou heap)

La quatrième zone de la mémoire est le tas (ou heap en anglais). Vu l'importance pratique de la terminologie anglaise, c'est celle-ci que nous utiliserons dans le cadre de ce document. C'est une des deux zones dans laquelle un programme peut obtenir de la mémoire supplémentaire pour stocker de l'information. Un programme peut y réserver une zone permettant de stocker des données et y associer un pointeur.

Le système d'exploitation mémorise, pour chaque processus en cours d'exécution, la limite supérieure de son heap. Le système d'exploitation permet à un processus de modifier la taille de son heap via les appels systèmes brk(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/brk.2.html] et sbrk(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/sbrk.2.html]. Malheureusement, ces deux appels systèmes se contentent de modifier la limite supérieure du heap sans fournir une API permettant au processus d'y allouer efficacement des blocs de mémoire. Rares sont les processus qui utilisent directement brk(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/brk.2.html] si ce n'est sous la forme d'un appel à sbrk(0) de façon à connaître la limite supérieure actuelle du heap.

En C, la plupart des processus allouent et libèrent de la mémoire en utilisant les fonctions malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] qui font partie de la librairie standard.

La fonction malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] prend comme argument la taille (en bytes) de la zone mémoire à allouer. La signature de la fonction malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] demande que cette taille soit de type size_t, c'est-à-dire le type retourné par l'expression sizeof. Il est important de toujours utiliser sizeof lors du calcul de la taille d'une zone mémoire à allouer. malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] retourne normalement un pointeur de type (void *). Ce type de pointeur est le type par défaut pour représenter dans un programme C une zone mémoire qui ne pointe pas vers un type de données particulier. En pratique, un programme va généralement utiliser malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] pour allouer de la mémoire pour stocker différents types de données et le pointeur retourné par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] sera casté dans un pointeur du bon type.

Note

typecast en langage C

Comme le langage Java, le langage C supporte des conversions implicites et explicites entre les différents types de données. Ces conversions sont possibles entre les types primitifs et les pointeurs. Nous les rencontrerons régulièrement, par exemple lorsqu'il faut récupérer un pointeur alloué par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] ou le résultat de sizeof. Contrairement au compilateur Java, le compilateur C n'émet pas toujours de message de warning lors de l'utilisation de typecast qui risque d'engendrer une perte de précision. Ce problème est illustré par l'exemple suivant avec les nombres.

  int i=1;
  float f=1e20;
  double d=1e100;

  printf("i [int]: %d, [float]:%f, [double]:%f\n",i,(float)i,(double)i);
  printf("f [int]: %d, [float]:%f, [double]:%f\n",(int)f,f,(double)f);
  printf("d [int]: %d, [float]:%f, [double]:%f\n",(int)d,(float)d,d);
  printf("sizeof -> int:%d float:%d double:%d\n",(int)sizeof(int), (int)sizeof(float), (int)sizeof(double));

La fonction de la librairie free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] est le pendant de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html]. Elle permet de libérer la mémoire qui a été allouée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html]. Elle prend comme argument un pointeur dont la valeur a été initialisée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et libère la zone mémoire qui avait été allouée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] pour ce pointeur. La valeur du pointeur n'est pas modifiée, mais après libération de la mémoire il n'est évidemment plus possible 1 d'accéder aux données qui étaient stockées dans cette zone.

Le programme ci-dessous illustre l'utilisation de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html].

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

typedef struct fraction {
  int num;
  int den;
} Fraction;

void error(char *msg) {
  fprintf(stderr,"Erreur :%s\n",msg);
  exit(EXIT_FAILURE);
}

int main(int argc, char *argv[]) {
  int size=1;
  if(argc==2)
    size=atoi(argv[1]);

  char * string;
  printf("Valeur du pointeur string avant malloc : %p\n",string);
  string=(char *) malloc((size+1)*sizeof(char));
  if(string==NULL)
    error("malloc(string)");

  printf("Valeur du pointeur string après malloc : %p\n",string);
  int *vector;
  vector=(int *)malloc(size*sizeof(int));
  if(vector==NULL)
    error("malloc(vector)");

  Fraction * fract_vect;
  fract_vect=(Fraction *) malloc(size*sizeof(Fraction));
  if(fract_vect==NULL)
    error("malloc(fract_vect)");

  free(string);
  printf("Valeur du pointeur string après free   : %p\n",string);
  string=NULL;
  free(vector);
  vector=NULL;
  free(fract_vect);
  fract_vect=NULL;

  return(EXIT_SUCCESS);
}

Ce programme alloue trois zones mémoires. Le pointeur vers la première est sauvé dans le pointeur string. Elle est destinée à contenir une chaîne de size caractères (avec un caractère supplémentaire pour stocker le caractère \0 de fin de chaîne). Il y a deux points à remarquer concernant cette allocation. Tout d'abord, le pointeur retourné par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] est "casté" en un char *. Cela indique au compilateur que string va bien contenir un pointeur vers une chaîne de caractères. Cette conversion explicite rend le programme plus clair. Ensuite, la valeur de retour de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] est systématiquement testée. malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] peut en effet retourner NULL lorsque la mémoire est remplie. Cela a peu de chance d'arriver dans un programme de test tel que celui-ci, mais tester les valeurs de retour des fonctions de la librairie est une bonne habitude à prendre lorsque l'on programme sous Unix. Le second pointeur, vector pointe vers une zone destiné à contenir un tableau d'entiers. Le dernier pointeur, fract_vect pointe vers une zone qui pourra stocker un tableau de Fraction. Lors de son exécution, le programme affiche la sortie suivante.

Valeur du pointeur string avant malloc : 0x7fff5fbfe1d8
Valeur du pointeur string après malloc : 0x100100080
Valeur du pointeur string après free   : 0x100100080

Dans cette sortie, on remarque que l'appel à fonction free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] libère la zone mémoire, mais ne modifie pas la valeur du pointeur correspondant. Le programmeur doit explicitement remettre le pointeur d'une zone mémoire libérée à NULL.

Un autre exemple d'utilisation de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] est la fonction duplicate ci-dessous qui permet de retourner une copie d'une chaîne de caractères. Il est important de noter qu'en C la fonction strlen(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strlen.3.html] retourne la longueur de la chaîne de caractères passée en argument sans prendre en compte le caractère \0 qui marque sa fin. C'est la raison pour laquelle malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] doit réserver un bloc de mémoire en plus. Même si généralement les char occupent un octet en mémoire, il est préférable d'utiliser explicitement sizeof(char) lors du calcul de l'espace mémoire nécessaire pour un type de données.

#include <string.h>

char *duplicate(char * str) {
  int i;
  size_t len=strlen(str);
  char *ptr=(char *)malloc(sizeof(char)*(len+1));
  if(ptr!=NULL) {
    for(i=0;i<len+1;i++) {
	*(ptr+i)=*(str+i);
      }
  }
  return ptr;
}

malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] sont fréquemment utilisés dans des programmes qui manipulent des structures de données dont la taille varie dans le temps. C'est le cas pour les différents sortes de listes chaînées, les piles, les queues, les arbres, ... L'exemple ci-dessous (./S3-src/stack.c) illustre une implémentation d'une pile simple en C. Le pointeur vers le sommet de la pile est défini comme une variable globale. Chaque élément de la pile est représenté comme un pointeur vers une structure qui contient un pointeur vers la donnée stockée (dans cet exemple des fractions) et l'élément suivant sur la pile. Les fonctions push et pop permettent respectivement d'ajouter un élément et de retirer un élément au sommet de la pile. La fonction push alloue la mémoire nécessaire avec malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] tandis que la fonction pop utilise free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] pour libérer la mémoire dès qu'un élément est retiré.

typedef struct node_t
{
  struct fraction_t *data;
  struct node_t *next;
} node;

struct node_t *stack; // sommet de la pile

// ajoute un élément au sommet de la pile
void push(struct fraction_t *f)
{
  struct node_t *n;
  n=(struct node_t *)malloc(sizeof(struct node_t));
  if(n==NULL)
    exit(EXIT_FAILURE);
  n->data = f;
  n->next = stack;
  stack = n;
}
// retire l'élément au sommet de la pile
struct fraction_t * pop()
{
  if(stack==NULL)
    return NULL;
  // else
  struct fraction_t *r;
  struct node_t *removed=stack;
  r=stack->data;
  stack=stack->next;
  free(removed);
  return (r);
}

Ces fonctions peuvent être utilisées pour empiler et dépiler des fractions sur une pile comme dans l'exemple ci-dessous. La fonction display permet d'afficher sur stdout le contenu de la pile.

// affiche le contenu de la pile
void display()
{
  struct node_t *t;
  t = stack;
  while(t!=NULL) {
    if(t->data!=NULL) {
      printf("Item at addr %p  :  Fraction %d/%d   Next %p\n",t,t->data->num,t->data->den,t->next);
    }
    else {
      printf("Bas du stack %p\n",t);
    }
    t=t->next;
  }
}

// exemple
int main(int argc, char *argv[]) {

  struct fraction_t demi={1,2};
  struct fraction_t tiers={1,3};
  struct fraction_t quart={1,4};
  struct fraction_t zero={0,1};

  // initialisation
  stack = (struct node_t *)malloc(sizeof(struct node_t));
  stack->next=NULL;
  stack->data=NULL;

  display();
  push(&zero);
  display();
  push(&demi);
  push(&tiers);
  push(&quart);
  display();

  struct fraction_t *f=pop();
  if(f!=NULL)
    printf("Popped : %d/%d\n",f->num,f->den);

  return(EXIT_SUCCESS);
}

Lors de son exécution le programme ./S3-src/stack.c présenté ci-dessus affiche les lignes suivantes sur sa sortie standard.

Bas du stack 0x100100080
Item at addr 0x100100090  :  Fraction 0/1   Next 0x100100080
Bas du stack 0x100100080
Item at addr 0x1001000c0  :  Fraction 1/4   Next 0x1001000b0
Item at addr 0x1001000b0  :  Fraction 1/3   Next 0x1001000a0
Item at addr 0x1001000a0  :  Fraction 1/2   Next 0x100100090
Item at addr 0x100100090  :  Fraction 0/1   Next 0x100100080
Bas du stack 0x100100080
Popped : 1/4

Le tas (ou heap) joue un rôle très important dans les programmes C. Les données qui sont stockées dans cette zone de la mémoire sont accessibles depuis toute fonction qui possède un pointeur vers la zone correspondante

Note

Ne comptez jamais sur les free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] implicites

Un programmeur débutant qui expérimente avec malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] pourrait écrire le code ci-dessous et conclure que comme celui-ci s'exécute correctement, il n'est pas nécessaire d'utiliser free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html]. Lors de l'exécution d'un programme, le système d'exploitation réserve de la mémoire pour les différents segments du programme et ajuste si nécessaire cette allocation durant l'exécution du programme. Lorsque le programme se termine, via return dans la fonction main ou par un appel explicite à exit(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/exit.2.html], le système d'exploitation libère tous les segments utilisés par le programme, le text, les données, le tas et la pile. Cela implique que le système d'exploitation effectue un appel implicite à free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] à la terminaison d'un programme.

#define LEN 1024
int main(int argc, char *argv[]) {

  char *str=(char *) malloc(sizeof(char)*LEN);
  for(int i=0;i<LEN-1;i++) {
    *(str+i)='A';
  }
  *(str+LEN)='\0';  // fin de chaîne
  return(EXIT_SUCCESS);
}

Un programmeur ne doit cependant jamais compter sur cet appel implicite à free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html]. Ne pas libérer la mémoire lorsqu'elle n'est plus utilisée est un problème courant qui est généralement baptisé memory leak. Ce problème est particulièrement gênant pour les processus tels que les serveurs Internet qui ne se terminent pas ou des processus qui s'exécutent longtemps. Une petite erreur de programmation peut causer un memory leak qui peut après quelque temps consommer une grande partie de l'espace mémoire inutilement. Il est important d'être bien attentif à l'utilisation correcte de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et de free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] pour toutes les opérations d'allocation et de libération de la mémoire.

malloc(3) est la fonction d'allocation de mémoire la plus fréquemment utilisée 5. La librairie standard contient cependant d'autres fonctions permettant d'allouer de la mémoire mais aussi de modifier des allocations antérieures. calloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/calloc.3.html] est nettement moins utilisée que malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html]. Elle a pourtant un avantage majeur par rapport à malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] puisqu'elle initialise à zéro la zone de mémoire allouée. malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] se contente d'allouer la zone de mémoire mais n'effectue aucune initialisation. Cela permet à malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] d'être plus rapide, mais le programmeur ne doit jamais oublier qu'il ne peut pas utiliser malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] sans initialiser la zone mémoire allouée. Cela peut s'observer en pratique avec le programme ci-dessous. Il alloue une zone mémoire pour v1, l'initialise puis la libère. Ensuite, le programme alloue une nouvelle zone mémoire pour v2 et y retrouve les valeurs qu'il avait stocké pour v1 précédemment. En pratique, n'importe quelle valeur pourrait se trouver dans la zone retournée par malloc(3).

#define LEN 1024

int main(int argc, char *argv[]) {

  int *v1;
  int *v2;
  int sum=0;
  v1=(int *)malloc(sizeof(int)*LEN);
  for(int i=0;i<LEN;i++) {
    sum+=*(v1+i);
    *(v1+i)=1252;
  }
  printf("Somme des éléments de v1 : %d\n", sum);
  sum=0;
  free(v1);
  v2=(int *)malloc(sizeof(int)*LEN);
  for(int i=0;i<LEN;i++) {
    sum+=*(v2+i);
  }

  printf("Somme des éléments de v2 : %d\n", sum);
  free(v2);

  return(EXIT_SUCCESS);
}

L'exécution du programme ci-dessus affiche le résultat suivant sur la sortie standard. Ceci illustre bien que la fonction malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] n'initialise pas les zones de mémoire qu'elle alloue.

Somme des éléments de v1 : 0
Somme des éléments de v2 : 1282048

Lors de l'exécution du programme, on remarque que la première zone mémoire retournée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] a été initialisée à zéro. C'est souvent le cas en pratique pour des raisons de sécurité, mais ce serait une erreur de faire cette hypothèse dans un programme. Si la zone de mémoire doit être initialisée, la mémoire doit être allouée par calloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/calloc.3.html] ou via une initialisation explicite ou en utilisant des fonctions telles que bzero(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/bzero.3.html] ou memset(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/memset.3.html].

Les arguments et variables d'environnement

Lorsque le système d'exploitation charge un programme Unix en mémoire, il initialise dans le haut de la mémoire une zone qui contient deux types de variables. Cette zone contient tout d'abord les arguments qui ont été passés via la ligne de commande. Le système d'exploitation met dans argc le nombre d'arguments et place dans char *argv[] tous les arguments passés avec dans argv[0] le nom du programme qui est exécuté.

Cette zone contient également les variables d'environnement. Ces variables sont généralement relatives à la configuration du système. Leurs valeurs sont définies par l'administrateur système ou l'utilisateur. De nombreuses variables d'environnement sont utilisées dans les systèmes Unix. Elles servent à modifier le comportement de certains programmes. Une liste exhaustive de toutes les variables d'environnement est impossible, mais en voici quelques unes qui sont utiles en pratique 6:

HOSTNAME : le nom de la machine sur laquelle le programme s'exécute. Ce nom est fixé par l'administrateur système via la commande hostname(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/hostname.1.html]

SHELL : l'interpréteur de commande utilisé par défaut pour l'utilisateur courant. Cet interpréteur est lancé par le système au démarrage d'une session de l'utilisateur. Il est stocké dans le fichier des mots de passe et peut être modifié par l'utilisateur via la commande passwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/passwd.1.html]

USER : le nom de l'utilisateur courant. Sous Unix, chaque utilisateur est identifié par un numéro d'utilisateur et un nom uniques. Ces identifiants sont fixés par l'administrateur système via la commande passwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/passwd.1.html]

HOME: le répertoire d'accueil de l'utilisateur courant. Ce répertoire d'accueil appartient à l'utilisateur. C'est dans ce répertoire qu'il peut stocker tous ses fichiers.

PRINTER : le nom de l'imprimante par défaut qui est utilisée par la commande lp(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/lp.1posix.html]

PATH: cette variable d'environnement contient la liste ordonnée des répertoires que le système parcourt pour trouver un programme à exécuter. Cette liste contient généralement les répertoires dans lesquels le système stocke les exécutables standards, comme /usr/local/bin:/bin:/usr/bin:/usr/local/sbin:/usr/sbin:/sbin: ainsi que des répertoires relatifs à des programmes spécialisés comme /usr/lib/mozart/bin:/opt/python3/bin. L'utilisateur peut ajouter des répertoires à son PATH avec bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] en incluant par exemple la commande PATH=$PATH:$HOME/local/bin:. dans son fichier .profile. Cette commande ajoute au PATH par défaut le répertoire $HOME/local/bin et le répertoire courant. Par convention, Unix utilise le caractère . pour représenter ce répertoire courant.

La librairie standard contient plusieurs fonctions qui permettent de manipuler les variables d'environnement d'un processus. La fonction getenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getenv.3.html] permet de récupérer la valeur associée à une variable d'environnement. La fonction unsetenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/unsetenv.3.html] permet de supprimer une variable de l'environnement du programme courant. La fonction setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html] permet elle de modifier la valeur d'une variable d'environnement. Cette fonction alloue de la mémoire pour stocker la nouvelle variable d'environnement et peut échouer si il n'y a pas assez de mémoire disponible pour stocker de nouvelles variables d'environnement. Ces fonctions sont utilisées notamment par l'interpréteur de commande mais parfois par des programmes dont le comportement dépend de la valeur de certaines variables d'environnement. Par exemple, la commande man(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/man.1.html] utilise différentes variables d'environnement pour déterminer par exemple où les pages de manuel sont stockées et la langue (variable LANG) dans laquelle il faut afficher les pages de manuel.

Le programme ci-dessous illustre brièvement l'utilisation de getenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getenv.3.html], unsetenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/unsetenv.3.html] et setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html]. Outre ces fonctions, il existe également clearenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/clearenv.3.html] qui permet d'effacer complètement toutes les variables d'environnement du programme courant et putenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/putenv.3.html] qui était utilisé avant setenv(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/setenv.3.html].

#include <stdio.h>
#include <stdlib.h>

// affiche la valeur de la variable d'environnement var
void print_var(char *var) {
  char *val=getenv(var);
  if(val!=NULL)
    printf("La variable %s a la valeur : %s\n",var,val);
  else
    printf("La variable %s n'a pas été assignée\n",var);
}

int main(int argc, char *argv[]) {

  char *old_path=getenv("PATH");

  print_var("PATH");

  if(unsetenv("PATH")!=0) {
    fprintf(stderr,"Erreur unsetenv\n");
    exit(EXIT_FAILURE);
  }

  print_var("PATH");

  if(setenv("PATH",old_path,1)!=0) {
    fprintf(stderr,"Erreur setenv\n");
    exit(EXIT_FAILURE);
  }

  print_var("PATH");

  return(EXIT_SUCCESS);
}

La pile (ou stack)

La pile ou stack en anglais est la dernière zone de mémoire utilisée par un processus. C'est une zone très importante car c'est dans cette zone que le processus va stocker l'ensemble des variables locales mais également les valeurs de retour de toutes les fonctions qui sont appelées. Cette zone est gérée comme une pile, d'où son nom. Pour comprendre son fonctionnement, nous utiliserons le programme ./S3-src/fact.c qui permet de calculer une factorielle de façon récursive.

// retourne i*j
int times(int i, int j) {
  int m;
  m=i*j;
  printf("\t[times(%d,%d)] : return(%d)\n",i,j,m);
  return m;
}
// calcul récursif de factorielle
// n>0
int fact(int n) {
  printf("[fact(%d)]: Valeur de n:%d, adresse: %p\n",n,n,&n);
  int f;
  if(n==1) {
    printf("[fact(%d)]: return(1)\n",n);
    return(n);
  }
  printf("[fact(%d)]: appel à fact(%d)\n",n,n-1);
  f=fact(n-1);
  printf("[fact(%d)]: calcul de times(%d,%d)\n",n,n,f);
  f=times(n,f);
  printf("[fact(%d)]: return(%d)\n",n,f);
  return(f);
}

void compute() {
  int nombre=3;
  int f;
  printf("La fonction fact est à l'adresse : %p\n",fact);
  printf("La fonction times est à l'adresse : %p\n",times);
  printf("La variable nombre vaut %d et est à l'adresse %p\n",nombre,&nombre);
  f=fact(nombre);
  printf("La factorielle de %d vaut %d\n",nombre,f);
}

Lors de l'exécution de la fonction compute(), le programme ci-dessus produit la sortie suivante.

La fonction fact est à l'adresse : 0x100000a0f
La fonction times est à l'adresse : 0x1000009d8
La variable nombre vaut 3 et est à l'adresse 0x7fff5fbfe1ac
[fact(3)]: Valeur de n:3, adresse: 0x7fff5fbfe17c
[fact(3)]: appel à fact(2)
[fact(2)]: Valeur de n:2, adresse: 0x7fff5fbfe14c
[fact(2)]: appel à fact(1)
[fact(1)]: Valeur de n:1, adresse: 0x7fff5fbfe11c
[fact(1)]: return(1)
[fact(2)]: calcul de times(2,1)
	[times(2,1)] : return(2)
[fact(2)]: return(2)
[fact(3)]: calcul de times(3,2)
	[times(3,2)] : return(6)
[fact(3)]: return(6)
La factorielle de 3 vaut 6

Il est intéressant d'analyser en détails ce calcul récursif de la factorielle car il illustre bien le fonctionnement du stack et son utilisation.

Tout d'abord, il faut noter que les fonctions fact et times se trouvent, comme toutes les fonctions définies dans le programme, à l'intérieur du segment text. La variable nombre quant à elle se trouve sur la pile en haut de la mémoire. Il s'agit d'une variable locale qui est allouée lors de l'exécution de la fonction compute. Il en va de même des arguments qui sont passés aux fonctions. Ceux-ci sont également stockés sur la pile. C'est le cas par exemple de l'argument n de la fonction fact. Lors de l'exécution de l'appel à fact(3), la valeur 3 est stockée sur la pile pour permettre à la fonction fact d'y accéder. Ces accès sont relatifs au sommet de la pile comme nous aurons l'occasion de le voir dans la présentation du langage d'assemblage. Le premier appel récursif se fait en calculant la valeur de l'argument (2) et en appelant la fonction. L'argument est placé sur la pile, mais à une autre adresse que celle utilisée pour fact(3). Durant son exécution, la fonction fact(2) accède à ses variables locales sur la pile sans interférer avec les variables locales de l'exécution de fact(3) qui attend le résultat de fact(2). Lorsque fact(2) fait l'appel récursif, la valeur de son argument (1) est placée sur la pile et l'exécution de fact(1) démarre. Celle-ci a comme environnement d'exécution le sommet de la pile qui contient la valeur 1 comme argument et la fonction retourne la valeur 1 à l'exécution de fact(2) qui l'avait lancée. Dès la fin de fact(1), fact(2) reprend son exécution où elle avait été interrompue et applique la fonction times avec 2 et 1 comme arguments. Ces deux arguments sont placés sur le pile et times peut y accéder au début de son exécution pour calculer la valeur 2 et retourner le résultat à la fonction qui l'a appelé, c'est-à-dire fact(2). Cette dernière retrouve son environnement d'exécution sur la pile. Elle peut maintenant retourner son résultat à la fonction fact(3) qui l'avait appelée. Celle-ci va appeler la fonction times avec 3 et 2 comme arguments et finira par retourner la valeur 6.

La pile joue un rôle essentiel lors de l'exécution de programmes en C puisque toutes les variables locales, y compris celles de la fonction main y sont stockées. Comme nous le verrons lorsque nous aborderons le langage assembleur, la pile sert aussi à stocker l'adresse de retour des fonctions. C'est ce qui permet à l'exécution de fact(2) de se poursuivre correctement après avoir récupéré la valeur calculée par l'appel à fact(1). L'utilisation de la pile pour stocker les variables locales et les arguments de fonctions a une conséquence importante. Lorsqu'une variable est définie comme argument ou localement à une fonction f, elle n'est accessible que durant l'exécution de la fonction f. Avant l'exécution de f cette variable n'existe pas et si f appelle la fonction g, la variable définie dans f n'est plus accessible à partir de la fonction g.

En outre, comme le langage C utilise le passage par valeur, les valeurs des arguments d'une fonction sont copiés sur la pile avant de démarrer l'exécution de cette fonction. Lorsque la fonction prend comme argument un entier, cette copie prend un temps très faible. Par contre, lorsque la fonction prend comme argument une ou plusieurs structures de grand taille, celles-ci doivent être entièrement copiées sur la pile. A titre d'exemple, le programme ci-dessous définit une très grande structure contenant un entier et une zone permettant de stocker un million de caractères. Lors de l'appel à la fonction sum, les structures one et two sont entièrement copiées sur la pile. Comme chaque structure occupe plus d'un million d'octets, cela prend plusieurs centaines de microsecondes. Cette copie est nécessaire pour respecter le passage par valeur des structures à la fonction sum. Celle-ci ne peut pas modifier le contenu des structures qui lui sont passées en argument. Par comparaison, lors de l'appel à sumptr, seules les adresses de ces deux structures sont copiées sur la pile. Un appel à sumptr prend moins d'une microseconde, mais bien entendu la fonction sumptr a accès via les pointeurs passés en argument à toute la zone de mémoire qui leur est associée.

#define MILLION 1000000

struct large_t {
  int i;
  char str[MILLION];
};

int sum(struct large_t s1, struct large_t s2) {
  return (s1.i+s2.i);
}

int sumptr(struct large_t *s1, struct large_t *s2) {
  return (s1->i+s2->i);
}

int main(int argc, char *argv[]) {
  struct timeval tvStart, tvEnd;
  int err;
  int n;
  struct large_t one={1,"one"};
  struct large_t two={1,"two"};

  n=sum(one,two);
  n=sumptr(&one,&two);

Certaines variantes de Unix et certains compilateurs permettent l'allocation de mémoire sur la pile via la fonction alloca(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/alloca.3.html]. Contrairement à la mémoire allouée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] qui doit être explicitement libérée en utilisant free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html], la mémoire allouée par alloca(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/alloca.3.html] est libérée automatiquement à la fin de l'exécution de la fonction dans laquelle la mémoire a été allouée. Cette façon d'allouer de la mémoire sur la pile n'est pas portable et il est préférable de n'allouer de la mémoire que sur le tas en utilisant malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html].

Les versions récentes du C et notamment [C99] permettent d'allouer de façon dynamique un tableau sur la pile. Cette fonctionnalité peut être utile dans certains cas, mais elle peut aussi être la source de nombreuses erreurs et difficultés. Pour bien comprendre ce problème, considérons à nouveau la fonction duplicate qui a été définie précédemment en utilisant malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et des pointeurs.

#include <string.h>

char *duplicate(char * str) {
  int i;
  size_t len=strlen(str);
  char *ptr=(char *)malloc(sizeof(char)*(len+1));
  if(ptr!=NULL) {
    for(i=0;i<len+1;i++) {
	*(ptr+i)=*(str+i);
      }
  }
  return ptr;
}

Un étudiant pourrait vouloir éviter d'utiliser malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] et écrire plutôt la fonction suivante.

char *duplicate2(char * str) {
  int i;
  size_t len=strlen(str);
  char str2[len+1];
  for(i=0;i<len+1;i++) {
    str2[i]=*(str+i);
  }
  return str2;
}

Lors de la compilation, gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] affiche le warning In function ‘duplicate2’: warning: function returns address of local variable. Ce warning indique que la ligne return str2; retourne l'adresse d'une variable locale qui n'est plus accessible à la fin de la fonction duplicate2. En effet, l'utilisation de tableaux alloués dynamiquement sur la pile est équivalent à une utilisation implicite de alloca(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/alloca.3.html]. La déclaration char str2[len]; est équivalente à char *str2 =(char *)alloca(len*sizeof(char)); et la zone mémoire allouée sur la pile pour str2 est libérée lors de l'exécution de return str2; puisque toute mémoire allouée sur la pile est implicitement libérée à la fin de l'exécution de la fonction durant laquelle elle a été allouée. Donc, une fonction qui appelle duplicate2 ne peut pas récupérer les données se trouvant dans la zone mémoire qui a été allouée par duplicate2.

Footnotes

1

Pour des raisons de performance, le compilateur C ne génère pas de code permettant de vérifier automatiquement qu'un accès via un pointeur pointe vers une zone de mémoire qui est libre. Il est donc parfois possible d'accéder à une zone mémoire qui a été libérée, mais le programme n'a aucune garantie sur la valeur qu'il y trouvera. Ce genre d'accès à des zones mémoires libérées doit bien entendu être complètement proscrit.

2

Sur de nombreuses variantes de Unix, cette limite à la taille du stack dépend du matériel utilisé et peut être configurée par l'administrateur système. Un processus peut connaître la taille maximale de son stack en utilisant l'appel système getrlimit(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getrlimit.2.html]. L'administrateur système peut modifier ces limites via l'appel système setrlimit(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/setrlimit.2.html]. La commande ulimit de bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] permet également de manipuler ces limites.

3

Dans de nombreuses variantes de Unix, il est possible de connaître le sommet du segment text d'un processus grâce à la variable etext. Cette variable, de type char est initialisée par le système au chargement du processus. Elle doit être déclarée comme variable de type extern char etext et son adresse (&etext) correspond au sommet du segment text.

4

Nous verrons ultérieurement que grâce à l'utilisation de la mémoire virtuelle, il est possible pour un processus d'utiliser des zones de mémoire qui ne sont pas contiguës.

5

Il existe différentes alternatives à l'utilisation de malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] pour l'allocation de mémoire comme Hoard [http://hoard.org/] ou gperftools [https://github.com/gperftools/gperftools]

6

Il possible de lister les définitions actuelles des variables d'environnement via la commande printenv(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/printenv.1.html]. Les interpréteurs de commande tels que bash(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/bash.1.html] permettent de facilement modifier les valeurs de ces variables. La plupart d'entre elles sont initialisées par le système ou via les fichiers qui sont chargés automatiquement au démarrage de l'interpréteur comme /etc/profile qui contient les variables fixées par l'administrateur système ou le fichier .profile du répertoire d'accueil de l'utilisateur qui contient les variables d'environnement propres à cet utilisateur.

Gestion des utilisateurs

Unix est un système d'exploitation multi-utilisateurs. Un tel système impose des contraintes de sécurité qui n'existent pas sur un système mono-utilisateur. Il est intéressant de passer en revue quelques unes de ces contraintes :

il doit être possible d'identifier et/ou d'authentifier les utilisateurs du système

il doit être possible d'exécuter des processus appartenant à plusieurs utilisateurs simultanément et de déterminer quel utilisateur est responsable de chaque opération

le système d'exploitation doit fournir des mécanismes simples qui permettent de contrôler l'accès aux différentes ressources (mémoire, stockage, ...).

il doit être possible d'allouer certaines ressources à un utilisateur particulier à un moment donné

Aujourd'hui, la plupart des systèmes informatiques demandent une authentification de l'utilisateur sous la forme d'un mot de passe, d'une manipulation particulière voire d'une identification biométrique comme une empreinte digitale. Cette authentification permet de vérifier que l'utilisateur est autorisé à manipuler le système informatique. Cela n'a pas toujours été le cas et de nombreux systèmes informatiques plus anciens étaient conçus pour être utilisés par un seul utilisateur qui était simplement celui qui interagissait physiquement avec l'ordinateur.

Les systèmes Unix supportent différents mécanismes d'authentification. Le plus simple et le plus utilisé est l'authentification par mot de passe. Chaque utilisateur est identifié par un nom d'utilisateur et il doit prouver son identité en tapant son mot de passe au démarrage de toute session sur le système. En pratique, une session peut s'établir localement sur l'ordinateur via son interface graphique par exemple ou à distance en faisant tourner un serveur tel que sshd(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/sshd.8.html] sur le système Unix et en permettant aux utilisateurs de s'y connecter via Internet en utilisant un client ssh(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ssh.1.html]. Dans les deux cas, le système d'exploitation lance un processus login(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/login.1.html] qui permet de vérifier le nom d'utilisateur et le mot de passe fourni par l'utilisateur. Si le mot de passe correspond à celui qui est stocké sur le système, l'utilisateur est authentifié et son shell peut démarrer. Sinon, l'accès au système est refusé.

Lorsqu'un utilisateur se connecte sur un système Unix, il fournit son nom d'utilisateur ou username. Ce nom d'utilisateur est une chaîne de caractères qui est facile à mémoriser par l'utilisateur. D'un point de vue implémentation, un système d'exploitation préfère manipuler des nombres plutôt que des chaînes de caractères. Unix associe à chaque utilisateur un identifiant qui est stocké sous la forme d'un nombre entier positif. La table de correspondance entre l'identifiant d'utilisateur et le nom d'utilisateur est le fichier /etc/passwd. Ce fichier texte, comme la grande majorité des fichiers de configuration d'un système Unix, comprend pour chaque utilisateur l'information suivante :

nom d'utilisateur (username)

mot de passe (sur les anciennes versions de Unix)

identifiant de l'utilisateur (userid)

identifiant du groupe principal auquel l'utilisateur appartient

nom et prénom de l'utilisateur

répertoire de démarrage de l'utilisateur

shell de l'utilisateur

L'extrait ci-dessous présente un exemple de fichier /etc/passwd. Des détails complémentaires sont disponibles dans la page de manuel passwd(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/passwd.5.html]. Un utilisateur peut modifier les informations le concernant dans ce fichier avec la commande passwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/passwd.1.html].

# Exemple de /etc/passwd
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0:System Administrator:/var/root:/bin/sh
daemon:*:1:1:System Services:/var/root:/usr/bin/false
slampion:*:1252:1252:Séraphin Lampion:/home/slampion:/bin/bash

Il y a en pratique trois types d'utilisateurs sur un système Unix. L'utilisateur root est l'administrateur du système. C'est l'utilisateur qui a le droit de réaliser toutes les opérations sur le système. Il peut créer de nouveaux utilisateurs, mais aussi formatter les disques, arrêter le système, interrompre des processus utilisateurs ou accéder à l'ensemble des fichiers sans restriction. Par convention, cet utilisateur a l'identifiant 0. Ensuite, il y a tous les utilisateurs normaux du système Unix. Ceux-ci ont le droit d'accéder à leurs fichiers, d'interagir avec leurs processus mais en général ne peuvent pas manipuler les fichiers d'autres utilisateurs ou interrompre leurs processus. L'utilisateur slampion dans l'exemple ci-dessus est un utilisateur normal. Enfin, pour faciliter l'administration du système, certains systèmes Unix utilisent des utilisateurs qui correspondent à un service particulier comme l'utilisateur daemon dans l'exemple ci-dessus. Une discussion de ce type d'utilisateur sort du cadre de ces notes. Le lecteur intéressé pourra consulter une référence sur l'administration des système Unix telle que [AdelsteinLubanovic2007] ou [Nemeth+2010].

Unix associe à chaque processus un identifiant d'utilisateur. Cet identifiant est stocké dans l'entrée du processus dans la table des processus. Un processus peut récupérer son identifiant d'utilisateur via l'appel système getuid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getuid.2.html]. Outre cet appel système, il existe également l'appel système setuid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/setuid.2.html] qui permet de modifier le userid du processus en cours d'exécution. Pour des raisons évidentes de sécurité, seul un processus appartenant à l'administrateur système (root) peut exécuter cet appel système. C'est le cas par exemple du processus login(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/login.1.html] qui appartient initialement à root puis exécute setuid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/setuid.2.html] afin d'appartenir à l'utilisateur authentifié puis exécute execve(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/execve.2.html] pour lancer le premier shell appartenant à l'utilisateur.

En pratique, il est parfois utile d'associer des droits d'accès à des groupes d'utilisateurs plutôt qu'à un utilisateur particulier. Par exemple, un département universitaire peut avoir un groupe correspondant à tous les étudiants et un autre aux membres du staff pour leur donner des permissions différentes. Un utilisateur peut appartenir à un groupe principal et plusieurs groupes secondaires. Le groupe principal est spécifié dans le fichier passwd(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/passwd.5.html] tandis que le fichier /etc/group décrit dans group(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/group.5.html] contient les groupes secondaires.

Systèmes de fichiers

Outre un processeur et une mémoire, la plupart des ordinateurs actuels sont en général équipés d'un ou plusieurs dispositifs de stockage. Les dispositifs les plus courants sont le disque dur, le lecteur de CD/DVD, la clé USB, la carte mémoire, ... Ces dispositifs de stockage ont des caractéristiques techniques très différentes. Certains stockent l'information sous forme magnétique, d'autres sous forme électrique ou en creusant via un laser des trous dans un support physique. D'un point de vue logique, ils offrent tous une interface très similaire au système d'exploitation qui veut les utiliser.

Dans un système de fichiers Unix, l'ensemble des répertoires et fichiers est organisé sous la forme d'un arbre. La racine de cet arbre est le répertoire /. Il est localisé sur un des dispositifs de stockage du système. Le système de fichiers Unix permet d'intégrer facilement des systèmes de fichiers qui se trouvent sur différents dispositifs de stockage. Cette opération est en général réalisée par l'administrateur système en utilisant la commande mount(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/mount.8.html]. A titre d'exemple, voici quelques répertoires qui sont montés sur un système Linux.

$ df -k
Filesystem           1K-blocks      Used Available Use% Mounted on
/dev/sda1             15116836   9425020   4923912  66% /
tmpfs                  1559516      4724   1554792   1% /dev/shm
/dev/sda2             19840924   4374616  14442168  24% /xendomains
xenstore               1972388        40   1972348   1% /var/lib/xenstored
david:/mnt/student   258547072 188106176  59935296  76% /etinfo/users
david:/mnt/staff     254696800 177422400  64136160  74% /etinfo/users2

Dans l'exemple ci-dessus, la première colonne correspond au dispositif de stockage qui contient les fichiers et répertoires. Le premier dispositif de stockage /dev/sda1/ est un disque local et contient le répertoire racine du système de fichiers. Le système de fichiers david://mnt/student est stocké sur le serveur david et est monté via mount(8) [https://sites.uclouvain.be/SystInfo/manpages/man8/mount.8.html] dans le répertoire /etinfo/users. Ainsi, tout accès à un fichier dans le répertoire /etinfo/users se fera via le serveur david.

Chaque répertoire du système de fichiers contient un ou plusieurs répertoires et un ou plusieurs fichiers. A titre d'exemple, il est intéressant de regarder le contenu de deux répertoires. Le premier est un extrait au contenu du répertoire racine obtenu avec la commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html]

$ ls -la /
dr-xr-xr-x.  26 root root 233472 Feb 23 03:18 .
dr-xr-xr-x.  26 root root 233472 Feb 23 03:18 ..
-rw-r--r--    1 root root      0 Feb 13 16:45 .autofsck
-rw-r--r--    1 root root      0 Jul 27  2011 .autorelabel
dr-xr-xr-x.   4 root root  4096 Dec 15 05:50 boot
drwxr-xr-x   19 root root  4160 Mar 22 12:04 dev
drwxr-xr-x. 125 root root 12288 Mar 22 12:04 etc
drwxr-xr-x    4 root root     0 Mar 22 10:22 etinfo
drwxr-xr-x.   2 root root  4096 Jan  6  2011 home
dr-xr-xr-x.  14 root root  4096 Mar 22 03:26 lib
dr-xr-xr-x.  10 root root 12288 Mar 22 03:26 lib64
drwx------.   2 root root 16384 Jul 27  2011 lost+found
...
drwxrwxrwt. 104 root root  4096 Mar 22 12:05 tmp
drwxr-xr-x.  13 root root  4096 Jul 19  2011 usr
drwxr-xr-x.  23 root root  4096 Jul 27  2011 var

Le répertoire racine contient quelques fichiers et des répertoires. Tout répertoire contient deux répertoires spéciaux. Le premier répertoire, identifié par le caractère . (un seul point) est un alias vers le répertoire lui-même. Cette entrée de répertoire est présente dans chaque répertoire dès qu'il est créé avec une commande telle que mkdir(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/mkdir.1.html]. Le deuxième répertoire spécial est .. (deux points consécutifs). Ce répertoire est un alias vers le répertoire parent du répertoire courant.

Les méta-données qui sont associées à chaque fichier ou répertoire contiennent, outre les informations de type, les bits de permission. Ceux-ci permettent d'encoder trois types de permissions et d'autorisation :

r : autorisation de lecture

w : autorisation d'écriture ou de modification

x : autorisation d'exécution

Ces bits de permissions sont regroupés en trois blocs. Le premier bloc correspond aux bits de permission qui sont applicables pour les accès qui sont effectués par un processus qui appartient à l'utilisateur qui est propriétaire du fichier/répertoire. Le deuxième bloc correspond aux bits de permission qui sont applicables pour les opérations effectuées par un processus dont l'identifiant de groupe est identique à l'identifiant de groupe du fichier/répertoire mais n'appartient pas à l'utilisateur qui est propriétaire du fichier/répertoire. Le dernier bloc est applicable pour les opérations effectuées par des processus qui appartiennent à d'autres utilisateurs.

Les valeurs de ces bits sont représentés pas les symboles rwx dans l'output de la commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html]. Les bits de permission peuvent être modifiés en utilisant la commande chmod(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chmod.1.html] qui utilise l'appel système chmod(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chmod.2.html]. Pour qu'un exécutable puisse être exécuté via l'appel système execve(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/execve.2.html], il est nécessaire que le fichier correspondant possède les bits de permission r et x.

Note

Manipulation des bits de permission avec chmod(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chmod.2.html]

L'appel système chmod(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chmod.2.html] permet de modifier les bits de permission qui sont associés à un fichier. Ceux-ci sont encodés sous la forme d'un entier sur 16 bits.

S_IRUSR (00400) : permission de lecture par le propriétaire

S_IWUSR (00200) : permission d'écriture par le propriétaire

S_IXUSR (00100) : permission d'exécution par le propriétaire

S_IRGRP (00040) : permission de lecture par le groupe hormis le propriétaire

S_IWGRP (00020) : permission d'écriture par le groupe hormis le propriétaire

S_IXGRP (00010) : permission d'exécution par le groupe hormis le propriétaire

S_IROTH (00004) : permission de lecture par tout utilisateur hormis le propriétaire et son groupe

S_IWOTH (00002) : permission d'écriture par tout utilisateur hormis le propriétaire et son groupe

S_IXOTH (00001) : permission d'exécution par tout utilisateur hormis le propriétaire et son groupe

#include <sys/stat.h>
int chmod(const char *path, mode_t mode);

Ces bits de permissions sont généralement spécifiés soit sous la forme d'une disjonction logique ou sous forme numérique. A titre d'exemple, un fichier qui peut être lu et écrit uniquement par son propriétaire aura comme permissions 00600 ou S_IRUSR|S_IWUSR.

Le nibble de poids fort des bits de permission sert à encoder des permissions particulières relatives aux fichiers et répertoires. Par exemple, lorsque la permission S_ISUID (04000) est associée à un exécutable, elle indique que celui-ci doit s'exécuter avec les permissions du propriétaire de l'exécutable et pas les permissions de l'utilisateur. Cette permission spéciale est utilisée par des programmes comme passwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/passwd.1.html] qui doivent disposer des permissions de l'administrateur système pour s'exécuter correctement (passwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/passwd.1.html] doit modifier le fichier passwd(5) [https://sites.uclouvain.be/SystInfo/manpages/man5/passwd.5.html] qui appartient à l'administrateur système).

Les exemples ci-dessous présentent le contenu partiel d'un répertoire.

$ ls -lai /etinfo/users/obo
total 1584396
drwx------ 78 obo  stafinfo       4096 Mar 17 00:34 .
drwxr-xr-x 93 root root           4096 Feb 22 11:37 ..
-rwxr-xr-x  1 obo  stafinfo      11490 Feb 28 00:43 a.out
-rw-------  1 obo  stafinfo       4055 Mar 22 15:13 .bash_history
-rw-r--r--  1 obo  stafinfo         55 Sep 18  1995 .bash_profile
-rw-r--r--  1 obo  stafinfo        101 Aug 28  2003 .bashrc
drwxr-xr-x  2 obo  stafinfo       4096 Nov 22  2004 bin
-rw-r--r--  1 obo  stafinfo        346 Feb 13 15:37 hello.c
drwxr-xr-x  3 obo  stafinfo       4096 Mar  2 09:30 sinf1252
drwxr-xr-x  2 obo  stafinfo       4096 May 17  2011 src

Dans un système Unix, que ce soit au niveau du shell ou dans n'importe quel processus écrit par exemple en langage C, les fichiers peuvent être spécifiés de deux façons. La première est d'indiquer le chemin complet depuis la racine qui permet d'accéder au fichier. Le chemin /etinfo/users/obo passé comme argument à la commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html] ci-dessus en est un exemple. Le premier caractère / correspond à la racine du système de fichiers et ensuite ce caractère est utilisé comme séparateur entre les répertoires successifs. Ainsi, le fichier /etinfo/users/obo/hello.c est un fichier qui a comme nom hello.c qui se trouve dans un répertoire nommé obo qui lui-même se trouve dans le répertoire users qui est dans le répertoire baptisé etinfo dans le répertoire racine. La seconde façon de spécifier un nom de fichier est de préciser son nom relatif. Pour éviter de forcer l'utilisateur à spécifier chaque fois le nom complet des fichiers et répertoires auxquels il veut accéder, le noyau maintient dans sa table des processus le répertoire courant de chaque processus. Par défaut, lorsqu'un processus est lancé, son répertoire courant est le répertoire à partir duquel le programme a été lancé. Ainsi, lorsque l'utilisateur tape une commande comme gcc hello.c depuis son shell, le processus gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] peut directement accéder au fichier hello.c qui se situe dans le répertoire courant. Un processus peut modifier son répertoire courant en utilisant l'appel système chdir(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chdir.2.html].

#include <unistd.h>

int chdir(const char *path);

Cet appel système prend comme argument une chaîne de caractères contenant le nom du nouveau répertoire courant. Ce nom peut être soit un nom complet (commençant par /), ou un nom relatif au répertoire courant actuel. Dans ce cas, il est parfois utile de pouvoir référer au répertoire parent du répertoire courant. Cela se fait en utilisant ... Dans chaque répertoire, cet alias correspond au répertoire parent. Ainsi, si le répertoire courant est /etinfo/users, alors le répertoire ../../bin est le répertoire bin se trouvant dans le répertoire racine. Depuis le shell, il est possible de modifier le répertoire courant avec la commande cd(1posix) [https://sites.uclouvain.be/SystInfo/manpages/man1/cd.1posix.html]. La commande pwd(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/pwd.1.html] affiche le répertoire courant actuel.

Il existe plusieurs appels systèmes et fonctions de la librairie standard qui permettent de parcourir le système de fichiers. Les principaux sont :

l'appel système stat(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/stat.2.html] permet de récupérer les méta-données qui sont associées à un fichier ou un répertoire. La commande stat(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/stat.1.html] fournit des fonctionnalités similaires depuis le shell.

les appels systèmes chmod(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chmod.2.html] et chown(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chown.2.html] permettent de modifier respectivement le mode (i.e. les permissions), le propriétaire et le groupe associés à un fichier. Les commandes chmod(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chmod.1.html], chown(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chown.1.html] et chgrp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/chgrp.1.html] permettent de faire de même depuis le shell.

l'appel système utime(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/utime.2.html] permet de modifier les dates de création/modification associées à un fichier/répertoire. Cet appel système est utilisé par la commande touch(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/touch.1.html]

l'appel système rename(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/rename.2.html] permet de changer le nom d'un fichier ou d'un répertoire. Il est utilisé notamment par la commande rename(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/rename.1.html]

l'appel système mkdir(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mkdir.2.html] permet de créer un répertoire alors que l'appel système rmdir(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/rmdir.2.html] permet d'en supprimer un

les fonctions de la librairie opendir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/opendir.3.html], closedir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/closedir.3.html], et readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] permettent de consulter le contenu de répertoires.

Les fonctions de manipulation des répertoires méritent que l'on s'y attarde un peu. Un répertoire est un fichier qui a une structure spéciale. Ces trois fonctions permettent d'en extraire de l'information en respectant le format d'un répertoire. Pour accéder à un répertoire, il faut d'abord l'ouvrir en utilisant opendir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/opendir.3.html]. La fonction readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] permet d'accéder aux différentes entrées de ce répertoire et closedir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/closedir.3.html] doit être utilisée lorsque l'accès n'est plus nécessaire. La fonction readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] permet de manipuler la structure dirent qui est définie dans bits/dirent.h [https://sites.uclouvain.be/SystInfo/usr/include/bits/dirent.h.html].

struct dirent {
     ino_t          d_ino;       /* inode number */
     off_t          d_off;       /* offset to the next dirent */
     unsigned short d_reclen;    /* length of this record */
     unsigned char  d_type;      /* type of file; not supported
                                    by all file system types */
     char           d_name[256]; /* filename */
 };

Cette structure comprend le numéro de l'inode, c'est-à-dire la métadonnée qui contient les informations relatives au fichier/répertoire, la position de l'entrée dirent qui suite, la longueur de l'entrée, son type et le nom de l'entrée dans le répertoire. Chaque appel à readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] retourne un pointeur vers une structure de ce type.

L'extrait de code ci-dessous permet de lister tous les fichiers présents dans le répertoire name.

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <dirent.h>

void exit_on_error(char *s) {
  perror(s);
  exit(EXIT_FAILURE);
}

int main (int argc, char *argv[])  {

  DIR *dirp;
  struct dirent *dp;
  char name[]=".";
  dirp = opendir(name);
  if(dirp==NULL) {
    exit_on_error("opendir");
  }
  while ((dp = readdir(dirp)) != NULL) {
    printf("%s\n",dp->d_name);
  }
  int err = closedir(dirp);
  if(err<0) {
    exit_on_error("closedir");
  }

}

La lecture d'un répertoire avec readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] commence au début de ce répertoire. A chaque appel à readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html], le programme appelant récupère un pointeur vers une zone mémoire contenant une structure dirent avec l'entrée suivante du répertoire ou NULL lorsque la fin du répertoire est atteinte. Si une fonction doit relire à nouveau un répertoire, cela peut se faire en utilisant seekdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/seekdir.3.html] ou rewinddir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/rewinddir.3.html].

Note

readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] et les threads

La fonction readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html] est un exemple de fonction non-réentrante qu'il faut éviter d'utiliser dans une application dont plusieurs threads doivent pouvoir parcourir le même répertoire. Ce problème est causé par l'utilisation d'une zone de mémoire static afin de stocker la structure dont le pointeur est retourné par readdir(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir.3.html]. Dans une application utilisant plusieurs threads, il faut utiliser la fonction readdir_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir_r.3.html] :

int  readdir_r(DIR *restrict dirp, struct dirent *restrict entry,
               struct dirent **restrict result);

Cette fonction prend comme arguments le pointeur entry vers un buffer propre à l'appelant qui permet de stocker le résultat de readdir_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/readdir_r.3.html].

Les appels systèmes link(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/link.2.html] et unlink(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/unlink.2.html] sont un peu particuliers et méritent une description plus détaillée. Sous Unix, un inode est associé à chaque fichier mais l'inode ne contient pas le nom de fichier parmi les méta-données qu'il stocke. Par contre, chaque inode contient un compteur (nlinks) du nombre de liens vers un fichier. Cela permet d'avoir une seule copie d'un fichier qui est accessible depuis plusieurs répertoires. Pour comprendre cette utilisation des liens sur un système de fichiers Unix, considérons le scénario suivant.

$ mkdir a
$ mkdir b
$ cd a
$ echo "test" > test.txt
$ cd ..
$ ln a/test.txt a/test2.txt
$ ls -li a
total 16
9624126 -rw-r--r--  2 obo  stafinfo  5 24 mar 21:14 test.txt
9624126 -rw-r--r--  2 obo  stafinfo  5 24 mar 21:14 test2.txt
$ ln a/test.txt b/test3.txt
$ stat --format "inode=%i nlinks=%h" b/test3.txt
inode=9624126 nlinks=3
$ ls -li b
total 8
9624126 -rw-r--r--  3 obo  stafinfo  5 24 mar 21:14 test3.txt
$ echo "complement" >> b/test3.txt
$ ls -li a
total 16
9624126 -rw-r--r--  3 obo  stafinfo  16 24 mar 21:15 test.txt
9624126 -rw-r--r--  3 obo  stafinfo  16 24 mar 21:15 test2.txt
$ ls -li b
total 8
9624126 -rw-r--r--  3 obo  stafinfo  16 24 mar 21:15 test3.txt
$ cat b/test3.txt
test
complement
$ cat a/test.txt
test
complement
$ rm a/test2.txt
$ ls -li a
total 8
9624126 -rw-r--r--  2 obo  stafinfo  16 24 mar 21:15 test.txt
$ rm a/test.txt
$ ls -li a
$ ls -li b
total 8
9624126 -rw-r--r--  1 obo  stafinfo  16 24 mar 21:15 test3.txt

Dans ce scénario, deux répertoires sont créés avec la commande mkdir(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/mkdir.1.html]. Ensuite, la commande echo(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/echo.1.html] est utilisée pour créer le fichier test.txt contenant la chaîne de caractères test dans le répertoire a. Ce fichier est associé à l'inode 9624126. La commande ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] permet de rendre ce fichier accessible sous un autre nom depuis le même répertoire. La sortie produite par la commande ls(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ls.1.html] indique que ces deux fichiers qui sont présents dans le répertoire a ont tous les deux le même inode. Ils correspondent donc aux mêmes données sur le disque. A ce moment, le compteur nlinks de l'inode 9624126 a la valeur 2. La commande ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] peut être utilisée pour créer un lien vers un fichier qui se trouve dans un autre répertoire 4 comme le montre la création du fichier test3.txt dans le répertoire b. Ces trois fichiers correspondant au même inode, toute modification à l'un des fichiers affecte et est visible dans n'importe lequel des liens vers ce fichier. C'est ce que l'on voit lorsque la commande echo "complement" >> b/test3.txt est exécutée. Cette commande affecte immédiatement les trois fichiers. La commande rm a/test2.txt efface la référence du fichier sous le nom a/test2.txt, mais les deux autres liens restent accessibles. Le fichier ne sera réellement effacé qu'après que le dernier lien vers l'inode correspondant aie été supprimé. La commande rm(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/rm.1.html] utilise en pratique l'appel système unlink(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/unlink.2.html] qui en toute généralité décrémente le compteur de liens de l'inode correspondant au fichier et l'efface lorsque ce compteur atteint la valeur 0.

Une description détaillée du fonctionnement de ces appels systèmes et fonctions de la librairie standard peut se trouver dans les livres de référence sur la programmation en C sous Unix [Kerrisk2010], [Mitchell+2001], [StevensRago2008].

Utilisation des fichiers

Si quelques processus manipulent le système de fichiers et parcourent les répertoires, les processus qui utilisent des données sauvegardées dans des fichiers sont encore plus nombreux. Un système Unix offre deux possibilités d'écrire et de lire dans un fichier. La première utilise directement les appels systèmes open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html], read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html]/ write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] et close(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/close.2.html]. La seconde s'appuie sur les fonctions fopen(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/fopen.3.html], fread(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/fread.3.html]/ fwrite(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/fwrite.3.html] et fclose(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/fclose.3.html] de la librairie stdio(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/stdio.3.html]. Seuls les appels systèmes sont traités dans ce cours. Des détails complémentaires sur les fonctions de la libraire peuvent être obtenus dans [Kerrisk2010], [Mitchell+2001] ou [StevensRago2008].

Du point de vue des appels systèmes de manipulation des fichiers, un fichier est une séquence d'octets. Avant qu'un processus ne puisse écrire ou lire dans un fichier, il doit d'abord demander au système d'exploitation l'autorisation d'accéder au fichier. Cela se fait en utilisant l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html].

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char *pathname, int flags);
int open(const char* pathname, int flags, mode_t mode);

Il existe deux variantes de l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html]. La première permet d'ouvrir des fichiers existants. Elle prend deux arguments. La deuxième permet de créer un nouveau fichier et l'ouvre ensuite. Elle prend trois arguments. Le premier argument est le nom absolu ou relatif du fichier dont l'ouverture est demandée. Le deuxième argument est un entier qui contient un ensemble de drapeaux binaires qui précisent la façon dont le fichier doit être ouvert. Ces drapeaux sont divisés en deux groupes. Le premier groupe est relatif à l'accès en lecture et/ou en écriture du fichier. Lors de l'ouverture d'un fichier avec open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html], il est nécessaire de spécifier l'un des trois drapeaux d'accès suivants :

O_RDONLY : indique que le fichier est ouvert uniquement en lecture. Aucune opération d'écriture ne sera effectuée sur le fichier.

O_WRONLY : indique que le fichier est ouvert uniquement en écriture. Aucune opération de lecture ne sera effectuée sur le fichier.

O_RDWR : indique que le fichier est ouvert pour des opérations de lecture et d'écriture.

En plus de l'un des trois drapeaux ci-dessus, il est également possible de spécifier un ou plusieurs drapeaux optionnels. Ces drapeaux sont décrits en détails dans la page de manuel open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html]. Les plus utiles sont probablement :

O_CREAT : indique que si le fichier n'existe pas, il doit être créé lors de l'exécution de l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html]. L'appel système creat(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/creat.2.html] peut également être utilisé pour créer un nouveau fichier. Lorsque le drapeau O_CREAT est spécifié, l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] prend comme troisième argument les permissions du fichier qui doit être créé. Celles-ci sont spécifiées de la même façon que pour l'appel système chmod(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/chmod.2.html]. Si elles ne sont pas spécifiées, le fichier est ouvert avec comme permissions les permissions par défaut du processus définies par l'appel système umask(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/umask.2.html]

O_APPEND : indique que le fichier est ouvert de façon à ce que les données écrites dans le fichier par l'appel système write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] s'ajoutent à la fin du fichier.

O_TRUNC : indique que si le fichier existe déjà et qu'il est ouvert en écriture, alors le contenu du fichier doit être supprimé avant que le processus ne commence à y accéder.

O_SYNC : ce drapeau indique que toutes les opérations d'écriture sur le fichier doivent être effectuées immédiatement sur le dispositif de stockage sans être mises en attente dans les buffers du noyau du système d'exploitation .. - O_CLOEXEC : ce drapeau qui est spécifique à Linux indique que le fichier doit être automatiquement fermé lors de l'exécution de l'appel système execve(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/execve.2.html]. Normalement, les fichiers qui ont été ouverts par open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] restent ouverts lors de l'exécution de execve(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/execve.2.html].

Ces différents drapeaux binaires doivent être combinés en utilisant une disjonction logique entre les différents drapeaux. Ainsi, O_CREAT|O_RDWR correspond à l'ouverture d'un fichier qui doit à la fois être créé si il n'existe pas et ouvert en lecture et écriture.

Lors de l'exécution de open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html], le noyau du système d'exploitation vérifie si le processus qui exécute l'appel système dispose des permissions suffisantes pour accéder au fichier. Si oui, le système d'exploitation ouvre le fichier et retourne au processus appelant le descripteur de fichier correspondant. Si non, le processus récupère une valeur de retour négative et errno indique le type d'erreur.

Sous Unix, un descripteur de fichier est représenté sous la forme d'un entier positif. L'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] retourne toujours le plus petit descripteur de fichier disponible. Par convention,

0 est le descripteur de fichier correspondant à l'entrée standard.

1 est le descripteur de fichier correspondant à la sortie standard.

2 est le descripteur de fichier correspondant à la sortie d'erreur standard.

Si l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] échoue, il retourne -1 comme descripteur de fichier et errno donne plus de précisions sur le type d'erreur. Il peut s'agir d'une erreur liée aux droits d'accès au fichier (EACESS), une erreur de drapeau (EINVAL) ou d'une erreur d'entrée sortie lors de l'accès au dispositif de stockage (EIO). Le noyau du système d'exploitation maintient une table de l'ensemble des fichiers qui sont ouverts par tous les processus actifs. Si cette table est remplie, il n'est plus possible d'ouvrir de nouveau fichier et open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] retourne une erreur. Il en va de même si le processus tente d'ouvrir plus de fichiers que le nombre maximum de fichiers ouverts qui est autorisé.

Note

Seul open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] vérifie les permissions d'accès aux fichiers

Sous Unix, seul l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] vérifie qu'un processus dispose des permissions suffisantes pour accéder à un fichier qui est ouvert. Si les permissions ou le propriétaire d'un fichier change alors que ce fichier est ouvert par un processus, ce processus continue à pouvoir y accéder sans être affecté par la modification de droits. Il en va de même lorsqu'un fichier est effacé avec l'appel système unlink(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/unlink.2.html]. Si un processus utilisait le fichier qui est effacé, il continue à pouvoir l'utiliser même si le fichier n'apparaît plus dans le répertoire.

Toutes les opérations qui sont faites sur un fichier se font en utilisant le descripteur de fichier comme référence au fichier. Un descripteur de fichier est une ressource limitée dans un système d'exploitation tel que Unix et il est important qu'un processus n'ouvre pas inutilement un grand nombre de fichiers 5 et ferme correctement les fichiers ouverts lorsqu'il ne doit plus y accéder. Cela se fait en utilisant l'appel système close(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/close.2.html]. Celui-ci prend comme argument le descripteur de fichier qui doit être fermé.

#include <unistd.h>

int close(int fd);

Tout processus doit correctement fermer tous les fichiers qu'il a utilisé. Par défaut, le système d'exploitation ferme automatiquement les descripteurs de fichiers correspondant 0, 1 et 2 lorsqu'un processus se termine. Les autres descripteurs de fichiers doivent être explicitement fermés par le processus. Si nécessaire, cela peut se faire en enregistrant une fonction permettant de fermer correctement les fichiers ouverts via atexit(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/atexit.3.html]. Il faut noter que par défaut un appel à execve(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/execve.2.html] ne ferme pas les descripteurs de fichiers ouverts par le processus. C'est nécessaire pour permettre au programme exécuté d'avoir les entrées et sorties standard voulues.

Lorsqu'un fichier a été ouvert, le noyau du système d'exploitation maintient un offset pointer. Cet offset pointer est la position actuelle de la tête de lecture/écriture du fichier. Lorsqu'un fichier est ouvert, son offset pointer est positionné au premier octet du fichier, sauf si le drapeau O_APPEND a été spécifié lors de l'ouverture du fichier, dans ce cas l'offset pointer est positionné juste après le dernier octet du fichier de façon à ce qu'une écriture s'ajoute à la suite du fichier.

Les deux appels systèmes permettant de lire et d'écrire dans un fichier sont respectivement read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] et write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html].

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

Ces deux appels systèmes prennent trois arguments. Le premier est le descripteur du fichier sur lequel l'opération doit être effectuée. Le second est un pointeur void * vers la zone mémoire à lire ou écrire et le dernier est la quantité de données à lire/écrire. Si l'appel système réussit, il retourne le nombre d'octets qui ont été écrits/lus et sinon une valeur négative et la variable errno donne plus de précisions sur le type d'erreur. read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] retourne 0 lorsque la fin du fichier a été atteinte.

Il est important de noter que read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] et write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] permettent de lire et d'écrire des séquences contiguës d'octets. Lorsque l'on écrit ou lit des chaînes de caractères dans lesquels chaque caractère est représenté sous la forme d'un byte, il est possible d'utiliser read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] et write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] pour lire et écrire d'autres types de données que des octets comme le montre l'exemple ci-dessous.

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

void exit_on_error(char *s) {
  perror(s);
  exit(EXIT_FAILURE);
}

int main (int argc, char *argv[])  {
  int n=1252;
  int n2;
  short ns=1252;
  short ns2;
  long nl=125212521252;
  long nl2;
  float f=12.52;
  float f2;
  char *s="sinf1252";
  char *s2=(char *) malloc(strlen(s)*sizeof(char )+1);
  int err;
  int fd;

  fd=open("test.dat",O_WRONLY|O_CREAT|O_TRUNC, S_IRUSR|S_IWUSR);
  if(fd==-1) {
    perror("open");
    exit(EXIT_FAILURE);
  }
  if( write(fd, (void *) s, strlen(s)) == -1 )
    exit_on_error("write s");
  if (write(fd, (void *) &n, sizeof(int )) == -1)
    exit_on_error("write n");
  if (write(fd, (void *) &ns, sizeof(short int))==-1)
    exit_on_error("write ns");
  if (write(fd, (void *) &nl, sizeof(long int))==-1)
    exit_on_error("write nl");
  if (write(fd, (void *) &f, sizeof(float))==-1)
    exit_on_error("write f");
  if (close(fd)==-1)
    exit_on_error("close ");

  // lecture
  fd=open("test.dat",O_RDONLY);
  if(fd==-1) {
    perror("open");
    exit(EXIT_FAILURE);
  }
  printf("Fichier ouvert\n");

  if(read(fd, (void *) s2, strlen(s))==-1)
    exit_on_error("read s");
  printf("Donnée écrite : %s, lue: %s\n",s,s2);

  if(read(fd, (void *) &n2, sizeof(int))==-1)
    exit_on_error("read n");
  printf("Donnée écrite : %d, lue: %d\n",n,n2);

  if(read(fd, (void *) &ns2, sizeof(short))==-1)
    exit_on_error("read ns");
  printf("Donnée écrite : %d, lue: %d\n",ns,ns2);

  if(read(fd, (void *) &nl2, sizeof(long))==-1)
    exit_on_error("read nl");
  printf("Donnée écrite : %ld, lue: %ld\n",nl,nl2);

  if(read(fd, (void *) &f2, sizeof(float))==-1)
    exit_on_error("read f");
  printf("Donnée écrite : %f, lue: %f\n",f,f2);
  err=close(fd);
  if(err==-1){
   perror("close");
    exit(EXIT_FAILURE);
  }

  return(EXIT_SUCCESS);
}

Lors de son exécution, ce programme affiche la sortie ci-dessous.

Fichier ouvert
Donnée écrite : sinf1252, lue: sinf1252
Donnée écrite : 1252, lue: 1252
Donnée écrite : 1252, lue: 1252
Donnée écrite : 125212521252, lue: 125212521252
Donnée écrite : 12.520000, lue: 12.520000

Si il est bien possible de sauvegarder dans un fichier des entiers, des nombres en virgule flottante voire même des structures, il faut être bien conscient que l'appel système write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] se contente de sauvegarder sur le disque le contenu de la zone mémoire pointée par le pointeur qu'il a reçu comme second argument. Si comme dans l'exemple précédent c'est le même processus qui lit les données qu'il a écrit, il pourra toujours récupérer les données correctement.

Par contre, lorsqu'un fichier est écrit sur un ordinateur, envoyé via Internet et lu sur un autre ordinateur, il peut se produire plusieurs problèmes dont il faut être conscient. Le premier problème est que deux ordinateurs différents n'utilisent pas nécessairement le même nombre d'octets pour représenter chaque type de données. Ainsi, sur un ordinateur équipé d'un ancien processeur [IA32], les entiers sont représentés sur 32 bits (i.e. 4 bytes) alors que sur les processeurs plus récents ils sont souvent représentés sur 64 bits (i.e. 8 bytes). Cela implique qu'un tableau de 100 entiers en 32 bits sera interprété comme un tableau de 50 entiers en 64 bits.

Le second problème est que les fabricants de processeurs ne se sont pas mis d'accord sur la façon dont il fallait représenter les entiers sur 16 et 32 bits en mémoire. Il y a deux techniques qui sont utilisées : big endian et little endian.

Pour comprendre ces deux techniques, regardons comment l'entier 16 bits 0b1111111100000000 est stocké en mémoire. En big endian, le byte 11111111 sera stocké à l'adresse x et le byte 00000000 à l'adresse x+1. En little endian, c'est le byte 00000000 qui est stocké à l'adresse x et le byte 11111111 qui est stocké à l'adresse x+1. Il en va de même pour les entiers encodés sur 32 bits comme illustré dans les deux figures ci-dessous 6.

Ecriture d'un entier 32 bits en mémoire en big endian

Ecriture d'un entier 32 bits en mémoire en little endian

Pour les nombres en virgule flottante, ce problème ne se pose heureusement pas car tous les processeurs actuels utilisent la même norme pour représenter les nombres en virgule flottant en mémoire.

Les processeurs [IA32] utilisent la représentation little endian tandis que les PowerPC utilisent big endian. Certains processeurs sont capables d'utiliser les deux représentations.

Il est également possible en utilisant l'appel système lseek(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/lseek.2.html] de déplacer l'offset pointer associé à un descripteur de fichier.

#include <sys/types.h>
#include <unistd.h>

off_t lseek(int fd, off_t offset, int whence);

Cet appel système prend trois arguments. Le premier est le descripteur de fichier dont l'offset pointer doit être modifié. Le second est un entier qui est utilisé pour le calcul de la nouvelle position offset pointer et le troisième indique comment l'offset pointer doit être calculé. Il y a trois modes de calcul possibles pour l'offset pointer :

whence==SEEK_SET : dans ce cas, le deuxième argument de l'appel système indique la valeur exacte du nouvel offset pointer

whence==SEEK_CUR : dans ce cas, le nouvel offset pointer sera sa position actuelle à laquelle le deuxième argument aura été ajouté

whence==SEEK_END : dans ce cas, le nouvel offset pointer sera la fin du fichier à laquelle le deuxième argument aura été ajouté

Note

Fichiers temporaires

Il est parfois nécessaire dans un programme de créer des fichiers temporaires qui sont utilisés pour effectuer des opérations dans le processus sans pour autant être visible dans d'autres processus et sur le système de fichiers. Il est possible d'utiliser open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html] pour créer un tel fichier temporaire, mais il faut dans ce cas prévoir tous les cas d'erreur qui peuvent se produire lorsque par exemple plusieurs instances du même programme s'exécutent au même moment. Une meilleure solution est d'utiliser la fonction de la librairie mkstemp(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/mkstemp.3.html]. Cette fonction prend comme argument un modèle de nom de fichier qui se termine par XXXXXX et génère un nom de fichier unique et retourne un descripteur de fichier associé à ce fichier. Elle s'utilise généralement comme suit :

char template[]="/tmp/sinf1252PROCXXXXXX";

int fd=mkstemp(template);
if(fd==-1)
  exit_on_error("mkstemp");
// template contient le nom exact du fichier généré
unlink(template);
// le fichier est effacé, mais reste accessible
// via son descripteur jusqu'à close(fd)

// Accès au fichier avec read et write

if(close(fd)==-1)
  exit_on_error("close");
// le fichier n'est plus accessible

L'utilisation de unlink(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/unlink.2.html] permet de supprimer le fichier du système de fichiers dès qu'il a été créé. Ce fichier reste cependant accessible au processus tant que celui-ci dispose d'un descripteur de fichier qui y est associé.

Note

Duplication de descripteurs de fichiers

Dans certains cas il est utile de pouvoir dupliquer un descripteur de fichier. C'est possible avec les appels systèmes dup(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/dup.2.html] et dup2(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/dup2.2.html]. L'appel système dup(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/dup.2.html] prend comme argument un descripteur de fichier et retourne le plus petit descripteur de fichier libre. Lorsqu'un descripteur de fichier a été dupliqué avec dup(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/dup.2.html] les deux descripteurs de fichiers partagent le même offset pointer et les mêmes modes d'accès au fichier.

Fichiers mappés en mémoire

Lorsqu'un processus Unix veut lire ou écrire des données dans un fichier, il utilise en général les appels systèmes open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html], read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html], write(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/write.2.html] et close(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/close.2.html] directement ou à travers une librairie de plus haut niveau comme la libraire d'entrées/sorties standard. Ce n'est pas la seule façon pour accéder à des données sur un dispositif de stockage. Grâce à la mémoire virtuelle, il est possible de placer le contenu d'un fichier ou d'une partie de fichier dans une zone de la mémoire du processus. Cette opération peut être effectuée en utilisant l'appel système mmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mmap.2.html]. Cet appel système permet de rendre un fichier accessibles directement dans la mémoire du processus.

#include <sys/mman.h>

void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);

L'appel système mmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mmap.2.html] prend six arguments, c'est un des appels systèmes qui utilise le plus d'arguments. Il permet de rendre accessible une portion d'un fichier via la mémoire d'un processus. Le cinquième argument est le descripteur du fichier qui doit être mappé. Celui-ci doit avoir été préalablement ouvert avec l'appel système open(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/open.2.html]. Le sixième argument spécifie l'offset à partir duquel le fichier doit être mappé, 0 correspondant au début du fichier. Le premier argument est l'adresse à laquelle la première page du fichier doit être mappée. Généralement, cet argument est mis à NULL de façon à laisser le noyau choisir l'adresse la plus appropriée. Le deuxième argument est la longueur de la zone du fichier qui doit être mappée en mémoire. Le troisième argument contient des drapeaux qui spécifient les permissions d'accès aux données mappées. Cet argument peut soit être PROT_NONE, ce qui indique que la page est inaccessible soit une permission classique :

PROT_EXEC, les pages mappées contiennent des instructions qui peuvent être exécutées

PROT_READ, les pages mappées contiennent des données qui peuvent être lues

PROT_WRITE, les pages mappées contiennent des données qui peuvent être modifiées

Ces drapeaux peuvent être combinés avec une disjonction logique. Le quatrième argument est un drapeau qui indique comment les pages doivent être mappées en mémoire. Ce drapeau spécifie comment un fichier qui est mappé par deux ou plusieurs processus doit être traité. Deux drapeaux sont possibles :

MAP_PRIVATE. Dans ce cas, le fichier est mappé dans chaque processus, mais si un processus modifie une page, cette modification n'est pas répercutée aux autres processus qui ont mappé le même fichier.

MAP_SHARED. Dans ce cas, plusieurs processus peuvent accéder et modifier la page qui est mappée en mémoire. Lorsqu'un processus modifie le contenu d'une page, la modification est visible aux autres processus. Par contre, le fichier qui est mappé en mémoire n'est modifié que lorsque le noyau du système d'exploitation décide d'écrire les données modifiées sur le dispositif de stockage. Ces écritures dépendent de nombreux facteurs, dont la charge du système. Si un processus veut être sûr des écritures sur disque des modifications qu'il a fait à un fichier mappé un mémoire, il doit exécuter l'appel système msync(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/msync.2.html] ou supprimer le mapping via munmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/munmap.2.html].

Ces deux drapeaux peuvent dans certains cas particuliers être combinés avec d'autres drapeaux définis dans la page de manuel de mmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mmap.2.html].

Lorsque mmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mmap.2.html] réussit, il retourne l'adresse du début de la zone mappée en mémoire. En cas d'erreur, la constante MAP_FAILED est retournée et errno est mis à jour en conséquence.

L'appel système msync(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/msync.2.html] permet de forcer l'écriture sur disque d'une zone mappée en mémoire. Le premier argument est l'adresse du début de la zone qui doit être écrite sur disque. Le deuxième argument est la longueur de la zone qui doit être écrite sur le disque. Enfin, le dernier contient un drapeau qui spécifie comment les pages correspondantes doivent être écrites sur le disque. Le drapeau MS_SYNC indique que l'appel msync(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/msync.2.html] doit bloquer tant que les données n'ont pas été écrites. Le drapeau MS_ASYNC indique au noyau que l'écriture doit être démarrée, mais l'appel système peut se terminer avant que toutes les pages modifiées aient été écrites sur disque.

#include <sys/mman.h>
int msync(void *addr, size_t length, int flags);

Lorsqu'un processus a fini d'utiliser un fichier mappé en mémoire, il doit d'abord supprimer le mapping en utilisant l'appel système munmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/munmap.2.html]. Cet appel système prend deux arguments. Le premier doit être un multiple de la taille d'une page 7. Le second est la taille de la zone pour laquelle le mapping doit être retiré.

#include <sys/mman.h>

int munmap(void *addr, size_t length);

A titre d'exemple d'utilisation de mmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/mmap.2.html] et munmap(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/munmap.2.html], le programme ci-dessous implémente l'équivalent de la commande cp(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/cp.1.html]. Il prend comme arguments deux noms de fichiers et copie le contenu du premier dans le second. La copie se fait en mappant le premier fichier entièrement en mémoire et en utilisant la fonction memcpy(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/memcpy.3.html] pour réaliser la copie. Cette solution fonctionne avec de petits fichiers. Avec de gros fichiers, elle n'est pas très efficace car tout le fichier doit être mappé en mémoire.

#include <sys/types.h>
#include <sys/stat.h>
#include <sys/mman.h>
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

int main (int argc, char *argv[]) {
 int file1, file2;
 void *src, *dst;
 struct stat file_stat;
 char dummy=0;

 if (argc != 3) {
   fprintf(stderr,"Usage : cp2 source dest\n");
   exit(EXIT_FAILURE);
 }
 // ouverture fichier source
 if ((file1 = open (argv[1], O_RDONLY)) < 0) {
   perror("open(source)");
   exit(EXIT_FAILURE);
 }

 if (fstat (file1,&file_stat) < 0) {
   perror("fstat");
   exit(EXIT_FAILURE);
 }
 // ouverture fichier destination
 if ((file2 = open (argv[2], O_RDWR | O_CREAT | O_TRUNC, file_stat.st_mode)) < 0) {
   perror("open(dest)");
   exit(EXIT_FAILURE);
 }

 // le fichier destination doit avoir la même taille que le source
 if (lseek (file2, file_stat.st_size - 1, SEEK_SET) == -1) {
   perror("lseek");
   exit(EXIT_FAILURE);
 }

 // écriture en fin de fichier
 if (write (file2, &dummy, sizeof(char)) != 1) {
   perror("write");
   exit(EXIT_FAILURE);
 }

 // mmap fichier source
 if ((src = mmap (NULL, file_stat.st_size, PROT_READ, MAP_SHARED, file1, 0))  == (void *)(-1)) {
   perror("mmap(src)");
   exit(EXIT_FAILURE);
 }

 // mmap fichier destination
 if ((dst = mmap (NULL, file_stat.st_size, PROT_READ | PROT_WRITE, MAP_SHARED, file2, 0)) == (void *)(-1)) {
   perror("mmap(src)");
   exit(EXIT_FAILURE);
 }

 // copie complète
 memcpy (dst, src, file_stat.st_size);

 // libération mémoire
 if(munmap(src,file_stat.st_size)<0) {
   perror("munmap(src)");
   exit(EXIT_FAILURE);
 }

 if(munmap(dst,file_stat.st_size)<0) {
   perror("munmap(dst)");
   exit(EXIT_FAILURE);
 }
 // fermeture fichiers
 if(close(file1)<0) {
   perror("close(file1)");
   exit(EXIT_FAILURE);
 }

 if(close(file2)<0) {
   perror("close(file2)");
   exit(EXIT_FAILURE);
 }
 return(EXIT_SUCCESS);
}

Footnotes

1

Cette structure est partiellement inspirée du format des inodes du système de fichiers Minix, voir linux/minix_fs.h [https://sites.uclouvain.be/SystInfo/usr/include/linux/minix_fs.h.html].

2

Le champ zone[10] permet de stocker dans l'inode les références vers les premiers secteurs du fichier et des références vers d'autres blocs qui contiennent eux-aussi des références vers des blocs. Cela permet de stocker une liste de secteurs qui est de taille variable à partir d'un inode qui a lui une taille fixe. Une description détaillée des inodes peut se trouver dans une référence sur les systèmes d'exploitation telle que [Tanenbaum+2009].

3

Source : linux/ext2_fs.h [https://sites.uclouvain.be/SystInfo/usr/include/linux/ext2_fs.h.html]

4

Dans un système de fichiers Unix, un lien ne peut être créé avec ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] ou link(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/link.2.html] que lorsque les deux répertoires concernés sont situés sur le même système de fichiers. Si ce n'est pas le cas, il faut utiliser un lien symbolique. Ceux-ci peuvent être créés en utilisant l'appel système symlink(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/symlink.2.html] ou via la commande ln(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/ln.1.html] avec l'argument -s.

5

Il y a une limite maximale au nombre de fichiers qui peuvent être ouverts par un processus. Cette limite peut être récupérée avec l'appel système getdtablesize(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getdtablesize.2.html].

6

Source : https://en.wikipedia.org/wiki/Endianness

7

Il est possible d'obtenir la taille des pages utilisée sur un système via les appels sysconf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sysconf.3.html] ou getpagesize(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/getpagesize.2.html]

Les sémaphores

Le problème de la coordination entre threads est un problème majeur. Outre les mutex que nous avons présenté, d'autres solutions à ce problème ont été développées. Historiquement, une des premières propositions de coordination sont les sémaphores [Dijkstra1965b]. Un sémaphore est une structure de données qui est maintenue par le système d'exploitation et contient :

un entier qui stocke la valeur, positive ou nulle, du sémaphore.

une queue qui contient les pointeurs vers les threads qui sont bloqués en attente sur ce sémaphore.

Tout comme pour les mutex, la queue associée à un sémaphore permet de bloquer les threads qui sont en attente d'une modification de la valeur du sémaphore.

Une implémentation des sémaphores se compose en général de quatre fonctions :

une fonction d'initialisation qui permet de créer le sémaphore et de lui attribuer une valeur initiale nulle ou positive.

une fonction permettant de détruire un sémaphore et de libérer les ressources qui lui sont associées.

une fonction post qui est utilisée par les threads pour modifier la valeur du sémaphore. S'il n'y a pas de thread en attente dans la queue associée au sémaphore, sa valeur est incrémentée d'une unité. Sinon, un des threads en attente est libéré et passe à l'état Ready.

une fonction wait qui est utilisée par les threads pour tester la valeur d'un sémaphore. Si la valeur du sémaphore est positive, elle est décrémentée d'une unité et la fonction réussit. Si le sémaphore a une valeur nulle, le thread est bloqué jusqu'à ce qu'un autre thread le débloque en appelant la fonction post.

Les sémaphores sont utilisés pour résoudre de nombreux problèmes de coordination [Downey2008]. Comme ils permettent de stocker une valeur entière, ils sont plus flexibles que les mutex qui sont utiles surtout pour les problèmes d'exclusion mutuelle.

Sémaphores POSIX

La librairie POSIX comprend une implémentation des sémaphores 1 qui expose plusieurs fonctions aux utilisateurs. La page de manuel sem_overview(7) [https://sites.uclouvain.be/SystInfo/manpages/man7/sem_overview.7.html] présente de façon sommaire les fonctions de la librairie relatives aux sémaphores. Les quatre principales sont les suivantes :

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);

Le fichier semaphore.h [https://sites.uclouvain.be/SystInfo/usr/include/semaphore.h.html] contient les différentes définitions de structures qui sont nécessaires au bon fonctionnement des sémaphores ainsi que les signatures des fonctions de cette API. Un sémaphore est représenté par une structure de données de type sem_t. Toutes les fonctions de manipulation des sémaphores prennent comme argument un pointeur vers le sémaphore concerné.

Pour pouvoir utiliser un sémaphore, il faut d'abord l'initialiser. Cela se fait en utilisant la fonction sem_init(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_init.3.html] qui prend comme argument un pointeur vers le sémaphore à initialiser. Nous n'utiliserons pas le second argument dans ce chapitre. Le troisième argument est la valeur initiale, positive ou nulle, du sémaphore.

La fonction sem_destroy(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_destroy.3.html] permet de libérer un sémaphore qui a été initialisé avec sem_init(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_init.3.html]. Les sémaphores consomment des ressources qui peuvent être limitées dans certains environnements. Il est important de détruire proprement les sémaphores dès qu'ils ne sont plus nécessaires.

Les deux principales fonctions de manipulation des sémaphores sont sem_wait(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_wait.3.html] et sem_post(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_post.3.html]. Certains auteurs utilisent down ou P à la place de sem_wait(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_wait.3.html] et up ou V à la place de sem_post(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_post.3.html] [Downey2008]. Schématiquement, l'opération sem_wait peut s'implémenter en utilisant le pseudo-code suivant :

int sem_wait(semaphore *s)
{
 s->val=s->val-1;
 if(s->val<0)
 {
  // Place this thread in s.queue;
  // This thread is blocked;
 }
}

La fonction sem_post(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_post.3.html] quant à elle peut schématiquement s'implémenter comme suit :

int sem_post(semaphore *s)
{
  s->val=s->val+1;
  if(s.val<=0)
  {
    // Remove one thread(T) from s.queue;
    // Mark Thread(T) as ready to run;
  }
}

Ces deux opérations sont bien entendu des opérations qui ne peuvent s'exécuter simultanément. Leur implémentation réelle comprend des sections critiques qui doivent être construites avec soin. Le pseudo-code ci-dessus ignore ces sections critiques. Des détails complémentaires sur l'implémentation des sémaphores peuvent être obtenus dans livre sur les systèmes d'exploitation [Stallings2011] [Tanenbaum+2009] .

La meilleure façon de comprendre leur utilisation est d'analyser des problèmes classiques de coordination qui peuvent être résolus en utilisant des sémaphores.

Exclusion mutuelle

Les sémaphores permettent de résoudre de nombreux problèmes classiques. Le premier est celui de l'exclusion mutuelle. Lorsqu'il est initialisé à 1, un sémaphore peut être utilisé de la même façon qu'un mutex. En utilisant des sémaphores, une exclusion mutuelle peut être protégée comme suit :

#include <semaphore.h>

//...

sem_t semaphore;

sem_init(&semaphore, 0, 1);

sem_wait(&semaphore);
// section critique
sem_post(&semaphore);

sem_destroy(&semaphore);

Les sémaphores peuvent être utilisés pour d'autres types de synchronisation. Par exemple, considérons une application découpée en threads dans laquelle la fonction after ne peut jamais être exécutée avant la fin de l'exécution de la fonction before. Ce problème de coordination peut facilement être résolu en utilisant un sémaphore qui est initialisé à la valeur 0. La fonction after doit démarrer par un appel à sem_wait(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_wait.3.html] sur ce sémaphore tandis que la fonction before doit se terminer par un appel à la fonction sem_post(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_post.3.html] sur ce sémaphore. De cette façon, si le thread qui exécute la fonction after est trop rapide, il sera bloqué sur l'appel à sem_wait(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_wait.3.html]. S'il arrive à cette fonction après la fin de la fonction before dans l'autre thread, il pourra passer sans être bloqué. Le programme ci-dessous illustre cette utilisation des sémaphores POSIX.

#define NTHREADS 2
sem_t semaphore;

void *before(void * param) {
  // do something
  for(int j=0;j<1000000;j++) {
  }
  sem_post(&semaphore);
  return(NULL);
}

void *after(void * param) {
  sem_wait(&semaphore);
  // do something
  for(int j=0;j<1000000;j++) {
  }
  return(NULL);
}

int main (int argc, char *argv[])  {
  pthread_t thread[NTHREADS];
  void * (* func[])(void *)={before, after};
  int err;

  err=sem_init(&semaphore, 0,0);
  if(err!=0) {
      error(err,"sem_init");
  }
  for(int i=0;i<NTHREADS;i++) {
    err=pthread_create(&(thread[i]),NULL,func[i],NULL);
    if(err!=0) {
      error(err,"pthread_create");
    }
  }

  for(int i=0;i<NTHREADS;i++) {
    err=pthread_join(thread[i],NULL);
    if(err!=0) {
      error(err,"pthread_join");
    }
  }
  sem_destroy(&semaphore);
  if(err!=0) {
      error(err,"sem_destroy");
  }
  return(EXIT_SUCCESS);
}

Si un sémaphore initialisé à la valeur 1 est généralement utilisé comme un mutex, il y a une différence importante entre les implémentations des sémaphores et des mutex. Un sémaphore est conçu pour être manipulé par différents threads et il est fort possible qu'un thread exécute sem_wait(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_wait.3.html] et qu'un autre exécute sem_post(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sem_post.3.html]. Pour les mutex, certaines implémentations supposent que le même thread exécute pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html] et pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html]. Lorsque ces opérations doivent être effectuées dans des threads différents, il est préférable d'utiliser des sémaphores à la place de mutex.

Problème des producteurs-consommateurs

Le problème des producteurs-consommateurs est un problème extrêmement fréquent et important dans les applications découpées en plusieurs threads. Il est courant de structurer une telle application, notamment si elle réalise de longs calculs, en deux types de threads :

les producteurs : Ce sont des threads qui produisent des données et placent le résultat de leurs calculs dans une zone mémoire accessible aux consommateurs.

les consommateurs : Ce sont des threads qui utilisent les valeurs calculées par les producteurs.

Ces deux types de threads communiquent en utilisant un buffer qui a une capacité limitée à N places comme illustré dans la figure ci-dessous.

Problème des producteurs-consommateurs

La difficulté du problème est de trouver une solution qui permet aux producteurs et aux consommateurs d'avancer à leur rythme sans que les producteurs ne bloquent inutilement les consommateurs et inversement. Le nombre de producteurs et de consommateurs ne doit pas nécessairement être connu à l'avance et ne doit pas être fixe. Un producteur peut arrêter de produire à n'importe quel moment.

Le buffer étant partagé entre les producteurs et les consommateurs, il doit nécessairement être protégé par un mutex. Les producteurs doivent pouvoir ajouter de l'information dans le buffer partagé tant qu'il y a au moins une place de libre dans le buffer. Un producteur ne doit être bloqué que si tout le buffer est rempli. Inversement, les consommateurs doivent être bloqués uniquement si le buffer est entièrement vide. Dès qu'une donnée est ajoutée dans le buffer, un consommateur doit être réveillé pour traiter cette donnée.

Ce problème peut être résolu en utilisant deux sémaphores et un mutex. L'accès au buffer, que ce soit par les consommateurs ou les producteurs est une section critique. Cet accès doit donc être protégé par l'utilisation d'un mutex. Quant aux sémaphores, le premier, baptisé empty dans l'exemple ci-dessous, sert à compter le nombre de places qui sont vides dans le buffer partagé. Ce sémaphore doit être initialisé à la taille du buffer puisque celui-ci est initialement vide. Le second sémaphore est baptisé full dans le pseudo-code ci-dessous. Sa valeur représente le nombre de places du buffer qui sont occupées. Il doit être initialisé à la valeur 0.

// Initialisation
#define N 10 // places dans le buffer
pthread_mutex_t mutex;
sem_t empty;
sem_t full;

pthread_mutex_init(&mutex, NULL);
sem_init(&empty, 0 , N);  // buffer vide
sem_init(&full, 0 , 0);   // buffer vide

Le fonctionnement général d'un producteur est le suivant. Tout d'abord, le producteur est mis en attente sur le sémaphore empty. Il ne pourra passer que si il y a au moins une place du buffer qui est vide. Lorsque la ligne sem_wait(&empty); réussit, le producteur s'approprie le mutex et modifie le buffer de façon à insérer l'élément produit (dans ce cas un entier). Il libère ensuite le mutex pour sortir de sa section critique.

// Producteur
void producer(void)
{
  int item;
  while(true)
  {
    item=produce(item);
    sem_wait(&empty); // attente d'une place libre
    pthread_mutex_lock(&mutex);
     // section critique
     insert_item();
    pthread_mutex_unlock(&mutex);
    sem_post(&full); // il y a une place remplie en plus
  }
}

Le consommateur quant à lui essaie d'abord de prendre le sémaphore full. Si celui-ci est positif, cela indique la présence d'au moins un élément dans le buffer partagé. Ensuite, il entre dans la section critique protégée par le mutex et récupère la donnée se trouvant dans le buffer. Puis, il incrémente la valeur du sémaphore empty de façon à indiquer à un producteur qu'une nouvelle place est disponible dans le buffer.

// Consommateur
void consumer(void)
{
 int item;
 while(true)
 {
   sem_wait(&full); // attente d'une place remplie
   pthread_mutex_lock(&mutex);
    // section critique
    item=remove(item);
   pthread_mutex_unlock(&mutex);
   sem_post(&empty); // il y a une place libre en plus
 }
}

De nombreux programmes découpés en threads fonctionnent avec un ensemble de producteurs et un ensemble de consommateurs.

Compléments sur les threads POSIX

Il existe différentes implémentations des threads POSIX. Les mécanismes de coordination utilisables varient parfois d'une implémentation à l'autre. Dans les sections précédentes, nous nous sommes focalisés sur les fonctions principales qui sont en général bien implémentées. Une discussion plus détaillée des fonctions implémentées sous Linux peut se trouver dans [Kerrisk2010]. [Gove2011] présente de façon détaillée les mécanismes de coordination utilisables sous Linux, Windows et Oracle Solaris. [StevensRago2008] comprend également une description des threads POSIX mais présente des exemples sur des versions plus anciennes de Linux, FreeBSD, Solaris et MacOS.

Il reste cependant quelques concepts qu'il est utile de connaître lorsque l'on développe des programmes découpés en threads en langage C.

Variables spécifiques à un thread

Dans un programme C séquentiel, on doit souvent combiner les variables globales, les variables locales et les arguments de fonctions. Lorsque le programme est découpé en threads, les variables globales restent utilisables, mais il faut faire attention aux problèmes d'accès concurrent. En pratique, il est parfois utile de pouvoir disposer dans chaque thread de variables qui tout en étant accessibles depuis toutes les fonctions du thread ne sont pas accessibles aux autres threads. Il y a différentes solutions pour résoudre ce problème.

Une première solution serait d'utiliser une zone mémoire qui est spécifique au thread et d'y placer par exemple une structure contenant toutes les variables auxquelles on souhaite pouvoir accéder depuis toutes les fonctions du thread. Cette zone mémoire pourrait être créée avant l'appel à pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] et un pointeur vers cette zone pourrait être passé comme argument à la fonction qui démarre le thread. Malheureusement l'argument qui est passé à cette fonction n'est pas équivalent à une variable globale et n'est pas accessible à toutes les fonctions du thread.

Une deuxième solution serait d'avoir un tableau global qui contiendrait des pointeurs vers des zones de mémoires qui ont été allouées pour chaque thread. Chaque thread pourrait alors accéder à ce tableau sur base de son identifiant. Cette solution pourrait fonctionner si le nombre de threads est fixe et que les identifiants de threads sont des entiers croissants. Malheureusement la librairie threads POSIX ne fournit pas de tels identifiants croissants. Officiellement, la fonction pthread_self(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_self.3.html] retourne un identifiant unique d'un thread qui a été créé. Malheureusement cet identifiant est de type pthread_t et ne peut pas être utilisé comme index dans un tableau. Sous Linux, l'appel système non-standard gettid(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/gettid.2.html] retourne l'identifiant du thread, mais il ne peut pas non plus être utilisé comme index dans un tableau.

Pour résoudre ce problème, deux solutions sont possibles. La première combine une extension au langage C qui est supportée par gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] avec la librairie threads POSIX. Il s'agit du qualificatif __thread qui peut être utilisé avant une déclaration de variable. Lorsqu'il est utilisé dans la déclaration d'une variable globale, il indique au compilateur et à la libraire POSIX qu'une copie de cette variable doit être créée pour chaque thread. Cette variable est initialisée au démarrage du thread et est utilisable uniquement à l'intérieur de ce thread. Le programme ci-dessous illustre cette utilisation du qualificatif __thread.

#define LOOP 1000000
#define NTHREADS 4

__thread int count=0;
int global_count=0;

void *f( void* param) {
  for(int i=0;i<LOOP;i++) {
    count++;
    global_count=global_count-1;
  }
  printf("Valeurs : count=%d, global_count=%d\n",count, global_count);
  return(NULL);
}

int main (int argc, char *argv[])  {
  pthread_t threads[NTHREADS];
  int err;

  for(int i=0;i<NTHREADS;i++) {
    count=i;   // local au thread du programme principal
    err=pthread_create(&(threads[i]),NULL,&f,NULL);
    if(err!=0)
      error(err,"pthread_create");
  }

  for(int i=0;i<NTHREADS;i++) {
    err=pthread_join(threads[i],NULL);
    if(err!=0)
      error(err,"pthread_create");
  }

  return(EXIT_SUCCESS);
}

Lors de son exécution, ce programme affiche la sortie suivante sur stdout. Cette sortie illustre bien que les variables dont la déclaration est précédée du qualificatif __thread sont utilisables uniquement à l'intérieur d'un thread.

Valeurs : count=1000000, global_count=-870754
Valeurs : count=1000000, global_count=-880737
Valeurs : count=1000000, global_count=-916383
Valeurs : count=1000000, global_count=-923423

La seconde solution proposée par la librairie POSIX est plus complexe. Elle nécessite l'utilisation des fonctions pthread_key_create(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_key_create.3posix.html], pthread_setspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_setspecific.3posix.html], pthread_getspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_getspecific.3posix.html] et pthread_key_delete(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_key_delete.3posix.html]. Cette API est malheureusement plus difficile à utiliser que le qualificatif __thread, mais elle illustre ce qu'il se passe en pratique lorsque ce qualificatif est utilisé.

Pour avoir une variable accessible depuis toutes les fonctions d'un thread, il faut tout d'abord créer une clé qui identifie cette variable. Cette clé est de type pthread_key_t et c'est l'adresse de cette structure en mémoire qui est utilisée comme identifiant pour la variable spécifique à chaque thread. Cette clé ne doit être créée qu'une seule fois. Cela peut se faire dans le programme qui lance les threads ou alors dans le premier thread lancé en utilisant la fonction pthread_once(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_once.3posix.html]. Une clé est créée grâce à la fonction pthread_key_create(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_key_create.3posix.html]. Cette fonction prend deux arguments. Le premier est un pointeur vers une structure de type pthread_key_t. Le second est la fonction optionnelle à appeler lorsque le thread utilisant la clé se termine.

Il faut noter que la fonction pthread_key_create(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_key_create.3posix.html] associe en pratique le pointeur NULL à la clé qui a été créée dans chaque thread. Le thread qui veut utiliser la variable correspondant à cette clé doit réserver la zone mémoire correspondante. Cela se fait en général en utilisant malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html] puis en appelant la fonction pthread_setspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_setspecific.3posix.html]. Celle-ci prend deux arguments. Le premier est une clé de type pthread_key_t qui a été préalablement créée. Le second est un pointeur (de type void *) vers la zone mémoire correspondant à la variable spécifique. Une fois que le lien entre la clé et le pointeur a été fait, la fonction pthread_getspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_getspecific.3posix.html] peut être utilisée pour récupérer le pointeur depuis n'importe quelle fonction du thread. L'implémentation des fonctions pthread_setspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_setspecific.3posix.html] et pthread_getspecific(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_getspecific.3posix.html] garantit que chaque thread aura sa variable qui lui est propre.

L'exemple ci-dessous illustre l'utilisation de cette API. Elle est nettement plus lourde à utiliser que le qualificatif __thread. Dans ce code, chaque thread démarre par la fonction f. Celle-ci crée une variable spécifique de type int qui joue le même rôle que la variable __thread int count; dans l'exemple précédent. La fonction g qui est appelée sans argument peut accéder à la zone mémoire créée en appelant pthread_getspecific(count). Elle peut ensuite exécuter ses calculs en utilisant le pointeur count_ptr. Avant de se terminer, la fonction f libère la zone mémoire qui avait été allouée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html]. Une alternative à l'appel explicite à free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] aurait été de passer free comme second argument à pthread_key_create(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_key_create.3posix.html] lors de la création de la clé count. En effet, ce second argument est la fonction à appeler à la fin du thread pour libérer la mémoire correspondant à cette clé.

#define LOOP 1000000
#define NTHREADS 4

pthread_key_t count;
int global_count=0;

void g(void ) {
  void * data=pthread_getspecific(count);
  if(data==NULL)
    error(-1,"pthread_getspecific");
  int *count_ptr=(int *)data;
  for(int i=0;i<LOOP;i++) {
    *count_ptr=*(count_ptr)+1;
    global_count=global_count-1;
  }
}

void *f( void* param) {
  int err;
  int *int_ptr=malloc(sizeof(int));
  *int_ptr=0;
  err=pthread_setspecific(count, (void *)int_ptr);
  if(err!=0)
    error(err,"pthread_setspecific");
  g();
  printf("Valeurs : count=%d, global_count=%d\n",*int_ptr, global_count);
  free(int_ptr);
  return(NULL);
}

int main (int argc, char *argv[])  {
  pthread_t threads[NTHREADS];
  int err;

  err=pthread_key_create(&(count),NULL);
  if(err!=0)
    error(err,"pthread_key_create");

  for(int i=0;i<NTHREADS;i++) {
    err=pthread_create(&(threads[i]),NULL,&f,NULL);
    if(err!=0)
      error(err,"pthread_create");
  }

  for(int i=0;i<NTHREADS;i++) {
    err=pthread_join(threads[i],NULL);
    if(err!=0)
      error(err,"pthread_create");
  }
  err=pthread_key_delete(count);
  if(err!=0)
    error(err,"pthread_key_delete");

  return(EXIT_SUCCESS);
}

En pratique, on préférera évidemment d'utiliser le qualificatif __thread plutôt que d'utiliser une API explicite lorsque c'est possible. Cependant, il ne faut pas oublier que lorsque ce qualificatif est utilisé, le compilateur doit introduire dans le programme du code permettant de faire le même genre d'opérations que les fonctions explicites de la librairie.

Fonctions thread-safe

Dans un programme séquentiel, il n'y a qu'un thread d'exécution et de nombreux programmeurs, y compris ceux qui ont développé la librairie standard, utilisent cette hypothèse lors de l'écriture de fonctions. Lorsqu'un programme est découpé en threads, chaque fonction peut être appelée par plusieurs threads simultanément. Cette exécution simultanée d'une fonction peut poser des difficultés notamment lorsque la fonction utilise des variables globales ou des variables statiques.

Pour comprendre le problème, il est intéressant de comparer plusieurs implémentations d'une fonction simple. Considérons le problème de déterminer l'élément maximum d'une structure de données contenant des entiers. Si la structure de données est un tableau, une solution simple est de le parcourir entièrement pour déterminer l'élément maximum. C'est ce que fait la fonction max_vector dans le programme ci-dessous. Dans un programme purement séquentiel dans lequel le tableau peut être modifié de temps en temps, parcourir tout le tableau pour déterminer son maximum n'est pas nécessairement la solution la plus efficace. Une alternative est de mettre à jour la valeur du maximum chaque fois qu'un élément du tableau est modifié. Les fonctions max_global et max_static sont deux solutions possibles. Chacune de ces fonctions doit être appelée chaque fois qu'un élément du tableau est modifié. max_global stocke dans une variable globale la valeur actuelle du maximum du tableau et met à jour cette valeur à chaque appel. La fonction max_static fait de même en utilisant une variable statique. Ces deux solutions sont équivalentes et elles pourraient très bien être intégrées à une librairie utilisée par de nombreux programmes.

#include <stdint.h>
#define SIZE 10000

int g_max=INT32_MIN;
int v[SIZE];

int max_vector(int n, int *v) {
  int max=INT32_MIN;
  for(int i=0;i<n;i++) {
    if(v[i]>max)
      max=v[i];
  }
  return max;
}

int max_global(int *v) {
    if (*v>g_max) {
      g_max=*v;
    }
    return(g_max);
}

int max_static(int *v){
  static int s_max=INT32_MIN;
  if (*v>s_max) {
    s_max=*v;
  }
  return(s_max);
}

Considérons maintenant un programme découpé en plusieurs threads qui chacun maintient un tableau d'entiers dont il faut connaître le maximum. Ces tableaux d'entiers sont distincts et ne sont pas partagés entre les threads. La fonction max_vector peut être utilisée par chaque thread pour déterminer le maximum du tableau. Par contre, les fonctions max_global et max_static ne peuvent pas être utilisées. En effet, chacune de ces fonctions maintient un état (dans ce cas le maximum calculé) alors qu'elle peut être appelée par différents threads qui auraient chacun besoin d'un état qui leur est propre. Pour que ces fonctions soient utilisables, il faudrait que les variables s_max et g_max soient spécifiques à chaque thread.

En pratique, ce problème de l'accès concurrent à des fonctions se pose pour de nombreuses fonctions et notamment celles de la librairie standard. Lorsque l'on développe une fonction qui peut être réutilisée, il est important de s'assurer que cette fonction peut être exécutée par plusieurs threads simultanément sans que cela ne pose de problèmes à l'exécution.

Ce problème affecte certaines fonctions de la librairie standard et plusieurs d'entre elles ont dû être modifiées pour pouvoir supporter les threads. A titre d'exemple, considérons la fonction strerror(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror.3.html]. Cette fonction prend comme argument le numéro de l'erreur et retourne une chaîne de caractères décrivant cette erreur. Cette fonction ne peut pas être utilisée telle quelle par des threads qui pourraient l'appeler simultanément. Pour s'en convaincre, regardons une version simplifiée d'une implémentation de cette fonction 3. Cette fonction utilise le tableau sys_errlist(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sys_errlist.3.html] qui contient les messages d'erreur associés aux principaux codes numériques d'erreur. Lorsque l'erreur est une erreur standard, tout se passe bien et la fonction retourne simplement un pointeur vers l'entrée du tableau sys_errlist correspondante. Par contre, si le code d'erreur n'est pas connu, un message est généré dans le tableau buf[32] qui est déclaré de façon statique. Si plusieurs threads exécutent strerror, ce sera le même tableau qui sera utilisé dans les différents threads. On pourrait remplacer le tableau statique par une allocation de zone mémoire faite via malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html], mais alors la zone mémoire créée risque de ne jamais être libérée par free(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/free.3.html] car l'utilisateur de strerror(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror.3.html] ne doit pas libérer le pointeur qu'il a reçu, ce qui pose d'autres problèmes en pratique.

char * strerror (int errnoval)
{
 char * msg;
 static char buf[32];
 if ((errnoval < 0) || (errnoval >= sys_nerr))
   { // Out of range, just return NULL
     msg = NULL;
   }
 else if ((sys_errlist == NULL) || (sys_errlist[errnoval] == NULL))
   { // In range, but no sys_errlist or no entry at this index.
     sprintf (buf, "Error %d", errnoval);
     msg = buf;
   }
 else
   { // In range, and a valid message.  Just return the message.
     msg = (char *) sys_errlist[errnoval];
   }
 return (msg);
}

La fonction strerror_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror_r.3.html] évite ce problème de tableau statique en utilisant trois arguments : le code d'erreur, un pointeur char * vers la zone devant stocker le message d'erreur et la taille de cette zone. Cela permet à strerror_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror_r.3.html] d'utiliser une zone mémoire qui lui est passée par le thread qu'il appelle et garantit que chaque thread disposera de son message d'erreur. Voici une implémentation possible de strerror_r(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/strerror_r.3.html].

strerror_r(int num, char *buf, size_t buflen)
{
  #define UPREFIX "Unknown error: %u"
  unsigned int errnum = num;
  int retval = 0;
  size_t slen;
  if (errnum < (unsigned int) sys_nerr) {
     slen = strlcpy(buf, sys_errlist[errnum], buflen);
  } else {
     slen = snprintf(buf, buflen, UPREFIX, errnum);
     retval = EINVAL;
  }
  if (slen >= buflen)
       retval = ERANGE;
  return retval;
}

Lorsque l'on intègre des fonctions provenant de la librairie standard ou d'une autre librairie dans un programme découpé en threads, il est important de vérifier que les fonctions utilisées sont bien thread-safe. La page de manuel pthreads(7) [https://sites.uclouvain.be/SystInfo/manpages/man7/pthreads.7.html] liste les fonctions qui ne sont pas thread-safe dans la librairie standard.

Footnotes

1

Les systèmes Unix supportent également des sémaphores dits System V du nom de la version de Unix dans laquelle ils ont été introduits. Dans ces notes, nous nous focalisons sur les sémaphores POSIX qui ont une API un peu plus simple que les es sémaphores System V. Les principales fonctions pour les sémaphores System V sont semget(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/semget.3posix.html], semctl(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/semctl.3posix.html] et semop(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/semop.3posix.html].

2

Les premiers compilateurs C permettaient au programmeur de donner des indications au compilateur en faisant précéder les déclarations de certaines variables avec le qualificatif register [KernighanRitchie1998]. Ce qualificatif indiquait que la variable était utilisée fréquemment et que le compilateur devrait en placer le contenu dans un registre. Les compilateurs actuels sont nettement plus performants et ils sont capables de détecter quelles sont les variables qu'il faut placer dans un registre. Il est inutile de chercher à influencer le compilateur en utilisant le qualificatif register. Les compilateurs actuels, dont gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html] supportent de nombreuses options [https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html] permettant d'optimiser les performances des programmes compilés. Certaines ont comme objectif d'accélérer l'exécution du programme, d'autres visent à réduire sa taille. Pour les programmes qui consomment beaucoup de temps CPU, il est utile d'activer l'optimisation du compilateur.

3

Cette implémentation est adaptée de https://opensource.apple.com/source/gcc/gcc-926/libiberty/strerror.c et est dans le domaine public.

Utilisation de plusieurs threads

Les performances des microprocesseurs se sont continuellement améliorées depuis les années 1960s. Cette amélioration a été possible grâce aux progrès constants de la micro-électronique qui a permis d'assembler des microprocesseurs contenant de plus en plus de transistors sur une surface de plus en plus réduite. La figure 1 ci-dessous illustre bien cette évolution puisqu'elle représente le nombre de transistors par microprocesseur en fonction du temps.

Evolution du nombre de transistors par microprocesseur

Cette évolution avait été prédite par Gordon Moore dans les années 1960s [Stokes2008]. Il a formulé en 1965 une hypothèse qui prédisait que le nombre de composants par puce continuerait à doubler tous les douze mois pour la prochaine décennie. Cette prédiction s'est avérée tout à fait réaliste. Elle est maintenant connue sous le nom de Loi de Moore et est fréquemment utilisée pour expliquer les améliorations de performance des ordinateurs.

Le fonctionnement d'un microprocesseur est régulé par une horloge. Celle-ci rythme la plupart des opérations du processeur et notamment le chargement des instructions depuis la mémoire. Pendant de nombreuses années, les performances des microprocesseurs ont fortement dépendu de leur vitesse d'horloge. Les premiers microprocesseurs avaient des fréquences d'horloge de quelques centaines de kHz. A titre d'exemple, le processeur intel 4004 avait une horloge à 740 kHz en 1971. Aujourd'hui, les processeurs rapides dépassent la fréquence de 3 GHz. La figure ci-dessous présente l'évolution de la fréquence d'horloge des microprocesseurs depuis les années 1970s 2. On remarque une évolution rapide jusqu'aux environs du milieu de la dernière décennie. La barrière des 10 MHz a été franchie à la fin des années 1970s. Les 100 MHz ont étés atteints en 1994 et le GHz aux environs de l'an 2000.

Evolution de la vitesse d'horloge des microprocesseurs

Pendant près de quarante ans, l'évolution technologique a permis une amélioration continue des performances des microprocesseurs. Cette amélioration a directement profité aux applications informatiques car elles ont pu s'exécuter plus rapidement au fur et à mesure que la vitesse d'horloge des microprocesseurs augmentait.

Malheureusement, vers 2005 cette croissance continue s'est arrêtée. La barrière des 3 GHz s'est avérée être une barrière très difficile à franchir d'un point de vue technologique. Aujourd'hui, les fabricants de microprocesseurs n'envisagent plus de chercher à continuer à augmenter les fréquences d'horloge des microprocesseurs.

Si pendant longtemps la fréquence d'horloge d'un microprocesseur a été une bonne heuristique pour prédire les performances du microprocesseur, ce n'est pas un indicateur parfait de performance. Certains processeurs exécutent une instruction durant chaque cycle d'horloge. D'autres processeurs prennent quelques cycles d'horloge pour exécuter chaque instruction et enfin certains processeurs sont capables d'exécuter plusieurs instructions durant chaque cycle d'horloge.

Une autre façon de mesurer les performances d'un microprocesseur est de comptabiliser le nombre d'instructions qu'il exécute par seconde. On parle en général de Millions d'Instructions par Seconde (ou MIPS). Si les premiers microprocesseurs effectuaient moins de 100.000 instructions par seconde, la barrière du MIPS a été franchie en 1979. Mesurées en MIPS, les performances des microprocesseurs ont continué à augmenter durant les dernières années malgré la barrière des 3 GHz comme le montre la figure ci-dessous.

Evolution des performances des microprocesseurs en MIPS

Note

Evaluation des performances de systèmes informatiques

La fréquence d'horloge d'un processeur et le nombre d'instructions qu'il est capable d'exécuter chaque seconde ne sont que quelques uns des paramètres qui influencent les performances d'un système informatique qui intègre ce processeur. Les performances globales d'un système informatique dépendent de nombreux autres facteurs comme la capacité de mémoire et ses performances, la vitesse des bus entre les différents composants, les performances des dispositifs de stockage ou des cartes réseaux. Les performances d'un système dépendront aussi fortement du type d'application utilisé. Un serveur web, un serveur de calcul scientifique et un serveur de bases de données n'auront pas les mêmes contraintes en termes de performance. L'évaluation complète des performances d'un système informatique se fait généralement en utilisant des benchmarks. Un benchmark est un ensemble de logiciels qui reproduisent le comportement de certaines classes d'applications de façon à pouvoir tester les performances de systèmes informatiques de façon reproductible. Différents organismes publient de tels benchmarks. Le plus connu est probablement Standard Performance Evaluation Corporation [https://www.spec.org] qui publie des benchmarks et des résultats de benchmarks pour différents types de systèmes informatiques et d'applications.

Cette progression continue des performances en MIPS a été possible grâce à l'introduction de processeurs qui sont capables d'exécuter plusieurs threads d'exécution simultanément. On parle alors de processeur multi-coeurs ou multi-threadé.

La notion de thread d'exécution est très importante dans un système informatique. Elle permet non seulement de comprendre comme un ordinateur équipé d'un seul microprocesseur peut exécuter plusieurs programmes simultanément, mais aussi comment des programmes peuvent profiter des nouveaux processeurs capables d'exécuter plusieurs threads simultanément. .. Pour comprendre cette notion, il est intéressant de revenir à nouveau sur l'exécution d'une fonction en langage assembleur. Considérons la fonction f :

int f(int a, int b ) {
  int m=0;
  int c=0;
  while(c<b) {
     m+=a;
     c=c+1;
  }
  return m;
}

Pour qu'un processeur puisse exécuter cette séquence d'instructions, il faut qu'il puisse accéder :

à la mémoire contenant les instructions à exécuter

à la mémoire contenant les données manipulées par cette séquence d'instruction. Pour rappel, cette mémoire est divisée en plusieurs parties :

la zone contenant les variables globales

le tas

la pile

aux registres, des zones de mémoire très rapide (mais peu nombreuses) se trouvant sur le processeur qui permettent de stocker entre autres : l'adresse de l'instruction à exécuter, des résultats intermédiaires obtenus durant l'exécution d'un instruction ou encore des informations sur la pile.

Un processeur multithreadé a la capacité d'exécuter plusieurs programmes simultanément. En pratique, ce processeur disposera de plusieurs copies des registres. Chacun de ces blocs de registres pourra être utilisé pour exécuter ces programmes simultanément à raison d'un thread d'exécution par bloc de registres. Chaque thread d'exécution va correspondre à une séquence différente d'instructions qui va modifier son propre bloc de registres. C'est grâce à cette capacité d'exécuter plusieurs threads d'exécution simultanément que les performances en MIPS des microprocesseurs ont pu continuer à croître alors que leur fréquence d'horloge stagnait.

Cette capacité d'exécuter plusieurs threads d'exécution simultanément n'est pas limitée à un thread d'exécution par programme. Sachant qu'un thread d'exécution n'est finalement qu'une séquence d'instructions qui utilisent un bloc de registres, il est tout à fait possible à plusieurs séquences d'exécution appartenant à un même programme de s'exécuter simultanément. Si on revient à la fonction assembleur ci-dessus, il est tout à fait possible que deux invocations de cette fonction s'exécutent simultanément sur un microprocesseur. Pour démarrer une telle instance, il suffit de pouvoir initialiser le bloc de registres nécessaire à la nouvelle instance et ensuite de démarrer l'exécution à la première instruction de la fonction. En pratique, cela nécessite la coopération du système d'exploitation. Différents mécanismes ont été proposés pour permettre à un programme de lancer différents threads d'exécution. Aujourd'hui, le plus courant est connu sous le nom de threads POSIX. C'est celui que nous allons étudier en détail, mais il en existe d'autres.

Note

D'autres types de threads

A côté des threads POSIX, il existe d'autres types de threads. [Gove2011] présente comment implémenter des threads sur différents systèmes d'exploitation. Sous Linux, NTPL [DrepperMolnar2005] et LinuxThreads [Leroy] sont deux anciennes implémentations des threads POSIX. GNU PTH [GNUPTH] est une librairie qui implémente les threads sans interaction directe avec le système d'exploitation. Cela permet à la librairie d'être portable sur de nombreux systèmes d'exploitation. Malheureusement, tous les threads GNU PTH d'un programme doivent s'exécuter sur le même processeur.

Les threads POSIX

Les threads POSIX sont supportés par la plupart des variantes de Unix. Ils sont souvent implémentés à l'intérieur d'une librairie. Sous Linux, il s'agit de la librairie pthreads(7) [https://sites.uclouvain.be/SystInfo/manpages/man7/pthreads.7.html] qui doit être explicitement compilée avec le paramètre -lpthread lorsque l'on utilise gcc(1) [https://sites.uclouvain.be/SystInfo/manpages/man1/gcc.1.html].

La librairie threads POSIX contient de nombreuses fonctions qui permettent de décomposer un programme en plusieurs threads d'exécution et de les gérer. Toutes ces fonctions nécessitent l'inclusion du fichier pthread.h [https://sites.uclouvain.be/SystInfo/usr/include/pthread.h.html]. La première fonction importante est pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] qui permet de créer un nouveau thread d'exécution. Cette fonction prend quatre arguments et retourne une valeur entière.

#include <pthread.h>

 int
 pthread_create(pthread_t *restrict thread,
                const pthread_attr_t *restrict attr,
                void *(*start_routine)(void *),
                void *restrict arg);

Le premier argument est un pointeur vers une structure de type pthread_t. Cette structure est définie dans pthread.h [https://sites.uclouvain.be/SystInfo/usr/include/pthread.h.html] et contient toutes les informations nécessaires à l'exécution d'un thread. Chaque thread doit disposer de sa structure de données de type pthread_t qui lui est propre.

Le second argument permet de spécifier des attributs spécifiques au thread qui est créé. Ces attributs permettent de configurer différents paramètres associés à un thread. Nous y reviendrons ultérieurement. Si cet argument est mis à NULL, la librairie pthreads utilisera les attributs par défaut qui sont en général largement suffisants.

Le troisième argument contient l'adresse de la fonction par laquelle le nouveau thread va démarrer son exécution. Cette adresse est le point de départ de l'exécution du thread et peut être comparée à la fonction main qui est lancée par le système d'exploitation lorsqu'un programme est exécuté. Un thread doit toujours débuter son exécution par une fonction dont la signature est void * function(void *), c'est-à-dire une fonction qui prend comme argument un pointeur générique (de type void *) et retourne un résultat du même type.

Le quatrième argument est l'argument qui est passé à la fonction qui débute le thread qui vient d'être créé. Cet argument est un pointeur générique de type void *, mais la fonction peut bien entendu le convertir dans un autre type.

La fonction pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] retourne un résultat entier. Une valeur de retour non-nulle indique une erreur et errno est mise à jour.

Un thread s'exécute en général pendant une certaine période de temps puis il peut retourner un résultat au thread d'exécution principal. Un thread peut retourner son résultat (de type void *) de deux façons au thread qui l'a lancé. Tout d'abord, un thread qui a démarré par la fonction f se termine lorsque cette fonction exécute return(...). L'autre façon de terminer un thread d'exécution est d'appeler explicitement la fonction pthread_exit(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_exit.3.html]. Celle-ci prend un argument de type void * et le retourne au thread qui l'avait lancé.

Pour récupérer le résultat d'un thread d'exécution, le thread principal doit utiliser la fonction pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html]. Celle-ci prend deux arguments et retourne un entier.

#include <pthread.h>

int
pthread_join(pthread_t thread, void **value_ptr);

Le premier argument de pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html] est la structure pthread_t correspondant au thread dont le résultat est attendu. Le second argument est un pointeur vers un pointeur générique (void **) qui après la terminaison du thread passé comme premier argument pointera vers la valeur de retour de ce thread.

L'exemple ci-dessous illustre une utilisation simple des fonctions pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html], pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html] et pthread_exit(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_exit.3.html].

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int global=0;

void error(int err, char *msg) {
  fprintf(stderr,"%s a retourné %d, message d'erreur : %s\n",msg,err,strerror(errno));
  exit(EXIT_FAILURE);
}

void *thread_first(void * param) {
  global++;
  return(NULL);
}

void *thread_second(void * param) {
  global++;
  pthread_exit(NULL);
}

int main (int argc, char *argv[])  {
  pthread_t first;
  pthread_t second;
  int err;

  err=pthread_create(&first,NULL,&thread_first,NULL);
  if(err!=0)
    error(err,"pthread_create");

  err=pthread_create(&second,NULL,&thread_second,NULL);
  if(err!=0)
    error(err,"pthread_create");

  for(int i=0; i<1000000000;i++) { /*...*/ }

  err=pthread_join(second,NULL);
    if(err!=0)
      error(err,"pthread_join");

  err=pthread_join(first,NULL);
    if(err!=0)
      error(err,"pthread_join");

  printf("global: %d\n",global);

  return(EXIT_SUCCESS);
}

Dans ce programme, la fonction main lance deux threads. Le premier exécute la fonction thread_first et le second la fonction thread_second. Ces deux fonctions incrémentent une variable globale et n'utilisent pas leur argument. thread_first se termine par return tandis que thread_second se termine par un appel à pthread_exit(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_exit.3.html]. Après avoir créé ses deux threads, la fonction main démarre une longue boucle puis appelle pthread_join pour attendre la fin des deux threads qu'elle avait lancé.

Afin d'illustrer la possibilité de passer des arguments à un thread et d'en récupérer la valeur de retour, considérons l'exemple ci-dessous.

#define NTHREADS 4
void *neg (void * param) {
  int *l;
  l=(int *) param;
  int *r=(int *)malloc(sizeof(int));
  *r=-*l;
  return ((void *) r);
}

int main (int argc, char *argv[])  {
  pthread_t threads[NTHREADS];
  int arg[NTHREADS];
  int err;

  for(long i=0;i<NTHREADS;i++) {
    arg[i]=i;
    err=pthread_create(&(threads[i]),NULL,&neg,(void *) &(arg[i]));
    if(err!=0)
      error(err,"pthread_create");
  }

  for(int i=0;i<NTHREADS;i++) {
    int *r;
    err=pthread_join(threads[i],(void **)&r);
    printf("Resultat[%d]=%d\n",i,*r);
    free(r);
    if(err!=0)
      error(err,"pthread_join");
  }
  return(EXIT_SUCCESS);
}

Ce programme lance 4 threads d'exécution en plus du thread principal. Chaque thread d'exécution exécute la fonction neg qui récupère un entier comme argument et retourne l'opposé de cet entier comme résultat.

Lors d'un appel à pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html], il est important de se rappeler que cette fonction crée le thread d'exécution, mais que ce thread ne s'exécute pas nécessairement immédiatement. En effet, il est très possible que le système d'exploitation ne puisse pas activer directement le nouveau thread d'exécution, par exemple parce que l'ensemble des processeurs de la machine sont actuellement utilisés. Dans ce cas, le thread d'exécution est mis en veille par le système d'exploitation et il sera démarré plus tard. Sachant que le thread peut devoir démarrer plus tard, il est important de s'assurer que la fonction lancée par pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] aura bien accès à son argument au moment où finalement elle démarrera. Dans l'exemple ci-dessous, cela se fait en passant comme quatrième argument l'adresse d'un entier casté en void *. Cette valeur est copiée sur la pile de la fonction neg. Celle-ci pourra accéder à cet entier via ce pointeur sans problème lorsqu'elle démarrera.

Note

Un thread doit pouvoir accéder à son argument

Lorsque l'on démarre un thread via la fonction pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html], il faut s'assurer que la fonction lancée pourra bien accéder à ses arguments. Ce n'est pas toujours le cas comme le montre l'exemple ci-dessous. Dans cet exemple, c'est l'adresse de la variable locale i qui est passée comme quatrième argument à la fonction pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html]. Cette adresse sera copiée sur la pile de la fonction neg pour chacun des threads créés. Malheureusement, lorsque la fonction neg sera exécutée, elle trouvera sur sa pile l'adresse d'une variable qui risque fort d'avoir été modifiée après l'appel à pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] ou pire risque d'avoir disparu car la boucle for s'est terminée. Il est très important de bien veiller à ce que le quatrième argument passé à pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] existe toujours au moment de l'exécution effective de la fonction qui démarre le thread lancé.

/// erroné !
for(long i=0;i<NTHREADS;i++) {
  err=pthread_create(&(threads[i]),NULL,&neg,(void *)&i);
  if(err!=0)
    error(err,"pthread_create");
}

Concernant pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html], le code ci-dessus illustre la récupération du résultat via un pointeur vers un entier. Comme la fonction neg retourne un résultat de type void * elle doit nécessairement retourner un pointeur qui peut être casté vers un pointeur de type void *. C'est ce que la fonction neg dans l'exemple réalise. Elle alloue une zone mémoire permettant de stocker un entier et place dans cette zone mémoire la valeur de retour de la fonction. Ce pointeur est ensuite casté en un pointeur de type void * avant d'appeler return. Il faut noter que l'appel à pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html] ne se termine que lorsque le thread spécifié comme premier argument se termine. Si ce thread ne se termine pas pour n'importe quelle raison, l'appel à pthread_join(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3.html] ne se terminera pas non plus.

Footnotes

1

Source : https://en.wikipedia.org/wiki/File:Transistor_Count_and_Moore%27s_Law_-_2011.svg

2

Plusieurs sites web recensent cette information, notamment https://www.intel.com/pressroom/kits/quickreffam.htm, https://en.wikipedia.org/wiki/List_of_Intel_microprocessors et https://en.wikipedia.org/wiki/Instructions_per_second

Communication entre threads

Lorsque un programme a été décomposé en plusieurs threads, ceux-ci ne sont en général pas complètement indépendants et ils doivent communiquer entre eux. Cette communication entre threads est un problème complexe comme nous allons le voir. Avant d'aborder ce problème, il est utile de revenir à l'organisation d'un processus et de ses threads en mémoire. La figure ci-dessous illustre schématiquement l'organisation de la mémoire après la création d'un thread POSIX.

Organisation de la mémoire après la création d'un thread POSIX

Le programme principal et le thread qu'il a créé partagent trois zones de la mémoire : le segment text qui comprend l'ensemble des instructions qui composent le programme, le segment de données qui comprend toutes les données statiques , initialisées ou non (c'est-à-dire les constantes, les variables globales ou encore les chaînes de caractère) et enfin le heap. Autant le programme principal que son thread peuvent accéder à n'importe quelle information se trouvant en mémoire dans ces zones. Par contre, le programme principal et le thread qu'il vient de créer ont chacun leur propre contexte et leur propre pile.

La première façon pour un processus de communiquer avec un thread qu'il a lancé est d'utiliser les arguments de la fonction de démarrage du thread et la valeur retournée par le thread que le processus principal peut récupérer via l'appel à pthread_join(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_join.3posix.html]. C'est un canal de communication très limité qui ne permet pas d'échange d'information pendant l'exécution du thread.

Il est cependant assez facile pour un processus de partager de l'information avec ses threads ou même de partager de l'information entre plusieurs threads. En effet, tous les threads d'un processus ont accès aux mêmes variables globales et au même heap. Il est donc tout à fait possible pour n'importe quel thread de modifier la valeur d'une variable globale. Deux threads qui réalisent un calcul peuvent donc stocker des résultats intermédiaires dans une variable globale ou un tableau global. Il en va de même pour l'utilisation d'une zone de mémoire allouée par malloc(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/malloc.3.html]. Chaque thread qui dispose d'un pointeur vers cette zone mémoire peut en lire le contenu ou en modifier la valeur.

Malheureusement, permettre à tous les threads de lire et d'écrire simultanément en mémoire peut être une source de problèmes. C'est une des difficultés majeures de l'utilisation de threads. Pour s'en convaincre, considérons l'exemple ci-dessous 1.

long global=0;
int increment(int i) {
  return i+1;
}
void *func(void * param) {
  for(int j=0;j<1000000;j++) {
    global=increment(global);
 }
  return(NULL);
}

Dans cet exemple, la variable global est incrémentée 1000000 de fois par la fonction func. Après l'exécution de cette fonction, la variable global contient la valeur 1000000. Sur une machine multiprocesseurs, un programmeur pourrait vouloir en accélérer les performances en lançant plusieurs threads qui exécutent chacun la fonction func. Cela pourrait se faire en utilisant par exemple la fonction main ci-dessous.

int main (int argc, char *argv[])  {
  pthread_t thread[NTHREADS];
  int err;
  for(int i=0;i<NTHREADS;i++) {
   err=pthread_create(&(thread[i]),NULL,&func,NULL);
   if(err!=0)
     error(err,"pthread_create");
  }
  /*...*/
  for(int i=NTHREADS-1;i>=0;i--) {
    err=pthread_join(thread[i],NULL);
    if(err!=0)
      error(err,"pthread_join");
  }
  printf("global: %ld\n",global);
  return(EXIT_SUCCESS);
}

Ce programme lance alors 4 threads d'exécution qui incrémentent chacun un million de fois la variable global. Celle-ci étant initialisée à 0, la valeur affichée par printf(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/printf.3.html] à la fin de l'exécution doit donc être 4000000. L'exécution du programme ne confirme malheureusement pas cette attente.

$ for i in {1..5}; do ./pthread-test;  done
global: 3408577
global: 3175353
global: 1994419
global: 3051040
global: 2118713

Non seulement la valeur attendue (4000000) n'est pas atteinte, mais en plus la valeur change d'une exécution du programme à la suivante. C'est une illustration du problème majeur de la découpe d'un programme en threads. Pour bien comprendre le problème, il est utile d'analyser en détails les opérations effectuées par deux threads qui exécutent la ligne global=increment(global);.

La variable global est stockée dans une zone mémoire qui est accessibles aux deux threads. Appelons-les T1 et T2. L'exécution de cette ligne par un thread nécessite l'exécution de plusieurs instructions en assembleur. Tout d'abord, il faut charger la valeur de la variable global depuis la mémoire vers un registre. Ensuite, il faut placer cette valeur sur la pile du thread puis appeler la fonction increment. Cette fonction récupère son argument sur la pile du thread, la place dans un registre, incrémente le contenu du registre et sauvegarde le résultat dans le registre %eax. Le résultat est retourné dans la fonction func et la variable globale peut enfin être mise à jour.

Malheureusement les difficultés surviennent lorsque deux threads exécutent en même temps la ligne global=increment(global);. Supposons qu'à cet instant, la valeur de la variable global est 1252. Le premier thread charge une copie de cette variable sur sa pile. Le second fait de même. Les deux threads ont donc chacun passé la valeur 1252 comme argument à la fonction increment. Celle-ci s'exécute et retourne la valeur 1253 que chaque thread va récupérer dans %eax. Chaque thread va ensuite transférer cette valeur dans la zone mémoire correspondant à la variable global. Si les deux threads exécutent l'instruction assembleur correspondante exactement au même moment, les deux écritures en mémoire seront sérialisées par les processeurs sans que l'on ne puisse a priori déterminer quelle écriture se fera en premier [McKenney2005]. Alors que les deux threads ont chacun exécuté un appel à la fonction increment, la valeur de la variable n'a finalement été incrémentée qu'une seule fois même si cette valeur a été transférée deux fois en mémoire. Ce problème se reproduit fréquemment. C'est pour cette raison que la valeur de la variable global n'est pas modifiée comme attendu.

Note

Contrôler la pile d'un thread POSIX

La taille de la pile d'un thread POSIX est l'un des attributs qui peuvent être modifiés lors de l'appel à pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html] pour créer un nouveau thread. Cet attribut peut être fixé en utilisant la fonction pthread_attr_setstackaddr(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_attr_setstackaddr.3posix.html] comme illustré dans l'exemple ci-dessous 2 (où thread_first est la fonction qui sera appelée à la création du thread). En général, la valeur par défaut choisie par le système suffit, sauf lorsque le programmeur sait qu'un thread devra par exemple allouer un grand tableau auquel il sera le seul à avoir accès. Ce tableau sera alors alloué sur la pile qui devra être suffisamment grande pour le contenir.

  pthread_t first;
  pthread_attr_t attr_first;
  size_t stacksize;

  int err;

  err= pthread_attr_init(&attr_first);
  if(err!=0)
    error(err,"pthread_attr_init");

  err= pthread_attr_getstacksize(&attr_first,&stacksize);
  if(err!=0)
    error(err,"pthread_attr_getstacksize");

  printf("Taille par défaut du stack : %ld\n",stacksize);

  stacksize=65536;

  err= pthread_attr_setstacksize(&attr_first,stacksize);
  if(err!=0)
    error(err,"pthread_attr_setstacksize");

  err=pthread_create(&first,&attr_first,&thread_first,NULL);
  if(err!=0)
    error(err,"pthread_create");

Ce problème d'accès concurrent à une zone de mémoire par plusieurs threads est un problème majeur dans le développement de programmes découpés en threads, que ceux-ci s'exécutent sur des ordinateurs mono-processeurs ou multiprocesseurs. Dans la littérature, il est connu sous le nom de problème de la section critique ou exclusion mutuelle. La section critique peut être définie comme étant une séquence d'instructions qui ne peuvent jamais être exécutées par plusieurs threads simultanément. Dans l'exemple ci-dessus, il s'agit de la ligne global=increment(global);. Dans d'autres types de programmes, la section critique peut être beaucoup plus grande et comprendre par exemple la mise à jour d'une base de données. En pratique, on retrouvera une section critique chaque fois que deux threads peuvent modifier ou lire la valeur d'une même zone de la mémoire.

Le fragment de code ci-dessus présente une autre illustration d'une section critique. Dans cet exemple, la fonction main (non présentée), créée deux threads. Le premier exécute la fonction inc qui incrémente la variable global. Le second exécute la fonction is_even qui teste la valeur de cette variable et compte le nombre de fois qu'elle est paire. Après la terminaison des deux threads, le programme affiche le contenu des variables global et even.

long global=0;
int even=0;
int odd=0;

void test_even(int i) {
  if((i%2)==0)
    even++;
}

int increment(int i) {
  return i+1;
}

void *inc(void * param) {
  for(int j=0;j<1000000;j++) {
    global=increment(global);
  }
  pthread_exit(NULL);
}

void *is_even(void * param) {
  for(int j=0;j<1000000;j++) {
    test_even(global);
  }
  pthread_exit(NULL);
}

L'exécution de ces deux threads donne, sans surprise des résultats qui varient d'une exécution à l'autre.

$ for i in {1..5}; do ./pthread-test-if;  done
global: 1000000, even:905140
global: 1000000, even:919756
global: 1000000, even:893058
global: 1000000, even:891266
global: 1000000, even:895043

Coordination entre threads

L'utilisation de plusieurs threads dans un programme fonctionnant sur un seul ou plusieurs processeurs nécessite l'utilisation de mécanismes de coordination entre ces threads. Ces mécanismes ont comme objectif d'éviter que deux threads ne puissent modifier ou tester de façon non coordonnée la même zone de la mémoire.

Exclusion mutuelle

Le premier problème important à résoudre lorsque l'on veut coordonner plusieurs threads d'exécution d'un même processus est celui de l'exclusion mutuelle. Ce problème a été initialement proposé par Dijkstra en 1965 [Dijkstra1965]. Il peut être reformulé de la façon suivante pour un programme décomposé en threads.

Considérons un programme décomposé en N threads d'exécution. Supposons également que chaque thread d'exécution est cyclique, c'est-à-dire qu'il exécute régulièrement le même ensemble d'instructions, sans que la durée de ce cycle ne soit fixée ni identique pour les N threads. Chacun de ces threads peut être décomposé en deux parties distinctes. La première est la partie non-critique. C'est un ensemble d'instructions qui peuvent être exécutées par le thread sans nécessiter la moindre coordination avec un autre thread. Plus précisément, tous les threads peuvent exécuter simultanément leur partie non-critique. La seconde partie du thread est appelée sa section critique. Il s'agit d'un ensemble d'instructions qui ne peuvent être exécutées que par un seul et unique thread. Le problème de l'exclusion mutuelle est de trouver un algorithme qui permet de garantir qu'il n'y aura jamais deux threads qui simultanément exécuteront les instructions de leur section critique.

Cela revient à dire qu'il n'y aura pas de violation de la section critique. Une telle violation pourrait avoir des conséquences catastrophiques sur l'exécution du programme. Cette propriété est une propriété de sûreté (safety en anglais). Dans un programme découpé en threads, une propriété de sûreté est une propriété qui doit être vérifiée à tout instant de l'exécution du programme.

En outre, une solution au problème de l'exclusion mutuelle doit satisfaire les contraintes suivantes [Dijkstra1965] :

La solution doit considérer tous les threads de la même façon et ne peut faire aucune hypothèse sur la priorité relative des différents threads.

La solution ne peut faire aucune hypothèse sur la vitesse relative ou absolue d'exécution des différents threads. Elle doit rester valide quelle que soit la vitesse d'exécution non nulle de chaque thread.

La solution doit permettre à un thread de s'arrêter en dehors de sa section critique sans que cela n'invalide la contrainte d'exclusion mutuelle

Si un ou plusieurs threads souhaitent entamer leur section critique, aucun de ces threads ne doit pouvoir être empêché indéfiniment d'accéder à sa section critique.

La troisième contrainte implique que la terminaison ou le crash d'un des threads ne doit pas avoir d'impact sur le fonctionnement du programme complet et le respect de la contrainte d'exclusion mutuelle pour la section critique.

La quatrième contrainte est un peu plus subtile mais tout aussi importante. Toute solution au problème de l'exclusion mutuelle contient nécessairement un mécanisme qui permet de bloquer l'exécution d'un thread pendant qu'un autre exécute sa section critique. Il est important qu'un thread puisse accéder à sa section critique si il le souhaite. C'est un exemple de propriété de vivacité (liveness en anglais). Une propriété de vivacité est une propriété qui ne peut pas être éternellement invalidée. Dans notre exemple, un thread ne pourra jamais être empêché d'accéder à sa section critique.

Exclusion mutuelle sur monoprocesseurs

Même si les threads sont très utiles dans des ordinateurs multiprocesseurs, ils ont été inventés et utilisés d'abord sur des processeurs capables d'exécuter un seul thread d'exécution à la fois. Sur un tel processeur, les threads d'exécution sont entrelacés plutôt que d'être exécutés réellement simultanément. Cet entrelacement est réalisé par le système d'exploitation.

Les systèmes d'exploitation de la famille Unix permettent d'exécuter plusieurs programmes en même temps sur un ordinateur, même si il est équipé d'un processeur qui n'est capable que d'exécuter un thread à la fois. Cette fonctionnalité est souvent appelée le multitâche (ou multitasking en anglais). Cette exécution simultanée de plusieurs programmes n'est en pratique qu'une illusion puisque le processeur ne peut qu'exécuter qu'une séquence d'instructions à la fois.

Pour donner cette illusion, un système d'exploitation multitâche tel que Unix exécute régulièrement des changements de contexte entre threads. Le contexte d'un thread est composé de l'ensemble des contenus des registres qui sont nécessaires à son exécution (y compris le contenu des registres spéciaux tels que %esp, %eip ou %eflags). Ces registres définissent l'état du thread du point de vue du processeur. Pour passer de l'exécution du thread T1 à l'exécution du thread T2, le système d'exploitation doit initier un changement de contexte. Pour réaliser ce changement de contexte, le système d'exploitation initie le transfert du contenu des registres utilisés par le thread T1 vers une zone mémoire lui appartenant. Il transfère ensuite depuis une autre zone mémoire lui appartenant le contexte du thread T2. Si ce changement de contexte est effectué fréquemment, il peut donner l'illusion à l'utilisateur que plusieurs threads ou programmes s'exécutent simultanément.

Sur un système Unix, il y a deux types d'événements qui peuvent provoquer un changement de contexte.

Le hardware génère une interruption

Un thread exécute un appel système bloquant

Ces deux types d'événements sont fréquents et il est important de bien comprendre comment ils sont traités par le système d'exploitation.

Une interruption est un signal électronique qui est généré par un dispositif connecté au microprocesseur. De nombreux dispositifs d'entrées-sorties comme les cartes réseau ou les contrôleurs de disque peuvent générer une interruption lorsqu'une information a été lue ou reçue et doit être traitée par le processeur. En outre, chaque ordinateur dispose d'une horloge temps réel qui génère des interruptions à une fréquence déterminée par le système d'exploitation mais qui est généralement comprise entre quelques dizaines et quelques milliers de Hz. Ces interruptions nécessitent un traitement rapide de la part du système d'exploitation. Pour cela, le processeur vérifie, à la fin de l'exécution de chaque instruction si un signal d'interruption 3 est présent. Si c'est le cas, le processeur sauvegarde en mémoire le contexte du thread en cours d'exécution et lance une routine de traitement d'interruption faisant partie du système d'exploitation. Cette routine analyse l'interruption présente et lance les fonctions du système d'exploitation nécessaires à son traitement. Dans le cas d'une lecture sur disque, par exemple, la routine de traitement d'interruption permettra d'aller chercher la donnée lue sur le contrôleur de disques.

Le deuxième type d'événement est l'exécution d'un appel système bloquant. Un thread exécute un appel système chaque fois qu'il doit interagir avec le système d'exploitation. Ces appels peuvent être exécutés directement ou via une fonction de la librairie 4. Il existe deux types d'appels systèmes : les appels bloquants et les appels non-bloquants. Un appel système non-bloquant est un appel système que le système d'exploitation peut exécuter immédiatement. Cet appel retourne généralement une valeur qui fait partie du système d'exploitation lui-même. L'appel gettimeofday(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/gettimeofday.2.html] qui permet de récupérer l'heure actuelle est un exemple d'appel non-bloquant. Un appel système bloquant est un appel système dont le résultat ne peut pas toujours être fourni immédiatement. Les lectures d'information en provenance de l'entrée standard (et donc généralement du clavier) sont un bon exemple simple d'appel système bloquant. Considérons un thread qui exécute la fonction de la librairie getchar(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getchar.3.html] qui retourne un caractère lu sur stdin. Cette fonction utilise l'appel système read(2) [https://sites.uclouvain.be/SystInfo/manpages/man2/read.2.html] pour lire un caractère sur stdin. Bien entendu, le système d'exploitation est obligé d'attendre que l'utilisateur presse une touche sur le clavier pour pouvoir fournir le résultat de cet appel système à l'utilisateur. Pendant tout le temps qui s'écoule entre l'exécution de getchar(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getchar.3.html] et la pression d'une touche sur le clavier, le thread est bloqué par le système d'exploitation. Plus aucune instruction du thread n'est exécutée tant que la fonction getchar(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/getchar.3.html] ne s'est pas terminée et le contexte du thread est mis en attente dans une zone mémoire gérée par le système d'exploitation. Il sera redémarré automatiquement par le système d'exploitation lorsque la donnée attendue sera disponible.

Ces interactions entre les threads et le système d'exploitation sont importantes. Pour bien les comprendre, il est utile de noter qu'un thread peut se trouver dans trois états différents du point de vue de son interaction avec le système d'exploitation. Ces trois états sont illustrés dans la figure ci-dessous.

Etats d'un thread d'exécution

Lorsqu'un thread est créé avec la fonction pthread_create(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_create.3.html], il est placé dans l'état Ready. Dans cet état, les instructions du thread ne s'exécutent sur aucun processeur mais il est prêt à être exécuté dès qu'un processeur se libérera. Le deuxième état pour un thread est l'état Running. Dans cet état, le thread est exécuté sur un des processeurs du système. Le dernier état est l'état Blocked. Un thread est dans l'état Blocked lorsqu'il a exécuté un appel système bloquant et que le système d'exploitation attend l'information permettant de retourner le résultat de l'appel système. Pendant ce temps, les instructions du thread ne s'exécutent sur aucun processeur.

Les transitions entre les différents états d'un thread sont gérées par le système d'exploitation. Lorsque plusieurs threads d'exécution sont simultanément actifs, le système d'exploitation doit arbitrer les demandes d'utilisation du CPU de chaque thread. Cet arbitrage est réalisé par l'ordonnanceur (ou scheduler en anglais). Le scheduler est un ensemble d'algorithmes qui sont utilisés par le système d'exploitation pour sélectionner le ou les threads qui peuvent utiliser un processeur à un moment donné. Il y a souvent plus de threads qui sont dans l'état Ready que de processeurs disponibles et le scheduler doit déterminer quels sont les threads à exécuter.

Une description détaillée du fonctionnement d'un scheduler relève plutôt d'un cours sur les systèmes d'exploitation que d'un premier cours sur le langage C, mais il est important de connaître les principes de base de fonctionnement de quelques schedulers.

Un premier scheduler simple est le round-robin. Ce scheduler maintient en permanence une liste circulaire de l'ensemble des threads qui se trouvent dans l'état Ready et un pointeur vers l'élément courant de cette liste. Lorsqu'un processeur devient disponible, le scheduler sélectionne le thread référencé par ce pointeur. Ce thread passe dans l'état Running, est retiré de la liste et le pointeur est déplacé vers l'élément suivant dans la liste. Pour éviter qu'un thread ne puisse monopoliser éternellement un processeur, un scheduler round-robin limite généralement le temps qu'un thread peut passer dans l'état Running. Lorsqu'un thread a utilisé un processeur pendant ce temps, le scheduler vérifie si il y a un thread en attente dans l'état Ready. Si c'est le cas, le scheduler force un changement de contexte, place le thread courant dans l'état Ready et le remet dans la liste circulaire tout en permettant à un nouveau thread de passer dans l'état Running pour s'exécuter. Lorsqu'un thread revient dans l'état Ready, soit parce qu'il vient d'être créé ou parce qu'il vient de quitter l'état Blocked, il est placé dans la liste afin de pouvoir être sélectionné par le scheduler. Un scheduler round-robin est équitable. Avec un tel scheduler, si N threads sont actifs en permanence, chacun recevra \(\frac{1}{N}\) de temps CPU disponible.

Un second type de scheduler simple est le scheduler à priorités. Une priorité est associée à chaque thread. Lorsque le scheduler doit sélectionner un thread à exécuter, il commence d'abord par parcourir les threads ayant une haute priorité. En pratique, un scheduler à priorité maintiendra une liste circulaire pour chaque niveau de priorité. Lorsque le scheduler est appelé, il sélectionnera toujours le thread ayant la plus haute priorité et se trouvant dans l'état Ready. Si plusieurs threads ont le même niveau de priorité, un scheduler de type round-robin peut être utilisé dans chaque niveau de priorité. Sous Unix, le scheduler utilise un scheduler à priorité avec un round-robin à chaque niveau de priorité, mais la priorité varie dynamiquement en fonction du temps de façon à favoriser les threads interactifs.

Connaissant ces bases du fonctionnement des schedulers, il est utile d'analyser en détails quels sont les événements qui peuvent provoquer des transitions entre les états d'un thread. Certains de ces événements sont provoqués par le thread lui-même. C'est le cas de la transition entre l'état Running et l'état Blocked. Elle se produit lorsque le thread exécute un appel système bloquant. Dans ce cas, un processeur redevient disponible et le scheduler peut sélectionner un autre thread pour s'exécuter sur ce processeur. La transition entre l'état Blocked et l'état Running dépend elle du système d'exploitation, directement lorsque le thread a été bloqué par le système d'exploitation ou indirectement lorsque le système d'exploitation attend une information venant d'un dispositif d'entrées-sorties. Les transitions entre les états Running et Ready dépendent elles entièrement du système d'exploitation. Elles se produisent lors de l'exécution du scheduler. Celui-ci est exécuté lorsque certaines interruptions surviennent. Il est exécuté à chaque interruption d'horloge. Cela permet de garantir l'exécution régulière du scheduler même si les seuls threads actifs exécutent une boucle infinie telle que while(true);. A l'occasion de cette interruption, le scheduler mesure le temps d'exécution de chaque thread et si un thread a consommé beaucoup de temps CPU alors que d'autres threads sont dans l'état Ready, le scheduler forcera un changement de contexte pour permettre à un autre thread de s'exécuter. De la même façon, une interruption relative à un dispositif d'entrées-sorties peut faire transiter un thread de l'état Blocked à l'état Ready. Cette modification du nombre de threads dans l'état Ready peut forcer le scheduler à devoir effectuer un changement de contexte pour permettre à ce thread de poursuivre son exécution. Sous Unix, le scheduler utilise des niveaux de priorité qui varient en fonction des opérations d'entrées sorties effectuées. Cela a comme conséquence de favoriser les threads qui effectuent des opérations d'entrées sorties par rapport aux threads qui effectuent uniquement du calcul.

Note

Un thread peut demander de passer la main.

Dans la plupart de nos exemples, les threads cherchent en permanence à exécuter des instructions. Ce n'est pas nécessairement le cas de tous les threads d'un programme. Par exemple, une application de calcul scientifique pourrait être découpée en N+1 threads. Les N premiers threads réalisent le calcul tandis que le dernier calcule des statistiques. Ce dernier thread ne doit pas consommer de ressources et être en compétition pour le processeur avec les autres threads. La librairie thread POSIX contient la fonction pthread_yield(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_yield.3.html] qui peut être utilisée par un thread pour indiquer explicitement qu'il peut être remplacé par un autre thread. Si un thread ne doit s'exécuter qu'à intervalles réguliers, il est préférable d'utiliser des appels à sleep(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/sleep.3.html] ou usleep(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/usleep.3.html]. Ces fonctions de la librairie permettent de demander au système d'exploitation de bloquer le thread pendant un temps au moins égal à l'argument de la fonction.

Sur une machine monoprocesseur, tous les threads s'exécutent sur le même processeur. Une violation de section critique peut se produire lorsque le scheduler décide de réaliser un changement de contexte alors qu'un thread se trouve dans sa section critique. Si la section critique d'un thread ne contient ni d'appel système bloquant ni d'appel à pthread_yield(3) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_yield.3.html], ce changement de contexte ne pourra se produire que si une interruption survient. Une solution pour résoudre le problème de l'exclusion mutuelle sur un ordinateur monoprocesseur pourrait donc être la suivante :

disable_interrupts();
// début section critique
// ...
// fin section critique
enable_interrupts();

Cette solution est possible, mais elle souffre de plusieurs inconvénients majeurs. Tout d'abord, une désactivation des interruptions perturbe le fonctionnement du système puisque sans interruptions, la plupart des opérations d'entrées-sorties et l'horloge sont inutilisables. Une telle désactivation ne peut être que très courte, par exemple pour modifier une ou quelques variables en mémoire. Ensuite, la désactivation des interruptions, comme d'autres opérations relatives au fonctionnement du matériel, est une opération privilégiée sur un microprocesseur. Elle ne peut être réalisée que par le système d'exploitation. Il faudrait donc imaginer un appel système qui permettrait à un thread de demander au système d'exploitation de désactiver les interruptions. Si un tel appel système existait, le premier programme qui exécuterait disable_interrupts(); sans le faire suivre de enable_interrupts(); quelques instants après pourrait rendre la machine complètement inutilisable puisque sans interruption plus aucune opération d'entrée-sortie n'est possible et qu'en plus le scheduler ne peut plus être activé par l'interruption d'horloge. Pour toutes ces raisons, la désactivation des interruptions n'est pas un mécanisme utilisable par les threads pour résoudre le problème de l'exclusion mutuelle 5.

Coordination par Mutex

Le premier mécanisme de coordination entre threads dans la librairie POSIX sont les mutex. Un mutex (abréviation de mutual exclusion) est une structure de données qui permet de contrôler l'accès à une ressource. Un mutex qui contrôle une ressource peut se trouver dans deux états :

libre (ou unlocked en anglais). Cet état indique que la ressource est libre et peut être utilisée sans risquer de provoquer une violation d'exclusion mutuelle.

réservée (ou locked en anglais). Cet état indique que la ressource associée est actuellement utilisée et qu'elle ne peut pas être utilisée par un autre thread.

Un mutex est toujours associé à une ressource. Cette ressource peut être une variable globale comme dans les exemples précédents, mais cela peut aussi être une structure de données plus complexe, une base de données, un fichier, ... Un mutex s'utilise par l'intermédiaire de deux fonctions de base. La fonction lock permet à un thread d'acquérir l'usage exclusif d'une ressource. Si la ressource est libre, elle est marquée comme réservée et le thread y accède directement. Si la ressource est occupée, le thread est bloqué par le système d'exploitation jusqu'à ce qu'elle ne devienne libre. A ce moment, le thread pourra poursuivre son exécution et utilisera la ressource avec la certitude qu'aucun autre thread ne pourra faire de même. Lorsque le thread a terminé d'utiliser la ressource associée au mutex, il appelle la fonction unlock. Cette fonction vérifie d'abord si un ou plusieurs autres threads sont en attente pour cette ressource (c'est-à-dire qu'ils ont appelé la fonction lock mais celle-ci n'a pas encore réussi). Si c'est le cas, un (et un seul) thread est choisi parmi les threads en attente et celui-ci accède à la ressource. Il est important de noter qu'un programme ne peut faire aucune hypothèse sur l'ordre dans lequel les threads qui sont en attente sur un mutex pourront accéder à la ressource partagée. Le programme doit être conçu en faisant l'hypothèse que si plusieurs threads sont bloqués sur un appel à lock pour un mutex, le thread qui sera libéré est choisi aléatoirement.

Sans entrer dans des détails qui relèvent du fonctionnement internes des systèmes d'exploitation, on peut schématiquement représenter un mutex comme étant une structure de données qui contient deux informations :

la valeur actuelle du mutex ( locked ou unlocked)

une queue contenant l'ensemble des threads qui sont bloqués en attente du mutex

Schématiquement, l'implémentation des fonctions lock et unlock peut être représentée par le code ci-dessous.

lock(mutex m) {
 if(m.val==unlocked)
 {
   m.val=locked;
 }
 else
 {
   // Place this thread in m.queue;
   // This thread is blocked;
 }
}

Le fonction lock vérifie si le mutex est libre. Dans ce cas, le mutex est marqué comme réservé et la fonction lock réussi. Sinon, le thread qui a appelé la fonction lock est placé dans la queue associée au mutex et passe dans l'état Blocked jusqu'à ce qu'un autre thread ne libère le mutex.

unlock(mutex m) {
 if(m.queue is empty)
 {
   m.val=unlocked;
 }
 else
 {
  // Remove one thread(T) from m.queue;
  // Mark Thread(T) as ready to run;
 }
}

La fonction unlock vérifie d'abord l'état de la queue associée au mutex. Si la queue est vide, cela indique qu'aucun thread n'est en attente. Dans ce cas, la valeur du mutex est mise à unlocked et la fonction se termine. Sinon, un des threads en attente dans la queue associée au mutex est choisi et marqué comme prêt à s'exécuter. Cela indique implicitement que l'appel à lock fait par ce thread réussi et qu'il peut accéder à la ressource.

Le code présenté ci-dessous n'est qu'une illustration du fonctionnement des opérations lock et unlock. Pour que ces opérations fonctionnent correctement, il faut bien entendu que les modifications aux valeurs du mutex et à la queue qui y est associée se fassent en garantissant qu'un seul thread exécute l'une de ces opérations sur un mutex à un instant donné. En pratique, les implémentations de lock et unlock utilisent des instructions atomiques telles que celles qui ont été présentées dans la section précédente pour garantir cette propriété.

Les mutex sont fréquemment utilisés pour protéger l'accès à une zone de mémoire partagée. Ainsi, si la variable globale g est utilisée en écriture et en lecture par deux threads, celle-ci devra être protégée par un mutex. Toute modification de cette variable devra être entourée par des appels à lock et unlock.

En C, cela se fait en utilisant les fonctions pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html] et pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html]. Un mutex POSIX est représenté par une structure de données de type pthread_mutex_t qui est définie dans le fichier pthread.h [https://sites.uclouvain.be/SystInfo/usr/include/pthread.h.html]. Avant d'être utilisé, un mutex doit être initialisé via la fonction pthread_mutex_init(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_init.3posix.html] et lorsqu'il n'est plus nécessaire, les ressources qui lui sont associées doivent être libérées avec la fonction pthread_mutex_destroy(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_destroy.3posix.html].

L'exemple ci-dessous reprend le programme dans lequel une variable globale est incrémentée par plusieurs threads.

#include <pthread.h>
#define NTHREADS 4

long global=0;
pthread_mutex_t mutex_global;

int increment(int i) {
  return i+1;
}

void *func(void * param) {
  int err;
  for(int j=0;j<1000000;j++) {
    err=pthread_mutex_lock(&mutex_global);
    if(err!=0)
      error(err,"pthread_mutex_lock");
    global=increment(global);
    err=pthread_mutex_unlock(&mutex_global);
    if(err!=0)
      error(err,"pthread_mutex_unlock");
  }
  return(NULL);
}

int main (int argc, char *argv[])  {
  pthread_t thread[NTHREADS];
  int err;

  err=pthread_mutex_init( &mutex_global, NULL);
  if(err!=0)
      error(err,"pthread_mutex_init");

  for(int i=0;i<NTHREADS;i++) {
    err=pthread_create(&(thread[i]),NULL,&func,NULL);
    if(err!=0)
      error(err,"pthread_create");
  }
  for(int i=0; i<1000000000;i++) { /*...*/ }

  for(int i=NTHREADS-1;i>=0;i--) {
    err=pthread_join(thread[i],NULL);
    if(err!=0)
      error(err,"pthread_join");
  }

  err=pthread_mutex_destroy(&mutex_global);
  if(err!=0)
    error(err,"pthread_mutex_destroy");

  printf("global: %ld\n",global);

  return(EXIT_SUCCESS);
}

Il est utile de regarder un peu plus en détails les différentes fonctions utilisées par ce programme. Tout d'abord, la ressource partagée est ici la variable global. Dans l'ensemble du programme, l'accès à cette variable est protégé par le mutex mutex_global. Celui-ci est représenté par une structure de données de type pthread_mutex_t.

Avant de pouvoir utiliser un mutex, il est nécessaire de l'initialiser. Cette initialisation est effectuée par la fonction pthread_mutex_init(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_init.3posix.html] qui prend deux arguments 6. Le premier est un pointeur vers une structure pthread_mutex_t et le second un pointeur vers une structure pthread_mutexattr_t contenant les attributs de ce mutex. Tout comme lors de la création d'un thread, ces attributs permettent de spécifier des paramètres à la création du mutex. Ces attributs peuvent être manipulés en utilisant les fonctions pthread_mutexattr_gettype(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutexattr_gettype.3posix.html] et pthread_mutexattr_settype(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutexattr_settype.3posix.html]. Dans le cadre de ces notes, nous utiliserons exclusivement les attributs par défaut et créerons toujours un mutex en passant NULL comme second argument à la fonction pthread_mutex_init(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_init.3posix.html].

Lorsqu'un mutex POSIX est initialisé, la ressource qui lui est associée est considérée comme libre. L'accès à la ressource doit se faire en précédant tout accès à la ressource par un appel à la fonction pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html]. En fonction des attributs spécifiés à la création du mutex, il peut y avoir de très rares cas où la fonction retourne une valeur non nulle. Dans ce cas, le type d'erreur est indiqué via errno. Lorsque le thread n'a plus besoin de la ressource protégée par le mutex, il doit appeler la fonction pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] pour libérer la ressource protégée.

pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html] et pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] sont toujours utilisés en couple. pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html] doit toujours précéder l'accès à la ressource partagée et pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] doit être appelé dès que l'accès exclusif à la ressource partagée n'est plus nécessaire.

L'utilisation des mutex permet de résoudre correctement le problème de l'exclusion mutuelle. Pour s'en convaincre, considérons le programme ci-dessus et les threads qui exécutent la fonction func. Celle-ci peut être résumée par les trois lignes suivantes :

pthread_mutex_lock(&mutex_global);
global=increment(global);
pthread_mutex_unlock(&mutex_global);

Pour montrer que cette solution répond bien au problème de l'exclusion mutuelle, il faut montrer qu'elle respecte la propriété de sûreté et la propriété de vivacité. Pour la propriété de sûreté, c'est par construction des mutex et parce que chaque thread exécute pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html] avant d'entrer en section critique et pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] dès qu'il en sort. Considérons le cas de deux threads qui sont en concurrence pour accéder à cette section critique. Le premier exécute pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html]. Il accède à sa section critique. A partir de cet instant, le second thread sera bloqué dès qu'il exécute l'appel à pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html]. Il restera bloqué dans l'exécution de cette fonction jusqu'à ce que le premier thread sorte de sa section critique et exécute pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html]. A ce moment, le premier thread n'est plus dans sa section critique et le système peut laisser le second y entrer en terminant l'exécution de l'appel à pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html]. Si un troisième thread essaye à ce moment d'entrer dans la section critique, il sera bloqué sur son appel à pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html].

Pour montrer que la propriété de vivacité est bien respectée, il faut montrer qu'un thread ne sera pas empêché éternellement d'entrer dans sa section critique. Un thread peut être empêché d'entrer dans sa section critique en étant bloqué sur l'appel à pthread_mutex_lock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_lock.3posix.html]. Comme chaque thread exécute pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] dès qu'il sort de sa section critique, le thread en attente finira par être exécuté. Pour qu'un thread utilisant le code ci-dessus ne puisse jamais entrer en section critique, il faudrait qu'il y aie en permanence plusieurs threads en attente sur pthread_mutex_unlock(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_unlock.3posix.html] et que notre thread ne soit jamais sélectionné par le système lorsque le thread précédent termine sa section critique.

Footnotes

1

Le programme complet est accessible via ./S5-src/pthread-test.c

2

Le programme complet est accessible via ./S6-src/pthread.c

3

De nombreux processeurs supportent plusieurs signaux d'interruption différents. Dans le cadre de ce cours, nous nous limiterons à l'utilisation d'un seul signal de ce type.

4

Les appels systèmes sont décrits dans la section 2 des pages de manuel tandis que la section 3 décrit les fonctions de la librairie.

5

Certains systèmes d'exploitation utilisent une désactivation parfois partielle des interruptions pour résoudre des problèmes d'exclusion mutuelle qui portent sur quelques instructions à l'intérieur du système d'exploitation lui-même. Il faut cependant noter qu'une désactivation des interruptions peut être particulièrement coûteuse en termes de performances dans un environnement multiprocesseurs.

6

Linux supporte également la macro PTHREAD_MUTEX_INITIALIZER qui permet d'initialiser directement un pthread_mutex_t déclaré comme variable globale. Dans cet exemple, la déclaration aurait été : pthread_mutex_t global_mutex=PTHREAD_MUTEX_INITIALIZER; et l'appel à pthread_mutex_init(3posix) [https://sites.uclouvain.be/SystInfo/manpages/man3/pthread_mutex_init.3posix.html] aurait été inutile. Comme il s'agit d'une extension spécifique à Linux, il est préférable de ne pas l'utiliser pour garantir la portabilité du code.

7

Le programme complet est ./S6-src/pthread-philo.c