Inline Assembly
Reviewed & published by Brayan K
By the end of this lesson you'll be able to read a basic GCC/Clang inline-assembly statement, explain its output, input, and clobber operands, and — far more importantly — know why compiler intrinsics and the optimizer are almost always the better tool, plus the portability and safety traps that make hand-written asm a last resort.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Explain what inline assembly is and the rare cases that justify it
- Read GCC/Clang extended asm: asm volatile("..." : out : in : clobbers)
- Identify output, input, and clobber operands and what %0/%1 mean
- Use portable compiler intrinsics (popcount, ctz, SIMD) instead
- Understand SIMD and why -O3 auto-vectorization usually wins
- Avoid the big traps: clobbering, non-portability, breaking the optimizer, UB
💡 Real-World Analogy
Think of the compiler as a master translator who turns your C++ into flawless machine code and re-checks the whole document every time anything changes. Writing inline assembly is like grabbing the pen and scribbling a sentence in the target language yourself. Occasionally you know a word the translator's dictionary is missing — but the moment you write it, the translator stops re-checking that sentence. If you misspell a register or forget to declare what you changed, nobody catches it, and the error can hide until a different optimization level exposes it. Compiler intrinsics are the polite middle ground: you suggest the exact word, but the translator still owns the page — it stays portable and keeps getting proofread.
1. What Inline Assembly Is (and Isn't)
Inline assembly means writing raw CPU instructions directly inside a C++ function. On GCC and Clang you use the asm keyword; on MSVC the syntax differs again — already a portability headache. It exists for the handful of cases the language genuinely cannot express: a privileged instruction in an operating-system kernel, a brand-new CPU feature with no wrapper yet, or constant-time cryptographic code. For everything else, the compiler writes better assembly than you will. Study the worked example below — the asm is in comments, and the runnable C++ computes the identical answer.
#include <iostream>
using namespace std;
// Inline assembly = raw CPU instructions written INSIDE a C++ function.
// GCC and Clang spell it: asm volatile("..." : outputs : inputs : clobbers);
//
// Here is the SAME idea (add two numbers) written three ways, from
// highest level to lowest. Only the C++ versions run in this editor.
int main() {
int a = 25, b = 17;
// 1) Plain C++ — the optimizer turns this into one 'add' instruction.
int high = a + b; // 42
// 2) What the GCC/Clang inline-asm version WOULD look like (do not run):
// int low;
// asm ("addl %1, %0" // the instruction: add input into output
// : "=r"(low) // OUTPUT: '=r' = write result to a register
// : "r"(a), // INPUT 0: 'r' = put a in any register
// "0"(b)); // INPUT 1: '0' = reuse output register, seeded with b
// // low is now 42 — exactly what 'a + b' gave us, but by hand.
cout << "a + b = " << high << endl; // a + b = 42
cout << "The asm version computes the very same 42 -" << endl;
cout << "the compiler just does it for free, and portably." << endl;
return 0;
}
// ✅ Expected output:
// a + b = 42
// The asm version computes the very same 42 -
// the compiler just does it for free, and portably.2. Extended Asm Syntax: Outputs, Inputs & Clobbers
A GCC/Clang extended asm statement has four colon-separated parts: the instruction template, the output operands (what the asm writes), the input operands (what it reads), and the clobber list (registers and flags it trashes). Placeholders %0, %1, %2 in the template refer to those operands in order. The keyword asm volatile tells the optimizer "keep this exactly where it is — do not move or delete it". Read the anatomy below carefully; the operand model is the whole game.
#include <iostream>
using namespace std;
// Anatomy of a GCC/Clang EXTENDED asm statement (study, don't run):
//
// asm volatile ( "template"
// : output operands // things asm WRITES
// : input operands // things asm READS
// : clobbers ); // registers/flags asm TRASHES
//
// %0, %1, %2 ... in the template refer to operands in order.
//
// int x = 5, y;
// asm volatile ("movl %1, %0" // copy operand 1 into operand 0
// : "=r"(y) // %0 OUTPUT: '=' write-only, 'r' a register
// : "r"(x) // %1 INPUT : 'r' any register, holds x
// : ); // no extra clobbers
// // y == 5
//
// Constraint letters you'll see most:
// "r" = any general register "m" = a memory location
// "=" = written by the asm "+" = both read AND written
// "0" = same place as operand 0 "cc" = the condition-flags register
//
// 'volatile' = "keep this exactly here, never optimize it away".
int main() {
// Runnable mirror of the move above, in pure C++:
int x = 5;
int y = x; // the 'movl' copies x into y
cout << "y = " << y << endl; // y = 5
cout << "Operands map by position: %0=output, %1=input." << endl;
return 0;
}
// ✅ Expected output:
// y = 5
// Operands map by position: %0=output, %1=input.🔎 Deep Dive: reading the colons
The shape is always asm volatile("template" : outputs : inputs : clobbers). Each operand is a constraint string plus a C++ variable in parentheses. The constraint tells the compiler where the value can live and whether the asm reads it, writes it, or both.
int in = 10, out;
asm volatile ("incl %0" // increment operand 0
: "=r"(out) // OUTPUT %0: "=" write-only, "r" any register
: "0"(in) // INPUT %1: "0" reuse %0's register, seeded with in
: "cc"); // CLOBBER : "cc" = we changed the flags register
// out == 11
// Constraints you meet first:
// "r" any register "m" memory "i" immediate constant
// "=" written only "+" read AND written "0".."9" tie to that operand
// Clobbers: "memory" (we touched RAM) "cc" (we touched the flags)Get the clobber list right and the optimizer keeps working around your block safely. Get it wrong and it assumes a register or memory is untouched, reuses it, and your program corrupts data — usually only at higher optimization levels.
3. The Better Tool: Compiler Intrinsics
A compiler intrinsic looks like a normal function call but compiles down to a single CPU instruction. Examples: __builtin_popcount(x) counts set bits, __builtin_ctz(x) counts trailing zeros, and C++20's <bit> header gives portable std::popcount and std::countr_zero. The crucial difference from inline asm: the optimizer understands an intrinsic, so it can fold, reorder, and schedule it, and it stays portable across compilers. The example below uses a plain-C++ popcount so it runs here, with the real intrinsic shown in comments.
#include <iostream>
#include <cstdint>
using namespace std;
// Compiler intrinsics: function-shaped wrappers that compile to a
// single CPU instruction. They are the GOOD alternative to inline asm:
// portable, and the optimizer still understands them.
//
// GCC/Clang spell these __builtin_*; C++20 added portable versions in
// the <bit> header (std::popcount, std::countl_zero, ...).
// popcount = how many bits are set to 1. One CPU instruction on modern
// chips; here is a plain-C++ version so it runs anywhere.
int popcount(uint32_t x) {
int count = 0;
while (x) { count += x & 1; x >>= 1; } // add up the 1-bits
return count;
}
int main() {
uint32_t flags = 0b1011; // four bits, three of them set
cout << "value 0b1011 has " << popcount(flags)
<< " bits set" << endl; // 3 bits set
// A real program would simply call the intrinsic instead of looping:
// #include <bit>
// std::popcount(flags); // C++20, fully portable
// __builtin_popcount(flags); // GCC/Clang
// Both emit the hardware POPCNT instruction when the CPU has it.
// Power-of-two trick: a power of two has exactly ONE bit set.
for (int n : {1, 2, 6, 8, 16, 17}) {
bool isPow2 = (n > 0) && (popcount(n) == 1);
cout << n << (isPow2 ? " is a power of 2" : " is not") << endl;
}
return 0;
}
// ✅ Expected output:
// value 0b1011 has 3 bits set
// 1 is a power of 2
// 2 is a power of 2
// 6 is not
// 8 is a power of 2
// 16 is a power of 2
// 17 is notYour turn. The program below is almost complete — fill in the two blanks marked ___ using the hints in the comments, then run it and check the expected output.
#include <iostream>
#include <cstdint>
using namespace std;
// A real program would call std::popcount; this loop is its portable twin.
int popcount(uint32_t x) {
int count = 0;
while (x) { count += x & 1; x >>= 1; }
return count;
}
int main() {
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// 1) Store the 8-bit pattern 1010 1010 (alternating bits)
uint32_t pattern = ___; // 👉 use 0b10101010 (binary literal)
// 2) Count how many bits are set by CALLING popcount on pattern
int setBits = ___; // 👉 call popcount(pattern)
cout << "0b10101010 has " << setBits << " bits set" << endl;
// ✅ Expected output:
// 0b10101010 has 4 bits set
return 0;
}4. SIMD & Letting the Optimizer Win
SIMD (Single Instruction, Multiple Data) processes several values with one instruction — SSE does 4 floats at a time, AVX does 8. You can hand-write it with intrinsics like _mm256_add_ps from <immintrin.h>, but the easiest and most portable path is to write a plain loop and compile with -O3 -march=native: modern compilers auto-vectorize it into exactly those wide instructions. The runnable loop below is one the optimizer will happily turn into AVX for you.
#include <iostream>
#include <vector>
using namespace std;
// SIMD = Single Instruction, Multiple Data: one instruction works on
// several values at once. SSE handles 4 floats per step, AVX handles 8.
//
// Real SIMD looks like this (study, don't run — needs the right CPU):
// #include <immintrin.h> // AVX intrinsics
// __m256 va = _mm256_loadu_ps(a + i); // load 8 floats
// __m256 vb = _mm256_loadu_ps(b + i); // load 8 floats
// __m256 vc = _mm256_add_ps(va, vb); // add all 8 in ONE instruction
// _mm256_storeu_ps(out + i, vc); // store the 8 results
//
// This runnable version adds element-by-element. With -O3 the compiler
// AUTO-VECTORIZES this exact loop into the AVX code above for you.
void addArrays(const float* a, const float* b, float* out, int n) {
for (int i = 0; i < n; i++)
out[i] = a[i] + b[i]; // compiler may do 8 per step
}
int main() {
vector<float> a = {1, 2, 3, 4, 5, 6, 7, 8};
vector<float> b = {10, 20, 30, 40, 50, 60, 70, 80};
vector<float> out(8);
addArrays(a.data(), b.data(), out.data(), 8);
cout << "Vector add (one instruction per 8 under AVX):" << endl;
for (float v : out) cout << v << " "; // 11 22 33 44 55 66 77 88
cout << endl;
cout << "Build with -O3 -march=native to let the compiler vectorize." << endl;
return 0;
}
// ✅ Expected output:
// Vector add (one instruction per 8 under AVX):
// 11 22 33 44 55 66 77 88
// Build with -O3 -march=native to let the compiler vectorize.Pro Tips
- 💡 Profile before you reach for asm: measure with a profiler and read the compiler's output (-S or Compiler Explorer) first. Most "slow" code is fixed by a better algorithm, not assembly.
- 💡 Prefer the C++20 <bit> header: std::popcount, std::countl_zero, std::bit_ceil are portable, type-safe, and need no compiler-specific builtins.
- 💡 Let -O3 -march=native vectorize: a clean loop the auto-vectorizer can see usually beats hand-written intrinsics and is far easier to maintain.
- 💡 If you must write asm, always use volatile for side-effecting blocks and list every clobbered register, plus "memory" and "cc" when relevant.
Common Errors (and the fix)
- Clobbering registers you didn't declare: your asm writes rcx but you left it out of the clobber list. The compiler keeps using its old value and data corrupts — often only at -O2. Fix: list every changed register, plus "memory" and "cc" when you touch RAM or flags.
- Non-portable code: asm("addl %1, %0" ...) assembles on x86 but fails or means something else on ARM or with MSVC. Fix: gate asm behind architecture checks, or — much better — replace it with an intrinsic or plain C++ that every compiler supports.
- Breaking the optimizer: the compiler can't see inside an asm block, so it can't fold constants or reorder around it, and your "fast" hack ends up slower than the C++ it replaced. Fix: prefer intrinsics the optimizer understands; reserve asm for what truly can't be expressed otherwise.
- Undefined behaviour from a missing volatile or wrong constraint: without volatile the optimizer may delete a side-effecting block; a "=r" on a value you actually read is a lie to the compiler. Both are UB and may "work" until you change a flag. Fix: mark side-effecting asm volatile and use "+r" for read-and-write operands.
- "impossible constraint in 'asm'" / "operand number out of range": the template references a %2 you never supplied, or a constraint can't be satisfied. Fix: count your operands — %0 is the first output — and make every placeholder match a declared operand.
📋 Quick Reference
| Concept | Form | Means |
|---|---|---|
| Extended asm | asm volatile(t : out : in : clob) | four colon sections |
| Output operand | "=r"(x) | asm writes x (a register) |
| Input operand | "r"(x) | asm reads x |
| Read+write | "+r"(x) | asm reads and writes x |
| Clobbers | : "memory", "cc" | touched RAM / flags |
| Portable bit op | std::popcount(x) | C++20, no asm needed |
| Auto-vectorize | -O3 -march=native | compiler emits SIMD |
Mini-Challenge: Alignment Checker
No blanks this time — just a brief and a blank canvas (with an outline to keep you on track). Use the provided trailingZeros helper — the portable twin of the __builtin_ctz intrinsic — to decide whether an address is 16-byte aligned. Build it, run it, and check your output against the example in the comments.
#include <iostream>
#include <cstdint>
using namespace std;
// A portable stand-in for the intrinsic std::countr_zero / __builtin_ctz.
int trailingZeros(uint32_t x) {
if (x == 0) return 32;
int count = 0;
while (!(x & 1)) { count++; x >>= 1; } // count low 0-bits
return count;
}
int main() {
// 🎯 MINI-CHALLENGE: alignment checker
// Many fast routines need data aligned to 16 bytes. A value is
// 16-byte aligned when its lowest 4 bits are zero — i.e. it has
// at LEAST 4 trailing zero bits.
//
// 1. Make a uint32_t "address" (try 0x1000, then 0x1004).
// 2. Use trailingZeros(address) to get the trailing-zero count.
// 3. Print whether it is 16-byte aligned (count >= 4).
//
// ✅ Expected:
// 0x1000 -> 16-byte aligned (4096 has 12 trailing zeros)
// 0x1004 -> NOT 16-byte aligned (4100 has 2 trailing zeros)
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Inline assembly embeds raw CPU instructions; it's a last resort, not a default
- ✅ Extended asm has four parts: template : outputs : inputs : clobbers, with %0/%1 by position
- ✅ asm volatile pins the block in place; the clobber list keeps your promise to the optimizer
- ✅ Compiler intrinsics (__builtin_popcount, C++20 <bit>) give the same instruction, portably
- ✅ For SIMD, a clean loop plus -O3 -march=native auto-vectorizes and usually wins
- ✅ Top traps: clobbering, non-portability, breaking the optimizer, and undefined behaviour
Practice quiz
What is inline assembly?
- A faster C++ compiler
- A standard library header
- Raw CPU instructions written directly inside a C++ function
- A type of smart pointer
Answer: Raw CPU instructions written directly inside a C++ function. Inline assembly embeds raw, architecture-specific CPU instructions inside C++ code. On GCC/Clang you use the asm keyword.
How many colon-separated sections does a GCC/Clang extended asm statement have?
- Four: template, outputs, inputs, clobbers
- Two: template and outputs
- One: just the template
- Three: inputs, outputs, return
Answer: Four: template, outputs, inputs, clobbers. Extended asm is asm volatile("template" : outputs : inputs : clobbers) — four sections. %0, %1, ... refer to operands in order.
What does the 'volatile' in asm volatile do?
- Makes the asm run twice
- Marks the registers as 32-bit
- Allocates heap memory
- Tells the compiler not to delete or move the asm block, even if outputs look unused
Answer: Tells the compiler not to delete or move the asm block, even if outputs look unused. volatile pins the block in place so the optimizer won't assume it's pure and remove it — essential for side-effecting instructions.
What is the clobber list for?
- Listing input variables
- Naming every register, plus "memory" or "cc", that the asm modifies but didn't declare as an output
- Setting the optimisation level
- Declaring the return type
Answer: Naming every register, plus "memory" or "cc", that the asm modifies but didn't declare as an output. Clobbers keep your promise to the optimizer. Forget one and the compiler reuses that register believing its old value is valid, causing corruption.
What is a compiler intrinsic?
- A function-shaped call the compiler turns into a single CPU instruction, which the optimizer still understands
- A macro that expands to asm text
- A runtime library call
- A debugging tool
Answer: A function-shaped call the compiler turns into a single CPU instruction, which the optimizer still understands. An intrinsic like __builtin_popcount(x) compiles to one instruction but stays portable and visible to the optimizer — far safer than inline asm.
Which C++20 header provides portable bit operations like std::popcount and std::countr_zero?
- <bitset>
- <cstdint>
- <bit>
- <immintrin.h>
Answer: <bit>. C++20 added <bit> with portable std::popcount, std::countl_zero, std::countr_zero, and std::bit_ceil — no compiler-specific builtins needed.
What does popcount of the value 0b1011 return?
- 2
- 3
- 4
- 11
Answer: 3. popcount counts the bits set to 1. 0b1011 has three 1-bits, so the result is 3.
What does SIMD stand for?
- Simple Integer Memory Data
- Synchronous Inline Machine Directives
- Static Inline Method Dispatch
- Single Instruction, Multiple Data
Answer: Single Instruction, Multiple Data. SIMD = Single Instruction, Multiple Data: one instruction operates on several values at once (SSE does 4 floats, AVX does 8).
What is the easiest, most portable way to get SIMD speedups for a plain loop?
- Hand-write _mm256 intrinsics always
- Write a clean loop and compile with -O3 -march=native so the compiler auto-vectorizes
- Use std::vector only
- Add the 'register' keyword
Answer: Write a clean loop and compile with -O3 -march=native so the compiler auto-vectorizes. A clean loop the auto-vectorizer can see, built with -O3 -march=native, usually matches hand-written intrinsics and is far easier to maintain.
When is hand-written inline assembly actually justified?
- Whenever code feels slow
- For every hot loop
- Almost never — only for things the language can't express, after profiling proves a need
- To replace std::vector
Answer: Almost never — only for things the language can't express, after profiling proves a need. Inline asm is a last resort for privileged instructions, a CPU feature with no intrinsic, or constant-time crypto. Otherwise the compiler writes better asm.
Continue this course
- Previous: Understanding Undefined Behavior & How to Avoid It
- Next: C++ Networking (Sockets, Protocol Handling, Async I/O) — Build TCP/UDP servers and clients with POSIX sockets and asio
- Quick reference: C++ cheat sheet
Frequently asked questions
Why won't the inline assembly examples run in this editor?
Inline assembly is raw CPU instructions tied to one architecture (x86-64, ARM, and so on). The online compiler may run on a different CPU, and many sandboxes block asm entirely for safety. That is exactly why the asm examples here are shown as commented worked examples you study, while the runnable boxes use portable intrinsics or plain C++ that produce the same result everywhere.
When is inline assembly actually justified?
Almost never in normal application code. It is reserved for things the language cannot express: a specific privileged instruction in an OS kernel, a CPU feature with no intrinsic, constant-time cryptography, or a hot loop you have profiled and proven the compiler cannot match. If you cannot point to a profiler result and a missing intrinsic, you do not need it.
What is the difference between inline asm and a compiler intrinsic?
An intrinsic looks like a normal function call — __builtin_popcount(x) or _mm256_add_ps(a, b) — but the compiler turns it into the matching CPU instruction. The optimizer still sees through it, can reorder it, and keeps it portable across compilers. Inline asm is an opaque block the optimizer cannot understand, so intrinsics give you the same instruction with far fewer footguns.
What does asm volatile do, and why the volatile?
volatile tells the compiler 'do not delete or move this asm block even if its outputs look unused'. Without it the optimizer may assume the block is pure and remove it, which silently breaks side-effecting instructions. Use volatile whenever the asm reads or writes hardware, memory, or flags that the compiler cannot see.
What is a clobber list and what happens if I get it wrong?
The clobber list is the third colon section of an extended asm statement: it names every register, plus "memory" or "cc", that your instructions modify but did not declare as an output. If you forget one, the compiler still believes its old value is valid, reuses that register, and you get corruption that often only shows up under -O2. Clobbers are how you keep your promise to the optimizer.