Skip to content

← The Expert Edge: Idiom, Review and Capstones step 3 of 14

Hard End-to-End

A macro-generated FFI binding layer

Write a macro_rules! macro that generates a C binding layer, and use it to bind four libc functions.

pub fn run(xs: Vec<i32>, s: String) -> (i64, f64, usize, bool)

Macros exist to eliminate boilerplate. FFI binding layers are among the most boilerplate-heavy code anyone writes in Rust: for every C function you want, the same declaration, the same wrapper, the same unsafe block, the same safety comment, over and over. Building the tool instead of using it is what turns “I have seen macros” and “I have seen FFI” into one capability.

What the macro must generate

The harness invokes your macro itself, so its syntax is fixed:

bind_c! {
    safe sqrt(x: c_double) -> c_double => c_sqrt;
    raw  abs(x: c_int) -> c_int => c_abs;
    raw  strlen(s: *const c_char) -> usize => c_strlen;
    raw  memcmp(a: *const c_void, b: *const c_void, n: usize) -> c_int => c_memcmp;
}

Each entry produces two items:

  • an unsafe extern "C" declaration of $name` with exactly that signature, and - a wrapper called `$wrap.

safe produces an ordinary pub fn whose body is the unsafe call. raw produces a pub unsafe fn carrying a # Safety section in its docs. The keyword is you making a claim, and you should only write safe when the C function is genuinely total over its Rust parameter types.

::: question abs takes an int and returns an int. Why is it bound raw rather than safe? Because C’s abs is undefined behaviour at INT_MIN. -INT_MIN is not representable in an int, and the C standard does not say the result wraps — it says the behaviour is undefined. So abs has a precondition, therefore its thin wrapper is unsafe, therefore something above it has to establish that precondition before calling.

That is the whole discipline in one example. “Takes scalars, returns scalars” is not the test. “Is there any input for which this is not defined?” is.

sqrt passes that test: every f64 has defined behaviour, including negative values (NaN) and infinities. It is bound safe. :::

What run computes

  • sum — Σ|x| over xs, as i64. Use the C abs for every value it is defined for, and handle i32::MIN yourself.
  • rootsqrt(sum as f64) through the C sqrt.
  • len — the length of s as C sees it: bytes up to the first nul. C strings are nul-terminated, so you must build a nul-terminated buffer before you can hand a pointer to strlen. If s contains an interior nul, C stops there and Rust does not — a difference worth internalising, because it is the root of a whole family of real security bugs.
  • same — is s equal to its own byte-reversal, decided by C memcmp? Compare lengths in Rust first; memcmp takes a single length and reading past the end of either buffer is UB. Zero-length is a special case worth thinking about.

The starter fails the lint gate, and the lint is the lesson

The starter compiles and passes every test. It still fails, on two clippy errors that are both about the same mistake.

clippy::macro_metavars_in_unsafe. The starter ships this “convenience”:

#[macro_export]
macro_rules! ffi {
    ($call:expr) => { unsafe { $call } };
}

Clippy’s note says exactly what is wrong: this allows the user of the macro to write unsafe code outside of an unsafe block. ffi!(anything_at_all) runs arbitrary caller-supplied code in an unsafe context, and the caller never types the word unsafe. Every unsafe block is supposed to be a place a reviewer stops and reads; this macro makes them invisible and unsearchable.

clippy::missing_safety_doc. A pub unsafe fn with no # Safety section is an obligation you imposed on your callers without saying what it is.

A proper bind_c! fixes both at once, and it is worth understanding why clippy accepts it. The lint’s own second suggestion is: or also expand referenced metavariables in a safe context to require an unsafe block at callsite. In bind_c!, $name` and `$arg appear in the extern declaration and in the wrapper’s signature — safe positions — as well as inside the unsafe block. The caller of bind_c! can only supply declarations, never an arbitrary expression, so there is no way to smuggle code past a reviewer. That is a real structural difference, not a lint-appeasement trick.

#[allow(clippy::macro_metavars_in_unsafe)] is the wrong answer here and would be the wrong answer in review. Restructure.

Macro details that will bite

Item position. These bindings are items, not expressions. A macro used in item position must expand to items, so $e:expr` fragments are no use — you want `ident`, `ty` and raw token trees. The natural way to handle a list of entries with two different leading keywords is a **TT muncher**: match one entry plus `$($rest:tt)*`, emit the items, then recurse with `bind_c!($($rest)*);`. Give it an empty `() => {};` base case or it will never terminate. **Generating docs.** `#[doc = "..."]` is what `///` desugars to, and `#[doc = concat!("Wraps C `", stringify!($name), ".”)]lets you build a doc line from a metavariable.///lines work too and can be mixed in. You need a real# Safetyheading in the generated docs for therawarm. **FFI-safe types only.** rustc'simproper_ctypeslint checks the *generated* declarations, so a macro that happily accepts&strorStringproduces code that fails the gate.c_int,c_double,c_char,c_void, raw pointers andusizeare fine; nothing with a Rust-defined layout is. **crate_in_macro_def.** Not needed here, but worth knowing: writingcrate::inside amacro_rules!body resolves at the *call* site, not the definition site, which is almost never what you meant.$crate::is the fix. ## One honest note on the safe layer Above the generated wrappers you write three small functions in ordinary Rust: thei32::MINguard forabs, the nul-terminated buffer forstrlen, the length check formemcmp. That is the shape every good binding crate has — a thin generatedsys` layer that mirrors the header exactly, and a hand-written safe layer above it that does the thinking. Do not try to make the macro clever enough to generate the safe layer. The whole point of the safe layer is that a human decided what the precondition was and how to establish it, and that reasoning does not come out of a token substitution.