Hello again. Last time, you separated the machine’s fixed-width bit patterns from C++’s language rules: the CPU can perform the same bit-level addition for signed and unsigned values, but C++ gives those operations different semantics. That distinction matters immediately when reading assembly: x86 instructions mostly operate on untyped bit patterns, while the compiler chooses signed or unsigned interpretations through instructions such as conditional jumps.
This lesson makes the C++ to machine-code boundary concrete. You will learn to read a short x86-64 assembly listing generated in WSL, identify where arguments and return values live, and map copies, arithmetic, array access, comparisons, branches, and loop control back to their source-level roles. The goal is not to memorize every x86 instruction. It is to build a repeatable tracing method.
The right mental model: preserve behavior, not source syntax
A compiler does not translate C++ line-by-line into a fixed assembly template. It must preserve the behavior required by the language and ABI, while being free to:
- keep a local variable in a register instead of memory;
- replace an index with a moving pointer;
- move a condition check to the bottom of a loop;
- eliminate calculations whose results cannot be observed;
- use an instruction such as
leafor ordinary arithmetic; - remove an entire function through inlining when optimization permits it.
So the useful question is not, “Which assembly line corresponds to this C++ line?” Instead ask:
What machine-level state represents each source-level value, and how does control move between the source-level regions?
At -O0, generated code often follows the source structure visibly, but has extra stack loads and stores that exist mainly to support debugging. At -O1 or -O2, the code is shorter and usually closer to the processor’s natural model: arguments and locals in registers, pointer increments for array traversal, and direct jumps for control flow.
Watch this brief introduction to Compiler Explorer (part 1 of 2) by Matt Godbolt. It demonstrates the source-to-assembly highlighting idea and shows why changing optimization levels changes the generated listing.
Compiler Explorer (part 1 of 2)
Watch this to see how a compiler view relates highlighted C++ regions to their generated assembly, including a small array-summation example.
Watch the interface tour to see source and assembly shown together and how optimization affects the listing. Then watch the array sum; focus on the distinction between loop setup, the body, and the condition check rather than trying to decode every instruction.
The compact x86-64 vocabulary you need
Your WSL environment on a typical 64-bit Linux installation uses the System V AMD64 ABI. An ABI is a binary-level agreement: independently compiled code can call each other because they agree on where arguments, return values, and preserved state go.
For a simple function with int parameters:
extern "C" int add(int left, int right) {
return left + right;
}
the usual convention is:
| C++ role | Register on x86-64 Linux |
|---|---|
| First integer or pointer argument | %rdi / %edi |
| Second integer or pointer argument | %rsi / %esi |
| Return value | %rax / %eax |
The r registers are 64-bit; their e forms select the lower 32 bits. Since int is normally 32 bits in your WSL GCC environment, %edi, %esi, and %eax are natural for this example.
A typical optimized implementation might be:
add:
leal (%rdi,%rsi), %eax
ret
Read it as:
- The caller has placed
leftin%ediandrightin%esi. lealcomputes their sum and writes it to%eax.%eaxis the return-value register, so the result is ready for the caller.retresumes execution in the caller.
Here lea means load effective address, but it does not necessarily access memory. Its addressing syntax can calculate a simple expression efficiently. The expression (%rdi,%rsi) denotes the numerical sum of the two register values in this use.
One important syntactic detail: GCC’s assembly output on Linux normally uses AT&T syntax.
addl %edx, %eax
means:
eax += edx;
The source operand comes first and destination comes second. This is the reverse of the Intel syntax often shown in online tutorials or documentation.
Use the following parts of Stanford CS107’s Guide to x86-64 as a reference while you work through the listings below.
Read Stanford CS107’s compact reference for the register roles, the instructions that occur repeatedly in compiler output, and the compare-and-branch pattern behind C++ control flow.
In the Registers section, read the register overview, concentrating on %rdi, %rsi, %rax, %rsp, and their 32-bit forms. Then, in Common instructions, read mov and lea. Retain the distinction: mov copies a value; lea computes an address-shaped arithmetic expression without dereferencing it. Finally, in Branches and other use of condition codes, read the branch pattern, then use the nearby instruction table to contrast signed jumps such as jle with unsigned jumps such as jbe.
Instructions as state changes
Most instructions in the listings you will inspect fit into five categories:
| Assembly form | Meaning in a trace | Likely C++ origin |
|---|---|---|
movl source, dest | Copy a 32-bit value | assignment, load, argument/return movement |
addl source, dest | Add into destination | total += value, x += y |
leaq expression, dest | Compute an address or simple arithmetic expression | pointer arithmetic, array + n, sometimes addition/scaling |
testl x, x | Set flags based on whether x is zero or negative; does not store a result | x > 0, x == 0, loop guard |
cmpq right, left followed by j... | Compare values and branch according to flags | if, while, for condition |
The suffix conveys width:
b: 1 bytew: 2 bytesl: 4 bytes, often used forintq: 8 bytes, often used for pointers andlongon Linux
A 32-bit instruction such as movl or addl works on the lower 32 bits of a register. On x86-64, writing a 32-bit register also clears the upper 32 bits of the full 64-bit register. This is a hardware rule, not an additional visible instruction.
Mapping an array access
Start with a compact C++ function:
extern "C" int element_at(const int* values, int index) {
return values[index];
}
A representative optimized x86-64 listing is:
element_at:
movslq %esi, %rsi
movl (%rdi,%rsi,4), %eax
ret
The first source-level mapping is determined by the calling convention:
values resides in %rdi
index resides in %esi
result must be placed in %eax
Now trace each instruction.
1. movslq %esi, %rsi
movslq copies a 32-bit value into a 64-bit register while sign-extending it. It turns the int index in %esi into a 64-bit signed value in %rsi.
This occurs because x86-64 addresses and pointer arithmetic use 64-bit registers. A negative int remains negative after sign extension. In well-defined C++, an invalid negative array index should never reach a valid dereference, but the compiler still has to use the correct representation for the expression it is implementing.
2. movl (%rdi,%rsi,4), %eax
The parenthesized expression is a memory address:
It maps naturally to:
values[index]
Why the scale factor 4? values points to int, and each int occupies 4 bytes on the target platform. The processor computes the scaled address and loads the 4-byte value from memory into %eax.
This is the direct machine-level form of C++ pointer arithmetic:
*(values + index)
3. ret
The loaded value is already in %eax, the conventional return register. No named machine-level result variable is needed.
The important lesson is that the compiler may represent values[index] using a base register, an index register, and a scale factor all within one memory operand. Parentheses in AT&T syntax mean “use this calculation as an address and dereference it.”
Mapping a loop with a branch
Now consider a function with array traversal, a branch, and an accumulator:
extern "C" int sum_positive(const int* values, int n) {
int total = 0;
for (int i = 0; i < n; ++i) {
if (values[i] > 0) {
total += values[i];
}
}
return total;
}
The following is a representative -O1 x86-64 listing. Your GCC version may choose different labels, registers, or loop shape; the meaningful relationships remain the same.
sum_positive:
testl %esi, %esi
jle .Lempty
movslq %esi, %rsi
leaq (%rdi,%rsi,4), %rcx
xorl %eax, %eax
.Lloop:
movl (%rdi), %edx
testl %edx, %edx
jle .Lskip
addl %edx, %eax
.Lskip:
addq $4, %rdi
cmpq %rcx, %rdi
jne .Lloop
ret
.Lempty:
xorl %eax, %eax
ret
Do not read labels such as .Lloop and .Lskip as special CPU features. They are names emitted by the assembler for positions in code. A jump transfers execution to the instruction at that label.
Initial guard: handling n <= 0
testl %esi, %esi
jle .Lempty
At function entry, %esi holds n.
testl %esi, %esi computes a bitwise AND of n with itself, discards the result, and updates the condition flags. It is a compact way to check whether a value is zero or has its signed sign bit set.
jle means jump if less than or equal, using a signed interpretation. Thus, this pair implements the case in which the loop body must not run:
if (n <= 0) {
return 0;
}
That interpretation is signed because n has type int. This connects directly to the previous lesson: the bits alone do not tell the CPU whether they are signed. The compiler chooses a signed conditional jump, such as jle, jl, or jg, when implementing signed comparisons. For an unsigned comparison, it would use instructions such as jbe, jb, or ja.
Establishing an end pointer
movslq %esi, %rsi
leaq (%rdi,%rsi,4), %rcx
The first line sign-extends n to a 64-bit value. The second computes:
So %rcx is an end pointer: the address immediately after the final array element.
At the C++ level, this is conceptually equivalent to:
const int* end = values + n;
The source uses an integer index i, but the compiler has chosen a different, equivalent representation: a moving pointer in %rdi and a fixed end pointer in %rcx. This is a central example of why assembly should be traced by values and invariants, rather than matched mechanically to source variable names.
Initializing total
xorl %eax, %eax
XORing a register with itself yields zero:
Therefore, %eax becomes zero and serves as total. Compilers frequently use xor rather than movl $0, %eax; both establish the same source-level value here.
Because %eax is also the integer return register, the accumulator is already in the right place for the final return total.
Loading the current element
.Lloop:
movl (%rdi), %edx
At the first loop iteration, %rdi still holds values, the address of element zero. On later iterations, it holds the current element’s address.
The instruction dereferences that pointer and copies the current int into %edx:
int current = *current_pointer;
The source writes values[i] twice: once in the condition and once in total += values[i]. This assembly performs only one load and reuses the loaded value in %edx. That is a small optimization with the same behavior for ordinary int arrays.
Implementing the if
testl %edx, %edx
jle .Lskip
addl %edx, %eax
This three-instruction region corresponds to:
if (current > 0) {
total += current;
}
The key reversal is worth noticing. The source says what should happen when the condition is true, but the compiler may emit a jump for the false case:
- Test whether
currentis zero or negative. - If it is, jump to
.Lskip. - Otherwise, fall through to the addition.
The branch skips over the source-level body when current <= 0. Falling through is the positive case.
addl %edx, %eax means:
total += current;
The compiler uses ordinary 32-bit addition. It does not emit a “signed add” or an “unsigned add” variant. If the mathematical total cannot fit in an int, the source program has signed-overflow undefined behavior, which you examined in the previous lesson. Correct code must ensure the accumulated value remains representable or select a suitable wider representation.
Advancing and repeating
.Lskip:
addq $4, %rdi
cmpq %rcx, %rdi
jne .Lloop
addq $4, %rdi advances the pointer by 4 bytes, which is exactly one int element:
++current_pointer;
cmpq %rcx, %rdi compares the current pointer to the end pointer. In AT&T syntax, you can reason about it as setting flags for the subtraction:
The result is discarded; only the equality status matters here.
jne .Lloop means “jump if not equal.” If the current pointer has not reached the end pointer, control returns to the loop body. Otherwise execution falls through to ret.
This is equivalent to the loop invariant:
- Before each
.Lloopiteration,%rdipoints to the next unprocessed element. %eaxcontains the sum of all positive elements before%rdi.%rcxremains the one-past-the-end address.
That invariant is a powerful bridge between your algorithmic reasoning and assembly tracing. It tells you what each register means throughout the loop, even though the compiler never labels it total or i.

The displayed translation is useful for recognizing the loop skeleton: establish a counter, test it at a label, execute or skip work, update the counter, and jump back. But inspect it critically as well. The C++ total is uninitialized, and total += i would read that indeterminate value if executed, making the C++ program invalid. Moreover, the visible assembly retains only the counter logic because the computed total has no observable use. Generated assembly reflects the behavior the compiler is required to preserve, including freedom to discard dead work; it is not a literal transcript of every source statement.
A repeatable tracing workflow
When you encounter a short listing, work in this order.
1. Identify the compilation context
Before interpreting a listing, establish:
- target architecture, such as x86-64;
- compiler and version;
- optimization level;
- assembly syntax, AT&T or Intel;
- platform ABI.
The same source compiled by GCC -O0 versus -O2, or for Linux versus Windows, can have substantially different output.
For this course, the default model is GCC on 64-bit Linux inside WSL, using System V AMD64 conventions and usually AT&T syntax.
2. Mark function boundaries and ABI values
At the top of a function, note each incoming parameter register. For the sum_positive example:
%rdi: values
%esi: n
%eax: eventual return value and total
Do this before decoding individual instructions. It gives the registers roles.
3. Divide the listing into basic blocks
A basic block is a straight-line group of instructions: once execution enters it, there is no jump away until its end.
For the loop example, the natural blocks are:
- entry guard;
- loop body;
- skip/addition-avoidance path;
- return-zero case.
Labels and jumps show the boundaries. Trace the control paths between these blocks, then interpret the operations within each one.
4. Translate instructions into simple state updates
Avoid translating directly into polished C++. Use a temporary, deliberately low-level notation:
%edx = *(int*)%rdi
if (%edx <= 0) goto .Lskip
%eax += %edx
%rdi += 4
if (%rdi != %rcx) goto .Lloop
return %eax
Only after that should you reconstruct readable C++.
5. Check widths, signedness, and dereferencing
Three details prevent many errors:
lversusqdistinguishes 4-byteintarithmetic from 8-byte pointer arithmetic.- Parentheses mean memory access:
(%rdi)loads from the address in%rdi;%rdialone is the address value. - Jumps choose the interpretation of comparisons.
jlandjleare signed;jbandjbeare unsigned.
WSL lab: generate, annotate, and map your own listing
Create trace.cpp:
extern "C" int sum_positive(const int* values, int n) {
int total = 0;
for (int i = 0; i < n; ++i) {
if (values[i] > 0) {
total += values[i];
}
}
return total;
}
Compile to an assembly file rather than an executable:
g++ -std=c++20 -O1 -S -fverbose-asm \
-fno-asynchronous-unwind-tables \
trace.cpp -o trace.s
The important flags are:
-S: stop after producing assembly;-O1: enable modest optimization, enough to expose register allocation and loop restructuring;-fverbose-asm: ask GCC to add source-oriented comments where it can.
GCC documents that -fverbose-asm adds source-line comments and hints about the high-level expressions associated with operands.
Code Gen Options (Using the GNU Compiler Collection (GCC))
Read GCC’s description of -fverbose-asm before inspecting your file. It explains why the comments are useful for tracing, while also making clear that they are explanatory hints rather than a stable machine-readable format.
Read the -fverbose-asm entry, beginning with its purpose. Then examine the example and limitations. In the example, notice how source lines and operand hints sit beside the instructions; compare that style with your trace.s.
Open trace.s and focus on the function beginning at sum_positive:. Ignore assembler directives beginning with a period, such as .text, .globl, or .size; they describe the assembly file to the assembler and linker rather than the runtime logic of the function.
Make a compact annotation table alongside the listing:
| Listing region | Register meaning | C++ responsibility |
|---|---|---|
| Entry | %rdi is array base; %esi is length | receive arguments; reject nonpositive n |
| Setup | %eax is zero; another register may hold end address | initialize total; establish loop boundary |
| Loop body | one register holds current element | load, test, conditionally add |
| Loop footer | pointer or index changes | advance to the next element and repeat |
| Return | %eax holds final sum | return total |
Your exact listing may not use %rcx as the end pointer, or it may use an integer index and scaled addressing instead. The checkpoint is semantic: you should be able to state which register is the accumulator, what location represents the current array element, and which jump either repeats or exits the loop.
Finally, compile the same file at -O0:
g++ -std=c++20 -O0 -S -fverbose-asm \
-fno-asynchronous-unwind-tables \
trace.cpp -o trace_O0.s
Compare only these questions:
- Are parameters stored to stack slots and loaded back at
-O0? - Does the lower-optimization version keep a visible loop index?
- Which listing is easier to relate to the source, and which has fewer unnecessary loads and stores?
Do not judge the better version by line count alone. -O0 is designed for straightforward debugging behavior, while optimized code is designed to execute efficiently under the C++ rules.
Takeaways
- Assembly mapping is about values, memory locations, and control flow, not a one-to-one correspondence with source lines.
- On x86-64 Linux, the first two integer or pointer arguments normally arrive in
%rdiand%rsi; anintreturn value is placed in%eax. - In GCC’s AT&T syntax, the source operand comes first:
addl %edx, %eaxmeans%eax += %edx. movcopies,leacomputes an address-shaped arithmetic expression, and parenthesized operands dereference memory.testandcmpset condition flags; conditional jumps turn those flags intoifstatements and loop control.- Signed and unsigned source comparisons use different jump families even though the registers contain only bits.
- Optimizers can replace an index-based loop with a pointer-and-end-pointer loop while preserving the same source-level behavior.
Next, you will shift from static inspection to measurement: two array traversal patterns can be algorithmically equivalent yet perform very differently because of cache locality.
Can't find a good explanation? Sign up and we'll make it for you
Sign up