Capítulo 9: Organizarse a prueba de balas


Nuestro bootloader se está haciendo mayor. Dentro de él, tenemos dos subproyectos: stage1 y stage2, cada uno en su carpeta, y dentro de stage2 tenemos código escrito en 2 lenguajes distintos. Pero eso no es ningún problema, lo que debería preocuparnos es que con C, las cosas van a crecer mucho más rápido, y actualizar build.sh una y otra vez va a ser cada vez más molesto.

Dentro de poco, empezaremos a crear distintos archivos de código C dentro de nuestra stage2. Después de eso, empezaremos con el kernel. A largo plazo, es probable que tengamos que hacer varias ediciones de cada proyecto según la arquitectura para la que queremos programar. Por todo esto, es vital poder actualizar el proyecto con facilidad.

En este capítulo exploraremos cómo mejorar la estructura de nuestro proyecto, y pasaremos nuestro script build.sh a una Makefile.

Tabla de contenidos


  1. Estructura del proyecto
  2. Empezar a usar GNU make
  3. Escribir las Makefiles
  4. Extra: depurar nuestro sistema con Bochs
  5. Resumen
  6. Ejemplo descargable

Estructura del proyecto


Actualmente, nuestra estructura de proyecto es exactamente esta:

sistema
|-- build
|   |-- bin
|   |   |-- stage1.bin
|   |   |-- stage2.bin
|   |-- obj
|   |   |-- entry.obj
|   |   |-- main.obj
|   |-- disco.img
|-- src
|   |-- stage1
|   |   |-- stage1.asm
|   |-- stage2
|   |   |-- entry.asm
|   |   |-- linker.ld
|   |   |-- main.c
|-- build.sh

Esta estructura de proyecto tiene tres fallos principales:

Para solucionar esto, te propongo una estructura un poquito más compleja, pero mucho más útil:

sistema
|-- build
|   |-- bootloader
|   |   |-- stage2
|   |   |   |-- entry.obj
|   |   |   |-- main.obj
|   |   |-- stage1.bin
|   |   |-- stage2.bin
|   |-- disco.img
|-- src
|   |-- bootloader
|       |-- stage1
|       |   |-- stage1.asm
|       |   |-- Makefile
|       |-- stage2
|       |   |-- entry.asm
|       |   |-- linker.ld
|       |   |-- main.c
|       |   |-- Makefile
|       |-- Makefile
|-- Makefile

Esta estructura nos permitirá dividir claramente el código del bootloader y el código del kernel una vez empecemos a programarlo. También permite construir cada ejecutable desde su propia Makefile. Todo ello manteniendo la separación entre el código (src) y los archivos compilados (build).

Todas las carpetas dentro de build las generará cada Makefile según vayan siendo necesarias, siguiendo solo una norma: las carpetas dentro de build deben seguir la misma estructura que las carpetas en src.

Cuando ejecutemos el comando make en la carpeta de nuestro proyecto, estaremos ejecutando el código de nuestra Makefile global, la que está en la carpeta raíz. Esa Makefile ejecutará la de cada proyecto, esas ejecutarán las que estén por debajo de ellas, etc.

Empezar a usar GNU make


La sintaxis de GNU make

Para familiarizarse rápido con la sintaxis de GNU make, recomiendo el tutorial Makefile Tutorial By Example escrito por Chase Lambert. Aquí solo voy a dar un par de pistas sobre la sintaxis para ponernos al día muy rápidamente.

Una Makefile es un archivo que situaremos en la carpeta raíz de nuestro proyecto. Lo llamaremos tal que así: Makefile, y cuando ejecutemos el comando make se ejcutarán los comandos que escribamos en su interior. Pero los comandos no se puede escribir uno tras otro como si fuera un script, dentro de la Makefile, dividiremos nuestras secciones de código en recetas.

A continuación, se van a mostrar varias recetas a modo de ejemplo para ir entendiendo su utilidad, su orden de ejecución, etc. Por motivos estéticos, las tabulaciones se han sustituido por 4 espacios seguidos. Si quieres probar el código, asegúrate de sustituir por tabulaciones esos 4 espacios seguidos al principio de ciertas lineas. Si no, el comando make dará error.

Esta es una receta que nos permite poner varias cosas en la consola ejecutando únicamente el comando make test:

test:
    echo "Esto es un test!"
    echo "Y está sacado de un tutorial de sistemas operativos :)"

Esto es un archivo que contiene 2 recetas. Puedes ejecutar una ejecutando make test1, y la otra ejecutando make test2. Si ponemos solamente make, se ejecutará únicamente la que esté escrita más arriba en el documento (en este caso, test1).

test1:
    echo "Este es el test número 1!"

test2:
    echo "Este es el test número 2!"

Si queremos que para ejecutar test2 sea necesario primero haber ejecutado test1, lo único que tenemos que hacer es anotarlo como pre-requisito en la receta:

test1:
    echo "Este es nuestro primer test!"

test2: test1
    echo "Este es el segundo test!"
    echo "Siempre que lo ejecutes (con make test2) se ejecutará test1 antes."

Vamos a trasladarlo al terreno práctico. Si vamos a la carpeta en la que tenemos nuestro archivo stage1.asm y creamos dentro de ella esta Makefile:

build/stage1.bin: stage1.asm
    nasm -f bin -o build/stage1.bin stage1.asm

create: remove
    # Recrear la carpeta 'build' para los archivos compilados
    mkdir build

remove:
    # Eliminar archivos compilados de la versión anterior, si existen
    rm -rf build

Con tan solo ejecutar el comando make, se ejecutará la receta build/stage1.bin, que requerirá antes la receta create, y esta a su vez la receta remove. Así habremos compilado nuestro código en un ejecutable, y además de forma mucho más entendible y mantenible que con un script.

Esto tiene muchas ventajas. Por ejemplo, si queremos que nuestra receta para fabricar stage1.bin se aplique a todos nuestros archivos de código assembly, podemos usar pattern rules:

build/%.bin: %.asm
    nasm -f bin -o $@ $<

Esto ya es más difícil de entender. Me explico:

Por último, podemos llamar a una Makefile de otra carpeta usando la opción -C [carpeta]. Por ejemplo, ejecutar en la raíz de nuestro proyecto make -C src/bootloader/stage1 ejecutará la Makefile de la stage1.

Uso de variables y funciones

Para tener más a mano los nombres de archivos y carpetas, es conveniente definirlos como variables al principio del archivo:

FOLDER=src/bootloader/stage1

call-another-makefile:
    make -C ${FOLDER}

Al entrar en la siguiente Makefile ya no podremos usar la variable ${FOLDER}. Si la queremos mantener, debemos declararla como variable global mediante el comando export:

export FOLDER="src/bootloader/stage1"

Pero hay un problema: la dirección src/bootloader/stage1 es relativa. Solo apunta a donde queremos que apunte si estamos operando desde la carpeta raíz de nuestro proyecto. Al cambiar de carpeta usando make -C [carpeta], dejará de ser válida.

Por suerte, podemos solucionar esto con la función $(abspath [dirección]). Esta función traducirá nuestro texto (src/bootloader/stage1) a la dirección completa, partiendo desde el directorio raíz del sistema (/):

export FOLDER=$(abspath src/bootloader/stage1)

El manual de GNU make es bastante fácil de leer y tiene secciones muy claras sobre los operadores para declarar variables y las funciones para procesar texto. No tienes por qué quedarte con toda esta información de golpe. Esta sección está más pensada como una referencia rápida a la que acudir si te pierdes en los siguientes apartados.

Escribir las Makefiles


La Makefile global

Estos son los pasos que vamos a seguir en la Makefile que vamos a situar en la raíz de nuestro proyecto:

De esta forma, el código responsable de compilar cada proyecto está en la propia carpeta de cada proyecto. La Makefile global solo se encarga de generar un disco vacío y llamar a cada Makefile para que lo llene. Esto hace el proceso mucho más claro, y la búsqueda de bugs relativamente más fácil.

En la Makefile global, también definiremos variables a las que podremos llamar desde cualquiera de las Makefiles locales de cada proyecto. Aquí tienes, a modo de propuesta, las variables que yo he definido:

# Archivo: Makefile (en carpeta raíz del proyecto)

# Carpetas de código y compilación
export DIR_BUILD=$(abspath build)
export DIR_SRC=$(abspath src)

# Dirección de la imagen de disco final
# Debe estar dentro de ${DIR_BUILD}, o puede provocar problemas
export IMG=${DIR_BUILD}/disco.img

# Proyectos: todos los nombres de carpeta bajo ${DIR_SRC}
export PROJECTS=$(wildcard ${DIR_SRC}/*)

# Compilador de Assembly
export ASM=nasm

# Compilador de C, y opciones que usamos siempre
export CC=[dirección de nuestro cross-compiler de GCC para i686-elf]

Tras estas definiciones viene el código, que yo he dividido en 5 fases: clean, create, imagen de disco, compile y qemu. Por costumbre personal, suelo redactar el código para que se ejecute de abajo a arriba. Siéntete libre de cambiar esto si te parece conveniente.

# Archivo: Makefile (en carpeta raíz del proyecto)

qemu: compile
    # Emular el sistema con QEMU
    qemu-system-i386 -fda ${IMG}

compile: ${IMG}
    # Ejecutar la Makefile de cada proyecto
    for dir in ${PROJECTS}; do \
        make -C $$dir; \
    done

${IMG}: create
    # Crear y formatear imagen de disco
    dd if="/dev/zero" of="$@" bs=512 count=2880
    mkfs.fat -F 12 -n "EXAMPLE  OS" "$@"

create: clean
    # Recrear la carpeta 'build' y las necesarias para nuestro disco
    mkdir -p ${DIR_BUILD}

clean:
    # Limpiar la carpeta 'build'
    rm -rf ${DIR_BUILD}

De todas estas lineas, la única que no estaba en nuestro build script anteriormente es el bucle for que ejecuta las Makefiles de cada proyecto. En esas lineas, solamente se ejecuta el comando make -C [carpeta] con cada carpeta que se encuentre bajo nuestro directorio src.

Con esta configuración, cuando ejecutemos make se compilará todo y se ejecutará QEMU. Personalmente, yo prefiero que al ejecutar ese comando, tan solo se compile el sistema, y no abra el emulador. Para ello, podemos asignarle la receta compile como final predeterminado definiendo esta variable:

# Archivo: Makefile (en carpeta raíz del proyecto)

# Si solo se ejecuta 'make', se llegará solo hasta la receta 'compile'
.DEFAULT_GOAL=compile

La Makefile local de nuestro primer proyecto: el bootloader

Esta Makefile, debido a la estructura del bootloader, es bastante simple. Se encargará de realizar las siguientes tareas:

Aquí tienes mi propuesta para implementar esto en un código de 3 fases (clean, create y compile):

# Archivo: Makefile (en src/bootloader)

# Subproyectos (local): todos los archivos de la carpeta menos la Makefile
SUBPROJECTS=$(filter-out Makefile, $(wildcard *))

# Carpeta en la que se guardarán los archivos del proyecto en 'build'
export DIR_PROJECTBUILD=${DIR_BUILD}/bootloader

compile: create
    # Ejecutar la Makefile de cada subproyecto
    for dir in ${SUBPROJECTS}; do \
        make -C $$dir; \
    done

create: clean
    # Crear la carpeta para los archivos de compilación de 'bootloader'
    mkdir -p ${DIR_PROJECTBUILD}

clean:
    # Borrar los archivos de compilación de 'bootloader'
    rm -rf ${DIR_PROJECTBUILD}

Compilar la stage1 desde su propia Makefile

Esto ya lo hemos hecho antes. Aproximadamente, tenemos que crear nuestro ejecutable usando nasm -f bin -o stage1.bin stage1.asm y luego instalarlo en el disco usando dd if="stage1.bin" of="disco.img" conv="notrunc". A esto, vamos a añadir una tarea llamada clean, que borrará la anterior versión de stage1.bin en caso de que exista.

# Archivo: Makefile (en src/bootloader/stage1)

BINARY=${DIR_PROJECTBUILD}/stage1.bin

install: ${BINARY}
    # Instalar archivo binario 'stage1.bin' al inicio del disco
    dd if="${BINARY}" of="${IMG}" conv=notrunc

${BINARY}: clean
    # Compilar el código de 'stage1' a un archivo binario
    ${ASM} -f bin -o ${BINARY} stage1.asm

clean:
    # Limpiar archivos de compilación de 'stage1'
    rm -rf ${BINARY}

Compilar la stage2 en su propia Makefile

La stage2 es más complicada. No solo tiene archivos escritos tanto en assembly como en C. Es que encima no sabemos la extensión que acabará teniendo, así que tendremos que irla actualizando junto con nuestro código cada vez que creemos un nuevo archivo. Pero no te preocupes, gracias a nuestra estructura de proyecto podemos hacer esto en muy pocas lineas de código.

Estas son las tareas que tendrá que realizar nuestra Makefile:

Esto ya son más tareas... Mi implementación ha sido la siguiente:

# Archivo: Makefile (en src/bootloader/stage2)

# Carpeta para los archivos de compilación
DIR_OUTPUT=${DIR_PROJECTBUILD}/stage2

# Dirección del ejecutable final
BINARY=${DIR_PROJECTBUILD}/stage2.bin

# Dirección del linker script
LINKER=linker.ld

# Lista de objetos binarios que tendremos que generar.
#  - Si existe entry.asm, debe existir ${DIR_OUTPUT}/entry.obj
#  - Si existe main.c, debe existir ${DIR_OUTPUT}/main.obj
#  - etc.
OBJECTS=\
${DIR_OUTPUT}/entry.obj \
${DIR_OUTPUT}/main.obj

install: ${BINARY}
    # Instalar 'stage2.bin' en nuestra imagen de disco
    mcopy -i ${IMG} ${BINARY} "::stage2.bin"

${BINARY}: ${OBJECTS}
    # Vincular objetos binarios en el ejecutable 'stage2.bin'
    ${CC} -T ${LINKER} -nostdlib -o $@ ${OBJECTS} -lgcc

${DIR_OUTPUT}/%.obj: %.c create
    # Compilar objetos de 'stage2' desde archivos de C... ($@)
    ${CC} -ffreestanding -nostdlib -c -o $@ $<

${DIR_OUTPUT}/%.obj: %.asm create
    # Compilar objetos de 'stage2' desde archivos de assembly... ($@)
    ${ASM} -f elf -o $@ $<

create: clean
    # Recrear la carpeta de archivos de compilación de 'stage2'
    mkdir -p ${DIR_OUTPUT}

clean:
    # Limpiar archivos de compilación de 'stage2'
    rm -rf ${DIR_OUTPUT}
    rm -rf ${BINARY}

Este código, aunque es algo simple y mejorable, aprovecha elementos bastante avanzados de la sintaxis de GNU make para hacer muchas cosas distintas en muy pocas lineas. Si no lo entiendes a la primera, no te preocupes. Puedes investigar más sobre GNU make leyendo el manual o, si tienes menos tiempo, completando el tutorial Makefile Tutorial By Example.

Extra: depurar nuestro sistema con Bochs


Llegados a este punto, ya puedes ejecutar make clean para limpiar los archivos compilados, make compile para compilar todo el sistema sin emularlo, e incluso make qemu para compilarlo y emularlo directamente. En esta sección, crearemos la receta make debug para depurar nuestro sistema operativo con el emulador Bochs. Esto puede ser de mucha ayuda según avancemos en nuestro proyecto.

Para empezar, tendremos que instalar bochs en nuestro sistema. Por ejemplo, en Arch Linux se puede instalar como paquete del AUR. Consulta las instrucciones para tu distribución, y asegúrate de saber en qué carpeta está instalado el programa.

En la carpeta raíz de nuestro proyecto, vamos a crear la configuración con la que iniciaremos el emulador Bochs. Yo he llamado a la mía bochs_config y le he dado esta forma, basándome en la creada por nanobyte en el capítulo 6 de su serie sobre desarrollo de sistemas operativos.

megs: 128
romimage: file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/share/bochs/VGABIOS-elpin-2.40
floppya: 1_44=build/disco.img, status=inserted
boot: floppy
mouse: enabled=0
display_library: x, options="gui_debug"
magic_break: enabled=1

Para ejecutar Bochs correctamente, tienes que asegurarte de varias cosas:

En un futuro, aprenderemos algo más sobre emulación con Bochs. Por ahora, tan solo tienes que preocuparte de añadir la siguiente receta en la Makefile global:

debug: compile
    bochs -f bochs_config -q -debugger

La opción -f sirve para especificar nuestro archivo de configuración (en este caso, bochs_config). La opción -q evita la configuración inicial del programa. Por último, la opción -debugger habilita la ventana de depuración. No siempre es necesario escribir la opción, pero conviene hacerlo.

Esto bastará para poder ejecutar make debug y aprovechar las funciones de depuración de Bochs si necesitamos arreglar errores de nuestro sistema operativo. Tras emular nuestro sistema, se creará el archivo bx_enh_dbg.ini, que contiene más atributos de configuración. Este archivo se puede modificar a mano, pero no es recomendable hacerlo si no es totalmente necesario.

Resumen


En este capítulo hemos comenzado a estructurar nuestro proyecto para poder expandirlo sin problemas en el futuro cercano. También hemos incorporado GNU make y hemos visto algunos conceptos fundamentales de su sintaxis.

Gracias a esta base, los siguientes episodios podrán centrarse, por fin, en terminar la stage2 de nuestro bootloader y saltar directamente a nuestro segundo subproyecto: el kernel. Para ello, crearemos un driver básico para soportar la lectura del sistema de archivos FAT12. También implementaremos una forma básica de la función printf(), que nos permitirá mostrar texto, números y más tipos de datos en la pantalla.

Ejemplo descargable


El archivo capitulo-9.tar.gz contiene todo el código que llevamos hasta ahora, y se compila con GNU make, utilizando la nueva estructura que le hemos dado al proyecto.