Le terme “fichier” en Unix recouvre plusieurs types d’objets:
La représentation d’un fichier contient à la fois les données contenues dans le fichier et des informations sur le fichier (aussi appelées méta-données) telles que son type, les droits d’accès, les dernières dates d’accès, etc.
En première approximation, le système de fichier est un arbre. La racine
est notée '/'
. Les arcs sont étiquetés par des noms (de fichiers), formés
d’une chaîne de caractères quelconques à l’exception des seuls caractères
'\000'
et '/'
, mais il est de bon usage d’éviter également les
caractères non imprimables ainsi que les espaces.
Les nœuds non terminaux du système de fichiers sont appelés
répertoires: il contiennent toujours deux arcs . et
. . qui désignent respectivement le répertoire lui-même et le
répertoire parent. Les autres nœuds sont parfois appelés
fichiers, par opposition aux répertoires, mais cela reste ambigu, car on peut
aussi désigner par fichier un nœud quelconque. Pour éviter
toute ambiguïté, on pourra parler de « fichiers non répertoires ».
Les nœuds du système de fichiers sont désignés par des
chemins. Ceux-ci peuvent se référer à l’origine de la hiérarchie et on
parlera de chemins absolus, ou à un répertoire (en général le répertoire
de travail).
Un chemin relatif est une suite de noms de fichiers séparés par le caractère
'/'
; un chemin absolu est un chemin relatif précédé par le caractère
'/'
(notez le double usage de ce caractère comme séparateur de chemin et
comme le nom de la racine).
La bibliothèque Filename
permet de manipuler les chemins de façon
portable. Notamment Filename.concat
permet de concaténer des chemins sans
faire référence au caractère '/'
, ce qui permettra au code de fonctionner
également sur d’autres architectures (par exemple le caractère de séparation
des chemins est ’\’ sous Windows). De même, le module
Filename
donne des noms currentdir
et parentdir
pour désigner les arcs
. et . . . Les fonctions Filename.basename
et
Filename.dirname
extraient d’un chemin p un préfixe d et un suffixe b
tel que les chemins p et d/b désignent le même fichier, d désigne le
répertoire dans lequel se trouve le fichier et b le nom du fichier dans ce
répertoire. Les opérations définies dans Filename
opèrent uniquement sur
les chemins indépendemment de leur existence dans la hiérarchie.
En fait, la hiérarchie n’est pas un arbre. D’abord les répertoires conventionnels . et . . permettent de s’auto-référencer et de remonter dans la hiérarchie, donc de créer des chemins menant d’un répertoire à lui-même. D’autre part les fichiers non répertoires peuvent avoir plusieurs antécédents. On dit alors qu’il a plusieurs «liens durs». Enfin, il existe aussi des «liens symboliques» qui se prêtent à une double interprétation. Un lien symbolique est un fichier non répertoire dont le contenu est un chemin. On peut donc interpréter un lien symbolique comme un fichier ordinaire et simplement lire son contenu, un lien. Mais on peut aussi suivre le lien symbolique de façon transparente et ne voir que le fichier cible. Cette dernière est la seule interprétation possible lorsque le lien apparaît au milieu d’un chemin: Si s est un lien symbolique dont la valeur est le chemin ℓ, alors le chemin p/s/q désigne le fichier ℓ/q si ℓ est un lien absolu ou le fichier ou p/ℓ/q si ℓ est un lien relatif.
- Liens inverses omis
- Liens durs
7 a deux antécédents 2 et 6
- Liens symboliques
10 désigne 5
11 ne désigne aucun nœud
- Chemins équivalents de 9 à 8?
. . /usr/lib
. /. . /usr/lib, etc.
foo/lib
La figure 2.1 donne un exemple de hiérarchie de fichiers. Le lien symbolique 11 désigné par le chemin /tmp/bar, dont la valeur est le chemin relatif . . /gnu, ne désigne aucun fichier existant dans la hiérarchie (à cet instant).
En général un parcours récursif de la hiérarchie effectue une lecture arborescente de la hiérarchie:
currentdir
et parentdir
sont ignorés.
Si l’on veut suivre les liens symboliques, on est alors ramené à un parcourt de graphe et il faut garder trace des nœuds déjà visités et des nœuds en cours de visite.
Chaque processus a un répertoire de travail. Celui-ci peut être consulté par
la commande getcwd
et changé par la commande chdir
. Il est possible de
restreindre la vision de la hiérarchie. L’appel chroot
p fait du
nœud p, qui doit être un répertoire, la racine de la hiérarchie. Les
chemins absolus sont alors interprétés par rapport à la nouvelle racine
(et le chemin . . appliqué à la nouvelle racine reste bien entendu
à la racine).
Il y a deux manières d’accéder à un fichier. La première est par son nom, ou chemin d’accès à l’intérieur de la hiérarchie de fichiers. Un
fichier peut avoir plusieurs noms différents, du fait des liens durs. Les
noms sont représentés par des chaînes de caractères (type string
). Voici
quelques exemples d’appels système qui opèrent au niveau des noms de
fichiers:
unlink f | efface le fichier de nom f (comme la commande rm -f f) |
link f1 f2 | crée un lien dur nommé f2 sur le fichier
de nom f1 (comme la commande ln f1 f2) |
symlink f1 f2 | crée un lien symbolique nommé f2 sur le fichier
de nom f1 (comme la commande ln -s f1 f2) |
rename f1 f2 | renomme en f2 le fichier de nom f1
(comme la commande mv f1 f2). |
L’autre manière d’accéder à un fichier est par l’intermédiaire d’un
descripteur. Un descripteur représente un pointeur vers un
fichier, plus des informations comme la position courante de
lecture/écriture dans ce fichier, des permissions sur ce fichier
(peut-on lire? peut-on écrire?), et des drapeaux gouvernant le
comportement des lectures et des écritures (écritures en ajout ou en
écrasement, lectures bloquantes ou non). Les descripteurs sont
représentés par des valeurs du type abstrait file_descr
.
Les accès à travers un descripteur sont en grande partie indépendants des accès via le nom du fichier. En particulier, lorsqu’on a obtenu un descripteur sur un fichier, le fichier peut être détruit ou renommé, le descripteur pointera toujours sur le fichier d’origine.
Au lancement d’un programme, trois descripteurs ont été préalloués et
liés aux variables stdin, stdout et stderr du module Unix
:
|
Lorsque le programme est lancé depuis un interpréteur de commandes
interactif et sans redirections, les trois descripteurs font référence
au terminal. Mais si, par exemple, l’entrée a été redirigée par la notation
cmd <
f, alors le descripteur stdin fait référence au fichier
de nom f pendant l’exécition de la commande cmd.
De même cmd >
f (respectivement
cmd 2>
f) fait en sorte que le descripteur stdout
(respectivement
stderr
) fasse reférence au fichier f pendant l’exécution de la commande
cmd.
Les appels système stat, lstat et fstat retournent les méta-données sur un fichier, c’est-à-dire les informations portant sur le nœud lui-même plutôt que son contenu. Entre autres, ces informations décrivent l’identité du fichier, son type du fichier, les droits d’accès, les dates des derniers d’accès, plus un certain nombre d’informations supplémentaires.
Les appels stat
et lstat
prennent un nom de fichier en argument.
L’appel fstat
prend en argument un descripteur déjà ouvert et donne les
informations sur le fichier qu’il désigne. La différence entre stat
et
lstat
se voit sur les liens symboliques: lstat
renvoie les informations
sur le lien symbolique lui-même, alors que stat
renvoie les informations
sur le fichier vers lequel pointe le lien symbolique.
st_dev : int
Un identificateur de la partition disque où se trouve le fichier st_ino : int
Un identificateur du fichier à l’intérieur de sa partition. Le couple ( st_dev
,st_ino
) identifie de manière unique un fichier dans le système de fichier.st_kind : file_kind
Le type du fichier. Le type file_kind
est un type concret énuméré, de constructeurs:
S_REG
fichier normal S_DIR
répertoire S_CHR
fichier spécial de type caractère S_BLK
fichier spécial de type bloc S_LNK
lien symbolique S_FIFO
tuyau S_SOCK
prise st_perm : int
Les droits d’accès au fichier st_nlink : int
Pour un répertoire: le nombre d’entrées dans le répertoire. Pour les autres: le nombre de liens durs sur ce fichier. st_uid : int
Le numéro de l’utilisateur propriétaire du fichier. st_gid : int
Le numéro du groupe propriétaire du fichier. st_rdev : int
L’identificateur du périphérique associé (pour les fichiers spéciaux). st_size : int
La taille du fichier, en octets. st_atime : int
La date du dernier accès au contenu du fichier. (En secondes depuis le 1ier janvier 1970, minuit). st_mtime : int
La date de la dernière modification du contenu du fichier. (Idem.) st_ctime : int
La date du dernier changement de l’état du fichier: ou bien écriture dans le fichier, ou bien changement des droits d’accès, du propriétaire, du groupe propriétaire, du nombre de liens.
Le résultat de ces trois appels est un objet enregistrement
(record) de type stats
décrit dans la table 2.1.
Un fichier est identifié de façon unique par la paire composé de son
numéro de périphérique (typiquement la partition sur laquelle il se trouve)
st_dev
et de son numéro d’inode st_ino
.
Un fichier a un propriétaire st_uid
et un groupe propriétaire st_gid
.
L’ensemble des utilisateurs et des groupes d’utilisateurs sur la machine
est habituellement décrit dans les fichiers /etc/passwd
et /etc/groups
.
On peut les interroger de façon portable par nom à l’aide des commandes
getpwnam et getgrnam ou par numéro à l’aide des commandes
getpwuid et getgrgid.
Le nom de l’utilisateur d’un processus en train de tourner et l’ensemble des groupes auxquels il appartient peuvent être récupérés par les commandes getlogin et getgroups.
L’appel chown
modifie le propriétaire (deuxième argument) et le groupe
propriétaire (troisième argument) d’un fichier (premier argument). Seul le
super utilisateur a le droit de changer arbitrairement ces informations.
Lorsque le fichier est tenu par un descripteur, on utilisera fchown
en
passant les descripteur au lieu du nom de fichier.
Les droits sont codés sous forme de bits dans un entier et le type
file_perm
est simplement une abréviation pour le type int
:
Les droits comportent une information en lecture, écriture et exécution pour
l’utilisateur, le groupe et les autres, plus des bits spéciaux.
Les droits sont donc représentés par un vecteur de bits:
|
où pour chacun des champs user, group et other on indique dans l’ordre les droits en lecture (r), écriture (w) et exécution (x). Les permissions sur un fichier sont l’union des permissions individuelles:
Bit (octal) | Notation ls -l | Droit |
0o100 | --x------ | exécution, pour le propriétaire |
0o200 | -w------- | écriture, pour le propriétaire |
0o400 | r-------- | lecture, pour le propriétaire |
0o10 | -----x--- | exécution, pour les membres des groupes du propriétaire |
0o20 | ----w---- | écriture, pour les membres des groupes du propriétaire |
0o40 | ---r---- | lecture, pour les membres des groupes du propriétaire |
0o1 | --------x | exécution, pour les autres utilisateurs |
0o2 | -------w- | écriture, pour les autres utilisateurs |
0o4 | ------r-- | lecture, pour les autres utilisateurs |
0o1000 | --------t | le bit t sur le groupe (sticky bit) |
0o2000 | -----s--- | le bit s sur le groupe (set-gid ) |
0o4000 | --s------ | le bit s sur l’utilisateur (set-uid ) |
Le sens des droits de lecture et d’écrire est évident ainsi que le droit
d’exécution pour un fichier. Pour un répertoire, le droit d’exécution
signifie le droit de se placer sur le répertoire (faire chdir
sur ce
répertoire). Le droit de lecture sur un répertoire est nécessaire
pour en lister son contenu mais pas pour en lire ses fichiers ou
sous-répertoires (mais il faut alors en connaître le nom).
Les bits spéciaux ne prennent de sens qu’en présence du bit x
(lorsqu’il
sont présents sans le bit x
, ils ne donnent pas de droits
supplémentaires). C’est pour cela que leur représentation se superpose à
celle du bit x
et on utilise les lettres S et T au lieu de
s et t lorsque le bit x
n’est pas simultanément présent.
Le bit t
permet aux sous-répertoires créés d’hériter des droits du
répertoire parent. Pour un répertoire, le bit s
permet d’utiliser le
uid
ou le gid
de propriétaire du répertoire plutôt que de l’utilisateur
à la création des répertoires. Pour un fichier exécutable, le bit s
permet de changer au lancement l’identité effective de l’utilisateur
(setuid) ou du groupe (setgid). Le processus conserve
également ses identités d’origine, à moins qu’il ait les privilèges du super
utilisateur, auquel cas, setuid
et setgid
changent à la fois son
identité effective et son identité d’origine. L’identité effective est
celle sous laquelle le processus s’exécute. L’identité d’origine est
maintenue pour permettre au processus de reprendre ultérieurement celle-ci
comme effective sans avoir besoin de privilèges. Les appels système
getuid et getgid retournent
les identités d’origine et geteuid et getegid retournent
les identités effectives.
Un processus possède également un masque de création de fichiers représenté de la même façon. Comme son nom l’indique, le masque est spécifie des interdictions (droits à masquer): lors de la création d’un fichier tous les bits à 1 dans le masque de création sont mis à zéro dans les droits du fichier créé. Le masque peut être consulté et changé par la fonction
val umask : int -> int |
Comme pour de nombreux appels système qui modifient une variable système, l’ancienne valeur de la variable est retournée par la fonction de modification. Pour simplement consulter la valeur, il faut donc la modifier deux fois, une fois avec une valeur arbitraire, puis remettre l’ancienne valeur en place. Par exemple, en faisant:
let m = umask 0 in ignore (umask m); m |
Les droits d’accès peuvent être modifiés avec l’appel chmod
.
On peut également tester les droits d’accès «dynamiquement» avec l’appel
système access
type access_permission = R_OK | W_OK | X_OK | F_OK |
val access : string -> access_permission list -> unit |
où les accès demandés sont représentés pas le type access_permission
dont
le sens est immédiat sauf pour F_OK
qui signifie seulement que le fichier
existe (éventuellement sans que le processus ait les droits correspondants).
Notez que access
peut retourner une information plus restrictive que celle
calculée à partir de l’information statique retournée par lstat
car une
hiérarchie de fichiers peut être montrée avec des droits restreints, par
exemple en lecture seule. Dans ce cas, access
refusera le droit d’écrire
alors que l’information contenue dans les méta-données relative au fichier peut
l’autoriser. C’est pour cela qu’on parle d’information «dynamique»
(ce que le processus peut réellement faire) par opposition à «statique»
(ce que le système de fichier indique).
Seul le noyau écrit dans les répertoires (lorsque des fichiers sont créés). Il est donc interdit d’ouvrir un répertoire en écriture. Dans certaines versions d’Unix on peut ouvrir un répertoire en lecture seule et le lire avec read, mais d’autres versions l’interdise. Cependant, même si c’est possible, il est préférable de ne pas le faire car le format des entrées des répertoires varie suivant les versions d’Unix, et il est souvent complexe. Les fonctions suivantes permettent de lire séquentiellement un répertoire de manière portable:
La fonction opendir renvoie un descripteur de lecture sur un
répertoire. La fonction readdir lit la prochaine entrée d’un
répertoire (ou déclenche l’exception End_of_file
si la fin du
répertoire est atteinte). La chaîne renvoyée est un nom de fichier
relatif au répertoire lu. La fonction rewinddir
repositionne
le descripteur au début du répertoire.
Pour créer un répertoire, ou détruire un répertoire vide, on dispose de:
Le deuxième argument de mkdir encode les droits d’accès donnés au nouveau répertoire. Notez qu’on ne peut détruire qu’un répertoire déjà vide. Pour détruire un répertoire et son contenu, il faut donc d’abord aller récursivement vider le contenu du répertoire puis détruire le répertoire.
Par exemple, on peut écrire une fonction d’intérêt général dans le
module Misc
qui itère sur les entrées d’un répertoire.
let iter_dir f dirname = let d = opendir dirname in try while true do f (readdir d) done with End_of_file -> closedir d |
La commande Unix find
permet de rechercher récursivement des fichiers dans
la hiérarchie selon certains critères (nom, type et droits du fichier)
etc. Nous nous proposons ici de réaliser d’une part une fonction de bibliothèque
Findlib.find
permettant d’effectuer de telles recherches et une commande
find
fournissant une version restreinte de la commande Unix find
n’implantant que les options -follow
et -maxdepth
.
Nous imposons l’interface suivante pour la bibliothèque Findlib
:
val find : (Unix.error * string * string -> unit) -> (string -> Unix.stats -> bool) -> bool -> int -> string list -> unit |
L’appel de fonction "find" handler action follow depth roots parcourt
la hiérarchie de fichiers à partir des racines indiquées dans la liste
roots (absolues ou relatives au répertoire courant au moment de l’appel)
jusqu’à une profondeur maximale depth en suivant les liens symboliques si
le drapeau follow est vrai. Les chemins trouvés sous une racine r
incluent r comme préfixe. Chaque chemin trouvé p est passé à la
fonction action. En fait, action reçoit également les informations
Unix.stat
p si le drapeau follow est vrai ou Unix.lstat
p
sinon. La fonction action retourne un booléen indiquant également dans le
cas d’un répertoire s’il faut poursuivre la recherche en profondeur (true
)
ou l’interrompre (false
).
La fonction handler sert au traitement des erreurs de parcours,
nécessairement de type Unix_error
: les arguments de l’exception sont alors
passés à la fonction handler et le parcours continue. En cas
d’interruption, l’exception est remontée à la fonction appelante.
Lorsqu’une exception est levée par les fonctions action ou handler, elle
arrête le parcours de façon abrupte et est remontée immédiatement à
l’appelant.
Pour remonter une exception Unix_error
sans qu’elle puisse être attrapée
comme une erreur de parcours, nous la cachons sous une autre exception.
exception Hidden of exn let hide_exn f x = try f x with exn -> raise (Hidden exn);; let reveal_exn f x = try f x with Hidden exn -> raise exn;; |
Voici le code de la fonction de parcours.
open Unix;; let find on_error on_path follow depth roots = let rec find_rec depth visiting filename = try let infos = (if follow then stat else lstat) filename in let continue = hide_exn (on_path filename) infos in let id = infos.st_dev, infos.st_ino in if infos.st_kind = S_DIR && depth > 0 && continue && (not follow || not (List.mem id visiting)) then let process_child child = if (child <> Filename.current_dir_name && child <> Filename.parent_dir_name) then let child_name = Filename.concat filename child in let visiting = if follow then id :: visiting else visiting in find_rec (depth-1) visiting child_name in Misc.iter_dir process_child filename with Unix_error (e, b, c) -> hide_exn on_error (e, b, c) in reveal_exn (List.iter (find_rec depth [])) roots;; |
Les répertoires sont identifiés par la paire id
(ligne 21)
constituée de leur numéro de périphérique et de leur numéro d’inode. La liste
visiting
contient l’ensemble des répertoires en train d’être visités. En
fait cette information n’est utile que si l’on suit les liens symboliques
(ligne 19).
On peut maintenant en déduire facilement la commande find
.
let find () = let follow = ref false in let maxdepth = ref max_int in let roots = ref [] in let usage_string = ("Usage: " ^ Sys.argv.(0) ^ " [files...] [options...]") in let opt_list = [ "-maxdepth", Arg.Int ((:=) maxdepth), "max depth search"; "-follow", Arg.Set follow, "follow symbolic links"; ] in Arg.parse opt_list (fun f -> roots := f :: !roots) usage_string; let action p infos = print_endline p; true in let errors = ref false in let on_error (e, b, c) = errors := true; prerr_endline (c ^ ": " ^ Unix.error_message e) in Findlib.find on_error action !follow !maxdepth (if !roots = [] then [ Filename.current_dir_name ] else List.rev !roots); if !errors then exit 1;; Unix.handle_unix_error find ();; |
L’essentiel du code est constitué par l’analyse de la ligne de commande,
pour laquelle nous utilisons la bibliothèque Arg
.
Bien que la commande find
implantée ci-dessus soit assez restreinte, la
fonction de bibliothèque Findlib.find
est quant à elle très générale, comme
le montre l’exercice suivant.
Findlib
pour écrire un programme find_but_CVS
équivalent à la commande Unix find . -type d -name CVS -prune -o -print
qui imprime récursivement les fichiers à partir du répertoire courant mais
sans voir (ni imprimer, ni visiter) les répertoires de nom CVS
.
getcwd
n’est pas un appel système mais définie en bibliothèque.
Donner une implémentation «primitive» de getcwd
.
Décrire le principe de l’algorithme.
Puis écrire l’algorithme (on évitera de répéter plusieurs fois le même appel
système).
La primitive openfile permet d’obtenir un descripteur sur un
fichier d’un certain nom (l’appel système correspond est open,
mais open
est un mot clé en OCaml).
val openfile : string -> open_flag list -> file_perm -> file_descr |
Le premier argument est le nom du fichier à ouvrir. Le deuxième
argument est une liste de drapeaux pris dans le type énuméré
open_flag
, et décrivant dans quel mode le fichier doit être ouvert,
et que faire s’il n’existe pas. Le troisième argument
de type file_perm
indique avec
quels droits d’accès créer le fichier, le cas échéant. Le résultat est
un descripteur de fichier pointant vers le fichier indiqué. La
position de lecture/écriture est initialement fixée au début du fichier.
La liste des modes d’ouverture (deuxième argument) doit contenir exactement un des trois drapeaux suivants:
|
Ces drapeaux conditionnent la possibilité de faire par la suite des
opérations de lecture ou d’écriture à travers le descripteur. L’appel
openfile
échoue si on demande à ouvrir en écriture un fichier sur lequel
le processus n’a pas le droit d’écrire, ou si on demande à ouvrir en
lecture un fichier que le processus n’a pas le droit de lire. C’est
pourquoi il ne faut pas ouvrir systématiquement en mode O_RDWR
.
La liste des modes d’ouverture peut contenir en plus un ou plusieurs des drapeaux parmi les suivants:
|
Le premier groupe indique le comportement à suivre selon que le fichier existe ou non.
Si O_APPEND
est fourni, le pointeur de lecture/écriture sera
positionné à la fin du fichier avant chaque écriture. En conséquence,
toutes les écritures s’ajouteront à la fin du fichier. Au contraire,
sans O_APPEND
, les écritures se font à la position courante
(initialement, le début du fichier).
Si O_TRUNC
est fourni, le fichier est tronqué au moment de
l’ouverture: la longueur du fichier est ramenée à zéro, et les octets
contenus dans le fichier sont perdus. Les écritures repartent donc
d’un fichier vide. Au contraire, sans O_TRUNC
, les écritures se
font par dessus les octets déjà présents, ou à la suite.
Si O_CREAT
est fourni, le fichier est créé s’il n’existe pas déjà.
Le fichier est créé avec une taille nulle, et avec pour droits d’accès
les droits indiqués par le troisième argument, modifiés par le masque
de création du processus. (Le masque de création est consultable et
modifiable par la commande umask, et par l’appel système de même
nom).
Exemple:
la plupart des programmes prennent 0o666
comme
troisième argument de openfile
, c’est-à-dire rw-rw-rw-
en notation
symbolique. Avec le masque de création standard de 0o022
, le fichier
est donc créé avec les droits rw-r--r--
. Avec
un masque plus confiant de 0o002
, le fichier est créé avec les droits
rw-rw-r--
.
Si O_EXCL
est fourni, openfile
échoue si le fichier existe déjà. Ce
drapeau, employé en conjonction avec O_CREAT
, permet d’utiliser des
fichiers comme verrous (locks).1 Un processus qui veut
prendre le verrou appelle openfile
sur le fichier avec les modes O_EXCL
et O_CREAT
. Si le fichier existe déjà, cela signifie qu’un autre processus
détient le verrou. Dans ce cas, openfile
déclenche une erreur, et il faut
attendre un peu, puis réessayer. Si le fichier n’existe pas, openfile
retourne sans erreur et le fichier est créé, empêchant les autres processus
de prendre le verrou. Pour libérer le verrou, le processus qui le détient
fait unlink dessus. La création d’un fichier est une opération
atomique: si deux processus essayent de créer un même fichier en parallèle
avec les options O_EXCL
et O_CREAT
, au plus un seul des deux seulement peut
réussir. Évidemment cette méthode n’est pas très satisfaisante car d’une
part le processus qui n’a pas le verrou doit être en attente active, d’autre
part un processus qui se termine anormalement peux laisser le verrou
bloqué.
Exemple:
pour se préparer à lire un fichier:
openfile filename [O_RDONLY] 0 |
Le troisième argument peut être quelconque, puisque O_CREAT
n’est
pas spécifié. On prend conventionnellement 0
. Pour écrire un fichier
à partir de rien, sans se préoccuper de ce qu’il contenait
éventuellement:
openfile filename [O_WRONLY; O_TRUNC; O_CREAT] 0o666 |
Si le fichier qu’on ouvre va contenir du code exécutable (cas des
fichiers créés par ld
), ou un script de commandes, on ajoute les
droits d’exécution dans le troisième argument:
openfile filename [O_WRONLY; O_TRUNC; O_CREAT] 0o777 |
Si le fichier qu’on ouvre est confidentiel, comme par exemple les
fichiers “boîte aux lettres” dans lesquels mail
stocke
les messages lus, on le crée en restreignant la lecture et l’écriture
au propriétaire uniquement:
openfile filename [O_WRONLY; O_TRUNC; O_CREAT] 0o600 |
Pour se préparer à ajouter des données à la fin d’un fichier existant, et le créer vide s’il n’existe pas:
openfile filename [O_WRONLY; O_APPEND; O_CREAT] 0o666 |
Le drapeau O_NONBLOCK
assure que si le support est un tuyau nommé
ou un fichier spécial, alors l’ouverture du fichier ainsi que les
lectures et écritures ultérieur se feront en mode non bloquant.
Le drapeau O_NOCTYY
assure que si le support est un terminal
de contrôle (clavier, fenêtre, etc.), alors celui-ci ne devient pas le
terminal de contrôle du processus appelant.
Le dernier groupe de drapeaux indique comment synchroniser les opérations de
lectures et écritures. Par défaut, ces opérations ne sont pas
synchronisées.
Si O_DSYNC
est fourni, les données sont écrites de façon synchronisée de
telle façon que la commande est bloquante et ne retourne que lorsque toutes
les écritures auront été effectuées physiquement sur le support (disque en
général).
Si O_SYNC
est fourni, ce sont à la fois les données et les
informations sur le fichier qui sont synchronisées.
Si O_RSYNC
est fourni en présence de O_DSYNC
les lectures des données
sont également synchronisées: il est assuré que toutes les écritures en
cours (demandées mais pas nécessairement enregistrées) sur ce fichier
seront effectivement écrites sur le support avant la prochaine lecture. Si
O_RSYNC
est fourni en présence de O_SYNC
cela s’applique également aux
informations sur le fichier.
Les appels systèmes read et write permettent de lire
et d’écrire les octets d’un fichier.
Pour des raisons historiques, l’appel système write
est relevé en
OCaml sous le nom single_write
:
val read : file_descr -> string -> int -> int -> int val single_write : file_descr -> string -> int -> int -> int |
Les deux appels read
et single_write
ont la même interface. Le premier
argument est le descripteur sur lequel la lecture ou l’écriture doit avoir
lieu. Le deuxième argument est une chaîne de caractères contenant les octets
à écrire (cas de single_write
), ou dans laquelle vont être stockés les
octets lus (cas de read
). Le troisième argument est la position, dans la
chaîne de caractères, du premier octet à écrire ou à lire. Le quatrième
argument est le nombre d’octets à lire ou à écrire. Le troisième argument et
le quatrième argument désignent donc une sous-chaîne de la chaîne passée en
deuxième argument. (Cette sous-chaîne ne doit pas déborder de la chaîne
d’origine; read
et single_write
ne vérifient pas ce fait.)
L’entier renvoyé par read
ou single_write
est le nombre d’octets
réellement lus ou écrits.
Les lectures et les écritures ont lieu à partir de la position
courante de lecture/écriture. (Si le fichier a été ouvert en mode
O_APPEND
, cette position est placée à la fin du fichier avant toute
écriture.) Cette position est avancée du nombre d’octets lus ou
écrits.
Dans le cas d’une écriture, le nombre d’octets effectivement écrits est normalement le nombre d’octets demandés, mais il y a plusieurs exceptions à ce comportement: (i) dans le cas où il n’est pas possible d’écrire les octets (si le disque est plein, par exemple); (ii) lorsqu’on écrit sur un descripteur de fichiers qui référence un tuyau ou une prise placé dans le mode entrées/sorties non bloquantes, les écritures peuvent être partielles; enfin, (iii) OCaml qui fait une copie supplémentaire dans un tampon auxiliaire et écrit celui-ci limite la taille du tampon auxiliaire à une valeur maximale (qui est en général la taille utilisée par le système pour ses propres tampons) ceci pour éviter d’allouer de trop gros tampons; si le le nombre d’octets à écrire est supérieure à cette limite, alors l’écriture sera forcément partielle même si le système aurait assez de ressource pour effectuer une écriture totale.
Pour contourner le problème de la limite des tampons, OCaml fournit
également une fonction write
qui répète plusieurs écritures tant qu’il n’y
a pas eu d’erreur d’écriture. Cependant, en cas d’erreur, la fonction
retourne l’erreur et ne permet pas de savoir le nombre d’octets
effectivement écrits. On utilisera donc plutôt la fonction single_write
que write
parce qu’elle préserve l’atomicité (on sait exactement ce qui a
été écrit) et est donc plus fidèle à l’appel système d’Unix (voir également
l’implémentation de single_write
décrite dans le chapitre
suivant 5.7).
Nous verrons dans le chapitre suivant que lorsqu’on écrit sur un descripteur
de fichier qui référence un tuyau ou une prise qui est placé dans le mode
entrées/sorties bloquantes et que l’appel est interrompu par un signal,
l’appel single_write
retourne une erreur EINTR
.
Exemple:
supposant fd
lié à un descripteur ouvert en écriture,
write fd "Hello world!" 3 7 |
écrit les caractères “"lo worl"” dans le fichier correspondant, et renvoie 7.
Dans le cas d’une lecture, il se peut que le nombre d’octets
effectivement lus soit strictement inférieur au nombre d’octets
demandés. Premier cas: lorsque la fin du fichier est proche,
c’est-à-dire lorsque le nombre d’octets entre la position courante
et la fin du fichier est inférieur au nombre d’octets requis.
En particulier, lorsque la position courante est sur la fin du
fichier, read
renvoie zéro. Cette convention “zéro égal fin de
fichier” s’applique aussi aux lectures depuis des fichiers spéciaux
ou des dispositifs de communication. Par exemple, read
sur le
terminal renvoie zéro si on frappe ctrl-D
en début de ligne.
Deuxième cas où le nombre d’octets lus peut être inférieur au nombre
d’octets demandés: lorsqu’on lit depuis un fichier spécial tel qu’un
terminal, ou depuis un dispositif de communication comme un tuyau ou une
prise. Par exemple, lorsqu’on lit depuis le terminal, read
bloque
jusqu’à ce qu’une ligne entière soit disponible. Si la longueur de la ligne
dépasse le nombre d’octets requis, read
retourne le nombre d’octets
requis. Sinon, read
retourne immédiatement avec la ligne lue, sans forcer
la lecture d’autres lignes pour atteindre le nombre d’octets requis. (C’est
le comportement par défaut du terminal; on peut aussi mettre le
terminal dans un mode de lecture caractère par caractère au lieu de ligne à
ligne. Voir section 2.13 ou page ??
pour avoir tous les détails.)
Exemple:
l’expression suivante lit au plus 100 caractères depuis l’entrée standard, et renvoie la chaîne des caractères lus.
let buffer = String.create 100 in let n = read stdin buffer 0 100 in String.sub buffer 0 n |
Exemple:
la fonction really_read ci-dessous a la même
interface que read
, mais fait plusieurs tentatives de lecture si
nécessaire pour essayer de lire le nombre d’octets requis. Si, ce
faisant, elle rencontre une fin de fichier, elle déclenche l’exception
End_of_file
.
let rec really_read fd buffer start length = if length <= 0 then () else match read fd buffer start length with 0 -> raise End_of_file | r -> really_read fd buffer (start + r) (length - r);; |
L’appel système close ferme le descripteur passé en argument.
val close : file_descr -> unit |
Une fois qu’un descripteur a été fermé, toute tentative de lire,
d’écrire, ou de faire quoi que ce soit avec ce descripteur échoue.
Il est recommandé de fermer les descripteurs dès qu’ils ne sont plus
utilisés. Ce n’est pas obligatoire; en particulier, contrairement à ce
qui se passe avec la bibliothèque standard Pervasives
, il n’est pas
nécessaire de fermer les descripteurs pour être certain que les
écritures en attente ont été effectuées: les écritures faites avec
write
sont immédiatement transmises au noyau. D’un autre côté, le
nombre de descripteurs qu’un processus peut allouer est limité par le
noyau (plusieurs centaines à quelques milliers). Faire
close
sur un descripteur inutile permet de le désallouer, et donc
d’éviter de tomber à court de descripteurs.
On va programmer une commande file_copy
, à deux arguments f1 et
f2, qui recopie dans le fichier de nom f2 les octets contenus
dans le fichier de nom f1.
open Unix;; let buffer_size = 8192;; let buffer = String.create buffer_size;; let file_copy input_name output_name = let fd_in = openfile input_name [O_RDONLY] 0 in let fd_out = openfile output_name [O_WRONLY; O_CREAT; O_TRUNC] 0o666 in let rec copy_loop () = match read fd_in buffer 0 buffer_size with 0 -> () | r -> ignore (write fd_out buffer 0 r); copy_loop () in copy_loop (); close fd_in; close fd_out;; |
let copy () = if Array.length Sys.argv = 3 then begin file_copy Sys.argv.(1) Sys.argv.(2); exit 0 end else begin prerr_endline ("Usage: " ^Sys.argv.(0)^ " <input_file> <output_file>"); exit 1 end;; handle_unix_error copy ();; |
L’essentiel du travail est fait par la fonction file_copy
des lignes
6–15. On commence par ouvrir un descripteur en lecture seule
sur le fichier d’entrée (ligne 7), et un descripteur en écriture
seule sur le fichier de sortie (ligne 8). Le fichier de sortie
est tronqué s’il existe déjà (option O_TRUNC
), et créé s’il n’existe pas
(option O_CREAT
), avec les droits rw-rw-rw-
modifiés par le masque de
création. (Ceci n’est pas satisfaisant: si on copie un fichier exécutable,
on voudrait que la copie soit également exécutable. On verra plus loin
comment attribuer à la copie les mêmes droits d’accès qu’à l’original.) Dans
les lignes 9–13, on effectue la copie par blocs de buffer_size
caractères. On demande à lire buffer_size
caractères (ligne
10). Si
read
renvoie zéro, c’est qu’on a atteint la fin du fichier d’entrée, et la
copie est terminée (ligne 11). Sinon (ligne 12), on
écrit les r
octets qu’on vient de lire sur le fichier de destination, et on
recommence. Finalement, on ferme les deux descripteurs. Le programme
principal (lignes 17–24) vérifie que la commande a reçu deux
arguments, et les passe à la fonction file_copy
.
Toute erreur pendant la copie, comme par exemple l’impossibilité
d’ouvrir le fichier d’entrée, parce qu’il n’existe pas ou parce qu’il
n’est pas permis de le lire, ou encore l’échec d’une écriture par
manque de place sur le disque, se traduit par une exception
Unix_error
qui se propage jusqu’au niveau le plus externe du
programme, où elle est interceptée et affichée par handle_unix_error
.
-a
au programme, telle que
file_copy -a
f1 f2
ajoute le contenu de f1 à la fin de f2 si f2 existe déjà.
Dans l’exemple file_copy
, les lectures se font par blocs de 8192
octets. Pourquoi pas octet par octet? ou mégaoctet par mégaoctet? Pour des
raisons d’efficacité. La figure 2.2 montre la vitesse de
copie, en octets par seconde, du programme file_copy
, quand on fait varier
la taille des blocs (la variable buffer_size
) de 1 octet a 8 mégaoctets,
en doublant à chaque fois.
Pour de petites tailles de blocs, la vitesse de copie est à peu près
proportionnelle à la taille des blocs. Cependant, la quantité de
données transférées est la même quelle que soit la taille des blocs.
L’essentiel du temps ne passe donc pas dans le transfert de données
proprement dit, mais dans la gestion de la boucle copy_loop
, et dans
les appels read
et write
. En mesurant plus finement, on voit que
ce sont les appels read
et write
qui prennent l’essentiel du
temps. On en conclut donc qu’un appel système, même lorsqu’il n’a pas
grand chose à faire (read
d’un caractère), prend un temps minimum
d’environ 4 micro-secondes (sur la machine employée pour faire le
test—un Pentium 4 à 2.8 GHz), disons 1 à 10 micro-secondes. Pour des blocs
d’entrée/sortie de petite taille, c’est ce temps d’appel système qui prédomine.
Pour des blocs plus gros, entre 4K et 1M, la vitesse est constante et maximale. Ici, le temps lié aux appels systèmes et à la boucle de copie est petit devant le temps de transfert des données. D’autre part la taille du tampon devient supérieur à la tailles des caches utilisés par le système. Et le temps passé par le système à gérer le transfert devient prépondérant sur le coût d’un appel système2
Enfin, pour de très gros blocs (8M et plus), la vitesse passe légèrement au-dessous du maximum. Entre en jeu ici le temps nécessaire pour allouer le bloc et lui attribuer des pages de mémoire réelles au fur et à mesure qu’il se remplit.
Moralité: un appel système, même s’il fait très peu de travail, coûte cher — beaucoup plus cher qu’un appel de fonction normale: en gros, de 2 à 20 micro-secondes par appel système, suivant les architectures. Il est donc important d’éviter de faire des appels système trop fréquents. En particulier, les opérations de lecture et d’écriture doivent se faire par blocs de taille suffisante, et non caractère par caractère.
Dans des exemples comme file_copy
, il n’est pas difficile de faire
les entrées/sorties par gros blocs. En revanche, d’autres types de
programmes s’écrivent naturellement avec des entrées caractère par
caractère (exemples: lecture d’une ligne depuis un fichier, analyse
lexicale), et des sorties de quelques caractères à la fois
(exemple: affichage d’un nombre). Pour répondre aux besoins de ces
programmes, la plupart des systèmes fournissent des bibliothèques
d’entrées-sorties, qui intercalent une couche de logiciel
supplémentaire entre l’application et le système d’exploitation.
Par exemple, en OCaml, on dispose du module Pervasives
de la
bibliothèque standard, qui fournit deux types abstraits in_channel
et out_channel
, analogues aux descripteurs de fichiers, et des
opérations sur ces types, comme input_char
, input_line
,
output_char
, ou output_string
.
Cette couche supplémentaire utilise des tampons (buffers) pour
transformer des suites de lectures ou d’écritures caractère par
caractère en une lecture ou une écriture d’un bloc. On obtient donc de
bien meilleures performances pour les programmes qui procèdent
caractère par caractère. De plus, cette couche supplémentaire permet
une plus grande portabilité des programmes: il suffit d’adapter cette
bibliothèque aux appels système fournis par un autre système
d’exploitation, et tous les programmes qui utilisent la bibliothèque
sont immédiatement portables vers cet autre système d’exploitation.
Pour illustrer les techniques de lecture/écriture par tampon, voici
une implémentation simple d’un fragment de la bibliothèque Pervasives
de
OCaml. L’interface est la suivante:
type in_channel exception End_of_file val open_in : string -> in_channel val input_char : in_channel -> char val close_in : in_channel -> unit type out_channel val open_out : string -> out_channel val output_char : out_channel -> char -> unit val close_out : out_channel -> unit |
Commençons par la partie “lecture”. Le type abstrait in_channel
est implémenté comme suit:
open Unix;; type in_channel = { in_buffer: string; in_fd: file_descr; mutable in_pos: int; mutable in_end: int };; exception End_of_file |
La chaîne de caractères du champ in_buffer
est le tampon proprement
dit. Le champ in_fd
est un descripteur de fichier (Unix), ouvert sur
le fichier en cours de lecture. Le champ in_pos
est la position
courante de lecture dans le tampon. Le champ in_end
est le nombre de
caractères valides dans le tampon.
Les champs in_pos
et in_end
vont être modifiés en place à
l’occasion des opérations de lecture; on les déclare donc mutable
.
let buffer_size = 8192;; let open_in filename = { in_buffer = String.create buffer_size; in_fd = openfile filename [O_RDONLY] 0; in_pos = 0; in_end = 0 };; |
À l’ouverture d’un fichier en lecture, on crée le tampon avec une
taille raisonnable (suffisamment grande pour ne pas faire d’appels
système trop souvent; suffisamment petite pour ne pas gâcher de
mémoire), et on initialise le champ in_fd
par un descripteur de
fichier Unix ouvert en lecture seule sur le fichier en question. Le
tampon est initialement vide (il ne contient aucun caractère du
fichier); le champ in_end
est donc initialisé à zéro.
let input_char chan = if chan.in_pos < chan.in_end then begin let c = chan.in_buffer.[chan.in_pos] in chan.in_pos <- chan.in_pos + 1; c end else begin match read chan.in_fd chan.in_buffer 0 buffer_size with 0 -> raise End_of_file | r -> chan.in_end <- r; chan.in_pos <- 1; chan.in_buffer.[0] end;; |
Pour lire un caractère depuis un in_channel
, de deux choses l’une.
Ou bien il reste au moins un caractère dans le tampon; c’est-à-dire,
le champ in_pos
est strictement inférieur au champ in_end
. Alors
on renvoie le prochain caractère du tampon, celui à la position
in_pos
, et on incrémente in_pos
. Ou bien le tampon est vide. On
fait alors un appel système read
pour remplir le tampon. Si read
retourne zéro, c’est que la fin du fichier a été atteinte; on
déclenche alors l’exception End_of_file
. Sinon, on place le nombre
de caractères lus dans le champ in_end
. (On peut avoir obtenu moins
de caractères que demandé, et donc le tampon peut être partiellement
rempli.) Et on renvoie le premier des caractères lus.
let close_in chan = close chan.in_fd;; |
La fermeture d’un in_channel
se réduit à la fermeture du descripteur
Unix sous-jacent.
La partie “écriture” est très proche de la partie “lecture”. La seule dissymétrie est que le tampon contient maintenant des écritures en retard, et non plus des lectures en avance.
type out_channel = { out_buffer: string; out_fd: file_descr; mutable out_pos: int };; let open_out filename = { out_buffer = String.create 8192; out_fd = openfile filename [O_WRONLY; O_TRUNC; O_CREAT] 0o666; out_pos = 0 };; let output_char chan c = if chan.out_pos < String.length chan.out_buffer then begin chan.out_buffer.[chan.out_pos] <- c; chan.out_pos <- chan.out_pos + 1 end else begin ignore (write chan.out_fd chan.out_buffer 0 chan.out_pos); chan.out_buffer.[0] <- c; chan.out_pos <- 1 end;; let close_out chan = ignore (write chan.out_fd chan.out_buffer 0 chan.out_pos); close chan.out_fd;; |
Pour écrire un caractère sur un out_channel
, ou bien le tampon n’est
pas plein, et on se contente de stocker le caractère dans le tampon à
la position out_pos
, et d’avancer out_pos
; ou bien le tampon est
plein, et dans ce cas on le vide dans le fichier par un appel write
,
puis on stocke le caractère à écrire au début du tampon.
Quand on ferme un out_channel
, il ne faut pas oublier de vider le
contenu du tampon (les caractères entre les positions 0 incluse et
out_pos
exclue) dans le fichier. Autrement, les écritures effectuées
depuis la dernière vidange seraient perdues.
output_char
sur chaque caractère
de la chaîne, mais est plus efficace.
L’appel système lseek permet de changer la position courante de lecture et d’écriture.
val lseek : file_descr -> int -> seek_command -> int |
Le premier argument est le descripteur qu’on veut positionner. Le deuxième argument est la position désirée. Il est interprété différemment suivant la valeur du troisième argument, qui indique le type de positionnement désiré:
SEEK_SET | Positionnement absolu. Le deuxième argument est le numéro du caractère où se placer. Le premier caractère d’un fichier est à la position zéro. |
SEEK_CUR | Positionnement relatif à la position courante. Le deuxième argument est un déplacement par rapport à la position courante. Il peut être négatif aussi bien que positif. |
SEEK_END | Positionnement relatif à la fin du fichier. Le deuxième argument est un déplacement par rapport à la fin du fichier. Il peut être négatif aussi bien que positif. |
L’entier renvoyé par lseek
est la position absolue du pointeur de
lecture/écriture (après que le positionnement a été effectué).
Une erreur se déclenche si la position absolue demandée est négative.
En revanche, la position demandée peut très bien être située après la
fin du fichier. Juste après un tel positionnement, un read
renvoie
zéro (fin de fichier atteinte); un write
étend le fichier par des
zéros jusqu’à la position demandée, puis écrit les données fournies.
Exemple:
pour se placer sur le millième caractère d’un fichier:
lseek fd 1000 SEEK_SET |
Pour reculer d’un caractère:
lseek fd (-1) SEEK_CUR |
Pour connaître la taille d’un fichier:
let file_size = lseek fd 0 SEEK_END in ... |
Pour les descripteurs ouverts en mode O_APPEND
, le pointeur de
lecture/écriture est automatiquement placé à la fin du fichier avant
chaque écriture. L’appel lseek
ne sert donc à rien pour écrire sur
un tel descripteur; en revanche, il est bien pris en compte pour la
lecture.
Le comportement de lseek
est indéterminé sur certains types de
fichiers pour lesquels l’accès direct est absurde: les dispositifs de
communication (tuyaux, prises), mais aussi la plupart des fichiers
spéciaux (périphériques), comme par exemple le terminal. Dans la
plupart des implémentations d’Unix, un lseek
sur de tels fichiers
est simplement ignoré: le pointeur de lecture/écriture est positionné,
mais les opérations de lecture et d’écriture l’ignorent. Sur
certaines implémentations, lseek
sur un tuyau ou sur une prise
déclenche une erreur.
tail
affiche les N dernières lignes d’un fichier.
Comment l’implémenter efficacement si le fichier en question est un
fichier normal? Comment faire face aux autres types de fichiers?
Comment ajouter l’option -f
? (cf. man tail
).
En Unix, la communication passe par des descripteurs de fichiers que ceux-ci soient matérialisés (fichiers, périphériques) ou volatiles (communication entre processus par des tuyaux ou des prises). Cela permet de donner une interface uniforme à la communication de données, indépendante du média. Bien sûr, l’implémentation des opérations dépend quant à elle du média. L’uniformité trouve ses limites dans la nécessité de donner accès à toutes les opérations offertes par le média. Les opérations générales (ouverture, écriture, lecture, etc.) restent uniformes sur la plupart des descripteurs mais certaines opérations ne fonctionnent que sur certains types de fichiers. En revanche, pour certains types de fichiers dits spéciaux, qui permettent de traiter la communication avec les périphériques, même les opérations générales peuvent avoir un comportement ad-hoc défini par le type et les paramètres du périphérique.
On peut raccourcir un fichier ordinaire par les appels suivants:
Le premier argument désigne le fichier à tronquer (par son nom, ou via un descripteur ouvert sur ce fichier). Le deuxième argument est la taille désirée. Toutes les données situées à partir de cette position sont perdues.
La plupart des opérations sur fichiers “suivent” les liens
symboliques: c’est-à-dire, elles s’appliquent au fichier vers
lequel pointe le lien symbolique, et non pas au lien symbolique
lui-même. Exemples: openfile
, stat
, truncate
, opendir
.
On dispose de deux opérations sur les liens symboliques:
L’appel symlink
f1 f2 créé le fichier f2 comme étant un
lien symbolique vers f1. (Comme la commande ln -s
f1 f2.)
L’appel readlink renvoie le contenu d’un lien symbolique,
c’est-à-dire le nom du fichier vers lequel il pointe.
Les fichiers spéciaux peuvent être de type caractère
ou de type block
.
Les premiers sont des flux de caractères: on ne peut lire ou écrire les
caractères que dans l’ordre. Ce sont typiquement les terminaux, les
périphériques sons, imprimantes, etc. Les seconds, typiquement les disques,
ont un support rémanent ou temporisé: on peut lire les caractères par blocs,
voir à une certaine distance donnée sous forme absolue ou relative par
rapport à la position courante. Parmi les fichiers spéciaux, on peut
distinguer:
/dev/null
(voir le chapitre 5).
Les fichiers spéciaux ont des comportements assez variables en réponse
aux appels système généraux sur fichiers. La plupart des fichiers
spéciaux (terminaux, lecteurs de bandes, disques, …) obéissent à
read
et write
de la manière évidente (mais parfois avec des
restrictions sur le nombre d’octets écrits ou lus). Beaucoup de
fichiers spéciaux ignorent lseek
.
En plus des appels systèmes généraux, les fichiers spéciaux qui
correspondent à des périphériques doivent pouvoir être paramétrés ou
commandés dynamiquement. Exemples de telles possibilités: pour un dérouleur
de bande, le rembobinage ou l’avance rapide; pour un terminal, le choix du
mode d’édition de ligne, des caractères spéciaux, des paramètres de la
liaison série (vitesse, parité, etc). Ces opérations sont réalisées en Unix
par l’appel système ioctl
qui regroupe tous les cas
particuliers. Cependant, cet appel système n’est pas relevé en OCaml...
parce qu’il est mal défini et ne peut pas être traité de façon uniforme.
Les terminaux (ou pseudo-terminaux) de contrôle sont un cas particulier de
fichiers spéciaux de type caractère pour lequel OCaml donne accès
à la configuration.
L’appel tcgetattr
prend en argument un descripteur de fichier ouvert sur
le fichier spécial en question et retourne une structure de type
terminal_io
qui décrit le statut du terminal représenté par ce fichier
selon la norme POSIX
(Voir page ?? pour une description complète).
val tcgetattr : file_descr -> terminal_io |
type terminal_io = { c_ignbrk : bool; c_brk_int : bool; ...; c_vstop : char } |
Cette structure peut être modifiée puis passée à la fonction tcsetattr pour changer les attributs du périphérique.
val tcsetattr : file_descr -> setattr_when -> terminal_io -> unit |
Le premier argument est le descripteur de fichier désignant le périphérique.
Le dernier argument est une structure de type tcgetattr décrivant les
paramètres du périphérique tels qu’on veut les établir. Le second argument
est un drapeau du type énuméré setattr_when
indiquant le moment à partir
duquel la modification doit prendre effet: immédiatement (TCSANOW
), après
avoir transmis toutes les données écrites (TCSADRAIN
) ou après avoir lu
toutes les données reçues (TCAFLUSH
). Le choix TCSADRAIN
est recommandé
pour modifier les paramètres d’écriture et TCSAFLUSH
pour modifier les
paramètres de lecture.
Exemple:
Pendant la lecture d’un mot de passe, il faut retirer l’écho des caractères tapés par l’utilisateur si le flux d’entrée standard est connecté à un terminal ou pseudo-terminal.
let read_passwd message = match try let default = tcgetattr stdin in let silent = { default with c_echo = false; c_echoe = false; c_echok = false; c_echonl = false; } in Some (default, silent) with _ -> None with | None -> input_line Pervasives.stdin | Some (default, silent) -> print_string message; flush Pervasives.stdout; tcsetattr stdin TCSANOW silent; try let s = input_line Pervasives.stdin in tcsetattr stdin TCSANOW default; s with x -> tcsetattr stdin TCSANOW default; raise x;; |
La fonction read_passwd
commence par récupérer la valeur par défaut des
paramètres du terminal associé à stdin
et construire une version modifiée
dans laquelle les caractères n’ont plus d’écho. En cas d’échec, c’est que
le flux d’entrée n’est pas un terminal de contrôle, on se contente de lire
une ligne. Sinon, on affiche un message, on change le terminal, on lit la
réponse et on remet le terminal dans son état normal. Il faut faire
attention à bien remettre le terminal dans son état normal également lorsque
la lecture a échoué.
Il arrive qu’une application ait besoin d’en lancer une autre en liant son
flux d’entrée à un terminal (ou pseudo terminal) de contrôle. Le système
OCaml ne fournit pas d’aide pour cela3: il faut manuellement
rechercher parmi l’ensemble des pseudo-terminaux (en général, ce sont
des fichiers de nom de la forme /dev/tty[a-z][a-f0-9]
) et trouver un de
ces fichiers qui ne soit pas déjà ouvert, pour l’ouvrir puis lancer
l’application avec ce fichier en flux d’entrée.
Quatre autres fonctions permettent de contrôler le flux (vider les données en attente, attendre la fin de la transmission, relancer la communication).
val tcsendbreak : file_descr -> int -> unit |
La fonction tcsendbreak envoie une interruption au
périphérique. Son deuxième argument est la durée de l’interruption (0
étant interprété comme la valeur par défaut pour le périphérique).
val tcdrain : file_descr -> unit |
La fonction tcdrain attend que toutes les données écrites aient été transmises.
val tcflush : file_descr -> flush_queue -> unit |
Selon la valeur du drapeau passé en second argument, la fonction
tcflush
abandonne les données écrites pas encore transmises (TCIFLUSH
), ou les
données reçues mais pas encore lues (TCOFLUSH
) ou les deux (TCIOFLUSH
).
val tcflow : file_descr -> flow_action -> unit |
Selon la valeur du drapeau passé en second argument, la fonction tcflow
suspend l’émission (TCOOFF
), redémarre l’émission (TCOON
), envoie un
caractère de contrôle STOP ou START pour demander que la transmission soit
suspendue (TCIOFF
) ou relancée (TCION
).
val setsid : unit -> int |
La fonction setsid place le processus dans une nouvelle session et le détache de son terminal de contrôle.
Deux processus peuvent modifier un même fichier en parallèle au risque que
certaines écritures en écrasent d’autres. Dans certains cas, l’ouverture en
mode O_APPEND
permet de s’en sortir, par exemple, pour un fichier de log
où on se contente d’écrire des informations toujours à la fin du fichier.
Mais ce mécanisme ne résout pas le cas plus général où les écritures sont à
des positions a priori arbitraires, par exemple, lorsqu’un fichier
représente une base de données . Il faut alors que les différents processus
utilisant ce fichier collaborent ensemble pour ne pas se marcher sur les
pieds. Un verrouillage de tout le fichier est toujours possible en créant un
fichier verrou auxiliaire (voir page ??).
L’appel système lockf permet une synchronisation plus fine qui
en ne verrouillant qu’une partie du fichier.
On va étendre la commande file_copy
pour copier, en plus des
fichiers normaux, les liens symboliques et les répertoires. Pour les
répertoires, on copie récursivement leur contenu.
On commence par récupérer la fonction file_copy
de l’exemple du même nom
pour copier les fichiers normaux (page ??).
open Unix |
... |
let file_copy input_name output_name = |
... |
La fonction set_infos
ci-dessous modifie le propriétaire, les
droits d’accès et les dates de dernier accès/dernière modification
d’un fichier. Son but est de préserver ces informations pendant la copie.
let set_infos filename infos = utimes filename infos.st_atime infos.st_mtime; chmod filename infos.st_perm; try chown filename infos.st_uid infos.st_gid with Unix_error(EPERM,_,_) -> () |
L’appel système utime
modifie les dates d’accès et de modification. On
utilise chmod
et chown
pour rétablir les droits d’accès et le
propriétaire. Pour les utilisateurs normaux, il y a un certain nombres de
cas où chown
va échouer avec une erreur “permission denied”. On
rattrape donc cette erreur là et on l’ignore.
Voici la fonction récursive principale.
let rec copy_rec source dest = let infos = lstat source in match infos.st_kind with S_REG -> file_copy source dest; set_infos dest infos | S_LNK -> let link = readlink source in symlink link dest | S_DIR -> mkdir dest 0o200; Misc.iter_dir (fun file -> if file <> Filename.current_dir_name && file <> Filename.parent_dir_name then copy_rec (Filename.concat source file) (Filename.concat dest file)) source; set_infos dest infos | _ -> prerr_endline ("Can't cope with special file " ^ source) |
On commence par lire les informations du fichier source. Si c’est un fichier
normal, on copie son contenu avec file_copy
, puis ses informations avec
set_infos
. Si c’est un lien symbolique, on lit ce vers quoi il pointe, et
on crée un lien qui pointe vers la même chose. Si c’est un répertoire, on
crée un répertoire comme destination, puis on lit les entrées du répertoire
source (en ignorant les entrées du répertoire vers lui-même
Filename.current_dir_name
et vers son parent Filename.parent_dir_name
,
qu’il ne faut certainement pas copier), et on appelle récursivement copy
pour chaque entrée. Les autres types de fichiers sont ignorés, avec un
message d’avertissement.
Le programme principal est sans surprise:
let copyrec () = if Array.length Sys.argv <> 3 then begin prerr_endline ("Usage: " ^Sys.argv.(0)^ " <source> <destination>"); exit 2 end else begin copy_rec Sys.argv.(1) Sys.argv.(2); exit 0 end ;; handle_unix_error copyrec ();; |
copyrec
duplique N fois un même fichier qui apparaît sous N
noms différents dans la hiérarchie de fichiers à copier. Essayer de
détecter cette situation, de ne copier qu’une fois le fichier, et de
faire des liens durs dans la hiérarchie de destination.
Le format tar
(pour tape archive) permet de représenter
un ensemble de fichiers en un seul fichier. (Entre autre il permet de stocker
toute une hiérarchie de fichiers sur une bande.) C’est donc d’une certaine
façon un mini système de fichiers.
Dans cette section nous décrivons un ensemble de fonctions qui permettent de
lire et d’écrire des archives au format tar
. La première partie, décrite
complètement, consiste à écrire une commande readtar
telle que
readtar
a affiche la liste des fichiers contenus dans l’archive a et
readtar
a f affiche le contenu du fichier f contenu dans l’archive
a. Nous proposons en exercice l’extraction de tous les fichiers contenus
dans une archive, ainsi que la fabrication d’une archive à partir d’un
ensemble de fichiers.
Une archive tar
est une suite d’enregistrements, chaque enregistrement
représentant un fichier. Un enregistrement est composé d’un entête qui code
les informations sur le fichier (son nom, son type, sa taille, son
propriétaire etc.) et du contenu du fichier.
L’entête est représenté sur un bloc (512 octets) comme indiqué dans le
tableau 2.2.
Offset Length1 Codage2 Nom Description 0 100 chaîne name Nom du fichier 100 8 octal perm Mode du fichier 108 8 octal uid ID de l’utilisateur 116 8 octal gid ID du groupe de l’utilisateur 124 12 octal size Taille du fichier3 136 12 octal mtime Date de la dernière modification 148 8 octal checksum Checksum de l’entête 156 1 caractère kind Type de fichier 157 100 octal link Lien 257 8 chaîne magic Signature ( "ustar\032\032\0"
)265 32 chaîne user Nom de l’utilisateur 297 32 chaîne group Nom du groupe de l’utilisateur 329 8 octal major Identificateur majeur du périphérique 337 8 octal minor Identificateur mineur du périphérique 345 167 Padding 1 en octets. 2 tous les champs sont codés sur des chaînes de caractères et
terminés par le caractère nul ’\000’, sauf les champskind
(Type de fichier) et
le champsize
(Taille du fichier) (’\000’ optionnel).
Le contenu est représenté à la suite de l’entête sur un nombre entier de blocs. Les enregistrements sont représentés les uns à la suite des autres. Le fichier est éventuellement complété par des blocs vides pour atteindre au moins 20 blocs.
Comme les archives sont aussi conçues pour être écrites sur des supports
fragiles et relues plusieurs années après, l’entête comporte un champ
checksum
qui permet de détecter les archives dont l’entête est endommagé
(ou d’utiliser comme une archive un fichier qui n’en serait pas une.)
Sa valeur est la somme des codes des caractères de l’entête (pendant ce
calcul, on prend comme hypothèse que le le champ checksum
, qui n’est pas
encore connu est composé de blancs et terminé par le caractère nul).
Le champ kind
représente le type des fichiers sur un octet.
Les valeurs significatives sont les caractères indiqués dans le tableau
ci-dessous4:
|
La plupart des cas correspondent au type st_link
des types de fichier
Unix. Le cas LINK
représente des liens durs: ceux-ci ont le même nœud
(inode) mais accessible par deux chemins différents; dans ce cas, le
lien doit obligatoirement mener à un autre fichier déjà défini dans
l’archive. Le cas CONT
représente un fichier ordinaire, mais qui est
représenté par une zone mémoire contigüe (c’est une particularité de certains
systèmes de fichiers, on pourra donc le traiter comme un fichier ordinaire).
Le champ link représente le lien lorsque kind
vaut LNK
ou
LINK
. Les champs major et minor représentent
les numéros majeur et mineur du périphérique dans le cas où le champ kind
vaut CHR
ou BLK
. Ces trois champs sont inutilisés dans les autres cas.
La valeur du champ kind
est naturellement représentée par un type concret
et l’entête par un enregistrement:
|
|
La lecture d’un entête n’est pas très intéressante, mais elle est incontournable.
exception Error of string * string let error err mes = raise (Error (err, mes));; let handle_error f s = try f s with | Error (err, mes) -> Printf.eprintf "Error: %s: %s" err mes; exit 2 let substring s offset len = let max_length = min (offset + len + 1) (String.length s) in let rec real_length j = if j < max_length && s.[j] <> '\000' then real_length (succ j) else j - offset in String.sub s offset (real_length offset);; let integer_of_octal nbytes s offset = let i = int_of_string ("0o" ^ substring s offset nbytes) in if i < 0 then error "Corrupted archive" "integer too large" else i;; let kind s i = match s.[i] with '\000' | '0' -> REG | '1' -> LINK (substring s (succ i) 99) | '2' -> LNK (substring s (succ i) 99) | '3' -> CHR (integer_of_octal 8 s 329, integer_of_octal 8 s 329) | '4' -> BLK (integer_of_octal 8 s 329, integer_of_octal 8 s 337) | '5' -> DIR | '6' -> FIFO | '7' -> CONT | _ -> error "Corrupted archive" "kind" let header_of_string s = { name = substring s 0 99; perm = integer_of_octal 8 s 100; uid = integer_of_octal 8 s 108; gid = integer_of_octal 8 s 116; size = integer_of_octal 12 s 124; mtime = integer_of_octal 12 s 136; kind = kind s 156; user = substring s 265 32; group = substring s 297 32; } let block_size = 512;; let total_size size = block_size + ((block_size -1 + size) / block_size) * block_size;; |
La fin de l’archive s’arrête soit sur une fin de fichier là ou devrait
commencer un nouvel enregistrement, soit sur un bloc complet mais vide. Pour
lire l’entête, nous devons donc essayer de lire un bloc, qui doit être vide
ou complet. Nous réutilisons la fonction really_read
définie plus haut
pour lire un bloc complet. La fin de fichier ne doit pas être rencontrée en
dehors de la lecture de l’entête.
let buffer_size = block_size;; let buffer = String.create buffer_size;; let end_of_file_error() = error "Corrupted archive" "unexpected end of file" let without_end_of_file f x = try f x with End_of_file -> end_of_file_error() let read_header fd = let len = read fd buffer 0 buffer_size in if len = 0 || buffer.[0] = '\000' then None else begin if len < buffer_size then without_end_of_file (really_read fd buffer len) (buffer_size - len); Some (header_of_string buffer) end;; |
Pour effectuer une quelconque opération dans une archive, il est nécessaire de lire l’ensemble des enregistrements dans l’ordre au moins jusqu’à trouver celui qui correspond à l’opération à effectuer. Par défaut, il suffit de lire l’entête de chaque enregistrement, sans avoir à en lire le contenu. Souvent, il suffira de lire le contenu de l’enregistrement recherché ou de lire le contenu après coup d’un enregistrement le précédent. Pour cela, il faut garder pour chaque enregistrement une information sur sa position dans l’archive, en plus de son entête. Nous utilisons le type suivant pour les enregistrements:
type record = { header : header; offset : int; descr : file_descr };; |
Nous allons maintenant écrire un itérateur général qui lit les enregistrements (sans leur contenu) et les accumulent. Toutefois, pour être général, nous restons abstrait par rapport à la fonction d’accumulation f (qui peut aussi bien ajouter les enregistrements à ceux déjà lus, les imprimer, les jeter, etc.)
let fold f initial fd = let rec fold_aux offset accu = ignore (without_end_of_file (lseek fd offset) SEEK_SET); match without_end_of_file read_header fd with Some h -> let r = { header = h; offset = offset + block_size; descr = fd } in fold_aux (offset + total_size h.size) (f r accu) | None -> accu in fold_aux 0 initial;; |
Une étape de fold_aux
commence à une position offset
avec un résultat
partiel accu
. Elle consiste à se placer à la position offset
, qui doit
être le début d’un enregistrement, lire l’entête, construire
l’enregistrement r
puis recommencer à la fin de l’enregistrement avec le
nouveau résultat f r accu
(moins partiel). On s’arrête lorsque l’entête
est vide, ce qui signifie qu’on est arrivé à la fin de l’archive.
Il suffit simplement d’afficher l’ensemble des enregistrements, au fur et à mesure, sans avoir à les conserver:
let list tarfile = let fd = openfile tarfile [ O_RDONLY ] 0o0 in let add r () = print_string r.header.name; print_newline() in fold add () fd; close fd |
La commande readtar
a f doit rechercher le fichier de nom f dans
l’archive et l’afficher si c’est un fichier régulier. De plus un chemin f
de l’archive qui est un lien dur et désigne un chemin g de l’archive
est suivi et le contenu de g
est affiché: en effet, bien que f et g soient représentés différemment dans
l’archive finale (l’un est un lien dur vers l’autre) ils désignaient
exactement le même fichier à sa
création. Le fait que g soit un lien vers f ou l’inverse dépend
uniquement de l’ordre dans lequel les fichiers ont été parcourus à la
création de l’archive. Pour l’instant nous ne suivons pas les liens
symboliques.
L’essentiel de la résolution des liens durs est effectué par les deux fonctions suivantes, définies récursivement.
let rec find_regular r list = match r.header.kind with | REG | CONT -> r | LINK name -> find_file name list | _ -> error r.header.name "Not a regular file" and find_file name list = match list with r :: rest -> if r.header.name = name then find_regular r rest else find_file name rest | [] -> error name "Link not found (corrupted archive)";; |
La fonction find_regular
trouve le fichier régulier correspondant à un
enregistrement (son premier) argument. Si celui-ci est un fichier régulier,
c’est gagné. Si c’est un fichier spécial (ou un lien symbolique), c’est
perdu. Il reste le cas d’un lien dur: la fonction recherche ce lien dans la
liste des enregistrements de l’archive (deuxième argument) en appelant la
fonction find_file
.
Un fois l’enregistrement trouvé il n’y a plus qu’à afficher son contenu.
Cette opération ressemble fortement à la fonction file_copy
, une fois le
descripteur positionné au début du fichier dans l’archive.
let copy_file file output = ignore (lseek file.descr file.offset SEEK_SET); let rec copy_loop len = if len > 0 then match read file.descr buffer 0 (min buffer_size len) with 0 -> end_of_file_error() | r -> ignore (write output buffer 0 r); copy_loop (len-r) in copy_loop file.header.size |
Il ne reste plus qu’à combiner les trois précédents.
exception Done let find_and_copy tarfile filename = let fd = openfile tarfile [ O_RDONLY ] 0o0 in let found_or_collect r accu = if r.header.name = filename then begin copy_file (find_regular r accu) stdout; raise Done end else r :: accu in try ignore (fold found_or_collect [] fd); error "File not found" filename with | Done -> close fd |
On lit les enregistrements de l’archive (sans lire leur contenu) jusqu’à
rencontrer un enregistrement du nom recherché. On appelle la fonction
find_regular
pour rechercher dans la liste des enregistrements lus celui
qui contient vraiment le fichier. Cette seconde recherche, en arrière, doit
toujours réussir si l’archive est bien formée. Par contre la première
recherche, en avant, va échouer si le fichier n’est pas dans l’archive.
Nous avons pris soin de distinguer les erreurs dues à une archive corrompue
ou à une recherche infructueuse.
Et voici la fonction principale qui réalise la commande readtar
:
let readtar () = let nargs = Array.length Sys.argv in if nargs = 2 then list Sys.argv.(1) else if nargs = 3 then find_and_copy Sys.argv.(1) Sys.argv.(2) else prerr_endline ("Usage: " ^Sys.argv.(0)^ " <tarfile> [ <source> ]");; handle_unix_error (handle_error readtar) ();; |
readtar
pour qu’elle suive les liens symboliques,
c’est-à-dire pour qu’elle affiche le contenu du fichier si l’archive avait
été au préalable extraite et si ce fichier correspond à un fichier de
l’archive.Derrière l’apparence triviale de cette généralisation se cachent quelques difficultés, car les liens symboliques sont des chemins quelconques qui peuvent ne pas correspondre exactement à des chemins de l’archive ou carrément pointer en dehors de l’archive (ils peuvent contenir . . ). De plus, les liens symboliques peuvent désigner des répertoires (ce qui est interdit pour les liens durs).
untar
telle que untar
a extrait et crée tous
les fichiers de l’archive a (sauf les fichiers spéciaux) en rétablissant
si possible les informations sur les fichiers (propriétaires, droits
d’accès) indiqués dans l’archive. L’arborescence de l’archive ne doit contenir que des chemins relatifs, sans
jamais pointer vers un répertoire parent (donc sans pourvoir pointer en
dehors de l’archive), mais on devra le détecter et refuser de créer des
fichiers ailleurs que dans un sous-répertoire du répertoire
courant. L’arborescence est reconstruite à la position où l’on se trouve à
l’appel de la commande untar
. Les répertoires non mentionnés explicitement
dans l’archive qui n’existent pas sont créés avec les droits par défaut de
l’utilisateur qui lance la commande.
tar
telle que tar
-xvf
a f1 f2 … construise
l’archive a à partir de la liste des fichiers f1, f2, etc. et de
leurs sous-répertoires.
write
pour effectuer le transfert complet—voir la
discussion la section 5.7. Mais cette limite est au delà de
la taille des caches du système et n’est pas observable.