Para descargar el Sistema Operativo tOSh, podés hacerlo desde la sección Resources de este mismo sitio.

Partes

Esta serie de artículos tiene las siguientes partes:

Armando Máquina de 1997Armando Máquina de 1997

Saltando a C

A veces, mientras escribo esta serie, me hago la pregunta que si es necesario estar explicando todos los conceptos de cada palabra especializada que uso como Kernel y nunca sé bien cuán profundo tengo que ir en mis artículos. Después lleno los términos de links y se me pasa. Mientras escribo exactamente esta oración me estoy preguntando: ¿Tengo que explicar que es un Kernel? Me parece que no. Si estás leyendo esto, asumo que sabés un montón. Y si no, igual te lleno de links por cada término técnico que uso para que puedas seguir investigando por tu cuenta.

Bien. Tenemos que armar un Kernel, porque ya estamos saltando a él, pero no hablamos de nada sobre como empezar a escribir bytes para un sistema operativo desde C.

Lo primero y quizá lo más importante de todo lo que tenés que saber sobre desarrollo de Kernel es que no podés programar así nomás, pim pam pum, agarro un gcc o un clang ¡paf! le chanto un main.c y a compilar y meter el binario donde se te cante. ¡Noooooo! Lo que necesitás hacer primero es prepararte un crosscompiler, es decir, un compilador que permita escribir binarios para otras arquitecturas sin depender de las bibliotecas que vienen con el sistema operativo o sus ABI objetivo porque no hay ABI. Es decir, no hay librerias, no hay interfaces, no hay syscalls, no hay nada porque no tenés kernel, ¡porque estás haciendo uno!

Parece una obviedad mencionar esto, pero si nos detenemos un segundo a pensar, uno está acostumbrado a tener cientos de capas de abstracción que evitan estar interactuando con el CPU y resolver cientos de problemas que se computan casi mágicamente con una o dos llamadas a función. Hacer un printf estándar de C es sorprendentemente complicado y es un rabbit hole en el cual hoy no, pero algún día entraré.

Dicho todo esto recomiendo fuertemente leer el artículo GCC Cross-compiler de osdev.

De KMain, Linkers y Bash

Este va a ser nuestro Kernel por ahora. Lo único que va a hacer es escribir el caracter "X" en la posición de memoria VGA xb8000 que es el área de memoria donde la placa de video está mirando su contenido para replicarlo en el monitor correspondiente y eventualmente terminamos crasheando el CPU porque seguimos escribiendo linearmente hasta 2^32 - 1 o mejor aún, hasta el límite de la memoria que tengamos instalada en el hardware.

__attribute__((section(".text.kmain_entrypoint")))
int kmain(void) {

    char * vga = (char *) 0xb8000;
    *vga = 'X';

    while(1){
        *(vga++) ='X';
    };

    char * str = "end of kernel code.";
    return 1;
}

Compilar este código requiere por lo menos el siguiente Makefile. Primero definimos donde tenemos la instalación de nuestra versión Crosscompilada de GCC que compilamos siguiendo las instrucciones en del artículo GCC Cross-compiler de osdev.

ASM=nasm
CROSS_DIR=~/opt/cross/bin
CC=$(CROSS_DIR)/i686-elf-gcc
AS=$(CROSS_DIR)/i686-elf-as 
LD=$(CROSS_DIR)/i686-elf-ld

Luego seteamos los siguientes flags del compilador:

  • -m32 le dice a GCC que genere código 32 bits. Es decir, registros generales en 32 bits (eax, ebx, ebp, esp, edi, esi, etc…), punteros de 32 bits, ABI de 32 bits, instrucciones compatibles con el modo protegido de 32 bits.
  • -std=gnu99 es la versión c99 del estándar C con extensiones GNU, del año 1999.
  • -Ttext 0x10000 flag de linker, le dice al linker que la sección .text comienza en la dirección 0x10000
  • -ffreestanding le decimos a GCC que no asuma que tenemos funciones estandar del runtime de C como malloc(), printf(), etc…
  • -fno-pic desactivamos Position Independent Code, con saber que todo el Kernel arranca en 0x10000 en adelante es suficiente.
  • -fno-asynchronous-unwind-tables y -fno-unwind-tables desactivamos la metadata que genera GCC para reconstruir excepciones, tracebacks, stacktraces, en caso de error. Reducimos el tamaño del binario con esto pero nos limitamos las capacidades para debuggear también.
  • -march=i486 reducimos el set de instrucciones bien atrás hasta 486 para evitar instrucciones avanzadas (onda, la instruciones de la familia pentium para arriba) limitando la compatibilidad con CPUs más viejas. Por ahora, tOSh corre a partir de i386.
  • -Map kernel.map es una opción del linker y nos sirve para crear un mapa de memoria y ubicar en que offsets está cada función y sección del binario
CCFLAGS= -m32 -std=gnu99 -Ttext 0x10000 
CCFLAGS+= -ffreestanding -O0 -Wall -Wextra -fno-pic
CCFLAGS+= -fno-asynchronous-unwind-tables
CCFLAGS+= -fno-unwind-tables
CCFLAGS+= -march=i486 # avoid instructions like cmovna to be generated by gcc
LDFLAGS= -Map kernel.map

Finalmente definimos un Makefile estandar. No hay nada loco aca, tenemos .o, .map, y algo especial que es nuestro archivo linker.ld.

KERNEL_SRC=$(wildcard kernel/*.c)
KERNEL_OBJS=$(KERNEL_SRC:.c=.o)
KERNEL_BIN=kernel.bin

all: kernel 

clean:
	rm -rf *.bin 
	rm -rf *.o
	rm -rf *.map
	rm -rf *.img
	rm -rf kernel/*.o
	rm -rf *.lst

%.o: %.c
	$(CC) -o $@ -c $< $(CCFLAGS)


kernel: $(KERNEL_OBJS)
		$(LD) -o $(KERNEL_BIN) $^ $(LDFLAGS) -Tkernel/linker.ld

El script del Linker

Cuando compilás, GCC genera unos archivos .o que contienen código y datos pero cada uno está pensado como un cacho independiente del otro. Además, cada .o tiene varias secciones, como .text, .rodata, .bss, etc… El linker lo que hace es agarrar todos esos .o que se generan al compilar uno o varios archivos .c y construye un programa ubicando cada uno de esos Código OBJeto y posicionarlos decidiendo donde quedan exactamente cada uno de ellos en memoria. Adicionalmente, resuelve referencias. Si la funcion pepito_delicioso() está programada en mi_libreria_de_pepitos.c, ese código va a estar en mi_libreria_de_pepitos.o y si mi funcion kmain.c la llama, el linker se encarga de resolver esas referencia a esa función.

El archivo linker.ld es un linker script y es un archivo que le dice al linker que no ubique los OBJ como se le cante, sino más bien, vos le decís donde querés que ubique cada cosa en memoria. Definiendo secciones nuevas o donde empieza .text uno tiene más control del layout de tu programa en memoria.

OUTPUT_FORMAT("binary")
/*ENTRY(kmain)*/
SECTIONS
{
    /* the start address of the kernel's .text section */
    . = 0x10000;

    .text BLOCK(4K) : ALIGN(4K)
    {
        /* *(.text.prologue) */

        /* 
            set up the program entry point 
            kmain.c should have only one function,
            and it's going to be the first section of code executed
            when jumping to the kernel.
            in the generated kernel.map file, kernel/kmain.o should be 
            set at address 0x0000000000010000

            and void kmain(void); should be the first function written on top 
            of the program
        */
        *(.text.kmain_entrypoint)

        /* and later on, all the kernel objects */
        *(.text)
    }

    .rodata BLOCK(4K) : ALIGN(4K)
    {
        *(.rodata)
    }

    .data BLOCK(4K) : ALIGN(4K)
    {
        *(.data)
    }

    .bss BLOCK(4K) : ALIGN(4K)
    {
        *(.bss)
    }

    .idt BLOCK(4K) : ALIGN(4K)
    {
        *(.idt)
    }
    
    .dma BLOCK(4K) : ALIGN(4K)
    {
        *(.dma)
    }

    end = .;
}

Al principio de todo, definimos OUTPUT_FORMAT(\"binary\"). Esto hace que el linker de gcc, no genere un archivo ejecutable .elf sino un .bin directamente que no tiene ni headers, ni tablas, ni simbolos de debugging, nada. Un binario plano. Nuestro Kernel no sabe nada de formatos binarios ni nada y si saltamos a un .elf el CPU va a ejecutar los bytes del header del formato ELF crasheando el CPU.

Con . = 0x10000; dentro de SECTIONS le decimos a linker que empiece a ubicar los binarios a partir de esa dirección. Esto apunta a .text, o sea que lo que le pasamos al Makefile donde le decimos donde empieza la seccion .text es redundante.

Despues, cada una de las secciones tienen que ser bloques y ademas estar alineados en "páginas de 4 kilobytes". Este tamaño, 4Kb, es el definido como página en x86. No solo las secciones tienen que estar en bloques de 4kb, sino que además tienen que estar alineados de a bloques de 4kb.

La primera sección ejecutable se llama: .text.kmain_entrypoint y lo que hacemos definiendolo asi es que sea cual fuere la función en C que tenga este atributo, se le chanta ahi, que es la dirección de memoria 0x10000 que ya habíamos definido y de esa manera nos aseguramos que esa funcion sea la primer función que se ejecute.

Si te fijás más arriba, en el código en C que presente primero, vas a ver arriba de kmain()el attributo que el linker va a tomar del script para que sea lo primero que se ejecute de todo:

/*
0x10000 <-- arrancando en esta dirección de memoria
*(.text.kmain_entrypoint) <-- primero mete esta función
*(.text) <-- despues el resto de funciones
+-------------------------------+
| kmain_entrypoint              | <- PRIMERO
+-------------------------------+
| otras funciones .text         |
| configurar_cosas()            |
| contemplar_existencia()       |
| handlear_interrupciones()     |
| etc.                          |
+-------------------------------+
*/
__attribute__((section(".text.kmain_entrypoint")))
void kmain(void) { [...] }

Las secciones siguientes como .bss, .rodata y .data son estandard pero las que quedan no, son específicas del diseño de mi Kernel.

La sección .idt la vamos a necesitar en un futuro cuando definamos la tabla de interrupciones de alguna manera como la siguiente:

    .idt BLOCK(4K) : ALIGN(4K)
    {
        *(.idt)
    }
__attribute__((section(".idt")))
struct idt_entry idt[256];

Lo mismo hacemos con la sección .dma que vamos a usar en un futuro también para guardar unos bùferes:

.dma BLOCK(4K) : ALIGN(4K)
{
    *(.dma)
}
__attribute__((section(".dma")))
uint8_t dma_buffer[...];

Entonces más o menos el layout de memoria del Kernel quedaría más o menos así:

0x10000

+--------------------------+
| .text                    |
|                          |
| kmain()                  | <- primera función de todas, el entrypoint
| fs_init()                |
| fs_read()                |
| idt_init()               |
| ...                      |
+--------------------------+
| .rodata                  |
| strings / constantes     |
+--------------------------+
| .data                    |
| variables inicializadas  |
+--------------------------+
| .bss                     |
| variables sin init       |
+--------------------------+
| .idt                     |
| IDT                      |
+--------------------------+
| .dma                     |
| DMA buffers              |
+--------------------------+
|                          |
| end                      |
+--------------------------+

La Gotita

Finalmente para unir y pegar con "La Gotita" todas las piezas del rompecabezas y tener una imagen para chantarle a qemu, bochs o una máquina real, necesitamos generar una imagen donde esté el bootloader, stage1 y el kernel y cada uno de sus binarios estén ubicados de manera tal el bootloader y el stage1 copien las cosas en memoria y que en el proceso de recompilar y programar, no se pisen los binarios entre si y estén lo suficientemente equiespaciados.

Para lograr esto, tuvimos que sentarnos con un papel y decir cuanto espacio suficiente siendo "suficiente" una variable que cambió bastante a lo largo del desarrollo de este Kernel. Más o menos este es mi layout del Floppy 3.5" 1.44MB que uso para meter el Sistema Operativo en él (update agosto 2026):

Sectores Offset Contenido Tamaño
1 0x000000 Bootsector (boot.bin) 512 B
2–8 0x000200–0x000FFF Espacio reservado / Stage1 hasta 7 sectores
9–136 0x001000–0x10FFF Kernel (kernel.bin) hasta 128 sectores
137–2880 0x11000–0x167FFF Filesystem / espacio restante ~1.37 MB
#!/bin/sh
echo "Cleaning up..."
make clean

echo "Compiling tOSh Kernel..."
make

kernel_size=$(stat -c %s kernel.bin)
kernel_sectors=$(( (kernel_size + 511) / 512 ))

MAX_KERNEL_SECTORS=128
KERNEL_FIRST_SECTOR=9
echo "kernel size: $kernel_size bytes, kernel sectors: $kernel_sectors"


if [ "$kernel_sectors" -gt "$MAX_KERNEL_SECTORS" ]; then
    echo "ERROR: kernel too large!"
    echo "Maximum: $((MAX_KERNEL_SECTORS * 512)) bytes"
    exit 1
fi

echo "Compiling bootsector and stage1..."
echo "taking into account the kernel size of $kernel_size bytes, which is $kernel_sectors sectors..."

echo "bootsector.asm -> boot.bin"
nasm -f bin bootsector.asm -o boot.bin


echo "Kernel first sector: $KERNEL_FIRST_SECTOR"

echo "stage1.asm -> stage1.bin with KERNEL_FIRST_SECTOR=$KERNEL_FIRST_SECTOR and KERNEL_SECTORS=$kernel_sectors"
nasm -D KERNEL_FIRST_SECTOR=$KERNEL_FIRST_SECTOR -D KERNEL_SECTORS=$kernel_sectors -f bin -l stage1.lst stage1.asm -o stage1.bin

stage1_size=$(stat -c%s stage1.bin)
stage1_sectors=$(( (stage1_size + 511) / 512 ))
echo "Stage1 size: $stage1_size bytes, $stage1_sectors sectors"

#nasm -f bin kernel/kernel.asm -o kernel.bin

echo "Creating floppy image..."
dd if=/dev/zero of=tOSh.img bs=512 count=2880

echo "Copying Bootsector at position 0, block 512 bytes..."
dd if=boot.bin of=tOSh.img conv=notrunc # copy the bootsector

echo "Copying stage1 at position 1, block 512 bytes..."
dd if=stage1.bin of=tOSh.img conv=notrunc bs=512 seek=1 # copy stage1

echo "Copying kernel at position $((KERNEL_FIRST_SECTOR -1)) (sector $KERNEL_FIRST_SECTOR), block 512 bytes..."
dd if=kernel.bin of=tOSh.img conv=notrunc bs=512 seek=$(($KERNEL_FIRST_SECTOR -1)) # copy kernel

Ejecutando este script de bash ./build.sh crea el sistema operativo en una imagen de floppy lista para ser booteada en una máquina o un emulador. Hasta acá lo único que hacemos es escribir X en memoria (empezando por el mapping de VGA) hasta que la CPU crashea por escribir fuera de los límites de memoria.

Este es el framework "básico" para poder empezara desarrollar un Kernel en C. A partir de ahora podemos seguir configurando la CPU, programarle drivers y aprovechar todas las bondades de un lenguaje de alto nivel como C abandonando así, el assembly.

Referencias