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
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:
- Toda la compilación se hace desde un mismo archivo, que se puede volver ilegible si el proyecto crece mucho.
- Nuestro script build.sh nos ofrece una sola opción: compilar todo y emularlo con qemu. Eso nos impide, por ejemplo, depurar fácilmente nuestro sistema con el emulador bochs.
- stage1 y stage2 forman parte del mismo proyecto, que es el bootloader, pero están en carpetas distintas.
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:
- La receta coge los archivos assembly de la carpeta, que pueden ser por ejemplo one.asm y two.asm, y genera binarios de su mismo nombre en la carpeta build: build/one.bin y build/two.bin
- El símbolo % sirve para denotar un patrón (coge archivos que se llamen loquesea.asm, y conviértelos en build/loquesea.bin).
- El símbolo $@ equivale al archivo que queremos generar (build/loquesea.bin), y el símbolo $< al archivo de procedencia (loquesea.asm).
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:
- Eliminar la anterior carpeta build si existe.
- Crear otra vez, vacía, la carpeta build.
- Crear una imagen de disco vacía para nuestro sistema operativo.
- Llamar a la Makefile de cada uno de los proyectos que se encuentren en src.
- Opcionalmente, emular nuestro sistema operativo usando QEMU.
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:
- Definir como variable global la carpeta, dentro de build, donde se guardarán los archivos compilados. Y, si ya existe, eliminarla y recrearla vacía.
- Hecho eso, llamar a la Makefile de nuestra stage1 y luego a la de stage2. Es decir, todas las carpetas dentro de la del bootloader.
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:
- Definir un directorio, dentro de build y dentro de la carpeta de compilación del bootloader, en el que guardaremos los archivos de la stage2.
- Borrarlo si ya existe, y recrearlo vacío.
- Compilar nuestros archivos de assembly a objetos binarios y guardar los objetos en esa nueva carpeta.
- Compilar nuestros archivos de C a objetos binarios, y guardarlos en el mismo sitio.
- Enlazar todos los objetos binarios que hayamos generado, para crear un solo ejecutable llamado stage2.bin
- Copiar el archivo stage2.bin a la carpeta raíz de nuestro sistema (es decir, a la imagen de disco).
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:
- La dirección del archivo en romimage apunta a una BIOS compatible con el emulador (se pueden encontrar varias en la carpeta de instalación).
- La dirección del archivo en vgaromimage apunta a una VGA BIOS compatible (también se pueden encontrar varias en la carpeta de instalación).
- En el atributo floppya, la variable 1_44 debe contener la dirección de la imagen de disco en la que hemos instalado nuestro sistema operativo.
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.