Static vs Dynamic Framework Dispatching (Part 2 of 3): SPM vs Xcode frameworks
This is Part 2 of a three-part series:
- Part 1: Dispatching under the hood
- Part 2: Hands-on experiments with SPM and Xcode frameworks
- Part 3: Debug, Release, @rpath, and the debug dylib
In Part 1, we followed a call from the app into library code. Now I want to find that call in a compiled app. Where did the static implementation end up? Which image owns the dynamic implementation? And what can a terminal command actually prove?
The examples use two small apps: one with Xcode framework targets, another with Swift Package Manager products. Both call an add function from a static library and a dynamic library. The arithmetic is deliberately boring. The interesting part is everything around the call.
What we will check
| Question | Useful evidence |
|---|---|
| Is this library an archive or a dynamic Mach-O image? | file, otool -hv |
| Which image defines the function? | nm, with demangled names |
| What dynamic dependencies does this image record? | otool -L, otool -l |
| Does this particular call branch to local code or a symbol stub? | Disassembly of the caller |
| Which imported symbol does a pointer slot refer to? | dyld_info -imports -fixups |
| Which images did this process actually load? | LLDB image list |
One distinction will matter throughout: static versus dynamic linking is separate from Swift or Objective-C method dispatch. A statically linked library can still use protocol witness tables, class dispatch, closures, and Objective-C messaging. Here we inspect specific calls to simple functions, not every possible call a library can make.
Set up the experiment
The two projects use these names:
| Build setup | App | Static dependency | Dynamic dependency |
|---|---|---|---|
| Xcode targets | Library-linking-Test-Xcode-Framework |
XcodeFrameworkStatic |
XcodeFrameworkDynamic |
| SwiftPM products | Library-linking-Test |
SDK-SPM-Static |
SDK-SPM-Dynamic |
The Xcode libraries expose MyMathStatic.add and MyMathDynamic.add. The SwiftPM modules both expose MyMath.add, so the app qualifies calls with SDK_SPM_Static or SDK_SPM_Dynamic. Some implementations also print a message, which makes them easier to find and confirms execution.
This is the essential API shape, with the logging omitted:
public struct MyMathStatic {
public static func add(_ a: Int, _ b: Int) -> Int {
a + b
}
}To recreate the Xcode case, start with an iOS app and add two framework targets with the names above. Put the static API in one target and the equivalent MyMathDynamic API in the other. Set their Mach-O types as shown below, link both to the app, and call both functions from the app so the linker has a reason to include them:
import XcodeFrameworkStatic
import XcodeFrameworkDynamic
print(MyMathStatic.add(2, 4))
print(MyMathDynamic.add(2, 4))Place those calls in an app lifecycle callback, such as the sample view’s onAppear. This recreates the linkage relationship; addresses and incidental runtime imports will differ from my build.
Use an unoptimized build for the first inspection. Optimization, inlining, dead stripping, and symbol stripping can change or remove the evidence we are looking for. An absent symbol in nm is not, by itself, proof that code was never linked.
The Xcode binary excerpts below were rechecked with Xcode 26.6 (17F113), Swift 6.3.3, and the arm64 iOS Simulator slice. Addresses are specific to that build. Earlier screenshots show the setup, not an exact promise about current Xcode’s UI or output.

Time to inspect the product. The build setting tells us what we asked for; the binary tells us what we got.
Xcode framework targets
For these sample targets, Mach-O Type is Static Library for XcodeFrameworkStatic and Dynamic Library for XcodeFrameworkDynamic.


The .framework suffix is a bundle format. It does not, on its own, prove that the binary inside is dynamic. Inspect that binary:
file "$BUILD_PRODUCTS/XcodeFrameworkStatic.framework/XcodeFrameworkStatic"
file "$BUILD_PRODUCTS/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic"Set BUILD_PRODUCTS to your build’s products directory first. In this experiment, the static result is an archive, while the dynamic result is a dynamically linked shared library. A universal build can report more than one architecture.
Swift Package Manager products
The sample manifests explicitly choose a linkage type:
.library(
name: "SDK-SPM-Static",
type: .static,
targets: ["SDK-SPM-Static"]
).library(
name: "SDK-SPM-Dynamic",
type: .dynamic,
targets: ["SDK-SPM-Dynamic"]
)These are product declarations, not complete manifests. The corresponding targets and source paths must also exist. If a product leaves type unspecified, SwiftPM lets the consumer choose the linkage; do not read an omitted type as an explicit .static declaration. See the SwiftPM product API.
Xcode’s SwiftPM integration can package a dynamic product as a framework. A command-line SwiftPM build can produce a .dylib. Inspect the product generated by the tool you actually used, rather than assuming its extension from Package.swift.
The SwiftPM app was also rebuilt for this revision. Its app-code image defines the static MyMath.add and imports the dynamic one:
T _$s14SDK_SPM_Static6MyMathV3addyS2i_SitFZ
U _$s15SDK_SPM_Dynamic6MyMathV3addyS2i_SitFZAddresses are omitted here. otool -L records @rpath/SDK-SPM-Dynamic.framework/SDK-SPM-Dynamic. In this sample configuration, that framework was not embedded in the app, so a successful build alone was not enough to make the product ready for standalone launch. The next section explains the missing step.

One shared workspace versus separate workspaces: a metaphor for where the implementations live, not for how fast they run.
Linking and embedding are separate steps
Linking connects code and records dependencies. Embedding puts a runtime dependency into the app bundle. A successful link does not guarantee that the app contains everything it needs at launch.
In the Xcode-framework sample, the app target uses Embed & Sign for its own dynamic framework. For its static framework, it uses Do Not Embed: the archive is an input to the linker, not a runtime image to ship beside the executable.

This is not a rule that every entry marked “Do Not Embed” must be static. Apple system frameworks are dynamic and are supplied by the operating system. Package managers may also arrange copying and signing through generated build phases. Check the final bundle as well as the settings screen. Apple’s framework embedding guide explains the distinction and common missing-framework failures.
Resources are another separate concern. Statically linking code does not automatically copy every image, localization, or resource bundle that code needs.

Linking records a dependency. Embedding makes sure the runtime file travels with the app.
Find the image that contains the app code
Before running nm, identify the right file. In a build with Xcode’s debug dylib support, the app executable is a small launcher and most app code is in App.debug.dylib. That includes code statically linked from our sample library.
For this iOS app, set APP to the built .app directory, then run:
APP="/path/to/Library-linking-Test-Xcode-Framework.app"
EXECUTABLE=$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$APP/Info.plist")
APP_CODE="$APP/$EXECUTABLE"
if [ -f "$APP/$EXECUTABLE.debug.dylib" ]; then
APP_CODE="$APP/$EXECUTABLE.debug.dylib"
fi
DYNAMIC_CODE="$APP/Frameworks/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamic"The path is a placeholder; replace it with your own build product. This layout is specific to the iOS sample. A macOS app has a different bundle structure.
Selecting the debug dylib does not mean the static framework became a dynamic dependency. It means a different output image received the statically linked code. Part 3 examines that layout in detail.
Experiment 1: find the definitions and imports
xcrun nm -arch arm64 "$APP_CODE" | grep MyMath | xcrun swift-demangle
xcrun nm -arch arm64 "$DYNAMIC_CODE" | grep MyMath | xcrun swift-demangleIn the app-code image, the relevant un-demangled lines from the inspected Debug build are:
0000000000004188 T _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZ
U _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZT identifies a text-section definition in this image. U identifies an undefined symbol that this image imports. The dynamic framework’s binary supplies the second definition.
So we have two different relationships:
- The static library contributed code to
APP_CODE; it has no separate runtime image of its own. APP_CODEimports the dynamic function from another Mach-O image.
This is a useful result for this build. With optimized or stripped products, combine symbol inspection with dependency information, a linker map, or matching debug symbols before drawing the same conclusion.
Experiment 2: inspect recorded dependencies
xcrun otool -arch arm64 -L "$APP_CODE"
xcrun otool -arch arm64 -L "$DYNAMIC_CODE"The relevant dependency recorded in the app-code image is:
@rpath/XcodeFrameworkDynamic.framework/XcodeFrameworkDynamicThere is no equivalent load command for XcodeFrameworkStatic. Its selected code was linked into the output image.
Two details prevent misleading readings of this output:
otool -Lreads the file. It does not list all images loaded by a running process, nor does it recursively expand every dependency.- For a dylib, it also displays that dylib’s own install name, from
LC_ID_DYLIB. TheApp.debug.dylibline in its own output is its identity, not a dependency on another copy of itself. Useotool -Dfor the identity orotool -lto distinguish load-command types.
Experiment 3: follow the call instruction
Inspect the caller, where the call sites and imported-symbol stubs live:
xcrun otool -arch arm64 -tvV "$APP_CODE" | grep -A6 addyS2i_SitFZRelevant lines from the same build:
00000000000015dc bl 0x4b38 ; symbol stub for: _$s21XcodeFrameworkDynamic06MyMathC0V3addyS2i_SitFZ
0000000000001748 bl _$s20XcodeFrameworkStatic06MyMathC0V3addyS2i_SitFZBoth use bl, the arm64 branch-and-link instruction. The difference is the destination. One branches to an imported-symbol stub; the other branches to the static function’s implementation within this image.
The first line is a call to a stub, not the stub body itself. To examine the stub instructions and pointer sections, use:
xcrun dyld_info -arch arm64 -disassemble "$APP_CODE"
xcrun otool -arch arm64 -Iv "$APP_CODE"This sample demonstrates a direct call to the static implementation. It does not establish “static linking means zero indirection everywhere.” Compiler optimization, language dispatch, function pointers, and other dynamic dependencies remain relevant.
A GOT entry does not prove lazy binding
This is where a binary dump can tempt us into claiming too much. Finding __stubs and __got establishes indirection. It does not establish that the function’s address is resolved on its first call.
Inspect the actual imports and fixups:
xcrun dyld_info -arch arm64 -imports -fixups "$APP_CODE" | grep MyMath
xcrun otool -arch arm64 -l "$APP_CODE" | grep -A3 LC_DYLD_CHAINED_FIXUPSFor the inspected binary, dyld_info associates a slot in __DATA_CONST,__got with XcodeFrameworkDynamic.MyMathDynamic.add, and the load commands include LC_DYLD_CHAINED_FIXUPS.
Modern chained fixups describe pointers that need binding or rebasing. Their on-disk bytes can encode fixup information; they are not necessarily the final pointers a running process sees. A raw hex dump of __got cannot demonstrate a before-and-after first call.
Classic lazy binding is a different case: an unresolved call takes a resolver path, then later calls use the resolved pointer. Do not substitute that model for every modern stub. Apple discusses chained fixups and page-in linking in Link fast: Improve build and launch times. Page-in linking concerns memory pages becoming available, not a promise to resolve each function at its first call.
Also avoid counting __got sections as a static/dynamic test. Even an app using only our static math library imports system and Swift runtime functions, so its final image can contain both stubs and a GOT.
Experiment 4: check the running process
Pause the app in Xcode’s debugger, then use LLDB:
(lldb) image list
(lldb) image lookup -rn 'MyMathStatic.*add'
(lldb) image lookup -rn 'MyMathDynamic.*add'Look at the owning image and its path. In a build with debug dylib support, expect the static implementation in the app’s debug dylib and the dynamic implementation in its framework image. Without that support, the static implementation can be in the main executable instead.
Image indices and addresses vary. An index such as [22] in someone else’s capture is not a rule about where app code belongs. If a lookup fails, check optimization and available symbols before interpreting it as missing code.
The image path also tells you which copy of a framework loaded. A copy in the build products directory and a copy inside the installed app can have the same name. That distinction will drive the missing-framework experiment in Part 3.
Where CocoaPods fits
CocoaPods can choose how source dependencies are built and packaged:
use_frameworks! :linkage => :static
# Or, in an alternative configuration:
# use_frameworks! :linkage => :dynamicThe Podfile reference documents these linkage options. A vendored precompiled binary still has its own linkage type; a Podfile setting cannot turn an existing dynamic binary into a static archive. Inspect the resulting binary using the same commands.
What these experiments say about performance
Static linking can reduce the number of separately loaded private images. Dynamic linking keeps a dependency in its own image and may avoid duplicating shared code across several consumers in one process. Neither observation gives us a launch-time measurement.
The linker can extract selected archive members, dead-strip unused sections, and cooperate with compiler optimizations. It does not necessarily copy every function from a static library into the app. Conversely, moving code into a dynamic framework can shrink the main executable while leaving total app size unchanged or larger.
Compare the final app bundle or exported artifact, not just one executable. Use size for segment sizes, not as a substitute for download size or resident memory. For launch performance, measure equivalent optimized builds on a device with Instruments and repeated runs.
What we established
For the sample’s inspected calls, static code belongs to the app-code image, while the dynamic function remains an import supplied by a separate framework. Xcode targets and SwiftPM products lead to the same questions about the final binary: where is the definition, what dependency is recorded, and where does the call go?
The next problem is more deceptive: what if the binary records the right dependency, the framework is missing from the app, and an Xcode run still succeeds? In Part 3, we separate build layout from launch environment and investigate exactly that case.