I’m building a hello world from the terminal with static libraries, dynamic libraries, frameworks, and XCFrameworks. This is intended to provide background information to consolidate understanding of the build process for Apple devices. There is little here that is used for day to day development. Or at least, unless something goes awry and you need to peek behind the courtain.

Here is the serie:

  1. Hello Static Library
  2. Mach-O
  3. Universal Binaries
  4. Hello Static Framework
  5. Hello Dynamic Framework
  6. Hello XCFramework

Hello Static Library

This script creates a static library and its client. Paste it in your ZSH shell to print Hello World!.
Beware, it will create a Hello folder at ~/Desktop/Hello removing any previous content.

Static library demo

# ignore any hash comments pasted on the terminal
setopt INTERACTIVE_COMMENTS
# ignore commands where the glob doesn't match anything
# (e.g., skips rm * when there is nothing to delete)
setopt null_glob
# creates an empty folder
mkdir -p ~/Desktop/Hello
cd ~/Desktop/Hello
rm *

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

# Create the object code
swiftc -parse-as-library \
    -enable-library-evolution \
    -emit-module-interface-path Hello.swiftinterface \
    -emit-module -module-name Hello \
    -emit-object -o Hello.o \
    Greeter.swift

# Archive it
ar -r libHello.a Hello.o

# or use this modern replacement for ar
# libtool -static -o libHello.a Hello.o

# Remove intermediate file
rm Hello.o

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

swiftc -parse-as-library \
    -emit-executable \
    -o UseHello \
    -L. -I. -lHello \
	UseHello.swift

./UseHello

Library source

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

Given that the library module is called Hello I’m naming this object Greeter to avoid name collisions. If library evolution is enabled (-enable-library-evolution) and there is an object with the same name as its parent Module or a module it imports, you’ll see compiler warnings and/or errors. You can read more about this in #56573.

To write the source inline I used the heredoc feature of Unix shells. The syntax is shown below, with content being any number of lines, and delimiter being an arbitrary string, usually ‘EOF’ (end of file).

<< 'delimiter'
content
'delimiter'

Compiling

The following command generates object code (.o) and module metadata files. Module is defined as “unit of code distribution describing the interface of a library or framework”.

swiftc -parse-as-library \
    -enable-library-evolution\
    -emit-module-interface-path Hello.swiftinterface \
    -emit-module -module-name Hello \
    -emit-object -o Hello.o \
    Greeter.swift 

swiftc -parse-as-library

Compiling the library with -parse-as-library changes two things:

  • The compiler generates metadata and linking information so the public API is accessible from other Swift files.
  • The compiler won’t look for top-level code to execute. Otherwise such code would be used as an implicit entry point to start execution. This is why you can compile and run files with just a print("Hello") on them.

swiftc -emit-module

-emit-module generates metadata files that let this library be linked with code elsewhere. These files are the Swift equivalent of C headers.

File Format Description
Hello.abi.json JSON A JSON file describing the Application Binary Interface (ABI) of the module. This includes the information for linker and loader, such as function signatures.
Hello.swiftdoc Binary Documentation comments extracted from the source. Visible from Xcode.
Hello.swiftmodule Binary Serialized AST (Abstract Syntax Tree) that allows other files to import and use this module without access to the source. This is specific of a Swift compiler version. Defined at ModuleFormat.h.
Hello.swiftsourceinfo Binary Maps the binary representations of .swiftmodule back to the original source locations. Used for debugging.

While Swift project may still use headers and .modulemap files to interact with C based languages, you won’t find any of that in Swift-only projects. Instead, Swift has its own module system built into the language. In Swift every unit of code (such as an executable, library, or framework) automatically becomes a module. Even when executables are not imported by other units of code, its module information is used for several features like reflection, refactoring, implicit imports, namespace management and others.

This poses a question: if a library needs these metadata to be linked, how do you distribute it?. We are used to see C libraries along with their headers. For instance, if you brew install libxml2 you end up with these many headers: ls /opt/homebrew/opt/libxml2/include/libxml2/libxml. You could do the same with Swift modules but there is a better way, pack it in frameworks. We’ll see how in following articles.

swiftc -enable-library-evolution

The flag -enable-library-evolution enables Module Stability. Meaning, the types and declarations marked with public or open can be linked against binaries compiled by future compiler versions. Otherwise clients would have to use your exact compiler version, or you would have to release a library version for each compiler version. Both options very impractical.

-emit-module-interface-path generates the .swiftinterface files that enable consumers to link against this module API.

File Format Description
Hello.swiftinterface Text Public declarations. Used by client code.
Hello.private.swiftinterface Text Public and internal declarations. Used within the same codebase of this or closely related modules.

They are similar in function to .swiftmodule files but compatible with future Swift versions.

  • Module Interface (.swiftinterface)
    • They are compiler version-agnostic.
    • They are textual files.
    • They describe the public API.
  • Compiled Module (.swiftmodule)
    • They are compiler specific.
    • They are binary files.
    • They describe the public and internal API.

The library would work with just the Module Interface file, but the Compiled Module version is nice to have because it offers a quick path to compilation for compatible compiler versions. There is another comparison in the post Plan for module stability from 2018.

In total, there are four ways to describe a module that is going to be imported: two are related to C-based languages (.modulemap and its compiled version .pcm), and the other two (.swiftmodule, .swiftinterface) are used in Swift-only projects.

swiftc -emit-object

Compiling source code produces files of object code (.o). This is machine code plus metadata, structured in Mach-O format. It has these main parts:

  • Compiled machine code
  • Symbols (function names, variable names)
  • Relocation information
  • Debugging information (if compiled with debug flags)

This is later transformed by the linker into a library or executable, where object files are merged and references between their symbols resolved. That is: source → object code → linking → binary.

Archiving

A static library is an archive of uncompressed object files in ar format, typically with the .a file extension. You can display the list of files in any archive using ar -t libFoo.a.

# Archive Hello.o as libHello.a
ar -r libHello.a Hello.o

# Or use this modern replacement for ar
# libtool -static -o libHello.a Hello.o

Static libraries share a common internal structure across macOS and Linux, consisting of:

  • Object Files: Compiled machine code and data.
  • Symbol Table: A directory of all symbols (functions, variables) within the object files.
  • Archive Header: Metadata about the library.
  • File Index: Facilitates quick access to specific object files.
  • String Table: Accommodates long filenames.
  • Ranlib Structure: An index used to speed up symbol lookup in UNIX-like systems.
  • (Optional) Fat Binary Content: Supports multiple architectures within a single library.
  • (Optional) Additional Metadata: Information such as version details and creation dates.

While the structure is largely defined by the ar format, certain aspects like the ranlib index and fat binaries have specific implementations or optimizations on macOS.

Inspecting the library

There are several tools we can use to inspect the binary.

ar to maitain libraries of object code. The SYMDEF SORTED below indicates the symbol table is indexed.

# Show files in the archive
% ar -t libHello.a
__.SYMDEF SORTED
Hello.o

dwarfdump to dump DWARF debug information. DWARF is a standardized debugging data format.

% dwarfdump -a libHello.a
libHello.a(Hello.o):	file format Mach-O arm64

otool (object tool) is the “preferred tool for inspecting Mach-O binaries”. The command below dissasembles the TEXT section where the machine code is, and demangles the names of the methods. ‘Mangling’ is a way to encode type information along with function names, at the expense of human readability.

otool -tv libHello.a | swift demangle

nm to display symbol tables.

nm libHello.a | swift demangle

# was the function hello exported?
% nm Hello.o | grep hello | swift demangle
000000000000005c T Hello.Greeter.hello() -> ()
0000000000000280 T dispatch thunk of Hello.Greeter.hello() -> ()
0000000000000474 S method descriptor for Hello.Greeter.hello() -> ()

file identifies the file type, lipo operates on universal files, objdump dumps object files, and more.

% file UseHello libHello.a  
UseHello:   Mach-O 64-bit executable arm64
libHello.a: current ar archive random library

% lipo -info libHello.a
Non-fat file: libHello.a is architecture: arm64

There are several other tools for analyzing Mach-O binaries, including objdump, vtool. For a deeper look, Hopper is the friendliest of five major decompilers for macOS. The trial version let’s you open binaries and generates commented assembler.

Compiling the client

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

swiftc -parse-as-library \
    -emit-executable \
    -o UseHello \
    -L. -I. -lHello \
    UseHello.swift

And here it is in all its hello world glory.

% ./UseHello
Hello World!

I used these compiler options to find the library:

Option Description
-L. Search for libraries in the current folder too.
-lHello Link the executable with the libHello.a library. This expects the library name without lib prefix or .a suffix.
-I. Search for modules in the current folder too. This looks for the module files (.swiftmodule, etc.)

Note that I passed -parse-as-library because I’m indicating the entry point with @main. Without it, the compiler defaults to look for top-level code to execute. For instance, I could omit it and write:

cat << EOF > UseHello.swift
import Hello

let greeter = Greeter()
greeter.hello()
EOF

Linking static libraries

Static libraries are linked at build time by copying the code of the library to the final executable. This implies that the library is no longer needed for the executable to run.

The linking process includes optimizations aimed at improving performance and efficiency, at the cost of a longer build process:

  • Dead Code Stripping: This optimization identifies and removes unused symbols, reducing the library’s size. A smaller size can speed up launch times, although it may increase build time due to the additional processing required.
  • Initialization Code Grouping: Initialization code is grouped to minimize page faults during loading, enhancing runtime performance.

There are several flags that influence dead code stripping. During development for Apple devices you probably saw these three I list below. They are only used with Objective-C projects.

  • -force_load $(SRCROOT)/path/binary: Forces the loading of specific library objects, regardless of whether they are directly referenced. This is essential for ensuring that static initializers are run, plugin code is loaded, or Objective-C categories are dynamically accessed by name.
  • -all_load: Ensures all objects from all static libraries are loaded. This avoids errors due to missing symbols and Objective-C methods dynamically invoked by string names.
  • -ObjC Loads all Objective-C classes and categories from static libraries, supporting runtime features like method swizzling.

Using libHello.a from SPM

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

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

# Remove everything except the xcframework
# (I’m discarding the message 'rm: Hello.xcframework: is a directory')
rm * 2>/dev/null

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

# Overwrite 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

# Overwrite main.swift to call the library
rm Sources/main.swift
mkdir -p Sources/UseHello
cat << EOF > Sources/UseHello/main.swift
import Hello
@main
public struct UseHello {
    public static func main() {
        let greeter = Greeter()
        greeter.hello()
    }
}
EOF

And now we are ready for the technological miracle.

% swift run
Building for debugging...
[9/9] Applying UseHello
Build of product 'UseHello' complete! (0.62s)
Hello World!

A few comments:

  • I wrapped the libHello.a in a xcframework because SPM only allows binary dependencies to .xcframework, .zip (containing an XCFramework).
  • I used Xcode 16 and macOS 15. If you are in Xcode 15 set lower versions, like swift-tools-version: 5.9 and macOS(.v14).
  • You don’t need to sign the framework but in some cases Xcode complains and shows a warning.

Conclusion

We hopefully gained a better understanding of the elements of the build process and Swift no-headers module system. Next page talks a bit about Mach-O, the format of executable files in macOS.

References