⚙️ Sur les OS et les noyaux
Introduction
J’utilise Gnu/Linux tous les jours, j’ai eu autrefois
cours sur les systèmes d’exploitation, fait un peu de
VHDL
et d’assembleur
… Et pourtant
le tutoriel Operating
System in 1,000 Lines 🇬🇧 m’a appris, réappris et fait
mieux comprendre ce qu’est réellement un système
d’exploitation et quel est le rôle concrêt d’un noyau (aka
kernel).
Cela a aussi abouti à pas mal de questionnement sur le fonctionnement de tout cela auquel j’ai recherché des réponses.
J’ai donc décidé de re-clarifier pour moi et pour d’éventuels lecteurs, quelques points en essayant de rester à la fois simple et clair.
L’espace de mémoire physique présenté au processeur est “virtuel”.
Un noyau doit décider comment il organise la mémoire. Le processus de boot lui donne accès à un espace mémoire dite “physique”. Pour être un plus concrêt, un noyau à d’une façon ou d’une autre une “carte” de l’organisation de la mémoire, par exemple via un linker script.
Mais qu’est-ce qui se cache derrière l’adresse
0x00000
ou 0x30000
? la RAM ? un
contrôleur de la carte mère quelconque ? le GPU ? Autre-chose
?
En réalité, l’allocation de tel ou tel parti de cet espace
à tel ou tel mémoire est définie pas une
spécification (lié à l’architecture du processeur et
le processeur en lui-même). Il y a ainsi un bon nombre de
“trou” dans cet adressage. L’adresse 0x02323
pointe peut-être vers rien du tout.
Donc l’espace de mémoire “physique” est, lui-même, une représentation « virtuelle » de différentes mémoires accessibles, non pas forcément dans le sens ou l’on pourrait le changer dynamiquement (il n’est pas très clair pour moi ce qui est physiquement fixé ou non là-dedans), mais plutôt dans le sens ou plusieurs mémoires bien différentes se retrouve accessibles depuis un même espace d’adressage de façon transparente pour le noyau (qui a intérêt à savoir réellement où se trouve chaque chose bien évidemment).
Il existe deux grandes méthodes pour organiser l’adressage l’espace mémoire classique (RAM) et l’espace IO :
- Avoir deux espaces mémoire séparé entre IO et RAM et donc des instructions CPU spécifiques. C’est le mécanisme de port IO. C’est ce que faisaient les anciens systèmes informatiques.
- Avoir un seul espace avec un branchement “physique” avec le/les contrôleurs IO de sorte que les IO puisse apparaître à des addresses mémoire “physique”. C’est ce qui est utilisé quasi-systématiquement de nos jours.
On pourrait penser que les addresses des IO sont fixés, mais cela ne semble pas si simple. Il existe des interactions possibles pour allouer la mémoire nécessaire.
Un exemple concrêt de l’interaction possible est la
technologie rezisable BAR
pour les GPU qui
permet de négocier la taille allouer pour l’accès au GPU,
autrement la taille sera limité et il faut donc un mécanisme
pour passer à la donnée suivante et ainsi des cycles cpu
perdus.
Dans le cas de périphériques tel qu’un disque dur ou autres, un principe qu’on retrouve beaucoup est :
- L’accessibilité des bits de configuration ou
d’instructions via un MMIO (
memory mapped IO
), c’est-à-dire un branchement direct entre l’appareil et le périphérique, ce qui implique un délai d’accès lecture/écriture relativement important. - La présence des espaces mémoire lecture/écriture plus
important sous la forme d’un DMA
(
direct memory access
). C’est-à-dire un tampon mémoire synchronisé entre l’appareil IO et l’ordinateur. Un exemple très parlant de ça, et le besoin d’éjecter la clé usb proprement mal gré que la copie (sur le tampon et non sur la clé usb) semble terminé.
Les processus sont déjà une première sorte de “conteneur”.
Si vous connaissez les conteneurs et leur logique
d’isolation, pensez que les processus
sont déjà
un premier mécanisme d’isolation du système :
- Par l’accès mémoire : Les processus n’ont accès qu’à une mémoire “virtuelle”, les addresses qu’il utilise ne sont valides qu’à l’intérieur de ceux-ci. Il y a tout un mécanisme dit de pagination, que je ne vais pas expliciter ici.
- Par l’accès aux périphériques et autres :
Les processus doivent demander l’accès au noyau
pour de nombreuses tâches comme écrire sur le disque, via
des fonctions appelés
appels systèmes
.
Ainsi les processus ne peuvent pas intéragir sur d’autre
processus par défault et doivent demander l’accès pour bon
nombre de tâches. Un certain nombre de mécanismes existent
néanmoins (en passant obligatoirement par des syscall) pour
au final permettre à ces processus de pouvoir communiquer
entre eux, ce sont les IPC(Inter
processus communication
)… mais ce sera un sujet pour
un autre article 😉.
Le noyau définis des concepts basiques de l’informatique
Les concepts tels que la pile, le tas, les processus sont tellement habituel pour un informaticien lambda qu’ils apparaissent comme naturel, il est donc un peu troublant, de se retrouver à devoir les implémenter, concrètement, simplement et de se demander à quoi pourrait ressembler un autre paradigme de fonctionnement de l’OS sans ces éléments ?
Ainsi dans le tutoriel, on implémente des choses telles que :
- Les processus
- Le kernel Panic
- La notion d’appel système
- La commutation de contexte
- …
La structure d’un processus dans le tutoriel est très simple :
struct process {
int pid; // 0 if it's an idle process
int state; // PROC_UNUSED, PROC_RUNNABLE, PROC_EXITED
vaddr_t sp; // kernel stack pointer
uint32_t *page_table; // points to first level page table
uint8_t stack[8192]; // kernel stack
};
Bien évidemment, dans le cas d’un noyau vraiment utilisé, les choses sont plus complexe. Mais il est amusant de voir à quel point tout cela est à la fois simple et réellement compliqué.
Un autre point intéressant a noté de voir qu’une partie de la mécanique tiens dans le code du kernel et une autre dans le rôle et l’implémentation même des instructions CPU. Ainsi, on a :
- des instructions CPU pour gérer/mettre à jour l’adressage mémoire virtuel.
- des instructions CPU pour changer de “mode” (Kernel ou utilisateur), permettant ainsi d’interdire aux instructions suivantes du programme de faire tel ou telles instructions.
Ces éléments sont un peu complexe à comprendre, notamment, car il nécessite de se plonger dans les méandres de la documentation d’architecture CPU, mais ce qu’il faut en déduire c’est que le CPU possède divers mécanismes pour simplifier la vie des concepteurs de système d’exploitation.
Programmer un pilote (driver) n’est (presque) qu’implémenter une spécification d’API
Dans la partie sur l’espace d’adresse physique, je parlais d’espace de configuration disponible via MMIO pour les IO. Mais il ne s’agit pas de remplis ces espaces de données au hasard.
Les appareils d’entrée/sortie une toute une spécification, je dirais même une API que le noyau doit respecter pour utiliser l’appareil, c’est (presque) aussi simple que ça un pilote.
Le tutoriel pour sa part nous donne pour mission d’implémenter un disque virtuel via Virtio et il est relativement amusant de voir qu’il s’agit pour une bonne grosse part de l’application stricte d’une spécification.
Les interruptions CPU sont essentielles au fonctionnement
Les interruptions ?
Les interruptions CPU sont un mécanisme, à l’image des
Signaux POSIX
(SIGINT, SIGKILL, etc.) qui permet
au CPU d’exécuter un code spécifique (configurable) quand le
CPU est confronté à des événements tels que :
- un périphérique émet un signalement.
- certaines instruction CPU.
- une erreur CPU : division par 0, erreur de segmentation, etc.
Tout comme les Signaux POSIX, il est possible de se “brancher dessus”.
C’est ce que fait le noyau :
- pour l’implémentation du kernel panic dans d’éventuel cas.
- pour les syscall permettant aux processus en mode utilisateur d’exécuter par ce biais du code kernel en mode kernel notamment pour pouvoir intéragir avec les périphériques.
Un processus passe généralement souvent dans le mode noyau à intervalles réguliers, ne serait qu’à cause des appels systèmes.
Les interruptions sont la base d’un os préemptif
Dans le tutoriel, l’os se veut coopératif, le processus en cours indique de lui-même quand il redonne la main au noyau pour une nouvelle tâche.
Dans les systèmes d’exploitation récents, le mécanisme est préemptif, c’est-à-dire que le noyau peut arrêter un processus en cours de route.
Seulement voilà, comment faire cela ?
La solution ce sont les interruptions. Tout le mécanisme de préemption fonctionne dessus. En trouvant différente manière de profiter d’interruption qui interromprait le processus, on revient au mode noyau et on peut alors, décider de ne plus re-exécuter le code de ce processus.
Dans les faits, il y a toute une mécanique très complexe d’ordonnancement dans linux là-dessus pour organiser au mieux le temps alloué aux différents processus et leur ordre.
Quant aux interruptions, une astuce pour éviter qu’un
processus reste éternellement en fonctionnement en mode
utilisateur si aucune interruption n’est provoqué, est tout
simplement l’existence
d’interruption à intervalles réguliers, configurable via
l’option de Compilation CONFIG_HZ
.
Pour conclure
Le développement de Kernel/Noyau ne semble pas pour moi, j’ai l’impression qu’une partie notable du travail consiste à implémenter des spécifications complexes (jeux d’instruction CPU, spécification de pilote) dans le contexte d’un langage relativement bas niveaux, c’est une discipline très exigeante à mille lieues de la fluidité du développement python auquel j’ai l’habitude.
Néanmoins, mieux comprendre et démythifié les bases sur lequel son propre code fonctionne permet d’avoir une capacité supplémentaire d’action en cas de problèmes.
Je ne peux donc que vous conseiller de faire ce tutoriel. Le tutoriel s’avère plus ou moins rude selon vos connaissances ainsi je vous conseillerais de ne pas forcément chercher à comprendre le moindre détail, mais d’avancer tranquillement dessus (et ne pas hésiter le copier/coller… vous n’avez pas tous prévu de développer des OS, non ?).
Bonne création de noyau à tous 😀.