Defer: Resource cleanup in C with GCCs magic

2025-09-307:036696oshub.org

[Defer Macro] “Warning: This is experimental, relies on GCC-specific extensions (__attribute__((cleanup)) and nested functions), and is not portabl...

Defer Macro

Warning: This is experimental, relies on GCC-specific extensions (__attribute__((cleanup)) and nested functions), and is not portable C. It’s just for fun and exploration. 

While working on my operating system project, RetrOS-32, I ran into some of GCC’s less common function attributes. One that stood out to me was the cleanup attribute, which lets you tie a function to a variable. When the variable goes out of scope, that function is called automatically. At first this sounded like an easy way to manage resources in C, a language where you normally have to handle everything by hand.

Here’s an example:

/* The function must take one parameter,
 * a pointer to a type compatible with the variable.
 * The return value of the function (if any) is ignored.
 */
void free_ptr(void* ptr) {
    free(*(void**)ptr);
}

int example(){
    __attribute(cleanup(free_ptr)) char* ptr = malloc(32);
    return 0;
}
This works, but it isn’t exactly safe. If malloc fails and returns NULL, the cleanup function will still be called, and there’s no simple way to add a guard inside free_ptr. Because of issues like that, I didn’t really use the feature at first.

That changed when I came across a blog post by Jens Gustedt, where he showed how the cleanup attribute could be combined with another GCC feature, nested functions, to build something more practical: a defer mechanism, similar to what languages like Go provide.


Nested functions are not part of standard C, but GCC supports them. They allow you to declare a function inside another function, and the inner one has access to the variables of the outer scope.

int example() {
    int a = 1;

    void set(){
        a = 2;
    }
    set();

    return a;
}


This ability to reach into the parent scope is what makes a defer macro possible. Jens’ approach uses cleanup to ensure the nested function is executed when leaving the scope, giving you a way to run code automatically at the end of a block.


Jens' defer macro is defined below:

#define __DEFER__(F, V)      \
    auto void F(int*);         \
    __attribute__((cleanup(F))) int V; \
    void F(int*)

#define __DEFER_(N) __DEFER__(__DEFER_FUNCTION_ ## N, __DEFER_VARIABLE_ ## N)
#define __DEFER(N) __DEFER_(N)
#define defer __DEFER(__COUNTER__)x


Going back to our previous example we could instead write:

int example() {
    char* ptr = malloc(32);

    defer { free(ptr); }

    return 0;
}

To see how this worked in practice, I put the code into Godbolt and checked the generated assembly. The compiler creates a separate function for the defer block and sets up the stack frame for it:
# AT&T syntax
__DEFER_FUNCTION_0.0:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $16, %rsp
        movq    %rdi, -8(%rbp)
        movq    %r10, %rax
        movq    %r10, -16(%rbp)
        movq    (%rax), %rax
        movq    %rax, %rdi
        call    free
        nop
        leave
        ret
example:
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   %rbx
        subq    $40, %rsp
        leaq    16(%rbp), %rax
        movq    %rax, -40(%rbp)
        movl    $32, %edi
        call    malloc
        movq    %rax, -48(%rbp) # <--- return value set
        
        movl    $0, %ebx
        leaq    -20(%rbp), %rax
        leaq    -48(%rbp), %rdx
        movq    %rdx, %r10
        movq    %rax, %rdi
        call    __DEFER_FUNCTION_0.0
        
        movl    %ebx, %eax # <--- return value restored
        movq    -8(%rbp), %rbx
        leave
        ret
The key detail here is that the return value is set before the call to the defer function. That means even if your deferred block frees memory, the return value will already be saved. This is important, because it guarantees that early returns won’t break cleanup logic.The downside is clear too: the setup is heavy. The pointer to ptr has to be explicitly passed to the defer function, and a full stack frame is built. That slows things down and clutters the generated code.
To reduce the overhead, I tried forcing the nested cleanup function to always inline:
#define __DEFER__(F, V)      \
    auto inline __attribute__((always_inline)) void F(int*);         \
    __attribute__((cleanup(F))) int V; \
    inline __attribute__((always_inline)) void F(int*)
#define __DEFER_(N) __DEFER__(__DEFER_FUNCTION_ ## N, __DEFER_VARIABLE_ ## N)
#define __DEFER(N) __DEFER_(N)
#define defer __DEFER(__COUNTER__)
This removes the separate function call. Instead of generating a new stack frame, the compiler drops the defer code directly into the function at the right points.

Here’s a slightly bigger example where this becomes useful:

#include <stdlib.h>

struct object {
    int value;
};

int example(int val) {
    
    struct object* obj = malloc(sizeof(struct object));
    if(obj == NULL) return -1;

    defer { free(obj); }

    obj->value = val;
    if(obj->value == 8){
        return obj->value;
    }

    obj->value = 10;
    return obj->value;
}


The assembly for this version is a lot cleaner:

# AT&T syntax
example:
        pushq   %rbp
        movq    %rsp, %rbp
        pushq   %rbx
        subq    $56, %rsp
        movl    %edi, -52(%rbp)
        leaq    16(%rbp), %rax
        movq    %rax, -40(%rbp)
        movl    $4, %edi
        call    malloc
        movq    %rax, -48(%rbp)
        movq    -48(%rbp), %rax
        testq   %rax, %rax
        jne     .L2
        movl    $-1, %ebx
        jmp     .L6 # <--- instant jump to return, no defer.
.L2:
        movq    -48(%rbp), %rax
        movl    -52(%rbp), %edx
        movl    %edx, (%rax)
        movq    -48(%rbp), %rax
        movl    (%rax), %eax
        cmpl    $8, %eax
        jne     .L4
        movq    -48(%rbp), %rax
        movl    (%rax), %ebx
        jmp     .L5
.L4:
        movq    -48(%rbp), %rax
        movl    $10, (%rax)
        movq    -48(%rbp), %rax
        movl    (%rax), %ebx
.L5:
        leaq    -28(%rbp), %rax
        movq    %rax, -24(%rbp)
        movq    -48(%rbp), %rax
        movq    %rax, %rdi
        call    free # <--- Defer'd free called before return
        nop
.L6:
        movl    %ebx, %eax
        movq    -8(%rbp), %rbx
        leave
        ret


There are a few interesting points in this output:

  • The malloc failure check jumps straight to .L6, skipping defer entirely. This makes sense, since the defer was defined after the allocation.
  • Early returns still trigger the cleanup. At .L2 you can see the code checking for obj->value == 8. Instead of jumping out immediately, it goes through .L5, where the free call happens.
  • The return value is always stored before defer runs. Even when freeing the object, the function returns the right value without accessing freed memory.


If you tried to write this manually, you’d end up with temporary variables and gotos to ensure cleanup runs, which quickly gets messy. The defer macro keeps it all in one place.


How the code could look with gotos and temporary variables:

int example(int val) {
    struct object* obj = malloc(sizeof(struct object));
    if (obj == NULL) return -1;

    obj->value = val;
    if (obj->value == 8) {
        int result = obj->value;
        goto cleanup;   // jump to cleanup before returning
    }

    obj->value = 10;
    int result = obj->value;
    goto cleanup;

cleanup:
    free(obj);
    return result;
}
This isn’t something to use in portable C code. It relies on GCC extensions and nested functions, which aren’t standard and won’t work with other compilers. But as an experiment it’s very interesting. It cleans up functions with multiple return paths, avoids duplication, and makes resource management easier to follow.It’s a good reminder that even in C, where things feel rigid, the compiler sometimes has a few tricks up its sleeve.Links:

Full example on Godbolt: https://godbolt.org/z/7xn5qqdT7


Defer implementation from Jens: https://gustedt.wordpress.com/2025/01/06/simple-defer-ready-to-use

Read the original article

Comments

  • By babel_ 2025-10-019:10

    Testing with Jen's macro this was based on, and found that the always_inline was redundant under even -O1 (https://godbolt.org/z/qoh861Gch via the examples from N3488 as became the baseline for the TS for C2y, which has recently a new revision under N3687), so there's an interesting trade-off between visibly seeing the `defer` by not not-inlining within the macro under an -O0 or similar unoptimised build, since with the inlining they are unmarked in the disassembly. But, there's an interesting twist here, as "defer: the feature" is likely not going to be implemented as "defer: the macro", since compilers will have the keyword (just `defer` in TS25755, or something else that uses a header for sugared `defer`) and may see the obvious optimised rewrite as the straightforward way of implementing it in the first place (as some already have), meaning we can have the benefit of the optimised inline with the opportunity to also keep it clearly identifiable, even in unoptimised and debug builds, which would certainly be nice to have!

  • By scoopr 2025-10-017:222 reply

    Then there is the proposal to add standard `defer` to C2y[0]

    [0] https://thephd.dev/c2y-the-defer-technical-specification-its...

  • By lionkor 2025-10-0115:381 reply

    Slightly off-topic, but:

    The fact that go "lifts" the deferred statement out of the block is just another reason in the long list of reasons that go shouldn't exist.

    Not only is there no protection against data-races (in a language all about multithreading), basically no static checking for safety, allocation and initialization is easy to mess up, but also defer just doesn't work as it does in C++, Rust, Zig, and any other language that implements similar semantics.

    What a joke.

    • By jibal 2025-10-0213:24

      One of those languages being D, which invented it (well, Andrei Alexandrescu did) under the name `scope(exit)` (there's also `scope(failure)` which is like Zig's `errdefer` and `scope(success)` which no other language has AFAIK).

HackerNews