In Hello Static Library I created a binary with the architecture of the machine where it was compiled, in my case ARM. But if we were to distribute it to customers with Intel chips we would need a universal binary, which is an executable that packs executable code for multiple architectures. These are also called fat binaries, and each architecture is called a slice.

The complete script is very similar, except it compiles twice and joins the result.

Static library 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 object files and generate module files for each architecture
swiftc -parse-as-library \
    -emit-object -o arm64/Hello.o \
    -emit-module -module-name Hello -emit-module-path arm64/Hello.swiftmodule \
    -enable-library-evolution -emit-module-interface-path Hello.swiftinterface \
    -target arm64-apple-macosx10.9.0 \
    Greeter.swift

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

# Create a universal (fat) static library from the object files
lipo -create arm64/Hello.o x86_64/Hello.o -output Hello.o
libtool -static -o libHello.a Hello.o
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

# Compile UseHello.swift into executables for each architecture
swiftc -parse-as-library \
    -o UseHello-arm64 \
    -target arm64-apple-macosx10.9.0 \
    -L. -lHello -I arm64 \
    UseHello.swift

swiftc -parse-as-library \
    -o UseHello-x86_64 \
    -target x86_64-apple-macosx10.9.0 \
    -L. -lHello -I x86_64 \
    UseHello.swift

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

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

# Run the executable
./UseHello


# CREATE EXECUTABLE PACKAGE

# Encapsulate the 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 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
@main
public struct UseHello {
    public static func main() {
        let greeter = Greeter()
        greeter.hello()
    }
}
EOF

swift run --arch x86_64

Creating a fat binary

We see that the compiler is now targeting specific architectures and joining the result.

# Same as before but compile twice to different folders
mkdir -p arm64 x86_64
swiftc -emit-module-path arm64 \
    -target arm64-apple-macosx10.9.0 \
    -o arm64/Hello.o \
    ...
swiftc -emit-module-path x86_64 
    -target x86_64-apple-macosx10.9.0 \
    -o x86_64/Hello.o \
    ...

# join the products compiled into one object file
lipo -create arm64/Hello.o x86_64/Hello.o -output Hello.o

Same trick for the executable, compile twice, join the result. The resulting executable contains two architectures.

 % file UseHello
UseHello: Mach-O universal binary with 2 architectures: [x86_64:Mach-O 64-bit executable x86_64] [arm64:Mach-O 64-bit executable arm64]
UseHello (for architecture x86_64):	Mach-O 64-bit executable x86_64
UseHello (for architecture arm64):	Mach-O 64-bit executable arm64

% lipo -archs UseHello
x86_64 arm64

% dyld_info -platform UseHello
UseHello [x86_64]:
    -platform:
        platform     minOS      sdk
           macOS     10.9      15.0   
UseHello [arm64]:
    -platform:
        platform     minOS      sdk
           macOS     11.0      15.0

macOS chooses the most suitable version for the current CPU, but I can also run each separatedly:

% arch -arch arm64 ./UseHello
Hello World!

% arch -arch x86_64 ./UseHello
Hello World!

Or even extract one:

% lipo -thin x86_64 ./UseHello -output UseHellox86_64
% ./UseHellox86_64 
Hello World!

But how come my Apple Silicon CPU runs x86? This is due to Rosetta 2. Rosetta 2 is Apple’s translation layer that allows x86_64 (Intel) binaries to run on ARM-based Macs. It translates the x86_64 instructions to ARM64 instructions on-the-fly, allowing me to run Intel-compiled software on my Apple Silicon Mac.

Simulator slice

To generate a simulator slice pass the appropriate target and specify a different SDK.

swiftc -parse-as-library \
    -emit-object -o iossimulator/Hello.o \
    -emit-module -module-name Hello -emit-module-path iossimulator \
    -enable-library-evolution \
    -emit-module-interface-path Hello.swiftinterface \
    -sdk $(xcrun --show-sdk-path --sdk iphonesimulator) \
    -target x86_64-apple-ios18.0-simulator \
    Hello.swift
...

The simulator slice is x86_64 so adding it to the static library would overwrite the macOS slide. For this I’ll need to create a XCFramework, which I do in Hello XCFramework.

macOS ARM64e slice

ARM64e is an architecture extension for Apple’s ARM64. It is available in several phones and latest M chips and provides enhanced security features. In the script before I replaced arm64 with arm64e and tried to run it:

% ./UseHello
zsh: killed     ./UseHello

Not very explanatory. Console.app shows additional messages:

kernel	AMFI: 'UseHello' has no CMS blob?
kernel	AMFI: 'UseHello': Unrecoverable CT signature issue, bailing out.
exec_mach_imgact: not running binary built against preview arm64e ABI

To get rid of the first messages sign the executable. This, by the way, adds a load command LC_CODE_SIGNATURE.

security find-identity -v -p codesigning | grep Development
codesign -s "YOUR_ID_HERE" UseHello

However, the third would require to disable SIP and boot into ARM64e preview mode using sudo nvram boot-args=-arm64e_preview_abi. Turns out this technology is still in development. Apple processes are compiled with ARM64e and can easily be replaced with new versions of the operative system, but Apple discourages its use by developers since OS updates can break compatibility. Therefore, the XNU kernel (where that exec_mach_imgact function belongs) is refusing to run the binary.

Using libHello.a from SPM

We wrap the library in XCFramework.

xcodebuild -create-xcframework \
    -library libHello.a \
    -output Hello.xcframework

Then you can run with

swift build --arch x86_64
.build/debug/UseHello

Actually, I tried --arch x86_64 --arch arm64, but something not so cool happened…

A mystery

Unexpectedly, compilation fails for arm64.

% swift build --arch arm64 
error: module 'Hello' was created for incompatible target x86_64-apple-macosx10.9.0: /Users/jano/Desktop/Hello/.build/arm64-apple-macosx/debug/ModuleCache/Hello-1UJ689923AKQI.swiftmodule
 1 | import Hello
   |        `- error: module 'Hello' was created for incompatible target x86_64-apple-macosx10.9.0: /Users/jano/Desktop/Hello/.build/arm64-apple-macosx/debug/ModuleCache/Hello-1UJ689923AKQI.swiftmodule

The reason is that SPM targets x86_64 despite the flag.

% grep -A 1 'target' .build/debug/description.json 
        "-target",
        "x86_64-apple-macosx15.0",

(?) I don’t know what to make of this. Sounds like a question for the packagemanager forum.

Conclusion

Generating universal binaries is a basic ability when distributing software for systems that run on multiple architectures. We’ll be using this in the next article to build a fat static framework. Spoiler alert --arch x86_64 --arch arm64 works fine this time.