Del gestor de arranque al kernelSi 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 { _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:
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:
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