wandres.dev
HÁBIL · Control de flujo

Salidas no locales

setjmp y longjmp: el mecanismo real, sus peligros documentados (volatile, recursos huérfanos, hilos), por qué C decidió no tener excepciones y cuál es el manejo de errores idiomático con [[nodiscard]] de C23.

⏱ 15 min

Todo el control de flujo que has visto hasta aquí es local: salta dentro de una función. setjmp y longjmp rompen esa frontera y permiten abandonar decenas de marcos de pila de golpe, como una excepción sin red. Entender por qué esa herramienta existe, por qué casi nunca debes usarla y por qué C nunca adoptó excepciones te dice más sobre la filosofía del lenguaje que cualquier otra decisión de diseño.

🎯 Al terminar esta lección sabrás
  • Usar setjmp y longjmp respetando las restricciones del estándar.
  • Enumerar los peligros: variables no volatile, recursos huérfanos y marcos muertos.
  • Argumentar por qué C no tiene excepciones y qué coste habrían tenido.
  • Aplicar el manejo de errores idiomático de C con [[nodiscard]] de C23.

El mecanismo

La cabecera setjmp.h define el tipo jmp_buf y dos operaciones asimétricas. setjmp guarda el contexto de ejecución —registros, puntero de pila, dirección de retorno— y devuelve 0. longjmp restaura ese contexto y hace que setjmp vuelva a retornar, esta vez con el valor que le pasaste.

#include <setjmp.h>

static jmp_buf punto_de_rescate;

static void nivel_profundo(int mal)
{
    if (mal)
        longjmp(punto_de_rescate, 42);   /* abandona todos los marcos */
    /* nunca se llega aquí si saltamos */
}

int ejecutar(void)
{
    int codigo = setjmp(punto_de_rescate);
    if (codigo != 0) {
        /* segunda "vuelta": llegamos aquí desde longjmp */
        return -codigo;
    }
    nivel_intermedio();      /* que a su vez llama a nivel_profundo */
    return 0;
}

Cuatro reglas duras que el estándar impone y que casi nadie recuerda:

  • setjmp es una macro, y su invocación solo puede aparecer en contextos muy concretos: como expresión de control completa de un if, switch, while o for, comparada con una constante entera, negada con !, o como sentencia de expresión completa. Guardar su resultado como en el ejemplo de arriba es la forma admitida; incrustarla en una expresión mayor es comportamiento indefinido.
  • longjmp con valor 0 hace que setjmp devuelva 1. Nunca puede devolver cero la segunda vez.
  • Si la función que llamó a setjmp ya retornó, el jmp_buf apunta a un marco muerto y saltar ahí es comportamiento indefinido.
  • Saltar desde un hilo distinto al que ejecutó setjmp también es comportamiento indefinido.

Los peligros

El primero es el más sutil y el que aparece en los exámenes: las variables automáticas locales a la función que llamó a setjmp, si no son volatile y cambiaron entre la llamada y el salto, tienen valor indeterminado tras volver.

int contar(void)
{
    volatile int intentos = 0;   /* sin volatile: valor indeterminado tras el salto */
    int total = 0;               /* peligro: puede revertir a un valor antiguo */

    if (setjmp(env) != 0)
        intentos++;              /* fiable solo por ser volatile */

    total += trabajo();
    return intentos * 10 + total;
}

La causa es mecánica: setjmp guardó los registros en ese instante y longjmp los restaura. Una variable que el compilador mantenía en un registro retrocede a su valor antiguo; una que vivía en memoria conserva el nuevo. Cuál de las dos cosas ocurre depende del nivel de optimización, así que el mismo binario compilado con -O0 y con -O2 puede dar resultados distintos. volatile fuerza el almacenamiento en memoria y elimina la ambigüedad.

El segundo peligro es estructural: longjmp no desenrolla nada. No ejecuta limpieza, no cierra ficheros, no libera memoria, no suelta locks.

flowchart TD
A[ejecutar llama a setjmp] --> B[nivel intermedio: abre fichero]
B --> C[nivel profundo: coge un lock]
C --> D[longjmp al punto de rescate]
D -->|salta sobre todo| A
C -.->|nunca se ejecuta| E[soltar lock]
B -.->|nunca se ejecuta| F[cerrar fichero]
style D fill:#f38ba8,color:#11111b
style E fill:#585b70,color:#cdd6f4
style F fill:#585b70,color:#cdd6f4

Por eso el uso legítimo de longjmp se restringe a bibliotecas que controlan todo el estado que hay entre el punto de rescate y el punto de fallo, con un arena o pool que se libera de golpe. Es exactamente lo que hacen libjpeg y libpng para abortar la decodificación de una imagen corrupta, y lo que hace el intérprete de Lua para propagar errores desde código C.

Por qué C no tiene excepciones

No fue un olvido. Las excepciones con desenrollado necesitan tres cosas que C, deliberadamente, no tiene:

  • Destructores. Sin ellos, desenrollar la pila no puede liberar nada automáticamente. Unas excepciones que dejan fugas en cada lanzamiento son peores que devolver un código.
  • Metadatos de desenrollado. Las excepciones de coste cero de C++ no son gratis: exigen tablas de desenrollado y una rutina de personalidad que engordan el binario incluso si nunca lanzas. En un microcontrolador con 32 KiB de flash o en un kernel, eso es inaceptable.
  • Rutas de control ocultas. Cualquier llamada podría lanzar, así que toda función pasa a tener salidas invisibles en el código. C prefiere que las rutas de error se vean en el texto, aunque cueste más escribirlas.

Hay una cuarta razón, política: C es la lingua franca de las ABI. Todo lenguaje habla con el mundo a través de una interfaz C. Un mecanismo de excepciones en C obligaría a cada lenguaje huésped a implementarlo o a lidiar con él en cada frontera.

El manejo de errores idiomático

Lo que C ofrece a cambio es un conjunto de convenciones sencillas. La regla común: el valor de retorno indica el resultado, el dato sale por parámetro.

🔢

Código de retorno entero

0 para éxito y negativo para error, con el código en la magnitud. Es la convención del kernel y la más fácil de propagar hacia arriba.

📤

Dato por parámetro de salida

El resultado se escribe en un puntero que pasa el llamante. Deja el retorno libre para el estado y evita valores centinela ambiguos.

🚩

Centinela más errno

NULL o -1 con el detalle en errno. Es lo que hace la biblioteca estándar, y obliga a poner errno a cero antes de llamar.

🏷️

Enum de errores propio

Un enum cerrado por módulo, cazado por -Wswitch. Documenta el dominio de fallos mejor que un entero suelto.

C23 aporta la pieza que faltaba para que esto no dependa de la disciplina del llamante: [[nodiscard]]. Ignorar el resultado de una función marcada así produce un aviso del compilador.

[[nodiscard("hay que comprobar el fallo de asignación")]]
int reservar_buffer(size_t n, char **out);

void uso(void)
{
    char *b;
    reservar_buffer(4096, &b);      /* aviso: resultado descartado */

    if (reservar_buffer(4096, &b) != 0)
        return;                     /* correcto */
    free(b);
}

Combinado con la cascada de goto de la lección anterior, tienes el sistema completo: [[nodiscard]] garantiza que nadie ignora un error, el código de retorno lo propaga y las etiquetas de limpieza deshacen el estado en orden inverso. Todo visible en el texto, sin marcos que desaparecen a tus espaldas.

La ausencia de excepciones es la tesis del lenguaje, no una carencia

Es tentador leer el manejo de errores de C como pobreza: sin excepciones, sin Result, sin monadas, solo enteros y punteros. Pero la decisión invierte la carga de forma consciente: en un lenguaje con excepciones, la ruta de error es implícita y hay que buscarla; en C es una sentencia más, ocupa líneas, se ve en el diff y se cuenta en el presupuesto de complejidad. Ese coste sintáctico es la característica, no el defecto —te obliga a decidir, en cada llamada, qué pasa cuando falla—. Y tiene una consecuencia que se olvida: en C todas las rutas de salida de una función son enumerables leyendo su cuerpo, lo cual hace posible verificarlo formalmente, auditarlo línea a línea y ejecutarlo en contextos donde no existe pila secundaria ni tabla de desenrollado: un manejador de interrupción, un cargador de arranque, un dispositivo con kilobytes de memoria. setjmp y longjmp existen como escotilla de emergencia para los pocos casos que la necesitan, y su lista de peligros —variables indeterminadas, recursos huérfanos, marcos muertos— no es un fallo de la implementación: es la factura honesta de saltarse el modelo. C te deja pagarla, pero te enseña el recibo.

⚔️ Rompe y repara
  1. Escribe el ejemplo de contar sin volatile, compílalo con -O0 y con -O2 y compara los resultados.
  2. Abre un fichero antes de un longjmp y confirma con valgrind que se queda huérfano.
  3. Reemplaza ese longjmp por códigos de retorno más cascada de goto y comprueba que la fuga desaparece.
  4. Marca tres funciones de tu proyecto con [[nodiscard]] y cuenta cuántos avisos aparecen.
  5. Investiga cómo libjpeg monta su punto de rescate y qué estado controla para poder abortar sin fugas.