wandres.dev
DIESTRO · Funciones a fondo

Funciones variádicas: va_list y el interior de printf

El mecanismo de stdarg.h, las promociones por defecto, cómo funciona printf por dentro, por qué las variádicas son inseguras por naturaleza y qué alternativas con verificación existen en C23.

⏱ 17 min

printf es la primera función que llamaste en C y probablemente la que peor entiendes. Acepta cualquier número de argumentos de cualquier tipo, algo que el sistema de tipos de C no permite en ningún otro sitio. La explicación no es que sea mágica: es que las funciones variádicas son el punto exacto donde el lenguaje renuncia a comprobar los tipos y te traslada esa responsabilidad. Aquí verás el mecanismo completo y por qué esa renuncia lleva cuarenta años produciendo vulnerabilidades.

🎯 Al terminar esta lección sabrás
  • Manejar va_list, va_start, va_arg, va_end y va_copy correctamente.
  • Aplicar las promociones aritméticas por defecto a los argumentos anónimos.
  • Explicar cómo printf interpreta la cadena de formato en tiempo de ejecución.
  • Reconocer y mitigar las vulnerabilidades de cadena de formato.

El mecanismo de stdarg.h

Una función variádica declara sus parámetros conocidos y termina con puntos suspensivos. Los argumentos que caen tras ellos son anónimos: no tienen nombre, ni tipo declarado, ni forma alguna de contarlos. La cabecera stdarg.h ofrece un objeto opaco, va_list, que actúa como cursor sobre esa zona.

#include <stdarg.h>

int suma_n(int cuantos, ...) {
    va_list ap;
    va_start(ap, cuantos);            // en C23 basta con va_start(ap)
    int total = 0;
    for (int i = 0; i < cuantos; i++)
        total += va_arg(ap, int);     // el TIPO lo pones tu: nadie lo verifica
    va_end(ap);
    return total;
}

Lee va_arg(ap, int) con atención, porque ahí está todo el asunto: tú declaras de qué tipo es el argumento siguiente, y la macro extrae exactamente esos bytes y avanza el cursor. Si te equivocas, no hay error de compilación ni de ejecución: hay comportamiento indefinido y una lectura desalineada de la pila o de un registro.

Los argumentos anónimos sufren además las promociones aritméticas por defecto: todo tipo entero menor que int se promueve a int, y float se promueve a double. La consecuencia práctica es contundente: va_arg(ap, char) y va_arg(ap, float) son siempre comportamiento indefinido, aunque hayas pasado un char o un float. Debes pedir int y double.

C23 relaja dos rigideces históricas: el segundo argumento de va_start es opcional, y ya se admite una función variádica sin ningún parámetro con nombre —algo antes prohibido—, lo que la vuelve compatible con la forma en que C++ declara sus variádicas.

💡
va_copy antes de recorrer dos veces

Un va_list es un cursor de un solo uso: tras recorrerlo, su estado es indeterminado. Si necesitas dos pasadas —medir primero y formatear después, como hace todo envoltorio de vsnprintf— duplícalo con va_copy(destino, origen) y cierra los dos con va_end. Pasar un va_list a otra función que lo consuma también lo invalida para el llamador: por eso la pareja printf y vprintf existe, y por eso tras llamar a vfprintf no puedes seguir usando el mismo cursor.

printf por dentro

Ahora puedes reconstruir printf entero. No existe ninguna magia: es un intérprete diminuto.

void log_msg(const char *fmt, ...) {
    va_list ap;
    va_start(ap, fmt);
    vfprintf(stderr, fmt, ap);   // delega pasando el cursor completo
    va_end(ap);
}

Toda la familia printf está construida así: la versión variádica solo abre el va_list y llama a la versión v correspondiente, que hace el trabajo real. Y el trabajo real consiste en recorrer la cadena de formato byte a byte, copiando lo que no sea una conversión y, cada vez que encuentra un %, decodificando qué tipo pedir:

flowchart TD
A[Recorrer la cadena de formato] --> B[Caracter normal]
B --> A2[Copiarlo a la salida]
A --> C[Se encontro el signo de porcentaje]
C --> D[Decodificar banderas anchura precision y longitud]
D --> E[Pedir un argumento del tipo indicado con va arg]
E --> F[Convertir a texto y emitir]
F --> A
style C fill:#f9e2af,color:#11111b
style E fill:#f38ba8,color:#11111b

Conviene deshacer aquí un mito heredado de los años ochenta: el de que “los argumentos variádicos van en la pila”. En x86-64 System V no es así. Los primeros argumentos viajan por registro como en cualquier llamada, y el prólogo de una función variádica vuelca esos registros a un área de guardado dentro de su propio marco. Por eso un va_list no es un simple puntero: en esta ABI es una estructura con dos desplazamientos —uno para registros enteros y otro para los de coma flotante—, un puntero al área de guardado y otro a la zona de desbordamiento en la pila. va_arg consulta el desplazamiento correspondiente al tipo que le pides y, cuando se agota, pasa a leer de la pila. De ahí que va_list sea un tipo opaco cuya representación cambia por completo entre arquitecturas, y que copiarlo con una asignación en vez de con va_copy sea incorrecto: en algunas ABI es un array y en otras una estructura.

La conclusión es incómoda: la cadena de formato es la declaración de tipos de la llamada, escrita como texto y evaluada en tiempo de ejecución. printf no comprueba nada porque no puede: en tiempo de ejecución ya no queda información de tipos, y ni siquiera sabe cuántos argumentos le pasaron. Si escribes tres conversiones y pasas dos argumentos, la tercera lee lo que hubiera en el registro o en la pila.

Inseguras por naturaleza

De ese diseño salen tres familias de fallo, y ninguna es un descuido de implementación: son consecuencias directas del mecanismo.

🔢

Aridad falsa

Más conversiones que argumentos: se leen registros o posiciones de pila que pertenecen a otra cosa. Fuga de datos.

🏷️

Tipo falso

Un %d con un long, un %s con un entero: se reinterpretan bytes. Con %s eso significa desreferenciar un puntero inventado.

✍️

La conversión que escribe

%n no imprime: escribe en memoria el número de bytes ya emitidos. Convierte una lectura en una escritura arbitraria.

🕳️

Formato controlado

Si la cadena de formato viene del usuario, el atacante elige las conversiones. Es la vulnerabilidad clásica de cadena de formato.

El error que las reúne todas cabe en una línea, y es la razón por la que existe un aviso del compilador dedicado a él:

printf(entrada_del_usuario);            // CATASTROFICO: formato controlado
printf("%s", entrada_del_usuario);      // correcto: el formato es literal

La familia de fallos no acaba en el formato. La otra convención variádica clásica es el centinela: funciones como execl recorren los argumentos hasta encontrar un NULL. Olvidarlo hace que la función siga leyendo posiciones que no le pertenecen hasta topar por azar con un cero, y el olvido es tan frecuente que existe un atributo para detectarlo, [[gnu::sentinel]]. Es el mismo defecto de raíz que la aridad falsa: la información sobre cuántos argumentos hay no viaja con la llamada, sino que se reconstruye a partir de una convención que nadie verifica.

La defensa mínima en cualquier proyecto serio es -Wformat -Wformat-security -Werror=format-security, que convierte en error el primer caso. Para tus propias funciones variádicas, C23 te permite pedir esa misma verificación con la sintaxis estándar de atributos aplicada al atributo de proveedor de gcc y clang:

[[gnu::format(printf, 1, 2)]]
void log_msg(const char *fmt, ...);     // ahora -Wformat revisa TUS llamadas

Los números indican qué parámetro es el formato y cuál es el primer argumento variable. Sin ese atributo, tu envoltorio de printf es una zona ciega donde el compilador no comprueba nada.

Aquí es donde el sistema de tipos dimite, y todo lo demás se explica

Las funciones variádicas son el único lugar del lenguaje donde C renuncia formalmente a comprobar tipos y aridad, y traslada el contrato a un texto interpretado en ejecución. Ese desplazamiento es la raíz de todo lo que has visto: no es que printf esté mal implementada, es que su contrato no es verificable por construcción. De ahí sale la vulnerabilidad de cadena de formato, que durante los años noventa fue tan rentable como el desbordamiento de buffer, y de ahí sale que %n esté hoy desactivado en la libc de Windows y restringido en glibc con _FORTIFY_SOURCE. Pero la lección va mucho más allá de C. Mira qué hicieron los lenguajes posteriores con el mismo problema: Rust convirtió el formato en una macro que analiza la cadena literal en compilación y falla si los tipos no cuadran; C++ hizo lo mismo con std::format y sus cadenas consteval; Python añadió las f-strings, que el propio compilador desmonta. Todos llegaron a la misma conclusión: el formato debe resolverse cuando aún existe información de tipos. C no puede hacerlo porque su modelo de compilación no tiene ese momento, y la comunidad lo parcheó desde fuera con atributos y avisos. Entender esto te da un criterio general que vale para cualquier lenguaje: cada vez que una API mueve un contrato de tipos del compilador a una cadena de texto, estás cambiando comodidad por una clase entera de vulnerabilidades. Reconocerlo en un ORM, en un router web o en una consulta SQL es exactamente el mismo razonamiento.

Cómo evitarlas casi siempre

La variádica rara vez es la mejor herramienta cuando el código es tuyo. Casi siempre hay una opción con verificación:

// 1. Array explicito: el compilador comprueba tipo y numero
int suma_arr(const int *v, size_t n);

// 2. Struct de opciones con inicializadores designados (C99/C23)
typedef struct { int reintentos; bool verboso; } Opciones;
void conectar(const char *host, Opciones o);
conectar("a.b", (Opciones){ .reintentos = 3 });

// 3. Macro variádica con __VA_OPT__ (C23) sobre una funcion tipada
#define LOG(fmt, ...) log_msg("%s:%d " fmt, __FILE__, __LINE__ __VA_OPT__(,) __VA_ARGS__)

Cuando la variádica es inevitable —normalmente porque envuelves a printf— hay una regla que reduce casi todo el riesgo: no reimplementes el formateo, delega en la variante v de la libc, y usa vsnprintf en lugar de vsprintf para que el tamaño del buffer sea siempre parte de la llamada. Recuerda además que vsnprintf devuelve los bytes que habría escrito, no los que escribió: comparar ese valor con el tamaño del buffer es la forma estándar de detectar el truncamiento.

La tercera es la más interesante: la variabilidad se resuelve en el preprocesador, no en la pila, así que la función subyacente conserva su firma tipada. __VA_OPT__, estandarizado en C23, resuelve por fin el viejo problema de la coma sobrante cuando no hay argumentos variables. Y para despacho genuinamente genérico por tipo, _Generic te da selección en compilación sin renunciar a la verificación.

⚔️ Reconstruye y rompe printf
  1. Implementa suma_n y llámala con char y con float; comprueba que solo funciona pidiendo int y double, y explica por qué.
  2. Escribe un envoltorio que use va_copy para medir con vsnprintf y luego formatear en un buffer reservado a medida.
  3. Llama a printf("%d %d\n", 1) y observa qué imprime; compílalo con -Wformat y lee el aviso.
  4. Añade [[gnu::format(printf, 1, 2)]] a tu log_msg y comprueba que ahora el compilador detecta un %s con un entero.
  5. Sustituye una de tus funciones variádicas por un struct de opciones con inicializadores designados y compara legibilidad y seguridad.