Structure interne du noyau Linux 2.4Date de mise à jour : 13 mai 2003
Par
Tigran Aivazian (mail)
Adaptation française du Linux Kernel 2.4 Internals I. Introduction I-A. Dernière version de ce document I-B. Droits d'utilisation I-C. Remerciements I-D. Adaptation française II. Amorçage (Booting) II-A. Construire l'image du noyau Linux II-B. Démarrage : aperçu II-C. Démarrage : BIOS POST II-D. Démarrage : secteur d'amorçage et lancement II-E. Utilisation de LILO comme chargeur d'amorçage (bootloader) II-F. Initialisation de haut niveau II-G. Amorçage multiprocesseur (SMP) sur x86 II-H. Libérer les données et le code d'initialisation II-I. Traitement de la ligne de commande du noyau III. Processus et gestion des interruptions III-A. Structure de tâche et table des processus III-B. Création et terminaison des tâches et des threads noyau III-C. L'ordonnanceur Linux III-D. Implémentation des listes chaînées Linux III-E. Les files d'attente (Wait Queues) III-F. Les chronomètres du noyau (timers) III-G. Bouts de listes (Bottom Halves) III-H. Les files de tâches (Task Queues) III-I. Mini-tâches (Tasklets) III-J. IRQ logicielles (softirq) III-K. Comment les appels système sont-ils implémentés sur une architecture i386 ? III-L. Opérations atomiques III-M. Verrous tournants, verrous tournants en lecture/écriture et verrous tournants gros lecteurs III-N. Les sémaphores et les sémaphores en lecture/écriture III-O. Le support noyau des modules chargeables IV. Système de fichiers virtuel (Virtual Filesystem : VFS) IV-A. Le cache inode et les interactions avec le Dcache IV-B. Enregistrement/dés-enregistrement de systèmes de fichiers IV-C. Gestion des descripteurs de fichier IV-D. Gestion de la structure des fichiers IV-E. Super-bloc et gestion des points de montage IV-F. Exemple de système de fichiers virtuel : pipefs IV-G. Exemple de système de fichiers sur disque : BFS IV-H. Domaines d'exécution et formats binaires V. Le cache de pages Linux VI. Mécanismes de communication inter-processus (IPC) VI-A. Sémaphores VI-A-1. Interfaces d'appels système des sémaphores VI-A-1-a. sys_semget() VI-A-1-b. sys_semctl() VI-A-1-c. sys_semop() VI-A-1-c-i. Opérations de sémaphores non-bloquantes VI-A-1-c-ii. Opérations de sémaphores qui échouent VI-A-1-c-iii. Opérations de sémaphore bloquantes VI-A-2. Structures spécifiques au support des sémaphores VI-A-2-a. struct sem_array VI-A-2-b. struct sem VI-A-2-c. struct seminfo VI-A-2-d. struct semid64_ds VI-A-2-e. struct sem_queue VI-A-2-f. struct sembuf VI-A-2-g. struct sem_undo VI-A-3. Les fonctions du support des sémaphores VI-A-3-a. newary() VI-A-3-b. freeary() VI-A-3-c. semctl_down() VI-A-3-c-i. IPC_RMID VI-A-3-c-ii. IPC_SET VI-A-3-d. semctl_nolock() VI-A-3-d-i. IPC_INFO et SEM_INFO VI-A-3-d-ii. SEM_STAT VI-A-3-e. semctl_main() VI-A-3-e-i. GETALL VI-A-3-e-ii. SETALL VI-A-3-e-iii. IPC_STAT VI-A-3-e-iv. GETVAL VI-A-3-e-v. GETPID VI-A-3-e-vi. GETNCNT VI-A-3-e-vii. GETZCNT VI-A-3-e-viii. SETVAL VI-A-3-f. count_semncnt() VI-A-3-g. count_semzcnt() VI-A-3-h. update_queue() VI-A-3-i. try_atomic_semop() VI-A-3-j. sem_revalidate() VI-A-3-k. freeundos() VI-A-3-l. alloc_undo() VI-A-3-m. sem_exit() VI-B. Les files de messages VI-B-1. L'interface d'appel système des messages VI-B-1-a. sys_msgget() VI-B-1-b. sys_msgctl() VI-B-1-b-i. IPC_INFO ( or MSG_INFO) VI-B-1-b-ii. IPC_STAT ( or MSG_STAT) VI-B-1-b-iii. IPC_SET VI-B-1-b-iv. IPC_RMID VI-B-1-c. sys_msgsnd() VI-B-1-d. sys_msgrcv() VI-B-2. Structures spécifiques aux messages VI-B-2-a. struct msg_queue VI-B-2-b. struct msg_msg VI-B-2-c. struct msg_msgseg VI-B-2-d. struct msg_sender VI-B-2-e. struct msg_receiver VI-B-2-f. struct msqid64_ds VI-B-2-g. struct msqid_ds VI-B-2-h. msg_setbuf VI-B-3. Fonctions de support des messages VI-B-3-a. newque() VI-B-3-b. freeque() VI-B-3-c. ss_wakeup() VI-B-3-d. ss_add() VI-B-3-e. ss_del() VI-B-3-f. expunge_all() VI-B-3-g. load_msg() VI-B-3-h. store_msg() VI-B-3-i. free_msg() VI-B-3-j. convert_mode() VI-B-3-k. testmsg() VI-B-3-l. pipelined_send() VI-B-3-m. copy_msqid_to_user() VI-B-3-n. copy_msqid_from_user() VI-C. La mémoire partagée VI-C-1. Interfaces d'appels système de la mémoire partagée VI-C-1-a. sys_shmget() VI-C-1-b. sys_shmctl() VI-C-1-b-i. IPC_INFO VI-C-1-b-ii. SHM_INFO VI-C-1-b-iii. SHM_STAT, IPC_STAT VI-C-1-b-iv. SHM_LOCK, SHM_UNLOCK VI-C-1-b-v. IPC_RMID VI-C-1-b-vi. IPC_SET VI-C-1-c. sys_shmat() VI-C-1-d. sys_shmdt() VI-C-2. Les structures de support de la mémoire partagée VI-C-2-a. struct shminfo64 VI-C-2-b. struct shm_info VI-C-2-c. struct shmid_kernel VI-C-2-d. struct shmid64_ds VI-C-2-e. struct shmem_inode_info VI-C-3. Les fonctions du support de la mémoire partagée VI-C-3-a. newseg() VI-C-3-b. shm_get_stat() VI-C-3-c. shmem_lock() VI-C-3-d. shm_destroy() VI-C-3-e. shm_inc() VI-C-3-f. shm_close() VI-C-3-g. shmem_file_setup() VI-D. Les primitives des IPC Linux VI-D-1. Les primitives génériques des IPC Linux utilisées avec les sémaphores, les messages et la mémoire partagée VI-D-1-a. ipc_alloc() VI-D-1-b. ipc_addid() VI-D-1-c. ipc_rmid() VI-D-1-d. ipc_buildid() VI-D-1-e. ipc_checkid() VI-D-1-f. grow_ary() VI-D-1-g. ipc_findkey() VI-D-1-h. ipcperms() VI-D-1-i. ipc_lock() VI-D-1-j. ipc_unlock() VI-D-1-k. ipc_lockall() VI-D-1-l. ipc_unlockall() VI-D-1-m. ipc_get() VI-D-1-n. ipc_parse_version() VI-D-2. Les structures des IPC génériques utilisées avec les sémaphores, les messages, et la mémoire partagée VI-D-2-a. struct kern_ipc_perm VI-D-2-b. struct ipc_ids VI-D-2-c. struct ipc_id I. IntroductionI-A. Dernière version de ce document
La source de la dernière version originale (en anglais) de ce
guide peut être téléchargée depuis http://www.moses.uklinux.net/patches/lki.sgml.
Il est également possible de la lire en ligne à http://www.moses.uklinux.net/patches/lki.html.
Ce guide en version originale est également diffusé par le
Projet de documentation Linux
(LDP). Il est disponible via ce projet sous différents
formats depuis http://www.tldp.org/guides.html.
L'adaptation française de ce guide a été réalisée dans le cadre du
projet traduc.org. La
dernière version française de ce document est disponible à http://www.traduc.org/docs/guides/lecture/lki/. N'hésitez pas à faire parvenir tout
commentaire relatif à cette version à commentaires CHEZ traduc POINT org.
I-B. Droits d'utilisation
Cette documentation est libre ; vous pouvez la redistribuer ou
la modifier dans les conditions de la Licence publique générale
GNU (GNU GPL) telle que
publiée par la Free Software Foundation; soit selon la version 2 de
la licence, ou (à votre choix) une plus récente. (NdT : une
version française officieuse de cette licence est disponible à
http://www.linux-france.org/article/these/gpl.html).
I-C. Remerciements
Remerciements à :
I-D. Adaptation française
L'adaptation française de ce document a été réalisée par Yaël Gomez
ygomez CHEZ yosins POINT net. La
relecture de ce document a été réalisée par Claire Boussard
clboussard CHEZ free POINT fr. La publication de ce
document a été préparée par Jean-Philippe Guérard
jean TIRET philippe POINT guerard CHEZ laposte POINT net.
II. Amorçage (Booting)II-A. Construire l'image du noyau Linux
Ce paragraphe décrit les étapes de la compilation d'un noyau Linux
et les messages renvoyés à chaque étape.
Le processus de construction du noyau dépend de l'architecture, c'est pourquoi
je voudrais souligner que l'on ne considérera ici que la compilation d'un
noyau Linux/x86.
Quand l'utilisateur tape « make zImage » ou « make
bzimage », l'image amorçable du noyau qui en résulte est stockée
respectivement en tant que arch/i386/boot/zImage ou
arch/i386/boot/bzImage.
Voici comment cette image est construite :
La taille d'un secteur d'amorçage est toujours de 512 octets. La taille
de setup doit faire plus de 4 secteurs, mais pas plus de 12k - la
règle est :
0x4000 octets $gt;= 512 + setup_sects * 512 + la place pour la pile
pendant l'exécution de bootsector/setup
On verra plus tard d'où vient cette restriction.
La taille maximum du bzImage produit à cette étape est d'à peu près
2,5M pour amorcer avec LILO et de 0xFFFF (0xFFFF0 = 1048560 octets)
pour amorcer avec une image binaire, c.a.d depuis une disquette ou un cédérom
(émulation El-Torito).
Remarquez qu'alors que tools/build contrôle la taille du
secteur d'amorçage, de l'image du noyau et la limite inférieure de la
taille de setup, il ne vérifie pas la taille maximum de setup. Dès
lors il est facile de construire un noyau défectueux rien qu'en ajoutant quelques
grands espaces (« .space ») à la fin de
setup.S.
II-B. Démarrage : aperçu
Les détails du processus d'amorçage sont spécifiques à l'architecture,
on va donc s'intéresser à l'architecture IBM PC/IA32. A cause de sa conception
vieillissante et du besoin de garder une compatibilité ascendante,
le microcode (firmware) des PC démarre le système de manière plutôt
démodée. Ce processus peut être divisé en six étapes logiques :
II-C. Démarrage : BIOS POST
II-D. Démarrage : secteur d'amorçage et lancement
Le secteur d'amorçage (bootsector) utilisé pour démarrer Linux peut
être soit :
On va s'intéresser ici en détail au secteur d'amorçage Linux.
Les premières lignes initialisent des macros qui seront utilisées comme
valeurs de segment :
(Les nombres sur la gauche sont les numéros de lignes du fichier
bootsect.S). Les valeurs de DEF_INITSEG,
DEF_SETUPSEG,
DEF_SYSSEG et
DEF_SYSSIZE sont tirées de
include/asm/boot.h :
Maintenant, regardons le détail du code de bootsect.S :
Les lignes de 53 à 60 déplacent le code du secteur d'amorçage de l'adresse
0x7c00 à 0x9000. Ce qui est fait en :
Si ce code n'utilise pas rep movsd, c'est
intentionnel (pensez à .code16).
La ligne 64 saute vers le label go: dans la nouvelle copie
du secteur d'amorçage, soit dans le segment 0x9000. Ceci plus les trois
instructions suivantes (lignes 64-76) prépare la pile à
$INITSEG:0x4000-0xC, i.e. %ss = $INITSEG (0x9000) et %sp = 0x3FF4
(0x4000-0xC). C'est de là que vient la limite sur la taille de setup
mentionnée plus haut (voir Construire l'image du noyau Linux).
Les lignes 77-103 corrigent la table des paramètres du disque afin
de permettre la lecture multi-secteurs :
Le contrôleur de disquette est réinitialisé en utilisant la fonction 0
du service BIOS int 0x13 et les secteurs de setup sont chargés juste
après le secteur d'amorçage, i.e. à l'adresse physique 0x90200
($INITSEG:0x200), en utilisant encore le service BIOS int 0x13,
fonction 2 (lire le(s) secteur(s)). Ça se passe aux lignes 107-124 :
Si le chargement échoue pour quelque raison que ce soit (la disquette est
abîmée ou quelqu'un a retiré la disquette pendant le chargement),
on affiche un code d'erreur et on réessaie de lire dans une boucle
infinie. Le seul moyen d'en sortir est de réamorcer la machine, à moins
que l'une des tentatives réussisse, mais cela a peu de chances d'arriver
(si quelque chose merde, ça ne peut qu'empirer).
Si le chargement des secteurs du code de lancement setup_sect
réussit, on saute au label
ok_load_setup~:.
On procède alors au chargement de l'image compressée du noyau à l'adresse
physique 0x10000. Ainsi on préserve les zones de données du microcode en
mémoire basse (0-64k). Une fois que le noyau est chargé, on saute en
$SETUPSEG:0 (arch/i386/boot/setup.S). Lorsqu'on n'a
plus besoin des données (i.e. plus d'appel au BIOS), elles sont écrasées
en déplaçant le noyau complet (compressé) de 0x10000 vers 0x1000 (ce
sont des adresses physiques bien sûr). C'est fait par
setup.S qui met les choses en place pour le mode
protégé et saute en 0x1000 qui est le début du noyau compressé, i.e.
arch/386/boot/compressed/{head.S,misc.c}.
Ceci configure la pile puis on appelle
decompress_kernel() qui décompresse le noyau a
l'adresse 0x10000 et on y saute.
Remarquez que les vieux chargeurs d'amorçage (vieilles versions de LILO)
ne pouvaient charger que seulement les 4 premiers secteurs de setup, c'est
pourquoi il y a un code dans setup qui charge le reste de lui-même si
besoin est. D'autre part, le code de setup devait tenir compte des
diverses combinaisons de type/version de chargeur par rapport à
zImage/bzImage et est donc très complexe.
Examinons la bidouille (kludge) dans le code du secteur d'amorçage qui
nous permet de charger un gros noyau appelé aussi « bzImage ».
Les secteurs de setup sont chargés comme d'habitude en 0x90200, mais le
noyau est chargé par morceaux de 64k, grâce à une fonction qui appelle
le BIOS pour déplacer les données de la mémoire basse vers la mémoire
haute. Cette fonction est référencée par
bootsect_kludge dans
bootsect.S et est définie en tant que
bootsect_helper dans
setup.S. Le label
bootsect_kludge dans
setup.S contient la valeur du segment de setup et le
décalage du code bootsect_helper par rapport à
lui de telle façon que bootsector peut utiliser l'instruction
lcall pour y sauter (saut inter-segment). La raison
pour laquelle ce code est dans setup.S est simplement
qu'il n'y a plus de place dans bootsect.S (ce n'est pas tout à fait
exact - il reste approximativement 4 octets et au moins 1 octet de
libre dans bootsect.S mais c'est évidemment
insuffisant). Cette fonction utilise le service BIOS int 0x15
(ax=0x8700) pour déplacer des données vers la mémoire haute et
réinitialise toujours %es afin de pointer sur 0x10000. Ainsi on
s'assure que le code de bootsect.S ne sort pas de la
mémoire basse lors de la copie des données depuis le disque.
II-E. Utilisation de LILO comme chargeur d'amorçage (bootloader)
Il y a plusieurs avantages à utiliser un chargeur d'amorçage spécialisé (LILO)
par rapport au simple secteur d'amorçage de Linux :
Les vieilles versions de LILO (v17 et avant) ne peuvent pas charger
les noyaux bzImage. Les nouvelles versions (depuis quelques années)
utilisent les mêmes techniques que bootsect+setup de déplacement des
données de la mémoire basse vers la haute par le biais de services BIOS.
Quelques personnes (Peter Anvin notamment) pensent que le support de
zImage devrait être supprimé. La raison principale pour le conserver
(d'après Alan Cox), c'est qu'il reste des BIOS « foireux » qui
rendent impossible d'amorcer bzImage alors qu'ils chargent bien zImage.
La dernière chose que fait LILO est de sauter vers setup.S
et les choses se déroulent ensuite comme d'habitude.
II-F. Initialisation de haut niveau
Par « initialisation de haut niveau » on entend tout ce qui
n'est pas directement lié au bootstrap, même si certaines parties du
code exécuté sont écrites en assembleur, à savoir
arch/i386/kernel/head.S qui est le début du noyau non
compressé. Les étapes suivantes sont exécutées :
La init/main.c:start_kernel() est écrite en C
et fait les choses suivantes :
La chose importante à noter ici c'est que le thread noyau
init() appelle
do_basic_setup() qui à son tour appelle
do_initcalls() qui parcourt la liste des
fonctions enregistrées par le biais de
__initcall ou de la macro
module_init() et les invoque. Ces fonctions ne
dépendent pas les unes des autres ou bien leurs dépendances ont été
manuellement fixées par l'ordre de l'édition de liens dans les
Makefiles. Ce qui veut dire qu'en fonction de la position des
répertoires dans l'arborescence et de la structure des Makefiles,
l'ordre dans lequel les fonctions d'initialisation sont appelées peut
changer. Quelquefois, c'est important d'en tenir compte car imaginez deux
sous-systèmes A et B avec B dépendant d'initialisations faites dans A.
Si A est compilé statiquement et que B est un module, alors on est sûr
qu'on entrera dans B après que A ait préparé tout l'environnement
nécessaire. Si A est un module, alors B en est nécessairement un aussi
donc il n'y pas de problème. Mais que se passe-t-il si A et B sont liés
statiquement dans le noyau ? L'ordre dans lequel ils sont invoqués
dépend du décalage relatif de leurs points d'entrée dans la section ELF
.initcall.init de l'image noyau. Rogier Wolff a
proposé d'introduire une infrastructure à priorité hiérarchique dans
laquelle les modules permettraient à l'éditeur de liens (linker) de
savoir dans quel ordre (relatif) ils doivent être liés, mais jusqu'ici
il n'y pas de correctif disponible qui implémente cela de manière
suffisamment élégante pour être acceptable dans le noyau. Néanmoins
assurez vous de l'ordre de liage. Si dans l'exemple ci-dessus A et B
marchent bien une première fois en étant compilés statiquement, ils
marcheront toujours, pourvu qu'ils soient listés séquentiellement dans
le même Makefile. S'ils ne marchent pas, changez l'ordre dans lequel
leurs fichiers objets sont listés.
Une autre chose qui vaut d'être notée c'est la capacité qu'a Linux
d'exécuter un « autre programme init » en passant une ligne de
commande « init= » à l'amorçage. C'est utile pour réparer un
/sbin/init abîmé accidentellement ou déboguer les
scripts d'initialisation (rc) et /etc/inittab à la
main, en les exécutant un par un.
II-G. Amorçage multiprocesseur (SMP) sur x86
Sur un système multiprocesseur, le processeur d'amorçage (BP) exécute la séquence normale d'instructions
du secteur d'amorçage (bootsector), setup etc. jusqu'à ce qu'on atteigne
start_kernel(), et ensuite smp_init() et plus
particulièrement src/i386/kernel/smpboot.c:smp_boot_cpus().
Le smp_boot_cpus() effectue une boucle pour chaque apicid
(jusqu'à NR_CPUS) et appelle pour chacun
do_boot_cpu(). Ce que fait do_boot_cpu(), c'est créer
(i.e. fork_by_hand) une tâche inactive (idle) pour le cpu cible et
écrire à des emplacements bien définis par les spécifications Intel MP
(0x467/0x469) l'EIP du code « trampoline » contenu dans
trampoline.S. Ensuite il génère STARTUP IPI sur le cpu
cible, ce qui fait que cet AP exécute le code de trampoline.S.
Le CPU d'amorçage crée une copie du code de trampoline pour chaque CPU
en mémoire basse. Le code AP écrit un nombre magique dans son
propre code qui est vérifié par le processeur d'amorçage pour s'assurer que l'AP
est en train d'exécuter le code trampoline. La nécessité de mettre
le code trampoline en mémoire basse vient des spécifications Intel MP.
Le code trampoline met simplement le registre %bx à 1, passe en
mode protégé et saute vers startup_32 qui est l'entrée principale
de arch/i386/kernel/head.S.
Maintenant que l'AP a commencé l'exécution de head.S et
découvre que ce n'est pas un processeur d'amorçage, il passe le code qui nettoie la BSS
et appelle initialize_secondary() qui ne fait qu'appeler
la tâche inactive pour ce CPU - rappelez vous que
init_tasks[cpu] avait déjà été initialisé par le processeur d'amorçage en
exécutant do_boot_cpu(cpu).
Remarquez que init_task peut être partagé mais que chaque tâche
inactive doit avoir sa propre TSS. C'est pourquoi
init_tss[NR_CPUS] est un tableau.
II-H. Libérer les données et le code d'initialisation
Une fois que le système d'exploitation s'est initialisé, la plus grande partie du code et
des structures de données ne sont jamais réutilisés. La plupart des
systèmes (BSD, FreeBSD etc.) ne peuvent disposer de ces informations,
et donc gaspillent la précieuse mémoire physique du noyau.
L'excuse qu'ils fournissent (voir le livre McKusick's 4.4BSD) c'est
que le code en question est réparti autour de plusieurs sous-systèmes
et que ce n'est pas faisable de le libérer : the
relevant code is spread around various subsystems and so it is not
feasible to free it. Linux, bien sûr, ne peut se
retrancher derrière de telles excuses car sous Linux « si quelque
chose est en principe possible, alors c'est déjà implémenté ou quelqu'un
travaille dessus ».
Donc, comme je l'ai dit plus tôt, le noyau Linux ne peut être
compilé que comme un binaire ELF, et maintenant nous en avons la raison
(ou une des raisons). La raison rattachée à l'élimination des données
et du code d'initialisation est que Linux fournit 2 macros à utiliser :
Elles s'évaluent comme des spécificateurs d'attributs gcc (aussi
connus comme « gcc magic ») telles que définies
dans include/linux/init.h :
Ce qui veut dire que si le code est compilé statiquement dans le
noyau (i.e on ne définit pas MODULE), alors celui ci est placé dans
une section ELF spéciale .text.init, qui est déclarée dans
la carte de correspondance de l'éditeur de lien dans arch/i386/vmlinux.lds.
Sinon (i.e. si c'est un module) les macros sont évaluées à rien.
Ce qui ce passe durant l'amorçage, c'est que le thread noyau « init »
(fonction init/main.c:init()) appelle une fonction spécifique
à l'architecture free_initmem() qui libère toutes les pages
entre les adresses __init_begin et __init_end.
Sur un système typique (ma station de travail), le résultat est
que 260k de mémoire sont libérés.
Les fonctions enregistrées via module_init() sont placées
dans .initcall.init qui est aussi libéré lors d'une compilation
statique. La tendance actuelle sous Linux, quand on crée un sous-système
(pas forcement un module), est de fournir dès les premières étapes de
la conception les points d'entrée init/exit de telle façon que le
sous-système puisse dans le futur être modularisé si besoin. Pipefs
en est un exemple, regardez fs/pipe.c. Même si un sous système
donné ne doit jamais devenir un module, i.e. bdflush
(voir fs/buffer.c), c'est toujours mieux et plus propre d'utiliser
la macro module_init() à la place de la fonction
d'initialisation, pourvu qu'on n'attache pas d'importance au
moment exact où la fonction est appelée.
Il y a deux autres macros qui fonctionnent de manière similaire,
appelées __exit et __exitdata, mais elles sont plus
directement liées au support des modules et par conséquent seront
expliquées dans un prochain paragraphe.
II-I. Traitement de la ligne de commande du noyau
Rappelons nous ce que devient la ligne de commande passée au noyau
pendant l'amorçage :
Alors, comment écrit-on le code qui traite la ligne de commande
d'amorçage ? On utilise la macro __setup() définie dans
include/linux/init.h :
Alors, typiquement vous l'utiliserez dans votre code de la façon
suivante (pris du code d'un vrai driver, BusLogic HBA
drivers/scsi/BusLogic.c :
Remarquez que __setup() ne fait rien pour les modules, donc
le code qui veut traiter la ligne de commande d'amorçage et qui peut être
soit dans un module, soit lié statiquement doit invoquer sa fonction
d'analyse syntaxique manuellement dans la routine d'initialisation du
module. Ce qui veut aussi dire qu'il est possible d'écrire du code qui
est capable de traiter les paramètres quand il est compilé comme un module et pas quand
il est statique ou vice versa.
III. Processus et gestion des interruptionsIII-A. Structure de tâche et table des processus
Sous Linux une structure struct task_struct est allouée
dynamiquement à chaque processus. Le nombre maximum de processus
qui peuvent être crées sous Linux est limité par la quantité de
mémoire physique présente, et est égal à
(voir kernel/fork.c:fork_init() :
ce qui, sur une architecture IA32, veut dire num_physpages/4.
Par exemple, sur une machine de 512 M, vous pouvez créer 32k de threads.
C'est une amélioration considérable par rapport à la limite
4k-epsilon des vieux noyaux (2.2 et avant). De plus, cela peut-être
modifié soit pendant l'exécution en utilisant KERN_MAX_THREADS de
sysctl(2), soit simplement en utilisant l'interface
procfs pour paramétrer le noyau :
L'ensemble des processus sur un système Linux est représenté par un
ensemble de structures struct task_struct qui sont liées de
deux façons :
La table de hachage est appelée pidhash[] et est définie dans
include/linux/sched.h :
Les tâches sont hachées en fonction de leur pid et la fonction
précédente est censée distribuer les éléments uniformément dans leur
domaine (de 0 à PID_MAX-1). La table de hachage est
utilisée pour retrouver rapidement une tâche par son pid en
utilisant find_task_pid() in-line depuis
include/linux/sched.h :
Les tâches de chaque liste de hachage (i.e hachées à la même valeur)
sont liées par p-$gt;pidhash_next/pidhash_pprev qui sont
utilisés par hash_pid() et unhash_pid() pour insérer
et retirer un processus donné dans la table de hachage. Ceci est fait
sous la protection d'un verrou tournant en lecture/écriture (read/write
spinlock) appelé tasklist_lock posé pour ÉCRIRE (WRITE).
La double liste chaînée circulaire qui utilise
p-$gt;next_task/prev_task est tenue à jour de façon à ce que l'on puisse
parcourir toutes les tâches du système facilement. C'est fait par la
macro for_each_task() de include/linux/sched.h :
Les utilisateurs de for_each_task() doivent poser un verrou
tasklist_lock pour LIRE. Remarquez que for_each_task()
utilise init_task pour marquer le début et la fin de la liste
- c'est plus sûr car la tâche inactive (idle - pid 0) ne
finit jamais.
Les fonctions qui modifient la table de hachage des processus ou des liens
de la table des processus, notamment fork(), exit()
et ptrace(), doivent poser tasklist_lock pour
ÉCRIRE. Ce qui est plus intéressant, c'est que pour écrire il faut
aussi désactiver les interruptions sur le CPU local. La raison de cela est
loin d'être triviale : la fonction send_sigio() parcourt la
liste des tâches et donc pose un tasklist_lock pour LIRE,
et elle est appelée depuis kill_fasync() dans un contexte
d'interruption. C'est pourquoi ceux qui écrivent doivent désactiver les
interruptions alors que ceux qui lisent n'ont pas besoin de le faire.
Maintenant que nous comprenons comment les structures
task_struct sont liées ensemble, examinons les membres de
task_struct. Ils correspondent plus ou moins aux membres des
structures UNIX « struct proc » et « struct user » combinées ensemble.
Les autres versions d'UNIX séparaient l'information sur l'état des
tâches en une partie qui devait être gardée en mémoire tout le temps
(appelée « proc struct » qui inclut l'état du processus, les informations
d'ordonnancement etc.) et une autre partie qui n'est nécessaire que
lorsque le processus tourne (appelée « u area » qui inclut la table des
descripteurs de fichiers, les informations de quota disque etc.). La
seule raison d'être d'une conception aussi laide est que la mémoire était alors
une denrée rare. Les systèmes d'exploitation modernes (bon, seulement
Linux pour le moment mais d'autres, comme FreeBSD semblent évoluer dans
la même direction) n'ont pas besoin d'une telle séparation,
ils maintiennent l'état des processus dans une structure de données
du noyau qui réside en mémoire en permanence.
La structure task_struct est déclarée dans
include/linux/sched.h et a actuellement une taille
de 1680 octets.
Le champ state (état) est déclaré comme :
Pourquoi est ce que TASK_EXCLUSIVE est définie comme 32 et
non 16 ? parce que 16 était utilisé par TASK_SWAPPING et que
j'ai oublié de décaler TASK_EXCLUSIVE quand j'ai retiré toutes
les références à TASK_SWAPPING (quelquefois dans 2.3.x).
La déclaration volatile dans p-$gt;state signifie qu'il
peut être modifié de manière asynchrone (depuis un gestionnaire
d'interruption) :
Les drapeaux (flags) de tâches contiennent des informations sur
les états des processus, états qui ne sont pas mutuellement exclusifs :
Les champs p-$gt;has_cpu,
p-$gt;processor, p-$gt;counter,
p-$gt;priority, p-$gt;policy et
p-$gt;rt_priority concernent l'ordonnanceur
et seront examinés plus tard.
Les champs p-$gt;mm et
p-$gt;active_mm pointent respectivement sur
l'espace d'adressage des processus décrit par la structure
mm_struct et vers l'espace d'adressage actif
si le processus n'en n'a pas un réel (i.e. pour les threads noyau). Cela
aide à minimiser les débordements de TLB lors d'un basculement d'espace
d'adressage quand une tâche est sortie de l'ordonnancement. Donc, si on
ajoute une tâche dans l'ordonnanceur (qui n'a pas de
p-$gt;mm) alors son
next-$gt;active_mm sera positionné sur le
prev-$gt;active_mm de la tâche qui est
sortie, qui sera le même que prev-$gt;mm si
prev-$gt;mm != NULL. L'espace d'adressage peut être
partagé entre les threads si le drapeau (flag)
CLONE_VM est passé à l'appel système
clone(2) ou par le biais de l'appel système
vfork(2).
Les champs p-$gt;exec_domain et
p-$gt;personality sont liés à la personnalité de la
tâche, i.e. la façon dont certain appels système se comportent pour
émuler la « personnalité » de certaines versions éloignées
d'UNIX.
Le champs p-$gt;fs contient les informations sur le
système de fichiers, ce qui veut dire sous Linux 3 types
d'informations :
Cette structure inclut aussi un compteur de références car elle peut
être partagée entre des tâches clonées quand le drapeau
CLONE_FS est passé à l'appel système
clone(2).
Le champ p-$gt;files contient la table des
descripteurs de fichiers. Elle aussi peut être partagée entre des
tâches clonées, pourvu que le drapeau
CLONE_FILES soit spécifié avec l'appel système
clone(2).
le champ p-$gt;sig contient les gestionnaires
(handlers) de signaux et peuvent être partagés entre des tâches par le
biais de CLONE_SIGHAND.
III-B. Création et terminaison des tâches et des threads noyau
Les livres consacrés aux systèmes d'exploitation définissent les
« processus » de différentes façons, depuis « l'instance d'un
programme en exécution » jusqu'à « ce qui est produit par les
appels système clone(2) ou fork(2) ». Sous Linux il y a trois type
de processus :
Le thread inactif est créé à la compilation pour le premier CPU ; il est
ensuite crée « manuellement » pour chaque CPU par le biais de la
fonction spécifique à l'architecture fork_by_hand() dans
arch/i386/kernel/smpboot.c, qui utilise l'appel système
fork(2) appelé à la main (sur certaines architectures). Les
tâches inactives partagent une structure init_task mais possèdent une
structure TSS privée, dans le tableau init_tss de chaque CPU.
Les tâches inactives ont toutes un pid = 0 et aucune autre tâche ne peut
partager le même pid, i.e. utiliser le drapeau CLONE_PID de
clone(2).
Les threads noyau sont créés en utilisant la fonction
kernel_thread() qui invoque l'appel système clone(2)
en mode noyau. Les threads noyau n'ont pas en général d'espace
d'adressage utilisateur, i.e. p-$gt;mm = NULL, car il font un
exit_mm() explicite, i.e. via la fonction daemonize().
Les threads noyau peuvent toujours accéder directement à l'espace
d'adressage du noyau. Il leur est attribué des numéros de pid dans
la tranche basse. L'exécution dans l'anneau (ring) 0 du processeur (sur x86,
c'est le cas) implique que le thread noyau profite de tous les privilèges
d'entrée/sortie et ne peut être préempté par l'ordonnanceur.
Les tâches utilisateurs sont créés avec les appels système
clone(2) ou fork(2), qui utilisent la fonction
kernel/fork.c:do_fork().
Voyons ce qui se passe quand un processus utilisateur fait un
appel système fork(2). Bien que fork(2) soit
dépendant de l'architecture à cause des différentes façons de transmettre
la pile et les registres utilisateur, la fonction utilisée réellement
do_fork() pour ce travail est portable et est placée dans
kernel/fork.c.
Les actions suivantes sont réalisées :
Donc les tâches sont créées. Il y a plusieurs façons pour une tâche
de se terminer :
Les fonctions implémentant les appels systèmes sous Linux sont
préfixées par sys_, mais elles ne font en général que
vérifier les arguments ou passer des informations
de la façon spécifique à l'architecture et le travail effectif est
réalisé par les fonctions do_. Il en est ainsi avec
sys_exit() qui appelle do_exit() pour faire le travail.
Malgré cela, d'autres parties du noyau invoquent parfois
sys_exit() alors quelles devraient plutôt appeler
do_exit().
La fonction do_exit() se trouve dans kernel/exit.c.
Les points remarquables de do_exit() sont qu'elle :
III-C. L'ordonnanceur Linux
Le travail de l'ordonnanceur est de répartir l'accès au CPU entre les
différents processus. L'ordonnanceur (ou scheduler) est implémenté dans
le « fichier main du noyau » kernel/sched.c. Le fichier
d'en-tête correspondant include/linux/sched.h est inclus
(explicitement ou non) dans à peu près tous les fichiers sources
du noyau.
Les champs de la structure de tâche intéressants pour l'ordonnanceur
incluent :
L'algorithme de d'ordonnancement est simple, malgré l'apparente
complexité de la fonction schedule(). Cette fonction est
complexe car elle implémente les trois algorithmes d'ordonnancement
en un seul et aussi à cause de certaines spécificités subtiles de l'architecture multiprocesseur (SMP).
Les gotos apparemment « inutiles » de schedule() sont ici dans
le but de générer le code le mieux optimisé (pour i386). Remarquez que
le code de l'ordonnanceur (comme la plus grande partie du noyau) a été complètement
réécrit pour le 2.4, donc la discussion qui suit ne s'applique pas à la
série des 2.2 ou moins.
Regardons cette fonction en détail :
III-D. Implémentation des listes chaînées Linux
Avant d'examiner l'implémentation des files d'attentes, nous devons
nous familiariser avec l'implémentation standard des doubles listes
chaînées de Linux. Les files d'attentes (comme beaucoup d'autres choses
dans Linux) en font une utilisation importante et elles sont appelées
dans le jargon « implémentation list.h » car le fichier le plus
significatif est include/linux/list.h.
La structure de données fondamentale ici est struct list_head :
Les trois premières macros sont utilisées pour initialiser une liste
vide en faisant pointer next et prev sur
elle même. Les restrictions syntaxiques du C rendent évidentes les
conditions de leur utilisation - par exemple, LIST_HEAD_INIT()
peut être utilisée pour l'initialisation des éléments de la structure
lors de sa déclaration, la seconde peut être utilisée pour
l'initialisation d'une variable statique et la troisième peut être
utilisée dans une fonction.
La macro list_entry() donne accès aux éléments individuels de la
liste, par exemple (dans fs/file_table.c:fs_may_remount_ro()) :
Un bon exemple de l'utilisation de la macro list_for_each()
se trouve dans l'ordonnanceur quand on parcourt la queue d'exécution en cherchant le
processus de meilleure qualité :
Ici, p-$gt;run_list est déclaré comme
struct list_head run_list dans la structure
task_struct et sert d'ancrage à la liste. Le retrait ou l'ajout
d'un élément à la liste (au début ou à la fin de la liste) est fait par
les macros list_del()/list_add()/list_add_tail(). Les exemples
ci-dessous ajoutent et retirent des tâches à la file d'exécution :
III-E. Les files d'attente (Wait Queues)
Quand un processus demande au noyau de faire quelque chose qui n'est
pas possible pour l'instant mais qui le sera plus tard, le processus
est endormi et il est réveillé à un moment où la requête a plus de chances
d'être satisfaite. L'un des mécanismes utilisés pour réaliser cela est
appelé « file d'attente (wait queue) ».
L'implémentation Linux autorise le réveil sémantique en utilisant le
drapeau TASK_EXCLUSIVE. Avec les files d'attentes, vous pouvez
utiliser soit des files bien connues et leurs fonctions
sleep_on / sleep_on_timeout / interruptible_sleep_on / interruptible_sleep_on_timeout,
ou définir votre propre file d'attente et utiliser
add/remove_wait_queue pour ajouter et supprimer des tâches vous même
et wake_up/wake_up_interruptible pour les réveiller lorsque c'est
nécessaire.
Un exemple du premier usage des files d'attentes est
l'interaction entre l'allocateur de pages (dans
mm/page_alloc.c:__alloc_pages()) et le démon noyau
kswapd (dans mm/vmscan.c:kswap()), par le biais de
la file d'attente kswapd_wait, déclarée dans
mm/vmscan.c; le démon kswapd dort dans cette file,
et il est réveillé à chaque fois que l'allocateur de pages a besoin de
libérer des pages.
Un exemple d'utilisation d'une file d'attente autonome est
l'interaction entre un processus utilisateur demandant des données
via l'appel système read(2) et le noyau tournant dans un contexte
d'interruption pour fournir les données. Le gestionnaire
d'interruption peut ressembler à
(drivers/char/rtc_interrupt() simplifié) :
Alors, le gestionnaire d'interruption obtient les données en lisant un
port d'entrée/sortie spécifique au périphérique (la macroCMOS_READ()
réalise quelques instructions outb/inb) puis réveille
tous ceux qui dorment dans la file d'attente rtc_wait.
Maintenant, l'appel système read(2) peut être implémenté comme :
Ce qui se passe dans rtc_read() est :
Il vaut la peine de remarquer que l'utilisation des files d'attente rend
plus facile d'implémenter l'appel système poll(2) :
Tout le travail est fait par la fonction indépendante du matériel
poll_wait() qui fait les manipulations nécessaires sur la
file d'attente; il suffit de la faire
pointer sur la file d'attente qui est réveillée par notre gestionnaire
d'interruption spécifique au périphérique.
III-F. Les chronomètres du noyau (timers)
Maintenant portons notre attention sur les chronomètres du noyau. Ils sont
utilisés pour reporter l'exécution d'une fonction particulière
(appelée « timer handler ») à un instant donné dans le futur.
La principale structure de données est struct
timer_list déclarée dans
include/linux/timer.h :
Le champ list sert à s'ancrer à la liste, en étant protégé par le
verrou tournant timerlist_lock. Le champ expires est
la valeur de jiffies qui provoque l'invocation de function
avec le paramètre data. Le champ running est
utilisé sur les multiprocesseurs pour tester si le « timer handler » est exécuté
par un autre CPU.
Les fonctions add_timer() et del_timer() ajoutent
et enlèvent un chronomètre donné à la liste. Quand un chronomètre expire, il est
retiré automatiquement. Avant d'être utilisé, un chronomètre DOIT être
initialisé par le biais de la fonction init_timer(). Et avant
qu'il soit ajouté, les champs function et expires
doivent être positionnés.
III-G. Bouts de listes (Bottom Halves)
Il est parfois raisonnable de séparer l'ensemble de ce qui doit être
fait par le gestionnaire d'interruption en travail immédiat (i.e.
acquitter l'interruption, mettre à jour les statistiques, et cætera) et en
travail qui peut être remis à plus tard, pendant que les interruptions
seront actives (i.e. faire des pré-traitements sur les données,
réveiller les processus pour ces données, et cætera).
Les Bottom halves sont un vieux mécanisme pour différer l'exécution
de tâches du noyau et elles existent depuis Linux 1.x. Dans Linux 2.0,
un nouveau mécanisme a été ajouté, appelé « task queues », qui
sera le sujet du prochain paragraphe.
Les Bottom halves sont sérialisées par le verrou tournant
global_bh_lock, ce qui veut dire qu'il ne peut y avoir qu'un seul bottom half
en exécution sur n'importe quel CPU à un instant donné. Cependant quand on
essaie d'exécuter le gestionnaire, si global_bh_lock n'est pas
disponible, le bottom half est marqué (i.e. ordonnancé) pour
l'exécution - donc le traitement peut continuer, au contraire d'une
boucle tournant sur global_bh_lock.
Il ne peut y avoir au total que 32 bottom halves enregistrés. Les
fonctions nécessaires à la manipulation des bottom halves sont comme suit
(toutes exportées dans les modules) :
Les Bottom halves sont des mini-tâches (tasklets) verrouillées
globalement, donc la question « quand les routines bottom half
sont-elles exécutées ? », se ramène à « quand les
mini-tâches sont-elles exécutées ? ». Et la réponse est, à
deux
endroits : a) à chaque schedule() et b) à
chaque chemin de retour d'interruption/appel système dans
entry.S (À FAIRE : par conséquent, le cas
schedule() est vraiment embêtant - c'est comme ajouter une
autre interruption très très lente, pourquoi ne pas se débarrasser du
label handle_softirq de schedule() ?).
III-H. Les files de tâches (Task Queues)
Les files de tâches peuvent être vues comme l'extension dynamique des
vieux bottom halves. En fait, dans le code source elles sont parfois
référencées comme « nouveaux » bottom halves. Plus précisément, les
vieux bottom halves dont on a discuté dans les section précédentes
ont les limitations suivantes :
Par contre, avec les files de tâches, un nombre arbitraire de fonctions
peuvent être chaînées et exécutées les unes après les autres ultérieurement.
On crée une nouvelle file de tâches en utilisant la macro
DECLARE_TASK_QUEUE() et on y ajoute une tâche au moyen de la fonction
queue_task(). La file de tâches peut alors être traitée en
utilisant run_task_queue(). Au lieu de créer votre propre
file de tâches (que vous devrez gérer manuellement), vous pouvez utiliser
les files de tâches prédéfinies de Linux qui sont traitées à des moments
bien précis :
A moins que le pilote utilise sa propre liste de tâches, il n'a pas
besoin d'appeler run_tasks_queues()
pour traiter la file, sauf dans les circonstances expliquées ci-dessous.
La raison pour laquelle les files de tâches
tq_timer/tq_scheduler ne sont pas traitées seulement aux
endroits habituels mais aussi ailleurs (la fermeture d'un périphérique
tty en est un exemple) devient claire si on se rappelle que le pilote
peut ordonnancer les tâches dans la file, et que ces tâches n'ont un sens
que pendant qu'une instance particulière du périphérique reste
valide - généralement jusqu'à ce que l'application le
ferme. Donc, le pilote peut avoir besoin d'appeler
run_task_queue() pour évacuer les tâches (et n'importe quoi
d'autre) qu'il a mises dans la file, parce que les autoriser à s'exécuter
plus tard n'aurait aucun sens - i.e. les structures de données utiles
auraient été libérées/réutilisées par une instance différente. C'est la
raison pour laquelle on voit run_task_queue() s'appliquer à
tq_timer et tq_scheduler à d'autres endroits qu'une interruption
de chronomètre et que schedule() respectivement.
III-I. Mini-tâches (Tasklets)
Pas encore, dans une révision future.
III-J. IRQ logicielles (softirq)
Pas encore, dans une révision future.
III-K. Comment les appels système sont-ils implémentés sur une architecture i386 ?
Il y a deux mécanismes sous Linux pour les implémenter :
Les programmes Linux natifs utilisent int 0x80 alors que les binaires
d'autres UNIX (Solaris, UnixWare 7, et cætera) utilisent le mécanisme
lcall7. Le nom historique « lcall7 » est inexact car il couvre
aussi lcall27 (i.e. Solaris/x86), tandis que la fonction de prise en
charge est appelée lcall7_func.
Quand le système démarre, la fonction
arch/i386/kernel/traps.c:trap_init() est appelée pour configurer
l'IDT de façon à ce que le vecteur 0x80 (de type 15, dpl 3) pointe à
l'adresse d'entrée de system_call dans arch/i386/kernel/entry.S.
Quand une application dans l'espace utilisateur fait un appel système,
les arguments sont passés via le registre et l'application exécute
l'instruction « int 0x80 ». Ce qui provoque une trappe (trap)
dans le
mode noyau et le processeur saute au point d'entrée system_call dans
entry.S. Voici ce qu'il fait :
Linux supporte jusqu'à 6 arguments pour les appels système. Ils sont
passés dans %ebx, %ecx, %edx, %esi, %edi (et %ebp utilisé
temporairement, voir _syscall6() dans
asm-i386/unistd.h). Le numéro de l'appel système est passé
via %eax.
III-L. Opérations atomiques
Il y a deux types d'opérations atomiques : bitmaps et atomic_t.
Les bitmaps sont très pratiques pour maintenir le concept
d'unités « allouées » ou « libres » pour de grands ensembles de données où
chaque unité est identifiée par un nombre, par exemple les inodes
libres ou les blocs libres. Ils sont aussi largement utilisés pour des
verrouillages simples, par exemple pour fournir un accès exclusif à un
périphérique ouvert. Un exemple de ceci peut être trouvé dans
arch/i386/kernel/microcode.c :
Il n'est pas nécessaire d'initialiser micro-code_status à 0
car la BSS est systématiquement remise à zéro sous Linux.
Les opérations sur les bitmaps sont :
Ces opérations utilisent la macro LOCK_PREFIX, qui s'évalue
au préfixe de l'instruction de verrouillage du bus (lock) sur les
noyaux multiprocesseur et à rien sur de l'uniprocesseur. Ce qui garantit l'atomicité
de l'accès dans les environnements multiprocesseurs.
Parfois les manipulations de bits ne sont pas pratiques,
on aimerait mieux utiliser des opérations arithmétiques - addition,
soustraction, incrémentation, décrémentation. Un cas typique en est
les compteurs de références (i.e. pour les inodes). Cette facilité est
fournie par le type de donnée atomic_t et les opérations
suivantes :
III-M. Verrous tournants, verrous tournants en lecture/écriture et verrous tournants gros lecteurs
Depuis les premiers jours du support Linux (début des années 90s), les
développeurs ont été confrontés au classique problème de l'accès à des
données partagées entre différents types de contextes (processus utilisateur
vs interruption) et les différentes instances d'un même contexte sur
plusieurs CPU.
Le support multiprocesseur et à rien sur de l'UP. Ce qui garantit l'atomicité a été ajouté à Linux 1.3.42 le 15 Novembre 1995
(le patch original avait été fait en octobre de la même année).
Si une région de code critique peut être exécutée soit dans un contexte
de processus soit dans un contexte d'interruption, alors la façon de le
protéger sur de l'UP est d'utiliser les instructions cli/sti :
Tandis que cela marche très bien sur de l'UP, ce n'est bien évidement d'aucune utilité
sur du multiprocesseur et à rien sur de l'UP. Ce qui garantit l'atomicité car la même séquence de code peut être exécutée simultanément
sur un autre processeur, et pendant que cli() fournit une
protection contre des accès concurrents avec un contexte d'interruption
sur chaque CPU individuellement, elle ne fournit pas de protection du
tout contre la concurrence entre contextes sur des CPU différents.
C'est là que les verrous tournants sont utiles.
Ils y a trois types de verrous tournants (spinlocks) : basique (vanilla),
lecture-écriture (read-write) et gros-lecteur (big-reader). Les verrous
tournants en lecture-écriture doivent être utilisés quand il y a une
tendance naturelle à avoir « beaucoup de lecteurs et peu
d'écrivains ».
Un exemple de ceci est l'accès à la liste des systèmes de fichiers
enregistrés (voir fs/super.c). Cette liste est gardée par
file_systems_lock, un verrou tournant en lecture-écriture parce
qu'on a besoin d'un accès exclusif quand on enregistre/dés-enregistre
un système de fichiers, mais n'importe quel processus peut lire le
fichier /proc/filesystems ou utiliser l'appel système
sysfs(2) pour forcer un examen systématique en lecture seule de la liste
des systèmes de fichiers. C'est là que l'utilisation des verrous
tournant en lecture-écriture prend tout son sens. Avec un verrou tournant en lecture-écriture,
on peut avoir plusieurs lecteurs en même temps mais un seul écrivain et
il ne peut pas y avoir de lecteurs quand il y a un écrivain. En passant, se serait
bien si les nouveaux lecteurs n'obtenaient pas de verrous pendant qu'un
écrivain essaie d'en poser un, i.e. si Linux pouvait gérer
correctement le problème de la frustration éventuelle d'un écrivain par plusieurs
lecteurs. Il faudrait pour cela que les lecteurs soient bloqués
pendant qu'un écrivain essaie de poser un verrou. Ce n'est pas
le cas actuellement et il n'est pas évident que ça le devienne -
l'argument opposé est - les lecteurs posent généralement un verrou
pour un temps très court alors devraient-ils réellement être privés lorsque
l'écrivain pose un verrou pour une période potentiellement longue ?
Les verrous tournant « gros-lecteur » sont une forme de verrou tournant
en lecture-écriture très optimisé pour des accès en lecture très légers,
avec une pénalisation des écritures. Il y a un nombre limité de verrous
gros-lecteur - actuellement il en existe seulement deux, dont un n'est
utilisé que sur le sparc64 (global irq) et l'autre n'est utilisé que
pour le réseau. Dans tous les autres cas où les modèles d'accès ne
collent pas à l'un de ces deux scénarios, on doit utiliser des verrous
tournant basiques. Vous ne pouvez pas bloquer pendant que
n'importe quelle sorte de verrou tournant est maintenu.
Les verrous tournants sont fournis en trois saveurs : simple (plain),
_irq() et _bh().
La raison pour laquelle vous ne pouvez pas utiliser de spin_lock()
simples si vous êtes en concurrence avec des gestionnaires
d'interruption, c'est que si vous en posez un et qu'une
interruption survienne sur le même processeur, il attendra
indéfiniment la levée du verrou : le propriétaire du verrou ayant été
interrompu, ne continuera pas tant que le gestionnaire d'interruption
n'aura pas retourné.
L'utilisation la plus courante des verrous tournant est pour
accéder à des structures de données partagées entre les contextes
des processus utilisateur et des gestionnaires d'interruption :
Il y a deux choses à remarquer dans cet exemple :
III-N. Les sémaphores et les sémaphores en lecture/écriture
Parfois, pendant l'accès à une structure de données partagées,
il faut effectuer des opérations qui peuvent bloquer, par
exemple la copie de données dans l'espace utilisateur. La primitive de
verrouillage disponible pour de tels scénarios sous Linux est appelée
sémaphore. Il y a deux types de sémaphores : basique et
lecture/écriture. En fonction de la valeur initiale du sémaphore, on
peut obtenir soit une exclusion mutuelle (valeur initiale
de 1) soit un type d'accès plus sophistiqué.
Les sémaphores en lecture-écriture diffèrent des sémaphores basiques de la
même façon que les verrous tournant en lecture-écriture diffèrent des
verrous tournant basiques : on peut avoir plusieurs lecteurs au même
instant mais seulement un écrivain et il ne peut y avoir de lecteurs
pendant qu'il y a des écrivains - i.e. l'écrivain bloque tous les
lecteurs et les nouveaux lecteurs sont bloqués tant qu'un écrivain attend.
De plus, les sémaphores basiques peuvent être interrompus - juste en
utilisant les opérations down/up_interruptible() au lieu des
simples down()/up() et en vérifiant la valeur retourné par
down_interruptible() : ce ne sera pas zéro si l'opération est
interrompue.
Utiliser les sémaphores pour l'exclusion mutuelle est la solution idéale dans
le cas où une section de code critique peut appeler par référence
des fonction inconnues enregistrées par d'autres sous-systèmes/modules,
i.e. l'appelant ne peut pas savoir à priori si la fonction bloque ou non.
Un exemple simple de l'utilisation d'un sémaphore dans
kernel/sys.c, est l'implémentation des appels
systèmes gethostname(2)/sethostname(2).
Les points à remarquer dans cet exemple sont :
Bien que l'implémentation des sémaphores et des sémaphores
lecture-écriture de Linux soit très sophistiquée, on peut imaginer des scénarios
qui ne sont pas encore implémentés, par exemple il n'y a pas
de concept de sémaphores lecture-écriture interruptibles. C'est
évidemment parce qu'il n'y a pas de situation du monde réel qui nécessite
ces implémentations de primitives exotiques.
III-O. Le support noyau des modules chargeables
Linux est un système d'exploitation monolithique et malgré toute la
publicité moderne à propos des « avantages » offerts par les systèmes
d'exploitation basés sur des micro-noyaux, la vérité reste
(dixit Linus Torvalds lui même) :
Par conséquent, Linux est et restera toujours basé sur une conception
monolithique, ce qui signifie que tous les sous-systèmes s'exécutent dans
le même mode privilégié et partagent le même espace d'adressage ; la
communication entre eux s'accomplit par le biais d'appels aux fonctions
C habituelles.
Cependant, bien que répartir les fonctionnalités du noyau dans des
« processus » séparés comme cela se fait dans les micro-noyaux
soit absolument une mauvaise idée, découper en modules noyau chargeables
dynamiquement à la demande peut être souhaitable dans certaines
circonstances (i.e. sur les machines avec peu de mémoire ou pour les
noyaux d'installation qui autrement contiendraient des pilotes de
périphériques ISA auto-testés qui sont mutuellement exclusifs). La
décision d'inclure le support pour les modules chargeables est prise à la
compilation et est déterminée par l'option CONFIG_MODULES. Le
support pour le chargement automatique des modules via le mécanisme
request_module() est une option de compilation séparée
(CONFIG_KMOD).
Les fonctionnalités suivantes peuvent être implémentées en
modules chargeables sous Linux :
Il n'y a que peu de choses qui ne peuvent pas être implémentées comme des
modules sous Linux (probablement parce que les rendre modulaires n'aurait
aucun sens) :
Linux fournit plusieurs appels systèmes pour aider au chargement des
modules :
L'interface de commande disponible pour les utilisateurs consiste en :
On peut charger manuellement un module en utilisant
insmod ou modprobe, mais
le module peut aussi être chargé automatiquement par le noyau quand une
fonction particulière est requise. L'interface du noyau prévue pour cela est
une fonction appelée request_module(name) qui est exportée vers
les modules, ainsi les modules peuvent eux-mêmes charger d'autres modules.
request_module(name) crée en interne un thread noyau qui
« execs »-ute dans l'espace utilisateur la commande
modprobe -s -k nom_du_module, en
utilisant l'interface noyau standard
exec_usermodehelper() (qui est aussi exportée
vers les modules). La fonction renvoie 0 en cas de succès, mais il n'est
généralement pas utile de tester le code de retour de
request_module(). Il vaut mieux utiliser ce
modèle :
Par exemple, fs/block_dev.c:get_blkfops() suit ce modèle pour
charger le module block-major-N quand un essai est fait pour
ouvrir le périphérique bloc de majeur N. évidemment,
il n'y a pas de module appelé block-major-N (Les développeurs
Linux choisissent des noms sensés pour leurs modules), ce nom
est associé au bon module en utilisant le fichier
/etc/modules.conf. Cependant, pour les plus connus des nombres
majeur (et pour d'autre modules) les commandes
modprobe/insmod savent quels modules réels charger sans avoir
besoin d'un alias explicite dans /etc/modules.conf.
Un bon exemple de chargement d'un module est dans l'appel système
mount(2). L'appel système mount(2) prend un type
de système de fichier comme argument sous la forme d'une chaîne de caractères que
fs/super.c:do_mount() passe ensuite à
fs/super.c:get_fs_type() :
Une ou deux choses à remarquer dans cette fonction :
Quand un module est chargé dans le noyau, il peut référencer tous les
symboles exportés comme publics par le noyau en
utilisant la macro EXPORT_SYMBOL() ou par les autres modules
actuellement chargés. Si le module utilise des symboles d'un autre
module, il est marqué comme dépendant de ce module durant le recalcul
des dépendances, effectué à l'amorçage par la commande
depmod -a (par exemple après l'installation d'un nouveau noyau).
Habituellement, l'ensemble des modules doivent correspondre à
la version des interfaces noyau qu'ils utilisent, ce qui sous Linux
signifie simplement « la version du noyau » car il n'y a pas en général
de mécanisme de gestion de version de l'interface noyau.
Cependant il existe une fonctionnalité limitée appelée « module versioning »
ou CONFIG_MODVERSIONS qui permet d'éviter de recompiler les
modules quand on change de noyau. Ce qui ce passe ici
c'est que la table des symboles noyau est traitée différemment pour les
accès internes et pour l'accès depuis les modules. Les éléments de la
partie publique (i.e. exportée) de la table des symboles sont construits
en faisant des sommes de contrôle 32bit des déclarations C. Donc, pour
résoudre un symbole utilisé par un module pendant le chargement, le chargeur
doit faire correspondre la représentation complète du symbole y
compris la somme de contrôle ; il refusera de charger le module si
les symboles différent. Ce qui se produit seulement quand à la fois le
noyau et le module sont compilés avec le « module versioning »
activé. Si l'un d'entre eux utilise le nom originel du symbole, le
chargeur essaie simplement de faire correspondre la version déclarée par
le module et celle exportée par le noyau et refuse de charger le module
si elles différent.
IV. Système de fichiers virtuel (Virtual Filesystem : VFS)IV-A. Le cache inode et les interactions avec le Dcache
Dans le but de supporter de multiples types de systèmes de fichiers, Linux
contient un niveau d'interface noyau spécial appelé VFS (Virtual
Filesystem Switch ou commutateur de systèmes de fichiers virtuels), similaire à l'interface vnode/vfs trouvé
dans les dérivés de SVR4 (il provient à l'origine des implémentations BSD
et Sun).
Le cache inode Linux est implémenté par un seul fichier,
fs/inode.c, qui consiste en 977 lignes de code. Il est
intéressant de noter qu'il n'y a pas eu tant de changements ces
5-7 dernières années : on peut encore reconnaître un peu de code
en comparant la dernière version avec, disons, la 1.3.42.
Voici la structure du cache inode de Linux :
Les listes de types sont ancrées sur inode-$gt;i_list, la table de
hachage sur inode-$gt;i_hash. Chaque inode peut être dans une
table de hachage et dans une seule liste de type (utilisé, inutilisé, modifié).
Toutes ces listes sont protégées par un unique verrou tournant :
inode_lock.
Le sous-système de cache inode est initialisé quand la fonction
inode_init() est appelée depuis
init/main.c:start_kernel(). La fonction est marquée
__init, ce qui veut dire que son code est supprimé plus tard.
On lui passe un seul argument - le nombre de pages physiques du système.
C'est ainsi que le cache inode peut se configurer lui même en fonction
de la quantité de mémoire disponible, i.e. créer une plus grande table de
hachage s'il y a assez de mémoire.
Il n'y a qu'une seule information statistique à propos du cache inode,
le nombre d'inodes inutilisés, stocké dans inodes_stat.nr_unused
et accessible aux programmes utilisateurs par les fichiers
/proc/sys/fs/inode-nr et /proc/sys/fs/inode-state.
On peut examiner une de ces liste avec gdb s'exécutant sur
un noyau en activité :
Remarquez que la valeur 8 a été déduite de l'adresse 0xdfb5a2e8 pour obtenir
l'adresse de struct inode (0xdfb5a2e0) d'après la définition
de la macro list_entry() dans include/linux/list.h.
Pour comprendre comment le cache inode fonctionne, nous allons
suivre la vie de l'inode d'un fichier régulier sur un système de
fichier ext2 quand il est ouvert et fermé :
L'appel système open(2) est implémenté dans la fonction
fs/open.c:sys_open et le travail effectif est réalisé par la fonction
fs/open.c:filp_open(), qui est divisée en deux parties :
La fonction open_namei() interagit avec le cache dentry via
path_walk(), qui à son tour appelle real_lookup(), qui
invoque la méthode inode_operations-$gt;lookup() spécifique au
système de fichiers. Le rôle de cette méthode est de trouver l'entrée
dans le répertoire parent qui correspond au nom et ensuite faire
iget(sb, ino) pour avoir l'inode correspondant - ce qui nous
amène dans le cache inode. Quand l'inode est lu, dentry est instancié
grâce à d_add(dentry, inode). Pendant que nous y
sommes, remarquez que pour les systèmes de fichiers de style UNIX qui ont
adopté le concept du numéro d'inode sur disque, il revient à la
méthode de recherche de gérer l'ordre des octets (endianness) spécifique au format
du CPU, i.e. si le numéro de l'inode d'entrée du répertoire en binaire
(fs-specific) est au format 32 bits petit boutien (little-endian) on peut faire :
Ainsi, quand on ouvre un fichier, on utilise iget(sb, ino)
qui en réalité est iget4(sb, ino, NULL, NULL), et on :
Maintenant, regardons ce qui ce passe quand on ferme le descripteur de
ce fichier. L'appel système close(2) est implémenté dans la
fonction fs/open.c:sys_close(), qui appelle
do_close(fd, 1) qui annule (remplace par NULL) le descripteur
du fichier dans la table des descripteur des fichiers processus et invoque la
fonction filp_close() qui effectue la plus grande partie du travail. Les choses
intéressantes se passent dans fput(), qui vérifie si ce descripteur était
la dernière référence au fichier, et si c'est le cas appelle
fs/file_table.c:_fput() qui appelle __fput(), là ou les
interactions avec dcache ont lieu (donc avec le cache inode aussi -
rappelez vous que dcache est le maître du cache inode!).
fs/dcache.c:dput() fait un dentry_iput() qui nous
ramène au cache inode via iput(inode), alors essayons de
comprendre fs/inode.c:iput(inode) :
Le travail effectué par iput() sur la dernière référence
de l'inode est relativement complexe, alors on lui consacrera
une liste propre :
IV-B. Enregistrement/dés-enregistrement de systèmes de fichiers
Le noyau Linux fournit un mécanisme permettant de créer de nouveaux
systèmes de fichiers avec un minimum d'effort, ceci pour des raisons
historiques :
Considérons les étapes nécessaire à l'implémentation d'un système de
fichiers sous Linux.
Le code nécessaire peut soit être un module
chargeable dynamiquement, soit être statiquement lié au noyau, ce qui
est fait de façon très transparente sous Linux. Il suffit
de créer et initialiser une structure struct file_system_type
et de l'enregistrer auprès du VFS en utilisant la fonction
register_filesystem() comme dans l'exemple de
fs/bfs/inode.c :
Les macros module_init()/module_exit() assurent que, lorsque
BFS est compilé comme un module, les fonctions init_bfs_fs()
et exit_bfs_fs() deviennent respectivement
init_module() et cleanup_module() ; si BFS est
lié statiquement dans le noyau, le code exit_bfs_fs()
disparaît puisqu'il est inutile.
struct file_system_type est déclaré dans
include/linux/fs.h :
Les champs eux-mêmes sont décrits ci-dessous :
Le travail de la fonction read_super() est de remplir les champs
du super-bloc, allouer l'inode racine (root) et initialiser toutes les
informations privées du système de fichiers associées à cette instance montée
de système de fichiers. Donc, typiquement le read_super() :
IV-C. Gestion des descripteurs de fichier
Sous Linux il y a plusieurs niveaux d'indirection entre le descripteur
de fichier utilisateur et la structure inode du noyau. Quand un
processus fait un appel système open(2), le noyau retourne un
petit entier non négatif qui peut être utilisé pour les opérations
d'entrée/sortie suivantes sur ce fichier. Cet entier est un index dans un tableau
de pointeurs sur struct file. Chaque structure de fichier
pointe sur un dentry via file-$gt;f_dentry. Et chaque dentry
pointe sur un inode via dentry-$gt;d_inode.
Chaque tâche contient un champ tsk-$gt;files qui est un pointeur
sur struct files_struct défini dans
include/linux/sched.h :
file-$gt;count est un compteur de références, incrémenté par
get_file() (appelé habituellement par fget()) et
décrémenté par fput() et par put_filp(). La
différence entre fput() et put_filp(), c'est que
fput() fait un travail supplémentaire, nécessaire habituellement pour
les fichiers réguliers, comme libérer les verrous flocks ou le
dentry, etc, tandis que put_filp() ne fait que manipuler les
structures de la table de fichier, i.e. décrémente le compteur, retire
le fichier de anon_list et l'ajoute à free_list,
sous le contrôle du verrou tournant files_lock.
tsk-$gt;files peut être partagé entre parent et enfant si le thread
enfant a été créé en utilisant l'appel système clone() avec
CLONE_FILES mis dans l'argument drapeaux. On peut voir cela
dans kernel/fork.c:copy_files() (appelé par
do_fork()) qui ne fait qu'incrémenter file-$gt;count si
CLONE_FILES est mis au lieu de copier la table
des descripteurs de fichiers selon l'habitude consacrée par la tradition du
classique fork(2) UNIX.
Quand un fichier est ouvert, la structure de fichier allouée pour lui
est installée à la position current-$gt;files-$gt;fd[fd] et un bit
fd est mis dans le bitmap current-$gt;files-$gt;open_fds.
Tout ceci est réalisé sous le contrôle du verrou tournant en
lecture-écriture current-$gt;files-$gt;file_lock. Quand le
descripteur est fermé, le bit fd est nettoyé dans
current-$gt;files-$gt;open_fds et current-$gt;files-$gt;next_fd
est rendu égal à fd, indication qui aidera à trouver le premier
descripteur libre la prochaine fois que ce processus voudra ouvrir
un fichier.
IV-D. Gestion de la structure des fichiers
La structure de fichier est déclarée dans include/linux/fs.h :
Regardons les divers champs de struct file :
Maintenant regardons la structure file_operations qui contient
les méthodes pouvant être invoquées sur les fichiers. Rappelons
nous que c'est une copie de inode-$gt;i_fop évalué par
la méthode s_op-$gt;read_inode(). Elle est déclarée dans
include/linux/fs.h :
IV-E. Super-bloc et gestion des points de montage
Sous Linux, les informations à propos des systèmes de fichiers montés
sont gardées dans deux structures séparées - super_block et
vfsmount. La raison en est que Linux autorise le montage du
même système de fichiers (périphérique bloc) sur plusieurs points de
montage, ce qui signifie que le même super_block peut
correspondre à des structures vfsmount multiples.
Regardons d'abord struct super_block, déclarée dans
include/linux/fs.h :
Les différents champs de la structure super_block sont :
Les opérations du super-bloc sont décrites dans la structure
super_operations déclarée dans include/linux/fs.h :
Alors regardons ce qu'il se passe quand nous montons un système de
fichiers présent sur un disque (FS_REQUIRES_DEV).
L'implémentation de l'appel système mount(2) est dans
fs/super.c:sys_mount() qui n'est qu'un emballage qui copie
les options, le type de système de fichiers et le nom du périphérique
pour la fonction do_mount() qui fait réellement le travail :
IV-F. Exemple de système de fichiers virtuel : pipefs
Comme exemple simple de système de fichiers qui ne requiert pas un
périphérique bloc pour être monté, considérons pipefs dans
fs/pipe.c. Le préambule de ce fichier va droit au but
et ne nécessite guère d'explications :
Le système de fichiers est du type FS_NOMOUNT|FS_SINGLE, ce
qui signifie qu'il ne peut pas être monté depuis l'espace utilisateur et
qu'il ne peut avoir qu'un super-bloc dans tout le système. Dire que le fichier est de type
FS_SINGLE signifie aussi qu'il doit être monté via
kern_mount() une fois qu'il a réussi à s'enregistrer via
register_filesystem(), et c'est exactement ce qui se passe
dans init_pipe_fs(). Le seul bug de cette fonction est que si
kern_mount() échoue (i.e. parce que kmalloc() échoue
dans l'allocation add_vfsmnt()), alors le système de fichiers reste
enregistré tandis que l'initialisation du module a échoué. Ce qui
fera « planter » cat /proc/filesystems. (je viens juste d'envoyer
un patch à Linus mentionnant cela, bien que ce ne soit pas un vrai bug
aujourd'hui car pipefs ne peut pas être compilé comme un module, la fonction
devrait être réécrite en gardant à l'esprit que dans le futur il pourrait
devenir un module).
Le résultat de register_filesystem() est que
pipe_fs_type est lié à la liste file_systems si bien qu'on
peut lire /proc/filesystems et y trouver l'entrée « pipefs » avec
un drapeau « nodev » indiquant que FS_REQUIRES_DEV n'est pas mis.
Il faudrait vraiment améliorer le fichier /proc/filesystems pour
qu'il supporte tous les nouveaux drapeaux FS_ (et j'ai écrit un
patch pour le faire) mais on ne peut pas le faire parce que cela planterait
toutes les applications utilisateur qui l'utilisent. Bien que les
interfaces du noyau Linux changent toutes les cinq minutes (toujours en
mieux), quand on en vient à la compatibilité dans l'espace
utilisateur, Linux est un système d'exploitation très conservateur qui
permet à beaucoup d'applications d'être utilisées longtemps sans
recompilation.
Le résultat de kern_mount() est que :
Maintenant que le système de fichiers est enregistré et monté dans le
noyau, nous pouvons l'utiliser. Le point d'entrée du système de fichiers
pipefs est l'appel système pipe(2), implémenté dans la fonction
sys_pipe() dépendante de l'architecture mais le travail effectif
est fait par une fonction portable fs/pipe.c:do_pipe().
Regardons do_pipe(). Les interactions avec pipefs se
produisent quand do_pipe() appelle get_pipe_inode()
pour allouer un nouvel inode pipefs. Pour cet inode,
inode-$gt;i_sb prend la valeur du super-bloc de pipefs
pipe_mnt-$gt;mnt_sb, les opérations fichier i_fop sont
mises à rdwr_pipe_fops et le nombre de lecteurs et
d'écrivains (contenu dans inode-$gt;i_pipe) est fixé à 1.
La raison pour maintenir un champ inode i_pipe séparé
au lieu de le laisser dans l'union fs-private est que les
tubes (pipes) et les FIFO partagent le même code et les FIFO peuvent
exister sur d'autre systèmes de fichier qui utilisent d'autres chemins
d'accès dans la même union, ce qui est du très mauvais C et ne fonctionne
que par pur hasard. Alors, oui, les noyaux 2.2.x fonctionnent par
chance et s'arrêteront dès que vous réarrangerez tant soit peu les champs dans l'inode.
Chaque appel système pipe(2) incrémente un compteur de
références de l'instance pipe_mnt.
Sous Linux, les pipes ne sont pas symétrique (pipes bidirectionnels
ou STREAM), i.e. les deux côtés du fichier ont des opérations
file-$gt;f_op différentes - read_pipe_fops et
write_pipe_fops respectivement. Écrire sur le côté
lecture retourne EBADF de même que lire sur le
côté écriture.
IV-G. Exemple de système de fichiers sur disque : BFS
Comme exemple simple d'un système de fichiers Linux sur disque, considérons
BFS. Le préambule du module BFS est dans fs/bfs/inode.c :
Une macro spéciale pour déclarer le fstype DECLARE_FSTYPE_DEV()
est utilisée pour mettre le fs_type-$gt;flags à
FS_REQUIRES_DEV ce qui signifie que BFS requiert un vrai
périphérique bloc pour être monté.
La fonction d'initialisation du module enregistre le système de fichiers
auprès du VFS et la fonction de nettoyage (présente seulement quand
BFS est configuré en tant que module) le dés-enregistre.
Une fois le système de fichiers enregistré, on peut procéder au montage,
ce qui invoquera la méthode fs_type-$gt;read_super() implémentée
dans fs/bfs/inode.c:bfs_read_super(). Elle fait ce qui suit :
Quand la fonction read_super() est retournée avec succès,
le VFS obtient une référence sur le module du système de fichiers via
l'appel à get_filesystem(fs_type) dans
fs/super.c:get_sb_bdev() et une référence au périphérique
bloc.
Maintenant, examinons ce qu'il se passe quand on fait une entrée/sortie sur le
système de fichiers. Nous avons déjà examiné comment les inodes sont
lus quand iget() est appelé et comment ils sont relâchés sur
un iput(). La lecture des inodes configure, parmi
d'autres choses, inode-$gt;i_op et inode-$gt;i_fop ;
l'ouverture d'un fichier propage inode-$gt;i_fop vers
file-$gt;f_op.
Parcourons le code de l'appel système link(2).
L'implémentation de l'appel système est dans
fs/namei.c:sys_link() :
D'autres opérations sur les inodes comme unlink()/rename(),
etc, fonctionnent de la même façon, cela ne vaut pas la peine
de les expliquer toutes en détails.
IV-H. Domaines d'exécution et formats binaires
Linux supporte le chargement des binaires des applications utilisateur
depuis les disques. Plus intéressant, les binaires peuvent être
stockés sous différents formats et la réponse du système d'exploitation
aux programmes via les appels systèmes peut dévier de la norme (la norme
étant le comportement de Linux) si nécessaire, afin d'émuler le
comportement d'appels systèmes d'autre versions (Solaris, UnixWare,
etc). C'est à cela que servent les domaines d'exécution et les
formats binaires.
Chaque tâche Linux a une personnalité stockée dans sa
task_struct (p-$gt;personality). Les personnalités
existantes à l'heure actuelle (soit dans le noyau officiel ou par
l'ajout d'un patch) incluent le support pour FreeBSD, Solaris,
UnixWare, OpenServer et beaucoup d'autres systèmes d'exploitation
populaires. La valeur de current-$gt;personality se décompose en
deux partie :
En changeant la personnalité, on peut changer la façon dont le système
d'exploitation traite certains appels système, par exemple l'ajout de
STICKY_TIMEOUT à current-$gt;personality fait que
l'appel système select(2) préserve la valeur du dernier
argument (timeout) au lieu de stocker le temps d'activité. Quelques
programmes bogués comptent sur des systèmes d'exploitation bogués
(pas Linux) et donc Linux fournit un moyen d'émuler les bugs dans les
cas où le code source n'est pas disponible et donc que les bugs ne
peuvent pas être corrigés.
Le domaine d'exécution est un ensemble de personnalités contiguës
implémentées par un seul module. Habituellement, il y a un seul domaine
d'exécution qui implémente une seule personnalité, mais quelquefois il est
possible d'implémenter des personnalités « proches » dans un seul module
sans trop de conditions à remplir.
Les domaines d'exécution sont implémentés dans
kernel/exec_domain.c et ont été complètement récrits pour
les noyaux 2.4, par rapport aux 2.2.x. La liste des domaines
d'exécution couramment supportés par le noyau, avec
l'ensemble des personnalités qu'ils supportent, est disponible dans
le fichier /proc/execdomains. Les domaines d'exécution, à
l'exception de PER_LINUX, peuvent être implémentés comme des
modules chargeables dynamiquement.
L'interface utilisateur consiste en l'appel système personality(2),
qui fixe la personnalité actuelle du processus ou renvoie la valeur de
current-$gt;personality quand l'argument personnalité a la valeur
impossible 0xffffffff. Évidemment, le comportement de cet appel
système lui-même ne dépend pas de la personnalité.
L'interface noyau pour l'enregistrement des domaines d'exécution est
constitué de deux fonctions :
La raison pour laquelle exec_domains_lock est en
lecture-écriture est que seules les requêtes d'enregistrement et de
dés-enregistrement modifient la liste, tandis que faire
cat /proc/filesystems appelle
fs/exec_domain.c:get_exec_domain_list(), qui n'a besoin que
d'un accès en lecture à la liste. L'enregistrement d'un nouveau domaine
d'exécution définit un « lcall7 handler » et une table de conversion
des numéros de signaux. Actuellement, le patch ABI étend ce concept
de domaine d'exécution pour inclure des informations supplémentaires
(comme les options de socket, les types de socket, les familles
d'adresses, les tables d'errno (symboles d'erreurs)).
Les formats binaires sont implémentés de manière similaire, i.e. une
liste chaînée simple de formats est définie dans fs/exec.c et
est protégée par un verrou binfmt_lock en lecture-écriture.
Comme pour exec_domains_lock, le binfmt_lock en
lecture est posé dans la plupart des cas sauf pour les
enregistrements/dés-enregistrements de format binaire. L'enregistrement
d'un nouveau format étend l'appel système execve(2) par de
nouvelles fonctions load_binary()/load_shlib() ainsi que
core_dump(). La méthode load_shlib() n'est utilisée
que par le vieil appel système uselib(2) pendant que la
méthode load_binary() est appelée par
search_binary_handler() depuis do_execve() qui
implémente l'appel système execve(2).
La personnalité du processus est déterminée au chargement du format
binaire par la méthode load_binary() correspondante en
utilisant quelques heuristiques. Par exemple, pour reconnaître les
binaires UnixWare7, on marque d'abord le binaire en utilisant
l'utilitaire elfmark(1), qui fixe e_flags de
l'entête ELF à la valeur magique 0x314B4455 qui a été détectée au moment
du chargement ELF et la personnalité courante
current-$gt;personality est mise à PER_UW7. Si cette heuristique
échoue, alors on en utilise une plus générique, telle que considérer que si l'emplacement de
l'interpréteur ELF est /usr/lib/ld.so.1 ou
/usr/lib/libc.so.1, ceci indique que le binaire est un SVR4
et mettre alors la personnalité à PER_SVR4. On peut écrire un
petit programme utilitaire qui utilise les capacité du
ptrace(2) de Linux pour exécuter le code pas à pas et ainsi forcer
un programme à s'exécuter dans n'importe quelle personnalité.
Une fois que la personnalité est connue (et par conséquent
current-$gt;exec_domain), les appels système sont pris en
charge comme suit. Admettons que le processus fasse un appel système
par le biais de l'instruction de porte lcall7. Cela transfert le
contrôle à ENTRY(lcall7) de arch/i386/kernel/entry.S
comme cela a été préparé dans arch/i386/kernel/traps.c:trap_init().
Après avoir converti la disposition de la pile de façon appropriée,
entry.S:lcall7 obtient un pointeur sur
exec_domain depuis current puis le décalage (offset) du
gestionnaire lcall7 dans exec_domain (qui est fixé en dur
à 4 dans le code assembleur, si bien que vous ne pouvez pas décaler le champs
handler dans la déclaration C de la structure
struct exec_domain) et va là bas. Donc, en C, ça ressemble à ceci :
où abi_dispatch() est une enveloppe autour de la table des
pointeurs de fonction qui implémente les appels
système uw7_funcs de la personnalité actuelle.
V. Le cache de pages Linux
Dans ce chapitre nous décrivons le cache de pages (pagecache) de Linux 2.4.
Le cache de pages est - comme son nom le suggère - un cache des pages
physiques. Dans le monde UNIX, le concept de cache de pages est devenu
populaire avec l'introduction de UNIX SVR4, où il a remplacé le cache
tampon (buffer cache) pour les opérations d'entrées/sorties (I/O) des données.
Alors que le cache de pages SVR4 n'est utilisé que pour cacher les données des
systèmes de fichiers et donc utilise la structure vnode et un offset dans
le fichier comme paramètres de hachage, le cache de pages de Linux est conçu
pour être plus générique, et donc utilise une structure address_space
(espace d'adressage - expliquée plus bas) comme premier paramètre. Parce que le cache de pages
Linux est fortement couplé à la notion d'espace d'adressage, vous aurez
besoin au moins d'une compréhension de base des espaces d'adressage pour
appréhender la façon dont fonctionne le cache de pages. Un espace
d'adressage est une espèce d'unité de gestion de mémoire logicielle (MMU) qui relie toutes les pages
d'un objet (par exemple un inode) à une autre zone (typiquement les blocs
physiques d'un disque). La structure address_space est définie
dans include/linux/fs.h comme :
Pour comprendre la façon dont l'espace d'adressage fonctionne, il suffit
de regarder quelques un de ces champs :
clean_pages, dirty_pages et locked_pages sont
des listes doublement chaînées de toutes les pages vierges, modifiées, et
verrouillées qui appartiennent à cet espace d'adressage,
nrpages est le nombre de pages dans cet address_space,
a_ops définit les méthodes de cet objet et host est
un pointeur vers l'inode auquel appartient l'espace d'adressage
- il peut aussi être NULL par exemple dans le cas de l'espace d'adressage du
gestionnaire de mémoire virtuelle (swapper). L'utilisation de clean_pages, dirty_pages,
locked_pages et nrpages est évidente, donc regardons
d'un oeil plus attentif la structure
address_space_operations, définie dans le même en-tête :
Pour une vue basique des principes des espaces d'adressage (et du cache de
pages) il faut regarder -$gt;writepage et -$gt;readpage,
mais en pratique il faut aussi regarder -$gt;prepare_write et
-$gt;commit_write.
Vous pouvez probablement deviner ce que font les méthodes de
address_space_operations grâce à leur nom ; néanmoins, elles
nécessitent quelques explications. Leur utilisation au cours d'une entrée/sortie
de données du système de fichier, ce qui est et de loin la façon la plus fréquente de passer par le
cache de pages, fournit un bon moyen de les comprendre. Contrairement
aux autres systèmes d'exploitation de type UNIX, Linux possède des opérations
génériques sur les fichiers (un sous ensemble des opération vnode SYSV)
pour les entrées/sorties de données au travers du cache de pages. Cela veut dire que les
données ne vont pas directement interagir avec le système de fichiers lors d'un
read/write/mmap (lire/écrire/projeter en mémoire), mais seront lues/écrites dans le cache de pages à
chaque fois que ce sera possible. Le cache de pages doit obtenir les données
du système de fichiers réel à bas niveau lorsque l'utilisateur
veut lire une page qui n'est pas encore en mémoire, ou écrire des
données sur le disque quand la mémoire libre diminue.
Pour lire, les méthodes génériques vont d'abord essayer de
trouver la page qui correspond au tuplet inode/index voulu.
Ensuite, on teste si la page existe vraiment.
Si elle n'existe pas, on alloue un nouvelle page, et on l'ajoute au
hachage du cache de pages.
Après que la page ait été hachée on utilise l'opération
-$gt;readpage d'address_space pour remplir la page avec les
données. (le fichier est une instance ouverte de l'inode).
Finalement nous pouvons copier les données dans l'espace utilisateur.
Pour écrire dans le système de fichier il y a deux manières : une pour
les projections en mémoire modifiables (mmap), et une pour la famille des appels système
write(2). Le cas mmap est très simple, donc nous le traiterons en
premier. Quand un utilisateur modifie une projection (mapping),
le sous-système VM (mémoire virtuelle) marque la page modifiée.
Le thread noyau bdflush qui essaie de libérer les pages, soit en
arrière plan soit parce que la mémoire libre risque de manquer, va essayer
d'appeler -$gt;writepage sur les pages qui sont explicitement
marquées modifiées. La méthode -$gt;writepage doit maintenant
écrire le contenu des pages sur le disque et libérer la page.
La deuxième manière est _beaucoup_ plus compliquée. Pour chaque page dans
laquelle l'utilisateur écrit, nous faisons en gros ce qui suit :
(pour le code complet voir mm/filemap.c:generic_file_write()).
D'abord nous essayons de trouver la page hachée ou d'en allouer une
nouvelle, ensuite nous appelons la méthode -$gt;prepare_write
d'address_space, nous copions le tampon utilisateur dans la zone mémoire du noyau et
finalement nous appelons la méthode -$gt;commit_write. Comme vous
l'avez probablement constaté -$gt;prepare_write et
-$gt;commit_write sont fondamentalement différentes de
-$gt;readpage et de -$gt;writepage, parce qu'elles ne sont
pas appelées seulement quand une entrée/sortie physique est nécessaire
mais à chaque fois que l'utilisateur modifie le fichier. Il y a deux
façons (ou plus ?) de gérer cela, la première utilise le cache tampon
(buffer cache) de Linux pour différer l'entrée/sortie physique, en remplissant un
pointeur page-$gt;buffers avec buffer_heads, ce qui sera utilisé dans
try_to_free_buffers (fs/buffers.c) pour provoquer une entrée/sortie dès
que la mémoire manquera, et c'est très largement utilisé dans le noyau
actuel. L'autre façon marque juste la page comme modifiée et compte sur
-$gt;writepage pour faire le reste du travail. Du fait de l'absence d'un
bitmap de validité dans la structure page, cela ne fonctionne pas avec un
système de fichiers qui a une granularité plus petite que PAGE_SIZE.
VI. Mécanismes de communication inter-processus (IPC)
Ce chapitre décrit les mécanismes de sémaphore, la mémoire partagée et les
files de messages IPC tels qu'ils sont implémentés dans le noyau
Linux 2.4. Il est organisé en quatre parties. Les trois premières parties
couvrent les interfaces et les fonctions supportées respectivement par les
semaphores, les
message, et la
sharedmem. La
ipc-primitives partie décrit un ensemble de
fonctions et de structures de données communes aux trois mécanismes.
VI-A. Sémaphores
Les fonctions décrites dans cette partie implémentent les mécanismes
de sémaphore au niveau utilisateur. Remarquez que cette implémentation
repose sur l'utilisation des sémaphores et des verrous tournants du
noyau. Pour éviter toute confusion, le terme « sémaphore noyau » sera
utilisé en référence aux sémaphores du noyau. Toutes les autres
utilisations du mot « sémaphore » feront référence aux sémaphores du
niveau utilisateur.
VI-A-1. Interfaces d'appels système des sémaphoresVI-A-1-a. sys_semget()
L'appel complet de sys_semget() est protégé par
struct ipc_ids, un sémaphore
noyau global.
Dans le cas où un nouvel ensemble de sémaphores doit être créé, la
fonction newary est appelée pour créer
et initialiser le nouvel ensemble de sémaphores. L'identificateur du nouvel
ensemble est retourné à l'appelant.
Dans le cas où une valeur de clef est fournie pour un ensemble de
sémaphores, ipc_findkey est
invoquée pour rechercher l'index de tableau correspondant au
descripteur du sémaphore. Les paramètres et permissions de
l'appelant sont vérifiés avant de retourner l'identificateur de l'ensemble
de sémaphores.
VI-A-1-b. sys_semctl()
Pour les commandes IPC_INFO and SEM_INFO,
IPC_INFO and SEM_INFO, et
SEM-STAT,
semctl-nolock est appelée pour
exécuter les fonctions nécessaires.
Pour les commandes GETALL,
GETVAL, GETPID,
GETNCNT, GETZCNT,
IPC-STAT, SETVAL,
et SETALL,
semctl_main est appelée pour exécuter
les fonctions nécessaires.
Pour les commandes ipc_rmid et
ipc_set,
semctl_down est appelé pour exécuter
les fonctions nécessaires. D'un bout à l'autre de ces opérations,
le verrou noyau global struct ipc_ids
est maintenu.
VI-A-1-c. sys_semop()
Après avoir validé les paramètres d'appel, les données des
opérations du sémaphore sont copiées depuis l'espace utilisateur
vers un tampon temporaire. Si un petit tampon temporaire est
suffisant, un tampon de pile est utilisé, sinon, un grand
tampon est alloué. Après avoir copié les données des opérations du
sémaphore, le verrou tournant global de sémaphore est verrouillé,
et l'identificateur de l'ensemble de sémaphores spécifique à l'utilisateur est
validé. Les permissions d'accès pour l'ensemble de sémaphores sont
également validées.
On analyse syntaxiquement (parse) toutes les opérations de
sémaphore spécifiées par l'utilisateur. Pendant ce processus, on
tient le compte de toutes les opérations dont le drapeau
SEM_UNDO est mis. Un drapeau decrease est mis si une des
opérations soustrait quelque chose à la valeur du sémaphore, et un drapeau
alter est mis si une des valeurs des sémaphores est modifiée
(i.e. augmentée ou diminuée). Le nombre des sémaphores à
modifier est validé.
Si SEM_UNDO a été imposé à une des opérations de sémaphores, alors
on recherche dans la liste undo (défaire) de la tâche courante
une structure undo associée à cet ensemble de sémaphores. Pendant la
recherche, si on trouve une valeur de -1 pour l'identificateur d'un ensemble de sémaphores
de l'une des structures undo, alors freeundos
est appelé pour libérer la structure undo et la retirer de la liste.
Si aucune structure undo n'est trouvée pour cet ensemble de
sémaphores alors alloc_undo est
appelé pour en allouer et en initialiser une.
La fonction try_atomic_semop
est appelée avec le paramètre do_undo égal à 0 pour
exécuter la séquence d'opérations. La valeur de retour indique
que les opérations réussissent, échouent ou n'ont pas été exécutées
parce qu'elles avaient besoin de bloquer. Chacun de ces cas est
décrit plus bas :
VI-A-1-c-i. Opérations de sémaphores non-bloquantes
La fonction try_atomic_semop
retourne zéro pour indiquer que toutes les opérations de la
séquence ont réussi. Dans ce cas,
update_queue est appelée pour
parcourir la file des opérations de sémaphores suspendues pour
l'ensemble de sémaphores et réveiller toutes les tâches qui n'ont
plus besoin de bloquer. Dans ce cas, cela termine l'exécution de
l'appel système sys_semop().
VI-A-1-c-ii. Opérations de sémaphores qui échouent
Si try_atomic_semop retourne
une valeur négative, c'est qu'une condition d'échec a été rencontrée.
Dans ce cas, aucune des opérations n'a été exécutée. Cela se
produit soit quand une opération de sémaphore risque de produire une
valeur de sémaphore invalide soit quand une opération marquée
IPC_NOWAIT est incapable de se terminer. La condition de l'erreur
est alors retournée à l'appelant de sys_semop().
Avant que sys_semop() retourne, un appel est fait à
update_queue pour parcourir la
file des opérations de sémaphores suspendues pour l'ensemble de
sémaphores et réveiller toutes les tâches endormies qui n'ont plus
besoin de bloquer.
VI-A-1-c-iii. Opérations de sémaphore bloquantes
La fonction try_atomic_semop
retourne 1 pour indiquer que la séquence des opérations de
sémaphore n'a pas été exécutée car l'un des sémaphores aurait bloqué.
Dans ce cas, un nouvel élément
struct sem_queue contenant les
opérations de ce sémaphore est initialisé. Si une de ces
opérations doit altérer l'état du sémaphore, le nouvel
élément est ajouté à la fin de la file. Sinon, le nouvel élément
est ajouté en tête de la file.
L'élément semsleeping de la tâche courante est positionné
pour indiquer que cette tâche est endormie sur cet élément
struct sem_queue. La tâche courante
est marquée TASK_INTERRUPTIBLE, et l'élément sleeper de
struct sem_queue est positionné pour
identifier cette tâche comme le dormeur. Le verrou tournant
global de sémaphore est ensuite déverrouillé, et schedule() est
appelé pour endormir la tâche courante.
Quand elle est réveillée, la tâche reverrouille le verrou
tournant global de sémaphore, détermine pourquoi elle a été
réveillée, et comment elle doit répondre. Les cas suivants sont
traités :
VI-A-2. Structures spécifiques au support des sémaphores
Les structures suivantes sont spécifiques au support
des sémaphores :
VI-A-2-a. struct sem_array
VI-A-2-b. struct sem
VI-A-2-c. struct seminfo
VI-A-2-d. struct semid64_ds
VI-A-2-e. struct sem_queue
VI-A-2-f. struct sembuf
VI-A-2-g. struct sem_undo
VI-A-3. Les fonctions du support des sémaphores
Les fonctions suivantes sont utilisées spécifiquement pour le
support des sémaphores :
VI-A-3-a. newary()
newary() utilise la fonction
ipc_alloc pour allouer la mémoire
requise pour le nouvel ensemble de sémaphores. Elle alloue assez de
mémoire pour le descripteur de l'ensemble de sémaphores et pour
chacun des sémaphores de l'ensemble. La mémoire allouée est remise à 0,
et l'adresse du premier élément du descripteur de l'ensemble de
sémaphores est passé à ipc_addid.
ipc_addid réserve une entrée dans le
tableau pour le nouveau descripteur et initialise
(struct kern_ipc_perm) les
données pour l'ensemble. La variable globale
used_sems reçoit le nombre de sémaphores du
nouvel ensemble et ainsi l'initialisation des données
(struct kern_ipc_perm) du nouvel ensemble est terminée.
D'autres initialisations concernant cet ensemble sont listées
Toutes les opérations suivant l'appel à
ipc_addid sont exécutées sous le
verrou tournant global des sémaphores. Après déverrouillage de
ce verrou, newary() appelle
ipc_buildid (via sem_buildid()).
Cette fonction utilise l'index du descripteur de l'ensemble de
sémaphores pour créer un identificateur unique, qui est alors retourné à
l'appelant de newary().
VI-A-3-b. freeary()
freeary() est appelée par semctl_down
pour exécuter les fonctions listées ci-dessous. Elle est appelée
avec le verrou tournant global de sémaphores verrouillé et elle
retourne avec ce verrou déverrouillé.
VI-A-3-c. semctl_down()
semctl_down() fournit les opérations
ipc_rmid et
ipc_set de l'appel système
semctl(). L'identificateur de l'ensemble de sémaphores et les permissions
d'accès sont vérifiés avant chacune de ces opérations, et dans
chaque cas, le verrou tournant global de sémaphore est maintenu
tout au long de l'opération.
VI-A-3-c-i. IPC_RMID
Les opérations IPC_RMID appellent
freeary pour retirer l'ensemble de
sémaphores.
VI-A-3-c-ii. IPC_SET
Les opérations IPC_SET mettent à jour les éléments uid,
gid, mode, et ctime de l'ensemble
de sémaphores.
VI-A-3-d. semctl_nolock()
semctl_nolock() est appelée par
sys_semctl pour exécuter les
fonctions IPC_INFO, SEM_INFO et SEM_STAT.
VI-A-3-d-i. IPC_INFO et SEM_INFO
IPC_INFO et SEM_INFO provoquent l'initialisation et le chargement
d'un tampon temporaire struct seminfo,
sans changer les données statistiques du sémaphore. Alors,
tout en maintenant le verrou noyau global de sémaphore
sem_ids.sem, les éléments semusz
et semaem de la structure
struct seminfo sont mis à jour suivant
la commande donnée (IPC_INFO ou SEM_INFO). La valeur de retour
de l'appel système est l'identificateur maximum des ensembles de sémaphores.
VI-A-3-d-ii. SEM_STAT
SEM_STAT provoque l'initialisation d'un tampon temporaire
struct semid64_ds. Le verrou
tournant global de sémaphore est maintenu pendant la copie des
valeurs sem_otime, sem_ctime, et
sem_nsems dans le tampon. Ces données sont ensuite
copiées dans l'espace utilisateur.
VI-A-3-e. semctl_main()
semctl_main() est appelée par
sys_semctl pour exécuter un grand nombre
des fonctions supportées, comme cela est décrit dans les
paragraphes ci-dessous. Avant d'exécuter l'une des opérations
suivantes, semctl_main() pose le verrou tournant global de
sémaphore et valide l'identificateur de l'ensemble de sémaphores et les
permissions. Le verrou tournant est relâché avant
de retourner.
VI-A-3-e-i. GETALL
L'opération GETALL charge les valeurs du sémaphore courant dans un
tampon noyau temporaire et les copie depuis l'espace utilisateur.
Une petit tampon de pile est utilisé si le sémaphore est petit.
Sinon, le verrou tournant est temporairement enlevé pour allouer
un tampon plus grand. Le verrou est maintenu pendant la copie des
valeurs de sémaphore dans le tampon temporaire.
VI-A-3-e-ii. SETALL
L'opération SETALL copie les valeurs des sémaphores depuis
l'espace utilisateur dans le tampon temporaire, et ensuite dans
l'ensemble de sémaphores. Le verrou tournant est enlevé pendant
la copie des valeurs depuis l'espace utilisateur dans le tampon
temporaire, et pendant la vérification de la vraisemblance des valeurs.
Si l'ensemble de sémaphores est petit, alors le tampon de pile est
utilisé, autrement un tampon plus grand est alloué. Le verrou
tournant est reposé et maintenu pendant que les opérations
suivantes sont réalisées sur l'ensemble de sémaphores :
VI-A-3-e-iii. IPC_STAT
Dans l'opération IPC_STAT, les valeurs sem_otime,
sem_ctime, et sem_nsems sont copiées dans le
tampon de pile. Les données sont ensuite copiées dans l'espace
utilisateur avant d'enlever le verrou tournant.
VI-A-3-e-iv. GETVAL
Pour GETVAL, s'il n'y a pas d'erreur, la valeur de retour de
l'appel système est égale à la valeur du sémaphore spécifié.
VI-A-3-e-v. GETPID
Pour GETPID, s'il n'y a pas d'erreur, la valeur de retour de
l'appel système est égale au pid associé à la dernière
opération sur le sémaphore.
VI-A-3-e-vi. GETNCNT
Pour GETNCNT, s'il n'y a pas d'erreur, la valeur de retour de
l'appel système est égale au nombre de processus attendant que
le sémaphore devienne négatif. Ce nombre est calculé
par la fonction count_semncnt.
VI-A-3-e-vii. GETZCNT
Pour GETZCNT, s'il n'y a pas d'erreur, la valeur de retour de
l'appel système est égale au nombre de processus attendant que le
sémaphore soit nul. Ce nombre est calculé par la fonction
count_semzcnt.
VI-A-3-e-viii. SETVAL
Après avoir validé la nouvelle valeur du sémaphore, les fonctions
suivantes sont exécutées :
VI-A-3-f. count_semncnt()
count_semncnt() compte le nombre de tâches attendant que la valeur
du sémaphore devienne négative.
VI-A-3-g. count_semzcnt()
count_semzcnt() compte le nombre de tâches attendant que la valeur
du sémaphore devienne nulle.
VI-A-3-h. update_queue()
update_queue() parcourt la file des semops suspendues d'un ensemble
de sémaphores et appelle
try_atomic_semop pour
déterminer quelles séquences d'opérations de sémaphore peuvent
réussir. Si l'état de l'élément de la file indique que les tâches
bloquées ont déjà été réveillées, alors on saute cet élément.
Pour les autres éléments de la file, le drapeau q-alter
est passé comme paramètre d'annulation à
try_atomic_semop, indiquant
que toutes les opérations de modification doivent être annulées avant de
retourner.
Si on prévoit que la séquence d'opérations va bloquer, alors update_queue()
retourne sans faire aucun changement.
Une séquence d'opérations peut échouer si une des opérations de
sémaphore provoque une valeur de sémaphore invalide, ou qu'une
opération marquée IPC_NOWAIT ne peut pas se finir. Dans un tel cas,
les tâches qui sont bloquées sur la séquence d'opérations du
sémaphore sont réveillées, et l'état de la file est positionné au
code d'erreur approprié. L'élément est retiré de la file .
Si la séquence d'opérations ne modifie rien, alors elles ont
du passer la valeur zéro comme paramètre d'annulation à
try_atomic_semop. Si ces
opérations ont réussi, elles sont considérées terminées et
retirées de la file. La tâche bloquée est réveillée, et le
status de l'élément de la file est positionné pour indiquer
le succès.
Si la séquence d'opérations doit modifier la valeur du sémaphore,
mais peut réussir, les tâche endormies qui n'ont plus besoin
d'être bloquées sont réveillées. L'état de la file est mis à 1
pour indiquer que les tâches bloquées ont été réveillées. Les
opérations n'ont pas été exécutées, donc l'élément n'est
pas retiré de la file. Les opérations de sémaphore doivent être
exécutées par une tâche réveillée.
VI-A-3-i. try_atomic_semop()
try_atomic_semop() est appelé par
sys_semop et
update_queue pour déterminer si
la séquence des opérations du sémaphore va réussir. Il le
détermine en essayant d'exécuter toutes les opérations.
Si une opération bloquante est rencontrée, le processus est
arrêté et toutes les opérations annulées. -EAGAIN est renvoyé si
IPC_NOWAIT est mis. Autrement 1 est renvoyé pour indiquer que la
séquence d'opérations est bloquée.
Si une valeur de sémaphore est ajustée au-delà des limites du
système, alors toutes les opérations sont annulées, et -ERANGE
est renvoyée.
Si toutes les opérations de la séquence réussissent, et que le
paramètre do_undo n'est pas nul, toutes les
opérations sont annulées, et 0 est renvoyé. Si le paramètre
do_undo est nul, toutes les opérations ont réussi
et sont maintenues de force, et le champ sem_otime du sémaphore
est mis à jour.
VI-A-3-j. sem_revalidate()
sem_revalidate() est appelée quand le verrou tournant global de
sémaphore a été temporairement levé et que l'on a besoin de
reverrouiller. Elle est appelée par
semctl_main et
alloc_undo. Elle valide l'identificateur du
sémaphore et les permissions et en cas de succès, retourne avec le
verrou tournant global de sémaphore verrouillé.
VI-A-3-k. freeundos()
freeundos() parcourt la liste d'annulations du processus à la recherche de
la structure d'annulation désirée. Si elle est trouvée, cette structure
est retirée de la liste et libérée. Un pointeur sur la structure
suivante dans la liste d'annulations du processus est retourné.
VI-A-3-l. alloc_undo()
alloc_undo() doit être appelée avec le verrou tournant global
de sémaphore verrouillé. En cas d'erreur, il retourne avec le
verrou déverrouillé.
Le verrou tournant global de sémaphores est déverrouillé, et
kmalloc() est appelée pour allouer suffisamment de mémoire pour
la structure struct sem_undo, et pour
un tableau de valeurs d'ajustement, une par sémaphore de
l'ensemble. En cas de succès, le verrou tournant global est remis
par l'appel à sem_revalidate.
La nouvelle structure semundo est initialisée, et l'adresse de cette
structure est placée à l'adresse fournie par l'appelant. La nouvelle
structure d'annulation est placée en tête de la liste d'annulations de la tâche
courante.
VI-A-3-m. sem_exit()
sem_exit() est appelée par do_exit(), et est responsable de
l'exécution de tous les ajustements undo pour la tâche qui se termine.
Si le processus courant a bloqué sur un sémaphore, alors il est
retiré de la liste struct sem_queue
pendant que le verrou tournant global de sémaphore est maintenu.
La liste d'annulations pour la tâche courante est ensuite parcourue, et les
opérations suivantes sont réalisées en maintenant et relâchant le
verrou tournant global de sémaphore pour chacun des éléments de la
liste. Les opération suivantes sont exécutées pour chacun
des éléments d'annulation :
Quand le traitement de la liste est terminé, la valeur
current-$gt;semundo est nettoyée.
VI-B. Les files de messagesVI-B-1. L'interface d'appel système des messagesVI-B-1-a. sys_msgget()
L'appel à sys_msgget() est entièrement protégé par un sémaphore global
de file de messages (struct ipc_ids).
S'il faut créer une file de messages, la fonction
newque est appelée pour créer et
initialiser cette nouvelle file de messages, le nouvel
identificateur de file est renvoyé à l'appelant.
Si une valeur de clef est fournie pour une file de messages
existante, on appelle ipc_findkey
pour retrouver l'index correspondant dans le tableau global
des descripteurs de file de messages (msg_ids.entries).
Les paramètres et permissions de l'appelant sont vérifiés avant
de renvoyer l'identificateur de la file de messages. L'opération
de recherche et de vérification est réalisée pendant que le
verrou tournant global de file de messages est maintenu (msg_ids.ary).
VI-B-1-b. sys_msgctl()
Les paramètres passés à sys_msgctl() sont : un identificateur de file de
messages (msqid), l'opération (cmd), et un
pointeur sur un tampon dans l'espace utilisateur du type
struct msqid_ds (buf). Cette
fonction met à notre disposition six opérations : IPC_INFO, MSG_INFO,
IPC_STAT, MSG_STAT, IPC_SET et IPC_RMID. L'identificateur de file de messages
et les paramètres des opérations sont validés, puis
l'opération (cmd) est exécutée comme suit :
VI-B-1-b-i. IPC_INFO ( or MSG_INFO)
L'information de la file de messages globale est copiée dans
l'espace utilisateur.
VI-B-1-b-ii. IPC_STAT ( or MSG_STAT)
Un tampon temporaire de type
struct msqid64_ds est
initialisé et le verrou tournant global de file de messages est
posé. Après vérification des permissions d'accès du
processus appelant, l'information de la file de messages associée
à l'identificateur de file de messages est chargée dans le tampon
temporaire, le verrou tournant global de file de messages est
déverrouillé, et le contenu du tampon temporaire est copié dans
l'espace utilisateur par
copy_msqid_to_user.
VI-B-1-b-iii. IPC_SET
Les données utilisateur sont copiées via
copy_msqid_to_user.
Le sémaphore global de file de messages et le verrou tournant sont
récupérés et relâchés à la fin. Après que l'identificateur de la file de
messages et que les permissions du processus courant ont été
validés, l'information de la file de message est mise à jour
avec les données fournies par l'utilisateur. Plus tard,
expunge_all et
ss_wakeup sont appelés pour réveiller
tous les processus endormis dans les files d'attente des récepteurs
et émetteurs de la file de messages. Ceci parce que, certains
récepteurs peuvent maintenant être exclus par des permissions
d'accès plus strictes et certains émetteurs
devenir capables d'envoyer un message grâce à l'augmentation de la taille
de la file d'attente.
VI-B-1-b-iv. IPC_RMID
Le sémaphore global de file de messages est obtenu et le verrou
tournant global de file de messages verrouillé. Après
validation de l'identificateur de la file de messages et des permissions
d'accès de la tâche courante, freeque
est appelée pour libérer les ressources relatives à l'identificateur de file
de messages. Le sémaphore global de file de messages et le verrou
tournant global de file de messages sont libérés.
VI-B-1-c. sys_msgsnd()
sys_msgsnd() reçoit comme paramètres un identificateur de file de messages
(msqid), un pointeur sur un tampon de type
struct msg_msg (msgp),
la taille du message envoyé (msgsz), et un drapeau (msgflg)
indiquant soit d'attendre (wait) soit de ne pas attendre (not wait). Il y a deux
files d'attente de tâches et une file d'attente de messages
associées à l'identificateur de file de messages. S'il y a une tâche dans la
file d'attente de réception qui attend ce message, le message
est délivré directement au récepteur, et le récepteur est réveillé.
Autrement, s'il y a assez de place dans la file d'attente,
le message est stocké dans cette file. En dernier recours, la tâche
émettrice se met elle-même dans la file d'attente d'émission.
Examinons de façon plus approfondie les opérations exécutées par
sys_msgsnd() :
VI-B-1-d. sys_msgrcv()
La fonction sys_msgrcv() reçoit en paramètres un identificateur de file de
messages (msqid), un pointeur sur un tampon du type
struct msg_msg (msgp), la taille
de message désirée (msgsz), le type de message
(msgtyp), et le drapeau (msgflg). Elle cherche dans la
file d'attente de messages associée à l'identificateur de file de messages
le premier message de la file qui correspond au type désiré, le
copie dans le tampon utilisateur donné. Si on ne trouve pas de message
de ce type, la tâche faisant la
requête est mise dans la file d'attente de réception jusqu'à ce que le
message désiré soit disponible. Examinons de façon plus approfondie
les opérations effectuées par sys_msgrcv() :
VI-B-2. Structures spécifiques aux messages
Les structures de données pour les files de messages sont définies
dans msg.c.
VI-B-2-a. struct msg_queue
VI-B-2-b. struct msg_msg
VI-B-2-c. struct msg_msgseg
VI-B-2-d. struct msg_sender
VI-B-2-e. struct msg_receiver
VI-B-2-f. struct msqid64_ds
VI-B-2-g. struct msqid_ds
VI-B-2-h. msg_setbuf
VI-B-3. Fonctions de support des messagesVI-B-3-a. newque()
newque() alloue la mémoire pour un nouveau descripteur de file de
messages (struct msg_queue)
puis appelle ipc_addid, qui réserve
une entrée dans le tableau des files de messages pour le nouveau
descripteur. Le descripteur de message est initialisé comme suit :
Pour toutes les opérations suivant l'appel de
ipc_addid, le
verrou tournant global de file de messages est maintenu. Une fois
le verrou tournant déverrouillé, newque() appelle msg_buildid(),
qui est en fait ipc_buildid.
ipc_buildid utilise l'index du
descripteur de la file de messages pour créer un identificateur de file de
messages unique qui est renvoyé à l'appelant de newque().
VI-B-3-b. freeque()
Quand une file de messages doit être retirée, la fonction freeque()
est appelée. Cette fonction suppose que le verrou tournant global de
file de messages a déjà été verrouillé par la fonction appelante. Elle
libère les ressources noyau associées à cette file de
messages. D'abord, elle appelle
ipc_rmid (via msg_rmid()) pour
retirer le descripteur de file de messages du tableau global des
descripteurs de file de messages. Ensuite elle appelle
expunge_all pour réveiller les
récepteurs et ss_wakeup pour réveiller
les émetteurs endormis dans cette file de messages. Plus tard le
verrou tournant global de file de messages est relâché. Tous les
messages stockées dans la file de messages sont libérés et la
mémoire occupée par le descripteur de file de messages est libérée.
VI-B-3-c. ss_wakeup()
ss_wakeup() réveille toutes les tâches attendant dans une file
d'attente d'émission de messages donnée. Cette fonction est appelée
par freeque, ensuite tous les émetteurs
de la file sont retirés.
VI-B-3-d. ss_add()
ss_add() reçoit en paramètres un descripteur de file de messages et
la structure de données d'un récepteur de message. Le
champ tsk de la structure de données du récepteur de message
prend pour valeur le processus courant, l'état du processus courant devient
TASK_INTERRUPTIBLE, ensuite elle insère la structure de donnée du
récepteur de message en tête de la file d'attente d'émission de la
file de messages donnée.
VI-B-3-e. ss_del()
Si la structure de données considérée de l'émetteur de message
(mss) est toujours associée à une file d'attente
d'émission, alors ss_del() retire mss de la file.
VI-B-3-f. expunge_all()
expunge_all() reçoit en paramètres un descripteur de file de
messages (msq) et la valeur d'un entier (res)
indiquant la raison du réveil des récepteurs. Pour chaque récepteur
endormi associé à msq, le champ r_msg prend pour valeur
cette raison (res), et la tâche associée
est réveillée. Cette fonction est appelée quand un message est
retiré ou quand une opération de contrôle des messages est effectuée.
VI-B-3-g. load_msg()
Quand un processus envoie un message, la fonction
sys_msgsnd invoque d'abord la
fonction load_msg() qui charge le message de l'espace utilisateur
vers l'espace noyau. Le message est représenté dans la mémoire
du noyau comme une liste chaînée de blocs. Associée au
premier, la structure struct msg_msg
décrit l'ensemble du message. Le bloc de données associé à
la structure msg_msg a une taille limitée à DATA_MSG_LEN. L'allocation
du bloc de données et de la structure s'effectue dans un bloc de données
mémoire contigu dont la taille peut aller jusqu'à une page mémoire. Si
le message complet ne tient pas dans le premier bloc de données,
des blocs additionnels sont alloués et sont organisés en
liste chaînée simple. Ces blocs additionnels ont une taille limitée à
DATA_SEG_LEN, et chacun comprend une structure
struct msg_msgseg associée. La
structure msg_msgseg et le bloc de données associé sont alloués dans
un bloc de données mémoire contigu dont la taille peut aller jusqu'à une
page mémoire. Cette fonction renvoie l'adresse de la nouvelle
structure struct msg_msg en cas de succès.
VI-B-3-h. store_msg()
La fonction store_msg() est appelée par
sys_msgrcv pour reconstituer un
message reçu dans le tampon de l'espace utilisateur fourni par
l'appelant. Les données décrites par la structure
struct msg_msg et toutes les structures
struct msg_msgseg sont
copiées à la suite dans le tampon de l'espace utilisateur.
VI-B-3-i. free_msg()
La fonction free_msg() libère la mémoire occupée par une structure de
données struct msg_msg d'un message,
et les segments du message.
VI-B-3-j. convert_mode()
convert_mode() est appelée par
sys_msgrcv. Elle reçoit en paramètres
l'adresse du type de message spécifié (msgtyp) et un
drapeau (msgflg). Elle renvoie le mode de
recherche déterminé d'après la valeur de msgtyp et msgflg.
Si msgtyp est nul, alors SEARCH_ANY est renvoyé. Si
msgtyp est négatif, alors msgtyp est remplacé par sa
valeur absolue et SEARCH_LESSEQUAL est renvoyé. Si MSG_EXCEPT est
spécifié dans msgflg, alors SEARCH_NOTEQUAL est renvoyé.
Sinon SEARCH_EQUAL est retourné.
VI-B-3-k. testmsg()
La fonction testmsg() vérifie si le message correspond aux critères
spécifiés par le récepteur. Elle renvoie 1 si une des conditions
suivantes est vraie :
VI-B-3-l. pipelined_send()
pipelined_send() autorise un processus à envoyer directement un
message à un récepteur en attente plutôt que de l'insérer dans la
file d'attente de messages associée. La fonction
testmsg est invoquée pour trouver le
premier récepteur qui attend le message donné. S'il est trouvé,
ce récepteur est retiré de la file d'attente de
réception, et la tâche réceptrice associée est réveillée. On stocke
le message dans le champ r_msg du récepteur,
on renvoie 1. Si aucun récepteur n'attend le
message, on renvoie 0.
En cherchant un récepteur, on peut trouver des candidats
récepteurs d'une taille trop petite pour le
message donné. Ces récepteurs sont retirés de la file, et sont
réveillés avec un statut d'erreur de E2BIG, qui est stocké dans le
champ r_msg. La recherche continue ensuite jusqu'à ce qu'on trouve
un récepteur valide ou jusqu'à la fin de la file.
VI-B-3-m. copy_msqid_to_user()
copy_msqid_to_user() copie le contenu d'un tampon noyau dans le
tampon utilisateur. Elle reçoit en paramètres un tampon
utilisateur, un tampon noyau du type
struct msqid64_ds, et un drapeau de
version indiquant si c'est la nouvelle version des IPC ou l'ancienne.
Si le drapeau de version est égal à IPC_64, copy_to_user()
est invoquée pour copier directement depuis le tampon noyau vers le tampon
utilisateur . Sinon, un tampon temporaire de type
struct msqid_ds est initialisé, et les données noyau sont
transférées vers ce tampon temporaire. Plus tard copy_to_user()
sera appelée pour copier le contenu du tampon temporaire dans le
tampon utilisateur.
VI-B-3-n. copy_msqid_from_user()
La fonction copy_msqid_from_user() reçoit en paramètres un tampon
message noyau du type struct msq_setbuf, un tampon utilisateur et un
drapeau de version indiquant si c'est la nouvelle version des IPC ou
l'ancienne. Dans le cas de la nouvelle version, copy_from_user() est
appelée pour copier le contenu du tampon utilisateur dans un tampon
utilisateur temporaire de type
struct msqid64_ds. Puis les champs
qbytes, uid, gid, et mode du
tampon noyau sont renseignés avec les valeurs correspondantes
du tampon temporaire. Dans le cas de la vieille version des IPC,
un tampon temporaire de type struct
struct msqid_ds est utilisé à la place.
VI-C. La mémoire partagéeVI-C-1. Interfaces d'appels système de la mémoire partagéeVI-C-1-a. sys_shmget()
L'appel à sys_shmget() est complètement protégé par un sémaphore global
de mémoire partagée.
Si l'on a besoin d'un nouveau segment de mémoire partagée,
on appelle la fonction newseg pour
créer et initialiser ce segment. L'identificateur
du nouveau segment est retourné à l'appelant.
Dans le cas où une valeur de clef est fournie pour un segment de
mémoire partagée existant, on cherche l'index correspondant dans le tableau
des descripteurs de mémoire partagée, et on vérifie les
paramètres et les permissions de l'appelant avant de
retourner l'identificateur du segment de mémoire partagée. L'opération de
recherche et de vérification est réalisée avec le verrou
tournant global de mémoire partagée maintenu.
VI-C-1-b. sys_shmctl()VI-C-1-b-i. IPC_INFO
Un tampon temporaire struct shminfo64
est chargé avec tous les paramètres de mémoire partagée du système
et est copié dans l'espace utilisateur pour que l'application
appelante puisse y accéder.
VI-C-1-b-ii. SHM_INFO
Le sémaphore global de mémoire partagée et le verrou tournant
global de mémoire partagée sont maintenus pendant que l'on
recherche les informations statistiques pour la
mémoire partagée à l'échelle du système. La fonction
shm_get_stat est appelée pour
calculer d'une part le nombre de pages de mémoire partagée qui
résident en mémoire et d'autre part le nombre de pages de mémoire partagée qui
ont été transférées dans la mémoire virtuelle (swap). Il y a d'autres statistiques comme le nombre total
de pages de mémoire partagée et le nombre de segments de mémoire
partagée en cours d'utilisation. Les nombres
swap_attempts et swap_successes sont
constants et nuls (codés en dur). Ces statistiques sont stockées dans
un tampon temporaire struct shm_info
et copiées dans l'espace utilisateur pour l'application appelante.
VI-C-1-b-iii. SHM_STAT, IPC_STAT
Pour SHM_STAT et IPC_STATA, un tampon temporaire de type
struct shmid64_ds
est initialisé, et le verrou tournant global de mémoire partagée
est verrouillé.
Dans le cas de SHM_STAT, Le paramètre identificateur du segment de mémoire
partagée attendu est un numéro d'index (i.e. il vaut de 0 à n où n est le
nombre d'identificateurs de mémoire partagée dans le système). Après avoir
validé l'index, ipc_buildid est
appelée (via shm_buildid()) pour convertir l'index en identificateur de
mémoire partagée. Dans le cas de SHM_STAT, c'est l'identificateur de mémoire
partagée qui sera la valeur retournée. Remarquez que c'est une
caractéristique non documentée, mais maintenue du programme
ipcs(8).
Dans le cas IPC_STAT, l'identificateur du segment de mémoire partagée attendu
doit avoir été généré par un appel à
sys_shmget. L'identificateur est validé avant
l'exécution. Dans le cas de IPC_STAT, c'est 0 qui sera la valeur de retour.
Pour SHM_STAT comme pour IPC_STAT, les permissions d'accès de
l'appelant sont vérifiées. Les statistiques voulues sont chargées
dans un tampon temporaire et ensuite copiées vers l'application
appelante.
VI-C-1-b-iv. SHM_LOCK, SHM_UNLOCK
Après validation des permissions d'accès, le verrou tournant
global de mémoire partagée est verrouillé, et l'identificateur de segment
de mémoire partagée est validé. La fonction
shmem_lock est appelée
pour SHM_LOCK comme pour SHM_UNLOCK. Les paramètres de
shmem_lock identifient la fonction
à exécuter.
VI-C-1-b-v. IPC_RMID
Durant IPC_RMID, le sémaphore global de mémoire partagée et le
verrou tournant global de mémoire partagée sont maintenus tout
du long. L'identificateur de mémoire partagée est validé, et
ensuite, s'il n'y pas d'attachements,
shm_destroy est appelée pour
détruire le segment de mémoire partagée. Sinon, le drapeau
SHM_DEST est mis pour le marquer « à détruire », et le
drapeau IPC_PRIVATE pour empêcher qu'un autre processus
puisse référencer l'identificateur de mémoire partagée.
VI-C-1-b-vi. IPC_SET
Après validation de l'identificateur du segment de mémoire partagée et des
permissions utilisateur, les drapeaux uid, gid,
et mode du segment de mémoire partagée sont mis à jour
avec les données utilisateur. Le champ shm_ctime peut
aussi être mis à jour. Ces changement sont réalisés pendant que le
sémaphore global de mémoire partagée et le verrou tournant global
de mémoire partagée sont maintenus.
VI-C-1-c. sys_shmat()
sys_shmat() prend comme paramètres un identificateur de segment de mémoire
partagée, une adresse à laquelle le segment doit être attaché
(shmaddr), et des drapeaux qui seront décrits ci-dessous.
Si shmaddr est différent de zéro, et que le drapeau
SHM_RND est spécifié, alors shmaddr est arrondi pour devenir
un multiple de SHMLBA. Si shmaddr n'est pas un multiple
de SHMLBA et que SHM_RND n'est pas spécifié, EINVAL est renvoyé.
Les permissions d'accès de l'appelant sont validées et le champ
shm_nattch du segment de mémoire partagée est
incrémenté. Remarquez que cet incrémentation garantit que le compteur
de liens soit non nul et empêche la destruction du
segment de mémoire partagée durant le processus d'attachement au
segment. Ces opérations sont exécutées sous la protection du verrou tournant
global de mémoire partagée.
La fonction do_mmap() est appelée pour créer une correspondance entre la mémoire
virtuelle et les pages du segment de mémoire partagée. C'est fait
en maintenant le sémaphore mmap_sem de la tâche
courante. Le drapeau MAP_SHARED est passé à do_mmap(). Si une
adresse est fournie par l'appelant, le drapeau MAP_FIXED est
aussi passé à do_mmap(). Sinon, do_mmap() choisira une adresse
virtuelle pour le correspondant du segment de mémoire partagée.
Remarquez que shm_inc sera invoquée à l'intérieur
de la fonction do_mmap() via la structure
shm_file_operations. Cette fonction est appelée pour fixer
le PID et le temps courant, et pour incrémenter le nombre
d'attachements à ce segment de mémoire partagée.
Après l'appel à do_mmap(), le sémaphore global de mémoire partagée
et le verrou tournant global de mémoire partagée sont obtenus tous
les deux. Le compteur d'attachements est ensuite décrémenté.
Le solde des changements pour ce compteur est de 1 après l'appel
à shmat(), à cause de l'appel à shm_inc.
Si, après avoir décrémenté le compteur d'attachements, celui-ci devient nul,
et si le segment est marqué « à détruire » (SHM_DEST), alors
shm_destroy est appelée pour libérer
les ressources du segment de mémoire partagée.
Finalement, l'adresse virtuelle à laquelle la mémoire partagée est
associée est renvoyée à l'appelant à l'adresse spécifiée par
l'utilisateur. Si un code d'erreur a été retourné par do_mmap(),
ce code est passé comme valeur de retour de l'appel système.
VI-C-1-d. sys_shmdt()
Le sémaphore global de mémoire partagée est maintenu pendant
l'exécution de sys_shmdt(). Dans la mm_struct du processus courant
on cherche la vm_area_struct associée à
l'adresse de la mémoire partagée. Quand elle est trouvée,
do_munmap() est appelée pour supprimer la correspondance avec l'adresse virtuelle
pour le segment de mémoire partagée.
Remarquez que do_munmap() fait un rappel à
shm_close, qui exécute les fonctions
de comptabilité de la mémoire partagée, et libère les
ressources du segment de mémoire partagée s'il n'y plus d'autre
attachement. sys_shmdt() retourne toujours 0.
VI-C-2. Les structures de support de la mémoire partagéeVI-C-2-a. struct shminfo64
VI-C-2-b. struct shm_info
VI-C-2-c. struct shmid_kernel
VI-C-2-d. struct shmid64_ds
VI-C-2-e. struct shmem_inode_info
VI-C-3. Les fonctions du support de la mémoire partagéeVI-C-3-a. newseg()
La fonction newseg() est appelée quand il faut créer un nouveau segment de mémoire
partagée. Elle agit sur 3 paramètres du
nouveau segment : la clef, le drapeau et la taille. Ayant vérifié
que la taille du segment de mémoire partagée créé est entre
SHMMIN et SHMMAX et que le nombre total de segments de mémoire
partagée ne dépasse pas SHMALL, elle alloue un nouveau descripteur
de segment de mémoire partagée. La fonction
shmem_file_setup est invoquée
plus tard pour créer un fichier non-lié de type tmpfs. Le pointeur
de fichier renvoyé est sauvegardé dans le champ shm_file du
descripteur de segment de mémoire partagée associé. La taille du fichier est
fixée égale à la taille du segment. Le nouveau
descripteur de segment de mémoire partagée est initialisé et inséré
dans le tableau IPC global des descripteurs de mémoire partagée.
L'identificateur du segment de mémoire partagée est créé par shm_buildid() (via
ipc_buildid). Cet identificateur de segment est
sauvegardé dans le champ id du descripteur de segment de mémoire
partagée, ainsi que dans le champ i_ino de l'inode
associé. De plus, l'adresse des opérations de mémoire partagée
définie dans la structure shm_file_operation est stockée
dans le fichier associé. La valeur de la variable globale
shm_tot, qui indique le nombre total de segments de mémoire
partagée du système, est aussi augmentée pour refléter ce
changement. En cas de succès, l'identificateur du segment est renvoyé à
l'application appelante.
VI-C-3-b. shm_get_stat()
shm_get_stat() fait le tour des structures de mémoire partagée,
et calcule le nombre total de pages utilisées par la
mémoire partagée et le nombre total de pages de mémoire partagée
qui ont été copiées dans la mémoire virtuelle sur le disque. Il y a une structure de fichier et
une structure d'inode pour chaque segment de mémoire partagée. Comme
les données sont obtenues via l'inode, le verrou tournant de
chaque structure d'inode accédée est verrouillé et déverrouillé
successivement.
VI-C-3-c. shmem_lock()
shmem_lock() reçoit en paramètres un pointeur sur le descripteur
du segment de mémoire partagée et un drapeau indiquant s'il est
verrouillé ou déverrouillé. L'état de verrouillage du segment de
mémoire partagée est stocké dans l'inode associé. Cet état est
comparé avec l'état désiré ; shmem_lock()
retourne simplement s'ils correspondent.
Tout en maintenant le sémaphore associé à l'inode, l'inode est mis
dans l'état verrouillé. Ce qui suit est réalisé pour chaque
page dans le segment de mémoire partagée :
VI-C-3-d. shm_destroy()
Pendant shm_destroy(), le nombre total de pages de mémoire partagée
est ajusté pour prendre en compte le retrait du segment de mémoire
partagée. ipc_rmid est appelée
(via shm_rmid()) pour retirer l'identificateur de mémoire partagée.
shmem_lock est appelée pour
déverrouiller les pages de mémoire partagée, ramenant
à zéro le compteur de références de chaque page.
fput() est appelée pour décrémenter le compteur d'utilisations
f_count de l'objet fichier associé, et si nécessaire,
pour libérer les ressources de l'objet fichier. kfree() est appelée
pour libérer le descripteur du segment de mémoire partagée.
VI-C-3-e. shm_inc()
shm_inc() fixe le PID, le temps actuel, et incrémente le nombre
d'attachements pour le segment de mémoire partagée donné. Ces
opérations sont réalisées avec le verrou tournant global de mémoire
partagée mis.
VI-C-3-f. shm_close()
shm_close() met à jour les champs shm_lprid et
shm_dtim et décrémente le nombre de segments de mémoire
partagée attachés. S'il n'y a plus d'attachement au segment
de mémoire partagée, alors
shm_destroy est appelée pour libérer
les ressources du segment de mémoire partagée. Ces opérations sont
réalisées à la fois sous le sémaphore global de mémoire partagée et
sous le verrou tournant global de mémoire partagée.
VI-C-3-g. shmem_file_setup()
La fonction shmem_file_setup() met en place un fichier non-lié
dans le système de fichiers tmpfs, de nom et de taille donnés. Si
les ressources mémoire sont suffisantes pour ce fichier, elle crée un
nouveau dentry sous le point de montage racine de tmpfs, et alloue
un nouveau descripteur de fichier et un nouvel objet inode de type
tmpfs. Ensuite, elle associe le nouvel objet dentry avec le nouvel
objet inode en appelant d_instantiate() et sauve l'adresse de
l'objet dentry dans le descripteur de fichier. Le champ
i_size de l'objet inode est mis à la taille du fichier et
le champ i_nlink est mis à zéro pour marquer l'inode comme
non-lié. De plus, shmem_file_setup() stocke l'adresse de la
structure d'opérations shmem_file_operations dans le champ
f_op, et initialise les champs f_mode et
f_vfsmnt du descripteur de fichier. La fonction
shmem_truncate() est appelée pour terminer l'initialisation de
l'objet inode. En cas de succès, shmem_file_setup() renvoie le
descripteur du nouveau fichier
VI-D. Les primitives des IPC LinuxVI-D-1. Les primitives génériques des IPC Linux utilisées avec les sémaphores, les messages et la mémoire partagée
Les mécanismes de sémaphores, de messages et de mémoire partagée de
Linux sont construits sur un ensemble de primitives communes. Ces
primitives sont décrites dans la section ci-dessous.
VI-D-1-a. ipc_alloc()
Si la mémoire à allouer est plus grande que PAGE_SIZE,
vmalloc() est utilisée pour allouer la mémoire. Sinon, c'est
kmalloc() qui est appelée avec GFP_KERNEL.
VI-D-1-b. ipc_addid()
Quand un nouvel ensemble de sémaphores, une file de messages, ou un segment
de mémoire partagée est ajouté, ipc_addid() appelle d'abord
grow_ary pour s'assurer que la taille du
tableau de descripteurs correspondant est suffisante en regard des possibilités
maximum du système. Le tableau de descripteurs est parcouru pour
trouver le premier élément inutilisé. Si un élément inutilisé est
trouvé, le compteur des descripteurs utilisés est incrémenté. La
structure struct kern_ipc_perm pour
le nouveau descripteur de ressource est initialisée, et l'index du
tableau pour le nouveau descripteur est renvoyé. Si ipc_addid()
réussit, elle retourne avec le verrou tournant global verrouillé
pour l'IPC donnée.
VI-D-1-c. ipc_rmid()
ipc_rmid() retire un descripteur d'IPC du tableau global des
descripteurs du type IPC, met à jour le compteur des identificateurs qui sont
en cours d'utilisation, ajuste l'identificateur maximum dans le tableau de
descripteurs correspondant si nécessaire. Un pointeur sur le descripteur d'IPC
associé à l'identificateur d'IPC donné est renvoyé.
VI-D-1-d. ipc_buildid()
ipc_buildid() crée un identificateur unique associé à chaque descripteur
d'un type d'IPC donné. Cet identificateur est créé au moment où le nouvel
élément IPC est ajouté (i.e. un nouveau segment de mémoire partagée
ou un nouvel ensemble de sémaphores). Les identificateurs IPC sont
facilement convertis en index du tableau de descripteurs correspondant.
Pour chaque type d'IPC, on maintient un numéro de séquence qui est incrémenté à
chaque fois qu'un descripteur est ajouté. Un identificateur est créé en
multipliant le numéro de séquence par SEQ_MULTIPLIER et en ajoutant
le produit à l'index du tableau de descripteurs. Le numéro de séquence
utilisé pour créer un identificateur d'IPC particulier est stocké dans le
descripteur correspondant. L'existence du numéro de séquence rend
possible la détection des identificateurs d'IPC dépassés.
VI-D-1-e. ipc_checkid()
ipc_checkid() divise l'identificateur IPC donné par SEQ_MULTIPLIER et compare
le quotient avec la valeur sauvegardée seq du descripteur correspondant.
Si elles sont égales, l'identificateur IPC est considéré comme valide et on
renvoie 1. Autrement, on renvoie 0.
VI-D-1-f. grow_ary()
grow_ary() offre la possibilité que le nombre maximum
(paramétrable) d'identificateurs pour un type d'IPC donné soit
changé dynamiquement. Elle force la limite maximum actuelle
a rester inférieure ou égale à la limite permanente du système
(IPCMNI) et la diminue si nécessaire. Elle s'assure aussi que le
tableau des descripteurs est assez grand. Si la taille du tableau
existant est assez grande, la limite maximum courante est
renvoyée. Autrement, un nouveau tableau plus grand est alloué,
l'ancien tableau est copié dans le nouveau, et l'ancien tableau est
libéré. Le verrou tournant global correspondant est maintenu pendant
la mise à jour du tableau de descripteurs du type d'IPC donné.
VI-D-1-g. ipc_findkey()
ipc_findkey() parcourt le tableau de descripteurs de l'objet
spécifié struct ipc_ids, et cherche la
clef spécifiée. Une fois trouvée, l'index du descripteur
correspondant est renvoyé. Si la clef n'est pas trouvée,
alors -1 est retourné.
VI-D-1-h. ipcperms()
ipcperms() vérifie les permissions utilisateur, groupe, et autres
pour l'accès aux ressources IPC. Elle retourne 0 si la permission
est donnée et -1 sinon.
VI-D-1-i. ipc_lock()
ipc_lock() prend un identificateur d'IPC comme l'un de ses paramètres. Elle
verrouille le verrou tournant global pour le type donné d'IPC, et
renvoie un pointeur sur le descripteur correspondant à l'identificateur IPC
spécifié.
VI-D-1-j. ipc_unlock()
ipc_unlock() relâche le verrou tournant pour le type d'IPC spécifié.
VI-D-1-k. ipc_lockall()
ipc_lockall() verrouille le verrou tournant global pour le mécanisme
d'IPC donné (i.e. mémoire partagée, sémaphores, et messages).
VI-D-1-l. ipc_unlockall()
ipc_unlockall() déverrouille le verrou tournant global pour le
mécanisme d'IPC donné (i.e. mémoire partagée, sémaphores, et
messages).
VI-D-1-m. ipc_get()
ipc_get() prend pour paramètres un pointeur sur un type particulier d'IPC (i.e.
mémoire partagée, sémaphores, ou files de messages) et un identificateur de
descripteur, et elle renvoie un pointeur sur le
descripteur d'IPC correspondant. Remarquez que bien que les descripteurs de chaque
type d'IPC soient de types différents, la structure commune
struct kern_ipc_perm est intégrée
comme première entité dans tous les cas. La fonction ipc_get() renvoie
ce type de donnée commun. Le modèle attendu consiste en un appel à ipc_get()
à travers une fonction enveloppe (i.e. shm_get()) qui force le type
de donnée au type de donnée correct du descripteur.
VI-D-1-n. ipc_parse_version()
ipc_parse_version() retire le drapeau IPC_64 de la commande s'il
est présent et renvoie soit IPC_64 soit IPC_OLD.
VI-D-2. Les structures des IPC génériques utilisées avec les sémaphores, les messages, et la mémoire partagée
Les mécanismes de sémaphores, de messages, et de mémoire partagée
utilisent tous les structures communes suivantes :
VI-D-2-a. struct kern_ipc_perm
Chacun des descripteurs d'IPC possède un objet de ce type comme
premier élément. Il permet l'accès à tous les descripteurs
depuis toutes les fonctions IPC génériques en utilisant un pointeur
de ce type de donnée.
VI-D-2-b. struct ipc_ids
La structure ipc_ids décrit les données communes aux sémaphores,
files de messages et à la mémoire partagée. Il y a trois instances
globales de cette structure de données --semid_ds,
msgid_ds et shmid_ds-- pour les sémaphores, les
messages et la mémoire partagée respectivement. Pour chaque
instance, le sémaphore sem est utilisé pour protéger
l'accès à la structure. Le champ entries pointe sur un
tableau de descripteurs d'IPC, et le verrou tournant ary
protège l'accès à ce tableau. Le champ seq est un numéro
de séquence global qui sera incrémenté quand une nouvelle
ressource IPC sera créée.
VI-D-2-c. struct ipc_id
Il existe un tableau de structures ipc_id dans chaque instance de la
structure struct ipc_ids. Ce tableau est
alloué dynamiquement et peut être remplacé par un tableau plus grand
grow_ary si nécessaire. Le tableau est
quelquefois référencé comme tableau de descripteurs, tant que le
type de donnée struct kern_ipc_perm
est utilisé comme descripteur de donnée commun par les fonctions
IPC génériques.
Ce document est issu de http://www.developpez.com et reste la propriété exclusive de son auteur.
La copie, modification et/ou distribution par quelque moyen que ce soit est soumise à l'obtention préalable de l'autorisation de l'auteur.
|