Having written so far, dizziness overwhelms my soul, and tears blind my eyes.
Talk stratachapter map
I used to work on embedded Linux. That’s how I ended up here: one side project led to another until I needed more from Miri, and somewhere along the way I forgot the original project. None of this has much to do with my work a few years ago.
More than anything, this is a personal drama. Life can’t be all code; there needs to be some action. Someone needs to cry, and what better topic to cry about than program verification?
Miri is an interpreter that executes Rust while tracking enough of the machine state to detect many kinds of undefined behavior. FFI is where execution leaves that model and enters native code Miri cannot see.
The problem with FFI🔗
Miri is powerful, but inherently limited—and slow. It’s meant for testing code; you’re not going to play video games on it. As you’ll see, I made that slowness much worse. “8,000 segfaults per second” will eventually need to become hundreds of thousands before this is broadly useful.
The core of the issue is that code spawned in an FFI call can do almost anything, and while it runs, Miri has no idea what it’s doing. That is a problem for an interpreter whose model depends on tracking pointer provenance and whether individual bytes are initialized. How do you preserve that model while executing random nonsense CPU instructions that were once written in C, C++, Java, or anything else?
My answer is that you will it into existence.
We can’t recover everything a precompiled binary does. The concrete goal I set out to achieve is to link an arbitrary The previous arbitrary-FFI implementation effectively initialized every reachable uninitialized byte and exposed the provenance of every reachable pointer. Nasty stuff..so, call a function, and have it somehow Just Work™ in terms of translating what it actually did to what Miri can understand. Even an incomplete account can constrain the worst case, which is much better than assuming the foreign code touched anything it could possibly reach.Previously
That means tracing every memory access: its address and size, and whether it was a read, a write, or both.
Faced with this daunting problem, I sought aid from the holy scripture of Google search results and Stack Overflow. I found a random post from 2013 about trapping every memory read and write using SIGSEGV. It can’t be that hard, right?
I wasn’t the first person to have the idea, though perhaps few had tried it in quite this form.
Tracing native accesses🔗
Turning every access into a fault🔗
Miri forks into two processes. The parent becomes a supervisor; the child remains Miri proper and can jump into whatever foreign code it has been given. Right before it does, we use This implementation is Linux-specific. A macOS port would need to replace mprotect to mark the Miri-managed pages exposed to that native call as PROT_NONE. Any access to those pages now raises a segfault.Portabilitybeyond Linux
ptrace, obtain equivalent mapping information, and contend with the platform’s execution restrictions. That would be plausible, as it’s what a lot of debuggers already need to do; but changing the underlying API would only be the beginning of the work.
A segfault is only a Unix signal. It is not magic, and execution can continue after one. Signals mean whatever you believe they do, after all. Under ptrace, the supervisor sees the child receive that signal and itself receives a siginfo, including the faulting address.
Great. Amazing. Lovely. Perfect. This all works.
An address is not enough, however. We still need the size of the access, whether it read or wrote, and some way to let the instruction finish. The page is still protected with PROT_NONE; restarted as-is, it would simply fault again.
Decoding and replaying the instruction🔗
Code is data, so the supervisor reads the bytes at the child’s instruction pointer and hands them to a disassembler. The decoder gives us the possible access width and whether the instruction may read, write, or do both.
This is necessarily conservative. In an RW operand, a write may be conditional on a read, for example. Even so, it gives Miri a much narrower bound on what the instruction could have touched. If the instruction could write, the affected bytes might now be initialized; bytes outside that range definitely were not. When native code reads bytes carrying provenance, Miri can conservatively mark that provenance as exposed.
The event type is correspondingly small: an address range, an access kind, and—because the decoder sometimes cannot know—a certainty bit for writes.
enum AccessEvent {
Read(AccessRange),
Write(AccessRange, bool), // `true` when the write is certain
}
struct AccessRange {
addr: usize,
size: usize,
}Boundary checkswhat survives
Tree Borrows and Stacked Borrows do not translate directly across this boundary. Native execution cannot reproduce the fine-grained events those models need, so at the boundary we use exposed provenance and conservatively allow more aliasing.
These native actions are written back into Miri’s state, so its ordinary validity checks can still reject bad values afterwards. This approach loses some information inside native code, but it does not discard every check around it.
There’s also the matter that getting the information we need from the decoder is highly architecture-specific. The current implementation uses Capstone and supports x86 and x86-64, since these architectures have well-behaved fixed-size accesses on all instructions; other architectures can have register-dependent access widths, such as with ARM scalable vectors. Capstone is an excellent disassembler. Unfortunately, I don’t need a disassembler; I need a magic machine that reports an operand’s access behavior and width. Its Rust wrapper also used to make I had prototyped something for ARM once, for which I assumed every access was at most 128 bits. Then I learned about how gigantic vector extensions can get and gave up trying for now. Ixi, at Oxide, had worked on yaxpeax and wondered whether it could do better. I annoyed them until Decoder archaeologyCapstone → yaxpeax
cargo check expensive because the native build still ran; later feature work made that less painful.yaxpeax-x86 grew the instruction-behavior API I needed, now described in its documentation. The next step is integrating it into Miri to make x86 support less flaky; other architectures remain longer-term work.
Once we understand the fault, the supervisor records its conservative access event. ptrace then lets it rewrite registers and memory in the child.
It diverts the instruction pointer into a fake extern "C" function, moves the stack pointer to zeroed scratch memory, and writes the page address into static storage. That function makes the page readable and writable, then raises SIGSTOP instead of returning.
The supervisor restores the interrupted registers and single-steps the original instruction before sending the child through a matching trampoline that restores PROT_NONE. Execution continues, and the event batch returns to Miri when the FFI call ends.
In extremely abridged source form, the control loop looks like this:
new_regs.set_ip(mempr_off as *const () as usize);
ptrace::setregs(pid, new_regs).unwrap();
// … the child unprotects the page, then stops
ptrace::setregs(pid, regs_bak).unwrap();
ptrace::step(pid, None).unwrap();
// … run mempr_on, restore the registers, and continueThis isn’t a calling convention. I don’t know what it is. It is a crime against computers. The first tests of this feature passed locally but hung in CI, repeatedly and without useful logs. I eventually, painstakingly, realised the issue was that Unix signals and our IPC messages were racing. At the start of an FFI call, the child sends its call information to the supervisor. The supervisor now acknowledges that message before the child proceeds. Without the handshake, The supervisor would then enter the stop-handling path before it knew an FFI call had begun, and the two sides could wait on different channels forever. Running Miri locally in many-seeds mode made the hang reproducible, and the handshake fixed the ordering bug. Separately, many-seeds testing resulted in me serializing entry into FFI with a mutex: processes running with different seeds can proceed in parallel, but only one can be inside FFI at a time. That both discovered and fixed an outstanding bug when mixing many-seeds and native-lib modes regarding unsynchronized access to foreign statics. I honest to god fixed a race condition by parallelizing it more. Do you see why I called this a psychological drama? Instead of segfault handling, we could use QEMU or write an x86 interpreter. Running the real host code has two advantages: it has a path to speed when native code rarely touches Miri’s memory, and it can call an arbitrary system Imagine a Vulkan library that mostly works in its own memory and exchanges only a few values with Miri. In principle, this design could one day render something to the screen. Emulating the entire environment would make that much harder, but I am determined to make it so you can indeed play games on your Miri.Failure stratumthe CI-only race
SIGSTOP could arrive before the IPC message.Why not emulate the CPU?
.so without recreating the host runtime.
Getting allocations back from native code🔗
Tracing accesses only covers pointers that travel from Miri into native code. Usually, pointers tend to come back as well. To Miri, an address returned by an allocator is otherwise just an integer pretending to be a pointer; Miri cannot know if it may safely dereference it on the host hardware.
So let’s trace allocations.
Intercepting libc🔗
Imagine a function that merely wraps malloc. Miri can shim malloc on the Rust side, but in a precompiled library that wrapper just looks like a random function returning an address. We need to recognize the underlying allocation.
We place traps at the relevant libc entry points. And by “place traps”, I do mean overwrite their first instruction with a breakpoint:
ptrace::write(pid, libc::malloc as *mut _, BREAKPT_INSTR.into()).unwrap();
ptrace::write(pid, libc::calloc as *mut _, BREAKPT_INSTR.into()).unwrap();
// …
ptrace::write(pid, libc::free as *mut _, BREAKPT_INSTR.into()).unwrap();When native execution reaches one, we redirect the instruction pointer into one of our fake functions. The original and replacement entry points use the same extern "C" ABI, so the redirect should be ABI-compatible. I did consider shimming all of libc, but was assured that would be excessive. Whatever you say, Ralf.
Does it work?
No. Of course it doesn’t.
Those tiny trampoline shims do not have a normal Miri context, stack, or runtime environment. More importantly, Miri cannot wait until the FFI call returns to learn about an allocation: the native code may use that memory immediately.
Keeping allocation events in order🔗
We smuggle a pointer to Miri’s interpreter context through a global, then recover it inside the interceptor to process the allocation or deallocation before native execution continues. Sure, that interpreter context type has a lifetime that we just make up in the pointer materialization, but what’s a little unbounded lifetime between friends?
Ordinary access events can be batched until the FFI call returns. Allocation and deallocation break that scheme: one can reuse an address, while the other makes an old address invalid. A pending event may therefore refer to the wrong allocation by the time Miri processes it. Thus we need to flush those events before either boundary, using another IPC channel hidden in a global Mutex.
Surely that was it?
Of course not; libc can call itself.
When libc calls itself🔗
Libc’s internal calls may be inlined or bound directly to private aliases, bypassing the public entry points we patched. Others still reach those breakpoints. If an internal free is redirected while the matching allocation was not, we can end up allocating with one allocator and freeing with the other.
Linux provides the lovely The bookkeeping has two independent axes: We must always determine where we fall at runtime, and do so by just staring at pointer and callsite addresses. The code that routes each call ended up being somewhat simple but straightforwardly hideous./proc/self/maps, which identifies the object behind each mapping. We use libc’s executable mappings and the saved call-site address to determine whether a call originated inside libc.This annoying matrix
question possible states where did the call originate? inside libc / outside libc who owns the allocation? libc / Miri
Origin is only half the problem; every allocation also needs an owner. A block obtained from real malloc and passed back to Miri must eventually return to libc rather than Miri’s allocator.
This is where I went insane. Picture me at my desk on the second floor of a little suburban house, very sleep-deprived. It was probably three or four in the morning, though I had only woken a few hours earlier because this had become my schedule. I was desperate to get something working. This was not nice code. Normal people do not want this merged into their compiler. Who is going to maintain it?Development conditionscirca 04:00
Either way, after some headscratching I managed to adapt the above routing code to handle that case as well; and after all of that nonsense, it finally passed CI. The allocation-tracing half still needs cleanup, and its current shape is regrettably split. This mechanism is a low-level fallback, not a specific integration for a managed language like e.g. Java. Managed runtimes typically obtain backing memory through libc or system calls such as An earlier prototype—effectively part one of this investigation—traces alloc-tracing-libc-part-1 installs the interception layer and has its review history in PR #4792. The full allocator hand-off lives on alloc-tracing-libc-part-2, which has no PR. The older all-in-one draft PR #4791 remains useful archaeology.Managed runtimesthe layer below
mmap; observing those layers recovers allocations or mappings, not the runtime’s object or garbage-collection semantics.mmap and munmap on its own branch; its history is draft PR #4622. The syscall handlers themselves are almost offensively direct. Also relevant is brk/sbrk; that’s a problem for later, for the six people who still use it. For this narrow bookkeeping, syscalls are pleasantly easy to intercept with ptrace: record the mapped address and size, and you are mostly done. That prototype worked on the first try and took about twenty minutes, just to make the rest of this story feel worse.
Current status and future work🔗
The two halves are at different stages. Native access tracing has been merged, and people are already using it; allocation tracing remains experimental branch work. The Miri documentation describes the experimental native-library flags. A minimal invocation looks like this:
MIRIFLAGS="-Zmiri-native-lib=/path/to/library.so -Zmiri-native-lib-enable-tracing" \
cargo +nightly miri testYes, the interface could be nicer. It will be nicer. There is a very big to-do list; please add to it by telling me what needs to improve. The original tracing PR was closed rather than merged whole. Its layers landed separately:Merge stratumnative tracing
Cost and coverage🔗
The prototype measurement behind the title was roughly 8,000 handled faults per second. Treat that as an order-of-magnitude result, not a reproducible benchmark; this article does not preserve the original machine and workload details.
Because every access to a protected Miri-managed page must fault, it is not fast. This is proof that the approach can work for a toy program, not its final form. Possible optimizations include moving some supervision into a small kernel component and replacing the decoder bottleneck. Merged access tracing has handwritten cases in Miri’s CI. The experimental allocation branch adds its own ugly boundary tests—for example, allocating through Miri’s Rust-side Broad ecosystem testing is premature while the allocation side remains unfinished. I also do not know how many people already use Miri’s FFI support; probably not many. Yet. This is not a supported workflow, but a C entry point packaged as a compatible native library could be called through a tiny Rust wrapper. The result would be closer to AddressSanitizer than full Miri: merged access tracing can catch some invalid accesses to Miri-managed memory, while following native allocations and frees additionally requires the experimental allocation work.Test ledgercurrent limits
malloc shim, passing the pointer into native C, and freeing it there.What about a C program?
What “unsound” means here🔗
Also, Miri is now unsound. You’re all very welcome.Review archaeologyplease don’t

That joke has a less precise scope than one might hope. Miri’s own documentation calls native-library mode unsound because Miri can lose initialization and provenance information for memory shared with native code. The feature is experimental and opt-in. While great care was taken to not make Miri itself literally unsound, one never knows with code like this; it still enables calling arbitrary native binaries from inside Miri itself.
Callbacks and threads🔗
Callbacks are one of the next targets. libffi, which already handles part of the FFI setup, can create ABI-compatible callback entry points. That solves only the first step. Miri must still regain control, schedule the Rust callback, synchronize memory state, and return to native code.
Native threads pose the same kind of coordination problem and might eventually be parked and resumed through Miri’s scheduler. A Diesel maintainer started trying Diesel’s SQLite tests under native-library mode. The immediate roadblock was Early usersDiesel
sqlite3_exec, which requires a callback function pointer to cross the FFI boundary. Miri does not support that yet.
There is a future in which you pass an arbitrary function pointer into FFI, have it called simultaneously from eighteen threads, and Miri still detects at least a bunch of UB.
That is the point of this story: here is a ridiculous thing, and here is how I willed it into existence despite reality insisting it was a load of crap.
Credits🔗
Ralf and Oli survived many painful review iterations while I learned what I was doing. Strophox created Miri’s previous FFI implementation, and ixi built the yaxpeax features this work needs.
Thanks as well to the Capstone developers and all my dependencies. I began with Iced, which was x86-only and also very nice. This project is part of why I now work on Rust. It also made me the subject of some internal gossip; I have heard variants of “Oh God, Ralf told me about this terrifying code you wrote” from several people. This was my first major Rust contribution and only my second or third large Rust project. The early code—especially the custom allocation setup for mapped memory—was awful. My deepest apologies to everyone helping me review it. Thank you also to caffeine, Monster Energy, and my beautiful wife. There are discredits too. A good programmer needs a grudge—a big grudge. Mine are x86 calling conventions, whoever made Personal effectsgossip, caffeine, grudges
ptrace this hard, and AArch64 scalable vector instructions. To hell with all of them, and I owe none of my success & many of my failures to them.