C++ Inline Assembly: GCC Extended asm, Constraints and Clobbers, and Why Intrinsics Usually Win

Key takeaways

Inline assembly (asm) lets you embed assembly inside C++ to use architecture-specific instructions. This article covers GCC/Clang AT&T syntax vs MSVC Intel syntax, constraints, and alternatives—with examples.

Basic syntax

Inline assembly is usually considered only when you hit a performance bottleneck or need special instructions. This article lists examples so you can learn compiler-specific syntax differences and recognize where portability is easily lost.

The most important thing to understand about GCC/Clang extended asm is that the compiler does not read your assembly. It treats the string as an opaque block and relies entirely on the constraints you write after the colons: which values go in, which come out, which registers and memory are overwritten. The optimizer then schedules, duplicates, moves, or deletes the block based on that description alone. If the description is incomplete, for example a register you modify is not listed, the compiler may keep a live value in that register, and your code corrupts it. Such bugs often appear only at -O2, only in some inlining contexts, or only after an unrelated compiler upgrade. That fragility is the main reason modern code uses intrinsics instead, and why inline asm should be the last resort rather than an optimization technique.

GCC/Clang (AT&T syntax)

Example main implementation:

int main() {
    int x = 10;
    int y = 20;
    int result;
    
    asm("addl %1, %0"
        : "=r" (result)  // output
        : "r" (x), "0" (y)  // input
    );
    
    cout << result << endl;  // 30
}

MSVC (Intel syntax)

Example main implementation:

int main() {
    int x = 10;
    int y = 20;
    int result;
    
    __asm {
        mov eax, x
        add eax, y
        mov result, eax
    }
    
    cout << result << endl;  // 30
}

This MSVC syntax only works for 32-bit x86 targets. The Microsoft compiler does not support inline assembly at all when building for x64 or ARM64, so this block fails to compile in any modern 64-bit Windows build. On x64, the options are intrinsics (<intrin.h> provides __cpuid, __rdtsc, _InterlockedIncrement, and many more) or a separate .asm file assembled with MASM and linked in. Code that must build with both MSVC and GCC/Clang therefore almost always ends up with intrinsics behind a small #ifdef, because there is no inline syntax that both accept.

Register constraints

Example add implementation:

int add(int a, int b) {
    int result;
    
    asm("addl %2, %0"
        : "=r" (result)      // output: any register
        : "0" (a), "r" (b)   // input
    );
    
    return result;
}

// Constraint letters:
// r: general-purpose register
// a: %eax/%rax
// b: %ebx/%rbx
// c: %ecx/%rcx
// d: %edx/%rdx
// m: memory
// i: immediate

The template "addl %2, %0" refers to operands by position: %0 is the output result, %1 is a, %2 is b. The input constraint "0" means “put this input in the same location as operand 0”, which is how you express an instruction like add that reads and writes the same register. The modern way to write the same thing is a read-write operand, "+r"(result), after initializing result = a.

Two constraint modifiers prevent the most common silent bugs. =&r (an early-clobber output) tells the compiler the output is written before all inputs are read, so it must not share a register with any input. Without it, the compiler may legitimately assign the same register to an input and the output, and a multi-instruction sequence reads a value it already overwrote. And a "cc" clobber declares that the flags register changes. On x86, GCC assumes this anyway, but other architectures need it.

CPUID, atomics, rdtsc, and memory barriers in asm

Querying CPUID

#include <iostream>
using namespace std;

void cpuid(int code, int* a, int* b, int* c, int* d) {
    asm volatile("cpuid"
        : "=a"(*a), "=b"(*b), "=c"(*c), "=d"(*d)
        : "a"(code)
    );
}

int main() {
    int a, b, c, d;
    cpuid(0, &a, &b, &c, &d);
    
    char vendor[13];
    *(int*)(vendor) = b;
    *(int*)(vendor + 4) = d;
    *(int*)(vendor + 8) = c;
    vendor[12] = '\0';
    
    cout << "CPU: " << vendor << endl;
}

This works for leaf 0, but it has two problems in real use. Many CPUID leaves also take a subleaf in ecx, so leaving ecx uninitialized returns garbage for leaves like 7 (extended feature flags). And on 32-bit x86 builds with position-independent code, ebx is reserved as the GOT pointer, and older GCC versions refused the "=b" constraint. Both compilers ship a header that handles all of this: GCC/Clang’s <cpuid.h> provides __get_cpuid(leaf, &a, &b, &c, &d) and __get_cpuid_count for subleaves (returning false if the leaf is unsupported), and MSVC provides __cpuid/__cpuidex in <intrin.h>. For the common case of checking features at runtime, GCC and Clang also offer __builtin_cpu_supports("avx2").

An atomic increment with lock xadd

int atomicIncrement(int* ptr) {
    int result;
    
    asm volatile(
        "lock; xaddl %0, %1"
        : "=r" (result), "+m" (*ptr)
        : "0" (1)
        : "memory"
    );
    
    return result;
}

int main() {
    int counter = 0;
    
    for (int i = 0; i < 10; i++) {
        atomicIncrement(&counter);
    }
    
    cout << counter << endl;  // 10
}

This is a correct lock xadd, and it is also exactly what std::atomic<int>::fetch_add(1) compiles to on x86. The standard version is portable to ARM (where the instruction sequence is completely different), is understood by the optimizer and by ThreadSanitizer, and states its memory ordering explicitly. There is no performance reason to hand-write atomics today, and doing it hides the synchronization from tools that would otherwise check it.

Reading the timestamp counter

uint64_t rdtsc() {
    uint32_t lo, hi;
    
    asm volatile("rdtsc"
        : "=a"(lo), "=d"(hi)
    );
    
    return ((uint64_t)hi << 32) | lo;
}

int main() {
    uint64_t start = rdtsc();
    
    // Code to measure
    for (int i = 0; i < 1000000; i++) {
        // ...
    }
    
    uint64_t end = rdtsc();
    
    cout << "Cycles: " << (end - start) << endl;
}

The numbers this prints are easy to misread. On modern x86 CPUs the time-stamp counter is invariant: it ticks at a constant reference frequency regardless of turbo boost or power saving, so it measures elapsed time, not core clock cycles. A 3 GHz TSC on a core boosting to 5 GHz counts 3 billion ticks per second, not 5. Second, rdtsc is not a serializing instruction: out-of-order execution can move it before or after the code you are timing. The usual fix is rdtscp (which waits for earlier instructions) or an lfence before the read. Third, the empty loop above is removed by the optimizer at -O2, so the measurement often shows almost nothing.

The intrinsics __rdtsc() and __rdtscp(&aux) (in <x86intrin.h> for GCC/Clang, <intrin.h> for MSVC) do the same job without inline asm. For most measurements, std::chrono::steady_clock is the better tool anyway (see steady_clock). Reach for the TSC only when you need the lowest possible overhead and understand its caveats, and for real cycle counts use hardware performance counters through perf.

Compiler barriers vs hardware fences

Example memoryBarrier implementation:

void memoryBarrier() {
    asm volatile("mfence" ::: "memory");
}

void compilerBarrier() {
    asm volatile("" ::: "memory");
}

// Usage
atomic<bool> ready(false);
int data = 0;

void producer() {
    data = 42;
    compilerBarrier();  // prevent reordering
    ready.store(true, memory_order_release);
}

The two barriers do different things. asm volatile("" ::: "memory") emits no instruction at all. It only stops the compiler from moving memory accesses across it, which is enough for some signal-handler and single-core embedded cases but provides no ordering guarantee between CPU cores. mfence is a real CPU instruction that orders all loads and stores, and on x86 it is rarely needed, because x86 already keeps most accesses in order. In the example, the compiler barrier is redundant: memory_order_release on the store already prevents both the compiler and the CPU from moving data = 42 after it. In portable code, express ordering with std::atomic operations or std::atomic_thread_fence, which compile to the correct instructions for each architecture.

volatile

Example main implementation:

int main() {
    int x = 10;
    
    // volatile: prevent optimization
    asm volatile("nop");  // not eliminated
    
    // Memory clobber
    asm volatile("" ::: "memory");  // prevent memory reordering
}

Platform differences

x86-64

// 64-bit registers
asm("movq %0, %%rax" : : "r"(value));

ARM

// ARM syntax
asm("mov r0, %0" : : "r"(value));

Cross-platform

#ifdef __x86_64__
    asm("rdtsc" : "=a"(lo), "=d"(hi));
#elif __aarch64__
    asm("mrs %0, cntvct_el0" : "=r"(cycles));
#else
    #error "Unsupported platform"
#endif

Clobbers, optimizer surprises, and portability

Undeclared register clobbers

C/C++ example:

// ❌ Register clobber
asm("movl $10, %eax");  // clobbers eax

// ✅ Specify clobbers
asm("movl $10, %%eax"
    :
    :
    : "%eax"  // eax is clobbered
);

The first line is basic asm (no colons). It uses single % signs, cannot declare clobbers, and GCC assumes it touches nothing the compiler cares about, so changing eax there corrupts whatever the compiler kept in it. Any asm that modifies registers or memory needs the extended form, where registers are written %%eax and the clobber list follows the third colon. Better still, let the compiler choose registers with constraints like "=r" and avoid naming fixed registers at all.

The optimizer deleting or hoisting your asm

// ⚠️ Extended asm with unused outputs may be deleted or moved
unsigned lo, hi;
asm("rdtsc" : "=a"(lo), "=d"(hi));   // if lo/hi are unused, the block can vanish

// ✅ volatile: keep it, and don't treat it as a pure function
asm volatile("rdtsc" : "=a"(lo), "=d"(hi));

GCC treats an extended asm statement with outputs as a pure computation of those outputs from the inputs. If the outputs are unused, it may delete the statement. If it appears in a loop with the same inputs, it may execute it only once. That is exactly wrong for instructions with side effects or changing results, such as reading a counter. volatile tells the compiler the statement has effects beyond its outputs. An asm statement with no outputs, including basic asm like asm("nop"), is already implicitly volatile. Note that volatile does not prevent the compiler from moving other code around the statement; that is what the "memory" clobber is for.

Code that only builds on one architecture

C/C++ example:

// ❌ x86-only
asm("rdtsc" : "=a"(lo), "=d"(hi));

// ✅ Conditional compilation
#ifdef __x86_64__
    asm("rdtsc" : "=a"(lo), "=d"(hi));
#else
    // Alternative implementation
#endif

Inline assembly vs intrinsics

C/C++ example:

// Inline assembly: add two ints
asm("addl %1, %0" : "=r"(result) : "r"(a), "0"(b));
// ...which the compiler would generate from plain C++ anyway:
result = a + b;

// Intrinsics: for instructions C++ cannot express directly
#include <immintrin.h>
__m128i va = _mm_set_epi32(4, 3, 2, 1);
__m128i vb = _mm_set1_epi32(10);
__m128i sum = _mm_add_epi32(va, vb);   // four 32-bit adds in one SSE2 instruction
unsigned long long ticks = __rdtsc();  // replaces the rdtsc asm block

Intrinsics are functions the compiler knows. _mm_add_epi32 takes __m128i vectors, not plain ints (passing an int is a compile error), and maps to one instruction, but the compiler still does register allocation, scheduling, and constant folding around it, and it can inline and vectorize surrounding code. With inline asm, the block is a black box that blocks those optimizations. That is why hand-written asm is often slower than the equivalent intrinsics, not faster.

Benefits of intrinsics:

  • Type-safe
  • Optimizable
  • More portable (compiler lowers to the right instructions)

When I see inline asm in a codebase today, it is usually one of three things: a CPUID or TSC read that predates the intrinsic headers, a hand-written atomic that predates std::atomic, or a genuinely missing instruction in a very specialized context (kernel code, a hypervisor, or a new instruction not yet exposed as an intrinsic). The first two can almost always be replaced, which removes code the optimizer cannot see and makes it build with MSVC on x64. The third is where inline asm still earns its place, and it should be small, isolated in one function, and covered by tests on every target.

Debugging inline asm

Example func implementation:

// Assembly output
void func() {
    int x = 10;
    int y = x * 2;
}

// Compile:
// g++ -S -O2 program.cpp
// Inspect program.s

FAQ

Q1: When should I use inline assembly?

A:

  • Extreme optimization
  • Direct hardware access
  • Special instructions (CPUID, RDTSC)

Q2: Intrinsics vs inline assembly?

A: Prefer intrinsics when possible—they are safer and more portable.

Q3: Is a performance win guaranteed?

A: No. The compiler’s optimizations may be better.

Q4: How do I keep things portable?

A:

  • Conditional compilation
  • Use intrinsics
  • Assembly as a last resort

Q5: How do I debug?

A:

Q6: Learning resources for inline assembly?

A:

  • GCC inline assembly documentation
  • Intel/AMD manuals
  • PC Assembly Language (Paul Carter)