Rust macros for systems code: proc-macros, syn/quote, and what miri proves about unsafe
sysmacros is chapter 43's proc-macro crate, powering two Rust-only techniques in the app binary -- #[derive(SysError)], a miniature thiserror built with syn 2.0's AST and quote's token emission, and #[instrument], a compile-time tracing attribute that times a function with Instant -- plus a sound unsafe unaligned read that cargo +nightly miri validates clean and, under the deliberately-named --oob-read path, catches as undefined behaviour. The book's first single-language deep dive, verified on the Fedora 44 host with verify.lua PASS 8/FAIL 0, the exact six-line stable stdout, and miri's own out-of-bounds diagnostic reproduced byte for byte.
Every chapter so far has mirrored the same behavior across C++23, Go, and
Rust, because the point of those chapters was a Linux mechanism — a
syscall, a signal, a namespace — that all three languages sit on top of
equally. This one breaks that pattern on purpose. Proc-macros are a Rust
compiler feature with no C++ or Go equivalent worth building a codetab
around, and miri, the interpreter that catches undefined behaviour a
normal build would run right past, only exists because Rust’s unsafe
carries obligations the type system stops checking. Chapter 12 gave
pmon a PmonError-shaped hierarchy by hand, written out field by field;
this chapter asks how much of that boilerplate a macro can write for
you, and how you prove the unsafe block sitting a few lines below it is
still doing exactly what its comment claims.
sysmacros is a small cargo workspace: a proc-macro = true crate holding
two macros, and an app binary that uses them. #[derive(SysError)]
turns an errno-style enum into a Display + std::error::Error impl from
one #[error("...")] attribute per variant — a miniature thiserror,
built from scratch with syn 2.0 and quote. #[instrument] wraps a
function to print its entry, its exit, and its wall-clock duration,
without a tracing runtime. And read_record is one unsafe unaligned
read of a repr(C) struct out of a byte buffer — sound on the path that
checks its bounds, and undefined behaviour on the --oob-read path that
skips the check, which is exactly the distinction miri exists to draw.
Tools used —
cargo(host, build/run the pinned stable toolchain —rustc 1.97.1perrust-toolchain.toml, inscripts/check-host.sh),cargo +nightly miri/ themiricomponent (host — a nightly toolchain plusmiriinstalled viarustup, exercised throughout this chapter but not part of the stable CI gate, so it is intentionally not inscripts/check-host.sh),lua(host, drivesverify.lua— inscripts/check-host.sh).
Two macros, one crate: sysmacros
sysmacros/Cargo.toml sets proc-macro = true in its [lib] section,
which is what makes it a fundamentally different kind of dependency:
rustc compiles it as a compiler plugin — a program that runs during
app’s own compilation, not inside app itself — before it ever touches
main.rs. That is why this is a two-crate cargo workspace rather than one
package: a proc-macro can only live in its own crate, compiled first, and
app’s Cargo.toml depends on it the ordinary way (sysmacros = { path =
"sysmacros" }). Nothing in sysmacros/src/lib.rs ever runs when you
execute the finished app binary; every line of it runs once, at build
time, and produces tokens that rustc splices into the program in its
place.
The macro PmonError actually uses is the plainer of the two ideas —
each variant just needs a message:
#[derive(SysError, Debug)]
enum PmonError {
#[error("open {path} failed: {errno}")]
Open { path: String, errno: i32 },
#[error("child {pid} exited with status {code}")]
ChildExit { pid: u32, code: i32 },
#[error("policy rejected: {0}")]
PolicyRejected(String),
}
#[derive(SysError)] reads that #[error("...")] string per variant and
writes the Display arm that formats it, plus a blanket impl
std::error::Error. The named-field variants (Open, ChildExit) format
their own fields by name; the tuple variant (PolicyRejected) formats its
one field positionally as {0}. #[instrument] sits on hash_chunk
below it the same way #[error(...)] sits on an enum variant: an
annotation the macro reads, not code that runs before the function does.
Both macros end at the same place — a syn::Result<TokenStream2> that is
either the new code or a syn::Error describing exactly what was wrong
with the input — which “Errors, three ways” returns to below.
A sound unsafe read, and what miri proves
read_record is the one piece of this chapter that is not macro-generated,
and it earns its own section because “sound” is a specific claim, not a
synonym for “compiles”:
fn read_record(buf: &[u8], oob: bool) -> Record {
let off = if oob { 4 } else { 0 };
// oob: deliberately reads past the 8-byte buffer.
// SOUND path checks the bounds; the --oob-read path skips the check (the
// bug miri catches).
if !oob {
assert!(buf.len() >= off + core::mem::size_of::<Record>());
}
// SAFETY (sound path): buf has >= 8 bytes, Record is repr(C) plain-old-data,
// read_unaligned tolerates any alignment. Under --oob-read this reads 4 bytes
// past the end -> undefined behaviour, which is exactly what miri reports.
unsafe { core::ptr::read_unaligned(buf.as_ptr().add(off) as *const Record) }
}
An unsafe block is a promise to the compiler: “I checked something the
type system can’t, and this operation is valid anyway.” read_unaligned
needs three things to be true — the pointer is readable for
size_of::<Record>() bytes, those bytes form a valid Record (satisfied
trivially here, since Record is repr(C) and made only of integers),
and the read doesn’t race another write. The assert! right above the
unsafe block is what makes the sound path’s promise true: it checks the
buffer really does have 8 bytes before offset 0 is read from. --oob-read
sets off = 4 and, by design, skips that assert! — the read now reaches
for offset 4..12 against an 8-byte buf, and nothing in a normal
cargo build/cargo run will tell you that happened. That silence is the
whole reason miri exists: it is not a linter looking at your source, it
is an interpreter that tracks every allocation’s real bounds and aborts
the instant a read or write steps outside them.
How the code works
Both macros are one function each, taking real syntax in and giving real
syntax back, and both follow the same three-step shape: parse_macro_input!
turns the raw TokenStream into a typed syn AST, an expand_* function
walks that AST and builds a quote! block, and the top-level macro
function unwraps the Result, turning Ok into the new tokens and Err
into a compile_error!. expand_sys_error is the derive side of that
shape:
fn expand_sys_error(input: &DeriveInput) -> syn::Result<TokenStream2> {
let name = &input.ident;
let data_enum = match &input.data {
Data::Enum(data_enum) => data_enum,
_ => {
return Err(syn::Error::new_spanned(
input,
"SysError can only be derived for enums",
))
}
};
let mut arms = Vec::with_capacity(data_enum.variants.len());
for variant in &data_enum.variants {
let vident = &variant.ident;
let error_attr = variant
.attrs
.iter()
.find(|attr| attr.path().is_ident("error"))
.ok_or_else(|| {
syn::Error::new_spanned(
variant,
format!("variant `{vident}` is missing #[error(\"...\")]"),
)
})?;
let message: LitStr = error_attr.parse_args()?;
let arm = match &variant.fields {
Fields::Named(fields) => {
let idents: Vec<_> = fields
.named
.iter()
.map(|f| f.ident.clone().expect("named field always has an ident"))
.collect();
// Edition-2024 inline format-arg capture: `path` and `errno`
// in the literal resolve directly to the bindings destructured
// in this match arm's pattern.
quote! {
Self::#vident { #(#idents),* } => write!(f, #message),
}
}
Fields::Unnamed(fields) => {
let idents: Vec<_> = (0..fields.unnamed.len())
.map(|i| format_ident!("f{}", i))
.collect();
quote! {
Self::#vident(#(#idents),*) => write!(f, #message, #(#idents),*),
}
}
Fields::Unit => {
quote! {
Self::#vident => write!(f, #message),
}
}
};
arms.push(arm);
}
Ok(quote! {
impl ::std::fmt::Display for #name {
fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result {
match self {
#(#arms)*
}
}
}
impl ::std::error::Error for #name {}
})
}
attr.path().is_ident("error") is syn 2.x’s API for “is this attribute
named error” — path() is a method, not a field, one of the surface
changes from syn 1.x. #[proc_macro_derive(SysError, attributes(error))]
(not shown above) declares error as an inert helper attribute: it
tells rustc that #[error("...")] on a variant is this derive’s business,
not an unknown-attribute error. error_attr.parse_args::<LitStr>()
(spelled parse_args()? above, with the type driven by the LitStr
annotation on message) parses whatever is inside the parentheses as a
single string literal — exactly the "open {path} failed: {errno}" from
PmonError::Open. The three Fields arms are why PolicyRejected’s tuple
variant formats differently from the two named-field variants: for
Fields::Named, the destructured pattern Self::#vident { #(#idents),* }
binds path and errno as local names, so write!(f, #message) alone is
enough — edition 2024’s inline format-arg capture resolves {path} and
{errno} in the literal directly against those bindings, with no
positional arguments at all. For Fields::Unnamed, there is no name to
capture, so the arm binds synthetic identifiers (f0, f1, …) and
passes them to write! explicitly as #(#idents),* after the message.
The whole function returns one quote! block containing both the
Display impl and the Error impl, and that block — not PmonError’s
own source — is what rustc actually type-checks and compiles into app.
expand_instrument takes the same shape and applies it to a function
instead of an enum:
fn expand_instrument(func: ItemFn) -> syn::Result<TokenStream2> {
let ItemFn {
attrs,
vis,
sig,
block,
} = func;
let fn_name = sig.ident.to_string();
let mut arg_idents = Vec::with_capacity(sig.inputs.len());
for input in &sig.inputs {
match input {
syn::FnArg::Typed(pat_type) => match &*pat_type.pat {
syn::Pat::Ident(pat_ident) => arg_idents.push(pat_ident.ident.clone()),
other => {
return Err(syn::Error::new_spanned(
other,
"#[instrument] requires simple identifier parameters",
))
}
},
syn::FnArg::Receiver(receiver) => {
return Err(syn::Error::new_spanned(
receiver,
"#[instrument] does not support methods with `self`",
))
}
}
}
let entry_fmt = {
let args_fmt = arg_idents
.iter()
.map(|id| format!("{id}="))
.collect::<Vec<_>>()
.join(", ");
format!("-> {fn_name}({args_fmt})")
};
let exit_fmt = format!("<- {fn_name} = (us)");
Ok(quote! {
#(#attrs)*
#vis #sig {
println!(#entry_fmt, #(#arg_idents),*);
let __instrument_start = ::std::time::Instant::now();
let __instrument_result = (move || #block)();
let __instrument_elapsed = __instrument_start.elapsed();
println!(#exit_fmt, __instrument_result, __instrument_elapsed.as_micros());
__instrument_result
}
})
}
parse_macro_input!(item as ItemFn) (in the caller, not shown) gives
expand_instrument a full parsed function — signature, visibility,
attributes, and body — as one syn::ItemFn. The loop over sig.inputs
rejects anything that isn’t a plain identifier parameter (self receivers,
destructuring patterns) with a syn::Error rather than silently mangling
it. The generated function keeps the original #(#attrs)*, #vis, and
#sig untouched — same name, same signature, same visibility — and
replaces only #block, the body, with one that prints an entry line,
runs the original body inside a closure so #block’s own returns and
? still work as written, times it with Instant, prints the exit line
with the return value and the elapsed microseconds, and returns the
result. hash_chunk(len: usize) -> u64 goes in as ordinary Rust and comes
out as a function with the identical signature that also happens to log
itself — the caller never sees a difference except the printed lines.
Errors, three ways
Every earlier chapter’s “errors, three ways” contrasted three languages;
this one has only Rust, so the three ways are the three moments an error
in this program can actually surface. Compile time: a malformed macro
input — #[derive(SysError)] on a struct, or a variant missing
#[error("...")] — becomes a syn::Error, and derive_sys_error turns
that into real tokens with err.to_compile_error().into(), so a bad
macro invocation is a compiler error at the call site, never a panic
inside sysmacros itself. Run time: once compiled, PmonError’s
three variants are ordinary values — println!("error: {open_err}")
drives the generated Display impl the same way any hand-written error
type would, producing error: open /etc/shadow failed: 13 and the two
lines after it. A checked run: read_record’s --oob-read path
produces no error at all under a normal cargo run — the read succeeds,
reads whatever bytes happen to sit past the allocation, and the program
keeps going, which is precisely what makes undefined behaviour dangerous
rather than merely broken. cargo +nightly miri run is what turns that
silence into a real, located diagnostic: not a Result, not a panic, but
an interpreter-level Undefined Behavior report naming the exact
instruction and the exact number of bytes it stepped past the end of the
allocation.
Concurrency lens
There is a genuine concurrency angle here, and it is not the hot loop —
there isn’t one. expand_sys_error and expand_instrument run inside
rustc’s own build process, once per macro invocation, as ordinary
synchronous function calls; nothing in sysmacros spawns a thread or
shares mutable state across invocations, because a proc-macro never needs
to — it is handed one TokenStream, returns one TokenStream, and is
done. #[instrument]’s Instant::now()/.elapsed() pair is just as
thread-agnostic at the other end of the pipeline: the generated wrapper
times whatever thread calls the instrumented function, on that thread’s
own clock, with no global state to synchronize. Call hash_chunk from ten
threads and you get ten independent entry/exit pairs, interleaved on
stdout by nothing more than println!’s own internal lock. Contrast a
real tracing framework like tracing: #[tracing::instrument] has to
enter a span on a thread- or task-local subscriber stack, so concurrent
spans compose into a tree a Registry maintains. This chapter’s
#[instrument] composes with concurrency by having nothing to compose —
it wraps whatever the function does and gets out of the way, at the cost
of never answering “which call is this exit line for” once two threads
print at once.
Build, run, observe
[host]$ cd examples/43-rust-macros-for-systems && ./demo.sh build
./demo.sh rust run builds (if needed) and runs the release binary on the
pinned stable toolchain — the exact path verify.lua exercises:
[host]$ ./demo.sh rust run
error: open /etc/shadow failed: 13
error: child 4242 exited with status 1
error: policy rejected: sandbox blocked os.execute
-> hash_chunk(len=4096)
<- hash_chunk = 10911624183873311525 (4us)
record: magic=0x4b565053 slots=256
Six lines, three techniques: lines 1–3 are #[derive(SysError)]’s
generated Display, one line per variant shape (named-field, multi-named-field,
tuple); lines 4–5 are #[instrument] around hash_chunk(len=4096) — the
hash itself (10911624183873311525) is a deterministic FNV-1a over
0..4096, so it is identical on every run, and only the microsecond
figure (4us here) varies; line 6 is the sound read_record call
printing the Record it read.
./demo.sh rust miri runs the identical scenario under
cargo +nightly miri run --quiet — the interpreter, not the compiled
binary. It prints the same six lines and exits 0 (no UB reported), with
one visible difference: the instrument line reads
<- hash_chunk = 10911624183873311525 (491785us) instead of (4us). The
hash value does not change — determinism holds whether the code runs
natively or under an interpreter — but the wall-clock duration jumps from
4 microseconds to roughly 491,785 microseconds, a slowdown of about five
orders of magnitude. That is not a regression to worry about: miri is a
UB checker, not a profiler, and it re-executes every memory operation
through its own bookkeeping instead of the CPU directly. The lesson is in
the contrast, not the absolute number — a wall-clock measurement taken
under miri tells you nothing about the compiled program’s real
performance, only about whether its memory operations are sound.
./demo.sh rust miri -- --oob-read runs the same binary with the
deliberately out-of-bounds path enabled, and this time miri stops
before printing the sixth line at all:
[host]$ cargo +nightly miri run --quiet -- --oob-read
error: Undefined Behavior: memory access failed: attempting to access 8 bytes, but got alloc25+0x4 which is only 4 bytes from the end of the allocation
--> src/main.rs:57:14
|
57 | unsafe { core::ptr::read_unaligned(buf.as_ptr().add(off) as *const Record) }
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Undefined Behavior occurred here
|
= help: this indicates a bug in the program: it performed an invalid operation, and caused Undefined Behavior
= help: see https://doc.rust-lang.org/nightly/reference/behavior-considered-undefined.html for further information
= note: stack backtrace: 0: read_record (src/main.rs:57) 1: main (src/main.rs:76)
alloc25 is miri’s own name for the specific allocation BUF occupies
in its interpreted heap; +0x4 is the offset the failing read started at
(off = 4 on the --oob-read path); “only 4 bytes from the end of the
allocation” is miri doing arithmetic a native run never does — BUF is
8 bytes, the read needs 8 more bytes starting at offset 4, and offset 4
plus 8 is 12, four bytes past where the allocation actually ends. The
line and column (src/main.rs:57:14) point at the exact unsafe block
quoted above. On stable Rust this same --oob-read invocation runs to
completion, silently reading whatever garbage bytes happen to sit past
BUF in memory — no error, no crash, no signal that anything was wrong.
The gate the CI-equivalent runner checks is the stable path only:
[host]$ python3 scripts/test-all-examples.py --only 43-rust-macros-for-systems
...
1 passed, 0 failed, 0 skipped
[host]$ cd examples/43-rust-macros-for-systems && LSP_LANG=rust lua verify.lua
...
PASS 8 / FAIL 0
Cross-check: the generated code is exactly what a systems error type needs, and the unsafe read only ever touches in-bounds bytes
Two independent claims, two independent proofs. The first is that
#[derive(SysError)]’s generated Display impl is not an approximation
of a hand-written systems error type — it is one: verify.lua asserts
the exact three lines PmonError’s variants must produce
(open /etc/shadow failed: 13, child 4242 exited with status 1,
policy rejected: sandbox blocked os.execute), plus the #[instrument]
entry/exit shape and the parsed Record fields, for PASS 8 / FAIL 0 —
eight assertions against behaviour, not “the binary exited 0.” The second
is that the sound read_record path really is sound and the
--oob-read path really is a bug, and that claim is checked by an
interpreter rather than taken on faith: the identical scenario run under
cargo +nightly miri run exits 0 with no UB reported on the default path,
and reports the precise out-of-bounds access — 4 bytes past an 8-byte
allocation, at src/main.rs:57 — the moment --oob-read is passed. The
491785us-versus-4us timing gap is the same proof from a different angle:
it shows miri is genuinely re-executing every memory access through its
own interpreter rather than fast-pathing to the native instruction, which
is exactly the mechanism that lets it catch a bounds violation a native
cargo run sails straight through.
What you learned
- A proc-macro crate is a second, separate compilation:
sysmacrossetsproc-macro = trueand is compiled by rustc as a compiler plugin beforemain.rsis ever touched — every proc-macro isTokenStreamin,TokenStreamout, and none of that code exists atapp’s runtime. #[derive(SysError)]earns the name “miniaturethiserror“: onesyn::Result<TokenStream2>function parses each variant’s#[error("...")]string withsyn 2.0, builds aDisplaymatch arm perFieldsshape (named, tuple, unit) withquote!, and edition 2024’s inline format-arg capture resolves{path}/{errno}straight against the destructured match bindings — no positional arguments needed.- Malformed macro input is a
compile_error!, never a panic: both macros convert asyn::Errorinto real tokens viaerr.to_compile_error(), so a missing#[error(...)]attribute or a non-enum target fails the build with a located diagnostic, the same discipline a hand-written derive would need to earn. unsafeis a promise the type system stopped checking, andmirichecks it anyway: the soundread_recordpath’sassert!makes itsSAFETYcomment true; the--oob-readpath skips that assert and reads 4 bytes past an 8-byte allocation — silent on stable, a named Undefined Behavior report undercargo +nightly miri run.miriis a correctness checker, not a profiler: the identical scenario took 4 microseconds natively and roughly 491,785 microseconds undermiri— a real, expected, five-order-of-magnitude interpreter slowdown that says nothing aboutapp’s real performance and everything about whether its memory operations are sound.#[instrument]is thread-agnostic because it holds no state at all: it wraps whatever function it’s given in aprintln!/Instant/println!sandwich with no shared subscriber or span stack, unlike a runtime tracing crate that has to maintain one — composable with any threading model precisely because it has nothing to synchronize.
Two deep dives remain in this part: this chapter’s Rust-only look at compile-time metaprogramming, and the next — the Go runtime itself, from a systems programmer’s seat.
verified — on the Fedora
44 reference host this session: python3 scripts/test-all-examples.py
--only 43-rust-macros-for-systems reported 1 passed, 0
failed (rust column only; this is the book's first single-language
example), and LSP_LANG=rust lua verify.lua reported
PASS 8 / FAIL 0. ./demo.sh rust run on the pinned
stable rustc 1.97.1 toolchain produced the exact six-line
transcript quoted above: the three #[derive(SysError)]
Display lines, the #[instrument] entry/exit pair
around hash_chunk(len=4096) (hash
10911624183873311525, deterministic; 4us measured
this run), and the parsed record: magic=0x4b565053 slots=256
line. cargo +nightly miri run --quiet (the sound path) exited 0
with no Undefined Behavior reported, printing the same six lines with the
instrument line's duration at 491785us instead of
4us — a real ~5-order-of-magnitude interpreter slowdown, not a
regression. cargo +nightly miri run --quiet -- --oob-read
reproduced the exact Undefined Behavior diagnostic quoted above: memory
access failed at alloc25+0x4, "only 4 bytes from the end of
the allocation," located at src/main.rs:57:14, the
read_unaligned call inside read_record. Crate
pins: syn 2.0 (features full), quote
1.0, proc-macro2 1.0, on stable rustc
1.97.1 (rust-toolchain.toml) with a separately
installed nightly toolchain plus the miri component via
rustup. Not exercised: this example is mode: local
per examples/manifest.yaml — there is no VM or LGTM path for
it; and the --oob-read path is only ever run under
miri in this book, never asserted on stable Rust, where the
same call is undefined behaviour rather than a checkable outcome.