OS1: noyau primitif sur Rust pour x86

J'ai décidé d'écrire un article, et si possible, puis une série d'articles pour partager mon expérience de recherche indépendante à la fois sur le dispositif Bare Bone x86 et sur l'organisation des systèmes d'exploitation. Pour le moment, mon hack ne peut même pas être appelé un système d'exploitation - c'est un petit noyau qui peut démarrer à partir de Multiboot (GRUB), gérer la mémoire réelle et virtuelle, et également effectuer plusieurs fonctions inutiles en mode multitâche sur un seul processeur.


Pendant le développement, je ne me suis pas fixé pour objectif d'écrire un nouveau Linux (même si, je l'avoue, j'en ai rêvé il y a environ 5 ans) ou d'impressionner quelqu'un, je vous demande donc de ne plus avoir l'air particulièrement impressionné. Ce que je voulais vraiment faire était de comprendre comment l'architecture i386 fonctionnait au niveau le plus élémentaire, et comment les systèmes d'exploitation faisaient exactement leur magie, et de déterrer le battage publicitaire Rust.


Dans mes notes, je vais essayer de partager non seulement les textes sources (ils peuvent être trouvés sur GitLab) et la théorie nue (on peut la trouver sur de nombreuses ressources), mais aussi le chemin que je suis allé pour trouver des réponses non évidentes. Plus précisément, dans cet article, je parlerai de la création d'un fichier noyau, de son chargement et de son initialisation .


Mes objectifs sont de structurer les informations dans ma tête, ainsi que d'aider ceux qui suivent un chemin similaire. Je comprends que des matériaux et des blogs similaires existent déjà sur le réseau, mais pour arriver à ma situation actuelle, j'ai dû les rassembler longtemps. Toutes les sources (en tout cas, dont je me souviens), je vais les partager dès maintenant.


Littérature et sources


Bien sûr, je l'ai en grande partie tirée de l'excellente ressource OSDev , à la fois sur le wiki et sur le forum. Deuxièmement, je nommerai Philip Opperman avec son blog - beaucoup d'informations sur le tas de rouille et de fer.


Certains points sont espionnés dans le noyau Linux, Minix n’est pas sans l’aide de la littérature spéciale, comme le livre de Tanenbaum « Operating Systems». Conception et implémentation » , livre de Robert Love« Le noyau Linux. Description du processus de développement . " Des questions difficiles sur l'organisation de l'architecture x86 ont été résolues à l'aide du manuel « Intel 64 et IA-32 Architectures Software Developer's Manual Volume 3 (3A, 3B, 3C & 3D): System Programming Guide ». Dans la compréhension du format des binaires, les dispositions sont des guides pour ld, llvm, nm, nasm, make.
UPD Merci à CoreTeamTech de m'avoir rappelé le merveilleux système Redox OS. Je ne suis pas sorti de sa source . Malheureusement, le système GitLab officiel n'est pas disponible sur IP russe, vous pouvez donc consulter GitHub .


Une autre préface


Je me rends compte que je ne suis pas un bon programmeur à Rust, d'ailleurs, c'est mon premier projet dans cette langue (pas la meilleure façon de commencer à sortir ensemble, non?). Par conséquent, la mise en œuvre peut vous sembler complètement incorrecte - à l'avance, je veux demander la clémence à mon code et je serai heureux de commenter et suggestions. Si un lecteur respecté peut me dire où et comment aller de l'avant, je lui en serai également très reconnaissant. Certains fragments de code peuvent être copiés des didacticiels tels quels et légèrement modifiés, mais j'essaierai de donner des explications aussi claires que possible à ces sections afin que vous n'ayez pas les mêmes questions que celles que j'ai eues lors de leur analyse. Je ne prétends pas non plus utiliser les bonnes approches dans la conception, donc si mon gestionnaire de mémoire vous donne envie d'écrire des commentaires en colère, je comprends pourquoi.


Boîte à outils


Je vais donc commencer par plonger dans les outils de développement que j'ai utilisés. En tant qu'environnement, j'ai choisi un bon éditeur VS Code pratique avec des plugins pour Rust et un débogueur GDB. VS Code n'est parfois pas très bon avec RLS, surtout quand il est redéfini dans un répertoire spécifique, donc après chaque mise à jour nocturne de Rust, j'ai dû réinstaller RLS.


La rouille a été choisie pour plusieurs raisons. Tout d'abord, sa popularité croissante et sa philosophie agréable. Deuxièmement, sa capacité à travailler avec un faible niveau mais avec une probabilité plus faible de «se tirer une balle dans le pied». Troisièmement, en tant qu'amoureux de Java et de Maven, je suis très accro à la création de systèmes et à la gestion des dépendances, et le cargo est déjà intégré au langage de la chaîne d'outils. Quatrièmement, je voulais juste quelque chose de nouveau, pas comme C.


Pour le code de bas niveau, j'ai pris NASM, comme Je me sens confiant dans la syntaxe Intel et je suis également à l'aise de travailler avec ses directives. J'ai délibérément abandonné les inserts d'assembleur dans Rust afin de séparer explicitement le travail avec le fer et la logique de haut niveau.
La marque et l'éditeur de liens de l'approvisionnement LLVM LLD (en tant qu'éditeur de liens plus rapide et meilleur) ont été utilisés comme assemblage général et mise en page - c'est une question de goût. Il était possible de faire avec des scripts de construction pour le fret.


Qemu a été utilisé pour le lancement - j'aime sa vitesse, son mode interactif et sa capacité à accrocher GDB. Pour démarrer et avoir immédiatement toutes les informations sur le matériel - bien sûr GRUB (Legacy est plus facile à organiser l'en-tête, alors prenez-le).


Liaison et mise en page


Curieusement, pour moi, cela s'est avéré être l'un des sujets les plus difficiles. Il était extrêmement difficile de réaliser après de longs essais avec des registres de segments x86 que les segments et les sections ne sont pas la même chose. Dans la programmation pour l'environnement existant, il n'est pas nécessaire de réfléchir à la façon de placer le programme en mémoire - pour chaque plate-forme et format, l'éditeur de liens a déjà une recette prête à l'emploi, il n'est donc pas nécessaire d'écrire un script de l'éditeur de liens.


Pour le fer nu, au contraire, il est nécessaire d'indiquer comment placer et adresser le code du programme en mémoire. Ici, je tiens à souligner que nous parlons d'une adresse linéaire (virtuelle) utilisant le mécanisme de page. OS1 utilise un mécanisme de page, mais je m'y attarderai séparément dans la section correspondante de l'article.


Logique, linéaire, virtuel, physique ...

Adresses physiques, logiques, linéaires, virtuelles. Je me suis cassé la tête sur cette question, donc pour les détails je veux adresser à cet excellent article


Pour les systèmes d'exploitation qui utilisent la pagination, dans un environnement 32 bits, chaque tâche dispose de 4 Go d'espace d'adressage mémoire, même si 128 Mo de RAM sont installés. Cela se produit uniquement en raison de l'organisation de la pagination de la mémoire; l'absence de pages dans la mémoire principale est gérée en conséquence.


Cependant, en réalité, les applications sont généralement disponibles un peu moins de 4 Go. En effet, le système d'exploitation doit gérer les interruptions, les appels système, ce qui signifie qu'au moins leurs gestionnaires doivent se trouver dans cet espace d'adressage. Nous sommes confrontés à la question: où exactement dans ces 4 Go les adresses du noyau doivent-elles être placées pour que les programmes puissent fonctionner correctement?


Dans le monde moderne des programmes, un tel concept est utilisé: chaque tâche croit qu'elle règne en maître sur le processeur et est le seul programme en cours d'exécution sur l'ordinateur (à ce stade, nous ne parlons pas de communication entre les processus). Si vous regardez exactement comment les compilateurs collectent les programmes à l'étape de liaison, il s'avère qu'ils commencent par une adresse linéaire de zéro ou proche de zéro. Cela signifie que si l'image du noyau occupe un espace mémoire proche de zéro, les programmes ainsi assemblés ne peuvent pas être exécutés, toute instruction jmp du programme entraînera l'entrée dans la mémoire protégée du noyau et une erreur de protection. Par conséquent, si nous voulons utiliser non seulement des programmes auto-écrits à l'avenir, il est raisonnable de donner à l'application autant de mémoire que possible près de zéro et de placer l'image du noyau plus haut.


Ce concept est appelé demi-noyau supérieur (ici, je vous renvoie à osdev.org, si vous voulez des informations connexes). La mémoire à choisir ne dépend que de votre appétit. 512 Mo est suffisant pour quelqu'un, mais j'ai décidé de me procurer 1 Go, donc mon noyau est situé à 3 Go + 1 Mo (+ 1 Mo est nécessaire pour se conformer aux limites de mémoire inférieures et supérieures, GRUB nous charge dans la mémoire physique après 1 Mo) .
Il est également important pour nous de spécifier le point d'entrée de notre fichier exécutable. Pour mon exécutable, ce sera la fonction _loader écrite en assembleur, sur laquelle je m'attarderai plus en détail dans la section suivante.


À propos du point d'entrée

Saviez-vous que vous avez menti toute votre vie sur le fait que main () est le point d'entrée du programme? En fait, main () est une convention du langage C et des langages qu'il génère. Si vous creusez, il s'avère quelque chose comme ce qui suit.


Tout d'abord, chaque plate-forme a ses propres spécifications et nom de point d'entrée: pour Linux, il s'agit généralement de _start, pour Windows, il s'agit de mainCRTStartup. Deuxièmement, ces points peuvent être redéfinis, mais cela ne fonctionnera pas pour utiliser les délices de libc. Troisièmement, le compilateur fournit ces points d'entrée par défaut et ils se trouvent dans les fichiers crt0..crtN (CRT - C RunTime, N - nombre d'arguments principaux).


En fait, que font les compilateurs comme gcc ou vc - ils sélectionnent un script de lien spécifique à la plate-forme qui définit un point d'entrée standard, sélectionnent le fichier objet souhaité avec la fonction d'initialisation d'initialisation C prête à l'emploi et appellent la fonction principale et le lien vers la sortie sous la forme d'un fichier du format souhaité avec un point d'entrée standard.


Donc, pour nos besoins, le point d'entrée standard et l'initialisation CRT doivent être désactivés, car nous n'avons rien d'autre que du fer nu.


Que devez-vous savoir d'autre pour créer un lien? Comment seront localisées les sections de données (.rodata, .data), les variables non initialisées (.bss, communes), et rappelez-vous également que GRUB nécessite l'emplacement des en-têtes de démarrage multiple dans les 8 premiers Ko du binaire.


Alors maintenant, nous pouvons écrire un script de l'éditeur de liens!


ENTRY(_loader) OUTPUT_FORMAT(elf32-i386) SECTIONS { . = 0xC0100000; .text ALIGN(4K) : AT(ADDR(.text) - 0xC0000000) { *(.multiboot1) *(.multiboot2) *(.text) } .rodata ALIGN(4K) : AT(ADDR(.rodata) - 0xC0000000) { *(.rodata*) } .data ALIGN (4K) : AT(ADDR(.data) - 0xC0000000) { *(.data) } .bss : AT(ADDR(.bss) - 0xC0000000) { _sbss = .; *(COMMON) *(.bss) _ebss = .; } } 

Télécharger après GRUB


Comme mentionné ci-dessus, la spécification Multiboot nécessite que l'en-tête se trouve dans les 8 premiers Ko de l'image de démarrage. La spécification complète peut être consultée ici , mais je m'attarderai uniquement sur les détails d'intérêt.


  • L'alignement sur 32 bits (4 octets) doit être respecté
  • Il doit y avoir un nombre magique 0x1BADB002
  • Il est nécessaire de dire au multibooter quelles informations nous voulons obtenir et comment placer les modules (dans mon cas, je veux que le module du noyau soit aligné sur une page de 4 Ko, et également obtenir une carte mémoire pour gagner du temps et des efforts)
  • Fournir une somme de contrôle (somme de contrôle + nombre magique + drapeaux devrait donner zéro)

 MB1_MODULEALIGN equ 1<<0 MB1_MEMINFO equ 1<<1 MB1_FLAGS equ MB1_MODULEALIGN | MB1_MEMINFO MB1_MAGIC equ 0x1BADB002 MB1_CHECKSUM equ -(MB1_MAGIC + MB1_FLAGS) section .multiboot1 align 4 dd MB1_MAGIC dd MB1_FLAGS dd MB1_CHECKSUM 

Après le démarrage, Multiboot garantit certaines conditions que nous devons considérer.


  • Le registre EAX contient le nombre magique 0x2BADB002, qui indique que le téléchargement a réussi
  • Le registre EBX contient l'adresse physique de la structure avec des informations sur les résultats du chargement (nous en reparlerons beaucoup plus tard)
  • Le processeur est en mode protégé, la mémoire des pages est désactivée, les registres de segments et la pile sont dans un état indéfini (pour nous), GRUB les a utilisés pour ses besoins et doit être redéfini dès que possible.

La première chose que nous devons faire est d'activer la pagination, de régler la pile et enfin de transférer le contrôle au code Rust de haut niveau.
Je ne m'attarderai pas en détail sur l'organisation des pages de la mémoire, Page Directory et Page Table, car d'excellents articles ont été écrits à ce sujet (l' un d'eux ). La principale chose que je veux partager est que les pages ne sont pas des segments! Veuillez ne pas répéter mon erreur et ne chargez pas l'adresse de la table des pages dans GDTR! Car le tableau des pages est CR3! La page peut avoir une taille différente dans différentes architectures, pour plus de simplicité de travail (pour n'avoir qu'une seule table de page), j'ai choisi une taille de 4 Mo en raison de l'inclusion de PSE.


Donc, nous voulons activer la mémoire de page virtuelle. Pour ce faire, nous avons besoin d'une table de pages et de son adresse physique, chargée dans CR3. Dans le même temps, notre fichier binaire a été lié pour fonctionner dans un espace d'adressage virtuel avec un décalage de 3 Go. Cela signifie que toutes les adresses et étiquettes variables ont un décalage de 3 Go. Le tableau des pages n'est qu'un tableau dans lequel l'adresse de la page contient sa véritable adresse, alignée sur la taille de la page, ainsi que des indicateurs d'accès et d'état. Étant donné que j'utilise des pages de 4 Mo, je n'ai besoin que d'une table de pages PD avec 1024 entrées:


 section .data align 0x1000 BootPageDirectory: dd 0x00000083 times (KERNEL_PAGE_NUMBER - 1) dd 0 dd 0x00000083 times (1024 - KERNEL_PAGE_NUMBER - 1) dd 0 

Qu'y a-t-il dans le tableau?


  1. La toute première page devrait conduire à la section actuelle du code (0-4 Mo de mémoire physique), car toutes les adresses du processeur sont physiques et la traduction en virtuel n'est pas encore effectuée. L'absence de ce descripteur de page entraînera un plantage immédiat, car le processeur ne pourra pas prendre l'instruction suivante après avoir activé les pages. Indicateurs: bit 0 - la table est présente, bit 1 - la page est écrite, bit 7 - taille de page 4 Mo. Après avoir activé les pages, l'enregistrement est réinitialisé.
  2. Sautez jusqu'à 3 Go - les zéros garantissent que la page n'est pas en mémoire
  3. La marque de 3 Go est notre cœur de mémoire virtuelle, référençant 0 dans la mémoire physique. Après avoir tourné les pages, nous allons travailler ici. Les drapeaux sont similaires au premier enregistrement.
  4. Sautez jusqu'à 4 Go.

Donc, nous avons déclaré la table et maintenant nous voulons charger son adresse physique dans CR3. N'oubliez pas le décalage d'adresse de 3 Go lors de la liaison. Tenter de charger l'adresse telle qu'elle nous enverra à la vraie adresse de 3 Go + décalage variable et entraînera un crash immédiat. Par conséquent, nous prenons l'adresse de BootPageDirectory et en soustrayons 3 Go, nous la mettons dans CR3. Nous activons le PSE dans le registre CR4, activons le travail avec les pages dans le registre CR0:


  mov ecx, (BootPageDirectory - KERNEL_VIRTUAL_BASE) mov cr3, ecx mov ecx, cr4 or ecx, 0x00000010 mov cr4, ecx mov ecx, cr0 or ecx, 0x80000000 mov cr0, ecx 

Jusqu'à présent, tout se passe bien, mais dès que nous réinitialisons la première page pour enfin passer à la moitié supérieure de 3 Go, tout s'effondrera, car le registre EIP a toujours une adresse physique dans la région du premier mégaoctet. Pour résoudre ce problème, nous effectuons une manipulation simple: mettez une marque à l'endroit le plus proche, chargez son adresse (elle est déjà avec un décalage de 3 Go, rappelez-vous cela) et faites un saut inconditionnel à travers elle. Après cela, une page inutile peut être réinitialisée pour de futures applications.


  lea ecx, [StartInHigherHalf] jmp ecx StartInHigherHalf: mov dword [BootPageDirectory], 0 invlpg [0] 

Maintenant, tout est question de très petit: initialisez la pile, passez la structure GRUB et l'assembleur suffit!


  mov esp, stack+STACKSIZE push eax push ebx lea ecx, [BootPageDirectory] push ecx call kmain hlt section .bss align 32 stack: resb STACKSIZE 

Ce que vous devez savoir sur ce morceau de code:


  1. Selon la convention C des appels (elle est également applicable à Rust), les variables sont transférées à la fonction via la pile dans l'ordre inverse. Toutes les variables sont alignées sur 4 octets en x86.
  2. La pile se développe à partir de la fin, donc le pointeur vers la pile doit conduire à la fin de la pile (ajoutez STACKSIZE à l'adresse). La taille de la pile que j'ai prise était de 16 Ko, devrait suffire.
  3. Les éléments suivants sont transférés au noyau: le nombre magique de Multiboot, l'adresse physique de la structure du chargeur de démarrage (il y a une précieuse carte mémoire pour nous), l'adresse virtuelle de la table des pages (quelque part dans 3 Go d'espace)

N'oubliez pas non plus de déclarer que kmain est externe et que _loader est global.


Etapes supplémentaires


Dans les notes suivantes, je vais parler de la configuration des registres de segments, passer brièvement en revue la sortie des informations via un tampon VGA, vous expliquer comment j'ai organisé le travail avec les interruptions, la gestion des pages et la chose la plus douce - le multitâche - je partirai pour le dessert.


Le code de projet complet est disponible sur GitLab .


Merci de votre attention!


UPD2: Partie 2
UPD2: Partie 3

Source: https://habr.com/ru/post/fr445506/


All Articles