Step 64. First Encounter with Assembly — The Final Form of My Code

Step 64. First Encounter with Assembly — The Final Form of My Code

Level 1 — Programming and the Computer’s Inner Workings | Difficulty ★★★★☆ | Estimated time: 4 hours

Prerequisites: Steps 56–63 complete. You’ve reproduced the four compilation stages by hand and know which stage’s artifact a .s file is.

  • What you need: a WSL Ubuntu terminal, gcc, objdump. The verification environment is Ubuntu 24.04, gcc 13.3.0, objdump 2.42, x86-64.
  • Caution: today’s exercises are 100% safe. We only extract and read the assembly of programs I wrote myself.

Last time we briefly opened a .s file. Strange words like mov and call. Today we read that file head-on. Assembly language is machine code transcribed into symbols humans can read — the lowest-floor language. Every program in the world — games, operating systems, Python — ultimately runs as a march of these small instructions. Today’s goal is not to code in assembly but to build the sense of matching "which instructions my C code becomes."


1. Learning Objectives

By the end of this chapter, you will be able to:

  • Explain what assembly language is (the human-readable edition of machine code)
  • Recognize six instructions: mov, add, cmp, jmp/jle, call, ret
  • Compare C code (functions, if, for) side by side with its assembly
  • Extract readable assembly with gcc -S -masm=intel -O0
  • Pull assembly out of an already-compiled executable with objdump -d

2. Background Knowledge — Today’s Tools and Concepts

Today’s Tools at a Glance

Category Details
Language/environment Assembly (x86-64, Intel syntax), C language, WSL Ubuntu bash (verified: gcc 13.3.0)
Today’s commands gcc -S -masm=intel -O0 (extract assembly for reading), objdump -d -M intel (disassemble an executable), grep -A 15 "<function name>:" (see just one function)
Concepts needed The machine-code/assembly correspondence, registers (rax, rdi, rsi, etc.), the six instructions, the calling convention, optimization (-O0 vs -O2)

2-1. Machine Code and Assembly — The Translated Edition of 0s and 1s

What the CPU directly knows is only patterns of 0s and 1s (machine code). Since a human can’t read things like 10111000 ..., assembly is what you get by giving each pattern a name.

Machine code and assembly correspond almost one-to-one. So looking at assembly is looking at machine code.

2-2. Registers — Boxes on the CPU’s Palm

Inside the CPU are a few ultra-small, very fast boxes: registers. Calculations happen on top of these boxes, not in memory.

Today’s cast of faces in the x86-64 environment: rax (where results mainly land), rdx, rdi (a function’s first argument), rsi (second argument), rsp (marks the top of the stack), rbp (the current function’s reference point). Names starting with e, like eax and edi, are notations using only the lower 32 bits of the same box. For today, just know them as "named palm-sized boxes."

2-3. A Few Instructions Are Enough

There are hundreds of assembly instructions, but today you need six.

Instruction Meaning
mov a, b Copy b into a (assignment)
add a, b Add b to a
cmp a, b Compare a and b (the result stays in markers called flags)
jmp / jle / je Jump / jump if less or equal / jump if equal
call name Call a function
ret Return

These six are the substance of all of if, for, and function calls.

2-4. Intel Syntax and AT&T Syntax — Two Notations

There are two ways of writing the same assembly: Intel syntax (mov rax, 1 — "1 into rax") and AT&T syntax (movq $1, %rax — order reversed, with % and $ marks).

gcc’s default output is AT&T syntax, but today we unify on the easier-to-read Intel syntax (-masm=intel). When you meet output showing % and $, just light your warning lamp: "ah, AT&T — order reversed."

2-5. A Function’s Prologue and the Calling Convention

The preparatory moves that appear every time a function starts, like push rbp and mov rbp, rsp, are called the prologue. You can skip over them wholesale as "the function’s warm-up exercises."

By contrast, there’s one promise you must remember: the calling convention — the promise that the first argument is handed over in rdi, the second in rsi, and the result is returned in rax. In today’s exercises you’ll see this promise actually kept.


3. Follow Along

3-1. One Function’s Assembly — The Smallest Comparison

Input (add.c)

int add(int a, int b) {
    return a + b;
}

Compile (up to assembly only)

gcc -S -masm=intel -O0 add.c -o add.s
cat add.s
add:
	endbr64
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -4[rbp], edi
	mov	DWORD PTR -8[rbp], esi
	mov	edx, DWORD PTR -4[rbp]
	mov	eax, DWORD PTR -8[rbp]
	add	eax, edx
	pop	rbp
	ret

(Verified 2026-09-09. Assembly-directive lines starting with .cfi and the .LFB0 marker are omitted for readability.)

How to read the options: -masm=intel is "in Intel syntax"; -O0 is "optimization off — translate exactly as taught."

How to read the output: let’s follow the order. The first argument a arrived in the edi box, the second b in esi (the calling convention), and after setting both down in memory (-4[rbp], -8[rbp] — spots a few slots away from rbp), it reads them back and adds with add eax, edx. The result sits in eax (the lower part of rax) — the real form of the promise "a function returns its result in rax." The topmost endbr64 is a branch-protection instruction on modern CPUs; for now, read it as a "function start marker" and move on.

Predict: if you change it to return a - b;, which one line changes? Predict, then re-extract and check.

Why: the sight of a one-line C function being translated into nine small instructions — this contrast is today’s starting point.

3-2. The Substance of if — Compare and Jump

Input (ifex.c)

int check(int score) {
    if (score >= 60) {
        return 1;
    } else {
        return 0;
    }
}

Compile and check

gcc -S -masm=intel -O0 ifex.c -o ifex.s
cat ifex.s
check:
	endbr64
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -4[rbp], edi
	cmp	DWORD PTR -4[rbp], 59
	jle	.L2
	mov	eax, 1
	jmp	.L3
.L2:
	mov	eax, 0
.L3:
	pop	rbp
	ret

(Verified 2026-09-09. Assembly-directive lines omitted.)

How to read the output: the if vanished and became cmp + jle. "Compare score with 59, and if less or equal (jle), jump to the marker .L2." .L2 is the else lump; .L3 is the function’s end. The reason it compares with 59 is that flipping ">= 60" gives "<= 59."

A conditional was "one comparison + a jump between signposts."

Why: "if is cmp and a jump" — this one sentence is today’s most important harvest. The way to recognize an if in a program without source is exactly this.

3-3. The Substance of for — A Loop Made of Jumps

Input (forex.c)

int sum(int n) {
    int total = 0;
    for (int i = 1; i <= n; i++) {
        total += i;
    }
    return total;
}

Compile and check

gcc -S -masm=intel -O0 forex.c -o forex.s
cat forex.s
sum:
	endbr64
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -20[rbp], edi
	mov	DWORD PTR -8[rbp], 0
	mov	DWORD PTR -4[rbp], 1
	jmp	.L2
.L3:
	mov	eax, DWORD PTR -4[rbp]
	add	DWORD PTR -8[rbp], eax
	add	DWORD PTR -4[rbp], 1
.L2:
	mov	eax, DWORD PTR -4[rbp]
	cmp	eax, DWORD PTR -20[rbp]
	jle	.L3
	mov	eax, DWORD PTR -8[rbp]
	pop	rbp
	ret

(Verified 2026-09-09. Assembly-directive lines omitted.)

How to read the output: between .L2 (the condition check) and .L3 (the body), jle travels back and forth, forming a loop. Find where the for’s three parts went. Initial value 1 → mov DWORD PTR -4[rbp], 1. Increment i++add DWORD PTR -4[rbp], 1. Condition i <= ncmp + jle.

A for was a jump loop of "check → body → increment → check again."

Predict: would the assembly of C code with the for rewritten as a while differ from this, or be the same? Experiment. To the compiler, the two are actually the same idea.

3-4. main and call — The Substance of Calling a Function

Input (callex.c)

#include <stdio.h>

int square(int n) {
    return n * n;
}

int main(void) {
    int r = square(7);
    printf("Result: %d\n", r);
    return 0;
}

Compile and check

gcc -S -masm=intel -O0 callex.c -o callex.s
grep -A 16 "^main:" callex.s
main:
	endbr64
	push	rbp
	mov	rbp, rsp
	sub	rsp, 16
	mov	edi, 7
	call	square
	mov	DWORD PTR -4[rbp], eax
	mov	eax, DWORD PTR -4[rbp]
	mov	esi, eax
	lea	rax, .LC0[rip]
	mov	rdi, rax
	mov	eax, 0
	call	printf@PLT
	mov	eax, 0
	leave
	ret

(Verified 2026-09-09. Assembly-directive lines omitted.)

How to read the output: just before calling square(7), it put 7 into edi — exactly per the promise "the first argument goes in rdi." It called with call square, and on return it took the result from eax and stored it in -4[rbp] (the variable r). Next, when calling printf, you can see it loads the result value into esi (second argument) and the string’s address into rdi (first argument).

The paths arguments and results travel — these promises are 2-5’s calling convention.

3-5. Even from an Executable — objdump

So far we’ve made .s files. You can also pull assembly out of an already-compiled executable.

gcc -O0 callex.c -o callex
objdump -d -M intel callex | grep -A 8 "<square>:"
0000000000001149 <square>:
    1149:	f3 0f 1e fa          	endbr64
    114d:	55                   	push   rbp
    114e:	48 89 e5             	mov    rbp,rsp
    1151:	89 7d fc             	mov    DWORD PTR [rbp-0x4],edi
    1154:	8b 45 fc             	mov    eax,DWORD PTR [rbp-0x4]
    1157:	0f af c0             	imul   eax,eax
    115a:	5d                   	pop    rbp
    115b:	c3                   	ret

(Verified 2026-09-09. Addresses (1149, etc.) can vary by environment.)

How to read the output: objdump is a tool that turns an executable back into assembly for viewing (a disassembler). Read it in three strands. Far left (1149, 114d …) is each instruction’s memory address. The middle (55, 48 89 e5 …) is the real machine-code bytes; the right is those bytes translated into assembly. You can even see that push rbp is a single byte, 55, in machine code. The square we made lives inside the executable, and the multiplication was translated as imul eax, eax (eax × eax).

objdump’s Intel syntax writes subtraction inside brackets like [rbp-0x4], while gcc -S’s output writes -4[rbp]. Different handwriting for the same meaning.

Why: "even an executable without source can be read as assembly." Today it’s a program whose source we know, so we can check our answers. As this comparison training accumulates, the day comes when you read without an answer key.

3-6. Before and After Optimization — The Translator’s Two Faces

Let’s extract the same C with -O0 and -O2 and compare.

gcc -S -masm=intel -O0 add.c -o add_O0.s
gcc -S -masm=intel -O2 add.c -o add_O2.s
grep -v cfi add_O2.s
add:
	endbr64
	lea	eax, [rdi+rsi]
	ret

(Verified 2026-09-09.)

How to read the output: a function that was nine lines at -O0 became effectively two lines. The faithful procedure of setting down in memory and reading back was omitted by optimization with a "you can just add directly anyway." It even did the addition with the address-calculation instruction lea instead of add.

Think of there being two translators. -O0 is a faithful literal translator; -O2 is a smart paraphraser. For studying, the literal translator is good; most actually shipped programs are translated by the paraphraser.

Think about it: then if you open a real shipped program with objdump, will today’s "faithful structure" show up as-is? The answer is "close to no." The same C becomes different assembly depending on compile options — this is where you gain the humility of knowing that "this is not the one and only form of the original code" when reading assembly.


4. Missions & Exercises

Mission — Three C–Assembly Comparison Cards

Make three comparison cards. Each card holds "C code / assembly (key lines) / one line on the correspondence."

  1. Card 1 (function): extract the assembly of int triple(int n) { return n * 3; } and mark the line where the multiplication happens
  2. Card 2 (if): write a function yourself that returns 1 for even numbers and 0 for odd, and in its assembly find the cmp and the jump, annotating what each means (hint: the remainder of division by 2 may be translated as an and operation. Observing how it was translated is the assignment)
  3. Card 3 (for): write a function yourself that multiplies from 1 to 10, and in its assembly find the loop (two signposts and a jump) and mark it with arrows
  4. On each card, write by hand the correspondence "one line of C ↔ how many lines of assembly"
  5. Open an executable containing card 1’s function with objdump and find the same function

Exercises

Problem 1. Explain the relationship between assembly and machine code, and state the positions where the two appear side by side in objdump output.

Problem 2. In 3-2, why was if (score >= 60) translated as cmp ..., 59 and jle?

Problem 3. The add extracted with -O0 was nine lines; with -O2, two. Which should you use when studying, and which side were most actually shipped programs translated with?

Problem 4. While reading assembly, you meet a line like movq $1, %rax. Which syntax is this, and should you read it as "put 1 into rax" or "put rax into 1"?


5. Model Answers & Completion Criteria

Mission Model Answer

An example of card 1:

int triple(int n) { return n * 3; }
gcc -S -masm=intel -O0 triple.c -o triple.s

The line where the multiplication happens (verified 2026-09-09): imul eax, DWORD PTR -4[rbp], 3 — translated as one line meaning "multiply the n in memory by 3 into eax." On the card, write this: "C n * 3 ↔ one line imul ..., 3. The result goes to eax."

How to verify: ① check that all three cards were extracted with -O0 (omit -O0 and the computation can vanish wholesale). ② On card 2, find and note that even-number detection was translated with an instruction like and or test. ③ On card 3, connect with arrows the two signposts (two .L numbers) and the one jump traveling between them. ④ Compare whether the triple found with objdump has the same shape as the one in the .s.

Exercise Answers

Problem 1 answer. Machine code is the pattern of 0s and 1s the CPU directly knows; assembly is the human-readable edition that gives those patterns names. They correspond almost one-to-one. In objdump output, the middle column (e.g., 48 89 e5) is the machine-code bytes, and the right column (e.g., mov rbp,rsp) is the assembly translating them.

Problem 2 answer. Because the compiler flipped the condition and translated it as "the condition for jumping to else." The opposite of ">= 60" is "<= 59," so it becomes a form that compares with 59 and, if less or equal (jle), jumps to the else marker (.L2).

Problem 3 answer. For studying, -O0. Because the structure you learned (the prologue, setting down in memory and reading back) shows up as-is. Most actually shipped programs are translated at -O2 or above, so keep in mind that the shape changes considerably.

Problem 4 answer. AT&T syntax (the % and $ marks are the signal). AT&T is in "source → destination" order, the reverse of Intel syntax. So you should read it as "put 1 into rax."

Completion Criteria Checklist

  • [ ] I can explain the assembly/machine-code relationship (one-to-one correspondence)
  • [ ] I know what mov, add, cmp, jmp/jle, call, ret mean
  • [ ] I can find what C’s if and for look like in assembly (cmp+jump, the signpost loop)
  • [ ] I can explain the paths arguments and results travel (rdi, rsi, rax — the calling convention)
  • [ ] I can use gcc -S -masm=intel -O0 and objdump -d -M intel
  • [ ] I can explain why -O0 and -O2 output differ
  • [ ] Mission: I completed the three C–assembly comparison cards

6. Common Pitfalls & Fixes

Wall 1. Optimization Makes Code Vanish Wholesale

Symptom: you extract assembly and the function body is only a line or two, or the computation result is already baked in as a constant.
Cause: you left out -O0. The compiler pre-computes everything "that doesn’t need computing."
Fix: for study purposes, always -O0. It translates in exactly the structure you learned.

Wall 2. Mixing AT&T and Intel Syntax While Reading

Symptom: you read movq $1, %rax as "put rax into 1" and understand it backward.
Cause: AT&T syntax is in "source → destination" order, the reverse of Intel.
Fix: today we unified with -masm=intel. When viewing output from other documents or tools, if you see % and $, light your warning lamp: "AT&T — order reversed."

Wall 3. Wearing Yourself Out Trying to Understand Everything

Symptom: digging into every single line like push rbp and .cfi, you make no progress.
Cause: today’s goal is not full translation but comparison sense. The prologue and .cfi directive lines can be skipped wholesale as "warm-up exercises."
Fix: fix a reading order. (a) Find the function name, (b) find the operations I wrote (add, imul, cmp), (c) find call and ret. Find just those three and the function’s skeleton reads.

Wall 4. objdump Output Is Too Long

Symptom: hundreds of lines pour out and you can’t tell where your code is.
Cause: besides our code, the executable is stuffed with linked parts.
Fix: find the function name with grep. objdump -d -M intel file | grep -A 20 "<main>:". The standard library portions can be skipped for now.

Wall 5. gcc -S and objdump Notations Differ Subtly

Symptom: gcc output shows -4[rbp]; objdump shows [rbp-0x4] (verified 2026-09-09).
Cause: even within the same Intel syntax, bracket notation differs by tool.
Fix: both mean the same thing — "4 bytes below rbp." It’s only a notational difference; read the content (whose vicinity, how far away).


7. Summary

Today’s Concepts

Concept One-line explanation
Machine code The pattern of 0s and 1s the CPU directly knows
Assembly The human-readable edition of machine code — nearly one-to-one
Register An ultra-small box inside the CPU — calculations happen here
Calling convention Arguments in rdi, rsi; results in rax — a promise agreed to be kept
Prologue The warm-up exercises at a function’s start (push rbp, etc.)
Disassembler A tool that turns an executable back into assembly (objdump)

Today’s Commands

Command What it does
gcc -S -masm=intel -O0 file.c Extract readable assembly
objdump -d -M intel executable Turn an executable back into assembly for viewing
objdump ... | grep -A 15 "<main>:" See just my function’s portion

A Sense More Important Than Commands

From today, assembly is a language "to read," not "to write." The compiler writes better than we do. The first move of people who must read executables without source — malware analysts, vulnerability researchers — was today’s objdump, and what their eyes need is today’s comparison sense.

Not every line will read yet. That’s fine. What’s needed today is not reading everything but the experience of recognizing one if and one for inside assembly. That experience accumulates into eyesight. Keep your three comparison cards safe — this practice of reading with the answer key (the source) beside you is the seed money for the day you read without one.


Once every box is checked, Step 64 is complete. Click the checkbox in the sidebar to save your progress.