The iPhone doesn't speak your Mac's language
You've built a high-performance image filter in Rust. It runs instantly on your laptop. Now you want to embed it in a Swift app and ship it to the App Store. You compile the Rust code, drag the resulting file into Xcode, and hit run. The simulator crashes. Or worse, the app builds, runs on the simulator, and then fails to install on a real device because the binary architecture is invalid.
Rust defaults to building for the machine you're working on. Your Mac might be ARM64 or x86_64. iOS devices are ARM64. The iOS simulator mimics the device but runs on your Mac's CPU, and Apple enforces strict rules about which binaries can run where. You cannot just copy a binary from your development environment to a phone. You must cross-compile: instruct Rust to emit code for a target architecture and operating system different from your host.
Cross-compilation is a factory reconfiguration
Think of Rust as a factory that assembles widgets. By default, the factory builds widgets for the local market. The assembly line is calibrated for local plug standards, safety regulations, and shipping containers. If you want to export to a country with different standards, you can't just ship the local widgets. You need to reconfigure the assembly line to produce widgets that fit foreign outlets and pass foreign inspections.
In Rust terms, the "local market" is your development machine. The "foreign country" is iOS. The reconfiguration involves two steps. First, you install the target toolchain, which gives the compiler the knowledge to emit instructions for the foreign CPU and ABI. Second, you point the build process at the iOS SDK, which provides the headers and system libraries required by the foreign operating system. Without the SDK, the compiler knows how to speak ARM, but it doesn't know about iOS system calls or frameworks.
Install the target toolchain
Rust uses rustup to manage toolchains and targets. A target is a triple that describes the architecture, vendor, and OS. For modern iOS devices, the target is aarch64-apple-ios. This tells the compiler to generate ARM64 code for Apple's iOS ABI.
Run this command to download the target backend:
# Add the ARM64 iOS target to your Rust toolchain
rustup target add aarch64-apple-ios
This command fetches the compiler backend and the standard library precompiled for iOS. It does not install Xcode or the iOS SDK. Those must already be present on your system. If you're on macOS, Xcode Command Line Tools usually provide the SDK. If you're on Linux or Windows, you need to obtain the iOS SDK separately, which is significantly more complex and often restricted by Apple's licensing.
Configure the build for mobile
iOS apps have different constraints than desktop apps. Battery life matters. Storage is limited. The App Store review process scrutinizes binaries. Your Cargo.toml should reflect these constraints.
Create a release profile that optimizes for size and enables link-time optimization. Link-time optimization allows the linker to inline Rust functions into Swift code, reducing overhead at the boundary.
# Cargo.toml
[lib]
# Produce a static library for embedding in Xcode
crate-type = ["staticlib"]
[profile.release]
# Optimize for binary size, crucial for mobile apps
opt-level = 's'
# Enable link-time optimization to shrink the final binary
lto = true
# Strip symbols to reduce file size further
strip = true
The crate-type = ["staticlib"] setting is essential. iOS apps bundle static libraries (.a files) rather than dynamic libraries in most cases. This setting tells Cargo to output a static archive that Xcode can link directly.
Convention aside: The community treats extern "C" as the universal handshake for mobile interop. Swift and Objective-C can call C functions directly. Rust name mangling breaks the link. Always mark exported functions with #[no_mangle] and extern "C". This preserves the symbol name so Swift can find it.
// src/lib.rs
/// Returns a status code for the iOS app to verify the library loaded correctly.
#[no_mangle]
pub extern "C" fn rust_check_status() -> i32 {
0
}
Build the library
With the target installed and the profile configured, build the library:
cargo build --release --target aarch64-apple-ios
Cargo invokes rustc with the target flag. The compiler checks the target specification and looks for the iOS SDK. On macOS, Cargo uses xcrun to locate the SDK automatically. If the SDK path is missing or incorrect, the build fails with a linker error.
The output lands in target/aarch64-apple-ios/release/libyour_crate.a. This file contains ARM64 machine code ready for an iPhone or iPad.
The simulator trap
The iOS simulator is not an iPhone. It runs on your Mac's CPU. If you have an Apple Silicon Mac (M1, M2, M3), the simulator runs ARM64 code. If you have an Intel Mac, the simulator runs x86_64 code. Building for aarch64-apple-ios produces a binary for physical devices. It will not run on the simulator, even on an M1 Mac, because the simulator uses a different target triple.
For Apple Silicon Macs, use the simulator-specific target:
rustup target add aarch64-apple-ios-sim
cargo build --release --target aarch64-apple-ios-sim
This produces a binary that runs on the simulator but fails on a real device. You now have two binaries: one for the device, one for the simulator. Managing both is tedious.
Universal binaries simplify distribution
Most iOS projects need a single library that works on both the simulator and the device. Apple's tool lipo can merge multiple binaries into a "universal" or "fat" binary containing code for multiple architectures. The cargo-universal crate automates this workflow.
Install the tool:
cargo install cargo-universal
Build a universal binary that includes both device and simulator architectures:
# Build for device and simulator, merge into one .a file
cargo universal --target aarch64-apple-ios --target aarch64-apple-ios-sim
The output is a single .a file in target/universal/release/. This file contains ARM64 code for devices and ARM64 code for the simulator. When you link this file in Xcode, the linker extracts the correct slice for the current build configuration.
Convention aside: cargo-universal is the standard tool for iOS and macOS universal builds. The community prefers it over manual lipo scripts because it handles target dependencies and rebuilds correctly.
Handling C dependencies
Many Rust crates use the cc crate to compile C code. When cross-compiling, cc must invoke the iOS compiler, not the host compiler. By default, cc detects the system compiler. On macOS, that might be the host Clang, which targets your Mac, not iOS.
You must configure cc to use the iOS toolchain. Set the CC_<target> environment variable to the path of the iOS clang. You can find this path using xcrun:
# Set the C compiler for the iOS target
export CC_aarch64_apple_ios=$(xcrun --find clang)
cargo build --release --target aarch64-apple-ios
This tells cc to use the Apple Clang toolchain when building for aarch64-apple-ios. The toolchain automatically picks up the iOS SDK headers and libraries. If you skip this step, cc might compile C code for the host, leading to linker errors or runtime crashes.
Convention aside: The CC_<target> environment variable pattern is the community standard for cross-compiling C dependencies. Tools like cargo-ndk for Android follow the same pattern. Always check your crate's dependencies for C code and configure the compiler accordingly.
Linking in Xcode
With the .a file ready, integrate it into your Xcode project. Add the static library to your project's "Link Binary With Libraries" build phase. Ensure the library is included in the target membership.
If your Rust code exposes headers, add the header search path in Xcode. Set "Header Search Paths" to the directory containing your Rust headers. Mark the path as recursive if needed.
If you use cargo-universal, link the universal .a file. Xcode handles the architecture selection automatically. If you build separate binaries, you must configure Xcode to link the simulator binary for simulator builds and the device binary for device builds. This requires build script logic or separate schemes, which is error-prone. Universal binaries avoid this complexity.
Pitfalls and compiler errors
Cross-compiling for iOS introduces specific failure modes. Recognizing these errors saves hours of debugging.
Missing target error. If you forget to add the target, Cargo rejects the build with E0463 (can't find crate for std). The compiler cannot find the standard library for the requested target. Run rustup target add aarch64-apple-ios to fix this.
Linker not found. If the SDK path is incorrect, the linker fails with ld: library not found for -lSystem. This happens when rustc cannot locate the iOS system libraries. Verify that Xcode Command Line Tools are installed and that xcrun --show-sdk-path --sdk iphoneos returns a valid path.
Architecture mismatch. If you link a device binary on the simulator, the app crashes with Bad CPU type in executable. The simulator cannot run ARM64 device code on an Intel Mac, and even on Apple Silicon, the simulator target differs. Use lipo -info your_lib.a to inspect the architectures in a binary. The output should list arm64 for device binaries and arm64 or x86_64 for simulator binaries. Universal binaries list both.
Name mangling issues. If Swift cannot find your Rust function, check the symbol name. Rust mangles names by default. Use nm your_lib.a to list symbols. If you see mangled names like _ZN14your_crate12rust_hello17h...E, you forgot #[no_mangle]. Add the attribute and rebuild.
Verify the architecture with lipo -info before you link. A mismatched binary will fail silently until runtime.
Decision matrix
Choose the right target and tooling based on your development environment and distribution needs.
Use aarch64-apple-ios when you are building for physical iOS devices. This target produces ARM64 binaries that run on iPhones and iPads with A-series or Apple Silicon chips.
Use aarch64-apple-ios-sim when you are developing on an Apple Silicon Mac and need to test on the iOS simulator. The simulator on M1/M2/M3 Macs runs ARM code, so this target matches the simulator environment.
Use x86_64-apple-ios when you are on an Intel Mac and targeting the simulator. Older Macs require x86 binaries for the simulator.
Use cargo-universal when you need a single library that works on both simulator and device. This creates a fat binary containing multiple architectures, which simplifies Xcode integration and reduces build configuration errors.
Reach for xcode-rust or cargo-xcode when you want Rust compilation integrated directly into the Xcode build graph. These tools generate Xcode project files so you can build and debug Rust code alongside Swift without manual steps.