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
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.
#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);
}+/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:
#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)); } // 32⁶³ × 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:
asm("addq %[b], %[s]" : [s] "=r"(sum) : "[s]"(a), [b] "rm"(b)); // s starts as aMissing 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:
#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)); } // 14one_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:
#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:
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
retstd::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:
#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:
- Plain C++ the compiler recognises:
(unsigned __int128)a * b >> 64is a singlemul;std::rotlisrol;std::popcountispopcnt. - Builtins:
__builtin_popcountll,__builtin_clzll/ctzll,__builtin_add_overflow,__builtin_bswap64,__builtin_expect,__builtin_unreachable(C++23:[[assume(…)]]),__builtin_assume_aligned. - Intrinsics from
<immintrin.h>/<x86intrin.h>:__rdtsc(),_addcarry_u64,_mulx_u64,_pext_u64, all of SSE/AVX. - 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:
#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
cpuidtakes a leaf ineax(and a subleaf inecx) and writes all four ofeax, ebx, ecx, edx. Leaf 0 returns the vendor string inebx, edx, ecx; leaf 1 and leaf 7 return feature bits. In real code use<cpuid.h>(__get_cpuid_count) or__builtin_cpu_supports("avx2").rdtsccounts 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:rdtscpwaits for earlier instructions to finish, andlfence; rdtscis the classic serialising pair. For benchmarking, preferstd::chrono::steady_clock(which uses the TSC through the vDSO anyway).
ebx, edx, ecx, each little-endian: copying them in that order spells the vendor (GenuineIntel here; AuthenticAMD on AMD).#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"));
}