When Rust meets iOS
You write a blazing-fast image processor in Rust. You want to ship it inside an iOS app. You compile the crate, drag the library into Xcode, and hit build. Xcode throws a linker error about missing architectures. Or worse, the build succeeds, you run it on the simulator, and it crashes instantly when you tap a button because the binary was built for the wrong CPU. Rust does not produce iOS binaries by default. You have to translate the output into a format Apple's toolchain understands.
The bridge between worlds
Rust compiles code for a specific target. iOS runs on real devices with ARM chips and simulators on Intel or Apple Silicon Macs. A binary built for one architecture will not run on the other. You need a universal library that contains code for both. Think of a universal library like a multi-language dictionary. One book, but it has pages for every language you might need. cargo-lipo builds that dictionary. It compiles Rust for every architecture Apple cares about and stitches them into a single .a file.
Swift does not talk Rust. It talks C. You write a thin C layer that Swift can call, and that layer calls your Rust code. This is called FFI, or Foreign Function Interface. The Rust side exports functions with C-compatible signatures. The Swift side declares those functions and calls them. The bridge is small, but it must be exact.
Minimal setup
Create a Rust library crate. Add a function that Swift will call. The function needs two attributes. #[no_mangle] stops Rust from renaming the function. extern "C" tells the compiler to use C calling conventions.
// src/lib.rs
/// Add two integers and return the sum.
/// This function is exposed to C and Swift via FFI.
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
// extern "C" uses the C calling convention.
// #[no_mangle] prevents Rust from renaming the function.
a + b
}
Install cargo-lipo to handle the build. The community standard for iOS builds is cargo-lipo. You could manually run cargo build --target for each architecture and use lipo to merge them, but that is error-prone and tedious. Install the tool and let it handle the plumbing.
cargo install cargo-lipo
cargo lipo --release
This command builds the library for aarch64-apple-ios, armv7-apple-ios, x86_64-apple-ios, and aarch64-apple-ios-sim. It outputs libmycrate.a in target/lipo/release. In Xcode, add the .a file to your target. Add a "Run Script" build phase to automate the build.
#!/bin/sh
# Navigate to the Rust crate directory.
cd "${PROJECT_DIR}/rust-crate"
# Build the universal library.
cargo lipo --release
In Swift, declare the function. Use @_silgen_name to map the Swift name to the C symbol.
// Declare the external function.
// @_silgen_name maps the Swift name to the C symbol.
@_silgen_name("rust_add")
func rust_add(a: Int32, b: Int32) -> Int32
let sum = rust_add(a: 10, b: 20)
print("Sum is \(sum)")
Use @_silgen_name for simple FFI declarations. It keeps the Swift code clean without requiring a bridging header. Bridging headers work, but they add friction when you rename symbols or restructure the project.
Passing data across the bridge
Simple integers work fine. Structs require care. Rust structs have a memory layout that the compiler can optimize. Swift expects a C-compatible layout. You must use #[repr(C)] to lock the layout.
// src/lib.rs
/// A point in 2D space.
/// repr(C) ensures the memory layout matches C expectations.
#[repr(C)]
pub struct Point {
pub x: f64,
pub y: f64,
}
/// Calculate distance from origin.
#[no_mangle]
pub extern "C" fn point_distance(p: Point) -> f64 {
// Access fields directly.
// The struct is passed by value, so it is copied.
(p.x * p.x + p.y * p.y).sqrt()
}
Swift declares the struct with matching fields.
struct Point {
var x: Double
var y: Double
}
@_silgen_name("point_distance")
func point_distance(_ p: Point) -> Double
Treat #[repr(C)] as a contract. If you change the struct, verify the layout. The compiler will not warn you if the layouts diverge. You get garbage values at runtime.
Pitfalls and compiler errors
If you try to return a String, the compiler rejects you with E0277 (trait bound not satisfied). String contains a pointer and length that Swift does not understand. You must use *const c_char or serialize to JSON. Rust strings are not FFI-safe.
If you forget #[no_mangle], the linker fails with a symbol not found error. The function name gets mangled into something like _ZN4mylib7rust_add17h1234567890abcdefE. Swift looks for rust_add. The linker cannot find it.
If you use std::fs or threading primitives that conflict with iOS Grand Central Dispatch, you may hit deadlocks or permission errors. iOS restricts file system access and thread creation. Stick to pure computation in Rust. Offload I/O to Swift.
If you pass a reference across the FFI boundary, you invite undefined behavior. Swift does not understand Rust lifetimes. The Rust value might be dropped while Swift still holds a pointer. Pass owned data or serialize to bytes.
Don't pass Rust types across the bridge. Serialize first, or use C primitives only.
Decision matrix
Use cargo-lipo when you need a universal static library for iOS device and simulator builds. It handles the multi-architecture stitching automatically.
Use bindgen when you have a complex C header file and want to generate Rust bindings automatically, though for simple FFI, manual extern "C" is often clearer.
Use serde to serialize data when you need to pass complex structures like vectors, maps, or enums across the FFI boundary. Serialization avoids layout mismatches and lifetime issues.
Use #[repr(C)] on every struct that crosses the FFI boundary. Without it, the compiler may reorder fields, causing silent data corruption.
Use @_silgen_name in Swift when you want a clean declaration without a bridging header. It maps directly to the C symbol name.
Keep the bridge thin. Rust does the heavy lifting; Swift just calls the function.