Capítulo 10: Implementar printf()


Al aprender cualquier lenguaje de programación, una de las primeras cosas que se hacen es crear un Hello, world!, un programa que muestra un texto de bienvenida en la consola. Nosotros ya hicimos uno en assembly al comenzar con el bootloader. Esto es el ejemplo más básico de "Hello, World!" en C:

#include <stdio.h>

int main() {
    printf("Hello, World!");
    return 0;
}

Actualmente, tenemos muchos problemas para ejecutar esto en nuestro bootloader, por ejemplo:

Estamos todavía en el bootloader, por lo que no deberíamos depender de que nuestro sistema operativo tenga instalada una biblioteca de C. En general, cuantas menos suposiciones hagamos sobre el sistema operativo, mejor. Por eso, mi propuesta es implementar nosotros mismos una versión reducida del header stdio.h en nuestro código.

Para ello, primero vamos a crear un archivo de código como tal, que he decidido llamar stdio.c. Para mantener un mínimo orden, seguiremos la norma de que si un header se llama loquesea.h, el código de sus funciones se definirá en el archivo loquesea.c. Siéntete libre de usar cualquier otra estructura si esa no te convence. Por ejemplo, la usada por la OSDev Wiki en el tutorial Meaty Skeleton.

Antes de crear el archivo, también vamos a borrar las funciones putc() y write() de nuestro archivo main.c. Son funciones temporales, bastante inseguras, que creamos únicamente para demostrar que el salto a C se producía sin ningún problema. Nuestro archivo main.c debería quedar así:

void quit() {
    for(;;);
}

void cstart() {
    quit();
}

Tabla de contenidos


  1. Fundamentos de C: headers, tamaño de variables y punteros
  2. Funciones para escribir caracteres
  3. Formatos: letras, números y strings
  4. La función final: implementar printf()
  5. Usar nuestro printf() desde cualquier archivo
  6. Compilar nuestro código con los nuevos archivos
  7. Resumen
  8. Ejemplo descargable

Fundamentos de C: headers, tamaño de variables y punteros


Al programar en C, te encontrarás principalmente con archivos de código (loquesea.c) y archivos de cabecera o headers (loquesea.h). En los primeros, se definen variables y funciones y se redacta el código de estas últimas. Los headers son parecidos, con la única diferencia de que no desarrollan código, solo declaraciones que pueden ser compartidas entre varios archivos .c.

Muchas de las funciones que los programadores usan rutinariamente en C están definidas en headers preinstalados en el sistema operativo. Estos headers forman la biblioteca estándar de C, y proveen utilidades muy básicas como imprimir texto en pantalla usando printf().

Nuestro compilador, GCC para i686-elf, no soporta el uso de toda la biblioteca estándar de C, pero sí viene con algunos headers que podemos utilizar, como stdint.h, que define tipos de variable con tamaño fijo. La OSDev Wiki tiene una lista de todos los headers a los que podemos acceder en nuestro proyecto en su artículo C Library.

El header stdint.h provee tipos de variable con tamaños determinados, aquí tienes una tabla con algunos de los tipos que más utilizaremos:

Nombre Tamaño Valor mín. Valor máx.
uint8_t 8 bits 0 255
uint16_t 16 bits 0 65.535
uint32_t 32 bits 0 4.294.967.296
uint64_t 64 bits 0 18.446.744.073.709.551.616
int8_t 8 bits -127 127
int16_t 16 bits -32.767 32.767
int32_t 32 bits -2.147.483.647 2.147.483.647
int64_t 64 bits -9.223.372.036.854.775.807 9.223.372.036.854.775.807

Es muy importante ser consciente de qué tipo usamos en cada momento. Por ejemplo, para definir caracteres ASCII, lo mejor es usar variables del tipo uint8_t, porque ningún caracter tiene una ID negativa y el máximo es 255. Además, todos los caracteres codificados en ASCII ocupan un byte.

Solo nos queda explicar una cosa: ¿qué tiene que ver esto con los punteros (o pointers)? Pues verás, estas dos lineas declaran pointers que apuntan exactamente a la misma dirección de la memoria:

uint8_t* screen8 = (uint8_t*) 0xB8000;
uint32_t* screen32 = (uint32_t*) 0xB8000;

Lo que varía entre ellos es únicamente el tamaño de variable. Mientras que screen8 cuenta cada byte, screen32 cuenta en unidades de 4 bytes. Pongamos que el contenido de la memoria es el siguiente:

0xB8000 0xB8001 0xB8002 0xB8003 0xB8004 0xB8005 0xB8006 0xB8007
0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07

En este caso si desrreferenciamos los dos punteros, sus valores serán distintos:

uint8_t a = *screen8; // la variable 'a' tendrá el valor '0x00'.
uint16_t b = *screen16; // la variable 'b' tendrá el valor '0x03020100'

Al desrreferenciar el puntero screen8 solo recogemos el valor del primer byte (0x00). Sin embargo, al desrreferenciar screen32 obtenemos el valor de los 4 primeros bytes a partir de la dirección a la que apunta. Además, los obtenemos invertidos, porque al pasar bytes separados al valor de una única variable, se presentan en orden inverso (0x03020100).

La diferencia de tamaño tiene todavía más implicaciones. Por ejemplo, el puntero screen8 apunta a la dirección 0xB8000, si queremos sacar el valor almacenado en el siguiente byte de la memoria, podemos usar un subscript operator (screen8[1]), para el siguiente screen8[2], screen8[3], etc... Si usamos este operador con el puntero de 32 bits, aumentará 32 bits cada vez, produciendo este comportamiento:

uint8_t a = screen8[0]; // tendrá el valor '0x00' (equivalente a '*screen8')
uint8_t b = screen8[1]; // tendrá el valor '0x01'
uint8_t c = screen8[2]; // tendrá el valor '0x02'
uint8_t d = screen8[3]; // tendrá el valor '0x03'

uint16_t e = screen16[0]; // tendrá el valor '0x03020100' (equivalente a '*screen16')
uint16_t f = screen16[1]; // tendrá el valor '0x07060504'

En resumen: es muy importante que los punteros tengan el mismo tamaño que los objetos a los que queremos apuntar. Si son caracteres, 8 bits, si son números más grandes, 16 bits, 32... etc. Si has programado en otros lenguajes, puedes interpretar los punteros como arrays que empiezan en un punto fijo de la RAM. Es importante también destacar que, en nuestro caso, el puntero en sí (y no su contenido) siempre ocupará 32 bits, pongamos el tipo que pongamos.

Funciones básicas para escribir caracteres


Como ya hemos dicho antes, tenemos que tratar al buffer VGA como una cuadrícula de 80 caracteres de ancho y 25 de alto. Esto implica que se pueden usar coordenadas para escribir cualquier caracter (por ejemplo: el caracter 5 de la linea 2 sería equivalente a la posición x=5; y=2). Cada celda de la cuadrícula ocupa exactamente 2 bytes en la memoria.

Este planteamiento nos permitiría escribir palabras enteras, como Hola, escribiendo una H en la coordenada (0,0), una o en (1,0), una l en (2,0) y una a en (3,0). Pero este no es el comportamiento esperado por el usuario. El usuario espera que el terminal salte a la siguiente posición automáticamente cada vez que escriba una letra, y no tener que pensar en las coordenadas de la siguiente, sobre todo si estamos en un fin de linea o de pantalla.

Por todo esto, nuestras primeras funciones, que nos permitirán escribir libremente usando coordenadas, serán privadas, solo para uso interno dentro de stdio.c, y servirán de base para funciones que se asemejan más al comportamiento esperado por el usuario.

Nota importante: durante este manual, pondremos una barrabaja (_) antes del nombre de las funciones privadas, para diferenciarlas de las globales, que sí se podrán usar desde cualquier archivo. Esto es una decisión arbitraria, puramente estética y organizativa.

La sección de variables

Nuestro archivo stdio.c comenzará con una sección en la que declararemos nuestras variables. Por ahora, la única que necesitaremos es un puntero hacia la dirección del buffer VGA.

Dado que cada caracter, junto a sus colores de frente y fondo, ocupará 2 bytes, importamos el header stdint.h le asignamos el tipo uint16_t*. Además, como queremos usar la variable en distintas funciones de nuestro código, la declaramos con la keyword static:

// Archivo: 'src/bootloader/stage2/stdio.c'

#include <stdint.h>

static uint16_t* screen = (uint16_t*) 0xB8000;

En esta sección incluiremos todos los headers necesarios para compilar nuestro código y todas las variables que pretendamos poder usar desde cualquier función declarada en este archivo.

_vga_write(): imprimir caracteres usando coordenadas.

Nuestra primera función de escritura será vga_write(uint8_t c, uint16_t color, uint8_t x, uint8_t y). Recibirá un caracter, un código de color, una coordenada X y una coordenada Y, y mostrará nuestra letra en la posición deseada.

Esto, en términos prácticos, es equivalente a escribir en la dirección de memoria 0xB8000 + x + y * 80 2 bytes que incluyan nuestro caracter y su código de color. Estas dos variables se pueden "fusionar" moviendo 8 bits a la izquierda el código de color mediante un LEFT SHIFT (<<) y realizando un OR (|) entre el caracter y el color.

Antes de escribir la función en sí, vamos a crear una carpeta llamada include, y vamos a crear ahí el header stdio.h para definir un par de constantes que usaremos en nuestro código:

// Archivo: 'src/bootloader/stage2/include/stdio.h'

#pragma once

#define VGA_WIDTH 80
#define VGA_HEIGHT 25

Mediante la linea #pragma once, nos aseguramos de que si estas constantes ya están definidas en otra parte de nuestro código, no se redefinan aquí. El resto del código define macros y tipos que nos ayudan a no tener que recordar todos los números que necesitaremos.

Al inicio de nuestro archivo de código stdio.c, vamos a incluir este header para poder usar su contenido.

// Archivo: 'src/bootloader/stage2/stdio.c'

#include "include/stdio.h"

Con todas estas preparaciones ya terminadas, podemos implementar nuestra función _vga_write() como se muestra a continuación. Esta implementación puede considerarse imperfecta y se invita al lector a mejorarla como considere:

void _vga_write(uint8_t c, uint16_t color, uint8_t x, uint8_t y) {
    uint16_t pos = x + y * VGA_WIDTH;
    screen[pos] = c | ((uint16_t) color << 8);
}

_vga_move(): mover el cursor al siguiente lugar.

Cuando escribimos en inglés o español (que son, por ahora, los únicos lenguajes que vamos a soportar) escribimos de izquierda a derecha, desde la parte superior de la página hasta la inferior. Esto se puede trasladar a un modelo en el que comenzamos en la coordenada (0,0) y, por el tamaño de la cuadrícula, terminamos en la (79,24). Para implementar este modelo, debemos declarar la x y la y del cursor como variables globales:

static uint8_t vga_x = 0;
static uint8_t vga_y = 0;
static uint8_t vga_color = 0x0F;

Es fácil pensar que, tras escribir un caracter, lo único que tenemos que hacer es sumarle 1 a la x, pero, ¿qué pasa cuando llegamos al fin de linea? ¿y al fin de página? Para cubrir esos casos, tendremos que dar soporte a los saltos de linea y al scroll de página.

Nuestra función _vga_move(uint8_t x, uint8_t y) recibirá un valor x y un valor y, y moverá el cursor hacia esa posición en la cuadrícula... pero con varias condiciones:

Es importante recordar que esta es una función privada. No es accesible al usuario final y nosotros, como programadores, somos los responsables de no proporcionarle valores que puedan llevar a error, como x=81 o y=26. Si crees que estas situaciones de error se podrán producir en tu código, dedica parte de tu implementación a prevenir estos bugs.

Esta es la implementación que propongo:

void _vga_move(uint8_t x, uint8_t y) {
    // Salto de linea
    if(x == VGA_WIDTH) x = 0, y++;

    // Scroll de página
    if(y == VGA_HEIGHT) {
        for(y = 0; y < VGA_HEIGHT; y++)
            for(x = 0; x < VGA_WIDTH; x++) {
            	// Copiar cada linea, excepto la primera, hacia la anterior.
                if(y) _vga_write(screen[x+VGA_WIDTH*y], screen[x+VGA_WIDTH*y] >> 8, x, y - 1);

                // Sustituir su contenido por espacios con fondo negro
                _vga_write(' ', 0x07, x, y);
            }

        // Mover el cursor hacia el primer caracter de la última linea
        x = 0, y = VGA_HEIGHT - 1;
    }

    // Guardar los cambios
    vga_x = x, vga_y = y;
}

Formatos: letras, números y strings.


_putc(): imprimir un caracter sin proporcionar coordenadas.

Poco a poco, nos vamos acercando a lo que espera el usuario final. La función _putc(uint8_t c) escribirá un caracter en la posición actual del cursor y lo moverá automáticamente a la siguiente posición.

Lo que se presenta a continuación es una implementación naíf, que no tiene en cuenta muchos de los casos de uso de esta función:

void _putc(uint8_t c) {
    _vga_write(c, vga_color, vga_x, vga_y);
    _vga_move(vga_x + 1, vga_y);
}

Esta implementación es capaz de hacer lo que hemos dicho, pero no permite el uso de algunos caracteres especiales que son muy comunes, como el retorno de carro (\r), el salto de linea (\n) y el tabulador (\t). Para imprimir estos caracteres, que en realidad son instrucciones lógicas, vamos a tener que complicar nuestra función un poquito.

Además, la función printf() de la biblioteca estándar de C devuelve el número de caracteres leidos. Para implementar esto correctamente, conviene devolver el número de caracteres leídos en cada función que pueda ser llamada desde printf(), por tanto, siempre que escribamos un caracter, devolveremos el valor 1.

Esta sería la función si le incluimos todos los detalles mencionados:

uint8_t _putc(uint8_t c) {
    switch(c) {
        case '\n':
            _vga_move(vga_x, vga_y + 1);
            return 1;
        case '\r':
            _vga_move(0, vga_y);
            return 1;
        case '\t':
            uint16_t dst = vga_x + (4 - vga_x % 4);
            for(; vga_x < dst && vga_x < VGA_WIDTH; vga_x++)
            	_vga_write(' ', vga_color, vga_x, vga_y);

            if(vga_x == VGA_WIDTH) vga_x = 0, vga_y++;
            return 1;
    }

    _vga_write(c, vga_color, vga_x, vga_y);
    _vga_move(vga_x + 1, vga_y);

    return 1;
}

_puts(): imprimir cadenas de texto enteras.

Para imprimir una cadena de texto, solamente tenemos que pasar cada uno de sus caracteres por nuestra función _putc(). Adicionalmente, podemos ir contando los caracteres escritos para devolverlos como valor de la función:

uint32_t _puts(const uint8_t* s) {
    uint32_t written = 0;
    for(; s[written]; written++) _putc(s[written]);
    return written;
}

_putn(): imprimir números en el terminal.

¿Cómo podemos convertir un número en un caracter? Existen varias formas, pero una de las más intuitivas es guardar una lista de caracteres con todas las cifras numéricas en orden:

const uint8_t digits[] = "01234566789";

A partir de ahí, si yo quiero convertir el número 2 a su caracter ASCII correspondiente, solo tengo que acceder al segundo caracter de la lista digits mediante la siguiente expresión:

uint8_t two_as_ascii_character = digits[2];

El problema es que esto no nos vale para números de varias cifras. Para usar este método, tendremos que extraer las cifras de cada número de una en una. Para ello, dividiremos el número entre 10 hasta que de 0 e iremos guardando el resto de cada división.

3456 / 10 = 345 // resto: 6
 345 / 10 = 34  // resto: 5
  34 / 10 = 3   // resto: 4
   3 / 10 = 0   // resto: 3

// Lista de restos: 6, 5, 4, 3.

Nuestra lista de restos almacena los caracteres en orden inverso. A partir de realizar ese proceso, tan solo nos quedaría atravesar la lista en orden inverso, convertir cada cifra en un caracter ASCII e imprimirlo en pantalla.

A continuación se muestra una función (_num_to_str()) que convierte cualquier número decimal en una lista de caracteres ASCII y guarda el resultado en un buffer. Además, devuelve la longitud del número en cifras:

uint8_t _num_to_str(uint8_t* str, uint64_t num) {
    const uint8_t digits[] = "0123456789";
    
    // Si el número es 0, devolver una string con el caracter '0'.
    if(num == 0) {
        str[0] = '0';
        str[1] = '\0';

        return 1;
    }

    // Dividir entre 10 hasta que el número sea 0.
    // Ir guardando los caracteres ASCII en 'reverse' y la longitud en 'numlen'
    uint16_t numlen = 0;
    uint8_t reverse[20]; // 20: máximo de cifras de un número de 64 bits.
    while(num) {
        reverse[numlen] = digits[num % 10];
        numlen++;

        num /= 10;
    }

    // Invertir los caracteres de 'reverse' y guardarlos en 'str'
    for(uint8_t i = 1; i <= numlen; i++)
        str[i - 1] = reverse[numlen - i];

    // Apuntar un caracter nulo al final de 'str' para indicar el fin de string.
    str[numlen] = '\0';

    // Nota: si no se sabe la longitud que puede tener el núm. final,
    // lo mejor es dejar 21 bytes de espacio en el buffer.

    return numlen;
}

Hecho esto, nuestra función _putn() solo tiene que convertir el número que reciba en una string usando _num_to_str() e imprimir esa misma cadena de texto usando _puts(). Si le indicamos que el número que debe imprimir es negativo, mostrará un signo negativo (-) antes del número.

La condición de si el número es negativo o positivo se transmitirá a través de una variable de tipo bool. Para usar este tipo de variable es necesario incluir el header <stdbool.h> al principio de nuestro archivo stdio.c.

#include <stdbool.h>

// ...

uint16_t _putn(uint64_t num, bool negative) {
    uint8_t written = 0;

    uint8_t str[20];
    _num_to_str(str, num);

    if(negative) written += _putc('-');
    written += _puts(str);
    return written;
}

La función final: implementar printf()


stdarg.h: aceptar un número indefinido de argumentos.

La función printf() tiene un número indefinido de argumentos. Por ejemplo, se puede invocar con solo un argumento:

printf("Hello, World!"); // Imprime: "Hello, World!"

Y también se puede invocar con varios formatos que requieran varios argumentos:

printf("Let's count: %i, %i, %i...", 1, 2, 3); // Imprime: "Let's count: 1, 2, 3..."

La definición formal de este tipo de funciones (func. con argumentos variables o variadic functions) se hace con unos puntos suspensivos (...), tal que así:

uint32_t printf(const uint8_t* fmt, ...) {
    // nuestro código
}

En este punto, el problema es que no podemos usar el valor de esos argumentos, porque no tienen nombre. La solución a ese problema nos la da el header stdarg.h, que define varias funciones interesantes para nuestro caso.

Para mantener nuestro código ordenado, haremos que printf() solo inicie la lista de argumentos, y llame a una función llamada _vprintf(), encargada de hacer todo el trabajo. Este sistema de organización es una versión simplificada del seguido por la mayoría de implementaciones de la C Standard Library (y el recomendado por la OSDev Wiki).

// Incluir stdarg.h al inicio de nuestro archivo
#include <stdarg.h>
// ...

uint32_t printf(const uint8_t* fmt, ...) {
    va_list args;                           // Declaramos una lista (va_list) de argumentos.
    va_start(args, fmt);                    // Inicializamos la lista desde 'fmt' para alante.
    uint32_t written = _vprintf(fmt, args); // Procesar los argumentos
    va_end(args);                           // Cerrar la lista de argumentos.

    return written;
}

A partir de ahí, _vprintf() solo debe recibir como argumento una va_list e ir invocando sus elementos según sea necesario:

uint32_t _vprintf(const uint8_t* fmt, va_list args) {
    // Dentro de esta función, cada vez que necesitemos invocar el
    // siguiente elemento de nuestra va_list, lo haremos con esta
    // función, indicando la lista y el tipo del siguiente argumento.

    uint16_t nextarg = va_arg(args, uint16_t);
}

_vprintf(): la implementación final.

Nuestra implementación va a ser mínima, dado que solo queremos poder poner letras, strings y números enteros de diferentes tamaños (así que faltan, por ejemplo, los pointers y los decimales). Estos son los formateadores que vamos a soportar:

Formato Descripción
%c Caracter ASCII
%d, %i Número en base 10 con signo
%u Número en base 10 sin signo
%s String
%% Escape, para poder escribir el signo %

Si te interesa implementar más formateadores, puedes ver una lista completa con todos los que hay en esta página (cplusplus.com).

Para soportar números de distintos tamaños, tendremos que implementar también los especificadores de longitud. Es decir, poner %hhd mostrará un digito de 8 bits, %hd uno de 16 bits, %d y %ld mostrarán uno de 32 y %lld uno de 64 bits. Esto aplica a todos los formateadores de números. Usar escrificadores incorrectos (como %hhd para un núm. de 64 bits) puede llevar a errores al representar la información.

Nuestra función _vprintf() tendrá 3 estados. En el primero (default), irá imprimiendo cada caracter hasta encontrarse con un %. A partir de ahí, pasará al estado length, en el que comprobará los especificadores de longitud (hh, h, l, ll). Por último, pasará al estado specifier en el que comprobará el tipo de dato a mostrar (c, d, i, u, s, %). Hechos todos los pasos, volverá al primer estado.

Para evitar liarnos con IDs numéricas, vamos a anotar en nuestro header (stdio.h) identificadores para cada estado y cada longitud posible en printf:

// Archivo: 'src/bootloader/stage2/include/stdio.h'

#define PRINTF_STATE_DEF 0
#define	PRINTF_STATE_LEN 1
#define PRINTF_STATE_SPC 2

#define PRINTF_LENGTH_HH 0
#define PRINTF_LENGTH_H 1
#define PRINTF_LENGTH_DF 2
#define PRINTF_LENGTH_L 3
#define PRINTF_LENGTH_LL 4

Y, sin más dilación, aquí está mi propuesta para implementar _vprintf(). Esta es una solución bastante rápida que solo nos servirá para nuestro bootloader. En nuestro kernel diseñaremos una función bastante más compleja.

Nota importante: esta función es usada por printf() y, por ello, debe ser declarada antes que printf(). Si la declaras después, el compilador dará error.

// Archivo: 'src/bootloader/stage2/stdio.c'

uint32_t _vprintf(const uint8_t* fmt, va_list args) {
    uint32_t written = 0;

    uint8_t state = PRINTF_STATE_DEF;
    uint8_t length = PRINTF_LENGTH_DF;

    while(*fmt) {
    	// Este iterador examina cada letra de la string 'fmt' hasta su final.

        switch(state) {
            // Este switch cambia el comportamiento según el estado
            // (DEFAULT, LENGTH o SPECIFIER).

            case PRINTF_STATE_DEF:
                // Estado DEFAULT: imprime caracteres y detecta '%'
                switch(*fmt) {
                    case '%':
                        // Si hay un '%' pasa al estado LENGTH
                        state = PRINTF_STATE_LEN;
                        fmt++;
                        break;
                    default:
                        written += _putc(*fmt);
                        fmt++;
                        break;
                }
                break;
            case PRINTF_STATE_LEN:
                // Estado LENGTH: detecta 'l', 'h', 'll' y 'hh'.
                // Si termina o encuentra cualquier otra letra,
                // salta al estado SPECIFIER.
                switch(*fmt) {
                    case 'l':
                        // Si hay una 'l', aumenta longitud.
                        if(length == PRINTF_LENGTH_L) {
                            length = PRINTF_LENGTH_LL;
                            state = PRINTF_STATE_SPC;
                            fmt++;
                        } else {
                            length = PRINTF_LENGTH_L;
                            fmt++;
                        }
                        break;
                    case 'h':
                        // Si hay una 'h', la disminuye.
                        if(length == PRINTF_LENGTH_H) {
                            length = PRINTF_LENGTH_HH;
                            state = PRINTF_STATE_SPC;
                            fmt++;
                        } else {
                            length = PRINTF_LENGTH_H;
                            fmt++;
                        }
                        break;
                    default:
                        // Ante otro caracter, pasa al estado SPECIFIER.
                        state = PRINTF_STATE_SPC;
                        break;
                }
                break;
            case PRINTF_STATE_SPC:
                // Estado SPECIFIER: detecta formateadores e imprime los argumentos.
                switch(*fmt) {
                    case 'd':
                    case 'i':
                        // %d, %i: imprimir núm. con signo.
                        if(length == PRINTF_LENGTH_LL) {
                            int64_t num = va_arg(args, int64_t);

                            // Si es negativo, lo detecta y lo apunta
                            // pero pasa un positivo a la función _putn().
                            bool negative = num < 0;
                            if(negative) num *= -1;
                            written += _putn(num, negative);
                        } else {
                            int32_t num = va_arg(args, int32_t);

                            bool negative = num < 0;
                            if(negative) num *= -1;
                            written += _putn(num, negative);
                        }
                        break;
                    case 'u':
                        // %u: imprimir núm. sin signo.
                        if(length == PRINTF_LENGTH_LL)
                            written += _putn(va_arg(args, uint64_t), false);
                        else
                            written += _putn(va_arg(args, uint32_t), false);
                        break;
                    case 'c':
                        // %c: imprimir caracter con _putc().
                        written += _putc(va_arg(args, uint32_t));
                        break;
                    case 's':
                        // %s: imprimir string con _puts().
                        written += _puts(va_arg(args, const uint8_t*));
                        break;
                    case '%':
                        // %%: imprimir '%'.
                        written += _putc('%');
                        break;
                }

                // Volver al estado DEFAULT.
                state = PRINTF_STATE_DEF;
                length = PRINTF_LENGTH_DF;
                fmt++;
                break;
        }
    }

    return written;
}

Esta función es algo limitada, pero nos sirve para muchas de las cosas que queremos hacer con nuestro bootloader. A partir de aquí podremos hacer cosas algo más complejas.

Si te interesa mejorar este printf(), te dejo un par de puntos de partida interesantes:

_vga_clear: limpiar la pantalla.

Durante el arranque, hemos ido poniendo textos de prueba. Además, la BIOS también ha llenado la pantalla de textos que ya no nos hacen falta. Ha llegado el momento de borrar todos esos textos para poder ver únicamente los nuestros.

El método para limpiar la pantalla es bastante menos sofisticado de lo que uno se podría imaginar. Consiste, sencillamente, en rellenar la pantalla de espacios manteniendo el color de fondo. Aquí te dejo una implementación básica:

void vga_clear() {
    // Rellenar la pantalla de espacios.
    for(uint8_t y = 0; y < VGA_HEIGHT; y++)
        for(uint8_t x = 0; x < VGA_WIDTH; x++)
            _vga_write(' ', vga_color, x, y);

    // Mover el cursor al punto de inicio.
    _vga_move(0, 0);
}

Usar nuestro printf() desde cualquier archivo


Nuestras funciones para imprimir texto están declaradas dentro del archivo stdio.c, pero fuera de él son inaccesibles. Sin embargo, si declaramos funciones en un header, podremos usarlas desde cualquier archivo que haya incluido ese header, como hemos hecho nosotros con va_start(), por citar una.

¿Y cómo declaramos funciones en un header? Pues basta con replicar la primera línea de la función, sin escribir su código. Por ejemplo, nuestra función vga_clear() está declarada así en stdio.c:

void vga_clear() {
    // código
}

Pues si queremos hacer esta función accesible desde el exterior, en nuestro header (stdio.h) debemos poner lo siguiente:

// Archivo: 'src/bootloader/stage2/include/stdio.h'

void vga_clear();

A partir de ese momento, la función vga_clear() se podrá utilizar desde cualquier archivo que incluya el header (mediante la linea #include "include/stdio.h").

Vamos a añadir también la declaración de nuestro printf() al header. Dado que devuelve un valor del tipo uint32_t y este tipo está definido en el header stdint.h, vamos a tener que incluirlo en el archivo tal que así:

#include <stdint.h>

// ...

uint32_t printf(const uint8_t* fmt, ...);

Gracias a esto, desde nuestro archivo main.c, podremos llamar a estas funciones y poner un mensaje de bienvenida en la pantalla, probando las nuevas funciones de nuestro bootloader:

// Archivo: 'src/bootloader/stage2/main.c'

#include "include/stdio.h"

void quit() {
    for(;;);
}

void cstart() {
    vga_clear();

    printf("Bienvenido al bootloader!\n\r");
    printf("\n");
    printf("Prueba de formatos:\n\r");
    printf("\n");
    printf("Caracter ASCII: %c.\n\r", '$');
    printf("Numeros con signo: %lld, %lld, %ld, %d, %hd, %hhd.\n\r", (int64_t) -1234, (int64_t) 5678, (int32_t) -90, (int32_t) 123, (int16_t) -4567, (int8_t) 89);
    printf("Numeros sin signo: %llu, %lu, %u, %hu, %hhu.\n\r", (uint64_t) 123456, (uint32_t) 7890, (uint32_t) 1234, (uint16_t) 567, (uint8_t) 89);
    printf("Cadena de texto: \"%s\".\n\r", "Holaa!");
    printf("Escape: %%.\n\r");

    quit();
}

Compilar nuestro código con los nuevos archivos


El código está terminado, solamente nos queda añadir los nuevos archivos a la Makefile de la stage2 para que los tenga en cuenta a la hora de compilar el proyecto. Ahora es cuando entra en juego esta parte tan interesante de la Makefile en cuestión:

# Archivo: 'src/bootloader/stage2/Makefile'

# 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

Tenemos un nuevo archivos de código, ya no solo existen entry.asm y main.c. Tenemos que añadir el archivo stdio.c, tal que así:

OBJECTS=\
${DIR_OUTPUT}/entry.obj \
${DIR_OUTPUT}/main.obj \
${DIR_OUTPUT}/stdio.obj

Hecho esto, podemos pasar a compilar nuestro código con make, emularlo con make qemu, depurarlo con make debug o limpiar los archivos de compilación con make clean. Recuerda siempre ejecutar el comando en la carpeta raíz de nuestro proyecto.

Resumen


Este capítulo ha sido, probablemente, el más largo hasta ahora. Hemos aprendido a manejar tipos de variables y pointers en C, los contenidos de los headers stdint.h, stdbool.h y stdarg.h y los hemos usado para implementar una versión mínima de la función printf(), que trabaja con el buffer VGA de la memoria de nuestro equipo.

Es mucha información, y te recomiendo probar bien el código y asegurarte de que has hecho todos los pasos correctamente. Los bugs silenciosos en printf() nos pueden llevar a buscar errores que no existen, o incluso tener errores de memoria que se extiendan a otras partes de nuestro proyecto.

En los siguientes capítulos implementaremos algunas funciones básicas de la C Standard Library y comenzaremos a desarrollar nuestro driver para leer particiones formateadas con el sistema de archivos FAT12.

Ejemplo descargable


En este capítulo te puede venir incluso mejor que en los anteriores usar el ejemplo descargable como referencia según avanzas con tu bootloader.

El archivo es capitulo-10.tar.gz.