Kernel mínimo en C

Para realizar el lab, se debe instalar el software necesario. La entrega se realiza en horario de clase del día indicado siguiendo las instrucciones de entrega en papel.

Índice

Compilar un kernel y lanzarlo en QEMU

  • Lecturas obligatorias1

    • BRY2
      • cap. 1: §1-10
  • Lecturas recomendadas

    • BRY2
      • cap. 2: §1-3

El siguiente código, una vez lanzado en el procesador, constituye un kernel completo con una única tarea: mantener la computadora prendida.

1
2
comienzo:
    jmp comienzo

Es equivalente al siguiente bucle infinito en C:

1
2
3
4
void comienzo(void) {
    while (1)
        continue;
}

El código se compila con gcc, siempre (en estos labs) en modo 32-bits:

1
$ gcc -g -m32 -O1 -c kern0.c

Una vez compilado el código del kernel, se debe generar una imagen binaria que pueda ser ejecutada bien en una computadora física, bien en un simulador como QEMU. Para ello, se deben enlazar los objetos (archivos *.o) con las instrucciones de arranque que correspondan según la arquitectura.

El estándar Multiboot simplifica enormemente la tarea, pues permite arrancar un kernel directamente en protected mode (32-bits) sin tener que cargar la imagen desde disco, ni realizar el paso desde real mode a mano (ver ejercicio kern2-mbr). Grub y muchos otros gestores de arranque ofrecen soporte para multiboot; en QEMU se activa mediante la opción -kernel (versión 1 de multiboot solamente).

Para indicar al gestor de arranque que configure multiboot se debe incluir, en los primeros bytes del binario final, la constante numérica 0x1BADB002 y el CRC adecuado, ambos alineados a 32-bits; por ejemplo, en un archivo boot.S:

1
2
3
4
5
6
7
8
9
#define MAGIC 0x1BADB002
#define FLAGS 0
#define CRC ( -(MAGIC + FLAGS) )

.align 4
multiboot:
    .long MAGIC
    .long FLAGS
    .long CRC

Así, el proceso completo para generar la imagen es:

1
2
3
4
5
6
7
8
# Compilar C y ASM
$ gcc -g -m32 -O1 -c kern0.c boot.S

# Enlazar
$ ld -m elf_i386 -Ttext 0x100000 kern0.o boot.o -o kern0

# Lanzar
$ qemu-system-i386 -serial mon:stdio -kernel kern0

Ej: kern0-boot

Compilar kern0 y lanzarlo en QEMU tal y como se ha indicado. Responder:

  • ¿emite algún aviso el proceso de compilado o enlazado? Si lo hubo, indicar cómo usar la opción --entry de ld(1) para subsanarlo.

  • ¿cuánta CPU consume el proceso qemu-system-i386 mientras ejecuta este kernel? ¿Qué está haciendo?2

Ej: kern0-quit

Para finalizar la ejecución de QEMU, se puede cerrar directamente la ventana. Alternativamente, y por haber especificado la opción -serial mon:stdio, se puede controlar la simulación desde la terminal en que se lanzó:

  1. Lanzar una vez más el kernel, y verificar que se puede finalizar su ejecución desde la terminal mediante la combinación de teclas Ctrl-a x.3

  2. Asimismo, la combinación Ctrl-a c permite entrar al “monitor” de QEMU, desde donde se puede obtener información adicional sobre el entorno de ejecución. Ejecutar el comando info registers en el monitor de QEMU, e incluir el resultado en la entrega. (El mismo comando, info reg, existe también en GDB.)

Ejemplo:

1
2
3
4
5
6
7
8
9
$ qemu-system-i386 -serial mon:stdio -kernel kern0
<Ctrl-a c>

QEMU 2.8.1 monitor - type 'help' for more information
(qemu) info registers↩︎
EAX=...

(qemu) <Ctrl-a x>
QEMU: Terminated

Ej: kern0-hlt

Un sistema operativo debe mantener siempre, al menos, un flujo de ejecución en la CPU. De lo contrario, finalizaría la ejecución del kernel.

El ciclo infinito en comienzo() asegura un flujo de ejecución constante, pero mantiene a la CPU permanentemente ocupada.

La instrucción hlt se usa para detener la CPU cuando no hay trabajo “real” que realizar. La instrucción se puede incluir directamente en código C así:

1
2
3
4
void comienzo(void) {
    while (1)
        asm("hlt");
}

Leer la página de Wikipedia HLT (x86 instruction), y responder:

  • una vez invocado hlt ¿cuándo se reanuda la ejecución?

Usar el comando powertop para comprobar el consumo de recursos de ambas versiones del kernel. En particular, para cada versión, anotar:

  • columna Usage: fragmento de tiempo usado por QEMU en cada segundo.

  • columna Events/s: número de veces por segundo que QEMU reclama la atención de la CPU.

Ej: kern0-gdb

  • Lecturas obligatorias

    • BRY2
      • cap. 3: §1-5
  • Lecturas recomendadas

Un kernel no puede correr directamente desde GDB, ya que la ejecución de dicho kernel debe ocurrir afuera del sistema operativo sobre el cual corre GDB. No obstante, GDB permite depurar de manera remota. Para ello, GDB y el entorno remoto se comunican por red mediante un protocolo específico (GDB Remote Serial Protocol).

En este caso, el “entorno remoto” es QEMU, el cual implementa el protocolo específico de GDB con la opción -gdb y un número de puerto TCP. Además, con la opción -S se indica a QEMU que no comience la ejecución del sistema hasta que así lo ordene remotamente GDB:

1
2
3
$ qemu-system-i386 -serial mon:stdio \
                   -S -kernel kern0  \
                   -gdb tcp:127.0.0.1:7508

Entonces, desde otra terminal GDB se puede comunicar con esta ejecución del kernel:

1
$ gdb -q -s kern0 -n -ex 'target remote 127.0.0.1:7508'

Mostrar una sesión de GDB en la que se realicen los siguientes pasos:4

  • poner un breakpoint en la función comienzo (p.ej. b comienzo)

  • continuar la ejecución hasta ese punto (c)

  • mostrar el valor del stack pointer en ese momento (p $esp), así como del registro %eax en formato hexadecimal (p/x $eax).5 Responder:

    • ¿Por qué hace falta el modificador /x al imprimir %eax, y no así %esp?
    • ¿Qué significado tiene el valor que contiene %eax, y cómo llegó hasta ahí? (Revisar la documentación de Multiboot, en particular la sección Machine state.)
  • el estándar Multiboot proporciona cierta informacion (Multiboot Information) que se puede consultar desde la función principal vía el registro %ebx. Desde el breakpoint en comienzo imprimir, con el comando x/4xw, los cuatro primeros valores enteros de dicha información, y explicar qué significan. A continuación, usar y mostrar las distintas invocaciones de x/... $ebx + ... necesarias para imprimir:

    • el campo flags en formato binario
    • la cantidad de memoria “baja” en formato decimal (en KiB)
    • la línea de comandos o “cadena de arranque” recibida por el kernel (al igual que en C, las expresiones de GDB permiten dereferenciar con el operador *)
    • si está presente (¿cómo saberlo?), el nombre del gestor de arranque.
    • la cantidad de memoria “alta”, en MiB. (Hacerlo en dos pasos, primero un comando x y a continuación un comando p (print) con una expresión que use la variable de GDB $__.)

Makefile y flags de compilación

  • Lecturas obligatorias

    • BRY2
      • cap. 7: §1-3

Se puede usar el siguiente archivo Makefile para simplificar la compilación del kernel (utilizando la solución al ejercicio kern0-boot para completar --entry ???):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
CFLAGS := -g -m32 -O1

kern0: boot.o kern0.o
	ld -m elf_i386 -Ttext 0x100000 --entry ??? $^ -o $@
	# Verificar imagen Multiboot v1.
	grub-file --is-x86-multiboot $@

%.o: %.S
	$(CC) $(CFLAGS) -c $<

clean:
	rm -f kern0 *.o core

.PHONY: clean

A continuación se proponen unos ejercicios para expandir este makefile, y los flags de compilación usados. Referencias útiles sobre make:

Ej: make-flags

Todo el código C de la materia debe seguir el estándar C99. A continuación se describen las opciones de compilación que se deben usar:

  • Para todo el código C de la materia:

    1
    
    CFLAGS := -g -std=c99 -Wall -Wextra -Wpedantic
    
  • Adicionalmente, para código de kernel (labs kern0 y kern2):

    1
    
    CFLAGS += -m32 -O1 -fasm -ffreestanding
    

Se pide:

  1. Recompilar el kernel usando make.

  2. ¿Qué compilador usa make por omisión? ¿Es o no gcc? Explicar cómo se podría forzar el uso de clang:

    • por una sola vez, desde la línea de comandos.
    • para todas las compilaciones del proyecto.
  3. Leer la sección sobre -ffreestanding en Guide to Bare Metal Programming with GCC (la segunda sección, sobre -nostdlib, se cubre en el lab x86); responder:

    • ¿Se pueden usar booleanos en modo “free standing”?

    • ¿Dónde se definen los tipos uint8_t, int32_t, etc.?

    • Teniendo en cuenta los tamaños de char, short, int, long y long long, escribir un archivo c99int.h con las directivas typedef necesarias para definir tipos enteros propios de 8, 16, 32 y 64 bits, con signo y sin signo. Por ejemplo:

      1
      2
      
      typedef short int16_t;
      typedef unsigned short uint16_t;
      

      Hacerlo de manera que las mismas directivas puedan usarse en x86 y x86_64. Se recomienda poner especial atención en el signo de char y el tamaño de long.

Ej: make-pattern

  • ¿Cómo funciona la regla que compila boot.S a boot.o?
  • ¿Qué son las variables $@, $^ y $<?
  • La regla kern0 usa $^ y la regla con %.S usa $<. ¿Qué ocurriría si se intercambiaran estas dos variables entre ambas reglas?

Ej: make-implicit

  • ¿Mediante qué regla se genera el archivo kern0.o?
  • Eliminar la regla %.o: %.S y ejecutar make clean kern0:
    • ¿Se llega a generar el archivo boot.o?
    • ¿Ocurre algún otro error? (Si no ocurre, mostrar la salida del comando uname -m).
    • ¿Se puede subsanar el error sin re-introducir la regla eliminada?

Ej: make-wildcard

Según aumenta el tamaño del kernel, se hace tedioso especificar uno a uno todos los archivos objeto que componen el kernel. Se pueden usar las funciones wildcard y patsubst de make para obtener la lista de todos los archivos C del directorio actual, y de ahí derivar la lista de objetos a enlazar.

Reconfigurar el archivo Makefile para que quede así:

1
2
3
4
5
SRCS := ...  # usar wildcard *.c
OBJS := ...  # usar patsubst sobre SRCS

kern0: boot.o $(OBJS)
	...

Inicialmente, SRCS contendría tan solo kern0.c.

Reglas QEMU

Al archivo Makefile se le pueden agregar las siguientes reglas para facilitar la ejecución y depurado con QEMU:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
QEMU := qemu-system-i386 -serial mon:stdio
KERN := kern0
BOOT := -kernel $(KERN)

qemu: $(KERN)
	$(QEMU) $(BOOT)

qemu-gdb: $(KERN)
	$(QEMU) -kernel kern0 -S -gdb tcp:127.0.0.1:7508 $(BOOT)

gdb:
	gdb -q -s kern0 -n -ex 'target remote 127.0.0.1:7508'

.PHONY: qemu qemu-gdb gdb

El buffer VGA

  • Lecturas obligatorias

    • REES
      • cap. 1
  • Lecturas recomendadas

    • K&R
      • cap. 5: §1-6

El siguiente kernel imprime, de manera bastante rudimentaria, un mensaje por pantalla al arrancar, usando el buffer VGA:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define VGABUF ((volatile char *) 0xb8000)

void comienzo(void) {
    volatile char *buf = VGABUF;

    *buf++ = 79;
    *buf++ = 47;
    *buf++ = 75;
    *buf++ = 47;

    while (1)
        asm("hlt");
}

Ej: kern0-vga

Explicar el código anterior, en particular:

  • qué se imprime por pantalla al arrancar.
  • qué representan cada uno de los valores enteros (incluyendo 0xb8000).
  • por qué se usa el modificador volatile para el puntero al buffer.

Ahora, implementar una función más genérica para imprimir en el buffer VGA:

1
2
static void
vga_write(const char *s, int8_t linea, uint8_t color) { ... }

donde se escribe la cadena en la línea indicada de la pantalla (si linea es menor que cero, se empieza a contar desde abajo).

Ej: kern0-const

Supongamos que se definiera VGABUF como una variable global const (de tal manera que no pueda ser modificada y que por tanto apunte siempre al comienzo del buffer):

1
static const volatile char *VGABUF = (volatile char *) 0xb8000;
  1. Explicar los errores o avisos de compilación que ocurren al recompilar el código original con la nueva definición:

    1
    2
    3
    4
    5
    6
    
    void comienzo() {
        volatile char *buf = VGABUF;
        *buf++ = 79;
        *buf++ = 47;
        // ...
    }
    

    ¿Es adecuada la declaración de VGABUF propuesta? ¿Se puede agregar o quitar algo para subsanar el error?

  2. La declaración de VGABUF resultante del punto anterior ¿permite avanzar directamente la variable global? ¿Qué ocurre al añadir la siguiente línea a la función comienzo?

    1
    2
    3
    4
    5
    6
    
    void comienzo(void) {
        VGABUF += 80;  // ?!?!
    
        volatile char *buf = VGABUF;
        // ...
    }
    

    ¿Se puede de alguna manera reintroducir el modificador const para que no compile esta nueva versión, pero siga compilando el código original? Justificar y explicar el cambio.

  3. Finalmente, sobre la solución del punto anterior: ¿qué ocurre al ejecutar la siguiente versión?

    1
    2
    3
    4
    5
    6
    
    void comienzo(void) {
        *(VGABUF + 120) += 88;
    
        volatile char *buf = VGABUF;
        // ...
    }
    

    ¿Se podría cambiar el tipo de VGABUF para que no se permita el uso directo de VGABUF? (Pero siga compilando, sin modificaciones, el código original.) Justificar por qué el nuevo tipo produce error al usar *VGABUF, y por qué no se hace necesario cambiar el código original.

Ej: kern0-endian

  1. Compilar el siguiente programa y justificar la salida que produce en la terminal:

    1
    2
    3
    4
    5
    6
    
    #include <stdio.h>
    
    int main(void) {
        unsigned int i = 0x00646c72;
        printf("H%x Wo%s\n", 57616, (char *) &i);
    }
    

    A continuación, reescribir el código para una arquitectura big-endian, de manera que imprima exactamente lo mismo.

  2. Cambiar el código de comienzo() para imprimir el mismo mensaje original con una sola asignación, “abusando” de un puntero a entero:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    static ... *VGABUF = ...
    
    void comienzo(void) {
        volatile unsigned *p = VGABUF;
        *p = 0x...
    
        while (1)
            asm("hlt");
    }
    

    Ayuda: convertir los valores 47, 75 y 79 de base 10 a base 16, y componer directamente una constante entera en hexadecimal.

  3. Usar un puntero a uint64_t para imprimir en la segunda línea de la pantalla la palabra HOLA, en negro sobre amarillo.

    Realizar la inicialización de “p” de dos maneras distintas:

    1
    2
    3
    4
    5
    6
    7
    8
    
    // Versión 1
    volatile uint64_t *p = VGABUF + 160;
    *p = 0x...
    
    // Versión 2
    volatile uint64_t *p = VGABUF;
    p += 160;
    *p = 0x...
    

    Justificar las diferencias de comportamiento entre ambas versiones.

Ej: kern0-objdump

  • Lectura sugerida

    • BRY2
      • cap. 7: §12-13

Incluir en la entrega la versión final del archivo Makefile (incluyendo los cambios propuestos en esta tarea), y un archivo kern0.c con:

  • la declaración correcta de la variable VGABUF
  • la función estática vga_write()
  • la función comienzo(), ahora con sendas invocaciones a vga_write() para imprimir, primero, el mensaje original en la línea 0; a continuación, HOLA en la siguiente línea, en negro sobre amarillo.

Sobre este código:

  1. Obtener el código máquina del binario final usando objdump:

    1
    2
    
    $ make
    $ objdump -d kern0
    

    Típicamente, se guarda la salida en un archivo con extensión .asm para tenerlo siempre a mano. Se puede usar la funcionalidad de redirección del intérprete de comandos:

    1
    
    $ objdump -d kern0 >kern0.asm
    

    Añadir al archivo Makefile una invocación de manera que se genere kern0.asm automáticamente tras la fase de enlazado, y se borre como parte de la regla clean. Usar la variable $@ según corresponda.

  2. ¿En qué cambia el código generado si se recompila con la opción -fno-inline de GCC?

  3. Sustituir la opción -d de objdump por -S, y explicar las diferencias en el resultado.

  4. De la salida de objdump -S sobre el binario compilado con -fno-inline, incluir la sección correspondiente a la función vga_write() y explicar cada instrucción de assembler en relación al código C original.

  1. Consultar lista de abreviaturas en la bibliografía↩︎

  2. Se puede comprobar el uso de CPU de cada proceso en el sistema mediante el comando: top -d 1 (salir pulsando q). ↩︎

  3. Esto es: presionar Ctrl-a, soltar, y a continuación teclear la letra x en minúscula. ↩︎

  4. Para cuidar tanto el medio ambiente como sus baterías, se recomienda haber completado el ejercicio kern0-hlt primero. ↩︎

  5. Existe para cada registro una variable asociada. GDB define también cuatro variables genéricas que se definen según la arquitectura actual. En el caso de x86, la asociación es:

    • $eip :: $pc (program counter)
    • $esp :: $sp (stack pointer)
    • $ebp :: $fp (frame pointer)
    • $eflags :: $ps (processor status)

    ↩︎