Skip to content

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:) and assumingMemoryBound(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). Use withMemoryRebound or 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:

#include <stdint.h>
#include <stddef.h>
uint32_t fnv1a(const uint8_t *data, size_t length);

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("add_ints")
public func addInts(_ a: Int32, _ b: Int32) -> Int32 { a + b }

@_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++):

// Package.swift
.target(name: "Geometry", swiftSettings: [.interoperabilityMode(.Cxx)])

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

  1. Keep the unsafe surface tiny and wrap it in a safe API with preconditions.
  2. Scope pointers with withUnsafe… closures; never store them.
  3. Check bounds yourself: precondition(index < count).
  4. Initialise before reading; deinitialise before freeing.
  5. Respect alignment and binding rules; prefer raw loads for type punning.
  6. Ownership at the boundary: who frees what? Document it (takeRetainedValue vs takeUnretainedValue).
  7. Test with sanitizers: swift build -c debug -Xswiftc -sanitize=address (ASan) and -sanitize=thread (TSan), and -sanitize=undefined where supported.
  8. Fuzz parsers of untrusted bytes.

Common mistakes

  • Escaping a pointer from withUnsafe… — it dangles once the closure ends.
  • Forgetting the null terminator or assuming String is NUL-terminated outside withCString.
  • Mismatched deallocate (freeing memory that came from malloc with Swift's deallocate — use the matching free).
  • Assuming Int is 32-bit when C says int; use Int32/CInt explicitly.
  • Capturing context in a @convention(c) closure (not allowed) or forgetting to keep the context object alive.
  • Aliasing violations: reading Float memory as UInt32 through a bound pointer instead of bitPattern or raw loads.
  • Retain-count errors with Unmanaged — leaks or use-after-free.

Exercises

  1. Write a safe wrapper checksum(_ data: [UInt8]) -> UInt32 (like the String version above) around the C fnv1a function above in a real package, with tests.
  2. Use withUnsafeBytes to read a big-endian UInt16 and a little-endian UInt32 from a byte array with loadUnaligned.
  3. Call qsort with a Swift array of Doubles and a @convention(c) comparator.
  4. Wrap a C function that takes a callback + void* context (write both sides) using Unmanaged and a Swift closure.
  5. Reproduce a use-after-free in a debug build with a dangling UnsafePointer, then catch it with -sanitize=address.
  6. Use Float.bitPattern and Float(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 through Unmanaged pointers.
  • Wrap unsafe code in small safe APIs, test with sanitizers, and fuzz untrusted input.

Next: Libraries, ABI & distribution →