OS1: Primitiver Kernel auf Rust für x86

Ich beschloss, einen Artikel und, wenn möglich, eine Reihe von Artikeln zu schreiben, um meine Erfahrungen mit der unabhängigen Forschung sowohl des Bare Bone x86-Geräts als auch der Organisation von Betriebssystemen zu teilen. Im Moment kann mein Hack nicht einmal als Betriebssystem bezeichnet werden - es ist ein kleiner Kernel, der von Multiboot (GRUB) booten, realen und virtuellen Speicher verwalten und im Multitasking-Modus mehrere nutzlose Funktionen auf einem einzelnen Prozessor ausführen kann.


Während der Entwicklung habe ich mir nicht das Ziel gesetzt, ein neues Linux zu schreiben (obwohl ich zugegebenermaßen vor ungefähr 5 Jahren davon geträumt habe) oder jemanden zu beeindrucken, deshalb bitte ich Sie, nicht mehr besonders beeindruckt auszusehen. Was ich wirklich tun wollte, war herauszufinden, wie die i386-Architektur auf der grundlegendsten Ebene funktioniert und wie genau die Betriebssysteme ihre Magie entfalten und den Hype Rust ausgraben.


In meinen Notizen werde ich versuchen, nicht nur die Ausgangstexte (sie sind auf GitLab zu finden) und die bloße Theorie (sie sind auf vielen Ressourcen zu finden) zu teilen, sondern auch den Weg, den ich gegangen bin, um nicht offensichtliche Antworten zu finden. In diesem Artikel werde ich speziell über das Erstellen, Laden und Initialisieren einer Kernel-Datei sprechen.


Mein Ziel ist es, die Informationen in meinem Kopf zu strukturieren und denen zu helfen, die einen ähnlichen Weg gehen. Ich verstehe, dass ähnliche Materialien und Blogs bereits im Netzwerk vorhanden sind, aber um zu meiner aktuellen Situation zu gelangen, musste ich sie lange Zeit zusammen sammeln. Alle Quellen (auf jeden Fall, an die ich mich erinnere) werde ich jetzt teilen.


Literatur und Quellen


Natürlich habe ich das meiste davon aus der exzellenten OSDev- Ressource erhalten, sowohl aus dem Wiki als auch aus dem Forum. Zweitens werde ich Philip Opperman mit seinem Blog nennen - viele Informationen über den Haufen Rost und Eisen.


Einige Punkte werden im Linux-Kernel ausspioniert, Minix ist nicht ohne die Hilfe spezieller Literatur, wie Tanenbaums Buch „ Operating Systems. Design und Implementierung “, Robert Love-Buch„ The Linux Kernel. Beschreibung des Entwicklungsprozesses . “ Schwierige Fragen zur Organisation der x86-Architektur wurden mit dem Handbuch „ Entwicklerhandbuch für Intel 64- und IA-32-Architekturen - Software Band 3 (3A, 3B, 3C und 3D): Systemprogrammierungshandbuch “ gelöst. Zum Verständnis des Formats von Binärdateien sind Layouts Hilfslinien für ld, llvm, nm, nasm, make.
UPD Vielen Dank an CoreTeamTech , der mich an das wunderbare Redox OS-System erinnert hat. Ich bin nicht aus seiner Quelle herausgekommen . Leider ist das offizielle GitLab-System nicht über die russische IP verfügbar, sodass Sie sich GitHub ansehen können.


Ein weiteres Vorwort


Mir ist klar, dass ich in Rust kein guter Programmierer bin. Außerdem ist dies mein erstes Projekt in dieser Sprache (nicht der beste Weg, um mit dem Dating zu beginnen, oder?). Daher scheint Ihnen die Implementierung völlig falsch zu sein - ich möchte im Voraus um Nachsicht für meinen Code bitten und werde gerne Kommentare und Vorschläge abgeben. Wenn mir ein angesehener Leser sagen kann, wo und wie ich weitermachen soll, bin ich auch sehr dankbar. Einige Codefragmente können aus den Tutorials kopiert und leicht modifiziert werden. Ich werde jedoch versuchen, solche Abschnitte so klar wie möglich zu erklären, damit Sie nicht die gleichen Fragen haben, die ich beim Parsen hatte. Ich gebe auch nicht vor, die richtigen Ansätze im Design zu verwenden. Wenn mein Speichermanager Sie dazu bringt, verärgerte Kommentare zu schreiben, verstehe ich warum.


Toolkit


Ich werde also zunächst in die von mir verwendeten Entwicklungswerkzeuge eintauchen. Als Umgebung habe ich einen guten und praktischen VS-Code-Editor mit Plugins für Rust und einen GDB-Debugger ausgewählt. VS-Code ist mit RLS manchmal nicht sehr gut, insbesondere wenn er in einem bestimmten Verzeichnis neu definiert wird. Daher musste ich RLS nach jedem nächtlichen Rust-Update neu installieren.


Rust wurde aus mehreren Gründen gewählt. Erstens seine wachsende Popularität und angenehme Philosophie. Zweitens seine Fähigkeit, mit einem niedrigen Niveau zu arbeiten, aber mit einer geringeren Wahrscheinlichkeit, „sich selbst in den Fuß zu schießen“. Drittens bin ich als Liebhaber von Java und Maven sehr süchtig nach Systemen und Abhängigkeitsmanagement, und Fracht ist bereits in die Toolchain-Sprache integriert. Viertens wollte ich nur etwas Neues, nicht wie C.


Für Low-Level-Code habe ich NASM als Ich bin von der Intel-Syntax überzeugt und arbeite auch gerne mit den Anweisungen. Ich habe Assembler-Inserts in Rust bewusst aufgegeben, um die Arbeit explizit mit Eisen- und High-Level-Logik zu trennen.
Make und der Linker aus dem LLVM LLD-Angebot (als schnellerer und besserer Linker) wurden als allgemeine Montage und Layout verwendet - dies ist Geschmackssache. Es war möglich, Skripte für Fracht zu erstellen.


Qemu wurde zum Starten verwendet - ich mag seine Geschwindigkeit, den interaktiven Modus und die Fähigkeit, GDB zu verknüpfen. Um zu booten und sofort alle Hardware-Informationen zu haben - natürlich GRUB (Legacy ist einfacher, den Header zu organisieren, also nimm ihn).


Verknüpfung und Layout


Seltsamerweise stellte sich für mich heraus, dass es eines der schwierigsten Themen war. Nach langen Versuchen mit x86-Segmentregistern war es äußerst schwierig zu erkennen, dass Segmente und Abschnitte nicht dasselbe sind. Bei der Programmierung für die vorhandene Umgebung müssen Sie nicht darüber nachdenken, wie das Programm im Speicher abgelegt werden soll. Für jede Plattform und jedes Format verfügt der Linker bereits über ein vorgefertigtes Rezept, sodass kein Linker-Skript geschrieben werden muss.


Im Gegensatz dazu muss für blankes Eisen angegeben werden, wie der Programmcode im Speicher abgelegt und adressiert werden soll. Hier möchte ich betonen, dass es sich um eine lineare (virtuelle) Adresse handelt, die den Seitenmechanismus verwendet. OS1 verwendet einen Seitenmechanismus, aber ich werde im entsprechenden Abschnitt des Artikels separat darauf eingehen.


Logisch, linear, virtuell, physisch ...

Logische, lineare, virtuelle, physische Adressen. Ich habe mir bei dieser Frage den Kopf gebrochen, also für die Details, die ich zu diesem ausgezeichneten Artikel ansprechen möchte


Bei Betriebssystemen, die Paging verwenden, verfügt jede Task in einer 32-Bit-Umgebung über 4 GB adressierbaren Speicherplatz, selbst wenn 128 MB RAM installiert sind. Dies geschieht nur aufgrund der Paging-Organisation des Speichers, das Fehlen von Seiten im Hauptspeicher wird entsprechend behandelt.


In der Realität sind Anwendungen jedoch normalerweise mit etwas weniger als 4 GB verfügbar. Dies liegt daran, dass das Betriebssystem Interrupts und Systemaufrufe verarbeiten muss, was bedeutet, dass sich mindestens ihre Handler in diesem Adressraum befinden müssen. Wir stehen vor der Frage: Wo genau in diesen 4 GB sollten die Kerneladressen platziert werden, damit Programme korrekt funktionieren können?


In der modernen Programmwelt wird ein solches Konzept verwendet: Jede Aufgabe glaubt, dass sie über dem Prozessor steht und das einzige laufende Programm auf dem Computer ist (in dieser Phase sprechen wir nicht über die Kommunikation zwischen Prozessen). Wenn Sie sich genau ansehen, wie die Compiler Programme in der Verknüpfungsphase sammeln, stellt sich heraus, dass sie mit einer linearen Adresse von Null oder nahe Null beginnen. Dies bedeutet, dass, wenn das Kernel-Image einen Speicherplatz nahe Null belegt, auf diese Weise zusammengestellte Programme nicht ausgeführt werden können, jeder jmp-Befehl im Programm zum Eintritt in den geschützten Speicher des Kernels und zu einem Schutzfehler führt. Wenn wir in Zukunft nicht nur selbst geschriebene Programme verwenden möchten, ist es daher sinnvoll, der Anwendung so viel Speicher wie möglich nahe Null zu geben und das Kernel-Image höher zu platzieren.


Dieses Konzept heißt Higher-Half-Kernel (hier verweise ich Sie auf osdev.org, wenn Sie verwandte Informationen wünschen). Welche Erinnerung Sie wählen sollten, hängt nur von Ihrem Appetit ab. 512 MB sind genug für jemanden, aber ich habe beschlossen, mir 1 GB zu schnappen, sodass sich mein Kernel auf 3 GB + 1 MB befindet (+ 1 MB werden benötigt, um die niedrigeren und höheren Speichergrenzen einzuhalten, GRUB lädt uns nach 1 MB in den physischen Speicher). .
Es ist für uns auch wichtig, den Einstiegspunkt in unsere ausführbare Datei anzugeben. Für meine ausführbare Datei ist dies die in Assembler geschriebene _loader-Funktion, auf die ich im nächsten Abschnitt näher eingehen werde.


Über den Einstiegspunkt

Wussten Sie, dass Sie Ihr ganzes Leben lang gelogen haben, dass main () der Einstiegspunkt in das Programm ist? Tatsächlich ist main () eine Konvention der C-Sprache und der von ihr erzeugten Sprachen. Wenn Sie herumgraben, stellt sich Folgendes heraus.


Erstens hat jede Plattform ihre eigene Spezifikation und ihren eigenen Einstiegspunktnamen: Für Linux ist es normalerweise _start, für Windows ist mainCRTStartup. Zweitens können diese Punkte neu definiert werden, aber dann funktioniert es nicht, die Freuden von libc zu nutzen. Drittens stellt der Compiler diese Einstiegspunkte standardmäßig bereit und sie befinden sich in den Dateien crt0..crtN (CRT - C RunTime, N - Anzahl der Hauptargumente).


Was machen Compiler wie gcc oder vc? Sie wählen ein plattformspezifisches Link-Skript aus, das einen Standardeinstiegspunkt definiert, wählen die gewünschte Objektdatei mit der vorgefertigten C-Initialisierungsfunktion aus und rufen die Hauptfunktion auf und verknüpfen die Ausgabe in Form einer Datei des gewünschten Formats mit einem Standardeinstiegspunkt.


Für unsere Zwecke sollten daher der Standardeinstiegspunkt und die CRT-Initialisierung deaktiviert werden, da wir absolut nichts als nacktes Eisen haben.


Was müssen Sie noch zum Verknüpfen wissen? Wie werden die Datenabschnitte (.rodata, .data), nicht initialisierten Variablen (.bss, common) lokalisiert und denken Sie auch daran, dass GRUB die Position von Multiboot-Headern in den ersten 8 KB der Binärdatei erfordert.


Jetzt können wir ein Linker-Skript schreiben!


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 = .; } } 

Nach GRUB herunterladen


Wie oben erwähnt, erfordert die Multiboot-Spezifikation, dass sich der Header in den ersten 8 KB des Boot-Images befindet. Die vollständige Spezifikation ist hier zu sehen, aber ich werde nur auf die Details von Interesse eingehen.


  • Die 32-Bit-Ausrichtung (4 Byte) muss eingehalten werden
  • Es muss eine magische Zahl 0x1BADB002 geben
  • Es ist notwendig, dem Multibooter mitzuteilen, welche Informationen wir erhalten möchten und wie die Module platziert werden sollen (in meinem Fall möchte ich, dass das Kernelmodul auf einer 4-KB-Seite ausgerichtet wird und ich eine Speicherkarte bekomme, um mir Zeit und Mühe zu sparen).
  • Geben Sie eine Prüfsumme an (Prüfsumme + magische Zahl + Flags sollten Null ergeben)

 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 

Nach dem Booten garantiert Multiboot einige Bedingungen, die wir berücksichtigen müssen.


  • Das EAX-Register enthält die magische Nummer 0x2BADB002, die besagt, dass der Download erfolgreich war
  • Das EBX-Register enthält die physikalische Adresse der Struktur mit Informationen zu den Ergebnissen des Ladens (wir werden viel später darüber sprechen).
  • Der Prozessor befindet sich im geschützten Modus, der Seitenspeicher ist ausgeschaltet, die Segmentregister und der Stapel befinden sich in einem (für uns) undefinierten Zustand. GRUB hat sie für seine Anforderungen verwendet und muss so schnell wie möglich neu definiert werden.

Das erste, was wir tun müssen, ist das Paging zu aktivieren, den Stapel zu optimieren und schließlich die Kontrolle auf den übergeordneten Rust-Code zu übertragen.
Ich werde nicht im Detail auf die Seitenorganisation von Speicher, Seitenverzeichnis und Seitentabelle eingehen, da darüber ausgezeichnete Artikel geschrieben wurden ( einer von ihnen ). Die Hauptsache, die ich teilen möchte, ist, dass Seiten keine Segmente sind! Bitte wiederholen Sie meinen Fehler nicht und laden Sie die Seitentabellenadresse nicht in GDTR! Für die Seitentabelle ist CR3! Die Seite kann in verschiedenen Architekturen eine unterschiedliche Größe haben. Um die Arbeit zu vereinfachen (um nur eine Seitentabelle zu haben), habe ich aufgrund der Einbeziehung von PSE eine Größe von 4 MB gewählt.


Wir möchten also den virtuellen Seitenspeicher aktivieren. Dazu benötigen wir eine Seitentabelle und ihre physikalische Adresse, die in CR3 geladen sind. Gleichzeitig wurde unsere Binärdatei verknüpft, um in einem virtuellen Adressraum mit einem Versatz von 3 GB zu arbeiten. Dies bedeutet, dass alle variablen Adressen und Beschriftungen einen Versatz von 3 GB haben. Die Seitentabelle ist nur ein Array, in dem die Seitenadresse ihre reale Adresse enthält, die an der Seitengröße ausgerichtet ist, sowie Zugriffs- und Statusflags. Da ich 4 MB Seiten verwende, benötige ich nur eine PD-Seitentabelle mit 1024 Einträgen:


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

Was ist in der Tabelle?


  1. Die allererste Seite sollte zum aktuellen Codeabschnitt führen (0-4 MB physischer Speicher), da alle Adressen im Prozessor physisch sind und die Übersetzung in virtuell noch nicht durchgeführt wurde. Das Fehlen dieses Seitendeskriptors führt zu einem sofortigen Absturz, da der Prozessor nach dem Einschalten der Seiten die nächste Anweisung nicht ausführen kann. Flags: Bit 0 - die Tabelle ist vorhanden, Bit 1 - die Seite ist geschrieben, Bit 7 - Seitengröße 4 MB. Nach dem Einschalten der Seiten wird der Datensatz zurückgesetzt.
  2. Überspringen Sie bis zu 3 GB - Nullen stellen sicher, dass sich die Seite nicht im Speicher befindet
  3. Die 3-GB-Marke ist unser Kern im virtuellen Speicher und verweist auf 0 im physischen Speicher. Nach dem Umblättern werden wir hier arbeiten. Flags ähneln dem ersten Datensatz.
  4. Überspringen Sie bis zu 4 GB.

Also haben wir die Tabelle deklariert und wollen nun ihre physikalische Adresse in CR3 laden. Vergessen Sie nicht den Adressoffset von 3 GB in der Verbindungsphase. Wenn Sie versuchen, die Adresse so zu laden, wie sie ist, werden wir an die tatsächliche Adresse mit 3 GB + variablem Offset gesendet und führen zu einem sofortigen Absturz. Daher nehmen wir die Adresse von BootPageDirectory und subtrahieren 3 GB davon, setzen sie in CR3. Wir schalten die PSE im CR4-Register ein, schalten die Arbeit mit Seiten im CR0-Register ein:


  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 

Bisher läuft alles gut, aber sobald wir die erste Seite zurücksetzen, um endlich in die obere Hälfte von 3 GB zu gelangen, wird alles zusammenbrechen, da das EIP-Register noch eine physikalische Adresse im Bereich des ersten Megabytes hat. Um dies zu beheben, führen wir eine einfache Manipulation durch: Setzen Sie eine Markierung an die nächstgelegene Stelle, laden Sie ihre Adresse (sie hat bereits einen Versatz von 3 GB, denken Sie daran) und machen Sie einen bedingungslosen Sprung durch sie. Danach kann eine unnötige Seite für zukünftige Anwendungen zurückgesetzt werden.


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

Jetzt dreht sich alles um das sehr Kleine: Initialisieren Sie den Stack, übergeben Sie die GRUB-Struktur und Assembler ist genug!


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

Was Sie über diesen Code wissen müssen:


  1. Gemäß der C-Konvention von Aufrufen (gilt auch für Rust) werden Variablen in umgekehrter Reihenfolge über den Stapel an die Funktion übertragen. Alle Variablen werden in x86 um 4 Byte ausgerichtet.
  2. Der Stapel wächst vom Ende an, daher sollte der Zeiger auf den Stapel zum Ende des Stapels führen (fügen Sie der Adresse STACKSIZE hinzu). Die Stapelgröße, die ich nahm, war 16 KB, sollte ausreichen.
  3. Folgendes wird auf den Kernel übertragen: die magische Nummer von Multiboot, die physische Adresse der Bootloader-Struktur (für uns liegt eine wertvolle Speicherkarte), die virtuelle Adresse der Seitentabelle (irgendwo in 3 GB Speicherplatz)

Vergessen Sie auch nicht zu erklären, dass kmain extern und _loader global ist.


Weitere Schritte


In den folgenden Anmerkungen werde ich über das Einrichten von Segmentregistern sprechen, kurz die Ausgabe von Informationen über einen VGA-Puffer durchgehen, Ihnen erklären, wie ich die Arbeit mit Interrupts, Seitenverwaltung und dem süßesten - Multitasking - organisiert habe. Ich werde zum Nachtisch gehen.


Der vollständige Projektcode ist auf GitLab verfügbar .


Vielen Dank für Ihre Aufmerksamkeit!


UPD2: Teil 2
UPD2: Teil 3

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


All Articles