The C Path — learn C, visually

⚙️ Compiler & Toolchain Mastery

Inline assembly: when C must step aside

⏱ 16 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

How do benchmarking tools time code more precisely than any stopwatch? They ask the CPU itself how many ticks have passed — using an instruction that C has no words for. Today you'll learn to smuggle raw CPU instructions into ordinary C, and as a finale you'll print text by talking straight to the operating system, with zero help from any library.

Sometimes you need an instruction C simply cannot express: read the CPU's cycle counter (the chip's own tick-counting clock), ask the processor what features it supports, or call the operating system directly with no library in between. GCC's extended asm lets you drop assembly into a C function — with the compiler still deciding which registers your values live in. Honest advice up front: you will need this almost never, and intrinsics — friendlier function-shaped versions of special instructions, covered below — are usually saner. But understanding it beats blindly pasting snippets from forums, and it teaches you how the compiler really thinks.

Anatomy: four sections, separated by colons

This spot has an interactive flow widget — open the interactive lesson to play with it.

asmadd.c
#include <stdio.h>

int main(void) {
    int a = 40, b = 2, sum;
    __asm__ ("lea (%1,%2), %0"     /* sum = a + b, the scenic way */
             : "=r"(sum)           /* output: any register, write-only */
             : "r"(a), "r"(b));    /* inputs: any registers            */
    printf("%d\n", sum);
    return 0;
}
terminal
$ gcc asmadd.c -o asmadd && ./asmadd
42
$ gcc -S -O2 asmadd.c && grep -A1 lea asmadd.s
        lea (%eax,%edx), %ecx
# GCC chose eax, edx, ecx for %1, %2, %0 — our template, its registers.

Read it like a contract with the compiler: "here's a template; put these C values in registers for me; I'll leave results in those; and by the way, I also trash these." The %0, %1, %2 in the template are the operands, numbered in the order listed — outputs first. GCC picks the actual registers, then pastes the template into its own output. It never parses your assembly — it trusts the contract completely, which is exactly why lying in the contract causes spectacular bugs.

The constraint mini-language

constraintmeaning
rany general-purpose register
ma memory operand (the variable's address)
ian immediate (compile-time constant)
a d S Dspecifically rax, rdx, rsi, rdi (x86)
= prefixwrite-only output
+ prefixread and written
"cc" / "memory" clobbers"I modify the flags" / "I read-write memory you don't see"

🧠 Checkpoint: In an output constraint, what is the difference between "=r" and "+r"?

  • = is x86, + is ARM
  • = means write-only; + means the asm both reads and writes the operand
  • + allows two registers
  • = makes the operand const
Show answer

= means write-only; + means the asm both reads and writes the operand — With "=r" GCC assumes the old value is irrelevant and may hand you a register full of garbage to overwrite. If your asm reads the value first (like "add %1, %0" does with %0), you must say "+r" — or the bug only shows up at -O2.

Real example: reading the timestamp counter

x86's rdtsc returns a 64-bit cycle count split across edx:eax — those fixed registers are why we need the a and d constraints:

rdtsc.c
#include <stdio.h>
#include <stdint.h>

static uint64_t rdtsc(void) {
    uint32_t lo, hi;
    __asm__ __volatile__ ("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi << 32) | lo;
}

int main(void) {
    uint64_t t0 = rdtsc();
    for (volatile int i = 0; i < 1000; i++)
        ;
    uint64_t t1 = rdtsc();
    printf("~%llu cycles for 1000 empty iterations\n",
           (unsigned long long)(t1 - t0));
    return 0;
}
terminal
$ gcc -O2 rdtsc.c -o rdtsc && ./rdtsc
~3708 cycles for 1000 empty iterations
$ ./rdtsc
~3652 cycles for 1000 empty iterations
# varies run to run — you are watching the actual silicon clock.

volatile, or: the optimizer versus your asm

GCC treats an asm statement like any expression: if its outputs are unused, it deletes it; if inputs are unchanged inside a loop, it hoists it out. That's catastrophic for rdtsc (same "time" every iteration!) or an I/O port write with no outputs at all. asm volatile means "this has side effects you can't see — execute it exactly as written, every time". Rule of thumb: reading a value that changes behind the compiler's back, or causing any side effect → volatile. Pure computation like our lea add → leave it off and let the optimizer schedule it.

🤔 Remove __volatile__ from the rdtsc() function and compile with -O2. What can go wrong?

Think first

GCC sees two calls to a "pure-looking" asm with identical (empty) inputs — so it may compute it once and reuse the value, or reorder it across the loop. Result: t1 - t0 == 0, or a measurement of nothing. volatile pins each execution in place. (For serious benchmarking you’d also add barriers, since the CPU itself reorders — rdtscp or lfence — but that’s a performance-engineering rabbit hole.)

🧠 Checkpoint: An asm statement with no outputs and no volatile, compiled at -O2, will typically be…

  • executed once at program start
  • deleted entirely as dead code
  • a compile error
  • run in a separate thread
Show answer

deleted entirely as dead code — No outputs used + not volatile = no observable effect, as far as GCC knows — so the optimizer removes it, exactly as it would remove an unused variable. Any asm executed purely for its side effects must be volatile.

Full thrill: a raw Linux syscall

Let's call write(1, msg, len) with no libc at all. The Linux x86-64 ABI: syscall number in rax (1 = write), args in rdi, rsi, rdx; the syscall instruction itself clobbers rcx and r11:

rawwrite.c
int main(void) {
    const char msg[] = "hello from a raw syscall\n";
    long ret;

    /* write(fd=1, buf=msg, count=sizeof msg - 1) */
    __asm__ __volatile__ (
        "syscall"
        : "=a"(ret)                       /* return value in rax  */
        : "a"(1),                         /* rax = 1  -> write    */
          "D"(1),                         /* rdi = fd 1 (stdout)  */
          "S"(msg),                       /* rsi = buffer         */
          "d"(sizeof msg - 1)             /* rdx = length         */
        : "rcx", "r11", "memory");        /* syscall trashes these */

    return ret == (long)(sizeof msg - 1) ? 0 : 1;
}
terminal
$ gcc -O2 rawwrite.c -o rawwrite && ./rawwrite
hello from a raw syscall
$ echo $?
0
$ strace ./rawwrite 2>&1 | grep ^write
write(1, "hello from a raw syscall\n", 25) = 25
# strace confirms: one write syscall, no printf, no libc buffering.

The "memory" clobber is essential: it tells GCC the kernel reads the buffer, so the stores initializing msg must actually happen before the syscall instead of being reordered or dead-store-eliminated.

🧠 Checkpoint: Why must the raw-syscall example list "memory" in its clobbers?

  • syscalls always allocate memory
  • It tells GCC the asm reads/writes memory it can’t see, so pending stores to msg must be flushed first
  • It reserves stack space for the kernel
  • Without it the program cannot link
Show answer

It tells GCC the asm reads/writes memory it can’t see, so pending stores to msg must be flushed first — GCC only knows the asm touches the listed operands. The kernel reads the buffer through a pointer, which GCC can’t see from the template — "memory" forces all memory writes to complete before the asm and stops caching across it.

The saner alternative: intrinsics

For 95% of "I need this one instruction" cases, GCC and Clang ship intrinsics — plain C functions that compile to the instruction, with the compiler fully aware of their semantics (so optimization stays safe, no constraint contracts to get wrong):

intrinsics.c — the civilized route
#include <stdio.h>
#include <x86intrin.h>       /* pulls in immintrin.h + friends */

int main(void) {
    unsigned long long t0 = __rdtsc();      /* our whole rdtsc()  */
    int bits = _popcnt32(0x00FF00F0);       /* one popcnt insn    */
    unsigned long long t1 = __rdtsc();

    printf("popcount = %d (measured in %llu cycles)\n",
           bits, t1 - t0);
    return 0;
}

<immintrin.h> is the umbrella header for the whole x86 SIMD universe (SSE/AVX). If you find yourself writing more than a handful of asm lines, you almost certainly want intrinsics — or to just check godbolt and discover the compiler already emits what you wanted.

💀

The classic inline-asm bug: forgetting a clobber. If your asm modifies a register you didn't declare, GCC may keep a live value there — and your function corrupts a random variable, but only at -O2, only sometimes. Undeclared side effects are UB by contract. When in doubt: more clobbers, or use an intrinsic.

Practice

This spot has an interactive editor widget — open the interactive lesson to play with it.

You can now out-argue the compiler instruction by instruction — next we zoom back out and automate whole builds with make.

▶ Practice this lesson interactively (with live gcc)