Systems Programming · Unsafe Code
Inline Assembly Constraints and What a Clobber List Does Not Say
A compiler knows nothing about the instructions inside an assembly block, so every register, flag, and memory effect has to be declared, and an undeclared effect is silently miscompiled.
An inline assembly block is opaque to the compiler in a way that no other construct is. The optimiser does not read the instructions, does not model their effect on registers, and does not know whether they touch memory. Everything it needs to know must be declared in the constraint string and the clobber list, and the declarations are the entire interface. An effect that is not declared is an effect the compiler will assume does not exist, and it will allocate a register or reorder a load accordingly.
The failure is rarely a crash in the assembly itself. It is a wrong value in a register that the compiler believed held something else, appearing several instructions later, after the value has been spilled and reloaded and the connection to the assembly block is no longer visible.
The allocation problem the constraints solve
Register allocation happens before the assembly is emitted, and it assigns every live value to a register or a stack slot. The allocator’s model of the program is built from the compiler’s intermediate representation, which contains the assembly block as a single node with a declared set of inputs, outputs, and side effects.
An input operand constrains the node to read a value, and the constraint letter says where it may live. A general-purpose register constraint tells the allocator to place the value in some register and to substitute that register’s name into the template. A memory constraint tells it to pass an address or a stack slot. An output operand tells it that a register will hold a result afterwards, and the allocator will read that register at the end of the block.
The allocator does not know which registers the instructions actually use. If the block writes to a register that is not declared as an output and not listed as clobbered, the allocator may have placed a live value there, and that value is destroyed. This is the single most common inline assembly bug, and it is not detectable by reading the assembly: the code is correct, the declaration is incomplete.
/* x86-64: unsigned 64-bit division, which needs rdx:rax as an implicit pair. */
uint64_t divmod(uint64_t n, uint64_t d, uint64_t *rem) {
uint64_t q, r;
__asm__ ("divq %4"
: "=a"(q), "=d"(r) /* rax gets the quotient, rdx the remainder */
: "0"(n), "1"((uint64_t)0), "r"(d) /* inputs tied to those registers */
: "cc"); /* divq writes the flags */
*rem = r;
return q;
}
divq reads the dividend from rdx:rax, divides by its operand, and writes the quotient to rax and the remainder to rdx. The "=a" and "=d" outputs name those two registers, and the "0" and "1" inputs tie the dividend halves to the same registers the outputs occupy, which is what makes the pairing work. The "cc" clobber states that the flags register is modified, which prevents the compiler from keeping a comparison result live across the block. Omitting "cc" is a bug that survives until a later conditional happens to use a stale flag.
Early clobber and the ordering trap
An output operand is normally assumed to be written only after all inputs have been read. That assumption lets the allocator reuse an input register for an output, which is often what the instruction does anyway. Some instructions violate it: they write an output before consuming every input, and an allocator that overlapped the two would destroy an input the instruction still needed.
The & modifier marks an output as early-clobber, meaning it may be written before the inputs are read and therefore may not share a register with any input. GCC’s documentation states the rule directly: use it on any output that is written before all inputs are consumed. Failing to use it produces code that works when the allocator happens to pick different registers and breaks when it does not, which is exactly the kind of bug that appears when a compiler version changes.
The multiple-output case is worse. An instruction that writes two outputs, where either may be written before the inputs are read, needs & on both, and the allocator must then find two registers that are not among the inputs. On a register-poor architecture that can force spills, which is the cost of the correctness.
Memory effects are not implied by anything
The default assumption for an assembly block is that it reads and writes memory. That is the conservative choice, and it is why a block with no memory clobber still acts as a barrier for loads and stores: the compiler cannot move a memory access across it, because it does not know whether the block touches that address.
The performance cost of the default is that every assembly block inhibits reordering of every memory access around it. The escape is to declare that the block does not access memory at all, or that it only reads memory. Rust’s asm! exposes this as the nomem and readonly options; GCC has no direct equivalent and instead relies on the absence of the "memory" clobber, which is a weaker statement since the compiler still cannot prove the block avoids a specific address.
nomem is a promise, and the promise is checked by nothing. A block marked nomem that performs a store through a pointer the compiler believes is untouched can have that store reordered against a load, or the loaded value cached in a register across the block. The declaration is a claim about the instructions, and it must be true of every path through them.
The readonly case is narrower: the block may read memory but not write it. Loads may be reordered across it in one direction, stores may not move into it. Getting the direction wrong is a silent miscompile.
Flags, the stack, and the ABI
Three more effects are easy to leave undeclared.
Condition flags. Most arithmetic instructions write the flags register, and most compilers keep flag values live across short spans. A block that writes flags without declaring the clobber can have a later conditional branch read a stale comparison. On x86-64 the declaration is "cc" in GCC and clobber_abi or an explicit flags clobber in Rust.
The stack pointer. An assembly block that pushes and pops in a balanced way needs no declaration, because the net effect is zero at the block boundary. A block that changes the stack pointer and does not restore it must declare it, and the compiler’s frame addressing will otherwise be wrong for every subsequent access to a local. Rust exposes nostack as the assertion that the block does not push, and the absence of that option is the safe default.
Callee-saved registers. A block that writes a callee-saved register without restoring it corrupts the caller’s state. The compiler’s model is that the block preserves exactly the registers the ABI says a call preserves, minus those declared as clobbered. Declaring a callee-saved register as an output is legal and tells the compiler the register’s value is destroyed; failing to declare it is the bug. Rust’s clobber_abi("C") declares the whole set the C ABI allows a call to destroy, which is the correct declaration for a block that behaves like a call.
Volatile is about deletion, not ordering
Marking a block volatile tells the compiler it may not delete it or duplicate it, even if its outputs are unused. It does not strengthen the memory model beyond what the clobber declarations say, and it does not prevent reordering relative to memory accesses any more than the default assumption already does.
The reason to use it is a block whose purpose is a side effect the compiler cannot see: a memory barrier instruction, a write to a device register, a system call, a timing read. Without volatile, a block whose outputs are unused is dead code and will be removed. With volatile, it stays, and it stays in the position the source gives it relative to other volatile operations.
The cost is that volatile blocks act as scheduling barriers for other volatile blocks, and a loop containing one cannot be unrolled or vectorised in the usual way. A barrier in a hot loop is often the point of the code, so the cost is accepted; a barrier inserted out of caution is a performance bug.
What the declarations cannot express
Constraint declarations describe registers, memory, and flags. They cannot describe an effect on a machine state the compiler does not model. A block that changes a control register, modifies a page table, or writes to a register that affects the floating-point environment has an effect the compiler will not track, and the only defence is to make the block a compiler barrier and to keep the affected code out of the optimiser’s reach.
They also cannot express a dependency between two blocks. Two asm! invocations with no declared relationship may be reordered relative to each other, and a sequence of instructions that must execute in order (a barrier followed by a load, a store followed by a fence) needs the memory declarations or volatile markers that force the order.
Because the contents are invisible to every tool above the compiler, an assembly block also bounds what dynamic analysis can attribute, as What Sanitizers and Miri Cannot Prove describes.
The practical conclusion is that the constraint string is a specification of the block’s effects and should be written with the same care as a function’s documentation. The obligations it transfers are the same kind described in What the Unsafe Keyword Actually Disables: the compiler has stepped back, and what it needs in order to step back safely is a complete statement of what it is stepping away from.
References
- GCC documentation: Extended Asm — GCC project
- The Rust Reference: inline assembly — Rust project
- System V Application Binary Interface, x86-64 Processor Supplement — AMD64 ABI specification
- Intel 64 and IA-32 Architectures Software Developer Manuals — Intel Corporation