OS1: n√ļcleo primitivo en Rust para x86

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?


  1. 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.
  2. Omita hasta 3 GB: los ceros aseguran que la página no esté en la memoria
  3. 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.
  4. 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:


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

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


All Articles