wandres.dev
MAGO · Freestanding y no-libc

Hosted frente a freestanding

La dicotomía normativa del estándar C: qué garantiza el lenguaje cuando no hay sistema operativo debajo, qué cabeceras sobreviven en C23, y qué sigue esperando el compilador de ti aunque le digas que no hay libc.

⏱ 16 min

El estándar de C no describe un lenguaje que necesite un sistema operativo: describe dos entornos de ejecución, y solo uno de ellos lo asume. Entender con precisión qué promete la norma en el entorno autónomo —y qué deja explícitamente en manos de la implementación— es lo que separa programar bare metal con método de hacerlo por superstición.

🎯 Al terminar esta lección sabrás
  • Leer la dicotomía hosted/freestanding tal y como la define la cláusula de conformidad.
  • Enumerar el conjunto de cabeceras autónomas de C23 y el criterio que las selecciona.
  • Distinguir lo que garantiza el lenguaje de lo que garantiza la implementación.
  • Detectar el entorno en tiempo de compilación con __STDC_HOSTED__.

Dos entornos, un solo lenguaje

La cláusula de conformidad del estándar define dos clases de implementación conforme, y la diferencia no está en la sintaxis ni en la semántica del lenguaje, sino en dos ejes muy concretos: el arranque y la biblioteca.

🏠

Implementación hosted

El programa empieza en main, con una de las dos firmas prescritas por la norma. La biblioteca estándar debe estar completa. Al retornar de main se ejecuta la terminación normal del programa.

🌌

Implementación freestanding

El nombre y el tipo del punto de entrada quedan definidos por la implementación. El arranque y la terminación son definidos por la implementación. Solo se exige un subconjunto mínimo de cabeceras.

Conviene subrayar lo que no cambia, porque es casi todo. La máquina abstracta sigue siendo la misma: puntos de secuencia, reglas de aliasing estricto, el modelo de objetos y su duración de almacenamiento, la aritmética de punteros, el comportamiento indefinido, _Atomic y su modelo de memoria, constexpr y los atributos de C23. El compilador optimiza exactamente igual. Un desbordamiento de entero con signo sigue siendo comportamiento indefinido en un microcontrolador sin sistema operativo, y el optimizador lo explotará con la misma alegría.

flowchart TD
N[Norma C23 clausula de conformidad] --> H[Implementacion hosted]
N --> F[Implementacion freestanding]
H --> H1[Entrada en main con firma fijada]
H --> H2[Biblioteca estandar completa]
F --> F1[Entrada de nombre y tipo definidos por la implementacion]
F --> F2[Solo el subconjunto de cabeceras autonomas]
H1 --> L[Misma maquina abstracta y mismas reglas de UB]
F1 --> L
style H fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b
style L fill:#89b4fa,color:#11111b

La norma expone el entorno como una macro predefinida, así que no hace falta adivinar:

#if __STDC_HOSTED__ == 1
#  include <stdio.h>
#  define LOG(msg) fputs(msg, stderr)
#else
   /* no hay printf, ni malloc, ni exit: solo lo que tu escribas */
#  define LOG(msg) uart_puts(msg)
#endif

Un detalle fino: freestanding es un mínimo, no un máximo. Una implementación autónoma puede ofrecer más cabeceras de las exigidas, y de hecho casi todas lo hacen. Lo que no puedes es contar con ellas de forma portable.

Las cabeceras que sobreviven

El criterio de selección es nítido y merece la pena enunciarlo: sobreviven las cabeceras que describen la implementación —tipos, límites, alineaciones, propiedades del compilador— y no las que piden servicios de un sistema. Ninguna de ellas necesita código de soporte en tiempo de ejecución; todas se resuelven con typedef, macros e intrínsecos del compilador.

/* Nucleo historico, exigido desde C89/C99 */
#include <float.h>       /* propiedades del punto flotante */
#include <iso646.h>      /* ortografias alternativas de operadores */
#include <limits.h>      /* limites de los tipos enteros */
#include <stdarg.h>      /* variadicos: intrinsecos, no runtime */
#include <stddef.h>      /* size_t, ptrdiff_t, nullptr_t, offsetof */
#include <stdint.h>      /* enteros de ancho fijo y exacto */

/* Compatibilidad: en C23 su contenido ya son palabras clave */
#include <stdalign.h>
#include <stdbool.h>
#include <stdnoreturn.h>

/* Novedad de C23, tambien autonomas */
#include <stdbit.h>      /* manipulacion de bits: popcount, rotaciones... */
#include <stdckdint.h>   /* aritmetica entera con deteccion de desbordamiento */

Aquí hay un matiz de C23 que casi nadie aprovecha: bool, true, false, alignas, alignof y static_assert son ya palabras clave del lenguaje. Las tres cabeceras de compatibilidad existen solo para código antiguo. En un fichero freestanding de C23 puedes escribir bool listo = true; o alignas(64) uint8_t buffer[256]; sin incluir absolutamente nada. Eso no es cosmética: es una dependencia menos en un entorno donde cada dependencia hay que justificarla.

Lo que no sobrevive es igual de instructivo. stdio.h necesita descriptores de fichero; stdlib.h necesita un asignador y una noción de terminación de proceso; string.h necesita código real enlazado; time.h necesita un reloj; threads.h necesita un planificador. Todo eso son servicios de un sistema, y en freestanding el sistema eres tú.

⚠️
Ojo con `float.h` y la coma flotante

Incluir float.h es legal en freestanding, pero eso no garantiza que exista una FPU ni que el enlazador encuentre las rutinas de coma flotante por software. En un Cortex-M0 sin FPU, un simple double a = b * c; genera una llamada a __aeabi_dmul, que vive en la biblioteca de soporte del compilador. La cabecera describe el formato; no provee la aritmética.

Las características condicionales del lenguaje

Hay una segunda capa de variabilidad que se suele pasar por alto, y esta afecta al lenguaje, no a la biblioteca. Desde C11 la norma admite que una implementación renuncie a ciertas construcciones, y obliga a anunciarlo mediante macros predefinidas. En un entorno autónomo esas renuncias son la norma y no la excepción, así que el código verdaderamente portable las consulta:

#ifdef __STDC_NO_ATOMICS__
   /* no hay _Atomic ni stdatomic.h: toca deshabilitar interrupciones */
#endif
#ifdef __STDC_NO_THREADS__
   /* no hay threads.h: no existe planificador que respalde hilos */
#endif
#ifdef __STDC_NO_VLA__
   /* no hay arrays de longitud variable: la pila es un recurso escaso */
#endif
#ifdef __STDC_NO_COMPLEX__
   /* sin tipos complejos: son opcionales en freestanding */
#endif

La macro más interesante en este contexto es __STDC_NO_ATOMICS__. Los atómicos sin bloqueo dependen de instrucciones del procesador, y un núcleo pequeño puede no tenerlas para todos los anchos; cuando no las tiene, la implementación necesitaría respaldarse en candados de la biblioteca, que en freestanding no existen. Por eso el compilador expone ATOMIC_INT_LOCK_FREE y compañía con el valor 1, que significa «a veces», o directamente renuncia. Consultar atomic_is_lock_free en tiempo de ejecución no sirve de nada si el enlazado ya falló.

Nota también que __STDC_NO_VLA__ no es una limitación técnica sino una decisión de ingeniería: los arrays de longitud variable reservan espacio de pila cuyo tamaño no es visible en tiempo de compilación, y en un sistema con dos kilobytes de pila eso es inaceptable. C23 mantiene los VLA como característica opcional precisamente para que los objetivos empotrados puedan declinarlos con conformidad plena.

Lo que el compilador sigue asumiendo

Aquí está el malentendido más caro del nivel: -ffreestanding no le dice al compilador «no generes llamadas a funciones de biblioteca». Le dice «no asumas la semántica de la biblioteca hosted». Son cosas distintas, y la diferencia se paga en errores de enlazado.

gcc -std=c23 -ffreestanding -nostdlib -O2 -c modulo.c -o modulo.o
nm -u modulo.o        # simbolos indefinidos: aqui aparece la verdad

Ese nm -u te va a devolver, con enorme frecuencia, tres familias de símbolos que tú nunca escribiste:

  • Las funciones de memoria. La norma permite explícitamente que una implementación autónoma genere llamadas a memcpy, memmove, memset y memcmp. Una asignación de estructura grande, un inicializador = {} sobre un array, o un bucle que el optimizador reconozca como copia, se convierten en una llamada. Es el tema completo de la lección 4 de este nivel.
  • La biblioteca de soporte del compilador. libgcc en GCC, compiler-rt en Clang. Contiene lo que el hardware no sabe hacer: división de 64 bits en un objetivo de 32 bits (__udivdi3), aritmética de coma flotante emulada, desenrollado de pila para excepciones. -nostdlib la excluye también; suele hacer falta añadir -lgcc al final del enlazado.
  • Los constructores y el arranque. -ffreestanding desactiva el tratamiento especial de main: deja de haber return 0; implícito y deja de asumirse que solo se llama una vez. El punto de entrada real lo decides tú.

Merece la pena internalizar la implicación: -ffreestanding es una aserción sobre el entorno, mientras que -nostdlib y -nodefaultlibs son decisiones de enlazado. Puedes usar cada una sin la otra, y confundirlas produce binarios que compilan y no enlazan, o que enlazan y no arrancan.

💡
Disciplina de dependencias: audita, no confíes

El hábito que distingue a un proyecto autónomo serio de uno frágil es tratar la lista de símbolos indefinidos como un contrato explícito y verificarlo en cada compilación. Basta con una regla que compare la salida de nm -u contra una lista blanca versionada en el repositorio:

nm -u --format=posix firmware.o | cut -d' ' -f1 | sort > simbolos.txt
diff -u simbolos-permitidos.txt simbolos.txt || exit 1

Cuando alguien introduce sin darse cuenta una estructura lo bastante grande como para provocar un memcpy, o un double que arrastra la emulación de coma flotante entera, el fallo aparece en la revisión y no seis meses después en un dispositivo desplegado en campo.

La norma ya contemplaba un mundo sin sistema operativo

Que el estándar de C dedique una de sus cláusulas normativas a definir un entorno sin biblioteca ni arranque no es una concesión marginal: es una declaración sobre la naturaleza del lenguaje. C fue diseñado para poder ser la capa más baja que existe, aquella bajo la cual solo hay silicio. Por eso la frontera que traza la norma es exactamente la correcta: mantiene intacta la semántica —la máquina abstracta, el modelo de objetos, el comportamiento indefinido— y suelta únicamente los servicios. El lenguaje no depende del sistema operativo; la biblioteca sí. Esa separación, escrita en 1989 y refinada hasta C23, es la razón de que el mismo lenguaje describa el bootloader que arranca tu portátil, el kernel que lo gobierna, el firmware del controlador de su teclado y el proceso hosted que estás leyendo. Y es la razón de que no_std de Rust, el -nostdlib de Zig o el subconjunto embebido de C++ sean todos variaciones del mismo reconocimiento: la biblioteca es una comodidad negociable, el lenguaje no.

⚔️ Interroga a tu compilador sobre el entorno
  1. Compila el mismo fichero con y sin -ffreestanding e imprime el valor de __STDC_HOSTED__ mediante un static_assert, para ver que la macro cambia de verdad.
  2. Escribe un fichero freestanding de C23 que use bool, alignas y static_assert sin incluir ninguna cabecera. Confirma que compila.
  3. Crea una función que copie una estructura de 4 KiB, compílala con -ffreestanding -O2 -c y busca con nm -u la llamada a memcpy que tú no escribiste.
  4. Escribe una división de dos uint64_t y compílala para un objetivo de 32 bits. Localiza el símbolo __udivdi3 y razona por qué -nostdlib te obliga a añadir -lgcc.
  5. Recorre la lista de cabeceras autónomas y clasifica cada una según el criterio: ¿describe la implementación o pide un servicio?