Bloque 1: el bootloader
En este bloque explicaremos qué ocurre cuando iniciamos un ordenador, cómo podemos crear un programa que no dependa de sistemas operativos y, más tarde, cómo ejecutar nuestro propio sistema operativo. Es un proceso complicado para el que también aprenderemos algo de assembly y manejo de memoria.
Vamos a empezar con un par de secciones que servirán como prólogo, para cubrir conceptos que se suelen asumir como requerimientos previos a la hora de programar sistemas operativos. Si conoces alguno de los temas tratados, siéntete libre de saltarte la sección. Tú, como en tu casa.
Tabla de contenidos
Sistemas de numeración y manejo de memoria
Casi todas las personas aprendemos a contar usando el sistema de numeración decimal, o numeración en base 10. Este sistema se caracteriza por tener 10 cifras, en orden: 0, 1, 2, 3, 4, 5, 6, 7, 8 y 9. Al acabarse las cifras, se añade una más, que representa las decenas, así formamos los números 10, 11, 12... 99, luego las centenas y así sucesivamente.
Sistema de numeración binario
Existen más sistemas de numeración. Es probable que alguna vez hayas aprendido a contar en binario o base 2. En este caso, solamente existen 2 cifras: el 0 y el 1. El sistema es el mismo: 0, 1, añadimos decenas, 10, 11, añadimos centenas... Puede ser lioso al principio, pero es muy fácil. Aquí tienes los números del 0 al 10 traducidos a binario.
Decimal | Binario |
---|---|
0 | 0 |
1 | 1 |
2 | 10 |
3 | 11 |
4 | 100 |
5 | 101 |
6 | 110 |
7 | 111 |
8 | 1000 |
9 | 1001 |
10 | 1010 |
Si sigues con esto, acabarás desarrollando ciertos trucos para contar rápido en decimal. Por ejemplo: sabrás que los números de 4 cifras empiezan con el 8, los números de 5 con el 16... En general, los números de x cifras empiezan con el número 2 elevado a x. Esto también aplica al sistema decimal, donde los números de x cifras empiezan con 10 elevado a x. Es aplicable a cualquier sistema de numeración.
Hay muchos tutoriales en Internet que explican cómo convertir números del binario al decimal, es muy recomendable aprender a hacerlo para coger soltura a la hora de escribir usando distintos sistemas de numeración.
Sistema de numeración hexadecimal
Habrás notado que el binario tarda muy poquito en tener números de muchas cifras. Por ejemplo, a partir del 32 los números tienen ya 6 cifras, y eso los hace muy difíciles de memorizar. Por eso, se suelen "resumir" utilizando el sistema de numeración hexadecimal o base 16, que tiene las cifras del 0 al 9 y, además, se extiende usando las letras de la A a la F. Aquí tienes algunos números en los 3 sistemas:
Decimal | Binario | Hexadecimal |
---|---|---|
0 | 0 | 0 |
1 | 1 | 1 |
2 | 10 | 2 |
... | ... | ... |
10 | 1010 | A |
11 | 1011 | B |
12 | 1100 | C |
13 | 1101 | D |
14 | 1110 | E |
15 | 1111 | F |
16 | 10000 | 10 |
... | ... | ... |
30 | 11110 | 1E |
31 | 11111 | 1F |
32 | 100000 | 20 |
¿Y por qué resulta esto útil? Ahora lo veremos. Simplemente quédate con que todo lo que un ordenador puede procesar se guarda en binario. Pongamos que declaras en cualquier lenguaje de programación una variable a con el valor 15, del tamaño de 1 byte. Por ejemplo, en C:
char a = 15;
Esto ha guardado en algún punto de la RAM del ordenador el valor 1111 (en hexadecimal 0xF y en decimal 15). Bueno, en realidad lo habrá guardado con ocho cifras (00001111). Esto es igual que si en decimal escribimos 000015, es equivalente a escribir el número sin los ceros a la izquierda.
Las memorias guardan en su interior una sucesión de bits (cifras en binario, que pueden ser 0 o 1). La combinación de 8 bits se llama byte (p. ej: 00000000 o 10101010). En un byte se pueden almacenar números del 0 al 255, porque son los que caben en 8 cifras en binario.
Teniendo en cuenta todo esto, podemos visualizar una memoria como una sucesión de bytes, que es como computan los datos la mayoría de ordenadores modernos. Por ejemplo, aquí tenemos una memoria de 4 bytes (32 bits):
0 | 1 | 2 | 3 |
---|---|---|---|
00001111 | 11110000 | 10101010 | 10001000 |
Aquí es donde vemos la utilidad del sistema hexadecimal, que nos permite resumir las 8 cifras binarias que componen un byte en tan solo 2 cifras hexadecimales. Aquí tienes la misma memoria, pero con cada byte escrito en hexadecimal, en vez de binario.
0 | 1 | 2 | 3 |
---|---|---|---|
0F | F0 | AA | 88 |
Ten en cuenta que las direcciones en la memoria se cuentan desde el 0 y también se suelen escribir en hexadecimal. Para dejar claro que es hexadecimal (y no decimal), los valores escritos en este sistema se suelen prefijar con 0x. En algunos lenguajes también es válido sufijar los hexadecimales con la letra h (p. ej: 0FFFh). Por todo esto, acostúmbrate a ver tablas de memoria más parecidas a esta durante el manual:
0x0 | 0x1 | 0x2 | 0x3 |
---|---|---|---|
0x0F | 0xF0 | 0xAA | 0x88 |
La palabra byte se puede acortar en algunos lenguajes a la letra b. Además, las combinaciones de 2 bytes, 4 bytes y 8 bytes se suelen llamar word (w), double-word (d) y quad-word (q), respectivamente. También te puedes encontrar nomenclaturas que se refieren a cada tipo de dato por su tamaño en bits, en ese caso serían números de 16 bits, 32 bits y 64 bits, respectivamente.
Conceptos básicos para crear programas ejecutables
Si pretendes seguir esto a fondo y formarte en el asunto, te recomiendo de corazón aprender algo de lenguaje ensamblador (o Assembly), sobre todo de su dialecto para los procesadores de arquitectura x86. La serie de tutoriales del usuario 0xAX en GitHub es perfecta si quieres aprender desde cero.
Aquí vamos a tratar brevemente conceptos esenciales de para generar ejecutables a partir de código Assembly, entendiendo por esenciales aquellos sin los que no podrías abarcar ni siquiera los capítulos 1 y 2 del manual. A partir de cierto punto, empezaremos con C, y entonces habrá una iniciación a conceptos esenciales de C... en fin, ya veré qué hacemos cuando llegue a ese punto.
Durante todo este manual vamos a escribir Assembly x86 con la sintaxis de NASM, que es el programa que vamos a usar para generar ejecutables a partir de nuestro código. Puedes descargarlo como paquete en cualquier distribución de Linux. Por ejemplo, en Arch Linux es el paquete nasm.
Secciones de un ejecutable
El código que se escribe en assembly (o en cualquier lenguaje compilado) se traduce después a código máquina y se escribe en un archivo que, normalmente, tiene un formato ejecutable. Alguna vez te habrás encontrado con un .exe en Windows... pues nosotros vamos a producir, por ahora, estructuras ejecutables un poquito más simples que esa.
Cuando tú escribes Assembly estando dentro de un sistema operativo como Linux, las secciones del ejecutable que vas a producir están ya más o menos predefinidas. Por eso, el código te va a quedar casi siempre con una estructura como esta:
global _start ; Se empezará a ejecutar por el símbolo "_start".
section .data
; Aquí se definen las variables que vamos a usar.
section .text
_start:
; Código del programa.
; Estas instrucciones sirven para cerrar el programa
; en Linux con el código de salida "5".
mov eax, 1
mov ebx, 5
int 0x80
Programando en un entorno freestanding (es decir, sin sistemas operativos ni bibliotecas de las que tirar), declarar estas secciones va a ser un poquito más complicado, pero no te preocupes, veremos cómo se hace cuando tengamos que hacerlo.
Compilar el ejecutable
Compilar es el proceso de traducir nuestro código a lenguaje máquina y copiar la traducción a un archivo binario. Aquí es donde usamos nasm por fin. Básicamente, tienes que abrir un terminal en la carpeta donde tengas el archivo con el código y ejecutar el siguiente comando:
nasm -f elf -o [nombre del ejecutable final] [nombre del archivo con el código]
Esto genera un archivo binario de formato ELF con el código de tu programa traducido. Es importante señalar que los corchetes ([]) solo encuadran lo que tienes que escribir, y no se deben poner en el comando final. Por ejemplo, si he escrito el código en archivo.asm y quiero generar el ejecutable binario.obj, lo haría así:
nasm -f elf -o binario.obj archivo.asm
Cuando lo generes, notarás que si le das doble click no pasa nada. Es normal. El archivo que has generado es un archivo de código binario (u objeto binario), y es ejecutable, lo que pasa es que tu sistema espera otro tipo de ejecutables, y además nada de lo que hace el código produce una respuesta gráfica. Para solucionarlo, debemos enlazar nuestros archivos binarios (aunque en este caso sea solo uno).
Enlazar los binarios para crear un ejecutable real
Enlazar es el proceso de combinar todos los archivos binarios que hayas creado en un solo ejecutable. Los compiladores modernos suelen incluir también un enlazador, y normalmente enlazan los archivos binarios automáticamente sin que tú tengas que poner nada más, pero NASM no es así.
Para enlazar el archivo vamos a descargar binutils, de GNU, que incluye el enlazador ld, además de otros programas interesantes. Suele estar incluido por defecto en cualquier distribución de GNU/Linux.
Asumiendo que tenemos el archivo binario.obj y queremos generar el ejecutable ejecutable (sin extensión), lo haríamos así:
ld -m elf_i386 -o ejecutable binario.obj
Esto genera un ejecutable en formato ELF, de 32 bits, llamado ejecutable, a partir del código que se encuentra en binario.obj. Si le das doble click, es probable que ejecute las instrucciones que le hemos puesto, que sirven solamente para cerrar el programa con el código de salida 5.
Puedes comprobar que el programa va correctamente abriendo un terminal en la carpeta, ejecutándolo y luego comprobando el código de salida:
./ejecutable # Ejecuta el programa
echo $? # Imprime el código de salida (que debería ser 5)
Resumen
De aquí, podemos quedarnos con que la memoria de un PC es una sucesión de bytes, que almacenan números en binario de 8 cifras, aunque normalmente se representan en hexadecimal. Los bytes son consecutivos y pueden almacenar números del 0 al 255, que son todos los que caben en un número binario de 8 cifras.
También hemos aprendido que un programa es un archivo que incluye una serie de instrucciones. Normalmente las escribimos en Assembly, o en cualquier otro lenguaje, y se deben compilar (es decir, traducirlas a lenguaje máquina) y enlazar (es decir, unir todos nuestros archivos de código en un solo ejecutable).
Existen varios formatos de ejecutable, aunque en eso no vamos a entrar en detalle. Si te interesa el tema, hay mucha información en la sección de la OSDev Wiki sobre formatos ejecutables.
A continuación, veremos cómo escribir algunas instrucciones en Assembly, y a partir de ahí empezaremos a hacer el primer ejecutable relacionado con nuestro sistema operativo: el bootloader.
Un bootloader es un programa cuya función principal es, citando a la OSDev Wiki:
- Cargar el kernel en la memoria
- Dar al kernel la información que necesita para funcionar correctamente
- Cambiar a un entorno favorable para el kernel
- Transferir el control de ejecución al kernel
El kernel es el primer programa que forma parte de nuestro sistema operativo como tal. El bootloader se ejecuta antes, y simplemente sirve para hacer las preparaciones pertinentes y cargar nuestro kernel.