wandres.dev
MAGO · Freestanding y no-libc

Reimplementar lo mínimo

Por qué el compilador sigue exigiendo `memcpy`, `memset`, `memmove` y `memcmp` aunque hayas quitado la libc, la trampa de recursión infinita que se lleva por delante a medio mundo, y cómo escribirlas correctas y rápidas.

⏱ 18 min

Quitas la libc, compilas con -nostdlib y el enlazador te reclama memcpy. Tú no lo escribiste en ninguna parte. No es un error: es una cláusula del contrato entre el lenguaje y su implementación que casi nadie ha leído, y que convierte a cuatro funciones de manipulación de memoria en parte del entorno de ejecución mínimo de C.

🎯 Al terminar esta lección sabrás
  • Saber qué construcciones del lenguaje generan llamadas que tú no escribiste.
  • Evitar la recursión infinita al implementar memset y memcpy.
  • Respetar las firmas exactas y la semántica de solape y de retorno.
  • Escribir versiones palabra a palabra correctas en objetivos con alineación estricta.

El contrato oculto: las cuatro que el compilador genera

La norma permite explícitamente que una implementación autónoma emita llamadas a memcpy, memmove, memset y memcmp, y tanto GCC como Clang documentan que exigen que el entorno freestanding las proporcione: no son parte de la biblioteca opcional, son parte del generador de código. El motivo es que hay construcciones del propio lenguaje cuya traducción natural es una copia o un relleno de tamaño arbitrario, y el compilador no va a desenrollar mil iteraciones en línea:

struct marco { uint8_t datos[4096]; uint32_t crc; };

void ejemplo(struct marco *destino, const struct marco *origen)
{
    *destino = *origen;                 /* asignacion de estructura: memcpy */
    struct marco local = {};            /* inicializador vacio de C23: memset */
    uint8_t tabla[512] = { 1, 2, 3 };   /* el resto se pone a cero: memset */
    (void)local; (void)tabla;
}

A esas construcciones se suma el reconocimiento de idiomas de bucle: el optimizador detecta que un bucle escribe un valor constante en un rango contiguo y lo sustituye por una llamada a memset, que copia elemento a elemento y lo sustituye por memcpy, o que compara byte a byte hasta la primera diferencia y lo sustituye por memcmp. En GCC ese paso es -ftree-loop-distribute-patterns; en Clang, loop-idiom-recognize. Ambos están activos en los niveles de optimización que usarás.

⚠️
`-ffreestanding` no desactiva la generación

Es el malentendido central del nivel. -ffreestanding implica -fno-builtin, que impide al compilador sustituir por código en línea las llamadas que tú escribes. No le impide en absoluto emitir llamadas a las cuatro funciones de memoria a partir de construcciones del lenguaje. La bandera reduce las optimizaciones basadas en semántica de biblioteca; no elimina la dependencia.

La trampa de la recursión infinita

Ahora junta las dos piezas anteriores y observa qué ocurre con la implementación más obvia de memset:

void *memset(void *s, int c, size_t n)
{
    unsigned char *p = s;
    while (n--) *p++ = (unsigned char)c;   /* bucle de relleno */
    return s;
}

El optimizador reconoce el idioma, concluye alegremente que ese bucle es un memset, y lo sustituye por una llamada a memset. Que es la función que está compilando. El resultado es recursión infinita: desbordamiento de pila en un sistema con MMU, y un HardFault silencioso en un microcontrolador. Es un fallo real y recurrente en bootloaders y firmware, y solo aparece al subir el nivel de optimización, que es cuando peor sienta.

flowchart TD
A[Escribes memset como bucle de bytes] --> B[Compilas con optimizacion activa]
B --> C[El paso de reconocimiento de idiomas ve un relleno contiguo]
C --> D[Lo sustituye por una llamada a memset]
D --> E[memset se llama a si misma sin fin]
E --> F[Desbordamiento de pila o HardFault]
A --> G[Con la mitigacion adecuada]
G --> H[El bucle sobrevive y el simbolo se resuelve]
style F fill:#f38ba8,color:#11111b
style H fill:#a6e3a1,color:#11111b

Hay tres mitigaciones, en orden de preferencia. La primera es acotar la excepción a la función concreta, con el atributo que entienda cada compilador:

#if defined(__clang__)
#  define SIN_IDIOMA __attribute__((no_builtin("memset")))
#else
#  define SIN_IDIOMA __attribute__((optimize("no-tree-loop-distribute-patterns")))
#endif

SIN_IDIOMA void *memset(void *s, int c, size_t n) { /* ... */ }

La segunda es la bandera equivalente aplicada al fichero completo de las rutinas de memoria, que es más contundente pero también más fácil de auditar en el sistema de construcción:

gcc -std=c23 -ffreestanding -O2 -fno-tree-loop-distribute-patterns \
    -c memoria.c -o memoria.o

La tercera es el truco portable de marcar el puntero como volatile dentro del bucle: el optimizador no puede reordenar ni fusionar accesos volátiles, así que el idioma deja de reconocerse. Funciona en cualquier compilador, pero castiga el rendimiento de forma brutal justo en la función más caliente del sistema, así que úsalo solo como diagnóstico.

Escribirlas bien: firmas, solape y palabras

Las firmas deben coincidir exactamente con las de la norma. El compilador conoce la semántica de estos nombres y razona con ella; una firma divergente no es un detalle de estilo, es una invitación a que el optimizador asuma cosas falsas.

void *memcpy(void *restrict s1, const void *restrict s2, size_t n);
void *memmove(void *s1, const void *s2, size_t n);
void *memset(void *s, int c, size_t n);
int   memcmp(const void *s1, const void *s2, size_t n);

Tres puntos de semántica que se olvidan constantemente: el restrict de memcpy es una precondición y no una optimización, de modo que solapar las regiones es comportamiento indefinido y por eso existe memmove; memset recibe un int que se convierte a unsigned char antes de escribirse, así que pasar 256 rellena de ceros; y memcmp compara los bytes como unsigned char y devuelve un signo, no necesariamente la diferencia.

memmove, por su parte, debe elegir la dirección de copia según el solape. La comparación de punteros a objetos distintos es formalmente comportamiento indefinido, así que la forma limpia es comparar sus valores enteros:

void *memmove(void *s1, const void *s2, size_t n)
{
    unsigned char *d = s1;
    const unsigned char *s = s2;

    if ((uintptr_t)d - (uintptr_t)s >= n)     /* no hay solape peligroso */
        return memcpy(s1, s2, n);

    d += n; s += n;                            /* copia descendente */
    while (n--) *--d = *--s;
    return s1;
}

Esa única resta sin signo captura los dos casos seguros a la vez —destino por debajo del origen, y separación mayor o igual que la longitud—, y es el mismo truco que usa musl. Para el rendimiento, en cambio, la regla en objetivos empotrados es no asumir accesos no alineados: un Cortex-M0 lanza una excepción de uso ante una carga de palabra desalineada, y allí donde el hardware la tolera el coste sigue siendo real. El patrón canónico es cabeza, cuerpo y cola:

#define DESAL(p) ((uintptr_t)(p) & (sizeof(size_t) - 1))

void *memcpy(void *restrict s1, const void *restrict s2, size_t n)
{
    unsigned char *d = s1;
    const unsigned char *s = s2;

    while (n && DESAL(d)) { *d++ = *s++; n--; }   /* cabeza: alinea destino */
    if (!DESAL(s)) {                              /* cuerpo palabra a palabra */
        size_t *dw = (size_t *)d;
        const size_t *sw = (const size_t *)s;
        for (; n >= sizeof(size_t); n -= sizeof(size_t)) *dw++ = *sw++;
        d = (unsigned char *)dw; s = (const unsigned char *)sw;
    }
    while (n--) *d++ = *s++;                      /* cola */
    return s1;
}

Con strlen aparece una sutileza extra. La técnica clásica lee una palabra completa y detecta el byte nulo con aritmética SWAR, pero eso implica leer más allá del terminador. Es seguro solo si alineas primero, porque una lectura alineada nunca cruza el límite de la página que contiene el último byte válido:

#define ONES  ((size_t)-1 / 255)                  /* 0x0101...01 */
#define HIGHS (ONES * 128)                        /* 0x8080...80 */
#define HASCERO(x) (((x) - ONES) & ~(x) & HIGHS)

size_t strlen(const char *s)
{
    const char *a = s;
    for (; DESAL(s); s++) if (!*s) return s - a;  /* alinea leyendo bytes */

    const size_t *w = (const size_t *)s;
    while (!HASCERO(*w)) w++;                     /* palabra a palabra */
    for (s = (const char *)w; *s; s++) { }        /* localiza el byte exacto */
    return s - a;
}
Las cuatro funciones que sostienen el lenguaje

Hay algo profundamente revelador en descubrir que C, el lenguaje que presume de no imponer un runtime, en realidad exige cuatro funciones de manipulación de memoria incluso en su modo más desnudo. No es una inconsistencia: es el reconocimiento honesto de que el modelo de objetos de C —donde una estructura es un bloque contiguo de bytes copiable— necesita una primitiva de copia de tamaño variable, y esa primitiva no cabe en el juego de instrucciones de forma universal. Copiar memoria es el verbo fundamental del lenguaje, y cuando quitas todo lo demás, ese verbo es lo que queda. Por eso estas cuatro funciones son las más optimizadas del mundo: en glibc hay versiones seleccionadas en tiempo de carga según las extensiones vectoriales de tu CPU, porque una fracción medible de todos los ciclos ejecutados en todos los ordenadores del planeta transcurre dentro de ellas. Y por eso la trampa de la recursión infinita es tan elegante en su ironía: el compilador es tan bueno reconociendo el idioma «copiar memoria» que lo reconoce incluso cuando estás intentando definirlo por primera vez. Has llegado al punto en que el lenguaje se muerde la cola, y ahora sabes por qué.

⚔️ Tu propia biblioteca de memoria
  1. Implementa las cuatro funciones y strlen, compílalas con -ffreestanding -O2 y verifica en el desensamblado que no hay recursión.
  2. Reproduce el fallo a propósito: quita la mitigación, compila con -O3 y observa la llamada recursiva con objdump -d.
  3. Escribe un test diferencial en un binario hosted que compare tu implementación con la de la libc para todas las longitudes de 0 a 128 y todos los desalineamientos de 0 a 15.
  4. Verifica memmove con solapes en ambas direcciones y con la distancia exacta igual a la longitud, que es el caso frontera de la resta sin signo.
  5. Mide con perf tu memcpy frente al de glibc para 8, 64, 4096 y 1 MiB, y explica la forma de la curva.