15. Macros, unsafe and FFI
Full examples: examples/l15_macros_unsafe.rs — cargo run --example l15_macros_unsafe — and l15-ffi/, a Rust library called from a C# console app.
This last lesson is an overview: each topic deserves its own book (linked in Sources). The goal is to recognise these tools in real code and know when they are the right answer — which is rarely.
Macros: code that writes code
Section titled “Macros: code that writes code”You have used macros since lesson 1: println!, vec!, assert_eq!, #[derive(Debug)], #[tokio::main]. They run at compile time and expand into ordinary Rust code, which is then type-checked like everything else.
| Rust | C# | Java |
|---|---|---|
macro_rules! (declarative) |
— | — |
#[derive(...)] (procedural) |
source generators | annotation processors, Lombok |
attribute macros (#[tokio::main]) |
source generators + attributes | annotation processors |
function-like procedural macros (sqlx::query!) |
source generators | — |
Declarative macros
Section titled “Declarative macros”A macro_rules! macro is a match on syntax: each rule has a pattern and an expansion.
macro_rules! square { ($x:expr) => { $x * $x };}
println!("square!(1 + 2) = {}", square!(1 + 2)); // 9$x:expr matches a whole expression and keeps it grouped, so square!(1 + 2) is (1 + 2) * (1 + 2) = 9. A C #define SQUARE(x) x * x would give 1 + 2 * 1 + 2 = 5. Rust macros work on the syntax tree, not on text.
Repetition, $( … ),*, handles lists — this is how vec! is written:
macro_rules! hashmap { ($($key:expr => $value:expr),* $(,)?) => {{ let mut map = HashMap::new(); $( map.insert($key, $value); )* map }};}
let ages = hashmap! { "Ada" => 36, "Grace" => 85,};Rules are tried in order and can recurse; macros can also generate items such as structs:
macro_rules! max_of { ($x:expr) => { $x }; ($x:expr, $($rest:expr),+) => {{ let rest = max_of!($($rest),+); if $x > rest { $x } else { rest } }};}
macro_rules! newtype { ($name:ident, $inner:ty) => { #[derive(Debug, Clone, Copy, PartialEq)] struct $name($inner); };}
newtype!(UserId, u32);newtype!(OrderId, u32);// max_of!(3, 9, 4) = 9// UserId(7) OrderId(7) true| Fragment | Matches |
|---|---|
expr |
an expression: 1 + 2, foo() |
ident |
an identifier: UserId |
ty |
a type: u32, Vec<String> |
pat |
a pattern: Some(x) |
literal |
a literal: 42, "text" |
block |
a block: { … } |
tt |
any single token tree — the escape hatch |
Because macros run in the compiler, their mistakes are compile errors. println! checks its format string against its arguments:
error: 2 positional arguments in format string, but there is 1 argument --> e15_format.rs:3:15 |3 | println!("{} is {} years old", name); | ^^ ^^ ----And a call that matches no rule is rejected at the call site:
error: unexpected end of macro invocation --> e15_macro_args.rs:8:20 |1 | macro_rules! square { | ------------------- when calling this macro...8 | println!("{}", square!()); | ^^^^^^^^^ missing tokens in macro arguments |note: while trying to match meta-variable `$x:expr`Procedural macros
Section titled “Procedural macros”Procedural macros are Rust functions that receive a token stream and return another one. They must live in their own crate with proc-macro = true, and they are usually written with the syn (parse) and quote (generate) crates. You will mostly use them:
- derive:
#[derive(Serialize, Deserialize)]from serde generates JSON (and other formats) support, asSystem.Text.Jsonsource generation or Jackson annotations would; - attribute:
#[tokio::main]rewritesmainto start a runtime,#[test]registers a test; - function-like:
sqlx::query!("SELECT …")checks SQL against a database schema at compile time.
unsafe: the compiler trusts you
Section titled “unsafe: the compiler trusts you”Safe Rust guarantees no dangling pointers, no data races and no out-of-bounds access. Some useful programs cannot be proven safe by the compiler — talking to C, writing a memory allocator, implementing Vec itself. unsafe marks the places where you take responsibility for those guarantees.
An unsafe block unlocks exactly five extra operations:
- dereference a raw pointer (
*const T,*mut T); - call an
unsafefunction (including foreign functions); - read or write a mutable
static; - implement an
unsafetrait (such asSendorSyncby hand); - access the fields of a
union.
Everything else still applies inside unsafe: the borrow checker, type checking, bounds checks on slices. Creating a raw pointer is safe; only using it is not:
let x = 42;let ptr = &x as *const i32; // fine in safe codeprintln!("{}", *ptr);error[E0133]: dereference of raw pointer is unsafe and requires unsafe block --> e15_deref.rs:4:20 |4 | println!("{}", *ptr); | ^^^^ dereference of raw pointer | = note: raw pointers may be null, dangling or unaligned; they can violate aliasing rules and cause data races: all of these are undefined behaviorC# has the same idea — the unsafe keyword, pointers and fixed, enabled with <AllowUnsafeBlocks> — and Java has sun.misc.Unsafe and the Foreign Function & Memory API. The difference is that in Rust, undefined behaviour in unsafe code can break guarantees everywhere else in the program, so the convention is strict.
Safe abstractions over unsafe code
Section titled “Safe abstractions over unsafe code”The standard pattern is a small unsafe core wrapped in a safe function that checks every condition itself. Returning mutable references to the first element and to the rest of a slice looks harmless, but the borrow checker cannot see that the two parts do not overlap:
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } Some((&mut values[0], &mut values[1..]))}error[E0499]: cannot borrow `*values` as mutable more than once at a time --> e15_split.rs:5:32 |1 | fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { | - let's call the lifetime of this reference `'1`...5 | Some((&mut values[0], &mut values[1..])) | ---------------------------^^^^^^------- | | | | | | | second mutable borrow occurs here | | first mutable borrow occurs here | returning this value requires that `values[_]` is borrowed for `'1`With raw pointers, and a SAFETY comment explaining why the invariants hold:
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } let len = values.len(); let ptr = values.as_mut_ptr(); // SAFETY: the slice is not empty, so `ptr` is valid for `len` elements; // element 0 and elements 1..len do not overlap, so the two mutable borrows are disjoint. unsafe { Some(( &mut *ptr, std::slice::from_raw_parts_mut(ptr.add(1), len - 1), )) }}// [10, 20, 30] -> first += 1, rest *= 2 -> [11, 40, 60]Callers only see a safe signature. This is exactly how the standard library implements split_at_mut and split_first_mut — which means you should use those instead (exercise 2).
Edition 2024 made unsafe more explicit. Inside an unsafe fn, unsafe operations now need their own unsafe block — the function’s unsafe restricts its callers, not its body:
warning[E0133]: dereference of raw pointer is unsafe and requires unsafe block --> w15_unsafe_op.rs:4:5 |4 | *ptr | ^^^^ dereference of raw pointer |note: an unsafe function restricts its caller, but its body is safe by default --> w15_unsafe_op.rs:3:1 |3 | pub unsafe fn read(ptr: *const i32) -> i32 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = note: `#[warn(unsafe_op_in_unsafe_fn)]` (part of `#[warn(rust_2024_compatibility)]`) on by defaultEvery public unsafe fn should document its contract in a # Safety section (lesson 14); clippy’s missing_safety_doc lint checks it. Miri, a nightly interpreter, can detect many kinds of undefined behaviour when running tests.
FFI: talking to other languages
Section titled “FFI: talking to other languages”FFI (foreign function interface) goes through the C ABI: the calling convention every language on the platform understands.
Calling C from Rust
Section titled “Calling C from Rust”unsafe extern "C" { fn abs(input: i32) -> i32;}
// SAFETY: `abs` has no preconditions for this input.println!("abs(-3) from C = {}", unsafe { abs(-3) }); // 3The block is unsafe extern because Rust cannot check that the declaration matches the real C function; since edition 2024, forgetting unsafe is an error (extern blocks must be unsafe). Calling the function needs unsafe too:
error[E0133]: call to unsafe function `abs` is unsafe and requires unsafe block --> e15_call_unsafe.rs:6:20 |6 | println!("{}", abs(-3)); | ^^^^^^^ call to unsafe function | = note: consult the function's documentation for information on how to avoid undefined behaviorFor real C libraries, the bindgen tool generates these declarations from the C headers.
Calling Rust from C#
Section titled “Calling Rust from C#”This is the reverse, and the most useful direction for a C# developer: write a performance-critical or shared piece in Rust, and call it from .NET with P/Invoke. The l15-ffi folder contains both sides.
Rust side — a library compiled as a native dynamic library:
[lib]# cdylib: a native .dll / .so / .dylib with a C ABI; rlib: so `cargo test` can link itcrate-type = ["cdylib", "rlib"]use std::ffi::{CStr, CString, c_char};
/// Adds `percent` VAT to an amount in cents.#[unsafe(no_mangle)]pub extern "C" fn pricing_add_vat(cents: u64, percent: u32) -> u64 { cents * (100 + u64::from(percent)) / 100}
/// Sums `len` prices.////// # Safety////// `prices` must point to `len` initialised `f64` values, or be null.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_sum(prices: *const f64, len: usize) -> f64 { if prices.is_null() { return 0.0; } // SAFETY: the caller guarantees `prices` points to `len` values. let prices = unsafe { std::slice::from_raw_parts(prices, len) }; prices.iter().sum()}
/// Formats `name: 42.50` into a string allocated by Rust./// The caller must release it with [`pricing_free_string`].////// # Safety////// `name` must be a valid, NUL-terminated string, or null.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_label(name: *const c_char, cents: u64) -> *mut c_char { if name.is_null() { return std::ptr::null_mut(); } // SAFETY: the caller guarantees a valid NUL-terminated string. let name = unsafe { CStr::from_ptr(name) }.to_string_lossy(); let label = format!("{name}: {}.{:02}", cents / 100, cents % 100); CString::new(label).map_or(std::ptr::null_mut(), CString::into_raw)}
/// Frees a string returned by [`pricing_label`].////// # Safety////// `label` must come from `pricing_label` and must not be used or freed again.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_free_string(label: *mut c_char) { if !label.is_null() { // SAFETY: the pointer was created by CString::into_raw in pricing_label. drop(unsafe { CString::from_raw(label) }); }}extern "C"uses the C calling convention;#[unsafe(no_mangle)]keeps the symbol namepricing_add_vatinstead of a mangled Rust name. In edition 2024 it is an unsafe attribute: two libraries exporting the same name would clash, and the compiler cannot check that.- Only C-compatible types cross the boundary: integers, floats,
bool, raw pointers,#[repr(C)]structs. NoString,VecorResult. - Whoever allocates, frees. A string allocated by Rust must go back to Rust (
pricing_free_string), never toMarshal.FreeHGlobal.
C# side — [LibraryImport], the source-generated successor of [DllImport]:
using System.Runtime.InteropServices;
Console.WriteLine($"1000 cents + 20% VAT = {Native.AddVat(1000, 20)}");
double[] prices = [19.99, 5.0, 12.5];Console.WriteLine($"sum computed in Rust: {Native.Sum(prices, (nuint)prices.Length):F2}");
// Rust allocated this string, so Rust must free itnint label = Native.Label("book", 4250);try{ Console.WriteLine(Marshal.PtrToStringUTF8(label));}finally{ Native.FreeString(label);}
static partial class Native{ // Resolves to pricing_ffi.dll on Windows, libpricing_ffi.so on Linux, libpricing_ffi.dylib on macOS private const string Lib = "pricing_ffi";
[LibraryImport(Lib, EntryPoint = "pricing_add_vat")] internal static partial ulong AddVat(ulong cents, uint percent);
[LibraryImport(Lib, EntryPoint = "pricing_sum")] internal static partial double Sum([In] double[] prices, nuint len);
[LibraryImport(Lib, EntryPoint = "pricing_label", StringMarshalling = StringMarshalling.Utf8)] internal static partial nint Label(string name, ulong cents);
[LibraryImport(Lib, EntryPoint = "pricing_free_string")] internal static partial void FreeString(nint label);}.NET strings are UTF-16; StringMarshalling.Utf8 converts them to the NUL-terminated UTF-8 that CStr expects. The double[] is pinned and passed as a pointer, without copying.
The project file copies the native library next to the executable. Build the Rust library first, then run the C# app:
cd code\rust-for-csharp-java\l15-fficargo build --release # target\release\pricing_ffi.dlldotnet run --project dotnetcd code/rust-for-csharp-java/l15-fficargo build --release # target/release/libpricing_ffi.sodotnet run --project dotnetcd code/rust-for-csharp-java/l15-fficargo build --release # target/release/libpricing_ffi.dylibdotnet run --project dotnet1000 cents + 20% VAT = 1200sum computed in Rust: 37.49book: 42.50The same DllImport name, pricing_ffi, works on every OS: .NET adds the platform’s prefix and extension when it probes for the library.
Hand-writing both sides does not scale. Tools generate them: cbindgen writes a C header from Rust, csbindgen writes C# DllImport declarations, and uniffi generates bindings for Kotlin, Swift and Python. For Java, the Foreign Function & Memory API (java.lang.foreign, final since Java 22) replaces JNI for this kind of call.
Key takeaways
Section titled “Key takeaways”- Macros expand at compile time into checked Rust code;
macro_rules!matches syntax, procedural macros are compiler plugins you mostly consume throughderiveand attributes. unsafeunlocks five operations and nothing else; the borrow checker keeps running.- Wrap small
unsafecores in safe functions that enforce the invariants, and document them withSAFETYcomments and# Safetysections. - FFI goes through the C ABI:
extern "C",#[unsafe(no_mangle)], C-compatible types, and whoever allocates frees. - A Rust
cdylibplus[LibraryImport]is a practical way to use Rust from .NET on Windows, Linux and macOS.
Exercises
Section titled “Exercises”- Write a macro
strings!so thatstrings!["Ada", "Grace", 42]produces aVec<String>(["Ada", "Grace", "42"]), andstrings![]an empty one. Allow a trailing comma.
Solution
macro_rules! strings { ($($s:expr),* $(,)?) => { vec![$($s.to_string()),*] };}
let names: Vec<String> = strings!["Ada", "Grace", 42];assert_eq!(names, ["Ada", "Grace", "42"]);let empty: Vec<String> = strings![];assert!(empty.is_empty());$(,)? accepts an optional trailing comma. Any type implementing Display has to_string(), so the integer works too. Macros can be invoked with (), [] or {}; [] just makes it look like vec!.
- Rewrite
split_first_restwithoutunsafe, in two ways: withsplit_first_mut, and withsplit_at_mut.
Solution
fn split_first_rest(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { values.split_first_mut()}
fn split_first_rest_at(values: &mut [i32]) -> Option<(&mut i32, &mut [i32])> { if values.is_empty() { return None; } let (head, rest) = values.split_at_mut(1); Some((&mut head[0], rest))}
let mut scores = [10, 20, 30];if let Some((first, rest)) = split_first_rest(&mut scores) { *first += 1; rest[0] *= 2;}if let Some((first, rest)) = split_first_rest_at(&mut scores) { *first += 1; rest[1] *= 2;}assert_eq!(scores, [12, 40, 60]);assert!(split_first_rest(&mut []).is_none());The unsafe still exists, inside the standard library, reviewed and tested once for everyone. Most application code never needs its own unsafe block.
- Add
pricing_average(prices, len, average)to the FFI library: it writes the average through an out pointer and returnsfalsewhenlenis 0. Call it from C# with anout doubleparameter.
Solution
/// Writes the average of `len` prices to `*average`./// Returns `false`, leaving `*average` untouched, when `len` is 0.////// # Safety////// `prices` must point to `len` initialised `f64` values and `average`/// must be a valid pointer to writable memory.#[unsafe(no_mangle)]pub unsafe extern "C" fn pricing_average( prices: *const f64, len: usize, average: *mut f64,) -> bool { if prices.is_null() || average.is_null() || len == 0 { return false; } // SAFETY: guaranteed by the caller, see above. let prices = unsafe { std::slice::from_raw_parts(prices, len) }; unsafe { *average = prices.iter().sum::<f64>() / len as f64 }; true}if (Native.Average(prices, (nuint)prices.Length, out double average)){ Console.WriteLine($"average: {average:F2}");}Console.WriteLine($"average of nothing: {Native.Average([], 0, out _)}");
// in class Native:[LibraryImport(Lib, EntryPoint = "pricing_average")][return: MarshalAs(UnmanagedType.U1)]internal static partial bool Average([In] double[] prices, nuint len, out double average);average: 12.50average of nothing: FalseA C# out double is passed as a pointer, which is what *mut f64 receives. Rust’s bool is one byte. [LibraryImport] refuses to guess how a bool is marshalled — without the attribute the build fails with SYSLIB1051: Marshalling bool without explicit marshalling information is not supported — so [return: MarshalAs(UnmanagedType.U1)] says: one byte. (The older [DllImport] silently assumed a 4-byte Win32 BOOL.) Put the new Rust function above the #[cfg(test)] module: clippy’s items_after_test_module lint rejects items placed after it.
Where to go next
Section titled “Where to go next”This was the last lesson of the core course. The follow-up, Rust in practice: IX and co, applies these ideas to real code in the IX workspace.
Sources
Section titled “Sources”- The Book, ch. 20.1 — Unsafe Rust and ch. 20.5 — Macros
- The Little Book of Rust Macros
- The Rustonomicon — the reference for unsafe code
- Edition Guide — Rust 2024 unsafe changes
- The Rust Reference —
externblocks and the C ABI - .NET — P/Invoke source generation (
LibraryImport) - Java — Foreign Function & Memory API