Structure interne du noyau Linux 2.4Date de mise à jour : 13 mai 2003
Par
Tigran Aivazian (mail) Yaël Gomez (Adaptation française) (mail) Claire Boussard (Relecture de la version française) (mail)
Adaptation française du Linux Kernel 2.4 Internals
Une introduction au noyau Linux 2.4. Ce document a été
réalisé comme support de cours donnés en interne par l'auteur chez
VERITAS Software Ltd. Celui-ci travaille en tant qu'ingénieur senior
Noyau Linux au sein de cette société.
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. Introduction
I-A. Dernière version de ce document
I-B. Droits d'utilisation
I-C. Remerciements
Remerciements à :
I-D. Adaptation française
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 :
-
Les fichiers sources C et assembleur sont compilés au format
objet relogeable (.o) ELF et certains d'entre eux sont regroupés
logiquement dans des archives (.a) en utilisant
ar(1).
-
En utilisant ld(1), les .o et .a ci-dessus sont
liés pour donner vmlinux qui est un fichier exécutable
ELF 32-bit LSB 80386 non strippé (les symboles n'ont pas été nettoyés),
statiquement lié.
-
System.map est produit par nm vmlinux,
les symboles inutiles sont retirés.
-
On entre dans le répertoire arch/i386/boot.
-
Le code assembleur du secteur d'amorçage bootsect.S est
pré-traité avec ou sans -D__BIG_KERNEL__ pour produire
respectivement, selon que la cible est bzImage ou zImage,
bbootsect.s ou bootsect.s.
-
bbootsect.s est assemblé et converti en un fichier
« binaire brut » (raw binary)
appelé bbootsect (ou bootsect.s
assemblé et converti en binaire brut donnant bootsect
pour zImage).
-
Le code Setup setup.S (setup.S inclut
video.S) est pré-traité pour donner
bsetup.s pour bzImage ou setup.s pour zImage.
De la même façon que pour le code de bootsector, la différence
réside en -D__BIG_KERNEL__, présent pour bzImage. Le
résultat est converti en un « binaire brut » appelé
bsetup.
-
On entre dans le répertoire arch/i386/boot/compressed
et convertit /usr/src/linux/vmlinux en $tmppiggy (nom
de fichier temporaire) au format binaire brut, en retirant les
sections ELF .note et .comment.
-
gzip -9 < $tmppiggy > $tmppiggy.gz
-
On lie $tmppiggy.gz au format ELF relogeable (ld -r)
piggy.o.
-
On compile les fonctions de compression head.S et
misc.c (toujours dans le répertoire
arch/i386/boot/compressed) en objets ELF
head.o et misc.o.
-
On lie ensemble head.o, misc.o et
piggy.o pour obtenir bvmlinux (ou
vmlinux pour zImage, attention à ne pas le confondre
avec /usr/src/linux/vmlinux!). Notez la différence
entre -Ttext 0x1000 utilisé pour vmlinux et
-Ttext 0x100000 pour bvmlinux,
i.e. pour bzImage compressé le chargeur (loader) est chargé en haut.
-
On convertit bvmlinux en un « binaire brut »
bvmlinux.out en enlevant les sections ELF
.note et .comment.
-
On revient dans le répertoire arch/i386/boot et, avec
le programme tools/build, concatène
bbootsect, bsetup et
compressed/bvmlinux.out pour obtenir
bzImage (supprimez le « b » du début pour
zImage). Ce qui a pour effet d'écrire à la fin du
secteur d'amorçage des variables importantes comme
setup_sects et
root_dev.
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 :
-
Le BIOS choisit le périphérique d'amorçage.
-
Le BIOS charge le secteur d'amorçage depuis le périphérique d'amorçage.
-
L'exécution du secteur d'amorçage charge setup, les routines de
décompression et l'image compressée du noyau.
-
Le noyau est décompressé en mode protégé.
-
Les initialisations de bas niveau sont réalisées en assembleur.
-
Les initialisations de haut niveau sont réalisées en C.
II-C. Démarrage : BIOS POST
-
L'alimentation démarre le générateur d'horloge et envoie le
signal #POWERGOOD sur le bus.
-
La ligne CPU #RESET est positionnée (Le CPU est maintenant en
mode réel).
-
%ds=%es=%fs=%gs=%ss=0, %cs=0xFFFF0000,%eip = 0x0000FFF0
(ROM BIOS POST code).
-
Toutes les vérifications POST sont exécutées avec les interruptions
désactivées.
-
IVT (Interrupt Vector Table ou table des vecteurs d'interruption) est initialisée à l'adresse 0.
-
La fonction BIOS de chargement du code d'amorçage est invoquée
via int 0x19, avec %dl contenant le périphérique
d'amorçage « numéro du disque ». Elle charge la piste 0,
secteur 1 à l'adresse physique 0x7C00 (0x07C0:0000).
II-D. Démarrage : secteur d'amorçage et lancement
Le secteur d'amorçage (bootsector) utilisé pour démarrer Linux peut
être soit :
-
Le secteur d'amorçage Linux (arch/i386/boot/bootsect.S),
-
Le secteur d'amorçage de LILO (ou un autre chargeur d'amorçage), ou
-
pas de secteur d'amorçage (loadlin, etc.)
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 :
29 SETUPSECS = 4
30 BOOTSEG = 0x07C0
31 INITSEG = DEF_INITSEG
32 SETUPSEG = DEF_SETUPSEG
33 SYSSEG = DEF_SYSSEG
34 SYSSIZE = DEF_SYSSIZE |
(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 :
#define DEF_INITSEG 0x9000
#define DEF_SYSSEG 0x1000
#define DEF_SETUPSEG 0x9020
#define DEF_SYSSIZE 0x7F00 |
Maintenant, regardons le détail du code de bootsect.S :
54 movw $BOOTSEG, %ax
55 movw %ax, %ds
56 movw $INITSEG, %ax
57 movw %ax, %es
58 movw $256, %cx
59 subw %si, %si
60 subw %di, %di
61 cld
62 rep
63 movsw
64 ljmp $INITSEG, $go
65 # bde - 0xff00 changé en 0x4000 => permet l'accès débogueur > 0x6400(bde).
66 # Ce ne serait pas un soucis si nous avions testé le haut de la mémoire. La
67 # configuration BIOS peut aussi permettre de placer en mémoire haute les tableaux
68 # des disques wini plutôt que dans le tableau des vecteurs. Il est possible
69 # que l'ancienne pile aie corrompu le tableau des disques.
70 go: movw $0x4000-12, %di # 0x4000 est une valeur arbitraire $gt;=
71 # la longueur du secteur d'amorçage
72 # + la longueur du setup + laplace pour
73 # la pile ; 12 est la taille du disk parm.
74 movw %ax, %ds # ax et es contiennent déjà INITSEG
75 movw %ax, %ss
76 movw %di, %sp # place la pile à INITSEG:0x4000-12. |
Les lignes de 53 à 60 déplacent le code du secteur d'amorçage de l'adresse
0x7c00 à 0x9000. Ce qui est fait en :
-
positionnant %ds:%si sur $BOOTSEG:0 (0x7C0:0 = 0x7C00)
-
positionnant %es:%di sur $INITSEG:0 (0x9000:0 = 0x90000)
-
fixant le nombre de mots de 16 bits dans %cx (256 mots = 512
octets = 1 secteur)
-
réinitialisant le drapeau DF (direction) dans EFLAGS pour
auto-incrémenter les adresses (cld)
-
avançant et copiant 512 octets (rep movsw)
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 :
77 # La table des paramètres de disque par défaut de nombreux BIOS ne
78 # permet pas la lecture multi-secteurs au-delà du numéro de secteur
79 # maximum spécifié dans le tableau des paramètres par défaut de la
80 # disquette, cela peut signifier 7 secteurs dans certains cas.
81 #
82 # Lire les secteurs un à un est lent et donc hors de question,
83 # nous remédions à cela en créant une table en RAM avec de nouveaux
84 # paramètres (pour le 1er disque). Nous mettons le nb max. de secteurs
85 # à 36 - nous ne rencontrerons pas plus pour un ED 2.88.
86 #
87 # Une valeur trop haute ne nuit pas, une trop basse, si.
88 #
89 # Les segments sont comme suit~: ds = es = ss = cs - INITSEG,
90 # fs = 0, et gs est inutilisé.
91 movw %cx, %fs # met fs à 0
92 movw $0x78, %bx # fs:bx est l'adresse du tableau des paramètres
93 pushw %ds
94 ldsw %fs:(%bx), %si # ds:si est la source
95 movb $6, %cl # copie 12 octets
96 pushw %di # di = 0x4000-12.
97 rep # pas besoin de cld -$gt; c'est fait
98 movsw # à la ligne 66
99 popw %di
100 popw %ds
101 movb $36, 0x4(%di) # corrige le compte des secteurs
102 movw %di, %fs:(%bx)
103 movw %es, %fs:2(%bx) |
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 :
107 load_setup:
108 xorb %ah, %ah # reset FDC
109 xorb %dl, %dl
110 int $0x13
111 xorw %dx, %dx # lecteur 0, tête 0
112 movb $0x02, %cl # secteur 2, piste 0
113 movw $0x0200, %bx # adresse = 512, dans INITSEG
114 movb $0x02, %ah # service 2, « lire secteur(s) »
115 movb setup_sects, %al # (suppose que tout est tête 0, piste 0)
116 int $0x13 # lire
117 jnc ok_load_setup # ok - continuons
118 pushw %ax # sort un code d'erreur
119 call print_nl
120 movw %sp, %bp
121 call print_hex
122 popw %ax
123 jmp load_setup
124 ok_load_setup: |
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 :
-
La possibilité de choisir entre plusieurs noyaux Linux ou même plusieurs
systèmes.
-
La possibilité de passer des paramètres en ligne de commande
au noyau (il y a un correctif appelé BCP qui donne cette possibilité à
bootsector+setup).
-
La possibilité de charger un noyau bzImage plus grand -
jusqu'à 2.5 Mo au lieu de 1 Mo.
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 :
-
Initialisation des valeurs de segments
(%ds = %es = %fs = %gs = __KERNEL_DS = 0x18).
-
Initialisation de la table des pages.
-
Activation de la pagination en positionnant le bit PG dans %cr0.
-
Initialisation à zéro de la BSS (sur du multiprocesseur, seul le premier
processeur fait cela).
-
Copie des premiers 2 ko des paramètres d'amorçage (ligne de
commande du noyau).
-
Vérification du type de CPU en utilisant EFLAGS et, si possible, cpuid,
capable de détecter un 386 ou plus.
-
Le premier CPU appelle start_kernel(), tous
les autres appellent
arch/i386/kernel/smpboot.c:initialize_secondary()
si ready=1, qui ne fait que recharger esp/eip et ne retourne pas.
La init/main.c:start_kernel() est écrite en C
et fait les choses suivantes :
-
Pose un verrou noyau global (c'est nécessaire afin qu'un seul CPU fasse
l'initialisation).
-
Effectue les initialisations spécifiques à la plate-forme
(analyse de la mémoire, copie une fois encore de la ligne de
commande d'amorçage, etc.).
-
Affiche la « bannière » du noyau Linux contenant la version,
le compilateur utilisé pour le construire, etc, jusqu'aux messages
des tampons noyau. Son contenu est celui de la variable Linux_banner
définie dans init/version.c et c'est la même chaîne qui est
affichée par cat /proc/version.
-
Initialise les trappes (traps).
-
Initialise les IRQ (Interrupt ReQuest).
-
Initialise les données nécessaires à l'ordonnanceur (scheduler).
-
Initialise les données conservant le temps.
-
Initialise le sous-système d'IRQ logiciel (softirq).
-
Analyse les options de la ligne de commande d'amorçage.
-
Initialise la console.
-
Si le support des modules a été compilé dans le noyau, il initialise les
capacités de chargement dynamique des modules.
-
Si la ligne de commande contient « profile= »,
il initialise les tampons nécessaires.
-
kmem_cache_init(), initialise la plupart du
slab allocator (l'allocateur de tranche ?).
-
Active les interruptions.
-
Évalue la vitesse de ce CPU en BogoMips.
-
Appelle mem_init() qui calcule
max_mapnr,
totalram_pages et
high_memory puis affiche la ligne
« Memory: ... ».
-
kmem_cache_sizes_init(), finit l'initialisation du
slab allocator.
-
Initialise les structures de données utilisées par procfs.
-
fork_init(), crée
l'uid_cache, initialise
max_threads en fonction de la quantité de
mémoire disponible et configure RLIMIT_NPROC
pour que init_task soit égal à
max_threads/2.
-
Crée les divers caches de type slab nécessaires pour le système de fichiers virtuel,
la mémoire virtuelle, le cache tampon, etc.
-
Si le support de la communication interprocessus (IPC System V) est
compilé dans le noyau, initialise le sous-système IPC.
Remarquez que pour shm System V, cela inclut de monter une instance
du système de fichiers shmfs en interne (dans le noyau).
-
Si le support des quotas est compilé dans le noyau, crée
et initialise un cache slab spécial pour eux.
-
Effectue les « vérifications de bogues (check for bugs) »
spécifiques à l'architecture et, à chaque fois que possible, active
les corrections pour les bugs processeur, bus, et cætera. La comparaison
de plusieurs architectures révèle que « ia64 n'a pas de bugs » et
que « ia32 en a quelques-uns », un bon exemple en est le
« bug f00f » qui est testé seulement si le noyau est compilé
pour un processeur inférieur au 686 et est alors corrigé.
-
Positionne un drapeau pour indiquer qu'un ordonnancement doit
être effectué à la prochaine occasion et crée un thread noyau
init() qui va « exec » execute_command si on a un paramètre
de boot « init= », ou essayer d'exécuter
/sbin/init,
/etc/init, /bin/init, /bin/sh dans cet
ordre ; si tout échoue, le noyau « panic » et émet la suggestion d'utiliser
le paramètre « init= ».
-
Rentre dans une boucle inactive (idle), qui est un fil inactif (idle thread)
avec un pid=0.
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 :
-
__init - pour le code d'initialisation
-
__initdata - pour les données
Elles s'évaluent comme des spécificateurs d'attributs gcc (aussi
connus comme « gcc magic ») telles que définies
dans include/linux/init.h :
#ifndef MODULE
#define __init __attribute__ ((__section__ (".text.init")))
#define __initdata __attribute__ ((__section__ (".data.init")))
#else
#define __init
#define __initdata
#endif |
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 :
-
LILO (ou BCP) traite la ligne de commande en utilisant les
services clavier du BIOS et la stocke dans un endroit bien repéré
de la mémoire physique, avec une signature signifiant qu'il
y a une ligne de commande valide ici.
-
arch/i386/kernel/head.S en copie les 2 premiers k
vers la page zéro.
-
arch/i386/kernel/setup.c:parse_mem_cmdline() (appelée
par setup_arch(), elle même appelée par
start_kernel()) copie 256 octets depuis la page zéro dans
saved_command_line qui est affiché par
/proc/cmdline. Cette même routine traite l'option « mem= »
si elle est présente et fait les ajustements nécessaires aux paramètres de la VM.
-
Revenons à la ligne de commande traitée par parse_options()
(appelé par start_kernel()) qui traite quelques paramètres
« internes au noyau » (actuellement « init= » et
l'environnement/arguments pour init) et passe chaque mot à
checksetup().
-
checksetup() parcourt le code de la section ELF
.setup.init et invoque chaque fonction, lui passant le
mot précédent si celui-ci convient. Remarquez qu'en utilisant une valeur de
retour de 0 depuis la fonction enregistrée via __setup(),
il est possible de passer le même « variable=value » à plus d'une
fonction avec « value » invalide pour l'une et valide pour l'autre.
Jeff Garzik commente : « les hackers qui font cela s'en
mordent les doigts :) ». Pourquoi ? Parce que c'est
clairement spécifique à l'ordre d'édition des liens, i.e. un noyau lié
dans un sens aura la fonction A appelée avant la fonction B, pour un
autre ce sera le contraire, le résultat dépendant de l'ordre.
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 :
struct kernel_param {
const char *str;
int (*setup_func)(char *);
};
extern struct kernel_param __setup_start, __setup_end;
#ifndef MODULE
#define __setup(str, fn) \
static char __setup_str_##fn[] __initdata = str; \
static struct kernel_param __setup_##fn __initsetup = \
{ __setup_str_##fn, fn }
#else
#define __setup(str,func) /* rien */
#endif |
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 :
static int __init
BusLogic_Setup(char *str)
{
int ints[3];
(void)get_options(str, ARRAY_SIZE(ints), ints);
if (ints[0] != 0) {
BusLogic_Error("BusLogic: Obsolete Command Line Entry "
"Format Ignored\n", NULL);
return 0;
}
if (str == NULL || *str == '\0')
return 0;
return BusLogic_ParseDriverOptions(str);
}
__setup("BusLogic=", BusLogic_Setup); |
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 interruptions
III-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() :
max_threads = mempages / (THREAD_SIZE/PAGE_SIZE) / 2; |
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 :
# cat /proc/sys/kernel/threads-max
32764
# echo 100000 $gt; /proc/sys/kernel/threads-max
# cat /proc/sys/kernel/threads-max
100000
# gdb -q vmlinux /proc/kcore
Core was generated by `BOOT_IMAGE=240ac18 ro root=306 video=matrox:vesa:0x118'.
#0 0x0 in ?? ()
(gdb) p max_threads
$1 = 100000 |
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 :
-
par une table de hachage, hachée sur le pid, et
-
par une liste circulaire doublement chaînée utilisant les
pointeurs p-$gt;next_task et p-$gt;prev_task.
La table de hachage est appelée pidhash[] et est définie dans
include/linux/sched.h :
#define PIDHASH_SZ (4096 $gt;$gt; 2)
extern struct task_struct *pidhash[PIDHASH_SZ];
#define pid_hashfn(x) ((((x) $gt;$gt; 8) ^ (x)) & (PIDHASH_SZ - 1)) |
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 :
static inline struct task_struct *find_task_by_pid(int pid)
{
struct task_struct *p, **htable = &pidhash[pid_hashfn(pid)];
for(p = *htable; p && p-$gt;pid != pid; p = p-$gt;pidhash_next);
return p;
} |
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 :
#define for_each_task(p) \
for (p = &init_task ; (p = p-$gt;next_task) != &init_task ; ) |
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 :
volatile long state;
#define TASK_RUNNING 0
#define TASK_INTERRUPTIBLE 1
#define TASK_UNINTERRUPTIBLE 2
#define TASK_ZOMBIE 4
#define TASK_STOPPED 8
#define TASK_EXCLUSIVE 32 |
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) :
-
TASK_RUNNING : signifie que la tâche « est censée être »
dans la file d'exécution (runqueue). La raison pour laquelle elle
peut ne pas être dans cette file, c'est que le marquage d'une
tâche comme TASK_RUNNING et son placement dans la
file n'est pas atomique. Il vous faut maintenir le verrou
tournant en lecture-écriture runqueue_lock pour lire afin
de rechercher dans la file d'exécution . Si vous faite cela vous verrez alors
que toutes les tâches dans la file sont dans l'état
TASK_RUNNING. Néanmoins, la réciproque n'est pas vraie
pour les raisons expliquées précédemment. De la même façon, les
pilotes peuvent se marquer eux mêmes (ou plutôt le contexte de
processus dans lequel ils s'exécutent) comme
TASK_INTERRUPTIBLE (ou TASK_UNINTERRUPTIBLE) et
appeler schedule(), qui va alors les retirer de la
file d'exécution (sauf s'il y a un signal en attente à leur destination,
auquel cas, ils restent dans la file).
-
TASK_INTERRUPTIBLE : signifie que la tâche est
endormie mais qu'elle peut être réveillée par un signal ou par
l'expiration d'une alarme.
-
TASK_UNINTERRUPTIBLE : idem que
TASK_INTERRUPTIBLE, sauf qu'elle ne peut pas être
réveillée.
-
TASK_ZOMBIE : la tâche s'est terminée mais son état n'a pas
été collecté par son parent (naturel ou par adoption -
pas d'appel wait() du parent).
-
TASK_STOPPED : La tâche a été arrêtée, soit par un
signal de contrôle des travaux (jobs) soit par
ptrace(2).
-
TASK_EXCLUSIVE : Ce n'est pas un état à part entière
mais il peut être combiné par un OU (OR) soit à
TASK_INTERRUPTIBLE soit à TASK_UNINTERRUPTIBLE.
Cela signifie que si cette tâche est endormie dans la file d'attente
avec beaucoup d'autres, elle sera la seule à être réveillée, sans réveiller
les autres tâches en attente, et provoquer
un problème de « thundering herd ».
Les drapeaux (flags) de tâches contiennent des informations sur
les états des processus, états qui ne sont pas mutuellement exclusifs :
unsigned long flags;
#define PF_ALIGNWARN 0x00000001 /* Averti des pb d'alignement. Pas encore */
#define PF_STARTING 0x00000002 /* en création */
#define PF_EXITING 0x00000004 /* en train de s'arrêter */
#define PF_FORKNOEXEC 0x00000040 /* s'est scindé, mais n'a pas encore */
#define PF_SUPERPRIV 0x00000100 /* a utilisé les privilèges de */
#define PF_DUMPCORE 0x00000200 /* a laissé un « core dump » */
#define PF_SIGNALED 0x00000400 /* tué par un signal */
#define PF_MEMALLOC 0x00000800 /* en train d'allouer de la mémoire */
#define PF_VFORK 0x00001000 /* réveille le parent dans mm_release */
#define PF_USEDFPU 0x00100000 /* à util. le FPU au cours de ce */
|
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 :
-
le dentry de la racine et le point de montage,
-
un dentry de la racine et un point de montage alternatif,
-
le dentry du répertoire de travail courant et le point de montage.
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(s) thread(s) idle (inutile/inactif),
-
les threads noyau,
-
les tâches utilisateur.
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 :
-
La variable locale retval est mise à -ENOMEM,
car c'est la valeur à laquelle errno doit être positionnée
si fork(2) échoue lors de l'allocation d'une nouvelle
structure de tâche.
-
Si CLONE_PID est positionné dans clone_flags, alors
on retourne une erreur (-EPERM), à moins que l'appelant
soit le thread inactif (pendant l'amorçage seulement). Donc, un thread
utilisateur normal ne peut pas passer CLONE_PID à
clone(2) en espérant que ça marche. Pour
fork(2), ce n'est pas pertinent, car clone_flags est positionné à
SIFCHLD, ça n'est pertinent que
lorsque do_fork() est appelé par sys_clone()
qui passe alors clone_flags avec la valeur demandée depuis
l'espace utilisateur.
-
current-$gt;vfork_sem est initialisé (il sera nettoyé
plus tard dans l'enfant). C'est utilisé par sys_vfork()
(l'appel système vfork(2) correspond à
clone_flags = CLONE_VFORK|CLONE_VM|SIGCHLD) pour que le
parent dorme jusqu'à ce que l'enfant fasse mm_release(),
par exemple en résultat de l'exec()ution d'un autre
programme ou en terminant par exit(2).
-
Une nouvelle structure de tâche est allouée en utilisant la
macro spécifique à l'architecture alloc_task_struct().
Sur un x86 c'est juste un gfp à la priorité GFP_KERNEL.
C'est la première raison pour laquelle l'appel système
fork(2) doit dormir. Si cette allocation échoue, on
retourne -ENOMEM.
-
Toutes les valeurs de la structure de tâche du processus
courant sont copiées dans la nouvelle structure, en utilisant
l'assignation de structure *p = *current. Peut-être que
cela devrait être remplacé par un appel à memcopy ? Plus tard, les champs qui ne
doivent pas être hérités par le fils seront positionnés aux
valeurs correctes.
-
Un GROS verrou de noyau est posé car autrement le reste du
code ne serait pas ré-entrant.
-
Si le parent a des ressources utilisateur (un concept d'UID,
Linux est suffisamment flexible pour en faire une question plutôt
qu'un fait), alors on vérifie si l'utilisateur dépasse la limite
douce RLIMIT_NPROC - si c'est le cas, on échoue avec
-EAGAIN, sinon, on incrémente le nombre de processus pour
l'uid donné p-$gt;user-$gt;count.
-
Si le nombre de tâches du système dépasse la valeur
paramétrable fixée par max_threads, on échoue avec -EAGAIN.
-
Si le binaire exécuté appartient à un domaine d'exécution
modularisé, on incrémente le compteur de références du module.
-
L'enfant est marqué « non terminé »
(p-$gt;did_exec = 0)
-
L'enfant est marqué comme ne pouvant pas être copié en zone d'échange
(p-$gt;swappable = 0)
-
L'enfant est placé dans l'état de sommeil sans interruption (uninterruptible sleep), i.e.
p-$gt;state = TASK_UNINTERRUPTIBLE (À FAIRE : Pourquoi c'est
comme ça ? Je pense que ce n'est pas nécessaire - débarrassez
vous en, Linus confirme que c'est inutile)
-
Les p-$gt;flags de l'enfant sont positionnés en fonction de
la valeur de clone_flags; pour fork(2), ce sera
p-$gt;flags = PF_FORKNOEXEC.
-
Le pid de l'enfant p-$gt;pid est fixé en utilisant un
algorithme rapide dans kernel/fork.c:get_pid()
(À FAIRE : le verrou tournant
lastpid_lock peut être redondant tant
que get_pid() est toujours appelé sous le gros verrou du
noyau depuis do_fork(), donc je retire les arguments
drapeau de get_pid(), le patch à été envoyé à Alan
le 20/06/2000).
-
La suite du code dans do_fork() initialise le reste de
la structure de tâche de l'enfant. Tout à la fin, cette structure
est hachée dans la table de hachage pidhash et l'enfant est
réveillé (À FAIRE : wake_up_process(p) positionne
p-$gt;state = TASK_RUNNING et ajoute le processus dans la
file d'exécution (runq), néanmoins on n'a probablement pas besoin de fixer
p-$gt;state à TASK_RUNNING auparavant dans
do_fork()). La partie intéressante consiste à mettre
p-$gt;exit_signal à clone_flags & CSIGNAL, ce qui
pour fork(2) signifie seulement SIGCHLD et
fixer p-$gt;pdeath_signal à 0. Le pdeath_signal est
utilisé quand un processus « oublie » son parent originel (en
mourant) et peut être fixé/obtenu par le biais des commandes
PR_GET/SET_PDEATHSIG de l'appel système prctl(2)
(Vous pouvez dire que la façon dont la valeur de
pdeath_signal est retournée via un argument pointant dans
l'espace utilisateur dans prctl(2) est un peu idiote -
mea culpa, une fois que Andries Brouwer a eu mis à jour la page man
ça a été trop tard pour corriger;)
Donc les tâches sont créées. Il y a plusieurs façons pour une tâche
de se terminer :
-
En faisant un appel système exit(2) ;
-
En recevant un signal qui la fait mourir par défaut ;
-
En étant forcée de mourir dans certaines conditions ;
-
En appelant bdflush(2) avec func == 1 (c'est
spécifique à Linux, pour la compatibilité avec les vieilles
distributions qui ont toujours la ligne « update » dans /etc/inittab
- de nos jours le travail de mise à jour est fait par le thread
noyau kupdate)
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 :
-
utilise le verrou global du noyau (elle verrouille mais ne
déverrouille pas),
-
appelle schedule() à la fin, qui ne retourne jamais,
-
positionne l'état de la tâche à TASK_ZOMBIE,
-
informe tous les enfants en envoyant current-$gt;pdeath_signal,
s'il n'est pas nul,
-
informe le parent en envoyant current-$gt;exit_signal, qui en
général est égal à SIGCHLD,
-
libère les ressources allouées par le fork, ferme les
fichiers ouverts, etc,
-
sur les architectures qui utilisent « lazy FPU switching »
(ia64, mips, mips64) (À FAIRE : retirer l'argument « flags » pour
les sparc, sparc64), fait tout ce qu'il faut selon le matériel pour
que le propriétaire du FPU (si celui-ci appartient à la tâche
courante) devienne « none » (personne).
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 :
-
p-$gt;need_resched : ce champ est positionné si
schedule() doit être appelé « à la prochaine
occasion ».
-
p-$gt;counter : nombre de tops d'horloge restant
dans cette tranche d'ordonnancement, décrémenté par un chronomètre. Quand
ce champ devient inférieur ou égal à zéro, il est remis à zéro
et p-$gt;need_resched est positionné. Cette
variable est parfois appelée « dynamic priority » d'un
processus car cette priorité change par elle-même.
-
p-$gt;priority : priorité statique du processus, elle
ne peut être changée que par des appels systèmes répertoriés comme
nice(2),
sched_setparam(2) POSIX.1b ou
setpriority(2) 4.4BSD/SVR4.
-
p-$gt;rt_priority : priorité temps réel
-
p-$gt;policy : politique d'ordonnancement, spécifie à
quelle classe d'ordonnancement la tâche appartient. Les tâches
peuvent modifier leur classe d'ordonnancement en utilisant
l'appel système sched_setscheduler(2). Les valeurs
reconnues sont SCHED_OTHER (processus UNIX traditionnel),
SCHED_FIFO (processus FIFO temps réel POSIX.1b) et
SCHED_RR (processus temps réel POSIX utilisant
l'algorithme round-robin). On peut aussi faire
SCHED_YIELD OU l'une de ces valeurs pour
indiquer que c'est le processus qui décide de libérer le CPU, par
exemple en invoquant l'appel système
sched_yield(2). Un processus FIFO (premier
arrivé, premier servi) temps réel tournera jusqu'à ce que :
a) il soit bloqué par une entrée/sortie, b) il libère
explicitement le CPU ou c) il soit préempté par un autre processus
temps réel avec une valeur de priorité
p-$gt;rt_priority plus importante. Pour
SCHED_RR c'est la même chose que pour
SCHED_FIFO, sauf que lorsque la tranche de
temps expire il retourne à la fin de la file d'exécution (runqueue).
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 :
-
Si current-$gt;active_mm == NULL alors quelque chose est
faux. Le processus courant, même un thread noyau
(current-$gt;mm == NULL) doit avoir un p-$gt;active_mm
valide en permanence.
-
S'il y a quelque chose à traiter dans la file de tâches
tq_scheduler, le faire maintenant. La file de tâches
fournit au noyau un mécanisme pour ordonnancer l'exécution ultérieurs de
fonctions. On détaillera cela par la suite.
-
Initialiser les variables locales prev et
this_cpu respectivement pour la tâche et le CPU courants.
-
Vérifier si schedule() a été appelé par le gestionnaire
d'interruption (suite à un bug) et panique si c'est le cas.
-
Libérer le verrou global du noyau.
-
S'il y a un travail à réaliser par IRQ logiciel (softirq), le faire
maintenant.
-
Initialiser le pointeur local
struct schedule_data *sched_data pour le faire pointer sur la zone
des données d'ordonnancement par CPU (cacheline-aligné pour éviter
le cacheline ping-pong) qui contient la valeur TSC de
last_schedule et le pointeur vers la dernière structure
de tâche ordonnancée (À FAIRE : sched_data n'est
utilisé que pour le multiprocesseur (SMP) alors pourquoi init_idle() l'initialise-t-il
aussi sur de l'uniprocesseur ?).
-
Un verrou tournant runqueue_lock est posé. Remarquez
que nous utilisons spin_lock_irq() car dans
schedule() on est sûr que les interruptions sont activées.
Par conséquent, quand on déverrouille runqueue_lock,
il n'y a qu'à les réactiver au lieu de sauver/restaurer eflags
(variante spin_lock_irqsave/restore).
-
La machine d'état de la tâche : si la tâche est dans un état
TASK_RUNNING, on n'y touche pas ; si elle est dans
un état TASK_INTERRUPTIBLE et qu'un signal est suspendu
elle est placée dans l'état TASK_RUNNING. Dans tous les
autres cas elle est effacée de la file d'exécution.
-
next (suivant - le meilleur candidat à l'ordonnancement)
prend pour valeur la tâche idle de notre CPU. Néanmoins, la qualité de ce
candidat est mise à une valeur très basse (-1000), dans l'espoir
de trouver un meilleur candidat.
-
Si la tâche prev (courante) est dans un état
TASK_RUNNING, alors la qualité courante est fixée à sa
qualité et cette tâche est marquée comme meilleur candidat à ordonnancer que
la tâche idle.
-
Maintenant la queue d'exécution est examinée et les qualités de chaque
processus susceptible d'être ordonnancé sur ce CPU sont comparées
à la valeur courante ; le processus qui a la qualité la plus haute
gagne. Maintenant le concept de « susceptible d'être ordonnancé sur ce CPU »
doit être éclairci : sur de l'UP, chaque processus de la queue d'exécution est
éligible pour être ordonnancé; sur du multiprocesseur, seuls les processus qui
ne sont pas en cours d'exécution sur un autre CPU sont éligibles
pour être ordonnancés sur ce CPU. La qualité est calculée au moyen d'une
fonction appelée goodness(), qui fixe très haut la qualité des processus
temps réel
(1000 + p-$gt;rt_priority), une valeur supérieure à 1000
garantit qu'aucun processus SCHED_OTHER ne peut gagner ;
donc ils ne sont en compétition qu'avec d'autres processus temps
réel ayant une plus grande priorité
p-$gt;rt_priority. La fonction goodness retourne 0 si la
tranche de temps (process time slice) du processus
(p-$gt;counter) est terminée. Pour des processus non temps
réel, la valeur initiale de la qualité est fixée à p-$gt;counter
- de cette façon, le processus a moins de chances d'avoir le CPU
s'il l'a déjà eu récemment, i.e. les processus interactifs sont
plus favorisés que les mangeurs de CPU. La constante spécifique à
l'architecture PROC_CHANGE_PENALTY essaie d'implémenter
« l'affinité CPU » (i.e. donner l'avantage à un processus du même
CPU). Cela donne aussi un léger avantage aux processus ayant mm
pointant sur le active_mm courant ou aux processus sans
espace d'adressage (utilisateur), i.e. les threads noyau.
-
si la valeur courante de la qualité est 0 alors la liste
complète des processus (pas seulement ceux de la queue d'exécution!) est
examinée et leurs priorités dynamiques sont recalculées par un
algorithme simple :
recalculate:
{
struct task_struct *p;
spin_unlock_irq(&runqueue_lock);
read_lock(&tasklist_lock);
for_each_task(p)
p-$gt;counter = (p-$gt;counter $gt;$gt; 1) + p-$gt;priority;
read_unlock(&tasklist_lock);
spin_lock_irq(&runqueue_lock);
}
Remarquez que l'on enlève le verrou runqueue_lock avant de
recalculer. La raison en est que nous devons traiter l'ensemble des
processus, ce qui peut prendre un long moment, durant lequel
schedule() peut être appelée sur un autre CPU et choisir
un processus avec une qualité satisfaisante pour ce CPU, pendant que
nous sur ce CPU on est forcé de recalculer. Ok, de l'aveu général
c'est un inconvénient parce que pendant qu'on (sur ce CPU)
choisit un processus avec la meilleure qualité, schedule()
s'exécutant sur un autre CPU peut recalculer les priorités dynamiques.
-
A partir de ce moment on est certain que next pointe sur la
tâche à ordonnancer, donc on initialise next-$gt;has_cpu à 1
et next-$gt;processor à this_cpu. Le
runqueue_lock peut être levé.
-
Si on rebascule sur la même tâche (next == prev) alors
on peut simplement réacquérir le verrou global du noyau et retourner,
sans avoir à traiter tout ce qui concerne le matériel (registres, pile
etc.) ni ce qui est lié à la VM (changer le répertoire de page,
recalculer active_mm etc.).
-
La macro switch_to() est spécifique à l'architecture.
Sur un i386, cela concerne a) la gestion du FPU, b) la gestion de
la LDT, c) le rechargement des registres de segment, d) la
gestion de la TSS et e) le rechargement des registres de déboguage.
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 :
struct list_head {
struct list_head *next, *prev;
};
#define LIST_HEAD_INIT(name) { &(name), &(name) }
#define LIST_HEAD(name) \
struct list_head name = LIST_HEAD_INIT(name)
#define INIT_LIST_HEAD(ptr) do { \
(ptr)-$gt;next = (ptr); (ptr)-$gt;prev = (ptr); \
} while (0)
#define list_entry(ptr, type, member) \
((type *)((char *)(ptr)-(unsigned long)(&((type *)0)-$gt;member)))
#define list_for_each(pos, head) \
for (pos = (head)-$gt;next; pos != (head); pos = pos-$gt;next) |
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()) :
struct super_block {
...
struct list_head s_files;
...
} *sb = &some_super_block;
struct file {
...
struct list_head f_list;
...
} *file;
struct list_head *p;
for (p = sb-$gt;s_files.next; p != &sb-$gt;s_files; p = p-$gt;next) {
struct file *file = list_entry(p, struct file, f_list);
do something to 'file'
} |
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é :
static LIST_HEAD(runqueue_head);
struct list_head *tmp;
struct task_struct *p;
list_for_each(tmp, &runqueue_head) {
p = list_entry(tmp, struct task_struct, run_list);
if (can_schedule(p)) {
int weight = goodness(p, this_cpu, prev-$gt;active_mm);
if (weight $gt; c)
c = weight, next = p;
}
} |
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 :
static inline void del_from_runqueue(struct task_struct * p)
{
nr_running--;
list_del(&p-$gt;run_list);
p-$gt;run_list.next = NULL;
}
static inline void add_to_runqueue(struct task_struct * p)
{
list_add(&p-$gt;run_list, &runqueue_head);
nr_running++;
}
static inline void move_last_runqueue(struct task_struct * p)
{
list_del(&p-$gt;run_list);
list_add_tail(&p-$gt;run_list, &runqueue_head);
}
static inline void move_first_runqueue(struct task_struct * p)
{
list_del(&p-$gt;run_list);
list_add(&p-$gt;run_list, &runqueue_head);
} |
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é) :
static DECLARE_WAIT_QUEUE_HEAD(rtc_wait);
void rtc_interrupt(int irq, void *dev_id, struct pt_regs *regs)
{
spin_lock(&rtc_lock);
rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
spin_unlock(&rtc_lock);
wake_up_interruptible(&rtc_wait);
} |
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 :
ssize_t rtc_read(struct file file, char *buf, size_t count, loff_t *ppos)
{
DECLARE_WAITQUEUE(wait, current);
unsigned long data;
ssize_t retval;
add_wait_queue(&rtc_wait, &wait);
current-$gt;state = TASK_INTERRUPTIBLE;
do {
spin_lock_irq(&rtc_lock);
data = rtc_irq_data;
rtc_irq_data = 0;
spin_unlock_irq(&rtc_lock);
if (data != 0)
break;
if (file-$gt;f_flags & O_NONBLOCK) {
retval = -EAGAIN;
goto out;
}
if (signal_pending(current)) {
retval = -ERESTARTSYS;
goto out;
}
schedule();
} while(1);
retval = put_user(data, (unsigned long *)buf);
if (!retval)
retval = sizeof(unsigned long);
out:
current-$gt;state = TASK_RUNNING;
remove_wait_queue(&rtc_wait, &wait);
return retval;
} |
Ce qui se passe dans rtc_read() est :
-
On déclare un élément de file d'attente pointant sur le
contexte du processus courant.
-
On ajoute cet élément à la file d'attente rtc_wait.
-
On marque le contexte courant comme TASK_INTERRUPTIBLE
ce qui signifie qu'il ne sera pas réordonnancé à la fin de son
prochain temps de sommeil.
-
On vérifie s'il y a des données disponibles ; s'il y en a,
on s'interrompt, on copie les données dans le tampon utilisateur, on se
marque TASK_RUNNING, on se retire nous même de la file
d'attente et on retourne.
-
S'il n'y a pas encore de données, on regarde si l'utilisateur
a spécifié une entrée/sortie non bloquante et si oui on échoue avec
EAGAIN (qui est la même chose que EWOULDBLOCK)
-
Nous regardons également s'il y a un signal suspendu et si
tel est le cas on demande aux « couches supérieures » de relancer
l'appel système si nécessaire. Par « si nécessaire » je pense aux
détails de la disposition du signal tels que spécifiés lors de l'appel
système sigaction(2).
-
Alors on « bascule hors de la tâche (switch out) », i.e. on
s'endort, jusqu'à ce qu'on soit réveillé par la routine
d'interruption. Si on ne s'est pas marqué nous même comme
TASK_INTERRUPTIBLE, alors l'ordonnanceur peut nous
ordonnancer avant que les données soient disponibles, ce qui
cause des traitements inutiles.
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) :
static unsigned int rtc_poll(struct file *file, poll_table *wait)
{
unsigned long l;
poll_wait(file, &rtc_wait, wait);
spin_lock_irq(&rtc_lock);
l = rtc_irq_data;
spin_unlock_irq(&rtc_lock);
if (l != 0)
return POLLIN | POLLRDNORM;
return 0;
} |
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 :
struct timer_list {
struct list_head list;
unsigned long expires;
unsigned long data;
void (*function)(unsigned long);
volatile int running;
}; |
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) :
-
void init_bh(int nr, void (*routine)(void)) : installe
le gestionnaire de bottom half pointé par l'argument
routine à l'emplacement nr. Les emplacements
sont énumérés dans include/linux/interrupt.h
sous la forme XXXX_BH, i.e. TIMER_BH ou
TQUEUE_BH. Typiquement, une fonction d'initialisation d'un
sous-système (init_module() pour les modules) installe les
bottom half requis en utilisant cette fonction.
-
void remove_bh(int nr) : fait le contraire de
init_bh(), i.e. elle désinstalle le bottom half installé à
l'emplacement nr. Il n'y pas de vérification d'erreur
effectuée ici, alors, par exemple remove_bh(32) va faire paniquer
(panic/Oops) le système. Typiquement, une routine de nettoyage d'un
sous-système (cleanup_module() pour modules) utilise cette
fonction pour libérer son emplacement qui pourra être réutilisé
plus tard par un autre sous-système. (À FAIRE : ne serait il pas
bien d'avoir un /proc/bottom_halves listant tous les
bottom halves enregistrés sur le système ? Ce qui signifie que
global_bh_lock doit être mis en lecture/écriture, évidemment)
-
void mark_bh(int nr) : marque le bottom half à
l'emplacement nr pour exécution. Typiquement, une routine
d'interruption marquera son bottom half (d'où le nom!) pour
exécution à un « moment plus sûr ».
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 :
-
Il y en a seulement un certain nombre (32).
-
Chaque bottom half peut être associé à un seul gestionnaire.
-
Les bottom halves sont dévorées par le verrou tournant mis
pour qu'elles ne puissent pas bloquer.
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 :
-
tq_timer : la file de tâche des
chronomètres, s'exécute à chaque interruption de chronomètre et quand on
libère un périphérique tty (fermeture ou libération d'un périphérique
terminal à demi ouvert). Tant que le gestionnaire de chronomètre
s'exécute dans un contexte d'interruption, la tâche
tq_timer s'exécute aussi dans un contexte
d'interruption et donc ne peut pas bloquer.
-
tq_scheduler : la file de tâche de l'ordonnanceur,
exécutée par l'ordonnanceur (et aussi quand on ferme un périphérique
tty, comme tq_timer). Comme l'ordonnanceur a été exécuté dans
le contexte du processus étant réordonnancé, les tâches de
tq_scheduler peuvent faire ce quelles veulent, i.e.
bloquer, utiliser les données du contexte processus
(mais pourquoi voudraient-elles le faire), et cætera.
-
tq_immediate : c'est un vrai bottom half
IMMEDIATE_BH, donc les pilotes peuvent
queue_task(task, &tq_immediate) (mettre une tâche dans la file) puis
mark_bh(IMMEDIATE_BH) (la marquer) pour être exécutée dans un
co
|