Decidí escribir un artículo y, si es posible, una serie de artículos para compartir mi experiencia de investigación independiente tanto del dispositivo Bare Bone x86 como de la organización de los sistemas operativos. Por el momento, mi pirateo ni siquiera puede llamarse un sistema operativo: es un kernel pequeño que puede arrancar desde Multiboot (GRUB), administrar memoria real y virtual, y también realizar varias funciones inútiles en modo multitarea en un procesador.
Durante el desarrollo, no me propuse el objetivo de escribir un nuevo Linux (aunque, lo admito, lo soñé hace unos 5 años) o impresionar a alguien, por lo que le pido que no parezca particularmente impresionado. Lo que realmente quería hacer era descubrir cómo funciona la arquitectura i386 en el nivel más básico, y cómo exactamente los sistemas operativos hacen su magia, y desenterrar la exageración Rust.
En mis notas intentaré compartir no solo los textos fuente (se pueden encontrar en GitLab) y la teoría básica (se puede encontrar en muchos recursos), sino también el camino que seguí para encontrar respuestas no obvias. Específicamente, en este artículo hablaré sobre la construcción de un archivo kernel, su carga e inicialización .
Mis objetivos son estructurar la información en mi cabeza, así como ayudar a aquellos que siguen un camino similar. Entiendo que materiales y blogs similares ya están en la red, pero para llegar a mi situación actual, tuve que reunirlos durante mucho tiempo. Todas las fuentes (en cualquier caso, que recuerdo), compartiré ahora mismo.
Literatura y Fuentes
Por supuesto, obtuve la mayor parte del excelente recurso OSDev , tanto de la wiki como del foro. En segundo lugar, nombraré a Philip Opperman con su blog , mucha información sobre el montón de óxido y hierro.
Algunos puntos se espían en el kernel de Linux, Minix no carece de la ayuda de literatura especial, como el libro de Tanenbaum " Sistemas operativos " . Diseño e implementación " , libro de Robert Love" The Linux Kernel. Descripción del proceso de desarrollo ". Las preguntas difíciles sobre la organización de la arquitectura x86 se resolvieron utilizando el manual “ Intel 64 y el Manual del desarrollador de software de arquitecturas IA-32, volumen 3 (3A, 3B, 3C y 3D): Guía de programación del sistema ”. En la comprensión del formato de los binarios, los diseños son guías para ld, llvm, nm, nasm, make.
UPD Gracias a CoreTeamTech por recordarme el maravilloso sistema Redox OS. No salí de su fuente . Desafortunadamente, el sistema oficial de GitLab no está disponible desde IP de Rusia, por lo que puede ver GitHub .
Otro prefacio
Me doy cuenta de que no soy un buen programador en Rust, además, este es mi primer proyecto en este idioma (no es la mejor manera de empezar a salir, ¿verdad?). Por lo tanto, la implementación puede parecerle completamente incorrecta: de antemano quiero pedir clemencia a mi código y me complacerá comentarlo y hacer sugerencias. Si un lector respetado puede decirme dónde y cómo seguir adelante, también estaré muy agradecido. Algunos fragmentos de código pueden copiarse de los tutoriales tal como están y modificarse ligeramente, pero intentaré dar explicaciones tan claras como sea posible a dichas secciones para que no tenga las mismas preguntas que tenía al analizarlas. Tampoco pretendo utilizar los enfoques correctos en el diseño, por lo que si mi administrador de memoria te hace querer escribir comentarios enojados, entiendo por qué.
Kit de herramientas
Entonces, comenzaré sumergiéndome en las herramientas de desarrollo que utilicé. Como entorno, elegí un editor VS Code bueno y conveniente con complementos para Rust y un depurador GDB. VS Code a veces no es muy bueno con RLS, especialmente al redefinirlo en un directorio específico, por lo que después de cada actualización nocturna de Rust tuve que reinstalar RLS.
Rust fue elegido por varias razones. En primer lugar, su creciente popularidad y filosofía agradable. En segundo lugar, su capacidad para trabajar con un nivel bajo pero con una probabilidad menor de "dispararse en el pie". En tercer lugar, como amante de Java y Maven, soy muy adicto a construir sistemas y gestión de dependencias, y la carga ya está integrada en el lenguaje de la cadena de herramientas. Cuarto, solo quería algo nuevo, no como C.
Para el código de bajo nivel, tomé NASM, como Me siento confiado en la sintaxis de Intel, y también me siento cómodo trabajando con sus directivas. Abandoné deliberadamente los insertos de ensamblador en Rust para separar explícitamente el trabajo con hierro y lógica de alto nivel.
Make y el enlazador del suministro LLVM LLD (como un enlazador más rápido y mejor) se usaron como un ensamblaje general y diseño, esto es cuestión de gustos. Fue posible hacer con scripts de construcción para carga.
Qemu se usó para lanzar: me gusta su velocidad, modo interactivo y la capacidad de conectar GDB. Para arrancar e inmediatamente tener toda la información del hardware, por supuesto GRUB (Legacy es más fácil de organizar el encabezado, así que tómalo).
Vinculación y diseño
Por extraño que parezca, para mí resultó ser uno de los temas más difíciles. Después de largas pruebas con registros de segmentos x86, fue extremadamente difícil darse cuenta de que los segmentos y las secciones no son lo mismo. En la programación para el entorno existente, no es necesario pensar en cómo colocar el programa en la memoria: para cada plataforma y formato, el enlazador ya tiene una receta preparada, por lo que no es necesario escribir un script de enlazador.
Para el hierro desnudo, por el contrario, es necesario indicar cómo colocar y direccionar el código del programa en la memoria. Aquí quiero enfatizar que estamos hablando de una dirección lineal (virtual) usando el mecanismo de página. OS1 usa un mecanismo de página, pero me detendré en él por separado en la sección correspondiente del artículo.
Lógico, lineal, virtual, físico ...Direcciones lógicas, lineales, virtuales, físicas. Me rompí la cabeza con esta pregunta, así que para los detalles que quiero abordar en este excelente artículo
Para los sistemas operativos que utilizan paginación, en un entorno de 32 bits, cada tarea tiene 4 GB de espacio de direcciones de memoria, incluso si tiene 128 MB de RAM instalada. Esto sucede solo debido a la organización de la memoria de paginación; la ausencia de páginas en la memoria principal se maneja en consecuencia.
Sin embargo, en realidad, las aplicaciones suelen estar disponibles un poco menos de 4 GB. Esto se debe a que el sistema operativo debe manejar las interrupciones, las llamadas al sistema, lo que significa que al menos sus manejadores deben estar en este espacio de direcciones. Nos enfrentamos a la pregunta: ¿dónde exactamente en estos 4 GB se deben colocar las direcciones del núcleo para que los programas puedan funcionar correctamente?
En el mundo moderno de los programas, se utiliza dicho concepto: cada tarea cree que reina sobre el procesador y es el único programa que se ejecuta en la computadora (en este momento no estamos hablando de comunicación entre procesos). Si observa exactamente cómo los compiladores recopilan programas en la etapa de vinculación, resulta que comienzan con una dirección lineal de cero o casi cero. Esto significa que si la imagen del kernel ocupa un espacio de memoria cercano a cero, los programas ensamblados de esta manera no se pueden ejecutar, cualquier instrucción jmp en el programa conducirá a la entrada en la memoria protegida del kernel y a un error de protección. Por lo tanto, si queremos utilizar no solo programas autoescritos en el futuro, es razonable darle a la aplicación tanta memoria como sea posible cerca de cero, y colocar la imagen del núcleo más arriba.
Este concepto se llama Kernel de mitad superior (aquí me remito a osdev.org, si desea información relacionada). Qué recuerdo elegir solo depende de tus apetitos. 512 MB es suficiente para alguien, pero decidí obtener 1 GB, por lo que mi kernel se encuentra en 3 GB + 1 MB (se necesita + 1 MB para cumplir con los límites de memoria más bajos, GRUB nos carga en la memoria física después de 1 MB) .
También es importante para nosotros especificar el punto de entrada a nuestro archivo ejecutable. Para mi ejecutable, esta será la función _loader escrita en ensamblador, en la que me detendré con más detalle en la siguiente sección.
Sobre el punto de entrada¿Sabía que ha estado mintiendo toda su vida sobre el hecho de que main () es el punto de entrada al programa? De hecho, main () es una convención del lenguaje C y los lenguajes generados por él. Si cavas, resulta algo como lo siguiente.
En primer lugar, cada plataforma tiene su propia especificación y nombre de punto de entrada: para Linux generalmente es _start, para Windows es mainCRTStartup. En segundo lugar, estos puntos se pueden redefinir, pero no funcionará usar las delicias de libc. En tercer lugar, el compilador proporciona estos puntos de entrada de forma predeterminada y están en los archivos crt0..crtN (CRT - C RunTime, N - número de argumentos principales).
En realidad, qué hacen los compiladores como gcc o vc: seleccionan un script de enlace específico de la plataforma que define un punto de entrada estándar, seleccionan el archivo de objeto deseado con la función de inicialización de C preparada y llaman a la función principal y se vinculan a la salida en forma de un archivo del formato deseado con un punto de entrada estándar.
Entonces, para nuestros propósitos, el punto de entrada estándar y la inicialización de CRT deben estar desactivados, ya que no tenemos absolutamente nada más que hierro desnudo.
¿Qué más necesitas saber para vincular? ¿Cómo se ubicarán las secciones de datos (.rodata, .data), las variables no inicializadas (.bss, común), y también recuerde que GRUB requiere la ubicación de encabezados de arranque múltiple en los primeros 8 KB del binario.
¡Así que ahora podemos escribir un script vinculador!
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 = .; } }
Descargar después de GRUB
Como se mencionó anteriormente, la especificación de arranque múltiple requiere que el encabezado esté en los primeros 8 KB de la imagen de arranque. La especificación completa se puede ver aquí , pero me detendré solo en los detalles de interés.
- Se debe respetar la alineación de 32 bits (4 bytes)
- Debe haber un número mágico 0x1BADB002
- Es necesario decirle al multibooter qué información queremos obtener y cómo colocar los módulos (en mi caso, quiero que el módulo del núcleo esté alineado con una página de 4 KB, y también obtener una tarjeta de memoria para ahorrar tiempo y esfuerzo)
- Proporcione una suma de verificación (suma de verificación + número mágico + banderas debería dar cero)
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
Después del arranque, Multiboot garantiza algunas condiciones que debemos tener en cuenta.
- El registro EAX contiene el número mágico 0x2BADB002, que dice que la descarga fue exitosa
- El registro EBX contiene la dirección física de la estructura con información sobre los resultados de la carga (hablaremos de eso mucho más tarde)
- El procesador está en modo protegido, la memoria de página está apagada, los registros de segmento y la pila están en un estado indefinido (para nosotros), GRUB los usó para sus necesidades y necesita redefinirse lo antes posible.
Lo primero que debemos hacer es habilitar la paginación, ajustar la pila y finalmente transferir el control al código Rust de alto nivel.
No me detendré en detalle en la organización de la página de la memoria, el Directorio de páginas y la Tabla de páginas, porque se han escrito excelentes artículos sobre esto ( uno de ellos ). ¡Lo principal que quiero compartir es que las páginas no son segmentos! ¡No repita mi error y no cargue la dirección de la tabla de páginas en GDTR! Para la tabla de páginas es CR3! La página puede tener un tamaño diferente en diferentes arquitecturas, por simplicidad de trabajo (para tener solo una tabla de páginas), elegí un tamaño de 4 MB debido a la inclusión de PSE.
Entonces, queremos habilitar la memoria de página virtual. Para hacer esto, necesitamos una tabla de páginas, y su dirección física, cargada en CR3. Al mismo tiempo, nuestro archivo binario estaba vinculado al trabajo en un espacio de direcciones virtual con un desplazamiento de 3 GB. Esto significa que todas las direcciones y etiquetas variables tienen un desplazamiento de 3 GB. La tabla de páginas es solo una matriz en la que la dirección de la página contiene su dirección real, alineada con el tamaño de la página, así como los indicadores de acceso y estado. Como uso páginas de 4 MB, solo necesito una tabla de páginas PD con 1024 entradas:
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é hay en la mesa?
- La primera página debe conducir a la sección actual de código (0-4 MB de memoria física), ya que todas las direcciones en el procesador son físicas y la traducción a virtual aún no se realiza. La ausencia de este descriptor de página dará lugar a un bloqueo inmediato, ya que el procesador no podrá tomar la siguiente instrucción después de pasar las páginas. Banderas: bit 0 - la tabla está presente, bit 1 - la página está escrita, bit 7 - tamaño de página 4 MB. Después de pasar las páginas, el registro se restablece.
- Omita hasta 3 GB: los ceros aseguran que la página no esté en la memoria
- La marca de 3 GB es nuestro núcleo en la memoria virtual, haciendo referencia a 0 en la memoria física. Después de pasar las páginas, trabajaremos aquí. Las banderas son similares al primer registro.
- Salta hasta 4 GB.
Entonces, declaramos la tabla y ahora queremos cargar su dirección física en CR3. No se olvide del desplazamiento de la dirección de 3 GB en la etapa de enlace. Intentar cargar la dirección tal como está nos enviará a la dirección real de 3 GB + desplazamiento variable y dará lugar a un bloqueo inmediato. Por lo tanto, tomamos la dirección de BootPageDirectory y le restamos 3 GB, lo ponemos en CR3. Activamos el PSE en el registro CR4, activamos el trabajo con páginas en el registro 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
Hasta ahora, todo va bien, pero tan pronto como restablezcamos la primera página para finalmente pasar a la mitad superior de 3 GB, todo colapsará, ya que el registro EIP todavía tiene una dirección física en la región del primer megabyte. Para solucionar esto, realizamos una manipulación simple: coloque una marca en el lugar más cercano, cargue su dirección (ya está con un desplazamiento de 3 GB, recuérdelo) y realice un salto incondicional a través de ella. Después de eso, se puede restablecer una página innecesaria para futuras aplicaciones.
lea ecx, [StartInHigherHalf] jmp ecx StartInHigherHalf: mov dword [BootPageDirectory], 0 invlpg [0]
Ahora se trata de lo muy pequeño: inicializar la pila, pasar la estructura GRUB y el ensamblador es suficiente.
mov esp, stack+STACKSIZE push eax push ebx lea ecx, [BootPageDirectory] push ecx call kmain hlt section .bss align 32 stack: resb STACKSIZE
Lo que necesita saber sobre este código:
- De acuerdo con la convención C de llamadas (también es aplicable a Rust), las variables se transfieren a la función a través de la pila en el orden inverso. Todas las variables están alineadas por 4 bytes en x86.
- La pila crece desde el final, por lo que el puntero a la pila debe conducir al final de la pila (agregue STACKSIZE a la dirección). El tamaño de la pila que tomé fue de 16 KB, debería ser suficiente.
- Lo siguiente se transfiere al kernel: el número mágico de arranque múltiple, la dirección física de la estructura del cargador de arranque (hay una valiosa tarjeta de memoria para nosotros), la dirección virtual de la tabla de páginas (en algún lugar en el espacio de 3 GB)
Además, no olvide declarar que kmain es externo y _loader es global.
Pasos adicionales
En las siguientes notas, hablaré sobre la configuración de registros de segmentos, repasaré brevemente la salida de información a través de un búfer VGA, te diré cómo organicé el trabajo con interrupciones, administración de páginas y lo más dulce es la multitarea: lo dejaré para el postre.
El código completo del proyecto está disponible en GitLab .
Gracias por su atencion!
UPD2: Parte 2
UPD2: Parte 3