Accueil
Rechercher:
sur developpez.com sur les forums
Forums | Tutoriels | F.A.Q's | Participez | Hébergement | Contacts
Accueil Conception Java DotNET Visual Basic  C  C++ Delphi MS-Office SQL & SGBD Oracle  4D  Business Intelligence
Club Emploi Blogs   TV   Dév. Web PHP XML Python Autres 2D-3D-Jeux Sécurité Windows Linux PC Mac
FORUM LINUX FAQ LINUX TUTORIELS LINUX LIVRES LINUX LINUX TV UNIX GTK+ Qt APACHE

Structure interne du noyau Linux 2.4

Date 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

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 :

  1. 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).
  2. 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é.
  3. System.map est produit par nm vmlinux, les symboles inutiles sont retirés.
  4. On entre dans le répertoire arch/i386/boot.
  5. 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.
  6. 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).
  7. 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.
  8. 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.
  9. gzip -9 < $tmppiggy > $tmppiggy.gz
  10. On lie $tmppiggy.gz au format ELF relogeable (ld -r) piggy.o.
  11. 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.
  12. 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.
  13. On convertit bvmlinux en un « binaire brut » bvmlinux.out en enlevant les sections ELF .note et .comment.
  14. 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 :

  1. Le BIOS choisit le périphérique d'amorçage.
  2. Le BIOS charge le secteur d'amorçage depuis le périphérique d'amorçage.
  3. L'exécution du secteur d'amorçage charge setup, les routines de décompression et l'image compressée du noyau.
  4. Le noyau est décompressé en mode protégé.
  5. Les initialisations de bas niveau sont réalisées en assembleur.
  6. Les initialisations de haut niveau sont réalisées en C.

II-C. Démarrage : BIOS POST

  1. L'alimentation démarre le générateur d'horloge et envoie le signal #POWERGOOD sur le bus.
  2. La ligne CPU #RESET est positionnée (Le CPU est maintenant en mode réel).
  3. %ds=%es=%fs=%gs=%ss=0, %cs=0xFFFF0000,%eip = 0x0000FFF0 (ROM BIOS POST code).
  4. Toutes les vérifications POST sont exécutées avec les interruptions désactivées.
  5. IVT (Interrupt Vector Table ou table des vecteurs d'interruption) est initialisée à l'adresse 0.
  6. 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  	    /* défaut pour le nombre de secteurs de lancement */
30 BOOTSEG   = 0x07C0	    /* adresse originelle du secteur d'amorçage */
31 INITSEG   = DEF_INITSEG  /* nous déplaçons l'amorçage ici - hors du chemin */
32 SETUPSEG  = DEF_SETUPSEG /* le lancement démarre ici */
33 SYSSEG    = DEF_SYSSEG   /* le système est chargé à 0x10000 (65536) */
34 SYSSIZE   = DEF_SYSSIZE  /* taille du système~: # en clicks de 16-octets */
(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 :
/* Ne changez rien ici, sauf si vous savez vraiment ce que vous faites. */
#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&#160;; 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 :

  1. positionnant %ds:%si sur $BOOTSEG:0 (0x7C0:0 = 0x7C00)
  2. positionnant %es:%di sur $INITSEG:0 (0x9000:0 = 0x90000)
  3. fixant le nombre de mots de 16 bits dans %cx (256 mots = 512 octets = 1 secteur)
  4. réinitialisant le drapeau DF (direction) dans EFLAGS pour auto-incrémenter les adresses (cld)
  5. 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, «&#160;lire secteur(s)&#160;»
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 :

  1. La possibilité de choisir entre plusieurs noyaux Linux ou même plusieurs systèmes.
  2. 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).
  3. 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 :

  1. Initialisation des valeurs de segments (%ds = %es = %fs = %gs = __KERNEL_DS = 0x18).
  2. Initialisation de la table des pages.
  3. Activation de la pagination en positionnant le bit PG dans %cr0.
  4. Initialisation à zéro de la BSS (sur du multiprocesseur, seul le premier processeur fait cela).
  5. Copie des premiers 2 ko des paramètres d'amorçage (ligne de commande du noyau).
  6. Vérification du type de CPU en utilisant EFLAGS et, si possible, cpuid, capable de détecter un 386 ou plus.
  7. 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 :

  1. Pose un verrou noyau global (c'est nécessaire afin qu'un seul CPU fasse l'initialisation).
  2. 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.).
  3. 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.
  4. Initialise les trappes (traps).
  5. Initialise les IRQ (Interrupt ReQuest).
  6. Initialise les données nécessaires à l'ordonnanceur (scheduler).
  7. Initialise les données conservant le temps.
  8. Initialise le sous-système d'IRQ logiciel (softirq).
  9. Analyse les options de la ligne de commande d'amorçage.
  10. Initialise la console.
  11. Si le support des modules a été compilé dans le noyau, il initialise les capacités de chargement dynamique des modules.
  12. Si la ligne de commande contient « profile= », il initialise les tampons nécessaires.
  13. kmem_cache_init(), initialise la plupart du slab allocator (l'allocateur de tranche ?).
  14. Active les interruptions.
  15. Évalue la vitesse de ce CPU en BogoMips.
  16. Appelle mem_init() qui calcule max_mapnr, totalram_pages et high_memory puis affiche la ligne « Memory: ... ».
  17. kmem_cache_sizes_init(), finit l'initialisation du slab allocator.
  18. Initialise les structures de données utilisées par procfs.
  19. 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.
  20. 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.
  21. 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).
  22. Si le support des quotas est compilé dans le noyau, crée et initialise un cache slab spécial pour eux.
  23. 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é.
  24. 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= ».
  25. 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 :

  1. 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.
  2. arch/i386/kernel/head.S en copie les 2 premiers k vers la page zéro.
  3. 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.
  4. 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().
  5. 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 :
/*
 * Utilisé pour initialiser les paramètres du noyau avec les valeurs
 * de la ligne de commande
 */
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() :
/*
* La valeur par défaut du nombre maximum de threads est fixée à une
* valeur sûre~: la structure des threads peut occuper au maximum la moitié
* de la mémoire.
*/
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 :

  1. par une table de hachage, hachée sur le pid, et
  2. 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 :
/* Hachage sur le PID. (est-ce que ceci ne devrait pas être dynamique~?) */
#define PIDHASH_SZ (4096 $gt;$gt; 2)
extern struct task_struct *pidhash[PIDHASH_SZ];
     
#define pid_hashfn(x)   ((((x) $gt;$gt; 8) ^ (x)) &#38; (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 = &#38;pidhash[pid_hashfn(pid)];
  
for(p = *htable; p &#38;&#38; 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 = &#38;init_task ; (p = p-$gt;next_task) != &#38;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;	/* -1 unrunnable, 0 runnable, $gt;0 stopped */

#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) :

  1. 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).
  2. 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.
  3. TASK_UNINTERRUPTIBLE : idem que TASK_INTERRUPTIBLE, sauf qu'elle ne peut pas être réveillée.
  4. 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).
  5. TASK_STOPPED : La tâche a été arrêtée, soit par un signal de contrôle des travaux (jobs) soit par ptrace(2).
  6. 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;  /* drapeaux pour chaque processus, cf. plus bas */
/*
 * Drapeaux par processus
 */
#define PF_ALIGNWARN  0x00000001    /* Averti des pb d'alignement. Pas encore */
                                    /* implanté, pour 486 seulement */
#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 */
                                    /* fait de «&#160;exec &#160;» */
#define PF_SUPERPRIV  0x00000100    /* a utilisé les privilèges de */
                                    /* super-utilisateur */
#define PF_DUMPCORE   0x00000200    /* a laissé un «&#160;core dump&#160;» */
#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 */
                                    /* quantième (SMP) */
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 :

  1. le dentry de la racine et le point de montage,
  2. un dentry de la racine et un point de montage alternatif,
  3. 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 :

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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.
  6. Un GROS verrou de noyau est posé car autrement le reste du code ne serait pas ré-entrant.
  7. 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.
  8. Si le nombre de tâches du système dépasse la valeur paramétrable fixée par max_threads, on échoue avec -EAGAIN.
  9. Si le binaire exécuté appartient à un domaine d'exécution modularisé, on incrémente le compteur de références du module.
  10. L'enfant est marqué « non terminé » (p-$gt;did_exec = 0)
  11. L'enfant est marqué comme ne pouvant pas être copié en zone d'échange (p-$gt;swappable = 0)
  12. 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)
  13. 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.
  14. 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).
  15. 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 :

  1. En faisant un appel système exit(2) ;
  2. En recevant un signal qui la fait mourir par défaut ;
  3. En étant forcée de mourir dans certaines conditions ;
  4. 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 :

  1. 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.
  2. 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.
  3. Initialiser les variables locales prev et this_cpu respectivement pour la tâche et le CPU courants.
  4. Vérifier si schedule() a été appelé par le gestionnaire d'interruption (suite à  un bug) et panique si c'est le cas.
  5. Libérer le verrou global du noyau.
  6. S'il y a un travail à réaliser par IRQ logiciel (softirq), le faire maintenant.
  7. 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 ?).
  8. 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).
  9. 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.
  10. 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.
  11. 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.
  12. 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.
  13. 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(&#38;runqueue_lock); read_lock(&#38;tasklist_lock); for_each_task(p) p-$gt;counter = (p-$gt;counter $gt;$gt; 1) + p-$gt;priority; read_unlock(&#38;tasklist_lock); spin_lock_irq(&#38;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.
  14. 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é.
  15. 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.).
  16. 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) { &#38;(name), &#38;(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)(&#38;((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 = &#38;some_super_block;

struct file {
   ...
   struct list_head f_list;
   ...
} *file;

struct list_head *p;

for (p = sb-$gt;s_files.next; p != &#38;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, &#38;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(&#38;p-$gt;run_list);
        p-$gt;run_list.next = NULL;
}

static inline void add_to_runqueue(struct task_struct * p)
{
        list_add(&#38;p-$gt;run_list, &#38;runqueue_head);
        nr_running++;
}

static inline void move_last_runqueue(struct task_struct * p)
{
        list_del(&#38;p-$gt;run_list);
        list_add_tail(&#38;p-$gt;run_list, &#38;runqueue_head);
}

static inline void move_first_runqueue(struct task_struct * p)
{
        list_del(&#38;p-$gt;run_list);
        list_add(&#38;p-$gt;run_list, &#38;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(&#38;rtc_lock);	
	rtc_irq_data = CMOS_READ(RTC_INTR_FLAGS);
	spin_unlock(&#38;rtc_lock);	
	wake_up_interruptible(&#38;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(&#38;rtc_wait, &#38;wait);
	current-$gt;state = TASK_INTERRUPTIBLE;
	do {
		spin_lock_irq(&#38;rtc_lock);
		data = rtc_irq_data;
		rtc_irq_data = 0;
		spin_unlock_irq(&#38;rtc_lock);

		if (data != 0)
			break;

		if (file-$gt;f_flags &#38; 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(&#38;rtc_wait, &#38;wait);
	return retval;
}
Ce qui se passe dans rtc_read() est :

  1. On déclare un élément de file d'attente pointant sur le contexte du processus courant.
  2. On ajoute cet élément à  la file d'attente rtc_wait.
  3. 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.
  4. 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.
  5. 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)
  6. 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).
  7. 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, &#38;rtc_wait, wait);

        spin_lock_irq(&#38;rtc_lock);
        l = rtc_irq_data;
        spin_unlock_irq(&#38;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 :

  1. Il y en a seulement un certain nombre (32).
  2. Chaque bottom half peut être associé à  un seul gestionnaire.
  3. 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 :

  1. 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.
  2. 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.
  3. 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