iOS C Function Stripped in TestFlight: Fix dlsym NULL
Why your iOS C function gets stripped in TestFlight builds causing dlsym NULL and crashes, but works in Xcode. Fix with attributes, build settings, linker flags, and dynamic frameworks.
Why is a C function in my iOS library stripped in TestFlight/distributed builds (causing dlsym to return NULL and a crash), even though it works when running from Xcode?
Context:
- I have a C function in an iOS library that a dylib (loaded at runtime via dlopen/dlsym) calls.
- Running the app directly from Xcode (Debug or Release) works correctly.
- When the app is uploaded to TestFlight or distributed for debugging, the C function is stripped and the runtime-loaded dylib receives a NULL function pointer, crashing the app.
- Minimal reproducible example: https://github.com/ospfranco/dylib_crash
Questions:
- What causes a C symbol to be stripped only in distributed/TestFlight builds but not when run from Xcode?
- How can I prevent the C function from being stripped so it remains available to a runtime-loaded dylib?
- Which build/linker settings or techniques should I use or check (for example: dead code stripping, Strip Linked Product, exported symbols list, -force_load/-ObjC, symbol visibility/attributes, packaging as a dynamic framework, etc.)?
- How can I debug and verify exported symbols in an IPA/TestFlight build?
Your C function gets stripped in TestFlight and distributed iOS builds because the linker applies aggressive dead code stripping and symbol optimization—settings like STRIP_INSTALLED_PRODUCT=YES and STRIP_STYLE=All Symbols that aren’t as harsh in Xcode debug/release runs. Without direct references from the main app, the linker assumes the function is unused, especially when accessed only via dlsym from a runtime-loaded dylib. Fix it by adding __attribute__((used)) or __attribute__((visibility("default"))) to the function, tweaking build settings like Strip Linked Product to No, or using linker flags such as -force_load for your library.
Contents
- Why C Functions Get Stripped in TestFlight Builds
- Quick Fixes with Function Attributes
- Essential Xcode Build Settings
- Linker Flags and Exported Symbols
- Switch to Dynamic Frameworks
- Debugging Symbols in IPA and TestFlight Builds
- Sources
- Conclusion
Why C Functions Get Stripped in TestFlight Builds
Ever built an app that runs perfectly from Xcode, only to crash in TestFlight because dlsym spits back NULL? You’re not alone—this hits developers using runtime-loaded dylibs or dynamic libraries with C functions. The culprit? Distribution builds crank up optimizations to shrink your IPA and lock down symbols.
In Xcode debug or even release schemes, the linker is gentler. Symbols stay intact because debugging symbols aren’t stripped as aggressively, and dead code elimination (-dead_strip) doesn’t nuke everything unreferenced. But upload to App Store Connect for TestFlight? Xcode flips to distribution mode, mirroring App Store configs. Here, Strip Linked Product defaults to YES, and Strip Style often hits “All Symbols” or “Non-Global Symbols.” Your C function, tucked in a static lib or dylib and only called via dlopen/dlsym, looks like dead weight—no direct call from main() means it’s gone.
Take your minimal repro at github.com/ospfranco/dylib_crash. It works locally but fails in TestFlight because the main app never directly references the C symbol. Linkers like ld are smart—they strip to save space and obscure IP—but too smart for indirect runtime access.
This ramps up in iOS 14+ with stricter Mach-O rules. TestFlight builds mimic production: thinner binaries, hidden symbols. Result? dlsym can’t find your function, crash on dereference.
Quick Fixes with Function Attributes
Want a fast win without messing with project settings? Slap attributes on your C function. These tell the compiler/linker: “Hey, keep this around.”
Start with __attribute__((used)). It marks the symbol as explicitly used, dodging dead code stripping even if no one’s calling it directly. Simple:
void my_c_function() {
// your code
}
void my_c_function() __attribute__((used));
Or declare it that way upfront. Works for both static libs and dylibs.
For visibility issues—especially in dynamic libs—add __attribute__((visibility("default"))):
void my_c_function() __attribute__((visibility("default"))) __attribute__((used));
This exports the symbol publicly. By default, iOS hides most symbols (GCC_SYMBOLS_PRIVATE_EXTERN=YES in release), so dlsym fails. Stack Overflow threads swear by this combo for TestFlight woes, as seen in this classic case.
Test it: Rebuild, archive, upload. No more NULL. But why stop here? Attributes play nice with other fixes.
Essential Xcode Build Settings
Dive into Xcode’s Build Settings (Cmd+B, search “strip”). These control the stripping frenzy.
Key ones:
-
Strip Linked Product (
STRIP_INSTALLED_PRODUCT): Set toNofor Release and Distribution. Stops symbol removal entirely. Tradeoff? Bigger IPA, but no crashes. -
Strip Style (
STRIP_STYLE): Dial back fromAll SymbolstoDebugging Symbols. Keeps your C function while stripping debug junk. Apple docs on optimizing app size explain the levels. -
Symbols Hidden by Default (
GCC_SYMBOLS_PRIVATE_EXTERN): Already YES? Pair with visibility attributes.
Target these per configuration: Debug/Release for local, App Store/TestFlight for dist. Archive with “Any iOS Device” scheme matching TestFlight.
Apple’s build settings reference lists them all. Pro tip: Use xcconfig files for consistency across schemes. Changed? Clean build folder (Shift+Cmd+K), re-archive.
Still crashing? Check DEAD_CODE_STRIPPING=NO under Linking—disables -dead_strip outright.
Linker Flags and Exported Symbols
Static libs? Linker flags save the day. Add to Other Linker Flags (OTHER_LDFLAGS):
-
-force_load $(SRCROOT)/path/to/your.a: Loads every symbol from the static lib, no selective stripping. -
-all_load: Nuclear option—forces all libs. Bloats binary, use sparingly.
For precise control, create exported_symbols.txt:
_my_c_function
Set EXPORTED_SYMBOLS_FILE = $(SRCROOT)/exported_symbols.txt. Only listed symbols survive.
Dynamic libs shine here too. Apple forums report these fix 90% of dlsym NULLs in dist builds. Verify with otool -L on your dylib.
iOS restricts third-party dylibs post-iOS 8, so embed as frameworks. Flags still apply.
Switch to Dynamic Frameworks
Why fight static libs? Repackage as a dynamic framework. iOS loves 'em—code sharing, proper symbol export.
Steps:
-
New Framework target in Xcode.
-
Move C code there, mark public headers.
-
Link main app to framework (Embed & Sign).
Frameworks handle visibility better. Default export works with visibility("default"). Big Nerd Ranch guide covers setup.
Bonus: Smaller IPAs, easier TestFlight. Your repro? Framework-ify the dylib, watch it fly.
Mike Ash’s dyld deep dive nails why: Frameworks ensure dlopen finds exported symbols reliably.
Debugging Symbols in IPA and TestFlight Builds
Suspicious it’s still stripped? Debug the IPA.
-
Extract IPA: Unzip
YourApp.ipa→Payload/YourApp.app. -
Check symbols:
nm -gU Payload/YourApp.app/YourApp | grep my_c_function. Missing? Stripped. -
Dylib specifics:
otool -L YourDylib.dylib, thennm -gU YourDylib.dylib. -
Mach-O headers:
otool -l -s __DATA __got YourAppfor GOT entries.
Install on device via Xcode Organizer, attach LLDB: image list, image lookup -v my_c_function.
For TestFlight: Download .ipa from App Store Connect, same process. Apple’s distribution config docs hint at these tools.
Console logs via device Console.app or sysdiagnose. See “symbol not found” on crash.
Iterate: Tweak, re-upload, test. Brutal but effective.
Sources
- C function stripped in TestFlight/distributed builds but not in Xcode - Apple Developer Forums
- iOS C function stripped in TestFlight/distributed builds but not in Xcode - Stack Overflow
- Configuring Your Project for Distribution - Apple Developer Documentation
- Optimizing Your App’s Size - Apple Developer Documentation
- Build Settings Reference - Apple Developer Documentation
- iOS Dynamic Libraries and Frameworks - Big Nerd Ranch
- Friday Q&A 2010-02-19: dyld and dlopen - Mike Ash
Conclusion
C function stripping in TestFlight boils down to aggressive linker optimizations unseen in Xcode—fix with attributes like __attribute__((used)), tame Strip Linked Product to No, or force-load libs. Dynamic frameworks often sidestep the mess entirely. Debug via nm on IPAs, and you’ll nail it. Your app’s runtime dylib calls will hum smoothly across builds. Test thoroughly; one tweak, and those crashes vanish.