Objective-C

Block Underlying Structure and Variable Capture Explained

By Seren  |  27 Jun, 2026  |  1 Comment


A Block in Objective-C is more than a closure — it is a full C struct with specific memory layout rules that determine how variables are captured and when the Block is deallocated.” Later I ran into retain cycles, wondered what __block actually did, and why local variables couldn’t be modified. That’s when I realized the underlying mechanism was far more complex. After reading the source code, I finally understood it.

Code 1: Block Underlying Structure

A Block is compiled into a C struct. Here’s the actual structure definition from the runtime source code:

// Block’s underlying structure (from objc runtime source)
struct Block_layout {
    void *isa;                          // points to class (Stack/Malloc/Global)
    volatile int32_t flags;             // lifecycle management flags
    int32_t reserved;                   // reserved
    void (*invoke)(void *, ...);        // function pointer to the Block’s code
    struct Block_descriptor *descriptor; // metadata: size, copy/dispose helpers
    // captured variables are stored AFTER this structure in memory
};

struct Block_descriptor {
    uint32_t reserved;                  // reserved
    uint32_t size;                      // total size of the Block
    void (*copy_helper)(void *dst, const void *src);
    void (*dispose_helper)(const void *src);
    const char *signature;              // type encoding string
};

// The isa pointer determines the Block’s type:
// __NSConcreteStackBlock  → lives on the stack (default)
// __NSConcreteMallocBlock → lives on the heap (after copy)
// __NSConcreteGlobalBlock → global (no captures, like a static function)

// Demo: three Block types
int globalVar = 10;

// Global Block — captures nothing
void (^globalBlock)(void) = ^{
    NSLog(@"I am a global block");
};
NSLog(@"Global Block class: %s", object_getClass((__bridge id)globalBlock));
// prints: __NSConcreteGlobalBlock

// Stack Block → captures a local variable
int localVar = 42;
void (^stackBlock)(void) = ^{
    NSLog(@"captured: %d", localVar);
};
NSLog(@"Stack Block class: %s", object_getClass((__bridge id)stackBlock));
// prints: __NSConcreteStackBlock

// Heap Block → stack block copied to heap
void (^heapBlock)(void) = [stackBlock copy];
NSLog(@"Heap Block class: %s", object_getClass((__bridge id)heapBlock));
// prints: __NSConcreteMallocBlock

Code 2: clang Rewrite — See the Real C++ Output

The most direct way to understand Blocks is to compile Objective-C to C++ and read the generated code:

// main.m — the code you write
int main() {
    int external = 10;
    void (^myBlock)(void) = ^{
        NSLog(@"external = %d", external);
    };
    myBlock();
    return 0;
}

// Run: clang -rewrite-objc main.m -o main.cpp
// The generated C++ shows:

// 1. The Block captures “external” by VALUE (copied into the struct)
struct __main_block_impl_0 {
    struct __block_impl impl;           // base structure
    struct __main_block_desc_0 *Desc;   // metadata
    int external;                       // CAPTURED VALUE stored here!
};

// 2. The Block’s actual code lives here
void __main_block_func_0(struct __main_block_impl_0 *__cself) {
    int external = __cself->external;  // reads from captured copy
    NSLog((NSString *)&__NSConstantStringImpl, external);
}

// 3. The constructor initializes the Block
__main_block_impl_0(void *fp, struct __main_block_desc_0 *desc,
                    int _external, int flags=0)
    : external(_external) {             // copies external’s VALUE
    impl.isa = &__NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
}
// KEY INSIGHT: external is stored by VALUE inside the struct.
// Modifying “external” inside the Block modifies the COPY, not the original.

Code 3: Variable Capture Comparison

Different variable types are captured differently. This is the #1 source of confusion in Block interviews:

int globalVar = 1;

void (^myBlock)(void) = ^{
    // 1. Global variable — NOT captured, accessed directly
    NSLog(@"global: %d", globalVar);

    // 2. Static local — captured by POINTER (address)
    static int staticLocal = 2;
    staticLocal++;
    NSLog(@"static: %d", staticLocal);  // 3 — modified!

    // 3. Auto variable — captured by VALUE (copy)
    int autoLocal = 3;
    // autoLocal = 4;  // COMPILE ERROR: cannot assign to variable
    NSLog(@"auto: %d", autoLocal);
};

myBlock();
myBlock();

// Results:
// global: 1 (unchanged — accessed directly)
// static: 3 (modified — captured by pointer, shared)
// static: 4 (modified again — static persists across calls)
// auto: 3 (copy, independent of external)

// Why auto variables can’t be captured by pointer:
// They live on the stack and are destroyed when scope ends.
// If the Block captured a pointer, it would point to garbage after
// the function returns — crash!

Code 4: __block Modifier — Stack to Heap

__block solves the “can’t modify captured auto variables” problem by wrapping the variable in a heap-allocatable structure:

// WITHOUT __block — value capture, can’t modify
int counter = 0;
void (^noBlock)(void) = ^{
    counter++;  // COMPILE ERROR!
};

// WITH __block — reference capture, CAN modify
__block int counter = 0;
void (^yesBlock)(void) = ^{
    counter++;  // OK! modifies the original
};
yesBlock();
NSLog(@"%d", counter);  // 1 — original modified!

// What __block generates in clang output:
// __block int counter = 0;
// becomes:
struct __Block_byref_counter_0 {
    void *isa;              // always NULL for byref
    __Block_byref_0 *forwarding;  // points to itself (or heap copy)
    volatile int32_t flags;
    uint32_t size;
    int counter;            // the actual value lives here
};

// The __forwarding pointer is the key:
// When on stack: forwarding → itself
// When copied to heap: forwarding → heap copy
// No matter what, you always reach the real value.

// Demo: __block survives copy to heap
__block int shared = 100;
void (^stack)(void) = ^{
    shared++;
    NSLog(@"shared: %d", shared);  // 101
};

void (^heap)(void) = [stack copy];  // copied to heap
heap();  // still works! shared: 102
NSLog(@"original: %d", shared);  // 102 — same value, same memory via __forwarding

Code 5: Retain Cycle with Block

// THE PROBLEM: Retain cycle
@interface NetworkManager : NSObject
@property (nonatomic, copy) void (^onComplete)(NSData *data);
@end

@implementation NetworkManager
- (void)fetchData {
    self.onComplete = ^(NSData *data) {
        // self captures self — RETAIN CYCLE!
        NSLog(@"Fetched %lu bytes", data.length);
        [self processResult:data];  // self → onComplete block → self
    };
}

- (void)processResult:(NSData *)data {
    NSLog(@"Processing...");
}

- (void)dealloc {
    NSLog(@"NetworkManager deallocated");  // NEVER called! Memory leak.
}
@end

// THE FIX: __weak breaks the cycle
- (void)fetchData {
    __weak typeof(self) weakSelf = self;
    self.onComplete = ^(NSData *data) {
        __strong typeof(weakSelf) strongSelf = weakSelf;
        if (!strongSelf) return;  // self might be gone
        NSLog(@"Fetched %lu bytes", data.length);
        [strongSelf processResult:data];
    };
    // self → onComplete block → weakSelf (NOT self) — no cycle!
}

// Under ARC, __weak capture means:
// - The Block does NOT increase the object’s retain count
// - The weak reference automatically becomes nil when the object deallocates
// - You must check for nil before using the strongSelf

// Why __strong inside the Block?
// To keep the object alive during the Block’s execution.
// Without strongSelf, the object could be deallocated mid-execution.

Code 6: Memory Management — Stack vs Heap Block

// Stack Block — NO retain/release of captured objects
void (^stackBlock)(void); {
    NSObject *obj = [[NSObject alloc] init];
    NSLog(@"retainCount before: %lu", (unsigned long)[obj retainCount]);

    stackBlock = ^{
        NSLog(@"captured: %p", obj);
    };
    // obj is on stack, stackBlock doesn’t retain it
}
// stackBlock goes out of scope here — but it’s on the stack, so it’s just abandoned.

// Heap Block — DOES retain captured objects
void (^heapBlock)(void); {
    NSObject *obj = [[NSObject alloc] init];
    NSLog(@"retainCount before: %lu", (unsigned long)[obj retainCount]);  // 1

    heapBlock = [^{ NSLog(@"captured: %p", obj); } copy];  // copy → heap
    NSLog(@"retainCount after: %lu", (unsigned long)[obj retainCount]);  // 2!
}
// The heap Block retained obj (retainCount 1→2).
// When the heap Block is released, dispose_helper releases obj (retainCount 2→1).

// This is handled by copy_helper and dispose_helper in the Block descriptor:
// copy_helper:   calls _Block_object_assign for each captured object
// dispose_helper: calls _Block_object_dispose for each captured object
// These are generated by the compiler for every Block that captures objects.

How to Verify with clang

// Run this command to see the C++ translation of any Objective-C Block:
clang -rewrite-objc main.m -o main.cpp

// What you’ll see in the generated main.cpp:
// 1. Block_layout structure with isa, flags, invoke, descriptor
// 2. Captured variables stored as struct members (by value or by __block ref)
// 3. copy_helper / dispose_helper functions for memory management
// 4. The __forwarding pointer inside __Block_byref structures

// You can also use Xcode’s debugger to inspect Block types at runtime:
// (lldb) p (void *)block.isa
// returns: __NSConcreteStackBlock or __NSConcreteMallocBlock or __NSConcreteGlobalBlock

// This is the single most useful command for understanding Block internals.
// I keep it in my cheat sheet because I use it during every Block-related code review.

Summary

Understanding Block internals comes down to three key insights:

Block is an object with an isa pointer pointing to Stack/Malloc/Global class. The compiler decides the type based on usage context.

Variable capture depends on type: global vars accessed directly, static locals captured by pointer, auto vars captured by value. __block wraps auto vars in a __Block_byref structure with a __forwarding pointer for heap-safe modification.

Memory management is in copy/dispose helpers: stack Blocks don’t retain objects; heap Blocks do. Retain cycles happen when a heap Block retains its capturing object. Use __weak to break the chain.

Use clang -rewrite-objc to verify any Block’s underlying structure yourself. Once you see the generated C++, the “magic” of Blocks disappears and you can reason about them with confidence.

Seren
Seren

A developer exploring iOS, Objective-C, Swift, and other tech stacks. Here to document my learning process, pitfalls, and growing journey across new technologies.

Your email address will not be published. Required fields are marked *

1 Comment