10. Unsafe Swift & C interoperability¶
What & why¶
Swift is memory-safe by default, but you'll sometimes need to step outside the guardrails: calling C libraries, parsing binary formats, or squeezing out performance. Swift makes this explicit — everything dangerous has "unsafe" in its name — and its C interoperability is among the best of any language. C++ interop is also supported.
The pointer family¶
| Type | Meaning |
|---|---|
UnsafePointer<T> |
read-only pointer to T (like const T*) |
UnsafeMutablePointer<T> |
read/write pointer (T*) |
UnsafeBufferPointer<T> |
pointer + count: a read-only collection of T |
UnsafeMutableBufferPointer<T> |
mutable buffer view |
UnsafeRawPointer / UnsafeMutableRawPointer |
untyped bytes (const void*) |
UnsafeRawBufferPointer |
untyped byte collection |
OpaquePointer |
pointer to an incomplete C type |
Unmanaged<T> |
a class reference outside ARC |
None of these manage memory or guarantee validity. A pointer is just an address; you keep it valid, initialised, aligned, and in bounds.
Scoped access: the safe-ish way¶
Give C a pointer only for the duration of a closure, so it can't dangle:
var numbers = [1, 2, 3, 4]
numbers.withUnsafeMutableBufferPointer { buffer in
for i in buffer.indices { buffer[i] *= 2 } // direct memory access, no bounds-check overhead in -Ounchecked
}
print(numbers) // [2, 4, 6, 8]
let total = numbers.withUnsafeBufferPointer { buffer in
buffer.reduce(0, +)
}
Never let the pointer escape the closure. The array may move or free its storage afterwards.
String to C string:
let name = "Swift"
name.withCString { cString in // UnsafePointer<CChar>, null-terminated
print(strlen(cString)) // 5 (needs `import Foundation` or Glibc/Darwin)
}
Manual allocation¶
let p = UnsafeMutablePointer<Int>.allocate(capacity: 4)
p.initialize(repeating: 0, count: 4)
p[2] = 99
print(p[2])
p.deinitialize(count: 4)
p.deallocate()
Rules: allocate → initialize → use → deinitialize → deallocate. Skipping a step leaks or corrupts memory. For short-lived scratch space prefer:
withUnsafeTemporaryAllocation(of: UInt8.self, capacity: 256) { buffer in
// stack-allocated if small; no heap traffic
}
Raw bytes, binding and alignment¶
let bytes: [UInt8] = [0x78, 0x56, 0x34, 0x12]
let value = bytes.withUnsafeBytes { raw in
raw.loadUnaligned(as: UInt32.self) // reads 4 bytes; unaligned-safe
}
print(String(value, radix: 16)) // 12345678 on little-endian machines
load(as:)requires proper alignment;loadUnaligned(as:)does not.bindMemory(to:)andassumingMemoryBound(to:)tell the compiler which type lives at an address. Two different bound types at the same address (type punning) is undefined behaviour (strict aliasing). UsewithMemoryReboundor load/store through raw pointers.- Mind endianness for binary formats:
UInt32(bigEndian:),.bigEndian,.littleEndian.
Calling C from Swift¶
System libraries are free¶
On macOS/Linux/Windows, import Foundation (or Glibc, Darwin, WinSDK, Musl) gives you the C standard library:
import Foundation
let t = time(nil)
let r = getenv("HOME").map { String(cString: $0) }
print(t, r as Any)
C types map predictably: int → Int32, long → Int, size_t → Int, char* → UnsafeMutablePointer<CChar>, void* → UnsafeMutableRawPointer?, const char* → UnsafePointer<CChar>?, structs → Swift structs, enums → Swift enums/OptionSet, function pointers → @convention(c) closures.
Your own C code in a SwiftPM package¶
Put C sources in a target with an include directory:
MyPackage/
├── Package.swift
├── Sources/
│ ├── CHash/
│ │ ├── hash.c
│ │ └── include/
│ │ └── hash.h
│ └── MyPackage/
│ └── Hashing.swift
hash.h:
hash.c:
#include "hash.h"
uint32_t fnv1a(const uint8_t *data, size_t length) {
uint32_t h = 2166136261u;
for (size_t i = 0; i < length; i++) { h ^= data[i]; h *= 16777619u; }
return h;
}
Package.swift:
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "MyPackage",
targets: [
.target(name: "CHash"), // C target: headers in include/
.target(name: "MyPackage", dependencies: ["CHash"]),
]
)
Use it from Swift:
import CHash
public func checksum(_ text: String) -> UInt32 {
var copy = text
return copy.withUTF8 { buffer in
fnv1a(buffer.baseAddress, buffer.count) // UnsafeBufferPointer<UInt8> -> const uint8_t*, size_t
}
}
The C header becomes a Swift module automatically (via a generated module map). Linking a system library (e.g. zlib, sqlite3) uses a .systemLibrary target with a module.modulemap, plus pkgConfig: to find it.
Callbacks: C function pointers¶
// C: void sort(int *a, size_t n, int (*cmp)(const void*, const void*));
let compare: @convention(c) (UnsafeRawPointer?, UnsafeRawPointer?) -> Int32 = { a, b in
let x = a!.load(as: Int32.self), y = b!.load(as: Int32.self)
return x < y ? -1 : (x > y ? 1 : 0)
}
var values: [Int32] = [5, 1, 4]
qsort(&values, values.count, MemoryLayout<Int32>.stride, compare)
print(values) // [1, 4, 5]
A @convention(c) closure cannot capture context. For callbacks that need state, pass a void* context and use Unmanaged:
final class Handler { func fire(_ x: Int) { print("fired", x) } }
let handler = Handler()
let context = Unmanaged.passUnretained(handler).toOpaque() // UnsafeMutableRawPointer
// pass `context` to C; the C callback gives it back unchanged:
let recovered = Unmanaged<Handler>.fromOpaque(context).takeUnretainedValue()
recovered.fire(1)
// Use passRetained / takeRetainedValue when C must own a +1 reference.
You must guarantee the object outlives every callback that might use the pointer.
Exposing Swift to C¶
@_cdecl gives a function a C symbol name (underscored = unofficial but widely used). Pair it with a hand-written header. For a stable C-facing API, keep the surface to C-compatible types.
C++ interop¶
Swift 5.9+ can import C++ headers directly (and Swift can be called from C++):
Supported: classes (as value or reference types), templates (instantiated), std::string/std::vector with conversions, methods, operators, namespaces. Not everything maps; use SWIFT_SHARED_REFERENCE / SWIFT_UNSAFE_REFERENCE annotations in headers to describe ownership for reference-like C++ types. Enabling Cxx interop on a module affects its clients (they must enable it too) — which is why most libraries wrap C++ behind a Swift-native or C API instead.
Embedded and bare-metal¶
Embedded Swift is a restricted subset for microcontrollers and kernels: no runtime reflection, no existentials, generic specialisation required, tiny binaries. It leans heavily on ~Copyable, Span, and C interop for hardware registers.
Safety checklist when going unsafe¶
- Keep the unsafe surface tiny and wrap it in a safe API with preconditions.
- Scope pointers with
withUnsafe…closures; never store them. - Check bounds yourself:
precondition(index < count). - Initialise before reading; deinitialise before freeing.
- Respect alignment and binding rules; prefer raw loads for type punning.
- Ownership at the boundary: who frees what? Document it (
takeRetainedValuevstakeUnretainedValue). - Test with sanitizers:
swift build -c debug -Xswiftc -sanitize=address(ASan) and-sanitize=thread(TSan), and-sanitize=undefinedwhere supported. - Fuzz parsers of untrusted bytes.
Common mistakes¶
- Escaping a pointer from
withUnsafe…— it dangles once the closure ends. - Forgetting the null terminator or assuming
Stringis NUL-terminated outsidewithCString. - Mismatched
deallocate(freeing memory that came frommallocwith Swift'sdeallocate— use the matchingfree). - Assuming
Intis 32-bit when C saysint; useInt32/CIntexplicitly. - Capturing context in a
@convention(c)closure (not allowed) or forgetting to keep the context object alive. - Aliasing violations: reading
Floatmemory asUInt32through a bound pointer instead ofbitPatternor raw loads. - Retain-count errors with
Unmanaged— leaks or use-after-free.
Exercises¶
- Write a safe wrapper
checksum(_ data: [UInt8]) -> UInt32(like theStringversion above) around the Cfnv1afunction above in a real package, with tests. - Use
withUnsafeBytesto read a big-endianUInt16and a little-endianUInt32from a byte array withloadUnaligned. - Call
qsortwith a Swift array ofDoubles and a@convention(c)comparator. - Wrap a C function that takes a callback +
void*context (write both sides) usingUnmanagedand a Swift closure. - Reproduce a use-after-free in a debug build with a dangling
UnsafePointer, then catch it with-sanitize=address. - Use
Float.bitPatternandFloat(bitPattern:)instead of pointer casts to reinterpret bits.
Recap¶
- Unsafe pointer types are explicit; scope them with
withUnsafe…closures and never let them escape. - Allocate → initialise → use → deinitialise → deallocate, in that order.
- C headers become Swift modules; C targets in SwiftPM make mixed packages easy; C++ interop is an opt-in per module.
@convention(c)closures can't capture; pass context throughUnmanagedpointers.- Wrap unsafe code in small safe APIs, test with sanitizers, and fuzz untrusted input.