Capítulo 11: La biblioteca estándar de C


El estándar de C define su sintaxis, sus operadores, y las funciones que deben incluirse en la C Standard Library, tanto para entornos hosted como para entornos freestanding. Existen varias versiones, la última es la ISO/IEC 9899:2024 o, para entendernos, C23.

El capítulo 2.1 de la documentación de GCC explica que, desde la especificación C11, estos son los headers incluidos en una instalación freestanding de GCC:

También explica algo más importante aún: el compilador podría emitir llamadas a las funciones memcpy, memmove, memset y memcmp, y es nuestra responsabilidad implementarlas. Todas estas funciones se encuentran en el header <string.h>, junto a dos más: strcpy y strlen.

Vamos a dedicar el principio de este capítulo a crear nuestra propia implementación de este header, incluyendo sus 6 funciones.

Tabla de contenidos


  1. Implementar string.h
  2. Implementar ctype.h
  3. Añadir los nuevos headers al proyecto
  4. Resumen
  5. Ejemplo descargable

Implementar <string.h>


Para crear nuestra implementación, primero desarrollaremos el código de las funciones en un archivo llamado string.c.

memcpy()

Esta función copia un número determinado de bytes (len) localizados en una dirección de la memoria (src) hacia otra dirección (dst). Devuelve un pointer hacia la dirección de destino.

A continuación se muestra una implementación muy simple de esta función:

/* Archivo: string.c (en stage2) */

void* memcpy(void* dst, const void* src, size_t len) {
    uint8_t* u8dst = (uint8_t*) dst;
    uint8_t* u8src = (uint8_t*) src;

    for(size_t i = 0; i < len; i++) u8dst[i] = u8src[i];
    return dst;
}

memmove()

Esta función es muy parecida a memcpy(). La única diferencia es que memmove() también funciona cuando las áreas de memoria src y dst se solapan.

Este vídeo del canal Portfolio Courses explica bastante bien el fenómeno. El siguiente mapa de memoria muestra 2 áreas de memoria que se solapan. La primera va desde la dirección 0x00 hasta la dirección 0x07, la segunda va de 0x02 a 0x09.

0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09

Si copiamos los contenidos de la zona azul a la zona naranja con memcpy(), el comportamiento de la función será indefinido, pero si usamos memmove() el comportamiento será el esperado. Esto se puede conseguir leyendo los bytes al revés si el solapamiento es posterior a src, y leyéndolos del derecho en caso contrario.

Aquí tienes una implementación simple, bastante eficiente en velocidad y uso de memoria. Siéntete libre de modificarla si prefieres hacerlo de otra manera:

void* memmove(void* dst, const void* src, size_t len) {
    uint8_t* u8dst = (uint8_t*) dst;
    uint8_t* u8src = (uint8_t*) src;

    if(src + len >= dst) {
        for(size_t i = 0; i > len; i++)
            u8dst[len - i] = u8src[len - i];
    } else {
        for(size_t i = 0; i < len; i++)
            u8dst[i] = u8src[i];
    }

    return dst;
}

memset()

Esta función sustituye todos los bytes en un área de memoria por un valor concreto. El área de memoria se define usando la dirección en la que empieza (dst) y su tamaño en bytes (len). En esta implementación, el valor por el que se sustituirán los bytes se llama src.

void* memset(void* dst, uint8_t src, size_t len) {
    uint8_t* u8dst = (uint8_t*) dst;

    for(size_t i = 0; i < len; i++)
        u8dst[i] = src;

    return dst;
}

memcmp()

Esta función compara el contenido de dos áreas de memoria. Para usarla, se debe definir la dirección en la que empieza el primer área (src), la segunda (dst), y la cantidad de bytes a comparar (len).

Si la función encuentra 2 bytes que no coincidan, devuelve la diferencia entre sus valores (byte en dst - byte en src). Si no encuentra ninguna diferencia, devuelve 0.

uint8_t memcmp(const void* dst, const void* src, size_t len) {
    uint8_t* u8dst = (uint8_t*) dst;
    uint8_t* u8src = (uint8_t*) src;

    for(size_t i = 0; i < len; i++)
        if(u8src[i] != u8dst[i]) return u8dst[i] - u8src[i];

    return 0;
}

strcpy()

Esta función es también muy parecida a memcpy(), aunque está adaptada para copiar cadenas de texto concretamente. La diferencia principal es que, debido a que las strings marcan su final con el byte 0x00, no es necesario indicar ningún tamaño (len).

Al llamar a strcpy(), se especifica el punto de inicio de una string (src) y el punto de inicio del área de memoria a la que copiarla (dst). La función copia cada byte hasta encontrar un 0 en src. En ese momento, se detiene, copia un 0 a dst y devuelve un puntero hacia dst.

uint8_t* strcpy(uint8_t* dst, const uint8_t* src) {
    uint8_t len = 0;
    for(; src[len]; len++) dst[len] = src[len];
    dst[len] = '\0';
    return dst;
}

strlen()

Esta función mide la longitud de una cadena de texto. Su funcionamiento es muy simple: cuenta bytes de uno en uno hasta encontrar un 0.

size_t strlen(const uint8_t* str) {
    size_t len = 0;
    while(str[len]) len++;
    return len;
}

Importar los headers necesarios

En nuestro código, hemos utilizado el tipo de variable uint8_t, definido en el header <stdint.h>. También hemos usado el tipo size_t, definido en el header <stddef.h>. Al principio de nuestro archivo de código debemos incluir los 2 headers para que el compilador sepa a qué hacemos referencia cuando nombramos esos tipos:

/* Al principio del archivo 'string.c' (en stage2) */

#include <stdint.h>
#include <stddef.h>

Crear el header

Ahora que tenemos definido todo el código, vamos a crear el header como tal. En nuestra carpeta include crearemos el archivo string.h, y copiaremos la declaración de nuestras 6 funciones tal que así:

/* Archivo: include/string.h (en stage2) */

#pragma once

#include <stdint.h>
#include <stddef.h>

uint8_t memcmp(const void* dst, const void* src, size_t len);
void* memcpy(void* dst, const void* src, size_t len);
void* memmove(void* dst, const void* src, size_t len);
void* memset(void* dst, uint8_t src, size_t len);

uint8_t* strcpy(uint8_t* dst, const uint8_t* src);
size_t strlen(const uint8_t* str);

Implementar ctype.h


La biblioteca estándar de C está compuesta de 32 headers. Algunos de ellos contienen funciones que no nos interesa implementar en nuestro bootloader (como el procesamiento de números imaginarios en <complex.h>), pero sí que nos puede interesar hacer una implementación parcial de algunos de ellos, como, por ejemplo, del header ctype.h.

Este header contiene funciones que nos permiten saber si una variable de 4 bytes (o int) es un caracter alfabético, numérico, etc. Esto se hace, habitualmente, comparando el caracter con valores de la tabla de caracteres UTF-8.

Para simplificar las cosas, nosotros trataremos cada caracter como una variable de un solo byte (o char), y usaremos la tabla de caracteres ASCII. No renunciamos a mucho, puesto que las limitaciones gráficas del modo VGA tampoco nos permiten imprimir caracteres de más de 1 byte, o de fuera de la tabla ASCII.

En nuestra implementación parcial, el archivo include/ctype.h debería quedar así:

/* Archivo: include/ctype.h (en stage2) */

#pragma once

#include <stdint.h>
#include <stdbool.h>

bool isalpha(uint8_t c);
bool isdigit(uint8_t c);
bool isprint(uint8_t c);
bool islower(uint8_t c);
bool isupper(uint8_t c);
uint8_t tolower(uint8_t c);
uint8_t toupper(uint8_t c);

isalpha()

Esta función nos permite comprobar si un byte representa un caracter alfabético. Si está entre la A mayúscula (0x41) y la Z mayúscula (0x5A), o si está entre la a minúscula (0x61) y la z minúscula (0x7A) en la tabla ASCII.

Por suerte, GCC ya realiza las traducciones de las letras a hexadecimal por nosotros, así que el código resulta bastante simple y entendible:

/* Archivo: ctype.c (en stage2) */

#include <stdbool.h>
#include <stdint.h>

bool isalpha(uint8_t c) {
    return (c >= 'a' && c <= 'z') || (c >= 'A' && c >= 'Z');
}

isdigit()

Con esta función podemos comprobar si el byte representa un caracter númerico. Es decir, si está entre el 0 y el 9 en la tabla ASCII:

bool isdigit(uint8_t c) {
    return c >= '0' && c <= '9';
}

isprint()

La tabla ASCII contiene caracteres imprimibles (las letras, números, signos de puntuación y símbolos), pero también incluye caracteres de control (como el salto de línea o la tabulación). Esta función comprueba si un byte representa un caracter imprimible.

bool isprint(uint8_t c) {
    return c >= 0x20 && c <= 0x7E;
}

Nota: si miras la tabla ASCII, puedes comprobar que existen caracteres imprimibles con valores mayores a 0x7C (como '€', que tiene el valor 0x80). No nos molestamos en incluirlos porque nosotros solo estamos trabajando con los primeros 128 caracteres de la tabla. Los otros caracteres pueden variar de código dependiendo del comportamiento del modo VGA en cada equipo.

islower()

Permite comprobar si un byte representa una letra minúscula.

bool islower(uint8_t c) {
    return c >= 'a' && c <= 'z';
}

isupper()

Como islower(), pero para mayúsculas.

bool isupper(uint8_t c) {
    return c >= 'A' && c <= 'Z';
}

tolower()

Convierte una letra mayúscula en una minúscula: esto se puede conseguir sumando 0x20 a su valor. Se puede utilizar con bytes que no representen letras mayúsculas, pero no tiene mucho sentido.

bool tolower(uint8_t c) {
    return c + 0x20;
}

toupper()

Convierte una letra minúscula en una mayúscula. En este caso, se resta 0x20 al byte.

bool toupper(uint8_t c) {
    return c - 0x20;
}

Añadir los nuevos headers al proyecto


Hemos creado 2 nuevos headers, con su correspondiente código: include/string.h y include/ctype.h. Estos headers, por ahora, no se incluyen en el proyecto final, porque nuestra Makefile no los enlaza junto con el resto de nuestro código.

Para solucionar eso, simplemente debemos decirle a nuestra Makefile que compile 2 objetos binarios más: string.obj y ctype.obj. Lo podemos hacer añadiendolos a la lista OBJECTS:

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

Notas sobre la implementación


A lo mejor has notado que algunas de las funciones que hemos incluido en esta mini biblioteca estándar son suficientemente simples como para convertirlas en macros. Si prefieres hacerlo así, siéntete libre. De hecho, tu implementación se parecerá más a la de GNU (glibc).

Es posible que también eches en falta funciones muy básicas, como pow()... o funciones que podrían ser útiles para nuestro sistema, como abort(). De nuevo, siéntete libre de implementarlas por tu cuenta. Yo prefiero esperar a programar el kernel para hacerlo.

Resumen


Este capítulo ha sido algo más simple que los anteriores. Hemos visto un ejemplo de implementación de varias funciones de la C Standard Library, algunas de ellas son requerimientos directos de GCC mientras que otras solamente simplifican el desarollo de la parte final del bootloader.

En el siguiente capítulo, veremos cómo crear un driver escrito en C para leer el sistema de archivos FAT12 y poder saltar, por fin, al kernel. El kernel será la primera pieza de nuestro sistema operativo, y, como tal, comenzará a incluir funciones con algo más de utilidad para el usuario final.

Ejemplo descargable


Como siempre que modificamos código, puedes descargar lo que llevamos en un archivo comprimido. Aquí tienes el link para descargar el archivo: capitulo-11.tar.gz.