⚙️ Compiler & Toolchain Mastery
Inline assembly: when C must step aside
▶ Open the interactive lesson — free, no signupHow 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.
#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;
}$ 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
| constraint | meaning |
|---|---|
r | any general-purpose register |
m | a memory operand (the variable's address) |
i | an immediate (compile-time constant) |
a d S D | specifically rax, rdx, rsi, rdi (x86) |
= prefix | write-only output |
+ prefix | read 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:
#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;
}$ 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:
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;
}$ 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
msgmust 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):
#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.