Objective-C + C Callback Interoperability
Build a complete, working pattern for registering an Objective-C object as the target of a C API's callback: writing the static C callback function, passing void *context correctly, converting it back to an Objective-C object safely, forwarding to an instance method, and — most importantly — unregistering the callback and invalidating the context before the object deallocates, to avoid dangling contexts.
The previous lesson, 'Advanced Pointer and Casting Patterns,' established the theory: what void * is, how __bridge casts move an Objective-C object through it, and why lifetime mismatches between a C API and an ARC-managed object produce use-after-free bugs. This lesson is the deep, fully worked companion to that one — instead of covering the pieces individually, it walks through one complete, realistic C-API-plus-Objective-C-object integration from registration through teardown, including the part most tutorials skip: how to *stop* the callback safely before your object goes away.
The Shape of a Callback-Based C API
Many C APIs — timers, audio buffers, hardware sensors, low-level networking, and plenty of Apple's own C-level frameworks — follow the same basic shape: a registration function that accepts a function pointer (the callback) plus a void *context, an internal mechanism that eventually invokes that function pointer on some event, always passing the same context back unchanged, and (in any well-designed API) a corresponding unregistration function that stops future invocations. This lesson models a plausible sensor-monitoring C API with exactly that shape, close in spirit to real APIs like Core Audio's queue callbacks or a hardware temperature/motion sensor SDK.
The registration function's signature looks like this: SensorHandle SensorStart(void (*callback)(double value, void *context), void *context);, returning an opaque handle you'll need later to stop the sensor, and the corresponding teardown function is void SensorStop(SensorHandle handle);. Notice that SensorStart takes the callback and context as two separate parameters — this is universal in C callback APIs, precisely because C has no way to bundle 'a function plus some associated data' into one value the way an Objective-C block or a Swift closure does; the API has to accept them separately and trust you to keep them logically paired yourself.
The whole flow that follows is exactly the diagram this lesson centers on: a C API invokes a callback function you supplied; that callback receives back the void *context you originally registered; the callback casts that context back into an Objective-C object; and finally, that object's own instance method — the 'application handler' — is where your actual business logic lives, completely insulated from the C plumbing above it.
• Callback-based C APIs universally separate the function pointer (the callback) from its associated data (the void *context), because C has no built-in way to bundle the two together. • A well-designed callback API always pairs a registration function with a corresponding unregistration/teardown function — registration without a way to stop it is a lifetime hazard by design. • The context is handed back to your callback completely unchanged and uninterpreted by the C API — it is pure pass-through data as far as the API is concerned. • This lesson's worked example models a plausible sensor SDK, but the exact same shape applies to timers, audio queues, and many other C-level Apple frameworks. • An opaque handle returned by the registration function (like SensorHandle) is typically what you pass to the teardown function later — keep it around for exactly that purpose.
Diagram: How Data Flows From C API to Application Handler
It's worth seeing the whole path from end to end before writing any code, because every piece of code in this lesson exists to implement one arrow in this chain:
The Static C Callback Function
The function you register with the C API must be a plain C function — specifically, it must match the exact function-pointer signature the API expects, and in practice it is almost always written as static, meaning it has internal linkage and is only visible within the .m file that defines it. There is no reason for any other file to call this function directly; its entire purpose is to be handed to the C API as a function pointer and invoked *by* that API, never called directly by your own Objective-C code. Marking it static communicates that intent clearly and avoids polluting the global C namespace with a name like sensorCallback that some other file might accidentally collide with.
Inside the callback, the very first thing it does is recover the Objective-C object from context: SensorMonitor *monitor = (__bridge SensorMonitor *)context;. This is the exact __bridge cast pattern from the previous lesson — no retain, no release, just a reinterpretation of the pointer's type, trusting that whatever registered this callback put a genuine, still-alive SensorMonitor * in as context. From that point on, monitor behaves like any ordinary Objective-C object reference: you can send it messages, access its properties, anything at all.
The callback function itself should stay as thin as possible — its entire job is the C-to-Objective-C translation step (recovering the object, and perhaps converting any C-level parameter types into more idiomatic Objective-C ones), immediately followed by forwarding to a real instance method where the actual logic lives. Putting substantial logic directly inside the static C function is a design smell: it means that logic can't easily use ordinary Objective-C idioms (dispatching to other objects, using properties, calling other methods), and it couples your business logic unnecessarily tightly to the shape of the C API's callback signature.
• The C callback function should be static, limiting its visibility to the file that defines it, since it's meant to be called only by the C API via its function pointer, never directly by your own code. • The very first statement in the callback should be the __bridge cast recovering the Objective-C object from context. • The callback function should do the minimum necessary C-to-Objective-C translation and then immediately forward to a real instance method — keep it thin. • Any C-level parameters (like a raw double value) can be converted or wrapped here before being handed to the instance method, if the instance method wants a more idiomatic Objective-C type. • This translation-then-forward structure is what makes the rest of your code able to ignore that a C API is even involved, past this one boundary function.
Registering the Callback with (__bridge void *)self
On the Objective-C side, registration happens inside an instance method — typically something like - (void)startMonitoring, called by whatever code owns the SensorMonitor instance and wants it to begin receiving events. Inside that method, the call to the C API looks like: self.sensorHandle = SensorStart(sensorCallback, (__bridge void *)self); — the static C callback function (declared above the @implementation block, or in a private extension, so it's visible at this call site) is passed by its bare name, which decays to a function pointer automatically, and self is passed as context via (__bridge void *)self.
Storing the returned SensorHandle in a property (self.sensorHandle) matters for exactly the reason the diagram above implies: you will need that handle later, when it's time to stop the sensor and unregister the callback, and the handle is the only thing the C API gives you that identifies *this particular registration* (as opposed to some other object's registration with the same API, if multiple SensorMonitor instances exist simultaneously).
At this point, the full round trip described in the diagram is wired up: the C API now holds a function pointer to sensorCallback and a void *context that is really self's address. Every time the underlying hardware or subsystem produces a new sensor reading, the C API will invoke sensorCallback(value, context), which will recover self via __bridge, and forward to whatever instance method does the real work — closing the loop from 'C API' all the way down to 'Application Handler' exactly as the diagram shows.
• Registration happens inside an ordinary Objective-C instance method, using (__bridge void *)self to pass the object itself as context. • The static C callback function's bare name is passed directly as the function-pointer argument — it decays to its own address automatically. • The handle returned by the registration function should be stored (commonly in a property) so it's available later for unregistration. • From this point on, every callback invocation completes the full loop: C API to callback function to void *context to Objective-C object to application handler method. • Nothing about this registration step retains self — the object's survival for as long as the C API might call back is a separate concern, covered next.
Forwarding to the Application Handler
The 'Application Handler' at the bottom of the diagram is simply an ordinary Objective-C instance method — nothing about it needs to know a C API is involved at all. In the sensor example, this might be - (void)didReceiveSensorValue:(double)value;, called from inside sensorCallback as [monitor didReceiveSensorValue:value]; immediately after the __bridge cast recovers monitor.
This separation is the entire point of the thin-callback design from earlier: didReceiveSensorValue: can do absolutely anything an ordinary Objective-C method can do — update a property, post an NSNotification, call a delegate method, append to an array, trigger UI updates (dispatched back to the main queue if needed, since the callback may fire on an arbitrary thread — see the callback-queue note below) — without any of that logic needing to know or care that its ultimate trigger was a raw C function pointer three stack frames up. The C boundary is fully absorbed by the one thin static function, and everything below that boundary is idiomatic Objective-C.
• The application handler is an ordinary instance method, unaware that its caller originated from a C function pointer several frames up. • All real business logic belongs in the application handler, not in the static C callback function itself. • Because the C API may invoke the callback on a thread it controls (not necessarily the main thread), the application handler is often where you'd dispatch UI-affecting work back to the main queue. • This clean separation is what lets the rest of your Objective-C codebase treat a C-API-driven event exactly like any other event source (a delegate call, a notification, a block callback).
Avoiding Dangling Contexts: Unregister Before Deallocation
Everything above works correctly as long as the SensorMonitor instance is still alive every time sensorCallback fires. The hazard identified in the previous lesson is exactly this: (__bridge void *)self did not retain self, so if the SensorMonitor object is deallocated while the C API still believes it has an active registration, the next callback invocation will recover a dangling context — the address of memory that used to hold a SensorMonitor, and may no longer.
The fix is procedural, not a casting trick: you must explicitly unregister the callback, via SensorStop(self.sensorHandle), *before* the SensorMonitor instance can be deallocated — reliably, not just usually. The natural place to do this is -dealloc itself: - (void)dealloc { SensorStop(_sensorHandle); }. This guarantees that by the time ARC actually reclaims the object's memory, the C API has already been told to stop calling back into it, closing the window during which a dangling context could ever be dereferenced.
It's worth being precise about why relying on dealloc alone is a reasonable, common pattern here but not a universal guarantee for every C API: some C APIs document that their teardown function is itself asynchronous or may briefly overlap with in-flight callbacks already dispatched before the stop call returns, which is a stronger hazard than this lesson's simplified sensor model assumes. For APIs with that documented behavior, additional synchronization (blocking until any in-flight callback finishes, or checking a 'still valid' flag inside the callback before dereferencing further) is necessary on top of simply calling the stop function. The general principle, though, is unconditional: never let an Objective-C object that's been handed to a C API as context be deallocated while that registration is still considered active by the C API's own bookkeeping.
• Call the C API's unregistration/teardown function (here, SensorStop(self.sensorHandle)) before the object can be deallocated — -dealloc is the natural, reliable place to do this. • This guarantees the C API has stopped considering the context 'active' before ARC reclaims the object's memory, closing the window for a dangling-context use-after-free. • Some real C APIs document that teardown may overlap with an already-in-flight callback — for those, calling the stop function in dealloc alone is not sufficient, and extra synchronization is needed. • The general, universal principle: never let a context object be deallocated while its C-API registration is still considered active. • This is the concrete, worked-example fix for the exact use-after-free hazard introduced abstractly in 'Advanced Pointer and Casting Patterns'.
Callback Queues: Choosing Which Thread the Callback Fires On
Many real C APIs go a step further than the simplified model here and let you specify which dispatch queue the callback should be invoked on, rather than leaving it to an arbitrary internal thread the API manages itself. A registration function might look like SensorHandle SensorStartOnQueue(dispatch_queue_t queue, void (*callback)(double, void *), void *context);, where queue tells the underlying subsystem to dispatch each callback invocation onto that specific queue rather than calling it directly from whatever internal thread produced the event.
This matters because it changes what your callback (and the application handler it forwards to) is allowed to assume about its execution context. A callback invoked directly on an arbitrary internal thread must treat any shared, non-thread-safe state defensively — and if it needs to update UI, it must explicitly dispatch back to the main queue itself. A callback whose C API lets you specify a target queue can instead be handed dispatch_get_main_queue() directly at registration time, guaranteeing every invocation already runs on the main thread, removing the need for that extra dispatch inside the handler. See 'Grand Central Dispatch' for the full treatment of queues, dispatch, and thread-safety; the point here is narrower — just that callback-registration APIs are one more place GCD queues show up, controlling not just where *your own* dispatched work runs, but where a C API's callback into your Objective-C code runs too.
• Some C APIs accept a dispatch_queue_t alongside the callback and context, letting you choose which queue each invocation is dispatched on rather than accepting an arbitrary internal thread. • Registering with dispatch_get_main_queue() guarantees every callback invocation happens on the main thread, simplifying any UI-affecting logic inside the handler. • Without a specifiable queue, a callback may fire on an arbitrary thread, and any main-thread work inside the handler must be dispatched there explicitly. • This is a genuine intersection point between plain C callback interoperability and Grand Central Dispatch — worth cross-referencing when either topic comes up in real framework code.
Code Examples
SensorHandle is an opaque pointer type -- you never look inside it, you just hold onto it and pass it back to SensorStop later. SensorStart takes the callback and context as two separate parameters, and SensorStop takes only the handle, which the C API internally associates with the right registration to tear down.
SensorHandle SensorStart(void (*callback)(double value, void *context),
The public interface exposes nothing about the C API at all -- no SensorHandle, no void *, no callback signature. Consumers of SensorMonitor just call startMonitoring and stopMonitoring like any other Objective-C API.
This is the complete round trip: startMonitoring registers sensorCallback with (__bridge void *)self as context; sensorCallback recovers the SensorMonitor via __bridge and forwards to didReceiveSensorValue:, the application handler; dealloc guarantees stopMonitoring runs -- and therefore SensorStop is called -- before ARC reclaims the object, avoiding a dangling context.
• (void)dealloc { [self stopMonitoring]; } Without an explicit teardown call before deallocation, the C API has no way to know the SensorMonitor is gone -- it keeps its registration active indefinitely and will eventually invoke sensorCallback with a context pointer to freed memory.
Common mistake: Omitting the -dealloc override (or any other guaranteed call to stopMonitoring) so SensorStop is never called before the object is deallocated.
typedef struct SensorHandle *SensorHandle;
SensorHandle SensorStart(void (*callback)(double value, void *context),
void *context);
void SensorStop(SensorHandle handle);@interface SensorMonitor : NSObject
@property (nonatomic, assign) double lastValue;
- (void)startMonitoring;
- (void)stopMonitoring;
@end#import "SensorMonitor.h"
@interface SensorMonitor ()
@property (nonatomic, assign) SensorHandle sensorHandle;
@end
static void sensorCallback(double value, void *context) {
SensorMonitor *monitor = (__bridge SensorMonitor *)context;
[monitor didReceiveSensorValue:value];
}
@implementation SensorMonitor
- (void)startMonitoring {
self.sensorHandle = SensorStart(sensorCallback, (__bridge void *)self);
}
- (void)stopMonitoring {
if (self.sensorHandle != NULL) {
SensorStop(self.sensorHandle);
self.sensorHandle = NULL;
}
}
- (void)didReceiveSensorValue:(double)value {
self.lastValue = value;
NSLog(@"Sensor reading: %f", value);
}
- (void)dealloc {
[self stopMonitoring];
}
@end@implementation SensorMonitor
- (void)startMonitoring {
self.sensorHandle = SensorStart(sensorCallback, (__bridge void *)self);
}
// No stopMonitoring call anywhere, and no -dealloc override at all.
// When this SensorMonitor is deallocated, SensorStop is never called,
// and the C API keeps calling sensorCallback with a now-dangling context.
@endC API to Application Handler
Each arrow is one step in the worked example: the C API invokes the static callback function, which receives void *context, casts it back to an Objective-C object via __bridge, and forwards to that object's application-handler instance method.
C API
↓
Callback Function
↓
void *context
↓
Objective-C Object
↓
Application HandlerImportant Rules
Structure every C-callback integration as: a thin static C callback function, a __bridge cast recovering the Objective-C object from context, and an immediate forward to a real instance method (the application handler).
Store whatever opaque handle the registration function returns, so it's available later for the matching unregistration call.
Always pair registration with a guaranteed, reliable unregistration call — the natural place is usually -dealloc — so the C API's registration is never left active after the object it targets is gone.
Keep real business logic in the application handler method, not the static C callback function itself, so it stays ordinary, testable Objective-C.
When a C API lets you specify a callback queue, prefer the main queue for handlers that touch UI, to avoid a manual dispatch-to-main step inside the handler.
This Pattern Everywhere in Apple's C-Level Frameworks
This exact register/callback/context/unregister shape is how you integrate with Core Audio's AudioQueueNewOutput, Core MIDI's notification callbacks, many CoreBluetooth-adjacent low-level C hooks, and countless third-party hardware SDKs shipped as C libraries for use from Objective-C. Recognizing the shape — function pointer plus void *context in, __bridge cast plus forward-to-instance-method in the callback, teardown call in -dealloc — means you can integrate with an unfamiliar C API confidently the first time you encounter it, instead of treating each one as a new mystery.
• (void)startMonitoring { self.sensorHandle = SensorStart(sensorCallback, (__bridge void *)self); } // (no -dealloc override, no explicit stopMonitoring call anywhere) - (void)startMonitoring { self.sensorHandle = SensorStart(sensorCallback, (__bridge void *)self); } - (void)dealloc { if (self.sensorHandle != NULL) { SensorStop(self.sensorHandle); } }
Registering without ever unregistering leaves the C API's callback active indefinitely, even after the Objective-C object is deallocated. Since (__bridge void *)self never retained self, the context becomes dangling the instant ARC reclaims the object, and the next callback invocation is a use-after-free. Calling the API's stop function in -dealloc closes this gap reliably.
In the worked SensorMonitor example, what is the static C callback function's main job? A. To contain all of the application's business logic B. To recover the Objective-C object from void *context via __bridge and forward immediately to an instance method C. To retain and release self on every invocation D. To replace the need for an application handler method entirely
B. To recover the Objective-C object from void *context via __bridge and forward immediately to an instance method — The static callback is meant to be a thin translation layer: cast context back to the real object, then forward to a real instance method where the actual logic lives.
Why is the callback function declared static in the .m file? A. static functions run faster on all platforms B. It has internal linkage and is meant to be called only by the C API via its function pointer, not directly by other files C. Objective-C requires all C callbacks to be static D. static prevents the function from ever crashing
B. It has internal linkage and is meant to be called only by the C API via its function pointer, not directly by other files — static gives the function internal linkage, appropriate because it's invoked only through the function pointer handed to the C API, never called directly elsewhere.
Where does the worked example call SensorStop to avoid a dangling context? A. Inside sensorCallback itself, on every invocation B. It never calls SensorStop C. Inside -dealloc (via stopMonitoring), guaranteeing it runs before the object is deallocated D. Inside startMonitoring, immediately after SensorStart
C. Inside -dealloc (via stopMonitoring), guaranteeing it runs before the object is deallocated — Calling SensorStop from -dealloc guarantees the C API's registration is torn down before ARC reclaims the SensorMonitor's memory, closing the use-after-free window.
What does a queue-aware registration function (accepting a dispatch_queue_t) let you control? A. Which class the callback belongs to B. Which queue the callback invocation is dispatched onto, e.g. guaranteeing main-thread execution by passing dispatch_get_main_queue() C. Whether __bridge retains the context object D. The exact memory address of the context pointer
B. Which queue the callback invocation is dispatched onto, e.g. guaranteeing main-thread execution by passing dispatch_get_main_queue() — A queue parameter tells the underlying C subsystem which dispatch queue to invoke the callback on, letting you guarantee, for example, that it always runs on the main thread.
What is the consequence of never calling the C API's unregistration function before an object used as context is deallocated? A. The C API automatically detects deallocation and stops calling back B. Nothing -- __bridge automatically nils out the context C. The C API keeps its registration active and may later invoke the callback with a dangling context pointer, a use-after-free D. The app fails to compile
C. The C API keeps its registration active and may later invoke the callback with a dangling context pointer, a use-after-free — The C API has no visibility into ARC's deallocation; without an explicit unregister call, it keeps the registration active and will eventually call back into memory that's no longer a valid object.
Extend the SensorMonitor example so that startMonitoring uses a queue-aware registration function SensorStartOnQueue(dispatch_queue_t queue, void (*callback)(double, void *), void *context), passing dispatch_get_main_queue(). Then remove the manual dispatch concerns from didReceiveSensorValue: (since it can now safely assume it's on the main thread) and add a comment explaining what would need to change if you instead wanted the callback to run on a background queue.
With dispatch_get_main_queue() passed at registration, didReceiveSensorValue: can update UI directly. Switching to a background queue would require wrapping any UI-affecting statements in dispatch_async(dispatch_get_main_queue(), ^{ ... }) inside the handler, since the callback (and therefore everything it forwards to synchronously) would then run off the main thread.
Think about what didReceiveSensorValue: would need to add back (a dispatch_async to the main queue around any UI-touching lines) if the queue argument were a background queue instead of the main queue.