Arranque del kernel de Linux. Parte 1

Del gestor de arranque al kernel

Si lees los artículos anteriores, conoces mi nuevo pasatiempo para la programación de bajo nivel. Escribí varios artículos sobre programación de ensambladores para x86_64 Linux y al mismo tiempo comencé a sumergirme en el código fuente del kernel de Linux.

Estoy muy interesado en comprender cómo funcionan las cosas de bajo nivel: cómo se ejecutan los programas en mi computadora, cómo se ubican en la memoria, cómo el núcleo gestiona los procesos y la memoria, cómo funciona la pila de red en un nivel bajo y mucho más. Entonces, decidí escribir otra serie de artículos sobre el kernel de Linux para la arquitectura x86_64 .

Tenga en cuenta que no soy un desarrollador de kernel profesional y no escribo código de kernel en el trabajo. Esto es solo un pasatiempo. Simplemente me gustan las cosas de bajo nivel y es interesante profundizar en ellas. Por lo tanto, si nota alguna confusión o aparecen preguntas / comentarios, contácteme en Twitter , por correo o simplemente cree un ticket . Estaría agradecido

Todos los artículos se publican en el repositorio de GitHub , y si algo está mal con mi inglés o el contenido del artículo, no dude en enviar una solicitud de extracción.

Tenga en cuenta que esto no es documentación oficial, sino simplemente capacitación e intercambio de conocimientos.

Conocimiento requerido

  • Entendiendo el Código C
  • Comprender el código del ensamblador (sintaxis de AT&T)

En cualquier caso, si recién está comenzando a aprender tales herramientas, intentaré explicar algo en este y en los artículos posteriores. Bien, con la introducción terminada, es hora de sumergirse en el kernel de Linux y las cosas de bajo nivel.

Comencé a escribir este libro en los días del núcleo Linux 3.18, y mucho ha cambiado desde entonces. Si hay cambios, actualizaré los artículos en consecuencia.

Botón de encendido mágico, ¿qué sigue?


Aunque estos son artículos sobre el kernel de Linux, aún no lo hemos alcanzado, al menos en esta sección. Tan pronto como presiona el botón de encendido mágico en su computadora portátil o de escritorio, comienza a funcionar. La placa base envía una señal a la fuente de alimentación . Después de recibir la señal, proporciona a la computadora la cantidad necesaria de electricidad. Tan pronto como la placa base recibe una señal de "Power OK" , intenta iniciar la CPU. Vuelca todos los datos restantes en sus registros y establece valores predefinidos para cada uno de ellos.

Los procesadores 80386 y versiones posteriores deben tener los siguientes valores en los registros de la CPU después de un reinicio:

  IP 0xfff0
 Selector CS 0xf000
 CS base 0xffff0000 

El procesador comienza a funcionar en modo real . Volvamos un poco e intentemos entender la segmentación de la memoria en este modo. El modo real es compatible con todos los procesadores compatibles con x86: desde 8086 hasta los modernos procesadores Intel de 64 bits. El procesador 8086 utiliza un bus de direcciones de 20 bits, es decir, puede funcionar con un espacio de direcciones de 0-0xFFFFF o 1 . Pero solo tiene registros de 16 bits con una dirección máxima de 2^16-1 o 0xffff (64 kilobytes).

La segmentación de memoria es necesaria para usar todo el espacio de direcciones disponible. Toda la memoria se divide en pequeños segmentos de un tamaño fijo de 65536 bytes (64 KB). Dado que con los registros de 16 bits no podemos acceder a la memoria por encima de 64 KB, se desarrolló un método alternativo.

La dirección consta de dos partes: 1) un selector de segmento con una dirección base; 2) desplazamiento de la dirección base. En modo real, la dirección base del * 16 segmento * 16 . Por lo tanto, para obtener la dirección física en la memoria, debe multiplicar parte del selector de segmento por 16 y agregarle el desplazamiento:

   =   * 16 +  

Por ejemplo, si el registro CS:IP tiene el valor 0x2000:0x0010 , la dirección física correspondiente será así:

 >>> hex((0x2000 << 4) + 0x0010) '0x20010' 

Pero si toma el selector del segmento más grande y el desplazamiento 0xffff:0xffff , obtiene la dirección:

 >>> hex((0xffff << 4) + 0xffff) '0x10ffef' 

es decir, 65520 bytes después del primer megabyte. Como solo un megabyte está disponible en modo real, 0x10ffef convierte en 0x00ffef con la línea A20 desactivada.

Bueno, ahora sabemos un poco sobre el modo real y el direccionamiento de memoria en este modo. Volvamos a la discusión de los valores de registro después del reinicio.

El registro CS consta de dos partes: un selector de segmento visible y una dirección base oculta. Aunque la dirección base generalmente se forma multiplicando el valor del selector de segmento por 16, durante un reinicio de hardware, el selector de segmento en el registro CS es 0xf000 , y la dirección base es 0xffff0000 . El procesador usa esta dirección base especial hasta que cambie el CS.

La dirección inicial se forma agregando la dirección base al valor en el registro EIP:

 >>> 0xffff0000 + 0xfff0 '0xfffffff0' 

Obtenemos 0xfffffff0 , que es 16 bytes por debajo de 4 GB. Este punto se llama un vector de reinicio . Esta es la ubicación en la memoria donde la CPU espera a que se ejecute la primera instrucción después de un reinicio: una operación de salto ( jmp ), que generalmente indica el punto de entrada del BIOS. Por ejemplo, si observa el código fuente de coreboot ( src/cpu/x86/16bit/reset16.inc ), veremos:

  .section ".reset", "ax", %progbits .code16 .globl _start _start: .byte 0xe9 .int _start16bit - ( . + 2 ) ... 

Aquí vemos el código de operación ( opcode ) jmp , concretamente 0xe9 , y la dirección de destino _start16bit - ( . + 2) .

También vemos que la sección de reset tiene 16 bytes, y se compila para ejecutarse desde la dirección 0xfffff0 ( src/cpu/x86/16bit/reset16.ld ):

 SECTIONS { /* Trigger an error if I have an unuseable start address */ _bogus = ASSERT(_start16bit >= 0xffff0000, "_start16bit too low. Please report."); _ROMTOP = 0xfffffff0; . = _ROMTOP; .reset . : { *(.reset); . = 15; BYTE(0x00); } } 

El BIOS ahora se inicia; Después de inicializar y verificar el hardware del BIOS, debe encontrar el dispositivo de arranque. El orden de arranque se guarda en la configuración del BIOS. Al intentar arrancar desde el disco duro, el BIOS intenta encontrar el sector de arranque. En los discos particionados MBR , el sector de arranque se almacena en los primeros 446 bytes del primer sector, donde cada sector es de 512 bytes. Los dos últimos bytes del primer sector son 0x55 y 0xaa . Le muestran al BIOS que es un dispositivo de arranque.

Por ejemplo:

 ; ; :       Intel x86 ; [BITS 16] boot: mov al, '!' mov ah, 0x0e mov bh, 0x00 mov bl, 0x07 int 0x10 jmp $ times 510-($-$$) db 0 db 0x55 db 0xaa 

Recopilamos y ejecutamos:

nasm -f bin boot.nasm && qemu-system-x86_64 boot

QEMU recibe un comando para usar el binario de boot que acabamos de crear como imagen de disco. Dado que el archivo binario generado anteriormente satisface los requisitos del sector de arranque (comenzando en 0x7c00 y terminando con una secuencia mágica), QEMU considerará el binario como el registro de arranque maestro (MBR) de la imagen del disco.

Verás



En este ejemplo, vemos que el código se ejecuta en modo real de 16 bits y comienza en la dirección 0x7c00 en la memoria. Después de comenzar, provoca una interrupción de 0x10 , ¡que simplemente imprime un personaje ! ; llena los 510 bytes restantes con ceros y termina con dos bytes mágicos 0xaa y 0x55 .

Puede ver el volcado binario con la utilidad objdump :

nasm -f bin boot.nasm
objdump -D -b binary -mi386 -Maddr16,data16,intel boot


Por supuesto, en el sector de arranque real, hay un código para continuar el proceso de arranque y una tabla de partición en lugar de un montón de ceros y un signo de exclamación :). Desde este momento, el BIOS transfiere el control al gestor de arranque.

Nota : como se explicó anteriormente, la CPU está en modo real; donde el cálculo de la dirección física en la memoria es el siguiente:

   =   * 16 +  

Tenemos solo registros de propósito general de 16 bits, y el valor máximo del registro de 16 bits es 0xffff , por lo que en los valores más grandes el resultado será:

 >>> hex((0xffff * 16) + 0xffff) '0x10ffef' 

donde 0x10ffef es 1 + 64 - 16 . El procesador 8086 (el primer procesador con modo real) tiene una línea de dirección de 20 bits. Como 2^20 = 1048576 , la memoria disponible real es de 1 MB.

En general, el direccionamiento de memoria en modo real es el siguiente:

  0x00000000 - 0x000003FF - tabla de vectores de interrupción del modo real
 0x00000400 - 0x000004FF - Área de datos del BIOS
 0x00000500 - 0x00007BFF - no utilizado
 0x00007C00 - 0x00007DFF - nuestro gestor de arranque
 0x00007E00 - 0x0009FFFF - no utilizado
 0x000A0000 - 0x000BFFFF - RAM de video (VRAM) 
 0x000B0000 - 0x000B7777 - memoria de video monocroma
 0x000B8000 - 0x000BFFFF - memoria de video en modo color
 0x000C0000 - 0x000C7FFF - BIOS de video ROM
 0x000C8000 - 0x000EFFFF - área de sombra (BIOS Shadow)
 0x000F0000 - 0x000FFFFF - BIOS del sistema 

Al comienzo del artículo, está escrito que la primera instrucción para el procesador se encuentra en 0xFFFFFFF0 , que es mucho más que 0xFFFFF (1 MB). ¿Cómo puede la CPU acceder a esta dirección en modo real? Responda en la documentación de coreboot :

0xFFFE_0000 - 0xFFFF_FFFF: 128 ROM

Al comienzo de la ejecución, el BIOS no está en RAM, sino en ROM.

Cargador de arranque


El kernel de Linux se puede cargar con diferentes cargadores de arranque, como GRUB 2 y syslinux . El kernel tiene un protocolo de arranque que define los requisitos del cargador de arranque para implementar el soporte de Linux. En este ejemplo, estamos trabajando con GRUB 2.

Continuando con el proceso de arranque, el BIOS seleccionó el dispositivo de arranque y transfirió el control al sector de arranque, la ejecución comienza con boot.img . Debido a su tamaño limitado, este es un código muy simple. Contiene un puntero para ir a la imagen principal de GRUB 2. Comienza con diskboot.img y generalmente se almacena inmediatamente después del primer sector en el espacio no utilizado antes de la primera partición. El código anterior carga en la memoria el resto de la imagen que contiene el núcleo GRUB 2 y los controladores para procesar los sistemas de archivos. Después de eso, se ejecuta la función grub_main .

La función grub_main inicializa la consola, devuelve la dirección base de los módulos, establece el dispositivo raíz, carga / analiza el archivo de configuración de grub, carga los módulos, etc. Al final de la ejecución, pone grub en modo normal. La función grub_normal_execute (del archivo fuente grub-core/normal/main.c ) completa los últimos preparativos y muestra un menú para elegir el sistema operativo. Cuando seleccionamos uno de los elementos del menú grub, se grub_menu_execute_entry función grub_menu_execute_entry , que ejecuta el comando de boot grub y carga el sistema operativo seleccionado.

Como se indica en el protocolo de arranque del núcleo, el gestor de arranque debe leer y completar algunos campos del encabezado de instalación del núcleo, que comienza en el desplazamiento 0x01f1 del código de instalación del núcleo. Este desplazamiento se indica en el script del vinculador . El encabezado del núcleo arch / x86 / boot / header.S comienza con:

  .globl hdr hdr: setup_sects: .byte 0 root_flags: .word ROOT_RDONLY syssize: .long 0 ram_size: .word 0 vid_mode: .word SVGA_MODE root_dev: .word 0 boot_flag: .word 0xAA55 

El gestor de arranque debe llenar este y otros encabezados (que están marcados solo como write tipo en el protocolo de arranque de Linux, como en este ejemplo) con valores que se reciben desde la línea de comandos o se calculan en el momento del arranque. Ahora no nos detendremos en las descripciones y explicaciones para todos los campos de encabezado. Más adelante discutiremos cómo los usa el núcleo. Para obtener una descripción de todos los campos, consulte el protocolo de descarga .

Como puede ver en el protocolo de arranque del núcleo, la memoria se mostrará de la siguiente manera:

  El |  Modo Kernel protegido |
 100000 + ------------------------ +
          El |  Mapeo de E / S |
 0A0000 + ------------------------ +
          El |  Reserva  para BIOS |  Deja todo lo posible libre
          ~ ~
          El |  Línea de comando |  (también puede estar por debajo de X + 10000)
 X + 10000 + ------------------------ +
          El |  Pila / Montón |  Para usar código de modo de núcleo real
 X + 08000 + ------------------------ +
          El |  Instalación de Kernel |  Código de modo real del kernel
          El |  Sector de arranque del núcleo |  Sector de arranque de kernel heredado
        X + ------------------------ +
          El |  Cargador |  <- Punto de entrada 0x7C00 sector de arranque
 001000 + ------------------------ +
          El |  Reserva  para MBR / BIOS |
 000800 + ------------------------ +
          El |  Usualmente uso  MBR |
 000600 + ------------------------ +
          El |  Usado  Solo BIOS |
 000000 + ------------------------ +

Entonces, cuando el cargador transfiere el control al núcleo, comienza con la dirección:

 X + sizeof (KernelBootSector) + 1 

donde X es la dirección del sector de arranque del kernel. En nuestro caso, X es 0x10000 , como se ve en el volcado de memoria:



El gestor de arranque movió el kernel de Linux a la memoria, rellenó los campos de encabezado y luego se movió a la dirección de memoria correspondiente. Ahora podemos ir directamente al código de instalación del núcleo.

Comienzo de la fase de instalación del núcleo.


¡Finalmente estamos en el centro! Aunque técnicamente aún no se está ejecutando. Primero, la parte de instalación del kernel necesita configurar algo, incluido un descompresor y algunas cosas con la administración de memoria. Después de todo esto, ella desempaquetará el núcleo real e irá a él. La instalación comienza en arch / x86 / boot / header.S con el carácter _start .

A primera vista, esto puede parecer un poco extraño, ya que hay varias instrucciones en frente de él. Pero hace mucho tiempo, el kernel de Linux tenía su propio gestor de arranque. Ahora si corres, por ejemplo,

qemu-system-x86_64 vmlinuz-3.18-generic

verás:



En realidad, el archivo de header.S comienza con el número mágico MZ (vea la captura de pantalla del volcado anterior), el texto del mensaje de error y el encabezado PE :

 #ifdef CONFIG_EFI_STUB # "MZ", MS-DOS header .byte 0x4d .byte 0x5a #endif ... ... ... pe_header: .ascii "PE" .word 0 

Es necesario cargar un sistema operativo con soporte UEFI . Consideraremos su dispositivo en los siguientes capítulos.

Punto de entrada real para instalar el núcleo:

 // header.S line 292 .globl _start _start: 

El gestor de arranque (grub2 y otros) conoce este punto (desplazamiento 0x200 de MZ ) y va directamente a él, aunque el header.S comienza desde la sección .bstext , donde se encuentra el texto del mensaje de error:

 // // arch/x86/boot/setup.ld // . = 0; // current position .bstext : { *(.bstext) } // put .bstext section to position 0 .bsdata : { *(.bsdata) } 

Punto de entrada de instalación del núcleo:

  .globl _start _start: .byte 0xeb .byte start_of_setup-1f 1: // // rest of the header // 

Aquí vemos el código de operación jmp ( 0xeb ), que va al punto start_of_setup-1f . En la notación Nf , por ejemplo, 2f refiere a la etiqueta local 2: En nuestro caso, esta es la etiqueta 1 , que está presente inmediatamente después de la transición, y contiene el resto del encabezado de configuración. Inmediatamente después del encabezado de instalación, vemos la sección .entrytext , que comienza con la etiqueta start_of_setup .

Este es el primer código realmente ejecutado (aparte de las instrucciones de salto anteriores, por supuesto). Después de que parte de la instalación del núcleo recibe el control del cargador, la primera instrucción jmp se encuentra en el desplazamiento 0x200 desde el comienzo del modo de núcleo real, es decir, después de los primeros 512 bytes. Esto se puede ver tanto en el protocolo de arranque del kernel de Linux como en el código fuente de grub2:

 segment = grub_linux_real_target >> 4; state.gs = state.fs = state.es = state.ds = state.ss = segment; state.cs = segment + 0x20; 

En nuestro caso, el kernel arranca en la dirección 0x10000 . Esto significa que después de comenzar la instalación del núcleo, los registros de segmento tendrán los siguientes valores:

gs = fs = es = ds = ss = 0x10000
cs = 0x10200


Después de ir a start_of_setup núcleo debe hacer lo siguiente:

  • Asegúrese de que todos los valores de registro de segmento sean iguales
  • Si es necesario, configure la pila correcta
  • Configurar bss
  • Vaya al código C en arch / x86 / boot / main.c

Veamos cómo se implementa esto.

Alineación de mayúsculas y minúsculas


En primer lugar, el núcleo verifica que los registros del segmento ds y es apuntan a la misma dirección. Luego borra la bandera de dirección usando la cld :

  movw %ds, %ax movw %ax, %es cld 

Como escribí anteriormente, grub2 por defecto carga el código de instalación del núcleo en 0x10000 y cs en 0x10200 , porque la ejecución no comienza desde el principio del archivo, sino desde la transición aquí:

 _start: .byte 0xeb .byte start_of_setup-1f 

Este es un desplazamiento de 512 bytes de 4d 5a . También es necesario alinear cs de 0x10200 a 0x10000 , como todos los demás registros de segmento. Después de eso, instale la pila:

  pushw %ds pushw $6f lretw 

Esta instrucción empuja el valor ds a la pila, seguido de la dirección de la etiqueta 6 y la instrucción lretw , que carga la dirección de la etiqueta 6 en el registro del contador de comandos y carga cs con el valor ds . Después de eso, ds y cs tendrán los mismos valores.

Configuración de pila


Casi todo este código es parte del proceso de preparación del entorno C en modo real. El siguiente paso es verificar el valor del registro ss y crear la pila correcta si el valor ss es incorrecto:

  movw %ss, %dx cmpw %ax, %dx movw %sp, %dx je 2f 

Esto puede desencadenar tres escenarios diferentes:

  • ss valor válido de 0x1000 (como con todos los demás registros excepto cs )
  • ss valor no válido y se establece el indicador CAN_USE_HEAP (ver más abajo)
  • ss valor no válido y el indicador CAN_USE_HEAP no CAN_USE_HEAP configurado (ver más abajo)

Considere todos los escenarios en orden:

  • ss valor válido ( 0x1000 ). En este caso, vamos a la etiqueta 2:

 2: andw $~3, %dx jnz 3f movw $0xfffc, %dx 3: movw %ax, %ss movzwl %dx, %esp sti 

Aquí establecemos la alineación del registro dx (que contiene el valor sp indicado por el gestor de arranque) en 4 bytes y verificamos si hay cero. Si es cero, entonces ponemos el valor 0xfffc dx (dirección alineada de 4 bytes antes del tamaño máximo de segmento de 64 KB). Si no es igual a cero, entonces continuamos usando el valor sp especificado por el gestor de arranque ( 0xf7f4 en nuestro caso). Luego colocamos el valor del 0x1000 en ss , que guarda la dirección de segmento correcta 0x1000 y establece el sp correcto. Ahora tenemos la pila correcta:



  • En el segundo escenario, ss != ds . Primero colocamos el valor _end (la dirección del final del código de instalación) en dx y verificamos los loadflags campo de loadflags , usando la instrucción testb para verificar si se puede usar el montón. loadflags es un encabezado de máscara de bits que se define de la siguiente manera:

 #define LOADED_HIGH (1<<0) #define QUIET_FLAG (1<<5) #define KEEP_SEGMENTS (1<<6) #define CAN_USE_HEAP (1<<7) 

y como se indica en el protocolo de arranque:

: loadflags

.

7 (): CAN_USE_HEAP
1, ,
heap_end_ptr . ,
.


Si se establece el bit CAN_USE_HEAP , en dx establecemos el valor heap_end_ptr (que apunta a _end ) y le agregamos STACK_SIZE (el tamaño mínimo de la pila es 1024 bytes). Después de eso, vaya a la etiqueta 2 (como en el caso anterior) y haga la pila correcta.



  • Si CAN_USE_HEAP no CAN_USE_HEAP configurado, solo use la pila mínima de _end a _end + STACK_SIZE :



Configuración de BSS


Se necesitan dos pasos más antes de pasar al código C principal: esto es configurar el área BSS y verificar la firma "mágica". Verificación de firma primero:

  cmpl $0x5a5aaa55, setup_sig jne setup_bad 

La instrucción simplemente compara setup_sig con el número mágico 0x5a5aaa55. Si no son iguales, se informa un error fatal.

Si el número mágico coincide y tenemos un conjunto de registros de segmento correctos y una pila, todo lo que queda es configurar la sección BSS antes de pasar al código C.

La sección BSS se utiliza para almacenar datos no inicializados asignados estáticamente. Linux comprueba cuidadosamente que esta área de memoria se restablece:

  movw $__bss_start, %di movw $_end+3, %cx xorl %eax, %eax subw %di, %cx shrw $2, %cx rep; stosl 

Primero, la dirección de inicio de __bss_start se mueve a di . Luego, la dirección _end + 3 (+3 para la alineación de 4 bytes) se mueve a cx . El registro eax se borra (usando la instrucción xor ), se calcula el tamaño de la partición bss ( cx-di ) y se coloca en cx . Luego, cx se divide en cuatro (el tamaño de la "palabra") y la instrucción stosl se usa stosl , almacenando el valor (cero) en la dirección que apunta a di , aumentando automáticamente di por cuatro y repitiendo esto hasta que llegue a cero). El efecto neto de este código es que los ceros se escriben en todas las palabras en la memoria desde __bss_start hasta _end :



Ir a principal


Eso es todo: tenemos una pila y BSS, por lo que puede ir a la función main() C:

  calll main 

La función main() se encuentra en arch / x86 / boot / main.c. Hablaremos de ella en la próxima parte.

Conclusión


Este es el final de la primera parte sobre el dispositivo kernel de Linux.Si tiene preguntas o sugerencias, contácteme en Twitter , por correo o simplemente cree un ticket . En la siguiente parte veremos el primer código en C, que se realiza durante la instalación del kernel de Linux, la ejecución de los subprogramas de memoria, como memset, memcpy, earlyprintk, aplicación temprana y la inicialización de la consola, y más.

Referencias


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


All Articles