the source
Five lines that could not be simpler. They are in C rather than C# for one reason: a C
compiler leaves a file on disk that objdump can read, while the JIT’s output exists only in
the memory of a running process. We will get the JIT to hand its version over at the end, and
the two turn out to be the same code.
int sum_to(int n)
{
int total = 0;
for (int i = 1; i <= n; i++)
total += i;
return total;
}
$ gcc -O0 -fno-stack-protector -c sumto.c -o sumto0.o
$ objdump -d --no-show-raw-insn sumto0.o
-O0 means “no optimisation”: it is the compiler transcribing your source, statement by
statement, which is exactly what you want the first time you read machine code.
--no-show-raw-insn drops the instruction bytes so the column of mnemonics lines up.
-fno-stack-protector needs a sentence, because the obvious guess about it is wrong here.
Ubuntu’s gcc defaults to -fstack-protector-strong, which stores a canary value in the frame
and checks it before returning — but only in functions that contain an array or a local whose
address is taken. sum_to has neither, so no canary is emitted with or without the flag: it
costs this function exactly zero instructions. What the flag does change is the frame layout.
Compile the same file both ways on this box and gcc puts total at a different offset with the
flag than without it, with i taking the other slot. The flag is passed here only to pin the
offsets the table below refers to.
before you read on
Sixteen instructions come out of those five lines. Guess two things first: how many of them touch memory, and how many are the loop body. Then check yourself against the table below — the gap between your guess and the answer is the thing this page is for.
what the machine got
Real output, unedited:
0000000000000000 <sum_to>:
0: endbr64
4: push %rbp
5: mov %rsp,%rbp
8: mov %edi,-0x14(%rbp)
b: movl $0x0,-0x4(%rbp)
12: movl $0x1,-0x8(%rbp)
19: jmp 25 <sum_to+0x25>
1b: mov -0x8(%rbp),%eax
1e: add %eax,-0x4(%rbp)
21: addl $0x1,-0x8(%rbp)
25: mov -0x8(%rbp),%eax
28: cmp -0x14(%rbp),%eax
2b: jle 1b <sum_to+0x1b>
2d: mov -0x4(%rbp),%eax
30: pop %rbp
31: ret
Two things to know before the table, because both trip people up permanently.
This is AT&T syntax: the destination is on the right. mov %edi,-0x14(%rbp) moves from
edi to the memory at rbp - 0x14. Intel syntax, which Microsoft’s tools and the .NET JIT
print, puts the destination first — the same instruction is mov [rbp-0x14], edi. Nothing
else differs; every operand pair is simply reversed.
Locals have no names, only offsets from rbp. The compiler assigned each variable a slot
in the frame and from here on that slot is the variable. The offsets count backwards from
the frame pointer because the stack grows downward, and they are written in hex for the reason
hex exists at all — byte boundaries are visible in it:
| slot | holds | why there |
|---|---|---|
-0x4(%rbp) |
total |
the slot gcc happened to assign it in this build |
-0x8(%rbp) |
i |
the next 4-byte slot down |
-0x14(%rbp) |
n |
the incoming argument, spilled out of edi |
Read that first column as a fact about this build and no more. Which local lands in which
slot is an artefact of the compiler’s internal ordering, not a rule about declaration order.
Only the argument slot has a reason behind it: n arrives in edi under the calling
convention and -O0 spills every incoming argument to the frame so a debugger can find it.
line by line
Every instruction, and the source line that caused it:
| instruction | what it does | source |
|---|---|---|
endbr64 |
a landing pad marking this as a legal indirect-branch target (a security feature, not your code) | none |
push %rbp |
save the caller’s frame pointer on the stack | the opening brace |
mov %rsp,%rbp |
this function’s frame starts here | the opening brace |
mov %edi,-0x14(%rbp) |
argument 1 arrived in edi; store it into the frame |
int sum_to(int n) |
movl $0x0,-0x4(%rbp) |
write 0 into the total slot |
int total = 0; |
movl $0x1,-0x8(%rbp) |
write 1 into the i slot |
for (int i = 1; … |
jmp 25 |
jump straight to the test — a for loop checks before its first iteration |
…; i <= n; … |
mov -0x8(%rbp),%eax |
load i into a register, because memory cannot be added to memory |
total += i; |
add %eax,-0x4(%rbp) |
add it into the total slot: read, add, write back |
total += i; |
addl $0x1,-0x8(%rbp) |
increment the i slot in place |
…; i++) |
mov -0x8(%rbp),%eax |
load i again — the previous load was three instructions ago and -O0 never remembers |
…; i <= n; … |
cmp -0x14(%rbp),%eax |
compute i - n and set the flags; the result is thrown away |
…; i <= n; … |
jle 1b |
if that comparison said less-or-equal, jump back to the loop body | …; i <= n; … |
mov -0x4(%rbp),%eax |
load total into eax, where the ABI says return values live |
return total; |
pop %rbp |
restore the caller’s frame pointer | the closing brace |
ret |
pop the return address into rip and continue in the caller |
the closing brace |
Sixteen instructions. Nine of them name a stack slot as an operand, and three more —
push, pop, ret — touch the stack implicitly, so twelve of the sixteen go to memory. The
loop body, the part that runs n times, is the six instructions from 1b to 2b, and five of
those six are memory accesses, for an operation that in the source is one +=.
why it looks like that
-O0 is a transcription, and that is the feature
At -O0 the compiler binds every variable to a memory slot and keeps it there. It is not being
stupid; it is being debuggable. If total lives at a fixed offset from rbp for the entire
function, then a debugger stopped anywhere can print it, change it, and have the change take
effect — and every source line maps to a contiguous run of instructions you can single-step
through. That property is worth a lot while you are finding a bug and nothing at all while you
are serving traffic.
-O1: the same loop, in registers
Same source file, one flag changed. The instructions and offsets below are objdump’s; the ;
comments in this listing and the next one are added here, because objdump prints none.
$ gcc -O1 -fno-stack-protector -c sumto.c -o sumto1.o
0000000000000000 <sum_to>:
0: endbr64
4: test %edi,%edi ; n <= 0?
6: jle 2c <sum_to+0x2c> ; then the answer is 0, skip everything
8: add $0x1,%edi ; edi = n + 1, the value i must reach
b: mov $0x1,%eax ; eax = i = 1
10: mov $0x0,%edx ; edx = total = 0
15: data16 cs nopw 0x0(%rax,%rax,1) ; padding so the loop starts on an aligned address
20: add %eax,%edx ; total += i ← the whole loop body
22: add $0x1,%eax ; i++
25: cmp %edi,%eax ; i == n + 1 ?
27: jne 20 <sum_to+0x20>
29: mov %edx,%eax ; return total
2b: ret
2c: mov $0x0,%edx ; the n <= 0 path
31: jmp 29 <sum_to+0x29>
The frame is gone — push %rbp and the slots with it — because nothing needs an address any
more. total is edx, i is eax, the statement total += i is now a single instruction,
and the whole loop is four real instructions plus the alignment padding in front of it, which
never executes as part of the loop — it exists so the loop’s first instruction starts on an
address the fetch hardware likes, and costs nothing once execution reaches add %eax,%edx.
Two smaller changes are worth noticing because they are what optimisers spend their time on:
the test i <= n was rewritten as i != n + 1 with the bound computed once before the loop,
which is the canonical shape that lets later passes reason about the trip count — and is the
reason -O2 is able to unroll it below. And the n <= 0 case was moved to a cold block at the
bottom, keeping the hot path contiguous in memory.
-O2: the one-to-one mapping breaks
$ gcc -O2 -fno-stack-protector -c sumto.c -o sumto2.o
0000000000000000 <sum_to>:
0: endbr64
4: test %edi,%edi
6: jle 40 <sum_to+0x40>
8: lea 0x1(%rdi),%ecx ; ecx = n + 1, the bound
b: xor %edx,%edx ; total = 0
d: and $0x1,%edi ; is n odd?
10: mov $0x1,%eax ; i = 1
15: je 30 <sum_to+0x30> ; n even → straight into the paired loop
17: mov $0x2,%eax ; n odd → peel one iteration: i = 2 …
1c: mov $0x1,%edx ; … total = 1
21: cmp %ecx,%eax ; was n == 1? then we are already done
23: je 3b <sum_to+0x3b>
25: data16 cs nopw 0x0(%rax,%rax,1) ; padding to align the paired loop
30: lea 0x1(%rdx,%rax,2),%edx ; total = total + 2i + 1 ← TWO iterations
34: add $0x2,%eax ; i += 2
37: cmp %ecx,%eax
39: jne 30 <sum_to+0x30>
3b: mov %edx,%eax
3d: ret
3e: xchg %ax,%ax ; padding, aligns whatever comes after this function
40: xor %edx,%edx
42: mov %edx,%eax
44: ret
The loop is now unrolled by two. total + i + (i+1) is total + 2i + 1, and x86’s address
arithmetic instruction computes exactly that shape in one go: lea 0x1(%rdx,%rax,2),%edx means
edx = rdx + rax*2 + 1. lea is doing arithmetic, not addressing — it is the standard trick
for “multiply by 2, 4 or 8 and add something”, with no memory involved at all.
Unrolling by two only works when the trip count is even, so the compiler peeled the odd case:
and $0x1,%edi asks whether n is odd and, if it is, does iteration 1 by hand before entering
the paired loop. That is why there is more code above the loop than in the loop. Unrolling is
worth doing because it halves the loop bookkeeping and gives the core two independent additions
to work on in the same stretch of instructions, which is
the pipeline’s subject rather than this page’s.
Ask which source line lea 0x1(%rdx,%rax,2),%edx came from and the question no longer has an
answer. It is two iterations of total += i, plus half of i++, fused into one instruction.
This is the moment the debugger’s line table starts pointing at surprising places, and it is
the whole reason optimised stack traces look wrong.
the same instructions, counted rather than timed
The three builds are not different arithmetic — they are the same five lines, wearing more or less machinery. Counting what each build makes the loop body do, instead of timing it, tells the same story with no stopwatch involved:
| build | loop-body instructions | of those, memory operands | frame? |
|---|---|---|---|
-O0 |
6 (1b-2b) |
5 | yes — push/pop %rbp |
-O1 |
4 | 0 | no |
-O2 |
4, doing 2 iterations each | 0 | no |
Every build computes the identical sum. What changes is how many of the CPU’s steps are spent
moving total and i to and from memory rather than doing arithmetic on them, and, at -O2,
how many source iterations one pass through the loop actually covers. The -O0 loop’s body
carries two chains that each round-trip through a stack slot every iteration — add %eax,-0x4(%rbp) reads total out of its slot and writes it back, and addl $0x1,-0x8(%rbp)
does the same to i — where -O1 and -O2 do the equivalent arithmetic entirely in registers,
which is exactly the register-versus-memory gap from the topic page,
now seen at three optimisation levels on the same five lines instead of two hand-written loops.
and what the JIT does with the same five lines
Written in C#, the loop is character-for-character the same. The runtime will print what it
generated for it — no decompiler, no external tool. Two details in the file below are load
bearing: SumTo is a static method on a named class, so the JIT gives it a name the dump
filter can match, and something actually calls it, because a method that is never invoked is
never compiled and therefore never printed.
using System.Runtime.CompilerServices;
static class P
{
[MethodImpl(MethodImplOptions.NoInlining)]
public static int SumTo(int n)
{
int total = 0;
for (int i = 1; i <= n; i++)
total += i;
return total;
}
static void Main() => Console.WriteLine(SumTo(1000));
}That file is checked in as bench/cpu-execution/read-the-disassembly-jit.cs.
DOTNET_TieredCompilation=0 skips tier 0, so the first compilation is already the optimised
one. Real output, unedited:
$ DOTNET_JitDisasm=SumTo DOTNET_TieredCompilation=0 dotnet run -c Release sumto.cs
500500
; Assembly listing for method P:SumTo(int):int (FullOpts)
; Emitting BLENDED_CODE for generic X64 + VEX on Unix
; FullOpts code
; optimized code
; rbp based frame
; fully interruptible
; No PGO data
G_M000_IG01: ;; offset=0x0000
push rbp
mov rbp, rsp
G_M000_IG02: ;; offset=0x0004
xor eax, eax
mov ecx, 1
test edi, edi
jle SHORT G_M000_IG04
align [0 bytes for IG03]
G_M000_IG03: ;; offset=0x000F
add eax, ecx
inc ecx
cmp ecx, edi
jle SHORT G_M000_IG03
G_M000_IG04: ;; offset=0x0017
pop rbp
ret
; Total bytes of code 25
That is Intel syntax — destination first. The G_M000_IGnn labels are the JIT’s basic blocks:
IG01 is the prologue, IG02 puts total in eax and i in ecx and takes the early exit
when n is not positive, IG03 is the loop, IG04 the epilogue. Strip the labels and the
offsets and it is the same four-instruction loop gcc reached at -O1: the body, an increment, a
compare, a branch, with total and i both in registers and no frame slots at all. RyuJIT kept
the source’s i <= n test where gcc rewrote it to i != n + 1, and RyuJIT did not unroll where
gcc at -O2 did — that second difference is the only structural gap left between them.
If you try this and get no listing at all, the filter did not match. DOTNET_JitDisasm matches
the JIT’s own name for the method, and a local function declared inside top-level statements is
compiled as something like Program:g__SumTo|0_0 — which is why the file above uses a static
method on a class P, and why DOTNET_JitDisasm='*SumTo*' is the wildcard that rescues the
other shape. The second way to get silence is a method nothing calls: uncompiled means
unprinted.
The point is not that .NET’s codegen matches gcc’s. It is that once the JIT has done its job, “managed code” is x86-64 instructions in registers like anything else — the overhead people expect to find here is not in the loop, it is in what the code does with objects and memory, which is where the rest of this section goes.
what this looks like in prod
You will not disassemble anything on a normal Tuesday. You will read disassembly on the three days a year when nothing else answers the question, and those days go much better if the syntax is not also new to you:
- A crash dump with no useful managed stack. The disassembly around the faulting
rip, plus the registers, is often the whole story: which register held the null, which call was in flight. - A profiler that attributes an implausible share of time to a line that cannot possibly cost
that. At
-O2-class optimisation the line table is approximate. The instruction-level view tells you what the sample really landed on, which is usually a memory access several source lines away. - “Did it inline / did it eliminate the bounds check?” These are questions about generated
code, and
DOTNET_JitDisasmanswers them in seconds where reasoning does not. The bounds-check half of that lives in IL, the JIT and code generation. - A benchmark that is too fast to be true. Disassembling the loop is how you find out the compiler deleted the work whose result you never used.
And one thing worth carrying from the counted table above: dotnet run file.cs produces Debug
code with the optimiser off, on identical source to dotnet run -c Release file.cs. Any
measurement taken in a Debug build is a measurement of the debugger’s convenience, not of your
code — see the topic page for what a Debug build actually generates.
the same idea in other languages
| language | how you see the machine code | the trap |
|---|---|---|
| C / C++ | objdump -d on the object file or binary; gcc -S for assembly text |
Disassembling an unlinked .o shows unresolved call targets as garbage: in this page’s other example call add3 printed as call 47 (caller+0x27) — the address of the next instruction — because the linker had not filled the relocation in yet. Always disassemble the linked binary. |
| Java | javap -c for bytecode; machine code needs -XX:+PrintAssembly plus an hsdis plugin that is not bundled with the JDK |
javap output is bytecode, not instructions. It is the exact analogue of IL, and reading it tells you nothing about what C2 finally emitted. |
| Go | go tool objdump, or go build -gcflags=-S |
Go prints its own portable pseudo-assembly — registers spelled AX, SP, and its own mnemonics — not GNU or Intel syntax. It looks like x86 and is not. |
| Python | dis.dis(f) gives CPython bytecode |
There is no machine code for your function to look at. The interpreter’s C loop is the machine code, and it is the same code for every function you write. |
common bugs
- Reading
.ofiles and believing the call targets. An unlinked object still has empty relocations, so everycallpoints at the instruction after itself. It looks like an infinite loop and is an artefact. - Mixing up AT&T and Intel operand order.
mov %eax,%edxandmov eax, edxmove in opposite directions. objdump and gcc default to AT&T, .NET and Windows tools to Intel; both will happily print instructions you read backwards. - Drawing conclusions from
-O0or from tier-0 JIT output. Both are transcriptions meant for a debugger. If you did not pass-O2, or you dumped before the method was promoted to full optimisation, you are reading code that will never run in production. - Assuming one source line equals one instruction run. After unrolling and vectorisation, one instruction can be several iterations of your loop, and one source line can be spread over three basic blocks — which is why optimised line numbers in a stack trace are approximate.
- Benchmarking a function whose result is unused. The compiler is entitled to delete it, and
at
-O2it will. A snippet that only computes a value and never uses it is not evidence about anything the compiler was allowed to remove.