Advanced Control Flow

This section documents C23 (ISO/IEC 9899:2024), per ISO/IEC JTC1/SC22/WG14’s freely available working draft N3220, which WG14 documents as differing from the published standard only editorially — the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the WG14 draft and cppreference.com’s C reference before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

C offers four ways to transfer control beyond the ordinary statements: a short jump (goto) within a function, a function call and its return, a long jump (longjmp) across frames, and asynchronous interruption by a signal. The last two come with restrictions strict enough that most code should avoid them — knowing exactly what those restrictions are is the point of this page.

Sequencing Recap

Before jumping anywhere, recall from Operators and Expressions what C guarantees about order within a statement:

  • Full expressions are sequenced: everything in one statement completes before the next statement starts.

  • &&, ||, ?: and the comma operator impose an order on their operands; nothing else does.

  • Function arguments are indeterminately sequenced — evaluated in some unspecified order, but not interleaved.

  • Two unsequenced writes to the same object, or a write and a read, are undefined behavior.

Everything below inherits those rules: a longjmp or a signal can only land between sequenced points, and whatever was mid-expression is lost.

Short Jumps — goto for Cleanup

goto transfers control to a label in the same function. Its legitimate uses are cleanup and escaping nested loops, both shown in Control Flow. Two constraints worth restating:

  • You may not jump into the scope of a variable-length array, and jumping over an ordinary declaration leaves that object uninitialized (its lifetime began, its initializer did not run).

  • goto cannot cross function boundaries — that is what longjmp is for.

#include <stdio.h>

int main(void)
{
    int status = 0;

    // Jumping FORWARD over a declaration: value's lifetime has begun but its
    // initializer never ran, so reading it would be undefined.
    goto skip;

    int value = 42;             // this initialization is skipped
    status = value;

skip:
    printf("status = %d\n", status);        // 0, and value must not be read here
    return 0;
}

Function Calls as Control Transfer

A call is the ordinary structured jump: it saves a return address, allocates a frame for the callee’s automatic objects, and returns control after the call expression. Two properties matter for the rest of this page:

  • Automatic objects are destroyed at return — so a pointer to one is dangling afterwards.

  • The call and return are sequenced, which is why they compose safely and longjmp does not.

Recursion, [[noreturn]] functions and function pointers are covered in Functions.

Long Jumps — setjmp and longjmp

setjmp records the machine context in a jmp_buf; longjmp restores it, abandoning every frame in between. It is the closest thing C has to an exception — and it is not one.

sequenceDiagram participant M as main participant P as parse() participant T as tokenize() Note over M: setjmp(env) returns 0
context saved M->>P: parse(input) P->>T: tokenize(text) Note over T: unrecoverable error T-->>M: longjmp(env, 2) Note over P,T: frames of parse() and tokenize()
are abandoned - no cleanup runs,
nothing is freed or closed Note over M: setjmp(env) returns 2 again
execution resumes here
#include <limits.h>
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>

static jmp_buf recovery;

// Values passed to longjmp: never 0, because 0 is what setjmp returns initially.
enum { ERROR_SYNTAX = 1, ERROR_OVERFLOW = 2 };

[[noreturn]] static void fail(int reason)
{
    longjmp(recovery, reason);              // abandons every frame back to setjmp
}

static long parse_digits(const char *text)
{
    long value = 0;

    for (const char *p = text; *p != '\0'; ++p) {
        if (*p < '0' || *p > '9') {
            fail(ERROR_SYNTAX);
        }
        if (value > (LONG_MAX - (*p - '0')) / 10) {
            fail(ERROR_OVERFLOW);
        }
        value = value * 10 + (*p - '0');
    }
    return value;
}

int main(void)
{
    // Anything modified between setjmp and longjmp and read afterwards MUST be
    // volatile -- otherwise the value it holds after the jump is indeterminate.
    volatile int attempts = 0;

    const char *inputs[] = { "123", "12x", "99999999999999999999999" };

    for (size_t i = 0; i < sizeof inputs / sizeof inputs[0]; ++i) {
        int reason = setjmp(recovery);      // 0 on the direct call

        if (reason == 0) {
            ++attempts;
            printf("%s -> %ld\n", inputs[i], parse_digits(inputs[i]));
        } else {
            printf("%s -> failed with reason %d\n", inputs[i], reason);
        }
    }

    printf("attempts = %d\n", attempts);
    return 0;
}

The restrictions, which are unusually strict:

  • setjmp may only appear in a handful of contexts: as a whole expression statement, or as the operand of a comparison against an integer constant in an if/switch/loop condition. int r = setjmp(env); is technically outside what the standard guarantees, though every real implementation accepts it.

  • Local variables that are not volatile have indeterminate values after a longjmp if they were modified since the setjmp. This is the volatile int attempts above, and it is the most commonly missed rule.

  • longjmp into a function that has already returned is undefined — the jmp_buf is dead.

  • Nothing is cleaned up. No free, no fclose, no unlocking of mutexes. Every resource acquired in an abandoned frame leaks.

  • Never pass 0 to longjmp; it is converted to 1, which conflates a jump with the initial call.

  • Combining longjmp with variable-length arrays, or jumping out of a signal handler, is undefined.

Because of the fourth point, setjmp/longjmp is the wrong tool for ordinary error handling. Its defensible uses are narrow: aborting deep recursion in an interpreter or parser that owns no unmanaged resources, and recovering in a language runtime. Everywhere else, return an error code and clean up with goto — see Error Handling and Program Failure.

Signals

A signal interrupts the program asynchronously and runs a handler on whatever stack was executing. The standard defines six: SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM.

#include <signal.h>
#include <stdio.h>

// The ONLY object type a handler may portably touch: volatile sig_atomic_t.
static volatile sig_atomic_t interrupted = 0;

static void handle_interrupt(int signal_number)
{
    (void)signal_number;
    interrupted = 1;            // set a flag -- that is all a handler should do
}

int main(void)
{
    if (signal(SIGINT, handle_interrupt) == SIG_ERR) {
        fputs("cannot install handler\n", stderr);
        return 1;
    }

    // The main loop polls the flag and does the real work outside the handler.
    for (int i = 0; i < 3 && !interrupted; ++i) {
        printf("working (%d)\n", i);
    }

    if (interrupted) {
        puts("interrupted -- shutting down cleanly");
    }

    signal(SIGINT, SIG_DFL);        // restore the default disposition
    return 0;
}

Async-Signal Safety

This is the part that makes signals hazardous. Inside a handler, standard C permits only:

  • Assigning to (and reading) an object of type volatile sig_atomic_t, or a lock-free atomic.

  • Calling signal for the signal currently being handled, abort, _Exit, quick_exit, and raise for the same signal.

  • Returning.

Everything else is undefined — including printf, malloc, free, and any function that touches errno or the streams. The reason is reentrancy: the signal may have arrived inside malloc, so calling malloc again corrupts its state. POSIX defines a longer list of async-signal-safe functions (write, _exit, signalfd machinery), which is what real programs use.

Two further C-specific rules:

  • A handler for SIGFPE, SIGILL or SIGSEGV — signals raised by the program’s own undefined behavior — may not return normally. The behavior is undefined if it does, because there is nothing sensible to resume.

  • Whether a handler stays installed after firing is implementation-defined in standard C (historic SysV semantics reset it to SIG_DFL). POSIX sigaction fixes this, and is what portable code should use.

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>

static volatile sig_atomic_t termination_requested = 0;

static void request_termination(int signal_number)
{
    (void)signal_number;
    termination_requested = 1;
    // NOT allowed here: printf, malloc, free, fopen, exit, longjmp.
}

int main(void)
{
    signal(SIGTERM, request_termination);

    // raise() sends a signal to the running program -- useful for testing a handler.
    if (raise(SIGTERM) != 0) {
        fputs("raise failed\n", stderr);
        return EXIT_FAILURE;
    }

    printf("termination requested: %d\n", (int)termination_requested);

    // abort() raises SIGABRT and does not return; commented out so the example runs.
    // abort();
    return 0;
}

The pattern to take away: a handler sets a flag; the main loop acts on it. Anything more ambitious needs POSIX (sigaction with SA_RESTART, a self-pipe, or signalfd) rather than standard C.

See Also