1 puntos por GN⁺ 2024-07-16 | 1 comentarios | Compartir por WhatsApp
  • Se construye paso a paso el flujo mínimo de un bootloader que comienza en un sector de arranque de 512 bytes y lleva una CPU x86_64 desde el real mode de 16 bits hasta el long mode de 64 bits
  • Primero se verifica la salida mediante interrupciones de BIOS y luego se conectan nasm, ld, objcopy y QEMU para comprobar que la imagen de arranque realmente se ejecute
  • Debido al límite de tamaño del sector de arranque, se divide en stage 1/stage 2, y antes de entrar en protected mode se lee desde el disco el siguiente código con int 0x13 de BIOS
  • A partir del protected mode de 32 bits ya no se pueden usar rutinas de BIOS, por lo que se requiere inicialización de hardware como GDT, flat segmentation y salida directa al buffer VGA
  • Entrar en long mode de 64 bits requiere ajustar correctamente las tablas de páginas, PAE, EFER.LME, cr0.PG y la GDT para 64 bits; después se puede llamar código C freestanding como si fuera un kernel

Entorno de 16 bits que comienza en BIOS

  • Después del reset, una CPU x86 está en real mode, y el tamaño predeterminado de los operandos es de 16 bits
  • El real mode crea un espacio de direcciones de 20 bits mediante segmentation y puede manejar hasta 1 MB de memoria
  • El primer código que ejecuta el BIOS está en el sector de arranque del disco
    • El BIOS busca un disco cuyo primer sector termine con el número mágico 0xaa55
    • Carga ese sector en la dirección de memoria 0x7c00
  • El espacio que entrega el BIOS es de solo 512 bytes, así que este código se centra en actuar como bootstrap para cargar el resto del bootloader
  • Las rutinas de BIOS solo pueden usarse mientras se permanece en real mode

Requisitos y entorno de compilación

Crear el sector de arranque y verificar la ejecución

  • El primer sector de arranque imprime "Hello, world!" usando rutinas de BIOS y luego se detiene con hlt y un bucle
  • Para imprimir la cadena se usa int 0x10, la llamada a los video services de BIOS, con ah = 0x0e
  • El Makefile genera un objeto con nasm, lo enlaza con un linker script y luego crea una imagen de arranque raw con objcopy -O binary
  • make boot ejecuta la imagen con QEMU
    • qemu-system-x86_64 -no-reboot -drive file=$<,format=raw,index=0,media=disk
  • El linker script configura el sector de arranque para que quede ubicado con base en 0x7c00
    • El origin de boot_sector en MEMORY es 0x7c00 y su length es 512
    • La sección .bootsign agrega 0x55, 0xaa en la posición 0x7c00 + 510
  • Aunque también se podrían manejar directamente el offset y el número mágico dentro del assembly del sector de arranque, aquí ese rol lo cumple el linker script

Stage 1 lee stage 2 desde el disco

  • Stage 1 es el código del sector de arranque que carga el BIOS, y su objetivo es traer stage 2 a memoria
  • Stage 2 contiene el código que pasa del real mode de 16 bits al protected mode de 32 bits
  • Después de entrar en protected mode ya no se pueden usar rutinas de BIOS, así que la lectura de sectores del disco debe terminarse antes de la transición
  • Para el acceso al disco se usa int 0x13, los disk services de BIOS
    • ah = 0x42 es la función de extended read de BIOS
    • dl = 0x80 es el número de unidad
    • El disk address packet contiene la cantidad de sectores a leer, la dirección de destino y el sector inicial
  • El ejemplo lee 64 sectores con READ_SECTORS_NUM equ 64
    • Como el sector de arranque es el sector 0, stage 2 se lee desde el sector 1
    • La dirección de destino es BOOT_LOAD_ADDR + SECTOR_SIZE, es decir, 0x7c00 + 512
  • En el código queda un manejo temporal que acepta el caso en que se lean menos sectores de los solicitados
  • Al comienzo, stage 2 copia print_string para real mode, imprime "Hello from stage 2" y confirma que el salto desde stage 1 continúa correctamente

Transición a protected mode de 32 bits

  • Para entrar en protected mode, primero se debe definir la Global Descriptor Table(GDT)
  • En protected mode, por defecto, segmentation se usa para la protección de memoria
  • En long mode de 64 bits se necesita paging, pero en el paso anterior, la entrada a protected mode, primero se requiere configurar segmentation
  • La GDT del ejemplo sigue el flat model del manual de Intel
    • Incluye un code segment y un data segment
    • Ambos segments se mapean a todo el linear address space
    • Como es una etapa intermedia hacia long mode, se usa el modelo más simple
  • La GDT es una estructura contigua ubicada en memoria
    • Al inicio hay un null descriptor para capturar traducciones inválidas
    • Después le siguen el code segment descriptor y el data segment descriptor
  • La transición sigue este orden
    • Se deshabilitan las interrupciones con cli
    • Se cargan la dirección y la longitud de la GDT en GDTR con lgdt [gdt32_pseudo_descriptor]
    • Se activa protected mode estableciendo cr0.PE, es decir, el bit 0 de cr0
    • Se vacía el instruction pipeline con un far jump y se actualiza cs al nuevo code segment
  • Tras entrar en protected mode, los valores de segment existentes ya no tienen significado, por lo que ds, ss, es, fs y gs se configuran con el nuevo data segment selector
  • Para volver a habilitar interrupciones después de toda la configuración se requiere trabajo adicional

Imprimir en pantalla sin BIOS

  • En protected mode ya no se pueden llamar rutinas de BIOS
  • La salida de texto pasa a escribirse directamente en el VGA buffer
  • print_string32 escribe caracteres y bytes de color en la dirección 0xb8000
    • El valor de color es 0xf
    • Cada celda de carácter usa 2 bytes
  • Esta función de salida es muy simple, por lo que el mensaje siempre aparece en la esquina superior izquierda de la pantalla

Tablas de páginas para entrar en long mode

  • El IA-32e mode de la documentación de Intel corresponde al long mode del manual de AMD64
  • Para cambiar a long mode, la CPU debe estar en protected mode y paging también debe estar activado
  • Para los conceptos de paging se consultan Introduction to Paging y OSTEP
  • En long mode con PAE activado se usan tablas de páginas de 4 niveles
  • build_page_table crea una tabla de páginas de 4 niveles en la dirección indicada
    • El tamaño de page es 0x1000
    • El tamaño de cada page table es 0x1000
    • La cantidad de entries es 512
    • Primero inicializa cuatro tables en cero, dejando todos los entries en estado not present
    • Conecta los primeros entries de PML4 → PDP → PD → page table
    • En la capa más baja de page table configura 512 entries

GDT para 64 bits y secuencia de transición a long mode

  • Aunque paging se encarga del virtual address space y la gestión de permisos, en long mode también se necesita la GDT
  • La GDT para 64 bits también sigue el flat model y es casi igual a la GDT para protected mode
  • La diferencia está en la configuración de bits relacionados con long mode
    • Se establece el 64-bit code segment flag del code segment
    • Cuando este flag está activado, el default operation size bit debe ser 0
  • La transición a long mode sigue este flujo
    • Se crea una page table de 4 niveles en la dirección 0x1000
    • Se coloca la dirección de la PML4 table en cr3
    • Se activa PAE estableciendo el bit 5 de cr4
    • Se lee el MSR 0xc0000080 y se establece EFER.LME, es decir, el bit 8
    • Se activa paging estableciendo el flag PG, el bit 31 de cr0
    • Se carga la GDT para 64 bits con lgdt
    • Se entra en modo de 64 bits con un far jump al 64-bit code segment
  • Justo después de activar paging, el estado es IA-32e compatibility mode; al saltar a una GDT con el 64-bit segment flag configurado, se cambia a modo de 64 bits
  • El mensaje de confirmación de éxito se muestra en la esquina superior izquierda de la pantalla mediante el VGA buffer

Llamar código C freestanding

  • Una vez que se llega al long mode de 64 bits, se puede llamar código C freestanding
  • kernel.c borra el VGA buffer 0xb8000 e imprime "Hello from C"
  • En el lado de assembly, start_long_mode declara extern _start_kernel después de la salida de texto para 64 bits y llama a _start_kernel
  • El linker script divide el área de memoria en tres partes
    • boot_sector: 0x7c00, length 512
    • stage2: 0x7e00, length 512
    • kernel: 0x8000, length 0x10000
  • Las secciones .text, .data, .rodata y .bss se ubican en el área de kernel
  • El Makefile se modifica para compilar tanto assembly como C
    • El compilador C es gcc
    • Las CFLAGS principales son -std=c99 -ffreestanding -m64 -mno-red-zone -fno-builtin -nostdinc -Wall -Wextra
  • El código completo del ejemplo se ofrece mediante un enlace de descarga

1 comentarios

 
GN⁺ 2024-07-16
Opiniones de Hacker News
  • Se puede entrar directamente en modo largo con mucho menos código, sin pasar por el modo protegido: https://wiki.osdev.org/Entering_Long_Mode_Directly
    Había un bootloader para un pequeño kernel de 64 bits hecho de esta forma y, aun incluyendo el código para leer el kernel desde el disco y configurar el modo VESA, cabía holgadamente en el sector de arranque. Ni siquiera hacía falta un cargador de segunda etapa.

    • Me pregunto cómo meten todo eso en 512 bytes. No creo que haya un sistema de archivos real que permita dejar el kernel en cualquier parte del disco como un archivo normal; con solo manejar la fragmentación de archivos ya se pasaría de 512 bytes por mucho.
    • Si simplemente usas https://limine-bootloader.org/, se vuelve mucho más sencillo. No hace falta tocar el modo real, tampoco en SMP; carga automáticamente el kernel con mapeo higher-half y también funciona en aarch64 y riscv64.
    • Es cierto, pero si quieres incluir también la tabla de particiones y soportar controladores AHCI modernos y SATA, queda aún menos espacio para el bootloader, así que hace falta optimizar. En ese caso no puedes usar los 510 bytes completos para el cargador, sino bastante menos; y si además tienes que rellenar entradas de partición válidas, tampoco puedes usar bytes dentro de la tabla, lo que lo vuelve más complicado.
      Si vas a usar discos duros modernos reales, conviene mirar GPT en lugar de MBR. Permite superar los límites de la tabla de particiones y manejar discos grandes de más de 2 TB. UEFI elimina estos problemas y permite usar un diseño de disco correcto sin demasiada dificultad.
      Para entrar en modo de 64 bits no hace falta el modo protegido. Pero es mejor no usar BIOS. Es sucio y solo complica más el trabajo.
      Es mejor usar UEFI con EDK2 o GnuEFI; ambos son bastante fáciles y cómodos de implementar. Toma un poco acostumbrarse a los conceptos iniciales de UEFI, pero viendo proyectos de ejemplo en GitHub se entiende fácilmente la estructura. En EDK los archivos .dec y .inf y demás son medio desagradables, y en GnuEFI hay que leer los headers para encontrar las funciones, pero aun así es mucho mejor que las interfaces de BIOS con especificación ambigua. En hardware real ni siquiera puedes asumir que int 0x10, int 0x15, etc. existan correctamente.
      En sistemas UEFI puedes asumir una base mínima estable y enumerar de forma razonable las funciones de hardware o de la plataforma. Además, como UEFI ya deja la plataforma bastante configurada, no hace falta inicializar mucho en el componente cargador del SO: puedes pasar directo a cargar los componentes adecuados para el diseño del SO, los drivers y el kernel. Basta con obtener el mapa de memoria y acceder al sistema de archivos EFI para leer lo necesario.
    • No sabía que esto fuera posible. Si el objetivo es simplemente llegar a modo largo, me pregunto por qué habría que pasar por el modo protegido en primer lugar.
  • El 80286 tiene una palabra de estado de máquina, Machine Status Word (MSW), que es un registro de 16 bits, y el 80386 la amplió al registro de 32 bits CR0. Más tarde, el modo largo de 64 bits agregó el MSR EFER y extendió CR0 a 64 bits, pero incluso hoy en CR0 solo se usan 11 bits, y EFER solo tiene 8 bits activos.
    Me pregunto por qué Intel/AMD eligieron dos veces una opción nueva en vez de usar simplemente los bits sobrantes de los registros existentes: https://wiki.osdev.org/CPU_Registers_x86-64#CR0

    • Probablemente sea para reforzar más la compatibilidad hacia atrás. El software podría asumir valores para los bits reservados o escribir valores ahí. La asignación de bits en este tipo de registros de hardware es bastante arbitraria, y usar bits altos tampoco tiene mucho costo.
    • Si tuviera que responder con una sola palabra, probablemente sería burocracia. Las organizaciones grandes, en general, no son especialmente buenas tomando buenas decisiones, y aparecen muchas elecciones sin sentido.
      Que CR1 y CR5~CR7 sigan reservados mientras existe CR8 parece ir por la misma línea.
  • Lo que parece más innecesariamente complejo en este artículo son el Makefile y el script de linker. NASM soporta salida binaria plana, pero parece que lo consideraron demasiado “hacky”.

    • Personalmente, creo que un script de linker es mucho más legible y fácil de razonar que NASM plano, especialmente cuando hay varios archivos fuente.
    • Totalmente de acuerdo. Más adelante, el Makefile y el script de linker se vuelven un dolor de cabeza importante, pero si vas a crear un binario plano, simplemente crea un binario plano. No hace falta inflarlo.
      Antes, en mi SO tenía un archivo llamado make.sh para burlarme de esto. Ahora uso cosas sofisticadas como “formatos de archivo”, así que -fbin y --oformat=binary son recuerdos efímeros. Durante mucho tiempo intenté separar archivos C de datos y archivos C de código, volcarlos como binarios y luego ensamblar ese monstruo, pero el enlazado y la carga se volvieron demasiado difíciles. Es mejor usar ELF o PE, y en la práctica parece que esos formatos justamente hacen ese trabajo.
  • Se ve genial y como un buen ejercicio, pero no sé si sea útil. Me pregunto si siquiera tiene una UX tipo juguete Fisher-Price para revisar o cambiar la configuración mientras se ejecuta.
    El arranque es el proceso de pasar de modo mínimo, modo de usuario único o modo de recuperación a un estado completamente operativo.
    He usado Unix junto con productos de Microsoft desde la época de Xenix/DOS, creo que desde hace unos 40 años. Me pregunto cuánto se ha avanzado en todo ese tiempo.
    También usé Linux desde la versión sueca, es decir, desde el primer lanzamiento, y también probé GNU 0.1.
    Pido disculpas por haber llamado Unix a Xenix. Xenix fue, desde poco después de su lanzamiento hasta su decadencia, un producto desastroso de tipo me-too que ya quería pertenecer al pasado.
    Microsoft no parece una empresa que lance productos, sino una que vacía la caja de arena del gato encima de sus clientes. Ejemplos recientes son Copilot y 22H2.
    Si vemos cómo han evolucionado los autos de F1, los lápices y las calculadoras de bolsillo, me pregunto qué tan cerca estamos de un ideal utilizable.
    También me pregunto por qué el bootloader no es un modo de kernel estático. Antes lo era, y hace poco alguien propuso que debería volver a serlo; yo estuve de acuerdo.

  • https://wiki.osdev.org/A20_Line

  • Me sorprende que todos los pasos necesarios para poner la CPU en el modo correcto parezcan innecesarios. La mayoría parecen procedimientos necesarios por compatibilidad hacia atrás
    Me pregunto si Intel no pudo haber ofrecido desde el principio una bandera o instrucción para arrancar en el modo correcto, o si no pudo haber eliminado la compatibilidad hacia atrás
    Recuerdo que ARM64 también tenía algunos problemas similares. Me pregunto si existe alguna CPU diseñada desde el inicio para 64 bits, que no necesite compatibilidad hacia atrás y entre por defecto en el estado deseado. Me pregunto si ese era el objetivo o el diseño de Itanium

    • X86S, propuesto por Intel, tiene ese propósito

      X86S is a legacy-reduced-OS ISA that removes outdated execution modes and operating system ISA.
      The presence of the X86S ISA is enumerated by a single, main CPUID feature LEGACY_REDUCED_ISA in CPUID 7.1.ECX[2] which implies all the ISA removals described in this document. A new, 64-bit “start-up” interprocessor interrupt (SIPI) has a separate CPUID feature flag.
      [0] https://cdrdv2.intel.com/v1/dl/getContent/776648 [cuidado: PDF]

    • Intel intentó eso con el 80376, pero no salió bien: https://en.wikipedia.org/wiki/Intel_80376
      Lo mismo pasó con Itanium, es decir, Itanic
      La compatibilidad hacia atrás es precisamente la razón clave para elegir x86 en lugar de ARM, MIPS, RISC-V, etc. Lamentablemente, parece que algunas personas en Intel y AMD no lo entienden bien
    • UEFI ya existe. Si pones un binario similar a los de Windows en una carpeta de la partición, se ejecuta en un entorno hospedado en modo de 64 bits. Por supuesto, también hay innumerables bootloaders que se encargan de hacer esto por ti
    • No sé cuál sería el problema en arm64
  • Es un proyecto excelente. Si los defensores de UEFI aquí se preguntan por qué alguien crearía otro método de bootloader, creo que se están perdiendo la razón por la que la gente hace este tipo de cosas
    Tal como escribió el autor al final, “si llegaste hasta aquí, genial”: realmente es genial

  • Me pregunto cuánto tiempo ha pasado desde que salió UEFI. Qué lástima; ojalá hubieran descartado también BIOS junto con el modo largo

    • BIOS ya está deprecado. En las motherboards nuevas, esa funcionalidad básicamente se emula mediante UEFI y ya no se está ampliando
      Que esté deprecado no significa que haya sido eliminado, sino que ya no se actualiza ni se desarrolla, con el objetivo de retirarlo
  • Me pregunto si este procedimiento de arranque también funciona en EFI/UEFI. Si funciona, también me pregunto si el supervisor de UEFI emula las transiciones entre modo real, modo protegido y modo largo, o si se realizan en el hardware real

    • No. El firmware UEFI proporciona al bootloader UEFI un entorno totalmente distinto del entorno BIOS legacy, es decir, del modo de direcciones reales. El firmware UEFI de los sistemas modernos entra directamente en modo largo de 64 bits, y también configura una GDT con modelo de memoria plano y paginación con mapeo identidad
      Escribí aquí el proceso para crear un bootloader UEFI para un OS de hobby: https://0xc0ffee.netlify.app/osdev/05-bootloader-p1.html
  • Me pregunto si esto es más simple en ARM

    • Es simple en el sentido de que cada fabricante de placas hace lo que quiere. Es simple para el fabricante de la placa, pero se vuelve horriblemente complejo para todos los demás
    • Sí. El bootloader sigue siendo complejo, pero requiere menos configuración legacy. Dicho eso, si apuntas a UEFI en lugar de BIOS, también se vuelve mucho más simple en x86
    • No estoy seguro, y no me haría muchas ilusiones. Ahora estoy profundizando en RISC-V, y ahí parece haber esperanza