CodeRigor

FREE LESSON

Inline assembly, builtins and barriers

GNU extended asm done right — outputs, inputs, constraints, clobbers, earlyclobbers, volatile and asm goto — and why builtins and intrinsics are usually the better tool. Plus rdtsc, cpuid, and compiler barriers versus CPU fences.

Sometimes you need one instruction in the middle of C++: read the time-stamp counter, query cpuid, use a carry flag. GNU extended asm lets you do that, and it's also one of the easiest ways to write code that works at -O0 and silently breaks at -O2. This lesson is about writing it correctly — and about the builtins and intrinsics that make most of it unnecessary.

1. The shape of an extended asm statement

Text
asm [volatile] ( "template"
                 : outputs        "=r"(out), …
                 : inputs         "r"(in), …
                 : clobbers       "cc", "memory", "rcx", … );

The compiler does not parse your template. It only substitutes operands (%0, %1, or named ones like %[x]) and pastes the text into its output. Everything it knows about the statement comes from the operand lists — and it believes them completely. Inline asm uses GCC's default AT&T syntax: op src, dst, registers spelled %%rax (two % in an extended template), immediates $1.

C++
#include <cstdint>
#include <cstdio>

int main() {
    std::uint64_t x = 10, y = 5;
    asm("addq %[y], %[x]"          // x += y
        : [x] "+r"(x)              // read-write, any register
        : [y] "r"(y)               // read-only, any register
        : "cc");                   // the flags change
    std::printf("%lu\n", x);
}
The compiler only sees the operand lists
It picks a register for each operand from its constraint, loads +/input operands, pastes your text with %[x] substituted, and afterwards assumes exactly the outputs and clobbers you listed changed.
The compiler only sees the operand lists
It picks a register for each operand from its constraint, loads +/input operands, pastes your text with %[x] substituted, and afterwards assumes exactly the outputs and clobbers you listed changed.

2. Constraints

A constraint says where an operand may live and how it's used.

constraint meaning
r any general-purpose register
m a memory operand
rm register or memory, compiler's choice
i an immediate (compile-time constant)
a b c d S D exactly rax, rbx, rcx, rdx, rsi, rdi (or their narrower forms)
q a register with an 8-bit form (for setc and friends)
= output, write-only: the old value isn't loaded
+ output that is also read
& earlyclobber: written before all inputs are consumed
0, [name] (as an input) same location as that output

Instructions with implicit registers are expressed with the fixed-register letters. mulq multiplies rax by its operand and writes rdx:rax:

C++
#include <cstdint>
#include <cstdio>

std::uint64_t mul_hi(std::uint64_t a, std::uint64_t b) {
    std::uint64_t lo, hi;
    asm("mulq %[b]"
        : "=a"(lo), "=d"(hi)      // both halves come out
        : "a"(a), [b] "rm"(b)     // a must be in rax
        : "cc");
    return hi;
}

int main() { std::printf("%lu\n", mul_hi(1ull << 63, 6)); }   // 3
mulq writes a 128-bit product to rdx:rax
2⁶³ × 6 = 3 · 2⁶⁴: the low 64 bits are 0, the high 64 bits are 3. Both registers change, so both must be outputs.
mulq writes a 128-bit product to rdx:rax
2⁶³ × 6 = 3 · 2⁶⁴: the low 64 bits are 0, the high 64 bits are 3. Both registers change, so both must be outputs.

Even though we only want hi, lo must be listed: rax is overwritten, and the compiler has to know.

3. The three classic constraint bugs

Write-only when you meant read-write. "=r"(x) tells GCC the old value of x is irrelevant, so it doesn't load it — the register holds garbage (at -O0 often another input). If the template reads the operand, use "+r", or tie an input to it:

Text
asm("addq %[b], %[s]" : [s] "=r"(sum) : "[s]"(a), [b] "rm"(b));   // s starts as a

Missing earlyclobber. GCC assumes a template reads every input before it writes any output, so it may give an output the same register as an input. If you write the output first, you destroy that input:

C++
#include <cstdint>
#include <cstdio>

std::uint64_t one_plus(std::uint64_t a, std::uint64_t b) {
    std::uint64_t r;
    asm("movq $1, %[r]\n\t"        // r is written first …
        "addq %[a], %[r]\n\t"      // … then a and b are read
        "addq %[b], %[r]"
        : [r] "=&r"(r)             // & : r must not share a register with a or b
        : [a] "r"(a), [b] "r"(b)
        : "cc");
    return r;
}

int main() { std::printf("%lu\n", one_plus(3, 10)); }   // 14
Why one_plus needs the &
one_plus(3, 10), register contents after each instruction. Without &, GCC may give r the same register as a, and the first write destroys a before it's read.
Why one_plus needs the &
one_plus(3, 10), register contents after each instruction. Without &, GCC may give r the same register as a, and the first write destroys a before it's read.

Remove the & and GCC at -O0 puts r and a in the same register: the first movq overwrites a and the result is 12. Paste it into the compiler explorer and compare the registers it picks.

Forgotten side effects. Without volatile, an asm statement with outputs is treated as a pure function of its inputs: if the outputs are unused it's deleted, and identical statements may be merged or hoisted out of loops. That's fatal for rdtsc and cpuid:

C++
#include <cstdint>
#include <cstdio>

std::uint64_t read_tsc() {
    std::uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));     // volatile: always executed, never merged
    return (std::uint64_t{hi} << 32) | lo;          // widen before shifting!
}

int main() {
    std::uint64_t t0 = read_tsc(), t1 = read_tsc();
    std::printf("%s\n", t1 >= t0 ? "monotonic" : "went backwards?");
}

Note the combine: hi << 32 on a uint32_t is undefined behaviour (UBSan catches it) and would drop the high half.

4. Clobbers: "cc" and "memory"

  • "cc" — the template changes the flags. On x86 GCC assumes this anyway, but write it: it documents intent and matters on other architectures.
  • Named registers ("rcx") — scratch registers the template uses that aren't operands.
  • "memory" — the template reads or writes memory that isn't an operand (for example through a pointer in a register). The compiler then must write cached values back before the statement and reload them afterwards.

Compiler barriers versus CPU fences

An empty template with a "memory" clobber emits no instruction, but stops the compiler from moving memory accesses across it:

Text
extern int g;
int twice_read() { int a = g; asm volatile("" ::: "memory"); int b = g; return a + b; }

twice_read:
    mov  eax, DWORD PTR g[rip]
    add  eax, DWORD PTR g[rip]      ; reloaded after the barrier
    ret
Two different reorderers, two different tools
A compiler barrier stops the optimiser moving accesses across it and emits nothing. The CPU can still let a later load pass an earlier store sitting in its store buffer; only a fence (std::atomic_thread_fence(seq_cst)) stops that.
Two different reorderers, two different tools
A compiler barrier stops the optimiser moving accesses across it and emits nothing. The CPU can still let a later load pass an earlier store sitting in its store buffer; only a fence (std::atomic_thread_fence(seq_cst)) stops that.

That's a compiler barrier: it constrains the optimiser, not the CPU. The CPU has its own reordering (on x86, a later load may pass an earlier store waiting in the store buffer). Preventing that needs a fence — and in C++ you write it with atomics, not asm:

C++ x86-64 code stops
asm volatile("" ::: "memory") nothing compiler reordering
std::atomic_signal_fence(seq_cst) nothing compiler reordering (portable spelling)
std::atomic_thread_fence(acquire / release) nothing (TSO gives it) compiler reordering
std::atomic_thread_fence(seq_cst) lock or [rsp], 0 or mfence store→load reordering too

A barrier alone never makes a data race safe: two threads touching a plain int is undefined behaviour whatever asm you put around it. Use std::atomic.

5. asm goto

asm goto lets the template jump to C++ labels — useful for "check a flag and branch" without materialising a boolean:

C++
#include <cstdint>
#include <cstdio>

bool add_overflows(std::int64_t a, std::int64_t b) {
    asm goto("addq %[b], %[a]\n\t"
             "jo %l[overflow]"
             : [a] "+r"(a)          // the add writes a's register
             : [b] "r"(b)
             : "cc"
             : overflow);
    return false;
overflow:
    return true;
}

int main() {
    std::printf("%d %d\n", add_overflows(1, 2), add_overflows(INT64_MAX, 1));
}

Outputs in asm goto are allowed since GCC 11 and Clang 11; they're valid on the fall-through path. The portable way is __builtin_add_overflow(a, b, &r), which compiles to the same add + jo/seto.

6. Prefer builtins and intrinsics

An asm block is a black box. The optimiser can't constant-fold it, can't vectorise around it, and can't pick a better instruction for the target. In order of preference:

  1. Plain C++ the compiler recognises: (unsigned __int128)a * b >> 64 is a single mul; std::rotl is rol; std::popcount is popcnt.
  2. Builtins: __builtin_popcountll, __builtin_clzll / ctzll, __builtin_add_overflow, __builtin_bswap64, __builtin_expect, __builtin_unreachable (C++23: [[assume(…)]]), __builtin_assume_aligned.
  3. Intrinsics from <immintrin.h> / <x86intrin.h>: __rdtsc(), _addcarry_u64, _mulx_u64, _pext_u64, all of SSE/AVX.
  4. Inline asm only for what none of these express.

Builtins adapt to the target: with -mpopcnt the builtin is one popcnt (plus an xor that breaks a false dependency on older Intel cores); on a baseline x86-64 target it calls __popcountdi2. Try both flag sets in the explorer:

C++
#include <cstdint>
int pc(std::uint64_t x) { return __builtin_popcountll(x); }

Hints change codegen too. __builtin_unreachable() lets the compiler assume a condition — x / 2 for x ≥ 0 becomes a single sar with no rounding fix-up — and __builtin_expect(cond, 0) moves the unlikely path out of the straight line. Both are promises: if they're false, the behaviour is undefined.

7. cpuid and rdtsc in practice

  • cpuid takes a leaf in eax (and a subleaf in ecx) and writes all four of eax, ebx, ecx, edx. Leaf 0 returns the vendor string in ebx, edx, ecx; leaf 1 and leaf 7 return feature bits. In real code use <cpuid.h> (__get_cpuid_count) or __builtin_cpu_supports("avx2").
  • rdtsc counts reference cycles at a constant rate on modern CPUs — a timestamp, not a cycle counter for your core's current frequency. It is not ordered with surrounding instructions: rdtscp waits for earlier instructions to finish, and lfence; rdtsc is the classic serialising pair. For benchmarking, prefer std::chrono::steady_clock (which uses the TSC through the vDSO anyway).
Leaf 0's vendor string, spread over three registers
The 12 bytes come back in the odd order ebx, edx, ecx, each little-endian: copying them in that order spells the vendor (GenuineIntel here; AuthenticAMD on AMD).
Leaf 0's vendor string, spread over three registers
The 12 bytes come back in the odd order ebx, edx, ecx, each little-endian: copying them in that order spells the vendor (GenuineIntel here; AuthenticAMD on AMD).
C++
#include <cpuid.h>
#include <cstdio>
#include <cstring>

int main() {
    unsigned a, b, c, d;
    __cpuid_count(0, 0, a, b, c, d);
    char vendor[13];
    std::memcpy(vendor + 0, &b, 4);
    std::memcpy(vendor + 4, &d, 4);
    std::memcpy(vendor + 8, &c, 4);
    vendor[12] = '\0';
    std::printf("%s, max leaf %u, avx2: %d\n", vendor, a, __builtin_cpu_supports("avx2"));
}

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Focus on the inline-assembly input/output/clobber explanation and preventing benchmark work from disappearing. The lesson distinguishes compiler barriers from CPU fences and uses the GCC reference for exact constraint rules.