Interface C / UNIX
http://www.mescours.ma/C/CC/C-Unix.html
Najib TOUNSI,
ntounsi@emi.ac.ma
Version: Sept. 2000, Dernière MaJ: Dec. 2021
Note: Le nom générique UNIX désigne plutôt une famille de système: LINUX, SOLARIS, Sun-OS, DEC-ULTRIX, AIX, HP-UX, UNIX-BSD, UNIX-SCO etc... Dans ce chapitre nous utilisons la norme POSIX, et ce qui suit reste valable quelque soit le système.
Vers la fin des années 60, Kenneth THOMPSON a crée le système UNIX en utilisant un langage ad-hoc appelé B, lui-même dérivé d'un langage système appelé BCPL. B n'est pas très typé et dépend beaucoup de l'architecture d'une machine. Plus tard, Dennis RITCHIE rejoint K. THOMPSON sur le projet UNIX pour lequel il conçoit et développe un langage de programmation qu'il appelle C. Il suggère par la même occasion que les organes périphériques soient considérés comme des fichiers normaux (ce qui fut une bonne décision de conception). Pour rendre UNIX portable sur d'autres plates-formes que le PDP7 Digital original, ils décident de le réécrire en C. La majeur partie du système est pratiquement écrite en C.
PROGRAMMES
UTILITAIRES Niveau Langage de Commandes. (Norme POSIX 1003.2) Shells, Compilateurs, Commandes/Outils, |
||||||
NOYAU
Niveau Primitives appels système. (Norme POSIX 1003.1)
|
||||||
RESSOURCES MACHINE |
On retiendra en particulier une bibliothèque de programmes très riche (les fameux include), contenant des définitions d'objets et des fonctions qui peuvent dispenser l'utilisateur d'avoir recours aux fonctionnalités de base (comme les primitives du noyau) en lui permettant de programmer en plus haut niveau. Donc, C est très intimement lié à UNIX.
Ce qui fait le charme , sinon le succès, d'UNIX, c'est que ses nombreuses commandes --écrites en C donc-- sont très paramétrables offrant une diversité d'options, et le fait que tous les fichiers, y compris les terminaux les imprimantes etc.., sont traités uniformément. En particulier, les commandes UNIX, dites outils pour la circonstance, se comportent comme des filtres ayant un fichier de données en entrée (appelé stdin) et générant un fichier résultat en sortie (appelé stdout). D'où la possibilité pour un fichier sortie d'une commande de devenir le fichier entrée d'une autre commande etc., offrant en plus un nombre quasi illimité de possibilités de traitements. Ce mécanisme de "tube" (pipe) s'est révélé être parfois d'une redoutable efficacité. Un travail insoupçonné peut être fait par simple composition de commandes élémentaires connectées par tubes (voir exemple 3 section 2.1.). A cela, se greffe un véritable langage de programmation de commandes: d'une part, une ligne commande peut être une expression complexe, formées de commandes élémentaires, et d'autre part des structures de contrôles permettent de les enchaîner de divers sortes. Par ailleurs UNIX est un système extrêmement riche dans le domaine de la communication entre programmes sur une même ou sur deux machines différentes.[1]
En ce qui concerne l'attrait, sinon l'utilité de C, c'est
En particulier, on appréciera la possibilité de "s'échapper" momentanément d'un programme, pour exécuter des commandes UNIX interactives, et y revenir ensuite.
Dans ce chapitre, nous allons étudier très brièvement comment se comportent les commandes UNIX, en particulier les redirections et les tubes (pipes), et comment écrire des commandes UNIX (pour plus de détails sur UNIX, voir Petit Guide UNIX). Ensuite, on introduira quelques primitives appels systèmes. Après, on étudiera les fichiers et les opérations C offertes pour les traiter et qui sont de deux sortes: sous forme de primitives de base, i.e. des appels au noyau, ou à travers une bibliothèque (stdio.h) de fonctions plus abstraites. On terminera sur les fichiers spéciaux qui sont les tubes et leur manipulation pour communiquer entre processus.
L'interface de base du système UNIX est un interpréteur de commandes, dit shell (pour coquille, la couche la plus externe d'un système d'exploitation).
En UNIX, il y a principalement
Le fichier /etc/shells, s'il existe, contient les shells disponibles sur une machine UNIX (sinon regarder les répertoires /bin ou /usr/bin qui doivent contenir les programmes shell, fichiers se terminant en sh).
Un shell est un programme qui lit une commande sur le clavier analyse ses paramètres et l'exécute en lui transmettant ces paramètres.
Répéter
- Lire Ligne Commande
- Analyser et Traiter Paramètres
- Exécuter Commande
Jusqu'à Cde = logout ou Fin Fichier Entrée
/* ^D */
Exemple: Voici une commande UNIX grep qui cherche une chaîne dans un fichier et imprime les lignes trouvées qui la contient, et une commande cat qui affiche un fichier.
% grep "tounsi" /etc/passwd tounsi:sw4H0hc.6:273:15:Tounsi Najib Prof.:/usr/users/tounsi:/bin/csh % cat hello.c main() { printf("Hello, world\n"); }
A chaque commande UNIX, sont associés trois fichiers. Les deux principaux sont les fichiers Entrée/Sortie standard, l'un en entrée appelé stdin et l'autre en sortie appelé stdout. Ce sont des noms logiques de fichier, connus de UNIX (Fig VI-1-a). Une commande lit ses données dans stdin et écrit ses résultats dans stdout. Le troisième fichier appelé stderr enregistre les complaintes et les erreurs éventuelles détectées par la commande.
![]() |
![]() |
|
(a) Entrées/Sorties Standard |
(b) Les Entrées/Sorties par Défaut |
Quand une commande UNIX est exécutée, le shell assigne automatiquement ces fichiers, en les connectant au terminal de contrôle: le clavier devient l'entrée stdin et l'écran, la sortie stdout (Fig-VI-1-b). Le fichier stderr est aussi assigné à l'écran pour que l'utilisateur puisse voir les résultats et les erreurs éventuels. Cette affectation est systématique, sauf si l'utilisateur spécifie autrement. A ce moment là, il doit indiquer au shell le nom physique du fichier qu'il désire fournir en entrée de la commande, et/ou le nom physique du fichier résultat en sortie de la commande. On dit redirection des fichiers E/S (idée reprise dans MS-DOS mais moins bien implantée).
Remarque: Une confusion parfois commise est de considérer stdin comme étant le clavier et stdout comme étant l'écran. L'écran et clavier sont des fichiers physiques, alors que stdin et stdout sont des fichiers logiques. stdin et stdout existent toujours contrairement à l'écran ou le clavier (un programme peut tourner sans qu'il soit rattaché à un terminal). Le fait est que stdin et stdout sont, et par défaut, le clavier et l'écran. Autrement dit, il ne faut pas confondre la notion de standard (dans un sens nécessaire) et la notion de défaut (dans un sens implicite).
Exemple: la commande bc (binary calcul) fait calculette interactive. Elle travaille ici sur le clavier et l'écran.
% bc 1+2 <--- clavier est stdin (saisie) 3 <--- écran est stdout (valeur affichée) 3*4 12 ^D <--- fin de saisie et de la commande(EOF) % _
On dit redirection, quand l'utilisateur indique un fichier E/S régulier en remplacement du clavier ou de l'écran. Pour rediriger les entrées et/ou les sorties, on indique les fichiers qu'il faut, par la notation: < pour rediriger les entrées, et > pour rediriger les sorties.
% com < fichierEntrée > fichierSortie
C'est le shell qui réalise la redirection dans ces cas. En analysant les paramètres de la commande, le shell comprendra qu'il faut exécuter la commande com, avec son fichier stdin connecté à fichierEntrée et son fichier stdout connecté à fichierSortie. La figure VI-2, illustre ce schéma.
Fig-VI-2 Les Entrées/Sorties Standard Redirigées
Exemple: Toujours à propos de la commande bc
% cat D 1+2 3*4 % bc <D <-- D est entree stdin de bc 3 12 <-- sortie stdout sur ecran % bc <D >R <-- D est stdin R est stdout % cat R 3 12 <-- CQFD %_
Et le fichier standard erreurs? On peut aussi le rediriger vers un autre fichier, par >& (cas du csh) ou par 2>, cas des autres shells, i.e. sortie numéro 2. (Ces trois fichiers stdin, stdout et stderr, possèdent dans le système des descripteurs entiers qui sont respectivement 0, 1, et 2. Nous y reviendrons plus en détail au chapitre suivant). Dans le cas du csh, la sortie erreur aussi bien que la sortie standard(!) vont dans le même fichier spécifié avec >&, comme illustré par:
% ksh $ bc 2> err <-- sous ksh 1+2 3 3/0 0 $ cat err divide by 0 $ ^D % bc >& err <-- sous csh 1+2 3/0 % cat err 3 divide by 0 0 %_
Il existe cependant une astuce pour séparer, sous csh, les deux sorties stdout et stderr. Cela consiste à mettre la commande ls dans un sous shell, entre parenthèses (), et de rediriger ensuite les erreurs de ce sous shell.
% (bc > R) >& err 1+2 3/0 % cat R 3 0 % cat err divide by 0 %_
Mais en général, on ne redirige pas stderr, car on a besoin d'être informé sur les erreurs éventuelles en cours d'exécution de la commande.
Exercice: Ecrire un programme C qui copie un fichier f1 sur un autre f2. Utiliser getchar() et putchar() pour lire sur clavier et écrire sur écran. A l'exécution, rediriger l'écran vers f2 et le clavier vers f1 .
On a dit que l'une des caractéristiques avantageuse de UNIX, est le fait que le résultat d'une commande peut constituer la donnée d'une autre commande. La notation utilisée est |. L'écriture
% cmd_1 | cmd_2
envoie le fichier standard de sortie de cmd_1 sur l' entrée standard de cmd_2. (figure-VI-3).
Fig-VI-3 Un Tube (pipe). La Sortie Standard de
commande1
devient Entrée Standard de commande2.
On peut bien sûr répéter ce mécanisme et réaliser des commandes en chaîne, chacune ayant pour données les résultats de la commande précédente.
% cmd_1 | cmd_2 | ... | cmd_n
Les deux bouts de la chaîne sont par défaut (si non redirigées bien sûr) le terminal: clavier du côté cmd1 et écran du côté cmdn.
Ce mécanisme de tube (pipe en anglais, on introduit d'un côté et on récupère de l'autre, selon le modèle First In First Out) permet au shell de connecter une sortie standard à une entrée standard. La synchronisation de la communication ainsi établie entre deux commandes, est automatiquement gérée par UNIX: commande2 attend que commande1 ait produit une sortie dans le tube avant de pouvoir la lire, et inversement, quand le tube est plein, commande1 attend que commande2 consomme une donnée avant de pouvoir y écrire de nouveau. Au Chapitre VIII, on verra comment utiliser des tubes directement en C.
Exemples:
1) La ligne:
% ls | wc -l 80
compte le nombre de fichiers sous le répertoire courant, et qui sont au nombre de 80: ls liste dans sa sortie les noms de fichiers d'un répertoire (un par ligne), et wc -l compte les lignes d'un fichier, celui qu'elle a en entrée et qui est en même temps sortie de la commande ls. C'est le même résultat que les deux lignes commandes
% ls > toto % wc -l < toto
(Exercice: qu'elle est néanmoins, la différence avec le cas précédent?) [2]
2) L'exemple suivant affiche les quatre derniers noms, dans l'ordre alphabétique, d'une liste se trouvant dans un fichier liste
% cat liste baba kover ali harry ahmed ahlem % cat liste | sort | tail -4 ali baba harry kover
3) Voici l'exemple d'une ligne commande qui compte le nombre de mots différents dans un document (adapté de Brad. J. COX):
% fortune > document % cat document I really hate this damned machine I wish that they would sell it. It never does quite what I want But only what I tell it. % tr -cs "a-zA-Z0-9" '\012' <document | sort -u | wc -l 21
tr lit le fichier document et remplace tout caractère non alphanumérique (options -cs) par newline ('\012') le transformant ainsi en une suite de un mot par ligne. Ensuite, sort trie le résultat obtenu en éliminant les doubles (option -u). wc n'a plus qu'à compter les lignes restantes.
2.1. Calculer combien d'utilisateurs sont connecté(e)s.(commande who)
2.2. Combien sont qui s'appellent toto? dont le nom commence par to? (utiliser la commande grep)
2.3. Afficher les utilisateurs connecté(e)s et dont le nom commence par to, dans l'ordre alphabétique.
2.4. Mettre dans un fichier les 20 premières lignes (commande head) de la spécification d'une commande UNIX (man commande).
La commande tee (T en anglais) permet d'envoyer à la fois sur sa sortie standard et dans un fichier en paramètre, ce qu'elle lit sur son entrée standard.
Fig-VI-4 Duplication d'une Entrée Standard
vers un Fichier
Elle est particulièrement utile si on veut afficher (ou sauvegarder dans un fichier) le résultat d'une commande, avant de l'envoyer en entrée d'une autre commande: fichier est dans ce cas "/dev/ttyxx" alias l'écran, et stdout l'entrée d'un tube (voir figure Fig-VI-4). On peut aussi afficher un résultat et en même temps le sauvegarder dans fichier: stdout revient à l'écran cette fois-ci.
Exemple: On liste sur écran les personnes connectées (who) et on compte leur nombre (wc) . Ici, le fichier et /dev/ttyp2, mon écran. tee récupère le résultat de who, l'envoie à wc et sur /dev/ttyp2.
% tty /dev/ttyp2 % who | tee /dev/ttyp2 | wc -l tounsi console Apr 18 11:31 tounsi ttyp0 Apr 18 11:32 tounsi ttyp1 Apr 18 11:34 tounsi ttyp2 Apr 18 11:32 4 %
Comparer avec
% who | tee save tounsi console Apr 18 11:31 tounsi ttyp0 Apr 18 11:32 tounsi ttyp1 Apr 18 11:34 tounsi ttyp2 Apr 18 11:32 % cat save tounsi console Apr 18 11:31 tounsi ttyp0 Apr 18 11:32 tounsi ttyp1 Apr 18 11:34 tounsi ttyp2 Apr 18 11:32 %
où tee récupère le résultat de who, l'envoie sur sa sortie standard l'écran et sur le fichier save.
Remarque: Cet exemple illustre, entre autre, l'utilisation des périphériques externes, les terminaux en l'occurrence, comme des fichiers «normaux». Ils s'appellent /dev/ttyxx, /dev/rstxx, /dev/rmtxx etc... Ils sont groupés dans le répertoire /dev (pour device) tout comme les disques /dev/sdyxx. En voici un échantillon:
crwx-w---- 1 tounsi 0, 0 Apr 23 17:42 console srwxrwxrwx 1 root 0 Apr 16 16:09 printer crw-rw-rw- 1 root 30, 0 Jul 27 1991 rmt0 crw-rw-rw- 2 root 30, 12 Jul 27 1991 rmt12 brw-r----- 1 root 7, 0 Jul 27 1991 sd0a brw-r----- 1 root 7, 1 Jul 27 1991 sd0b crw-rw-rw- 1 root 20, 1 Apr 23 17:39 ttyp1 crw-rw-rw- 1 root 20, 2 Apr 23 17:38 ttyp2
On peut noter au passage le premier caractère b ou c qui indique le type de fichier: b indique E/S en mode bloc (disques, ...) et c indique E/S en mode caractère (terminaux, ...). On en reparlera plus tard avec les fichiers. A titre de complétude, les autres modes sont s pour socket, p pour les tubes nommés (pipe), d pour répertoire (directory),l pour lien (link) et - pour simple fichier.
On va arrêter ici cette digression dans le monde UNIX. Le lecteur ou la lectrice peuvent se référer à Petit Guide Unix.
Chaque commande originale UNIX est un programme binaire qui est écrit en C. Il s'agit des commandes externes, correspondant à un fichier exécutable sous un répertoire bin, e.g. /bin/time, et non des commandes internes au shell, e.g. time, ou des fichiers scripts interprétés par shell. En outre, ce programme s'exécute dans un certain environnement défini par les variables shell. Nous allons en montrer le principe.
On sait que tout programme C contient une fonction main() sans paramètres et c'est la première fonction exécutée. Or, c'est le shell qui lance l'exécution d'un programme et lui transmet une liste de paramètres éventuels fournis sur la même ligne commande. Ces paramètres sont transmis, après analyse et prétraitement, à la fonction main() du programme considéré. Comment les récupérer, sachant qu'en plus ils sont en nombre quelconque? Il suffit tout simplement, ce que fait le shell, d'en indiquer le nombre et d'en fournir la liste. Ce nombre est conventionnellement désigné argc (argument count) et la liste est un tableau de longueur variable désigné argv[] (argument value). Il contient la liste de tous les paramètres de la commande, y compris le nom de celle-ci. La fin du tableau est marquée aussi par son contenu qui est la chaîne vide NULL ('\0'). Si par exemple on exécute un programme par:
% a.out -o toto titi
on aura:
La fonction main() devra alors être munie des deux arguments, argc et argv, ainsi
main (int argc, char* argv[]) {/* * obtenir et traiter les argc parametres dans argv * }
afin de récupérer dans le programme, la liste des paramètres d'exécution: argv étant un tableau de argc chaînes de caractères. Chaque chaîne représente un paramètre de la ligne commande. Les paramètres, ramassés sur la ligne commande, sont fournis au programme par le shell de lancement (on verra plus loin comment une primitive exec() réalise cela).
D'autres informations sont éventuellement récupérables par main(). Ce sont les variables d'environnement comme PATH, TERM qu'on peut récupérer par l'intermédiaire d'un troisième argument arge.
main (int argc, char* argv[], char* arge[]) {...}
où arge, comme argv, est un tableau de chaînes de caractères (dernier élément NULL) de la forme:
"VARIABLE=valeur".
Exemples:
PATH=.:./bin:/bin:/usr/bin:/usr/local/bin HOME=/usr/users/tounsi SHELL=/bin/csh
etc...
Ainsi, un programme lancé par un utilisateur sous un shell peut s'exécuter dans l'environnement que l'utilisateur a défini. (La fonction getenv(), de la bibliothèque stdlib.h permet aussi de récupérer la valeur d' une variable environnement. Elle a une homologue putenv(), qui permet de la modifier). Dans les exemples qui suivent, et pour simplifier, on ne va pas utiliser cet argument arge. Nous y reviendrons avec la primitive système exec().
Exemple_1: Voici un exemple de programme qui fait juste imprimer le tableau argv[]. On va l'exécuter avec différents paramètres pour illustrer la convention entre un shell et la programme.
/* Je reviens à mon prompt habituel pour l'historique */ tounsi@gnaoui 47> cat maincv.c main(int argc, char* argv[]) { int i; for (i=0; i<argc; i++) printf("%s\n",argv[i]); } tounsi@gnaoui 48> cc -o maCommande maincv.c tounsi@gnaoui 49> maCommande maCommande tounsi@gnaoui 50> maCommande toto titi maCommande toto titi tounsi@gnaoui 51> maCommande -o toto <--- avec une chaine de type option maCommande -o toto tounsi@gnaoui 52> maCommande - o titi <--- une option doit etre rattachee à son - maCommande - o titi tounsi@gnaoui 53>maCommande "- o" $user `hostname` '$user' maCommande - o tounsi <--- paramètres analyses gnaoui $user
La dernière illustration montre le besoin d'analyser, avec prétraitement éventuel, les paramètres avant leur transmission: "chaine entiere" pour une chaîne entière, $var pour le contenu d'une variable environnement, `commande` pour obtenir le résultat d'une comande ou 'chaîne' pour une chaine explicite. C'est une des caractéristique très avantageuse des shells (Voir Petit Guide UNIX ou faire man csh).
Pour les commandes (e.g. copie de fichier) dont on connaît d'avance le nombre de paramètres à utiliser, il est d'usage de tester argc dès le début du programme. Par exemple, pour la copie d'un fichier, on peut écrire:
if (argc != 3) printf(" Plait-il?... );
car on sait qu'il doit y avoir 3 paramètres: deux fichiers, origine et destination, en plus du nom de la commande (supposée sans options). On doit aussi pouvoir connaître la liste des options présentes le cas échéant, et qui doivent suivre immédiatement --convention UNIX-- le nom de la commande. L'instruction
if ( strcmp(argv[1],"-o") == 0 )
teste si l'argument option, donné en position 1, est la valeur "-o", . Une autre façon de faire est d'utiliser sscanf() qui permet, comme une lecture avec format, une analyse assez aisée d'une chaîne de caractères. Surtout pour récupérer des paramètres numériques.
Exemple_2:
A titre d'exemple voici, repris du précédent, un programme commande qui imprime les i premiers paramètres fournis (en dehors de la commande et de l'option). Justement, une option -nnombre indique ce nombre. Par exemple:
%commande -n2 toto titi tata
doit afficher toto et titi.
tounsi@shems 34> cat maincv1.c #include <stdio.h> main(argc,argv) int argc; char *argv[]; { int i; int val_option; if (argc <2) /* pas d'arguments ni d'options on ne fait rien */ exit(0); else if (argc == 2){ /* une option et rien d'autre */ printf("Usage: %s -nN arg1 arg2 ...\n",argv[0]); exit(1); } else { /* ici il y a l'option est au moins un autre parametre */ (1) if (!(*argv[1]=='-' && *(argv[1]+1)=='n')){ printf("%s: syntaxe option\n",argv[0]); exit(1); } sscanf(argv[1],"-n%d",&val_option); (2) if ( val_option > argc-2 ){ /* pas assez de parametres */ printf("%s: pas assez d'arguments\n",argv[0]); exit(1); } for(i=0; i<val_option; i++) printf("%s\n", argv[i+2]); exit(0); }
}
On aura noté les lignes: (1) pour tester la justesse de l'option, et (2) pour lire la valeur numérique de cette option. Voici l'exécution:
tounsi@shems 35> cc -o maCdeOpt maincv1.c tounsi@shems 36> maCdeOpt <--- aucun resultat prevu tounsi@shems 37> maCdeOpt -n3 Usage: maCdeOpt -nN arg1 arg2 ... tounsi@shems 38> maCdeopt -n2 toto titi tutu toto titi tounsi@shems 40>maCdeOpt -n4 titi tutu maCdeOpt: pas assez d'arguments tounsi@shems 41>maCdeOpt -p titi maCdeOpt: syntaxe option
On aura noté aussi, que chaque message d'erreur est précédé du nom de la commande qu'on peut récupérer dans argv[0]. L'instruction exit(valeur) sera vue plus loin. Elle est comme return(valeur) mais renvoie au shell de lancement ou au système.
Le langage C permet d'appeler à partir d'un programme, des fonctions systèmes dites primitives pour la circonstance. Ce sont des appels aux fonctionnalités internes d'un système d'exploitation, e.g. création de processus, allocation mémoire, entrée/sortie etc... Nous allons en étudier les principaux, et montrer quelques exemple de primitives. Mais d'abord précisons une notion sous-jacente: le processus.
Un processus est tout simplement un programme en cours d'exécution à un moment donné. C'est une occurrence d'exécution d'un programme donné. Tout programme qui s'exécute donne naissance à un processus. Bien distinguer la différence entre la notion de programme qui est statique et celle de processus qui est dynamique: un même programme s'exécutant plusieurs fois (même simultanément) donne lieu, à chaque fois, à un processus différent.
Plus précisément, un processus est l' image à tout instant de l'état d'avancement d'un programme en cours d'exécution. Cette image est constituée du code de programme, de la zone des données (statique dynamique et pile) et d'un ensemble d'autres informations (bloc de contrôle du processus) dont le système a besoin pour contrôler le bon déroulement du processus et qui consituent son contexte d'exécution. On y trouve essentiellement, les valeurs des registres, en particulier le compteur ordinal (CO), les liens avec l'utilisateur et le système d'E/S, terminal de lancement, descripteurs des fichiers ouverts, liste des signaux posibles etc...
Fig-V-5 Image d'un Processus
Le processus est l'unité de traitement du système UNIX et d'un système opératoire en général (la notion de thread, plus récente, permet de décomposer un programme en plusieurs «sous processus», appelés threads) . Pour s'exécuter, un processus a besoin des ressources nécessaires: mémoire, processeur, fichiers, dispositifs périphé-riques etc... UNIX peut en traiter plusieurs simultanément en leur faisant partager des ressources. En cours de son exécution, un processus peut se trouver dans l'un des deux modes:
Le passage du mode utilisateur au mode noyau se fait par appel aux primitives systèmes. C'est le cas par exemple pour réaliser une E/S ou dialoguer avec un terminal: on peut ainsi accéder à des zones mémoires particulières et communiquer avec un gestionnaire de périphériques. Ce passage (ou privilège) ne dure bien sûr que le temps d'exécution de la primitive. Il peut aussi être provoqué par un signal externe, interruption, qui nécessitera un traitement particulier fait par un gestionnaire (handler) d'interruptions.
Un processus possède un certain nombre de caractéristiques dont nous retiendrons en particulier:
En effet, pour exister un processus doit d'abord naître. La primitive système fork() permet à un processus d'en créer un autre. A chaque appel à fork(), il y a naissance d'un nouveau processus. On dira processus parent pour celui qui fait fork() et enfant pour celui qui est créé. Le processus créé est identique à son géniteur et il a la même image (même code et mêmes données). Ils coexisteront ensemble et se dérouleront de façon asynchrone. Notamment, le processus fils peut survivre après la fin du processus père. Par ailleurs, un processus parent transmet un certain nombre de caractéristiques à ses enfants (on peut parler d'héritage), en particulier les fichiers ouverts dont ils partagent alors les informations.
fork() est la seule façon sous UNIX de créer un processus. Sauf un, le processus originel, swapper, de numéro 0. Il est généré par une séquence particulière lors du démarrage de la machine. Il crée ensuite (il «fork»), un processus numéro 1, le processus init, qui lui même lance, entre autre, les processus getty de gestion des terminaux de connections (c'est lui qui affiche le prompt login: et attend que quelqu'un veille bien se présenter), et les processus shells utilisateurs qui prennent le relais au login. Le processus init est donc l'ancêtre commun de tous les processus jusqu'à l'arrêt de la machine. Le swapper, comme son nom l'indique, a pour tâche de gérer le chargement/sauvegarde de l'image de tout processus qui n'a pas besoin du processeur et qui peut libérer des ressources. D'autres processus particuliers, appelés démons (daemons), sont aussi créés et lancés après démarrage du système ou sur demande de l'administrateur. Les démons sont des processus qui s'exécutent à intervalle régulier ou en réponse à certains événements. Ils assurent un certain nombre de services généraux et restent résidents en mémoire et s'exécutent en arrière plan (background). Le processus init est d'ailleurs classé dans cette catégorie comme le cron , processus qui gère les commandes à exécuter en différé (Batch), le lpd qui gère le spooler d'impression, le mailer ou sendmail qui delivre les courriers, le serveur web httpd, etc...
Un processus naît donc par appel à la primitive fork(). Il meurt de deux façons: normalement par appel à une primitive exit(status) ou quand il arrive à sa fin, anormalement par réception d'un signal d'interruption qui met fin à son exécution.
La terminaison dite normale correspond au cas où un processus a fini son travail et doit donc libérer toutes les ressources utilisées. Parfois, il reste dans une espèce d'oubliettes du système sous une forme dite zombie: il existe mais n'utilise aucune ressource, notamment la mémoire. Son code et ses données ayant disparus, il existera jusqu'à ce que son géniteur termine à son tour ou prenne connaissance de cette terminaison. Car en effet, un processus père, peut vouloir connaître la terminason d'un processus fils. Pour cela un processus renvoie une valeur dite code retour (exit-status) à laquelle son père peut accéder par la primitive wait() (Voir 3.2.4). Un code retour égale à 0 reflète habituellement un comportement sans souci du processus, et un code retour non nul signifie une situation d'erreur détectée par le processus (e.g. données non conformes, fichiers inexistants, interruption captée etc...). En l'absence d'une instruction explicite exit(), le code retour, défaut 0, est envoyé par UNIX.
Un processus peut aussi se terminer de façon anormale. On considère dans ce cas les événements internes (e.g. division par 0, accès mémoire illégal etc...) ou externes (e.g. fin exécution d'un fils, interruption terminal ^C (intr), ^| (quit), ^S (stop), commande kill, erreur E/S etc...) qui peuvent survenir à tout moment pour provoquer l'nterruption d'un processus. Le contrôle passe alors au noyau, ou plus exactement au handler d'interruption, qui provoque l'exécution d'un code approprié et qui génère parfois une copie sur disque (dump) de l'image du processus: le fameux core dumped [3]. La technique du signal est utilisée pour la prise en compte de ces événements par le processus lui-même. En effet, si le noyau dispose d'une procédure de prise en compte par défaut d'un signal, un processus peut lui même se prémunir de l'effet d'un signal, par ce qu'on appelle un déroutement (primitive signal() justement. Voir 3.2.5). Noter au passage qu'un signal est aussi émis pour prévenir le père de cette terminaison (voir wait() et waitpid() plus bas).
Nous allons maintenant examiner quelques primitives de base, liées ou non à la notion de processus. Elle sont données selon la spécification de la section 2 du manuel UNIX (commande UNIX man 2 ...). En l'occurrence DEC_ULTRIX V4.3 (sauf indication contraire) à base de UNIX BSD et conforme à la norme POSIX. On remarquera d'ailleurs l'usage d'un certain nombre de fichiers #include contenant des macro-définitions qui favorisent la portabilité, comme par exemple:
Se rappeler que les primitives d'appels système sont connues du compilateur et n'ont pas besoin de bibliothèques. Se rappeler aussi qu'on peut faire "man 2 primitive" avec profit pour avoir plus de détails.
#include <stdlib.h> int system (commande) char* commande;
C'est la primitive la plus simple et l'une des plus caractéristiques de UNIX. Elle permet de lancer une commande shell à partir d'un programme, comme si c'était un sous programme. Plus exactement, cette commande est exécutée par un nouveau shell (sh dans ce cas) créé par l'appel, qui s'empile sur celui de lancement du programme. le programme attend alors que ce shell se termine. La primitive retourne 0 si cela s'est mal passé et que ce shell n'a pu être lancé.
On peut par exemple créer un fichier sous shell, et l'utiliser dans le programme. On peut tout simplement aussi lancer un shell par system("csh") et s'échapper momentanément du programme, pour passer en mode commande UNIX, et y revenir ensuite.
Exemple:
tounsi@gnaoui 24> cat system.c #include <stdlib.h> main() { system("who | tee /dev/ttyp2 | cat >file"); printf("OK who\n"); /* on peut ici recupérer le resultat de who * (se trouve dans file) et le traiter */ system("csh"); printf("OK csh\n"); } tounsi@gnaoui 25> cc system.c tounsi@gnaoui 26> a.out tounsi ttyp1 Mar 23 11:58 (:0.0) <--- resultat de WHO tounsi ttyp2 Mar 23 15:21 (:0.0) tounsi console Mar 23 11:34 OK who <--- 1er retour au programme ----new csh---- <--- resultat d'appel csh tounsi@gnaoui 1> echo mode commande <--- commande tapée ^D <--- ^D qui quitte csh tounsi@gnaoui 2>OK csh <--- 2eme retour au programme tounsi@gnaoui 27>
En suivant l'historique des commandes(25, 26, (1, 2), 27), on voit bien le nouveau shell de l'exécution lancé par system("csh"). (La ligne ----new csh---- est propre à mon environnement et sert à m'indiquer un nouveau shell). Et pour parler vrai, ce new csh est un troisième shell empilé: il y a d'abord le shell csh de lancement, ensuite il y a celui (un simple sh) appelé par system() pour exécuter la commande "csh" qui, elle, constituera le troisième shell. (Exercice: le verifier avec la commande ps)
Comme on l'a déjà dit, la primitive fork() permet la création dynamique d'un nouveau processus. Son interface est:
#include <sys/types.h> #include <unistd.h> pid_t fork();
Le processus, dit père (parent process), qui exécute cette primitive, crée un nouveau processus appelé fils (child process). Le nouveau processus est une copie exacte du premier. C'est le noyau en fait qui réalise cette opération: il duplique le processus père, donne au processus fils un nouveau numéro pid (process identifier) et le fait hériter des principales informations (attributs) du père. En particulier les compteurs, les registres et les fichiers ouverts. Le processus fils est une copie conforme du père, i.e. même code[4], mêmes données.
Fig-V-5-bis Naissance (Duplication) d'un Processus par fork()
Les deux processus exécutent donc le même programme sur une copie des données. Comment alors distinguer les deux processus pour leur faire exécuter des codes différents? Par la valeur retour de la primitive fork(). Pour le processus père, l'appel à fork() crée un processus fils avec un nouveau pid et retourne ce numéro pid qui est un entier non nul. Pour le fils la valeur retour de ce même appel est 0.
L'appel à fork() se fait généralement dans un test:
if ( fork() != 0)
Code correspondant au père
else
Code correspondant au fils
Si la primitive échoue -- il n'y a donc pas de création de processus fils-- la valeur retour est -1 comme toujours. Ce sera le cas quand le nombre de processus créés ou existants dans le système est trop élevé par exemple.
Exemples:
Nous allons commencer par l'exemple standard: qui est le père, qui est le fils? On utilisera les fonctions primitives générales getpid() et getppid() qui retournent respectivement l'identité du processus courant et celle de son père.
#include <unistd.h> #include <stdio.h> #include <sys/types.h> main(){ pid_t pid; printf("Mon pere le shell est %d\n",getppid()); switch (pid = fork()){ case (pid_t)-1: /* fork a echoue seul! le pere existe */ printf(" Desole: Pas de processus crie\n"); exit(2); case (pid_t)0: /* On est chez le fils */ printf("Je suis le fils mon numero est %d\n" ,getpid()); printf("Je suis le fils mon pere est %d\n", getppid()); exit(0); default: /* On est chez le pere */ printf(" Je suis le pere de %d \n", pid); printf(" Je suis le pere mon numero %d\n",getpid()); printf(" Je suis le pere mon pere est %d\n",getppid()); } }
Dans cet exemple, on a un programme qui utilise un fork(). Il commence par afficher avec getppid() le numéro du processus qui l'a créé, en l'occurrence le shell de lancement. Le travail se poursuit par l'appel à fork(). Arrêtons nous là-dessus; que va-t-il se passer? Un nouveau processus est créé, copie conforme de son père. Qui dit copie, dit en particulier celle de la pile d'exécution et du compteur ordinal (CO) qui indique la prochaine instruction à exécuter. Donc les deux processus se poursuivent au même point, à savoir après l'appel à fork(), et recoivent tous les deux la valeur retour de ce même appel: 0 pour le fils, valeur > 0 pour le pèrel. Le père se poursuit et le fils commençant vraiment. En d'autres termes, le premier printf n'est réalisé que par le père ici ( il y a quand même une certaine hiérarchie paternelle...) Donc le fils commence après le fork() qui l'a créé (et qu'il n'exécute en fait jamais).
Fig-V-5 ter Duplication du Compteur Ordinal
Dans le programme précédent, chaque processus va alors tester la valeur retour de fork(). Pour -1, le fils n'est pas créé et c'est uniquement le père qui travaille. Pour 0, le fils est donc créé et c'est lui qui a reçu cette valeur retour et qui exécute la séquence du deuxième case ici. Autrement la valeur retour de fork est positive et est égale au numéro du processus fils créé, et on est chez le père qui exécute la séquence associée, séquence default ici.
Il faut noter que les deux processus se déroulent de manière concurrente, et c'est le système qui contrôle leur élection pour le processeur. Cela veut notamment dire que l'ordre d'exécution individuelle de leurs instructions est totalement hors contrôle du programme (nous verrons plus bas comment synchroniser le déroulement de deux ou plusieurs processus).
Remarque: les appels exit() jouent en même temps le rôle de l'instruction break; pour le switch.
Trois exécutions de ce programme sont illustrées:
1ère exécution:
tounsi@gnaoui 16> echo $$ 9411 <--- pid du shell avant lancement tounsi@gnaoui 17> a.out Mon pere le shell est 9411 Je suis le pere de 9499 Je suis le pere mon numero 9498 Je suis le pere mon pere est 9411 Retour au shell tounsi@gnaoui 18> Je suis le fils mon numero est 9499 <--- pere terminé. Je suis le fils mon pere est 1 <--- Pere adoptif est init pid = 1
Ici le père s'exécute rapidement (en premier) et se termine. Le fils, devenu orphelin, est alors rattaché au processus démon init de pid 1.
2e exécution:
tounsi@gnaoui 19> a.out Mon pere le shell est 9411 Je suis le pere de 9502 Je suis le fils mon numero est 9502 Je suis le pere mon numero 9501 Je suis le fils mon pere est 9501 Je suis le pere mon pere est 9411 tounsi@gnaoui 20>
Ici, par chance, l'exécution est parfaitement enchevêtrée, chaque processus une instruction. Le noyau a été équitable.
3e exécution:
tounsi@gnaoui 22> a.out Mon pere le shell est 9411 Je suis le pere de 9507 Je suis le pere mon numero 9506 Je suis le pere mon pere est 9411 Je suis le fils mon numero est 9507 Je suis le fils mon pere est 1 tounsi@gnaoui 23>
Ici c'est comme le premier cas, mais le processus fils devient orphelin apparemment juste avant le retour au shell de lancement.
On aura remarqué aussi que la première instruction du programme, printf("Mon pere ...); n'est pas exécutée dans le fils et que tous les numéros pid sont différents et croissants.
Exercices
1. Ecrire un programme qui imprime les messages suivants:
Je te cree mon fils <-- par le pere merci Papa <-- par le fils Tu t'appellera <numeroPidFils> <-- par le pere
Indication: faire faire un calcul quelconque par le père le temps que le fils sorte son message.
2. Faire imprimer par le fils aussi son propre numéro pid
3. Le programme suivant:
main() { printf("%d\n",fork());}
imprime:
4168 0
Commenter.
Autres exemples: Nous allons négliger le cas du fork() = -1.
1) Création de deux processus fils. On va définir un tableau de fonctions tab_fonct[2] qui pointe vers deux fonctions f1() et f2(), qui seront le traitement respectif de chaque fils.
tounsi@gnaoui 55>cat fork2Essai.c #include <unistd.h> #include <stdio.h> #include <sys/types.h> void f1(),f2(); void (*tab_fonct[2])() = {f1,f2}; int i; main(){ pid_t pid[2]; for (i=0; i<2; i++) switch (pid [i] = fork()){ case (pid_t)0: (*tab_fonct[i])(); exit(0); default: sleep(1); printf("Je suis %d, pere de %d\n",getpid(), pid[i]); } } void f1(){ printf("je suis %d, %d er fils de %d\n", getpid(),i+1, getppid()); } void f2(){ printf("je suis %d, %d eme fils de %d\n", getpid(),i+1,getppid()); }
On crée les deux fils dans une boucle de 2 itérations. sleep(1) fait dormir le père 1 seconde à chaque fois pour attendre le fils (une façon détournée de "synchroniser" les exécutions du père et des fils en forçant l'ordre des instructions).
Voici l'exécution:
tounsi@gnaoui 56> !cc cc fork2Essai.c tounsi@gnaoui 57> a.out je suis 9633, 1 er fils de 9632 Je suis 9632, pere de 9633 je suis 9634, 2 eme fils de 9632 Je suis 9632, pere de 9634
Noter l'arrêt exit(0) des processus fils. La boucle ne revient pas pour ces deux processus.
2) Accès aux valeurs de données copiées et héritées par le fils. Nous verrons plus loin que la copie des descripteurs des fichiers ouverts, rend les fichiers partageables cette fois.
#include <unistd.h> #include <stdio.h> #include <sys/types.h> main(){ int commun = 123; pid_t pid; switch (pid = fork()){ case (pid_t)0: /* On est chez le fils */ printf("Je suis le fils, commun = %d\n" ,++commun); exit(0); default: /* On est chez le pere */ sleep(1); printf(" Je suis le pere, commun = %d\n",commun); } }
Le fils imprime la donnée commun, et l'incrémente. Pendant ce temps, son père dort une seconde avant de l'imprimer à son tour. Le fils a hérité par copie de la même donnée, et le père l'utilise telle qu'elle est définie chez lui, comme le montre l'exécution:
tounsi@gnaoui 97>!cc cc fork3Essai.c tounsi@gnaoui 98>a.out Je suis le fils, commun = 124 Je suis le pere, commun = 123
L'incrémentation faite par le fils n'est pas prise en compte. Il en est de même pour les données du tas qui sont copiées aussi (cas de int *commun; par exemple).
Il faut dire avant de terminer que, vu isolément, fork() peut apparaître quelque peu singulière. Mais le fork prend toute sa puissance quand il est combiné (cas général) avec d'autres fonctions UNIX et en particulier la famille des fonctions exec(), qui initient l'exécution de nouveaux programmes avec passage de paramètres. En effet, si avec fork on peut discuter l'utilité d'avoir deux fois un même programme (voir remarque juste ci après), on dispose de exec qui permet le recouvrement d'un programme par un autre.
Remarque: La sémantique de fork est de travailler sur une copie des données du père comme on l'a vu. Dans les premières implantations d'UNIX fork recopiait effectivement ces données. Or, cela est peut être inutile (overhead coûteux) si le fils ne modifie pas ces données ou si, probablement, il va se recouvrir. Actuellement, dans les nouvelles réalisations d'UNIX, la recopie n'est faite qu'en cas d'écriture par le fils (copy on write).
Exercices
1. Nous avons déjà vu comment un processus peut exécuter plusieurs fois fork() et créer ainsi plusieurs fils. Vérifier que chaque fils peut lui-même avoir ses propres descendants. Faire un programme qui crée deux fils, chacun à son tour créant le sien. Pour cela reprendre, en le simplifiant par exemple, le programme avec les fils exécutant les fonctions f1() et f2() précédemment définies, remplacer f1() et f2() par une seule fonction fils() qui exécute aussi un fork. Faire en sorte qu'après chacun des fork, chaque père affiche son pid et celui de son (ses) fils
2. Le programme suivant:
main(){ printf("a) %d\n",fork()); printf("b) %d\n",fork()); }
imprime:
a) 4106 a) 0 b) 4107 b) 0 b) 4108 b) 0
Commenter.
3) L'instruction
printf(" %d est cree par %d\n",fork(), getpid());
imprime:
4133 est cree par 4132 0 est cree par 4133
alors que l'instruction
printf(" %d cree %d\n",getpid(),fork());
imprime:
4145 cree 4146 4145 cree 0
(le même pid pour celui qui crée).
Pourquoi? (indication: considérer l'ordre d'évaluation des paramètres de printf()).
Ce sont six primitives commençant en exec: execl(), execv(), execlp(), execlv(), execle() et execve(). Elles permettent à un processus de charger et d'exécuter un nouveau programme binaire en lui transmettant les arguments nécessaires. Ce nouveau programme écrase l'ancien (l'image du processus réalisant un exec()) et s'exécute sur de nouvelles données, les siennes propres. Ce mécanisme de recouvrement (overlay), permet donc de démarrer au début un programme complètement nouveau. Cela implique qu'il n'y a retour au processus faisant exec(), que si le recouvrement n'a pas eu lieu; la valeur retour sera alors -1. Il faut noter que ce n'est pas une création de processus. En particulier, le même numéro de processus, le même père et la plupart des autres attributs sont conservés pour le nouveau programme. C'est d'ailleurs ainsi qu'un shell exécute une commande: il fait un fork pour créer un processus fils qui ensuite appelle un exec pour charger le fichier binaire de la commande.
Pour comprendre le fonctionnement de ces primitives, il faut se rappeler comment est fait le chargement l'exécution d'un programme. C'est la fonction main() qui débute. Elle est définie comme:
int main(argc, argv, arge) int argc, char *argv[], *arge[];
où argc est le nombre de paramètres qui compose la ligne commande, argv la liste de ces paramètres et arge la liste des variables d'environnement (facultatif).
On retrouve tout cela dans la définition des primitives exec(), sauf qu'ici, argc (qui est juste une astuce interne au shell) ne figurera pas dans ces primitives. La liste des arguments à transmettre au nouveau programme est terminée par la constante char* NULL alias 0. Voici d'ailleurs le profile de ces fonctions:
int execl(chemin, arg0 [, arg1,... , argn], NULL) char *chemin, *arg0, *arg1, ..., *argn; int execv(chemin, argv) char *chemin, *argv[]; int execlp(fichier, arg0 [, arg1,... , argn], NULL) char *fichier, *arg0, *arg1, ..., *argn; int execvp(fichier, argv) char *fichier, *argv[]; int execle(chemin, arg0 [, arg1,... , argn], NULL, arge) char *chemin, *arg0, *arg1, ..., *argn, *arge[]; int execve(chemin, argv, arge) char *chemin, *argv[], *arge[];
En considérant les définitions équivalentes de leurs paramètres, ces primitives se distinguent par:
Exemples:
1) execl("/bin/ls", "ls", "-l", "/usr/local",NULL);
Le processus appelant cette primitive se recouvre par un programme (/bin/ls) exécutant la même chose que ce que la commande shell "ls -l /usr/local" aurait fait, c'est à dire, liste des fichiers du répertoire /usr/local.
Noter que le paramètre correspondant à arg0 est quand même "ls", et cela pour respecter la convention du premier élément de la liste argv.
tounsi@shems 42>cat execl_ls.c #include <stdio.h> main(){ execl("/bin/ls", "ls", "-l", "/usr/local",NULL); perror("execl"); }tounsi@shems 43>cc execl_ls.c tounsi@shems 44>a.out total 3 drwxr-sr-x 2 root 512 Apr 17 1992 bin lrwxrwxrwx 1 root 11 Aug 1 1991 emacs -> /usr1/emacs drwxr-sr-x 7 tester 512 Apr 20 1992 isode-6.0 tounsi@shems 45>ls -l /usr/local total 3 drwxr-sr-x 2 root 512 Apr 17 1992 bin lrwxrwxrwx 1 root 11 Aug 1 1991 emacs -> /usr1/emacs drwxr-sr-x 7 tester 512 Apr 20 1992 isode-6.0
Remarque: perror() n'est exécutée ici que si execl() a raté.
2) execlp("ls", "ls", "-l", "/usr/local",NULL);
Ici le premier "ls" est une référence relative à l'un des répertoires spécifiés par la variable environnement PATH (e.g. .:/bin:/usr/bin/:/usr/local/bin). Ici, elle sera trouvée dans /bin. On a le même résultat que précédemment.
3) execv("/bin/ls", argv);
se comporte comme execl(), mais argv aura été rempli au préalable:
char * argv[MAX]; /* MAX > 3 */ ... argv[0] = "ls"; argv[1] = "-l"; argv[2] = "/usr/local"; argv[3] = NULL; ... execv ("/bin/ls",argv);
De même execvp("ls",argv); se comporte comme execv() avec "/bin/ls" ci-dessus.
4) execle ("com2exec", "com2exec", NULL, arge);
Comme execl(), execle() charge et exécute "com2exec" en substituant l'ancien environnement par celui donné par le tableau arge. Tableau d'éléments sous forme de VARIABLE=valeur, le dernier élément étant NULL. On peut le vérifier par le programme suivant:
tounsi@shems 128> cat execle.c #include <stdio.h> #define MAX 5 main(){ char * arge[MAX]; arge[0]="PATH=/bin"; arge[1]=NULL; execle("com2exec", "com2exec", NULL, arge); perror("execle"); }
Voici maintenant le source de com2exec, qu'on connaît déjà, et à qui on va fournir l'environnement PATH=/bin, pour la recherche de ls.
tounsi@shems 130> cat com2exec.c #include <stdio.h> main(argc, argv, arge) int argc; char* argv[], arge[]; { execlp("ls", "ls", "-l", "/usr/local",NULL); perror("execlp"); }
Avec PATH=/bin
tounsi@shems 132> cc execle.c tounsi@shems 133>a.out total 3 drwxr-sr-x 2 root 512 Apr 17 1992 bin lrwxrwxrwx 1 root 11 Aug 1 1991 emacs -> /usr1/emacs drwxr-sr-x 7 tester 512 Apr 20 1992 isode-6.0
Avec PATH=/etc par exemple
tounsi@shems 138>a.out execlp: No such file or directory
message imprimé par l'appel perror("execlp"); au retour de execlp(), non réussi donc, dans com2exec.c. En effet, la commande ls n'a pas été trouvée dans la liste des répertoires de PATH, et pour cause.
execve() est identique à execle() sauf qu' on utilise un tableau argv comme pour execv().
Exemple:
execve("com2exec", argv, arge);
Pour terminer, sachez que les primitives exec permettent d'exécuter aussi des scripts shell.
Exercice: Si on reprend l'algorithme d'un shell hypothétique vu précédemment:
Répéter
- Lire Ligne Commande
- Analyser et Traiter Paramètres
- Exécuter Commande
Jusqu'à Cde = logout ou Fin Fichier Entrée
/* ^D */
on peut décomposer Exécuter Commande en:
- faire fork() pour créer un nouveau processus
- faire exec() chez le fils pour le recouvrir avec la commande et ses paramètres
Réaliser un mini-shell comme indiqué en se limitant à des commandes sans paramètres d'abord et avec paramètres ensuite. (On négligera les variables d'environnement).
Ces fonctions font partie de la classe des primitives d'attente de la fin d'exécution d'un processus. Les principales sont wait() et waitpid() reprises dans la norme POSIX. D'autres sont disponibles en consultant le manuel man comme wait3() de UNIX BSD, reprise par waitpid() justement.
Ce sont des primitives qui permettent, comme leur nom l'indique, d'attendre (to wait) le résultat de l'exécution d'un processus fils, même s'il s'est recouvert par exec. Plus exactement, elles permettent d'attendre la terminaison d'un descendant avec récupération des informations sur cette terminaison, comme le résultat de exit() s'il a lieu. On peut ainsi synchroniser un processus sur la terminaison d'autres processus. Précisons dès à présent qu'attendre n'implique pas forcément ne rien faire. On verra justement, que l'attente est bloquante ou non, selon le cas et la volonté du processus.
La fonction donnée par
#include <sys/types.h> #include <sys/wait.h> pid_t wait(status) int *status;
bloque un processus père en attente d'un de ses fils. Elle est bloquante donc et retourne le pid du 1er processus fils terminé, sinon -1 s'il n'y a aucun processus fils. Mais attention, il y a deux cas:
Remarque: On peut tester le résultat de wait() pour savoir lequel des fils est effectivement terminé, mais on peut préférer utiliser waitpid(), donnée plus bas, qui permet de désigner le fils qu'on veut attendre.
Les informations relatives à la terminaison d'un processus sont récupérables à travers le paramètre status passé par adresse. De manière générale, un processus doit se terminer normalement par exit(n). Dans ce cas, l'octet de poids faible de n (octet de droite) est mis dans le second octet de poids faible de status. Autrement dit, à la terminaison normale d'un processus, status/256 est la valeur de n dans le processus appelant. On peut y accéder directement, et c'est conseillé, par la fonction macro-définie WEXITSTATUS(status) de <sys/wait.h>. Si le processus se termine anormalement status reflète son état (state), notamment le numéro du signal qui l'a interrompu (accessible avec WTERMSIG(status)) éventuellement augmenté de 128 si une image core a été créée.
Noter qu'un exit(0) reflète par convention une fin sans problème. Par ailleurs, l'instruction return(n) la plus externe, équivaut à exit(n) vers le lanceur.
Exemple:
#include <sys/types.h> #include <sys/wait.h> main() { int status; pid_t pid_fork, pid_wait; if (( pid_fork = fork()) == 0){ system("ps"); exit(1025); } else { pid_wait = wait(&status); printf("\n%d %d %d %d %d \n\n",pid_fork, pid_wait, status, WEXITSTATUS(status), status/256); } return(3); }
On envoie un processus fils, et on l'attend. Auparavant, une commande ps visualise l'état des processus existants. On imprime ensuite les numéros, identiques, du processus fils et de celui qu'on attend ainsi que la valeur, en plusieurs versions, du status retourné (qui est 1 dans ce cas, le premier octet de 1025).
Voici le résultat:
tounsi@gnaoui 29> a.out PID TT STAT TIME COMMAND 10071 p0 S 0:01 - (csh) 10101 p0 S 11:30 xemacs 10211 p0 S 0:00 a.out <--- pere 10212 p0 S 0:00 a.out <--- fils 10213 p0 S 0:00 sh -c ps 10214 p0 R 0:00 ps 10212 10212 256 1 1 <--- résultat de printf tounsi@gnaoui 30> echo $status 3
où on voit:
Incidemment, ce programme main() retourne 3 au shell de lancement, comme l'illustre la commande echo $status après exécution.
Fonction waitpid()
Une seconde fonction:
pid_t waitpid(pid, status, options) int *status; int options;
permet de désigner explicitement le fils préféré qu'on attend et de bloquer ou pas le père. Elle reprend la primitive wait3() de la distribution Berkeley d'UNIX. Nous en présentons les cas simples.
La fonction waitpid() permet d'attendre un processus fils particulier ou appartenant à un groupe. L'appel le plus simple est:
waitpid (pid, &status, 0),
où pid est le processus fils qu'on attend et status l'information sur sa terminaison (par signal() ou exit()). L'option 0 en dernier paramètre bloque le processus qui appelle.
Cette fonction retourne:
Exemple: On crée deux processus fils, le premier fait exit(1) et le second exit(2) et on attend le second.
#include <sys/types.h> #include <sys/wait.h> main(){ pid_t pid, pid1, pid2; int status; if ((pid1 = fork()) == 0) exit(1); else if ((pid2 = fork()) == 0) sleep(2),exit(2); else { pid = waitpid(pid2,&status,0); printf(" fils termine %d son status %d \n",pid, WEXITSTATUS(status)); } }
Résultat de ce programme envoyé en background pour faire ps entre temps:
tounsi@gnaoui 32> a.out& [1] 11091 tounsi@gnaoui 33> ps PID TT STAT TIME COMMAND 11092 p2 Z 0:00 <exiting> <--- 1er fils zombie (etat Z) 10947 p2 S 0:01 - (csh) 11091 p2 S 0:00 a.out <--- père 11093 p2 S 0:00 a.out <--- 2ème fils 11094 p2 R 0:00 ps tounsi@gnaoui 34> fils termine 11093 son status 2 <---resultat
Si maintenant on ne bloque pas le père, option WNOHANG, dans waitpid()
waitpid(pid1, &status, WNOHANG);
on obtient rapidement (car on n'attend pas le fils qui dort)
fils termine 0 son status 0
parce que le fils est encore en cours et que le père ne se bloque pas. Il reçoit donc 0 en retour du waitpid() non bloquant et le status aussi est 0 (non significatif). La terminaison du fils sera ignorée tout simplement. A moins que le père ne fasse un wait() ultérieur. Par exemple boucler en attente sur waitpid(), en exécutant:
while( (pid = waitpid(pid2,&status,WNOHANG)) ==0) printf(" fils termine %d son status %d\n", pid, WEXITSTATUS(status)); printf(" fils termine %d son status %d \n",pid, WEXITSTATUS(status));
Dans ce cas on obtient:
fils termine 0 son status 0 <--- pas de fils terminé fils termine 0 son status 0 . ... . <--- boucle fils termine 0 son status 0 . fils termine 11546 son status 2 <--- ça y est
Comme cela, le père peut effectuer autre chose en attendant ...
Remarque: dans l'exécution précédant cette dernière (sur DEC_ULTRIX), le processus fils 11092 est marqué <exiting> avec état (STATE) Z comme zombie. En principe, il devrait être <defunct>, cas de SUN_OS Release 4.1 par exemple, pour terminé et non encore attendu par son père. <exiting> signifie stoppé en attente de faire exit(). Sur LINUX, c'est marqué <zombie> tout simplement.
Voici en bref, les autres détails sur cette fonction.
La notion de groupe de processus permet de différencier entre les processus lancés dans une même session UNIX à partir d'un terminal. Elle est utile parfois pour pouvoir distinguer entre les processus en premier ou arrière plan. Un processus appartient par défaut au même groupe que son père (Pour plus de details, voir dans man, la famille des primitives getgrgid()).
Ces constantes symbolique sont respectivement 1 et 2.
En tout cas, les 6 fonctions macro-définies suivantes, permettent de savoir ce qu'il en est des processus fils, si appelées immédiatement après un wait() ou waitpid(). Elles sont à appeler avec, comme paramètre donnée, la valeur status fournie au retour.
Exemple: On va créer 3 processus fils dont deux qui tournent indéfiniment. Le père va boucler en attente de tous sans se bloquer, et examinera à chaque fois les informations retournées. Le premier fils va finir par s'arrêter en retournant une valeur status 3. Les autres seront interrompus par un signal (intervention externe kill).
#include <unistd.h> #include <stdio.h> #include <sys/types.h> #include <sys/wait.h #include <errno.h> void f1(),f2(); void (*tab_fonct[3])() = {f1,f2,f2}; int i;
main(){ pid_t pid[3]; pid_t pidter; int status; for (i=0; i<3; i++) switch (pid [i] = fork()){ case (pid_t)0: /* On lance 3 fils */ (*tab_fonct[i])(); exit(status/256); /* car arret fils par kill*/ default: printf("Je suis le pere\n"); } /* on attend jusqu'a ce qu'il n'y ait plus de fils */ while ( (pidter=waitpid(-1, &status, WNOHANG|WUNTRACED)) != -1){ if (WIFEXITED(status)) /* le premier teste positif en principe*/ printf("fils %d termine avec exit %d\n", pidter, WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("fils %d termine avec signal %d\n",pidter, WTERMSIG(status)); else if (WIFSTOPPED(status)) printf("fils %d termine avec sgnalStop %d\n", pidter, WSTOPSIG(status)); sleep(15); } printf("erreur du waitpid %d \n", errno); /* errno = 10 (ECHILD), plus de fils en principe */ }
void f1(){ printf("fils_%d pid %d sage\n", i,getpid()); sleep(3); exit (3); } void f2(){ printf("fils_%d pid %d boucle\n",i, getpid()); while(1); /* sera arrêté par kill -KILL ou par ou kill -STOP */ }
On a utilisé ici la variable int errno du fichier include <errno.h>, qui contient un ensemble de codes d'erreurs associés surtout à la gestion des processus et aux E/S, et symbolisés par des constantes symboliques, dont #define ECHILD 10 qui signifie «pas de fils». errno est positionné automatiquement par UNIX à chaque primitive. Voici le résultat de ce programme, lancé en arrière plan pour intervenir sur le terminal, avec ps et kill. Les interventions utilisateurs sont indiquées $
tounsi@gnaoui 33> a.out & [1] 12916 Je suis le pere fils_0 pid 12917 sage Je suis le pere Je suis le pere fils 0 termine avec exit 0 <-- 1ere attente aucun fils terminé fils_2 pid 12919 boucle fils_1 pid 12918 boucle ps <-- $demande de ps PID TT STAT TIME COMMAND 12917 p2 Z 0:00 <exiting> <-- fils_0 terminé(état Z) 12347 p2 S 0:01 -csh (csh) 12916 p2 S 0:00 a.out 12918 p2 R 0:01 a.out 12919 p2 R 0:01 a.out 12920 p2 R 0:00 ps fils 12917 termine avec exit 3 <-- fils_0 bien terminé ps <-- $deuxieme ps PID TT STAT TIME COMMAND /* fils_0 (12917) inexistant */ 12347 p2 S 0:01 -csh (csh) 12916 p2 S 0:00 a.out 12918 p2 R 0:07 a.out 12919 p2 R 0:07 a.out 12925 p2 R 0:00 ps fils 0 termine avec exit 0 <-- Toujours attente car existe encore 2 fils kill -STOP 12918 <-- $on stoppe fils_1 fils 12918 termine avec sgnalStop 17 <-- bien stoppé kill -KILL 12919 <-- $on "tue" fils_2 fils 0 termine avec exit 0 fils 12919 termine avec signal 9 <-- bien "mort" maintenant fils 0 termine avec exit 0 <-- toujours attente car existe encore 1 fils (celui stoppé) kill -KILL 12918 <-- $on s'en debarasse fils 12918 termine avec signal 9 <-- recu 5/5 erreur du waitpid 10 <-- waitpid = -1 plus de fils errno 10 [1] Done a.out
On voit les attentes infructueuses toutes les 15 secondes (fils non terminés) avec waitpid() = 0 et exit() = 0. Le signal 9 est celui qui tue un processus, et 17 celui qui le stoppe sans le tuer. A la fin, waitpid() = -1, correspond bien à l'erreur aucun fils avec errno = 10, processus sans descendants.
Signaux et Interruptions
Un signal est un événement, interne ou externe, qui interrompt le déroulement normal d'un processus. Cette interruption a lieu en générale, quand le processus passe du mode noyau au mode utilisateur. On dit que le signal est dit délivré. Pour certains, les appels systèmes sont refaits si interrompus. Un signal peut être:
Chaque signal a un sens et possède un numéro symbolisé par une constante. Leur liste se trouve dans /usr/include/signal.h dont voici un extrait, autosignificatif, où on reconnaît les signaux 9 et 17 déjà rencontrés.
#define SIGINT 2 /* interrupt */ #define SIGQUIT 3 /* quit */ #define SIGFPE 8 /* floating point exception */ #define SIGKILL 9 /* kill (cannot be caught or ignored) */ #define SIGBUS 10 /* bus error */ #define SIGSEGV 11 /* segmentation violation */ #define SIGSTOP 17 /* sendable stop signal not from tty */ #define SIGTSTP 18 /* stop signal from tty */ #define SIGCONT 19 /* continue a stopped process */ #define SIGCHLD 20 /* to parent on child stop or exit */
Voir aussi man signal.
A chaque signal, est associé un traitement particulier qui le gère. Ce traitement par défaut, est réalisé par le handler d'interruption du noyau, et se traduit en général par
ou peut être tout simplement le signal est ignoré, comme SIGCHLD qui indique la terminaison d'un fils (stoppé ou zombie). Signal ignoré si le fils n'est pas attendu.
Exemple: Cas d'envoi des signaux par shell à partir du terminal: commandes
kill -n°Sig pid
ou
kill -nomSig pid
On va lancer le programme: main(){ while(1); } qui boucle et on va l'interrompre par envoi de divers signaux.
tounsi@gnaoui 120>cat arretezle.c main(){while(1);} tounsi@gnaoui 121>cc arretezle.c tounsi@gnaoui 122>a.out & [1] 834 tounsi@gnaoui 123>ps 834 PID TT STAT TIME COMMAND 834 p1 R 0:10 a.out <-- il est "Running" tounsi@gnaoui 124>kill -STOP 834 <-- on le stoppe tounsi@gnaoui 125>ps 834 PID TT STAT TIME COMMAND 834 p1 T 0:18 a.out <-- il est "sTopped" [1] + Stopped (signal) a.out tounsi@gnaoui 126>kill -CONT 834 <-- on le reprend tounsi@gnaoui 127>ps 834 PID TT STAT TIME COMMAND 834 p1 R 0:22 a.out <-- redevient "Running" tounsi@gnaoui 128>kill -BUS 834 <-- Provocation "bus error" tounsi@gnaoui 129> [1] Bus error a.out (core dumped) <-- AIE!! tounsi@gnaoui 135>a.out & <-- relance [1] 842 tounsi@gnaoui 136>kill -FPE 842 <-- provocation erreur arith. tounsi@gnaoui 137> [1] Floating exception a.out (core dumped) tounsi@gnaoui 137>a.out & [1] 843 tounsi@gnaoui 138>kill -TERM 843 <-- provocation fin normale tounsi@gnaoui 139> [1] Terminated a.out tounsi@gnaoui 139>a.out & [1] 844 tounsi@gnaoui 140>kill -KILL 844 <-- assassinat tounsi@gnaoui 141> [1] Killed a.out tounsi@gnaoui 141>a.out & [1] 845 tounsi@gnaoui 142>kill -QUIT 845 <-- signal quit tounsi@gnaoui 143> [1] Quit a.out (core dumped) tounsi@gnaoui 144>rm core <-- merci
Remarquer les interruptions qui provoquent une image core (QUIT FPE ...), et le fait qu'un signal ne correspond pas forcément à un événement réel; l'erreur arithmétique n'a pas réellement eu lieu.
La Primitive kill()
Les signaux sont particulièrement intéressants quand ils sont envoyés par un programme utilisateur. On peut ainsi communiquer entre processus (sans échange de données). En fait, les commandes UNIX kill ci-dessus se traduisent par l'envoi, par le processus shell, au processus visé du signal correspondant avec la primitive:
#include <signal.h> int kill(pid, sig) pid_t pid; int sig;
qui envoie au processus pid le signal numéro sig tout simplement. Kill() retourne un entier 0 ou -1 au processus appelant, selon qu'elle s'est déroulée normalement (signal transmis) ou pas (processus inexistant par exemple). pid a en gros le même sens que dans waitpid(), et sig doit être compris entre 1 et NSIG, constante symbolique désignant le nombre maximum de signaux disponibles (32 par exemple chez-moi). Voir fichier include signal.h. Si sig = 0, il n'y a pas de signal envoyé mais cela peut permettre de tester l'existence d'un processus:
kill (pid, 0)
est 0 si un processus de n° pid existe. En outre, l'envoi d'un signal ne peut se faire que si le processus visé appartient au même propriétaire bien sûr. (Exercice: Comment s'envoyer des signaux à soi-même? Comment se suicider?)
La primitive:
int raise (sig) int sig;
envoie le signal sig à soi-même. C'est comme l'appel kill(getpid(), sig). On peut par exemple se faire Hara-Kiri par raise(9); (Réponses aux exercices juste proposés)
Exemple: On reprend le même programme, simplifié, que précédemment pour waitpid() en créant un processus fils qu'on va interrompre par le père.
#include <signal.h> #include <sys/types.h> #include <unistd.h> #include <sys/wait.h> main() { pid_t pid ; int status; if ((pid=fork())==0){ sleep(10); exit(3);} sleep(1); printf("Existe(=0)? %d\n", kill(pid,0)); kill(pid,SIGUSR1); waitpid(pid,&status,0); if (WIFEXITED(status)) printf("fin normale par exit %d\n", WEXITSTATUS(status)); if (WIFSIGNALED(status)) printf("fin anormale par signal %d\n", status); }
Se rappeler que status donne l'état de terminaison d'un processus, accessible par WEXITSTATUS pour fin normale (valeur de exit()) et WTERMSIG pour fin brutale (valeur du signal reçu). Afficher status tout court dans ce dernier cas est toutefois plus informatif, car il est augmenté de 128 si une image core est créée.
Voici l'exécution du programme ci-dessus:
tounsi@gnaoui 56> a.out Existe(=0)? 0 fin anormale par signal 30
SIGUSR1 (valeur 30) est un signal prévu pour l'utilisateur, n'ayant pas d'action spécifique par défaut sinon d'interrompre son programme.
Dans le même programme, avec SIGBUS au lieu de SIGUSR1. SIGBUS (valeur 10) provoque un core et la valeur de status retournée est augmentée de 128
tounsi@gnaoui 58> a.out Existe(=0)? 0 fin anormale par signal 138
Voici maintenant le cas d'un signal SIGCHLD qu'on a mis à la place de SIGUSR1 et qui signifie terminaison d'un fils. L'action par défaut est d'ignorer ce signal. D'autant plus qu'ici le fils n'a pas de fils propre. (Ce signal SIGCHLD, rappelons le, est celui automatiquement envoyé au père quand un fils passe à l'état zombie ou est stoppé).
tounsi@gnaoui 60>a.out Existe(=0)? 0 fin normale par exit 3
On voit donc que le processus a ignoré ce signal et s'est terminé normalement parl'instruction exit(3).
Voici un exemple de signal reçu par le père provenant de son fils.
#include <signal.h> #include <errno.h> #include <sys/types.h> main() { pid_t pid ; if ((pid=fork())==0){ kill(getppid(),SIGCHLD); exit(0);} else sleep(1); printf("Recu signal de mon fils. errno = %d\n", errno); }
Avec SIGCHLD, qui est un signal ignoré, on obtient (printf(...) exécutée):
Recu signal de mon fils. errno = 4
errno = 4 correspond à «appel système interrompu» qui concerne sleep(1). Autrement, ce serait 0 (exercice: le vérifier).
Avec SIGUSR1 au lieu de SIGCHLD, on obtient:
User defined signal 1
qui est un message UNIX, printf(...) ne s'étant pas exécutée, vu que le père a été interrompu.
Exercice: Bloquer son père jusqu'à ce qu'on ait fini (sans utiliser wait()). Faire dormir le père le temps que son fils puisse le bloquer et se rappeler que normalement le père ne peut attendre le fils qu'en se bloquant sur un wait() (ou waitpid() avec option 0) sinon la terminaison d'un fils est signalée mais "ignorée".
Une solution: On bloque (on stoppe au fait) le père par SIGSTOP, et on le libère par SIGCONT. Pendant ce temps, celui-ci, répéte le test si son fils existe toujours.
#include <signal.h> #include <sys/types.h> main() { pid_t pid ; int status,i; if ((pid=fork())==0){ kill(getppid(),SIGSTOP); sleep(10); /* correspondant aux traitements du fils */ kill(getppid(),SIGCONT); exit(0);} else printf("Avant\n"); while ( kill(pid,0) == 0) sleep(1); printf("Apres\n"); }
Résultat: en utilisant /bin/time qui mesure le temps d'exécution
tounsi@gnaoui 74> /bin/time a.out Avant Apres
11.1 real 0.0 user 0.0 sys
Entre Avant et Apres, il s'est écoulé un moment... Par ailleurs, on voit que le temps global est supérieur à dix secondes, temps du fils, et que le temps d'occupation du CPU (user + sys) est quasiment nul. L'attente du père est inactive à cause de sleep()!
Cet exemple montre le besoin de communiquer des données entre deux processus. On peut le faire par fichiers (se rappeler que les données ne sont pas partagées), en particulier des tubes (Named pipes). c.f. Chap VIII.
La Primitive signal()
Permet à un processus de choisir lui-même sa réaction à un signal. C'est une primitive originale UNIX, reprise en C ANSI (mais pas en POSIX, voir pour cela sigaction() ou sigvec() plus générale). signal() est une primitive d'interface plus simple et donc plus portable. Elle permet de dérouter le comportement provoqué par la délivrance d'un signal vers une fonction utilisateur. On dit dans ce cas que le signal est capté par le processus (Ang. Caught, to catch). Ce n'est pas le handler d'interruption par défaut qui va réagir à ce signal dans ce cas (en terminant le processus par exemple).
Remarque: Le nom signal est peut être mal choisi ici, car ce n'est pas une action d'envoi de signal. Dans la littérature sur les systèmes d'exploitations, on trouve ce mot signal, associé à wait(), dans la synchronisation entre processus, à la manière des primitives sémaphores P() et V() de E. W. DIJKSTRA. Ici, ce serait plutôt l'inverse de l'envoi d'un signal. C'est une définition de réaction à un signal.
Signal() est une fonction de profile
void (*signal(sig, handler))() void (*handler)();
et de paramètres
Elle rend un pointeur vers une fonction sans paramètre, inutilisée pour un programme en général; d'où sa définition un peu tortueuse. Mais son usage est simple.
Exemple: étant donnée une routine déclarée void nouv_hand (int); l'appel
signal (SIGBUS, nouv_hand);
installe nouv_hand (int)comme nouveau handler pour l'impertinent signal SIGBUS.
Noter bien que cette primitive n'est pas une attente d'un signal quelconque (voir plus loin la primitive pause()), mais a pour objet d'installer un handler d'interruption et de retourner au programme. C'est comme une "déclaration", mais dynamique, d'un nouveau handler.
Cependant, les signaux SIGCONT, SIGSTOP et SIGKILL ne peuvent, et pour cause, être captés. (Exercice: pourquoi à votre avis). Il en est de même pour SIGALRM (voir alarm() plus bas), si aucune alarme n'est mise.
La valeur retour de signal(), au cas où sig est illégal (sig<0 ou sig>NSIG), est SIG_ERR (alias -1 toujours) macro-définie ... (void (*)())-1.
Remarques:
1) La valeur retour de signal() est un pointeur vers une fonction entière. En l'occurrence, celle précédemment associée au signal considéré. On peut dans ce cas vouloir momentanément changer de handler et restaurer l'ancien plus tard. On peut alors déclarer un pointeur vers une fonction, soit pred()
int (*pred)();
et faire
pred = signal(sig, hand);
pour que pred() pointe vers la fonction précédemment associée à sig. On la restaurera par
signal(sig, pred);
2) Dérouter ne veut pas dire inhiber, bloquer (ou masquer) une interruption! On peut aussi faire cela en C, sujet qui déborde du cadre de ce manuel. Sachez néanmoins qu'il existe une primitive sigvec(2) et d'autres associées, sigblock(2) sigsetmask(2) etc...qui permettent un traitement des signaux beaucoup plus élaboré, et notamment par manipulation du vecteur d'interruptions. L'équivalent POSIX de la fonction signal() étudiée ici est sigaction() associée à d'autres. voir leur manuel respectif (man 2 sigaction ...).
Exemple: On va provoquer un "BUS-ERROR core dumped", et on va dérouter ensuite cette interruption.
#include <signal.h> void handl(int); main() { int pid; if ( (pid=fork()) ==0){ printf("Processus fils %d\n", getpid()); /* signal( SIGBUS, handl); */ while(1)sleep(1); /* boucle jusqu'a interruption */ } else { sleep(1); printf("Mon fils est %d\n",pid); kill(pid, SIGBUS); /* interrompt le fils */ } } void handl(int sig){ printf("Message recu 5/5 %d\n",sig); }
Résultat (instruction signal en commentaire) avec signal SIGBUS et prise en compte par le handler réél provoquant un image core:
tounsi@gnaoui 164> ls -l core core: No such file or directory tounsi@gnaoui 165> a.out Processus fils 1603 Mon fils est 1603 tounsi@gnaoui 166> ls -l core <-- core créé? -rw-r--r-- 1 tounsi 36864 Apr 2 19:36 core
N.B. On ne voit pas apparaître l'horrible message, core dumped, habituel de SIGBUS, le kill étant explicite ici.
Maintenant, l'instruction signal est effective (on a enlevé le commentaire de signal...)
tounsi@gnaoui 175> a.out Processus fils 1624 Mon fils est 1624 Message recu 5/5 10 <--- fonction handl sig = 10 tounsi@gnaoui 176> ls -l core core: No such file or directory
La prise en compte de SIGBUS est déroutée vers la fonction utilisateur handl(). La valeur du signal reçu est bien 10.
Remarques:
1) C'est une valeur 10 mais pas 138! L'augmentation de 128 n'a rien à voir ici. (exercice: Comment et où retrouver cette valeur 138?)
2) Avant que l'instruction signal ne soit effective dans le fils (le noyau le préempt) et le handler associé installé, il se peut qu'entre temps le signal concerné se pointe. Il aura alors son effet précédent (probablement celui par défaut: SIG_DFL). Il n'existe aucun remède contre cela. Comme le dit D. RITCHIE, les signaux ont été prévus pour être fatals ou ignorés (pas pour être captés.) Dans notre exemple, nous avons fait sleep(1) chez le père pour justement laisser au fils le temps de s'exécuter.
3) Dans certaines version d'UNIX (à base d'ATT), il faut réinstaller le handler associé à chaque fois qu'un signal est capté (e.g. en bas de la fonction void handl() ci-dessus, il faut rajouter l'instruction signal (SIGBUS, handl);. Car sinon, c'est le handler SIG_DFL qui reprend le relais après exécution de handl.
Les signaux SIGKILL, SIGSTOP, SIGCONT ne sont pas déroutables comme déjà signalé (sic). L'action du handler par défaut est la seule possible. Ce handler d'ailleurs, est désigné symboliquement par SIG_DFL. De même celui pour ignorer un signal (sauf SIGKILL) est SIG_IGN (il y a le caractère souligné _ ici).
Ainsi dans l'exemple juste ci-dessus, avec signal(SIGBUS, SIG_DFL); dans le fils et toujours kill(pid, SIGBUS); dans le père, on aura l'image core créée par SIG_DFL.
Pour terminer, sachez que la manipulation des signaux par programme doit se faire avec précaution et nécessite une bonne connaissance des primitives concernées, en plus d'une bonne compréhension des techniques des systèmes d'exploitation.
Primitive Pause()
Stoppe un processus jusqu'à réception d'un signal.
#include <unistd.h> int pause()
C'est la primitive qui permet à un processus de se mettre en attente de l'arrivée d'un signal quelconque, e.g. SIGALRM. Le retour au programme, si retour il y a, n'est pas un retour normal vu que le handler d'interruption a fait quelque chose entre temps. La valeur retour est donc -1 et errno vaut EINTR 4 pour appel système interrompu (pause donc).
Ainsi donc, et selon le handler associé au signal qui s'est produit, soit
Primitive Alarm()
Envoi une alarme, i.e. un signal, après un certain temps donné.
#include <unistd.h> unsigned alarm(nb_secondes) unsigned nb_secondes;
Avec cette primitive, le signal SIGALRM (sans A) doit être envoyé au processus appelant au bout d'un nombre de secondes égal au paramètre nb_secondes. Si le signal n'est pas capté ou ignoré, il termine le processus. L'alarme n'est pas héritable. Après fork(), elle est éteinte dans le fils.
Chaque appel à cette primitive remet l'alarme au nouveau temps donné. Néanmoins, sa valeur retour est le temps qui reste à parcourir dans un précédent appel à alarm(). Un argument de 0 secondes éteint l'alarme comme le bouton d'un réveil avant qu'il ne sonne.
A l'appel de alarm(), le processus n'est pas bloqué pour autant et poursuit son activité (ne pas confondre avec sleep()). Justement cela lui permet par exemple de mener une tâche dans un certain délai. Si le délai imparti est bon, il coupe l'alarme par alarm(0), sinon le processus est interrompu par SIGALRM et mène alors l'action appropriée. Il doit donc capter l'alarme dans ce cas.
Exemple: Le programme suivant demande de saisir un entier dans un délai bref de 5 secondes. Si c'est fait il continue normalement (ici imprime cet entier et s'arrête), sinon il sonne une cloche (caractère \007 alias Bell) et fait l'action associée (ici sortie d'un message de regret et arrêt).
tounsi@gnaoui 116>cat alarmEssai.c #include <signal.h> #include <unistd.h> #include <stdio.h> #define TEMPSMAX 5 int OKdelai; void reveil(int); /* capte le signal */ main() { int n; OKdelai = 1; /* mis à 0 si signal capté */ signal(SIGALRM, reveil); printf("Se depecher renter entier (5 sec) \n-> "); alarm(TEMPSMAX); scanf("%d",&n); if (OKdelai){ /* C'est fait dans les délais */ alarm (0); /* arret alarme */ printf("OK %d\n",n); } else printf("Desole\n"); } void reveil (int sig){ OKdelai = 0; printf("\007 Vous avez depasse le temps\n"); fclose(stdin); /* pour debloquer le scanf! */ signal(SIGALRM, reveil); /* pour la prochaine fois */ }
On demande de capter le signal par la routine réveil et on met l'alarme avant la saisie scanf(...). La routine débloque quand même la saisie scanf(...) en fermant le fichier entrée et remet signal pour un besoin ultérieur. Voici le résultat:
tounsi@gnaoui 117>cc alarmEssai.c tounsi@gnaoui 118>a.out Se depecher rentrer entier (5 sec) -> 6 <-- entier tapé OK 6 tounsi@gnaoui 119>a.out Se depecher rentrer entier (5 sec) <-- On ne saisit rien -> Vous avez dépassé le temps <-- BING Desole
La primitive alarm() est intéressante quand elle combinée avec pause().
Voici un programme commande (adapté de "UNIX PROGRAMMING", HAVILAND et SALAMA ) qui permet de rappeler un message à son utilisateur dans un temps donné. Usage:
%rappeler -temps message
Exemple:
%rappeler -10 il est temps de partir
imprimera ce message 10 minutes plus tard. Voici le programme:
#include <stdio.h> #include <unistd.h> #include <sys/types.h> #include <signal.h> int OKtemps=0; /* Pour capter le signal SIGALRM */ int ilEstTemps(int sig){ OKtemps=1; } main(int argc, char *argv[]){ pid_t pid; int nbsec,i; if(argc <2){ printf("%s Usage: rappeler -min message\n",argv[0]); exit(1); } if (sscanf(argv[1],"-%d",&nbsec)==0){ printf("%s temps incorrecte\n",argv[0]) ; exit(2); } nbsec = nbsec*60; /* on cree un fils background et c'est lui qui rappele */ switch(pid=fork()){ case (pid_t)-1: perror("rappeler"); exit(1); case (pid_t)0: break; default: printf("rappeler process-id %d (%d min)\n",pid,nbsec/60); exit(0); } /* C'est le fils qui continue ici */ signal(SIGALRM, ilEstTemps); alarm(nbsec); pause(); /* on attend un signal (esperons SIGALARM)*/ /* ici c'etait SIGALARM */ if(OKtemps){ printf("\007"); for (i=2; i<argc; i++) printf("%s ",argv[i]); printf("\n"); } exit(0); }
Voici un exemple d'exécution:
tounsi@gnaoui 157> cc -o rappeler rappeler.c tounsi@gnaoui 158> rappeler -5 a tout a l'heure rappeler process-id 1156 (5 min) <-- Message UNIX de prise en compte (Commande , pid, (temps) ) tounsi@gnaoui 160>_
Au bout de 5 minutes on obtient le message: "a tout a l'heure"
Exercice: Utiliser ce programme pour prendre une pause café après cinq heures et demi de travail.
Note de l'Auteur: Merci de m'avoir suivi.