Programmation Système C sous Unix
Programmation Système C sous Unix
Partie III
Le système d'exploitation sur lequel vous travaillerez devra faire partie de la « famille Unix
», dont font partie GNU/Linux, Mac OS, Free BSD....
Il faut savoir que le langage C, à partir duquel nous programmerons, a été créé spécialement
pour la programmation système, plus précisément pour le développement du système
d'exploitation... UNIX. Il est donc particulièrement adapté à ce type de programmation.
Avant-propos
Avant de nous jeter corps et âme dans la programmation système, commençons par étudier
quelques notions théoriques sur la programmation système et sur la famille Unix.
A la fin de ce premier chapitre, vous saurez :
Aperçu général
Ce cours a pour but de vous apprendre à maîtriser toutes les finesses de la programmation
système. La programmation système permet de créer des drivers, communiquer avec les
périphériques, voire même créer un système d'exploitation !Par exemple, les
emblématiques Apache, Emacs, gcc, gdb ou encore glibc sont des programmes systèmes.
1
Programmation système unix
Une couche logicielle qui a accès au matériel informatique s'appelle une couche
d'abstraction matérielle.
Le noyau est une sorte de logiciel d'arrière-plan qui assure les communications entre ces
programmes. C'est donc par lui qu'il va nous falloir passer pour avoir accès aux informations
du système.
Pour accéder à ces informations, nous allons utiliser des fonctions qui permettent de
communiquer avec le noyau. Ces fonctions s'appellent des appels-systèmes.
De manière plus théorique, le terme « appel-système » désigne l'appel d'une fonction, qui,
depuis l'espace utilisateur, demande des services ou des ressources au système d'exploitation.
Par exemple, les fonctions read et write sont des appels-systèmes.
Pour des raisons de sécurité évidentes, les applications de l'espace utilisateur ne peuvent pas
directement exécuter le code du noyau ou manipuler ses données. Par conséquent, un
mécanisme de signaux a été mis en place. Quand une application exécute un appel-système,
elle peut alors effectuer un trap, et peut exécuter le code, du moment que le noyau le lui
autorise.
On peut également qualifier le système de multitâche, ce qui signifie qu'il est capable de
gérer l'exécution de plusieurs programmes en simultanée et de multi-utilisateur car il permet
que plusieurs utilisateurs aient accès au même ordinateur en même temps, et qu'ils puissent
profiter des mêmes ressources.
1- Conventions typographiques
Derrière ce titre un peu barbare, je vais vous montrer comment ce cours va être
administré pour mettre en valeur les expressions :
Noms propres, matériaux en italique Exemple : Sous un
environnement GNOME de Linux, quand j'ai ouvert l'analyseur d'utilisation des disques, rien
ne s'est passé.
Police courrier pour les chemins ou noms de répertoire/fichiers Exemple : Rendez-
vous dans le répertoire /usr/include et ouvrez le fichier nommé passwd.
Mots importants en gras Exemple : Nous allons utiliser un appel-système qui va
dupliquer le processus appelant.
2
Programmation système unix
Constantes définies par le système en violet Exemple : Il existe deux constantes pour
cela : STDIN_FILENO et STDOUT_FILENO.
2- Gestion des erreurs
Avec la programmation système, nous allons étudier et manipuler tout ce qui touche à votre
système d'exploitation.
Ainsi, nous devrons faire face assez souvent à des codes d'erreurs. La gestion des erreurs est
donc un élément primordial dans la programmation système.
Pour signaler une erreur, les fonctions renvoient une valeur spéciale, indiquée dans leur
documentation. Celle-ci est généralement -1 (sauf pour quelques exceptions). La valeur
d'erreur alerte l'appelant de la survenance d'une erreur, mais elle ne fournit pas la description
de ce qui s'est produit. La variable globale errno est alors utilisée pour en trouver la cause.
#include <errno.h>
Sa valeur est valable uniquement juste après l'utilisation de la fonction que l'on veut tester. En
effet, si on utilise une autre fonction entre le retour que l'on veut tester et l'exploitation de la
variable de errno, la valeur de errno peut être modifiée entre temps.
A chaque valeur possible de errno correspond une constante du préprocesseur. Pour les
connaître, je vous conseille de taper la commande man errno. Une description de chaque
erreur y est .
3- La fonction perror
3
Programmation système unix
Dans cette première sous-partie, nous allons découvrir la notion de processus de façon
généraliste, c'est-à-dire les concepts de ces derniers présents dans tous les systèmes
d'exploitation qui les utilisent.
2- Parallélisme et pseudo-parallélisme
3- Programmes et processus
4
Programmation système unix
C'est quoi ce vide au milieu ? La pile empiète sur cet espace de manière automatique et
l'extension du segment de données commence lors d'une allocation dynamique.
Le segment de texte (le code), quant à lui, ne bouge pas.
Un système d'exploitation est préemptif lorsqu'il peut arrêter à tout moment n'importe
quelle application pour passer à la suivante (exemple : Windows XP, Windows 7 et
GNU/Linux sont des systèmes préemptifs). Il peut aussi être coopératif quand il permet à
plusieurs applications de fonctionner et d'occuper la mémoire, et leur laissant le soin de
gérer cette occupation (exemple : Windows 95, 98 et Millénium sont des systèmes
coopératifs).
En résumé :
5
Programmation système unix
Vous aurez donc compris que le multitâche coopératif est plus « dangereux » pour le
système, car risque de blocage si une application fait des siennes. Enfin, dernière chose à
retenir : les systèmes basés sur Unix sont des systèmes préemptifs.
Nous allons maintenant voir certaines notions et termes de vocabulaire relatifs aux
processus, en étudiant leur implémentation sous Unix ainsi que la Particularités de la gestion
des processus sous Unix
Dans les systèmes basés sur Unix tout particulièrement, les processus jouent un rôle
très important. Le concept de processus a été mis au point dès les débuts de ce système : il
a ainsi participé à sa gloire et à sa célébrité. Une des particularités de la gestion des
processus sous Unix consiste à séparer la création d'un processus et l'exécution d'une
image binaire. Bien que la plupart du temps ces deux tâches sont exécutées ensemble, cette
division a permis de nouvelles libertés quant à la gestion des tâches. Par exemple, cela
permet d'avoir plusieurs processus pour un même programme.
Autrement dit, sous les autres systèmes d'exploitation (mis à part quelques exceptions),
processus = nouveau programme, alors que sous Unix ce n'est pas forcément le cas.
Ce principe, peu utilisé dans les autres systèmes, a survécu de nos jours. Alors que la
plupart des systèmes d'exploitation offrent un seul appel-système pour exécuter un
nouveau programme, Unix en possède deux : fork et exec (nous étudierons ce dernier dans
le troisième chapitre).
a) PID
Lorsque l'on crée un processus (nous verrons comment faire dans la suite du chapitre),
on utilise une fonction qui permet de dupliquer le processus appelant. On distingue alors
les deux processus par leur PID. Le processus appelant est alors nommé processus père et
le nouveau processus processus fils. Quant on s'occupe du processus fils, le PID du
processus père est noté PPID (Parent PID).
Attribution du PID
Par défaut, le noyau attribue un PID avec une valeur inférieure à 32768. Le
32768ème processus créé reçoit la plus petite valeur de PID libéré par un processus
mort entre-temps (paix à son âme... :p ). Par ailleurs, cette valeur maximale peut être
changée par l'administrateur en modifiant la valeur du fichier
/proc/sys/kernel/pid_max.
De plus, les PID sont attribués de façon linéaire. Par exemple, si 17 est le PID le plus
élevé affecté, un processus créé à cet instant aura comme PID 18. Le noyau réutilise
les PID de processus n'existant plus uniquement quand la valeur de pid_max est
atteinte.
6
Programmation système unix
b) UID
Les systèmes basés sur Unix sont particulièrement axés sur le côté multi-utilisateur.
Ainsi, il existe de très nombreuses sécurités sur les permissions nécessaires pour exécuter
telle ou telle action.
C'est pour cela que chaque utilisateur possède un identifiant, sous forme numérique,
nommé UID (User IDentifier).
En conséquence, nous pouvons également distinguer les processus entre eux par l'UID de
l'utilisateur qui les a lancés.
Pour examiner tous les utilisateurs de l'ordinateur, allez dans /etc et ouvrez le fichier
texte nommé passwd.
Ce fichier rassemble des informations sur tous les utilisateurs ayant un compte sur le
système ; une ligne par utilisateur et 7 champs par ligne, séparés par le caractère deux-
points ( : ).
En fait, l'analyse de ce fichier ne nous importe peu mais cela peut vous permettre de
connaître les différents utilisateurs de l'ordinateur (les plus connus étant root et vous-
même ; mais vous pouvez remarquer qu'il en existe plein d'autres!).
Il existe une permission spéciale, uniquement pour les exécutables binaires, appelée la
permission Set – UID. Cette permission permet à l'utilisateur ayant les droits d'exécution
sur ce fichier d'exécuter le fichier avec les privilèges de son propriétaire. On met les droits
Set - UID avec la commande chmod et l'argument +s. On passe en second argument le
nom du fichier.
Exemple :
7
Programmation système unix
$ ls -l
total 4 -rw-r--r-- 1 lucas lucas 290 2010-12-01 15:39 zombie.sh
$ chmod +s zombie.sh
$ ls -l
total 4 -rwSr-Sr-- 1 lucas lucas 290 2010-12-01 15:39 zombie.sh
c) GID
Connaître le GID d'un processus n'est pas capital, c'est pourquoi nous ne nous arrêterons
pas sur ce point.
Les processus sont organisés en hiérarchie. Chaque processus doit être lancé par un
autre (rappelez-vous les notions sur processus père et processus fils). La racine de cette
hiérarchie est le programme initial. En voici quelques explications.
Le premier de ces processus qui existe est exécuté en tant que programme initial.
Si les quatre programmes n'ont pas pu être exécutés, le système s'arrête : panique du
noyau...
De plus, sous Unix, un processus peut évoluer dans deux modes différents : le
mode noyau et le mode utilisateur
utilisateur.. Généralement, un processus utilisateur entre dans le
mode noyau quand il effectue un appel
appel-système.
9
Programmation système unix
Grâce à ces informations stockées dans la table des processus, un processus bloqué pourra
redémarrer ultérieurement avec les mêmes caractéristiques.
$ ps
Pour afficher tous les processus en cours d'exécution, on peut utiliser l'option aux (a :
processus de tous les utilisateurs ; u : affichage détaillé ; y : démons) :
$ ps aux
Dans le résultat qui s'affiche, vous pouvez voir la liste de tous vos processus en cours
d'exécution.
10
Programmation système unix
TRAVAUX PRATIQUE I
Création d'un nouveau processus
Terminaison d'un processus
Terminaison d'un programme
Un programme peut se terminer de façon normale (volontaire) ou anormale (erreurs).
Terminaison normale d'un processus
void exit(status);
Celle-ci a pour avantage de quitter le programme quel que soit la fonction dans laquelle on se
trouve.
Par exemple, avec ce code :
#include <stdio.h>
#include <stdlib.h>
voidquit(void)
{
printf(" Nous sommes dans la fonction quit().\n");
exit(EXIT_SUCCESS);
}
int main(void)
{
quit();
printf(" Nous sommes dans le main.\n");
return EXIT_SUCCESS;
}
$ ./a.out
Nous sommes dans la fonction quitterProgramme.
L'exécution montre que l'instruction printf("Nous sommes dans le main. "); n'est pas
exécutée. Le programme s'est arrêté à la lecture de exit(EXIT_SUCCESS);.
11
Programmation système unix
Il existe également une fonction qui permet de suspendre l'exécution d'un processus père
jusqu'à ce qu'un de ses fils, dont on doit passer le PID en paramètre, se termine. Il s'agit de la
fonction waitpid :
#include <sys/wait.h>
si pid> 0, le processus père est suspendu jusqu'à la fin d'un processus fils dont le PID est
égal à la valeur pid ;
si pid = 0, le processus père est suspendu jusqu'à la fin de n'importe lequel de ses fils
appartenant à son groupe ;
si pid = -1, le processus père est suspendu jusqu'à la fin de n'importe lequel de ses fils ;
si pid< -1, le processus père est suspendu jusqu'à la mort de n'importe lequel de ses fils
dont le GID est égal.
Si l'on résume : un processus peut se terminer de façon normale (return ou exit) ou bien
anormale (assert). Le processus existe toujours mais devient un zombie jusqu'à ce
que wait soit appelé ou que le père meure.
Le code de retour du processus est stocké dans l'emplacement pointé par status.
Pour avoir toutes ces informations, nous pouvons utiliser les macros suivantes :
Macro Description
Elle renvoie vrai si le statut provient d'un processus fils qui s'est terminé
WIFEXITED(status)
en quittant le main avec return ou avec un appel à exit.
Elle renvoie le code de retour du processus fils passé à exit ou à return.
WEXITSTATUS(status)
Cette macro est utilisable uniquement si vous avez
utilisé WIFEXITED avant, et que cette dernière a renvoyé vrai.
WIFSIGNALED(status) Elle renvoie vrai si le statut provient d'un processus fils qui s'est terminé
12
Programmation système unix
Macro Description
à cause d'un signal.
Elle renvoie la valeur du signal qui a provoqué la terminaison du
processus fils.
WTERMSIG(status)
Cette macro est utilisable uniquement si vous avez
utilisé WIFSIGNALED avant, et que cette dernière a renvoyé vrai.
Ces quatre macros sont les plus utilisées et les plus utiles. Il en existe d'autres : pour plus
d'infos, un petit man 2 wait devrait vous aider.
Premier code :
13
Programmation système unix
void child_process(void)
{
printf(" Nous sommes dans le fils !\n"
" Le PID du fils est %d.\n"
" Le PPID du fils est %d.\n", (int) getpid(), (int) getppid());
}
if (wait(&status) == -1) {
perror("wait :");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf(" Terminaison normale du processus fils.\n"
" Code de retour : %d.\n", WEXITSTATUS(status));
}
if (WIFSIGNALED(status)) {
printf(" Terminaison anormale du processus fils.\n"
" Tué par le signal : %d.\n", WTERMSIG(status));
}
int main(void)
{
pid_tpid = create_process();
switch (pid) {
/* Si on a une erreur irrémédiable (ENOMEM dans notre cas) */
case -1:
perror("fork");
return EXIT_FAILURE;
break;
/* Si on est dans le fils */
case 0:
child_process();
break;
/* Si on est dans le père */
default:
father_process(pid);
break;
}
14
Programmation système unix
return EXIT_SUCCESS;
}
Résultat :
$ ./a.out
Nous sommes dans le père
Le PID du fils est 1510
Le PID du père est 1508
Nous sommes dans le fils
Le PID du fils est 1510
Le PPID du fils est 1508
Terminaison normale du processus fils.
Code de retour : 0.
$
Deuxième code :
15
Programmation système unix
if (wait(&status) == -1) {
perror("wait :");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf(" Terminaison normale du processus fils.\n"
" Code de retour : %d.\n", WEXITSTATUS(status));
}
if (WIFSIGNALED(status)) {
printf(" Terminaison anormale du processus fils.\n"
" Tué par le signal : %d.\n", WTERMSIG(status));
}
int main(void)
{
pid_tpid = create_process();
switch (pid) {
/* Si on a une erreur irrémédiable (ENOMEM dans notre cas) */
case -1:
perror("fork");
return EXIT_FAILURE;
break;
/* Si on est dans le fils */
case 0:
child_process();
break;
/* Si on est dans le père */
16
Programmation système unix
default:
father_process(pid);
break;
}
return EXIT_SUCCESS;
}
Résultat :
$ ./a.out
Nous sommes dans le père
Le PID du fils est 11433
Le PID du père est 11431
Nous sommes dans le fils
Le PID du fils est 11433
Le PPID du fils est 11431
Terminaison normale du processus fils.
Code de retour : 1.
$
Troisième code :
17
Programmation système unix
if (wait(&status) == -1) {
perror("wait :");
exit(EXIT_FAILURE);
}
if (WIFEXITED(status)) {
printf(" Terminaison normale du processus fils.\n"
" Code de retour : %d.\n", WEXITSTATUS(status));
}
if (WIFSIGNALED(status)) {
printf(" Terminaison anormale du processus fils.\n"
" Tué par le signal : %d.\n", WTERMSIG(status));
}
int main(void)
{
pid_tpid = create_process();
switch (pid) {
/* Si on a une erreur irrémédiable (ENOMEM dans notre cas) */
case -1:
perror("fork");
return EXIT_FAILURE;
break;
/* Si on est dans le fils */
18
Programmation système unix
case 0:
child_process();
break;
/* Si on est dans le père */
default:
father_process(pid);
break;
}
return EXIT_SUCCESS;
}
Résultat :
$ ./a.out
Le PID du fils est 18745
Le PID du père est 18743
Nous sommes dans le fils
Le PID du fils est 18745
Le PPID du fils est 18743
$ kill 18745
$
$ ./a.out
Le PID du fils est 18745
Le PID du père est 18743
Nous sommes dans le fils
Le PID du fils est 18745
Le PPID du fils est 18743
Terminaison anormale du processus fils.
Tué par le signal : 15.
$
Bon, pour clore ce chapitre, voici un exercice très facile que vous pouvez réaliser si vous
avez bien suivi ce chapitre.
Écrire un programme qui crée un fils. Le père doit afficher « Je suis le père » et le fils doit
afficher « Je suis le fils ».
19
Programmation système unix
Correction :
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/wait.h>
#include <sys/types.h>
int main(void)
{
pid_tpid_fils;
do {
pid_fils = fork();
} while ((pid_fils == -1) && (errno == EAGAIN));
if (pid_fils == -1) {
perror("fork");
} else if (pid_fils == 0) {
printf("Je suis le fils");
} else {
printf("Je suis le père");
if (wait(NULL) == -1) {
perror("wait :");
}
}
return EXIT_SUCCESS;
}
Vous pouvez aussi vous amuser à créer plusieurs processus, qui exécutent chacun une tâche
spécifique.
20
Programmation système unix
Argc est un entier de type int qui donne le nombre d'arguments passés en ligne de
commande plus 1.
Argv est un tableau de pointeurs. Argv[0] contient de nom du fichier exécutable du
programme. Les cases suivantes argv[1], argv[2], etc. contiennent les arguments
passés en ligne de commande. Enfin, argv[argc] doit obligatoirement être NULL.
Fort de ces indications, écrivez un programme qui prend des arguments et qui affiche :
Argument 1 : ...
Argument 2 : ...
etc.
Correction !
#include <stdio.h>
#include <stdlib.h>
/* Si il en a reçu un ou plus */
else {
21
Programmation système unix
return EXIT_SUCCESS;
}
Résultat :
Argument 1 : je
Argument 2 : mange
Argument 3 : de
Argument 4 : la
Argument 5 : choucroute
$
La variable PATH
La variable PATH contient une série de chemin vers des répertoires qui contiennent des
exécutables ou des scripts de commande.
Comme toute variable qui se respecte, on peut afficher sa valeur avec la commande :
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games
Lorsqu'on lance une commande dans la console, le système va chercher l'exécutable dans les
chemins donnés dans le PATH. Pour le PATH donné en exemple, le noyau ira chercher
l'exécutable dans /usr/local/sbin, puis dans /usr/local/bin, etc... En conséquence, si deux
commandes portent le même nom, c'est la première trouvée qui sera exécutée.
L'environnement
Une application peut être exécutée dans des contextes différents : terminaux, répertoire de
travail...
C'est pourquoi le programmeur système a souvent besoin d'accéder à l'environnement.
Celui-ci est définit sous la forme de variables d'environnement.
Variables d'environnement
22
PProgrammation système unix
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int i;
return EXIT_SUCCESS;
}
ORBIT_SOCKETDIR=/tmp/orbit
ORBIT_SOCKETDIR=/tmp/orbit-lucas
SSH_AGENT_PID=1412
GPG_AGENT_INFO=/tmp/gpg
GPG_AGENT_INFO=/tmp/gpg-oRe8EV/S.gpg-agent:1413:1
TERM=xterm
SHELL=/bin/bash
XDG_SESSION_COOKIE=66803415d39a03f0d150db3d0000000f
XDG_SESSION_COOKIE=66803415d39a03f0d150db3d0000000f-1300522273.
1300522273.896593-
1253931584
WINDOWID=119537667
GNOME_KEYRING_CONTROL=/tmp/keyring
GNOME_KEYRING_CONTROL=/tmp/keyring-noSBVu
GTK_MODULES=canberra
GTK_MODULES=canberra-gtk-module:gail:atk-bridge
USER=lucas
23
Programmation système unix
SSH_AUTH_SOCK=/tmp/keyring-noSBVu/ssh
SESSION_MANAGER=local/lucas-Deskstop:@/tmp/.ICE-unix/1352,unix/lucas-
Deskstop:/tmp/.ICE-unix/1352
USERNAME=lucas
DEFAULTS_PATH=/usr/share/gconf/gnome.default.path
XDG_CONFIG_DIRS=/etc/xdg/xdg-gnome:/etc/xdg
DESKTOP_SESSION=gnome
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games
PWD=/home/lucas/Documents
GDM_KEYBOARD_LAYOUT=fr oss
LANG=fr_FR.UTF-8
MANDATORY_PATH=/usr/share/gconf/gnome.mandatory.path
GDM_LANG=fr_FR.UTF-8
GDMSESSION=gnome
SHLVL=1
HOME=/home/lucas
GNOME_DESKTOP_SESSION_ID=this-is-deprecated
LOGNAME=lucas
XDG_DATA_DIRS=/usr/share/gnome:/usr/local/share/:/usr/share/
DBUS_SESSION_BUS_ADDRESS=unix:abstract=/tmp/dbus-
XTApH2KvXM,guid=7d07bc72de6da00b353c54e400000013
LESSOPEN=| /usr/bin/lesspipe %s
WINDOWPATH=7
DISPLAY=:0.0
LESSCLOSE=/usr/bin/lesspipe %s %s
XAUTHORITY=/var/run/gdm/auth-for-lucas-N38PVj/database
COLORTERM=gnome-terminal
OLDPWD=/home/lucas
_=./a.out
La fonction :
sert à créer une variable d'environnement. Elle prend en argument une chaîne de caractère du
type « NOM=VALEUR ».
24
Programmation système unix
La fonction :
sert à modifier une variable d'environnement. Elle prend en argument le nom de la variable,
la valeur à affecter et si on écrase la précédente valeur de la variable (si il y en a une) ou pas
(1 pour l'écraser, 0 sinon).
Enfin, la fonction :
#include <stdio.h>
#include <stdlib.h>
if (argc<= 1) {
fprintf(stderr, "Le programme n'a reçu aucun argument.\n");
return EXIT_FAILURE;
}
if (!variable) {
printf("%s : n'existe pas\n", argv[i]);
} else {
printf("%s : %s\n", argv[i], variable);
}
}
25
Programmation système unix
return EXIT_SUCCESS;
}
Exemple d'exécution :
Bien, nous allons maintenant rentrer dans le vif du sujet de ce chapitre : dans cette sous-
partie, vous allez pouvoir lancer des programmes.
Pour cela, je dois vous présenter une famille de fonction nous permettant de réaliser cela : la
famille exec. En réalité, il existe six fonctions appartenant à cette famille
: execl, execle, execlp, execv, execve et execvp. Parmi eux, seule la fonction execve est un
appel-système, les autres sont implémentées à partir de celui-ci. Ces fonctions permettent de
remplacer un programme en cours par un autre programme sans en changer le PID.
Autrement dit, on peut remplacer le code source d'un programme par celui d'un autre
programme en faisant appel à une fonction exec.
Voici leurs prototypes :
26
Programmation système unix
De plus, toutes ces fonctions renvoient -1. errno peut correspondre à plusieurs constantes,
dont EACCESS (vous n'avez pas les permissions nécessaires pour exécuter le programme),
E2BIG (la liste d'argument est trop grande), ENOENT (le programme n'existe pas),
ETXTBSY (le programme a été ouvert en écriture par d'autres processus), ENOMEM (pas
assez de mémoire), ENOEXEC (le fichier exécutable n'a pas le bon format) ou encore
ENOTDIR (le chemin d'accès contient un nom de répertoire incorrect).
Seul execve est un appel-système, et les autres ne sont que ses dérivés. La logique de
mon sadisme ( :diable: ) voudrait qu'on l'utilise « par défaut », mais je vous conseille plutôt
la fonction execv qui fonctionne de la même manière que execve, mis à part que vous
n'avez pas à vous soucier de l'environnement.
Maintenant, écrivez un programme qui lance la commande ps. Vous pouvez la trouver dans
le dossier /bin.
Correction :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
/* Tableau de char contenant les arguments (là aucun : le nom du
programme et NULL sont obligatoires) */
char *arguments[] = { "ps", NULL };
/* Lancement de la commande */
if (execv("/bin/ps", arguments) == -1) {
perror("execv");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
$ ./a.out
PID TTY TIME CMD
1762 pts/0 00:00:00 bash
1845 pts/0 00:00:00 ps
Maintenant que vous pouvez remarquer que ça a marché, créez un programme qui
prend en argument le chemin complet d'un répertoire et qui ouvre l'analyseur d'utilisation
27
Programmation système unix
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
/* On lance le programme */
if (execv("/usr/bin/baobab", arguments) == -1) {
perror("execv");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
28
Programmation système unix
switch (pid) {
/* Si on a une erreur irrémédiable (ENOMEM dans notre cas) */
case -1:
perror("fork");
return EXIT_FAILURE;
break;
/* Si on est dans le fils */
case 0:
son_process(arg);
break;
/* Si on est dans le père */
default:
29
Programmation système unix
father_process();
break;
}
return EXIT_SUCCESS;
}
La fonction system est semblable aux exec, mais elle est beaucoup plus simple d'utilisation.
En revanche, on ne peut pas y passer d'arguments. Son prototype est
#include <stdlib.h>
Écrivez un programme qui lance la commande clear qui permet d'effacer le contenu de la
console.
30
Programmation système unix
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
system("clear");
return EXIT_SUCCESS;
}
31
Programmation système unix
Vous avez pu remarquer, lors de notre étude des processus, qu'en général, le système
réserve un processus à chaque application, sauf quelques exceptions. Beaucoup de
programmes exécutent plusieurs activités en parallèle, du moins en apparent parallélisme,
comme nous l'avons vu précédemment. Comme à l'échelle des processus, certaines de ces
activités peuvent se bloquer, et ainsi réserver ce blocage à un seul thread séquentiel,
permettant par conséquent de ne pas stopper toute l'application.
Ensuite, il faut savoir que le principal avantage des threads par rapport aux processus, c'est
la facilité et la rapidité de leur création. En effet, tous les threads d'un même processus
partagent le même espace d'adressage, et donc toutes les variables. Cela évite donc
l'allocation de tous ces espaces lors de la création, et il est à noter que, sur de nombreux
systèmes, la création d'un thread est environ cent fois plus rapide que celle d'un processus.
Au-delà de la création, la superposition de l'exécution des activités dans une même
application permet une importante accélération quant au fonctionnement de cette dernière.
Sachez également que la communication entre les threads est plus aisée que celle entre les
processus, pour lesquels on doit utiliser des notions compliquées comme les tubes (voir
chapitre suivant).
2- Compilation
Toutes les fonctions relatives aux threads sont incluses dans le fichier d'en-
tête <pthread.h> et dans la bibliothèque libpthread.a (soit -lpthread à la compilation).
Exemple :
Écrivez la ligne de commande qui vous permet de compiler votre programme sur
les threads constitué d'un seul fichier main.c et avoir en sortie un exécutable
nommé monProgramme.
Correction :
a) Créer un thread
Pour créer un thread, il faut déjà déclarer une variable le représentant. Celle-ci sera de
type pthread_t (qui est, sur la plupart des systèmes, un typedef d'unsigned long int).
Ensuite, pour créer la tâche elle-même, il suffit d'utiliser la fonction :
32
Programmation système unix
#include <pthread.h>
Ce prototype est un peu compliqué, c'est pourquoi nous allons récapituler ensemble.
La fonction renvoie une valeur de type int : 0 si la création a été réussie ou une autre
valeur si il y a eu une erreur.
Le premier argument est un pointeur vers l'identifiant du thread (valeur de
type pthread_t).
Le second argument désigne les attributs du thread. Vous pouvez choisir de mettre le
thread en état joignable (par défaut) ou détaché, et choisir sa politique
d'ordonnancement (usuelle, temps-réel...). Dans nos exemple, on mettra généralement
NULL.
Le troisième argument est un pointeur vers la fonction à exécuter dans le thread. Cette
dernière devra être de la forme void *fonction(void* arg) et contiendra le code à
exécuter par le thread.
Enfin, le quatrième et dernier argument est l'argument à passer au thread.
b) Supprimer un thread
#include <pthread.h>
voidpthread_exit(void *ret);
Elle prend en argument la valeur qui doit être retournée par le thread, et doit être placée en
dernière position dans la fonction concernée.
c) Première application
Voici un premier code qui réutilise toutes les notions des threads que nous avons vu jusque
là.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
33
Programmation système unix
(void) arg;
pthread_exit(NULL);
}
int main(void)
{
pthread_t thread1;
return EXIT_SUCCESS;
}
On compile, on exécute. Et là, zut... Le résultat, dans le meilleur des cas, affiche le message
de thread en dernier. Dans le pire des cas, celui-ci ne s'affiche même pas (ce qui veut dire que
le return s'est exécuté avant le thread...).
Ce qui normal, puisqu'en théorie, comme avec les processus, le thread principal ne va pas
attendre de lui-même que le thread se termine avant d'exécuter le reste de son code.
Par conséquent, il va falloir lui en faire la demande. :zorro: Pour cela, Dieu pthread a créé la
fonction pthread_join.
#include <pthread.h>
Elle prend donc en paramètre l'identifiant du thread et son second paramètre, un pointeur,
permet de récupérer la valeur retournée par la fonction dans laquelle s'exécute le thread (c'est-
à-dire l'argument de pthread_exit).
e) Exercice résumé
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
34
Programmation système unix
#include <pthread.h>
int main(void)
{
pthread_t thread1;
if (pthread_join(thread1, NULL)) {
perror("pthread_join");
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
Exclusions mutuelles
Problématique
Avec les threads, toutes les variables sont partagées : c'est la mémoire partagée.
Mais cela pose des problèmes. En effet, quand deux threads cherchent à modifier deux
variables en même temps, que se passe-t-il ? Et si un thread lit une variable quand un autre
thread la modifie ?
C'est assez problématique. Par conséquent, nous allons voir une mécanisme de
synchronisation : les mutex, un des outils permettant l'exclusion mutuelle.
35
Programmation système unix
Les mutex
Concrètement, un mutex est en C une variable de type pthread_mutex_t. Elle va nous servir
de verrou, pour nous permettre de protéger des données. Ce verrou peut donc prendre deux
états : disponible et verrouillé.
Quand un thread a accès à une variable protégée par un mutex, on dit qu'il tient le mutex.
Bien évidemment, il ne peut y avoir qu'un seul thread qui tient le mutex en même temps.
Le problème, c'est qu'il faut que le mutex soit accessible en même temps que la variable et
dans tout le fichier (vu que différents threads s'exécutent dans différentes fonctions). La
solution la plus simple consiste à déclarer les mutex en variable globale. Mais nous ne
sommes pas des barbares ! :pirate: Par conséquent, j'ai choisi de vous montrer une autre
solution : déclarer le mutex dans une structure avec la donnée à protéger.
Allez, un petit exemple ne vous fera pas de mal :
typedefstruct data {
int var;
pthread_mutex_tmutex;
} data;
Initialiser un mutex
#include <stdlib.h>
#include <pthread.h>
typedefstruct data {
int var;
pthread_mutex_tmutex;
} data;
int main(void)
{
data new_data;
new_data.mutex = PTHREAD_MUTEX_INITIALIZER;
return EXIT_SUCCESS;
}
36
Programmation système unix
Verrouiller un mutex
L'étape suivante consiste à établir une zone critique, c'est-à-dire la zone où plusieurs threads
ont l'occasion de modifier ou de lire une même variable en même temps.
Une fois cela fait, on verrouille le mutex grâce à la fonction :
#include <pthread.h>
intpthread_mutex_lock(pthread_mutex_t *mut);
Déverrouiller un mutex
#include <pthread.h>
intpthread_mutex_unlock(pthread_mutex_t *mut);
Détruire un mutex
#include <pthread.h>
intpthread_mutex_destroy(pthread_mutex_t *mut);
Les conditions
Lorsqu'un thread doit patienter jusqu'à ce qu'un événement survienne dans un autre thread,
on emploie une technique appelée la condition.
Quand un thread est en attente d'une condition, il reste bloqué tant que celle-ci n'est pas
réalisée par un autre thread.
Comme avec les mutex, on déclare la condition en variable globale, de cette manière :
pthread_cond_tnomCondition = PTHREAD_COND_INITIALIZER;
intpthread_cond_signal(pthread_cond_t *nomCondition);
37
Programmation système unix
Exemple :
Créez un code qui crée deux threads : un qui incrémente une variable compteur par un
nombre tiré au hasard entre 0 et 10, et l'autre qui affiche un message lorsque la variable
compteur dépasse 20.
Correction :
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
return 0;
}
srand(time(NULL));
printf("\n%d", compteur);
Résultat :
lucas@lucas-Deskstop:~/Documents$ ./monprog
4
9
18
26
LE COMPTEUR A DÉPASSÉ 20.
9
18
23
LE COMPTEUR A DÉPASSÉ 20.
0
3
5
9
12
19
23
LE COMPTEUR A DÉPASSÉ 20.
0
39
Programmation système unix
8
9
10
17
25
LE COMPTEUR A DÉPASSÉ 20.
2
10
10
16
25
LE COMPTEUR A DÉPASSÉ 20.
8
10
18
26
LE COMPTEUR A DÉPASSÉ 20.
0
7
9
^C
Terminons ce chapitre par quelques moyen mnémotechniques qui peuvent vous permettre de
retenir toutes les notions que l'on a apprises :
Vous pouvez remarquer que toutes les variables et les fonctions sur les threads
commencent par pthread_
pthread_create : create = créer en anglais (donc créer le thread)
pthread_exit : exit = sortir (donc sortir du thread)
pthread_join : join = joindre (donc joindre le thread)
PTHREAD_MUTEX_INITIALIZER : initializer = initialiser (donc initialiser le
mutex)
pthread_mutex_lock : lock = vérouiller (donc vérouiller le mutex)
pthread_mutex_unlock : unlock = dévérouiller (donc dévérouiller le mutex)
pthread_cond_wait : wait = attendre (donc attendre la condition)
1- Définition
Un tube (en anglais pipe) peut être représenté comme un tuyau (imaginaire, bien sûr ! ;) )
dans lequel circulent des informations.
40
Programmation système unix
2- Utilité
3- Vocabulaire
Un écrit des informations dans le tube. Celui-ci est appelé entrée du tube ;
L'autre lit les informations dans le tube. Il est nommé sortie du tube.
1) Créer
Pour commencer, il nous faudrait créer le tube. Pour cela, on utilise la fonction :
Elle renvoie une valeur de type int, qui est de 0 si elle réussit, ou une autre valeur dans
le cas contraire.
Elle prend en argument un tableau de int, comprenant :
o descripteur[0]: désigne la sortie du tube ;
o descripteur[1] : désigne l'entrée du tube.
Mais là, nous devons faire face à notre premier problème. En effet, on ne crée le tube que
dans un seul processus. L'autre ne peut donc pas en connaître l'entrée ou la sortie !
En conséquence, il faut utiliser pipeavant d'utiliser fork. Ensuite, le père et le fils auront les
mêmes valeurs dans leur tableau descripteur, et pourront donc communiquer.
2) Écrire
La fonction prend en paramètre l'entrée du tube (on lui enverra descripteur[1]), un pointeur
générique vers la mémoire contenant l'élément à écrire (hummm, ça rappelle des
souvenirs, non ? ^^ ) ainsi que le nombre d'octets de cet élément. Elle renvoie une valeur de
type ssize_t correspondant au nombre d'octets effectivement écrits.
#include <stdio.h>
41
Programmation système unix
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_tpid_fils;
intdescripteurTube[2];
char messageEcrire[TAILLE_MESSAGE];
pipe(descripteurTube);
pid_fils = fork();
return EXIT_SUCCESS;
}
3) Lire
La fonction prend en paramètre la sortie du tube (ce qui correspond à... descripteur[0]),
un pointeur vers la mémoire contenant l'élément à lire et le nombre d'octets de cet
élément. Elle renvoie une valeur de type ssize_t qui correspond au nombre d'octets
effectivement lus. On pourra ainsi comparer le troisième paramètre (nombreOctetsALire) à la
valeur renvoyée pour vérifier qu'il n'y a pas eu d'erreurs.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
42
Programmation système unix
int main(void)
{
pid_tpid_fils;
intdescripteurTube[2];
char messageLire[TAILLE_MESSAGE];
pipe(descripteurTube);
pid_fils = fork();
return EXIT_SUCESS;
}
4) Fermer
Lorsque nous utilisons un tube pour faire communiquer deux processus, il est
important de fermer l'entrée du tube qui lit et la sortie du tube qui écrit.
En effet, il faut que le noyau voie qu'il n'y a plus de processus disposant d'un descripteur
sur l'entrée du tube. Ainsi, dès qu'un processus tentera de lire à nouveau, il lui
enverra EOF (fin de fichier).
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
pid_tpid_fils;
intdescripteurTube[2];
pipe(descripteurTube);
pid_fils = fork();
43
Programmation système unix
return EXIT_SUCESS;
}
Pratique
Écrivez un programme qui crée deux processus : le père écrit le message « Bonjour, fils. Je
suis ton père ! ». Le fils le récupère, puis l'affiche.
Correction :
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
pid_tpid_fils;
intdescripteurTube[2];
printf("Création du tube.\n");
if(pipe(descripteurTube) != 0)
{
fprintf(stderr, "Erreur de création du tube.\n");
return EXIT_FAILURE;
}
pid_fils = fork();
if(pid_fils == -1)
{
fprintf(stderr, "Erreur de création du processus.\n");
return 1;
}
if(pid_fils == 0)
{
44
Programmation système unix
else
{
printf("\nFermeture de la sortie dans le père.\n");
close(descripteurTube[0]);
printf("Nous sommes dans le père (pid = %d).\nIl envoie le message suivant au fils :
\"%s\".\n\n\n", getpid(), messageEcrire);
wait(NULL);
}
return 0;
}
Résultat :
Création du tube.
Entrées/Sorties et tubes
On peut lier la sortie d'un tube à stdin ou l'entrée d'un tube à stdout. Ensuite :
Dans le premier cas : toutes les informations qui sortent du tube arrivent dans le flot
d'entrée standard et peuvent être lues avec scanf, fgets, etc...
45
Programmation système unix
Dans le second cas : toutes les informations qui sortent par stdout sont écrites dans le
tube. On peut utiliser printf, puts, etc...
Le second paramètre correspond au nouveau descripteur que l'on veut lier au tube.
Il existe deux constantes, déclarées dans unistd.h :
STDIN_FILENO;
STDOUT_FILENO.
Exemple : Si on veut lier l'entrée d'un tube d'entrée et de sortie définies dans un tableau
descripteur à stdout :
dup2(tube[1], STDOUT_FILENO);
6) Tubes nommés
Cependant, un problème se pose avec l'utilisation des tubes classiques. En effet, il faut
obligatoirement que les processus connaissent le processus qui a créé le tube. Avec les
tubes tout simples, il n'est pas possible de lancer des programmes indépendants, puis qu'ils
établissent un dialogue.
C'est pourquoi on a créé une "extension" des tubes, appelée "tube nommé" (named pipe).
Son concept est assez simple : comme son nom l'indique, le tube dispose d'un nom dans le
système de fichier. Il suffit qu'un processus l'appelle par son nom, et hop, il accourt pour
laisser le processus lire ou écrire en son intérieur. Sympa, hein ! :p
Pour commencer, il faut créer le tube. Concrètement, nous allons créer un fichier.
Pour créer un tube nommé, on utilise la fonction mkfifo, dont voici le prototype :
intmkfifo (const char* nom, mode_t mode);
Le premier argument est le nom du tube nommé. On donne généralement l'extension « .fifo »
au nom du tube nommé. Ne vous souciez pas pour l'instant de savoir à quoi correspond ce
46
Programmation système unix
Bon, jusque là pas de gros problèmes. En revanche, vous allez peut-être regretter d'avoir
connu le deuxième paramètre... :p En fait, il s'agit concrètement des droits d'accès du tube.
Deux solutions :
Enfin, dernière petite ( :-° ) chose : la fonction renvoie 0 si elle réussit, ou -1 en cas d'erreur.
Vous pouvez aussi consulter la variable errno, qui peut contenir :
EACCES : le programme n'a pas les droits suffisants pour accéder au chemin de
création du tube nommé ;
EEXIST : le tube nommé existe déjà ;
ENAMETOOLONG : dépassement de la limitation en taille du nom de fichier (assez
rare ^^ ) ;
ENOENT : le chemin du tube nommé n'existe pas ;
ENOSPC : il n'y a plus assez de place sur le système de fichiers.
Exercice :
Créez un tube nommé que vous appellerez « essai », dont vous vous aurez toutes les
permissions, dont le groupe aura les permissions de lecture et d'écriture et dont les autres
n'auront aucun droits.
Deux solutions de possibles, pour chacune présentées pour les droits d'accès :
1)
47
Programmation système unix
2)
Ouvrir
La fonction renvoie une valeur de type int que l'on attribue à l'extrémité du tube en question.
Le premier argument est le nom du fichier (on mettra le nom du tube nommé, bien
évidemment).
Le second argument indique si c'est l'entrée ou la sortie du tube. Il existe deux constantes
pour cela, déclarées dans fcntl.h :
Ensuite, vous pouvez écrire et lire avec write et read comme si c'était des tubes classiques.
Écrivez deux programmes indépendants : un écrit un message dans un tube nommé, et l'autre
le lit, puis l'affiche. Exécutez ces deux programmes en même temps.
Correction :
Ecrivain.c :
#include <fcntl.h>
48
Programmation système unix
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
intentreeTube;
char nomTube[] = "essai.fifo";
if(mkfifo(nomTube, 0644) != 0)
{
fprintf(stderr, "Impossible de créer le tube nommé.\n");
exit(EXIT_FAILURE);
}
return EXIT_SUCCESS;
}
Lecteur.c :
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
intsortieTube;
char nomTube[] = "essai.fifo";
char chaineALire[TAILLE_MESSAGE];
return EXIT_SUCCESS;
}
Résultat :
Premier terminal :
lucas@lucas-Deskstop:~/Documents$ ./ecrivain
_
Deuxième terminal :
lucas@lucas-Deskstop:~/Documents$ ./lecteur
Bonjour
50