Every Block you write is actually a full C struct hiding behind a simple syntax. Understanding this struct — its fields, offsets, and memory layout — is the key to debugging Block-related crashes. It wasn’t until I was debugging a crash and saw __block_invoke everywhere in the stack trace that I realized Blocks are far more complex than just “chunks of code” — they’re essentially Objective-C objects with a complete internal memory structure, and calling a Block is fundamentally calling a function pointer.
A Block Is a Struct + a Function Pointer at Its Core
The underlying data structure of a Block is clearly defined in the LLVM documentation. Here’s the simplified core structure:
struct Block_literal_1 {
void *isa; // Points to the class object — Blocks are Objective-C objects
int flags; // Flags storing the Block's metadata
int reserved; // Reserved field
void (*invoke)(void *, ...); // Function pointer to the Block's implementation
struct Block_descriptor_1 *descriptor; // Description information
// Captured variables follow immediately after
};
isa Pointer: A Block Is Also an Object
isa is the first member of the Block struct, proving that Blocks are treated as objects at the Objective-C runtime level. The class isa points to determines the Block’s type:
_NSConcreteGlobalBlock: Global Block that captures no external variables — behaves like a static function_NSConcreteStackBlock: Stack Block allocated on the stack — becomes invalid after the function returns_NSConcreteMallocBlock: Heap Block copied from the stack viaBlock_copy— requires manual memory management
invoke Function Pointer: The Entry Point for Block Execution
invoke is the most critical member in the Block struct. It points to a function whose implementation is the code inside the Block’s {}. When you call a Block, it essentially jumps through this function pointer to execute.
In C++-translated source code, you can see the Block creation process:
// Block creation
void (*block)(void) = &__main_block_impl_0(
__main_block_func_0, // Function pointer to the Block's implementation
&__main_block_desc_0_DATA,
num
);
// Block execution
block->impl.FuncPtr(block);
__main_block_func_0 is the wrapped Block code. The invoke function pointer points to it, and executing the Block essentially calls this function.
descriptor: Describing the Block’s Size and Helper Functions
descriptor points to a Block_descriptor_1 struct that stores the Block’s metadata:
struct Block_descriptor_1 {
unsigned long int reserved; // Reserved
unsigned long int size; // Size of the Block struct
void (*copy_helper)(void *dst, void *src); // Optional
void (*dispose_helper)(void *src); // Optional
const char *signature; // Optional — method signature
};
size stores the total memory size of the entire Block struct, including captured variables. copy_helper and dispose_helper exist only when the Block captures objects requiring memory management — they handle retain/release operations when the Block is copied to the heap.
signature is the Block’s method signature, revealing its parameter types and return type. During debugging or reverse engineering, checking whether the (1<<30) bit is set in flags determines if signature exists.
flags: Bitwise-Encoded Metadata
flags stores multiple Block states through bitwise OR operations:
enum {
BLOCK_HAS_COPY_DISPOSE = (1 << 25), // Has copy/dispose helper functions
BLOCK_HAS_CTOR = (1 << 26), // Helper functions contain C++ code
BLOCK_IS_GLOBAL = (1 << 28), // Global Block
BLOCK_HAS_STRET = (1 << 29), // stret calling convention
BLOCK_HAS_SIGNATURE = (1 << 30), // Has method signature
};
flags & (1<<25) checks for copy/dispose helpers, and flags & (1<<30) checks for a method signature. Despite being only 4 bytes, flags packs this much information through bitwise reuse — minimizing the struct size while maintaining full functionality.
Captured Variables Follow the Struct
At the end of the Block_literal_1 struct, a /* imported variables */ comment indicates that captured variables follow the descriptor pointer. If a Block captures an int num, the compiled struct gains an extra int num member:
struct __main_block_impl_0 {
struct __block_impl impl;
struct __main_block_desc_0* Desc;
int num; // Captured variable
};
A key detail: captured values are const copies. When a Block captures a regular local variable, it stores a copy of the value. Subsequent changes to the external variable don’t affect the Block, and the Block can’t modify this value either.
To modify the external variable, you need the __block specifier. With __block, the variable is wrapped into a __Block_byref_xxx struct, and the Block captures a pointer to this struct, pointing to memory on the heap.
Memory Layout and Pointer Offsets
On 64-bit systems, pointers are 8 bytes and ints are 4 bytes. The layout of Block_literal_1 is:
| Offset | Member | Size |
|---|---|---|
| 0 | isa | 8 bytes |
| 8 | flags | 4 bytes |
| 12 | reserved | 4 bytes |
| 16 | invoke | 8 bytes |
| 24 | descriptor | 8 bytes |
| 32… | Captured variables | Variable |
The invoke function pointer is at offset 16. After printing a Block’s address with LLDB, you can use memory read to read 8 bytes at offset 16 to get the invoke address. Once you have the address, use disassemble --start-address to disassemble it, or set a breakpoint directly — letting you locate the Block’s entry point during debugging.
Summary
A Block’s internal structure can be broken down into three core layers: isa determines the Block’s type and object identity; the invoke function pointer is the execution entry point; and descriptor describes the Block’s size and helper functions. Captured variables are stored at the end of the struct, while flags controls which optional fields exist via bitwise manipulation.
Understanding this structure isn’t just about grasping underlying principles — during debugging, you can manually locate the invoke address by using memory read with offsets, disassemble it, and set breakpoints to directly pinpoint the callback’s entry point.
In my experience, the most practical use of understanding Block internals is debugging memory issues. I once spent two hours tracking down a crash where a Block was being called after the object that created it was deallocated. The stack trace showed __block_invoke, but without understanding the Block struct, I had no idea where to look. Once I realized the invoke pointer was at offset 16, I used memory read in LLDB to read the Block’s memory layout and confirmed it was a Stack Block that had been deallocated. Adding [block copy] fixed the crash immediately.
Another useful trick: when you see an unknown function pointer in a crash log and suspect it’s a Block, check if the pointer points to a memory region that starts with a valid isa value. If it does, you can read the flags to determine the Block type, then use the invoke offset to find the actual implementation. This technique has saved me from many “mystery crash” debugging sessions where the crash log alone wasn’t enough to identify the root cause.
A Reproducible Debugging Scenario: Finding an Unknown Callback
I reproduced the kind of crash that originally makes __block_invoke look unhelpful: an asynchronous callback appears in a stack trace, but the callback name does not identify the code that created it. Instead of guessing from the symbol alone, I used a Block created inside a small command-line program and inspected the same fields a debugger exposes—its runtime class, flags, descriptor size, captured value, and invoke address.
The useful lesson was not simply that the function pointer sits after the first three header fields. It was that the pointer only becomes meaningful when I already know the memory is a valid Block compiled with a compatible Apple/Clang ABI. Reading arbitrary crash addresses as Block structures can produce misleading values or another fault.
The Inspector Used in the Runnable Project
typedef void (*DemoBlockInvoke)(void *blockLiteral);
struct DemoBlockLiteral {
void *isa;
int32_t flags;
int32_t reserved;
DemoBlockInvoke invoke;
struct DemoBlockDescriptor *descriptor;
};
const struct DemoBlockLiteral *literal =
(__bridge const struct DemoBlockLiteral *)block;
NSLog(@"class=%@ flags=0x%08x", [block class], literal->flags);
NSLog(@"descriptor size=%lu", (unsigned long)literal->descriptor->size);
NSLog(@"invoke=%p", literal->invoke);
block();
literal->invoke((void *)literal);
The demo deliberately uses a no-argument Block whose layout is known by the program. It calls the Block normally, then calls the same implementation through invoke. Both paths print the same callback message, making the function-pointer explanation observable rather than theoretical.
What the Output Proves—and What It Does Not
- The Global Block reports
__NSGlobalBlock__, the global flag, a descriptor size of 32 bytes, and aninvokeaddress. - The capturing Block reports heap-managed storage under ARC, a larger descriptor size, and the known captured integer 42 after the header.
- Calling through
invokereaches the same implementation as ordinary Block syntax. - The observed offsets apply to this 64-bit Apple/Clang build; they are not a portable application API or a promise for every compiler and architecture.
For authoritative background, compare the demo with the LLVM Block Implementation Specification. The project keeps its declarations local and clearly named so they are not confused with public Foundation APIs.
Download and Run the Complete Project
The complete tested source is available in its dedicated repository: Objective-C Block ABI Inspector on GitHub.
git clone https://github.com/2252408699/objc-block-abi-inspector-demo.git
cd objc-block-abi-inspector-demo
make run
Requirements: macOS and Xcode or the Xcode Command Line Tools. Use the code as an educational debugger aid for Blocks created by your own process. Production logic should use the supported Block language syntax and APIs instead of depending on raw runtime offsets.
