Once again we meet on the road trying to print a Hello World on our consoles.

Today’s script.

Dynamic Framework demo

# ignore lines that start with a hash (#)
setopt INTERACTIVE_COMMENTS

# skip commands where the glob pattern does not match any files
setopt null_glob

mkdir -p ~/Desktop/Hello
cd ~/Desktop/Hello
rm -rf *

# Compile the library source
cat << EOF > Greeter.swift
public class Greeter {
    public init() {}
    public func hello() {
        print("Hello World!")
    }
}
EOF

# Create directories for each architecture
mkdir -p arm64 x86_64

# Compile Hello.swift to dynamic libraries and generate module files for each architecture
swiftc -parse-as-library \
    -emit-library -o libHello_arm64.dylib \
    -emit-module -module-name Hello -emit-module-path arm64/Hello.swiftmodule \
    -enable-library-evolution -emit-module-interface-path arm64/Hello.swiftinterface \
    -target arm64-apple-macosx10.9.0 \
    Greeter.swift

swiftc -parse-as-library \
    -emit-library -o libHello_x86_64.dylib \
    -emit-module -module-name Hello -emit-module-path x86_64/Hello.swiftmodule \
    -enable-library-evolution -emit-module-interface-path x86_64/Hello.swiftinterface \
    -target x86_64-apple-macosx10.9.0 \
    Greeter.swift

# Create a universal (fat) dynamic library from the object files
lipo -create libHello_arm64.dylib libHello_x86_64.dylib -output libHello.dylib
rm libHello_arm64.dylib
rm libHello_x86_64.dylib

# Build the framework structure
mkdir -p Hello.framework/Versions/A/
mkdir -p Hello.framework/Versions/A/Modules
mkdir -p Hello.framework/Versions/A/Modules/Hello.swiftmodule
mkdir -p Hello.framework/Versions/A/Resources

# Move the dynamic library
mv libHello.dylib Hello.framework/Versions/A/Hello

# Move common module files
mv arm64/Hello.swiftdoc Hello.framework/Versions/A/Modules/
mv arm64/Hello.abi.json Hello.framework/Versions/A/Modules/
mv arm64/Hello.swiftsourceinfo Hello.framework/Versions/A/Modules/

# Move ARM64 module files
mv arm64/Hello.swiftmodule Hello.framework/Versions/A/Modules/Hello.swiftmodule/arm64.swiftmodule
mv arm64/Hello.swiftinterface         Hello.framework/Versions/A/Modules/Hello.swiftmodule/arm64.swiftinterface
mv arm64/Hello.private.swiftinterface Hello.framework/Versions/A/Modules/Hello.swiftmodule/arm64.private.swiftinterface

# Move x86_64 module files
mv x86_64/Hello.swiftmodule Hello.framework/Versions/A/Modules/Hello.swiftmodule/x86_64.swiftmodule
mv x86_64/Hello.swiftinterface         Hello.framework/Versions/A/Modules/Hello.swiftmodule/x86_64.swiftinterface
mv x86_64/Hello.private.swiftinterface Hello.framework/Versions/A/Modules/Hello.swiftmodule/x86_64.private.swiftinterface

# Create the Info.plist
cat << EOF > Hello.framework/Versions/A/Info.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleExecutable</key>
    <string>Hello</string>
    <key>CFBundleIdentifier</key>
    <string>com.example.Hello</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>Hello</string>
    <key>CFBundlePackageType</key>
    <string>FMWK</string>
    <key>CFBundleShortVersionString</key>
    <string>1.0</string>
    <key>CFBundleVersion</key>
    <string>1</string>
    <key>NSHumanReadableCopyright</key>
    <string>Copyright © 2024 Hello Company. All rights reserved.</string>
</dict>
</plist>
EOF

# Create symbolic links to speed up access
cd Hello.framework/Versions
ln -s A Current
cd ..
ln -s Versions/Current/Hello Hello
ln -s Versions/Current/Headers Headers
ln -s Versions/Current/Info.plist Info.plist
ln -s Versions/Current/Resources Resources
ln -s Versions/Current/Modules Modules

cd ..
chmod -R 755 Hello.framework

# set the install name in the binary
install_name_tool -id @rpath/Hello.framework/Hello Hello.framework/Versions/A/Hello


# COMPILE THE CLIENT

# Create UseHello.swift
cat << EOF > UseHello.swift
import Hello
@main
public struct UseHello {
    public static func main() {
        let greeter = Greeter()
        greeter.hello()
    }
}
EOF

# Compile UseHello.swift into executables for each architecture
swiftc -parse-as-library \
    -o UseHello-arm64 \
    -target arm64-apple-macosx10.9.0 \
    -F. -framework Hello -I Hello.framework/Modules/arm64 \
    UseHello.swift
swiftc -parse-as-library \
    -o UseHello-x86_64 \
    -target x86_64-apple-macosx10.9.0 \
    -F. -framework Hello -I Hello.framework/Modules/x86_64 \
    UseHello.swift 

# Create a universal (fat) binary
lipo -create UseHello-arm64 UseHello-x86_64 -output UseHello

# check for frameworks in the current folder
install_name_tool -add_rpath @executable_path/. UseHello

# Clean up intermediate files
rm UseHello-arm64 UseHello-x86_64

# cleanup
rm -rf arm64 
rm -rf x86_64

# Execute
./UseHello


# CREATE EXECUTABLE PACKAGE

# Encapsulate the libHello.a in a xcframework
xcodebuild -create-xcframework \
    -framework Hello.framework \
    -output Hello.xcframework

# Optionally sign the framework
# security find-identity -v -p codesigning
# codesign --sign "YOUR_ID_HERE" --timestamp --options runtime Hello.xcframework

# Remove every file in the current folder except the xcframework
rm -rf Hello.framework
# 2>/dev/null hides the message 'rm: Hello.xcframework: is a directory'
rm * 2>/dev/null

# Create an executable 
swift package init --type executable --name UseHello

# Overwrite the Package.swift to add the dependency
cat << EOF > Package.swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "UseHello",
    platforms: [.macOS(.v15)],
    products: [
        .executable(name: "UseHello", targets: ["UseHello"])
    ],
    dependencies: [],
    targets: [
        .executableTarget(name: "UseHello", dependencies: ["Hello"]),
        .binaryTarget(name: "Hello", path: "./Hello.xcframework")
    ]
)
EOF

# Replace the default main.swift file.
rm Sources/main.swift
mkdir -p Sources/UseHello
cat << EOF > Sources/UseHello/main.swift
import Hello
Greeter().hello()
EOF

# Build a fat client
xcrun swift build -c release --arch arm64 --arch x86_64

# Copy the executable
cp .build/apple/Products/Release/UseHello .

# Add current folder to the search path (so it finds the framework)
install_name_tool -add_rpath @executable_path/Hello.xcframework/macos-arm64_x86_64/. UseHello

# Execute
./UseHello

Small Differences

This is the same as the static framework with some differences I’ll discuss further below.

# emit a dynamic library, not object code
swiftc -emit-library ... Hello.swift

# set the identification name of the framework
install_name_tool -id @rpath/Hello.framework/Hello \
Hello.framework/Versions/A/Hello

# add the current path to the list of framework search paths
install_name_tool -add_rpath @executable_path/. UseHello

Going dynamic

With static libraries, the library code is embedded directly into the main executable during the build process, including time-intensive optimizations. This slows down the build process.

When using dynamic libraries

  • At build time the static linker (ld) stores the path to the library in the application binary.
  • At run time the dynamic linker (dyld) loads the whole library to memory.

Therefore compilation is faster because we skip the time spent on optimizations, but execution is slower. Overall this speeds up iteration because optimizations take way more time than loading libraries.

There are a few other consequences:

  • Dynamic libraries can have their own initialization and cleanup routines, which execute when the library is loaded or unloaded.
  • Libraries can tell the dynamic linker to load additional code.
  • Parallel compilation of libraries since they are independent products.
  • Libraries can be shared and updated independently.

Install name tool

I used this command in the script:

install_name_tool \
    -id @rpath/Hello.framework/Hello \
	Hello.framework/Versions/A/Hello

The install_name_tool records the expected location of frameworks in their own binaries. This helps executables to load their dependencies.

Here is how it works:

  1. install_name_tool assigns an identification name to the binary located at Hello.framework/Versions/A/Hello. This name represents the expected runtime location of the binary within the framework structure.
  2. When an executable that depends on this framework is compiled, the compiler will read the location of the framework from its identification name and store it in the executable as a LC_LOAD_DYLIB command.
  3. Upon launching the executable, the system attempts to load the framework using the path stored in the LC_LOAD_DYLIB command to ensure the framework is available to handle calls made by the application.

Install name variables

The variable @rpath refers to the runtime path, which isn’t just a single directory but rather a list of potential directories. During execution, the system appends /Hello.framework/Hello to each path to locate the framework.

There are two other install name variables that can be used:

  • @executable_path is the path of the executable that depends on the framework.
  • @loader_path is the path of the binary (executable or another library) that depends on the framework.

Example: if the app executable is MyApp.app/Contents/MacOS/MyApp then MyApp.app/Contents/MacOS is the @executable_path and this can be used to refer to MyApp.app/Contents/Frameworks passing @executable_path/../Frameworks.

The man dyld command provides a definition of these variables and more details.

Framework Search Paths

At build time the framework search paths are specified by the -F flag. Additionally, paths can be explicitly embedded into the executable in two ways:

  • using the install_name_tool,
  • or passing an option to the linker with -Xlinker -rpath -Xlinker <path>.

Paths embedded in the executable can be displayed with otool:

# look for the command load runtime path and show me 2 lines of context
otool -l UseHello | grep -A2 LC_RPATH

At run time the system first checks the paths that were embedded in the executable during the build phase. If the framework is not found, it defaults to checking several standard system locations:

  • /Library/Frameworks
  • /System/Library/Frameworks
  • ~/Library/Frameworks
  • Paths in the DYLD_FRAMEWORK_PATH variable.

These mechanisms ensure that applications can dynamically link to the necessary frameworks providing some flexibility to their installation paths.

The Dynamic Linker

dyld is the dynamic loader that helps the kernel to launch programs. It has several functions:

  • Load dynamic libraries and frameworks into the program’s address space.
  • Resolve symbolic references between programs and libraries, sometimes lazily for performance reasons.
  • Calls initialization routines for loaded libraries.
  • Looks for frameworks in the Framework Search Paths, interpreting the install name variables @rpath, @executable_path, and @loader_path.

Whenever you hear that the system is loading a library it is actually dyld who is doing the loading.

Inspecting the binary

dyld_info is a tool that provides information about dynamic linking and loading of executables and libraries. For instance, it lists the symbols exported in a binary:

% dyld_info -exports libHello.dylib | swift demangle | grep 'hello()'
        0x000043E0  dispatch thunk of Hello.Greeter.hello() -> ()
        0x00004E1C  method descriptor for Hello.Greeter.hello() -> ()

Using the framework from a SPM

Once again, the script encapsulates the dependency in a XCFramework. The script is clear except for the install name trickery. After building I look for the executable and copy it to the current folder.

% find .build -name UseHello
.build/apple/Products/Release/UseHello

% cp .build/apple/Products/Release/UseHello .
% ./UseHello

./UseHello 
dyld[46851]: Library not loaded: @rpath/Hello.framework/Hello
  Referenced from: <922B895D-1938-302F-9567-D955FAD16CF8> 
  /Users/jano/Desktop/Hello/UseHello
  Reason: tried: '/Users/jano/Desktop/lib/Hello.framework/Hello' (no such file), 
  '/Users/jano/Desktop/lib/Hello.framework/Hello' (no such file)

OK, install name doesn’t point to the xcframework. Let’s see it in full.

% otool -l UseHello | grep -A2 LC_RPATH
          cmd LC_RPATH
      cmdsize 40
         path @executable_path/../lib (offset 12)

Aha! look at us using our arcane Mach-O LC commands to diagnose the issue. Let’s point it to the dynamic framework inside the xcframework.

% install_name_tool -add_rpath @executable_path/Hello.xcframework/macos-arm64_x86_64/. UseHello

% UseHello
Hello World!

Yes, this is fragile. You have to decide on a location for the framework, whether absolute or relative to the executable. In real life is not an issue because either we use a system location or we encapsulate executable and its dependencies in an .app.

Packaging SPM as a framework

Previously I compiled a dummy file Greeter.swift to a dylib and packaged it to a framework.

Another source for a dylib could be a SPM package of type library. Just run a variant of the following command to get the dylib. The rest of the procedure is the same.

swift build -c release --arch arm64 --arch x86_64

Conclusion

In this article, we explored how to create dynamic frameworks, which are extremely popular within the Apple ecosystem. However, what to do when two platforms share the same binary architecture (x86_64)? such is the case for iOS simulator and macOS. The solution lies in XCFrameworks, which I’ll cover in the final article. Additionally I’ll talk about “mergeable libraries,” which offer two interesting features: link the same library statically or dynamically, and/or merge two frameworks into one.