Assembly 101

﷽

When I started reading Computer Systems: A Programmer’s Perspective (CS:APP), assembly was one of the hardest parts to understand.

At first, assembly looks much more complicated than C or C++. You suddenly see instructions like:

ASM
movq %rax, %rdx
subq $8, %rsp
cmpq %rsi, %rdi
jne .L3

It can feel unreadable.

The good news is that assembly instructions themselves are usually quite simple.

The difficult part is understanding the ideas behind them:

  • registers
  • memory
  • addresses
  • pointers
  • the stack
  • function calls
  • calling conventions
  • comparisons
  • jumps
  • system calls

Once these concepts start making sense, assembly becomes much easier to read.

Before going into this article I advice you as the reader to know: hexadecimale, difference between bit & bytes and of course C/C++ programming as this really helps with the understanding of the assembly code it helped with the understanding an example: Assembly memory: movq %rax, (%rbx)

This guide focuses on the x86-64 assembly used throughout CS:APP. Its important to note that most of the assembly code is 64 bits. As for the different bits like 32, 16, 8, 1 there are specific assembly registers those are already mentioned in CS:APP. I highly recommend to read the CS:APP first then go back to this article, as CS:APP goes more in the detail.

Assembly Depends on the CPU Architecture

There is no single universal assembly language.

Different CPU architectures have different registers and different machine instructions.

For example:

ASM
x86-64
ARM64
RISC-V

Assembly written for x86-64 cannot simply run on ARM64 because the processors understand different instruction sets.

CS mainly focuses on x86-64.

There is also another source of confusion: the same x86-64 instructions can be written using different assembly syntaxes.

Two common syntaxes are:

ASM
Intel syntax
AT&T syntax

CS mostly uses AT&T syntax.

For example, Intel syntax might write:

ASM
mov rax, rbx

while AT&T syntax writes:

ASM
movq %rbx, %rax

One important difference is operand order.

AT&T syntax generally uses:

ASM
instruction SOURCE, DESTINATION

So:

ASM
movq %rbx, %rax

means:

ASM
copy the value in RBX into RAX

Registers

Registers are small storage locations directly inside the CPU.

Some common x86-64 registers are:

ASM
%rax
%rbx
%rcx
%rdx
%rsi
%rdi
%rsp
%rbp
%r8
%r9

You can think of them as very small temporary storage locations the CPU can access quickly.

For example:

ASM
movq $10, %rax

means:

ASM
put the value 10 into RAX

The $ means that 10 is an immediate value, meaning the literal number itself.

So:

ASM
movq $10, %rax

means:

ASM
RAX = 10

while:

ASM
movq %rbx, %rax

means:

ASM
RAX = the value currently stored in RBX

Registers are not all used in exactly the same way.

For example:

ASM
%rsp

has a special role as the stack pointer.

Other registers such as %rdi, %rsi, and %rax often have special roles because of calling conventions.

Register Sizes

You will often see names such as:

ASM
%rax
%eax
%ax
%al

These are not completely different registers.

They refer to different-sized portions of the same register.

ASM
%rax    64 bits
%eax    32 bits
%ax     16 bits
%al      8 bits

For example:

ASM
movl $5, %eax

writes 5 into the 32-bit %eax register.

On x86-64 there is an important rule:

Writing to a 32-bit register automatically clears the upper 32 bits of the corresponding 64-bit register.

So after:

ASM
movl $5, %eax

the entire %rax register contains:

ASM
5

The upper 32 bits become zero.

This is different from writing to %ax or %al.

For example:

ASM
movw $5, %ax

changes only the lower 16 bits.

The rest of %rax remains unchanged.

Likewise:

ASM
movb $5, %al

changes only the lowest 8 bits.

This is one reason compilers frequently use 32-bit registers such as %eax.

Instruction Suffixes

In AT&T syntax, instructions often end with a letter describing the size of the value they operate on.

ASM
b = byte       8 bits
w = word      16 bits
l = long      32 bits
q = quad      64 bits

For example:

ASM
movb
movw
movl
movq

all perform a move, but on different-sized values.

Likewise:

ASM
addq
subq
cmpq

operate on 64-bit values.

Instructions

Assembly programs are mainly sequences of small operations.

For example:

ASM
movq $10, %rax
addq $5, %rax
subq $2, %rax

Conceptually:

ASM
rax = 10;
rax = rax + 5;
rax = rax - 2;

After these instructions:

ASM
%rax = 13

This is one of the main differences between reading C and reading assembly.

C might express something in one line:

ASM
x = 10 + 5 - 2;

while assembly may perform several small operations one after another.

Memory

Registers are useful, but the CPU only has a limited number of them.

Most program data therefore lives in memory.

Memory can be imagined as a large sequence of bytes.

Each byte has an address.

For example:

ASM
Address       Value

0x1000        72
0x1001        101
0x1002        108
0x1003        108
0x1004        111
0x1005        0

These bytes could represent the C string:

ASM
Hello

The final 0 is important.

C strings end with a null byte:

ASM
'\0'

So:

ASM
"Hello"

actually occupies six bytes:

ASM
H e l l o \0

The Three Main Operand Types

Before reading much assembly, it helps to understand the three operand types you will constantly encounter.

Immediate Values

An immediate is a literal value.

ASM
$10

means:

ASM
the number 10

For example:

ASM
movq $10, %rax

places the number 10 into %rax.

Registers

A register operand refers to the value currently stored inside a register.

For example:

ASM
movq %rdi, %rax

means:

ASM
copy the value in RDI into RAX

Memory Operands

Parentheses usually mean that memory is being accessed through an address.

Consider:

ASM
movq (%rdi), %rax

Here %rdi contains an address.

The parentheses mean:

ASM
go to the memory address stored in RDI

and load the value stored there.

This difference is extremely important.

ASM
movq %rdi, %rax

means:

ASM
copy the VALUE in RDI into RAX

while:

ASM
movq (%rdi), %rax

means:

ASM
treat RDI as an ADDRESS
go to that memory location
load 8 bytes
store them in RAX

Suppose:

ASM
%rdi = 0x1000

and memory contains:

ASM
0x1000 -> 42

Then:

ASM
movq %rdi, %rax

produces:

ASM
%rax = 0x1000

while:

ASM
movq (%rdi), %rax

produces:

ASM
%rax = 42

One of the most useful questions you can ask while reading assembly is:

Does this register contain a value, or does it contain an address?

Memory Offsets

Assembly can also access memory using an offset.

For example:

ASM
movq 8(%rdi), %rax

means:

ASM
load 8 bytes from memory at address RDI + 8

If:

ASM
%rdi = 0x1000

then:

ASM
8(%rdi)

refers to:

ASM
0x1008

You will see this constantly when assembly accesses:

  • arrays
  • structures
  • local variables
  • stack values

Understanding lea

You will frequently see:

ASM
leaq

Its name means:

ASM
Load Effective Address

Despite the name, lea does not read memory.

Compare:

ASM
movq 8(%rdi), %rax

with:

ASM
leaq 8(%rdi), %rax

The first instruction means:

ASM
go to address RDI + 8
load the value stored there
place it into RAX

The second means:

ASM
calculate RDI + 8
place that result into RAX

No memory is accessed.

So:

ASM
leaq 8(%rdi), %rax

is roughly:

ASM
rax = rdi + 8;

Because lea can perform address calculations efficiently, compilers also use it for normal arithmetic.

For example:

ASM
leaq (%rdi,%rsi), %rax

computes:

ASM
RDI + RSI

without accessing memory.

More General Addressing

You may also see:

ASM
8(%rax,%rdx,4)

This calculates an address as:

ASM
8 + RAX + RDX * 4

The general form is:

ASM
displacement(base, index, scale)

which calculates:

ASM
displacement + base + index * scale

The scale is normally:

ASM
1
2
4
8

This is especially useful for arrays.

If an array contains 4-byte integers:

ASM
array[i]

the address may be calculated roughly as:

ASM
base + i * 4

Strings in Assembly

Strings are simply bytes stored in memory.

Compiler-generated assembly might contain:

ASM
.section .rodata

.LC0:
    .string "Hello"

.rodata means:

ASM
read-only data

The directive:

ASM
.string "Hello"

stores:

ASM
H e l l o \0

The null terminator is added automatically.

By contrast:

ASM
.ascii "Hello"

stores only:

ASM
H e l l o

and does not automatically add a null byte.

.LC0 is a label representing the location of the string.

Labels beginning with .L are commonly compiler-generated local labels.

You may later see:

ASM
leaq .LC0(%rip), %rdi

This loads the address of the string into %rdi.

It does not copy the entire string into %rdi.

The %rip part uses RIP-relative addressing, meaning the address is calculated relative to the current instruction.

This is common in modern x86-64 compiler output.

Understanding the Stack

The stack is one of the most important concepts to understand when reading CS.

At first, instructions such as:

ASM
subq $24, %rsp
movq %rbx, 8(%rsp)
call foo
addq $24, %rsp

can look completely arbitrary.

But once you understand what the stack is doing, they start to make sense.

The stack is simply an area of memory that programs use heavily when calling functions.

It is commonly used for things such as:

ASM
return addresses
local variables
saved registers
temporary values
extra function arguments

The most important register for understanding the stack is:

ASM
%rsp

%rsp is the stack pointer.

It contains the address of the current top of the stack.

The Stack Is Just Memory

The stack is not a completely separate piece of hardware.

It is part of the program’s memory.

Imagine memory like this:

ASM
Higher addresses

0x7fffffffe100
0x7fffffffe0f8
0x7fffffffe0f0
0x7fffffffe0e8
0x7fffffffe0e0

Lower addresses

Some region of memory is being used as the stack.

If:

ASM
%rsp = 0x7fffffffe0f0

then the current top of the stack is at:

ASM
0x7fffffffe0f0

So %rsp does not contain the stack itself.

It contains a pointer into the stack.

The Stack Grows Downward

On x86-64, the stack normally grows toward lower memory addresses.

Suppose:

ASM
%rsp = 0x1000

If we want to create 8 bytes of new stack space:

ASM
subq $8, %rsp

Now:

ASM
%rsp = 0x0ff8

Conceptually:

ASM
Higher addresses

0x1000    old %rsp
0x0ff8    new %rsp

Lower addresses

So:

ASM
subq $8, %rsp

can often be interpreted as:

ASM
reserve 8 bytes of stack space

Later:

ASM
addq $8, %rsp

moves %rsp back and releases that space.

You will often see:

ASM
subq $32, %rsp

# use stack space

addq $32, %rsp
ret

What Does “Top of the Stack” Mean?

The word “top” can be confusing because the stack grows toward lower addresses.

The top simply means:

the memory location currently pointed to by %rsp

If:

ASM
%rsp = 0x1000

then the top is 0x1000.

After:

ASM
subq $8, %rsp

the new top is 0x0ff8.

The word “top” is about the logical stack structure, not the highest address.

push and pop

x86-64 provides instructions specifically for working with the stack.

For example:

ASM
pushq %rax

Conceptually, pushq does two things:

ASM
1. subtract 8 from %rsp
2. store the value at the new address

So:

ASM
pushq %rax

is roughly equivalent to:

ASM
subq $8, %rsp
movq %rax, (%rsp)

Suppose:

ASM
%rsp = 0x1000
%rax = 42

After:

ASM
pushq %rax

we get:

ASM
%rsp = 0x0ff8

and memory contains:

ASM
0x0ff8 -> 42

The opposite operation is:

ASM
popq %rax

Conceptually:

ASM
1. load the value at (%rsp)
2. add 8 to %rsp

roughly:

ASM
movq (%rsp), %rax
addq $8, %rsp

Why Functions Need the Stack

Consider:

ASM
long add(long a, long b)
{
    return a + b;
}

This function is simple enough to use only registers.

A compiler could generate:

ASM
add:
    leaq (%rdi,%rsi), %rax
    ret

No local stack space is needed.

But larger functions may:

  • use many variables
  • call other functions
  • need to preserve registers
  • need temporary storage

There are only a limited number of registers.

When more storage is needed, the stack becomes useful.

A Function’s Stack Frame

The area of stack memory used by one function is often called its stack frame.

Suppose a function executes:

ASM
subq $32, %rsp

You can imagine:

ASM
Higher addresses

+----------------------+
| previous stack data  |
+----------------------+
|                      |
|   function's stack   |
|       32 bytes       |
|                      |
+----------------------+ <- %rsp

Lower addresses

Those 32 bytes may be used for:

ASM
local variables
temporary values
saved registers

Before returning, the function may execute:

ASM
addq $32, %rsp

which releases the stack space.

The Stack Is Temporary

Stack storage normally exists only while the function is active.

Consider:

ASM
long foo()
{
    long x = 10;
    return x;
}

If x is stored on the stack, its storage belongs to foo‘s stack frame.

Once foo returns, that storage may be reused.

This is why code like this is dangerous:

ASM
int *bad()
{
    int x = 10;
    return &x;
}

x belongs to the current stack frame.

After the function returns, that memory is no longer safely owned by bad().

call Uses the Stack

Suppose we execute:

ASM
call foo

The CPU needs to know where execution should continue after foo finishes.

For example:

ASM
movq $5, %rdi
call foo
movq %rax, %rbx

After foo finishes, execution should continue at:

ASM
movq %rax, %rbx

The call instruction stores the address of that next instruction on the stack.

This stored address is called the return address.

Conceptually:

ASM
call foo

does something like:

ASM
push address of next instruction
jump to foo

ret Uses the Stack

At the end of a function you will often see:

ASM
ret

ret gets the saved return address from the stack and continues execution there.

Conceptually:

ASM
pop return address
jump to that address

So:

ASM
call
 |
 | save return address
 v
function executes
 |
 v
ret
 |
 | retrieve return address
 v
caller continues

Nested Function Calls

Suppose:

ASM
main()
{
    foo();
}

foo()
{
    bar();
}

When main calls foo, a return address is pushed onto the stack.

Then foo calls bar, which pushes another return address.

The stack may look like:

ASM
Higher addresses

+-----------------------------+
| return address into main    |
+-----------------------------+
| return address into foo     | <- %rsp
+-----------------------------+

Lower addresses

When bar executes:

ASM
ret

it returns to foo.

Then when foo executes:

ASM
ret

it returns to main.

This is why a stack works so well for function calls.

The most recently called function must return first.

This behavior is called:

ASM
LIFO

which means:

ASM
Last In, First Out

Local Variables on the Stack

Consider:

ASM
long foo(long x)
{
    long a = x;
    long b = 10;

    return a + b;
}

An optimized compiler might keep everything in registers.

But imagine the variables are stored on the stack.

A simplified version could look like:

ASM
foo:
    subq $16, %rsp

    movq %rdi, (%rsp)
    movq $10, 8(%rsp)

    movq (%rsp), %rax
    addq 8(%rsp), %rax

    addq $16, %rsp
    ret

After:

ASM
subq $16, %rsp

the stack might look like:

ASM
Higher addresses

+------------------------+
| return address         |
+------------------------+
| b = 10                 | 8(%rsp)
+------------------------+
| a = x                  | (%rsp)
+------------------------+ <- %rsp

Lower addresses

So:

ASM
movq %rdi, (%rsp)

stores a.

And:

ASM
movq $10, 8(%rsp)

stores b.

Register Spilling

Registers are usually preferred because they are fast.

But there are only a limited number of them.

If the compiler needs more temporary storage, it may move some register values onto the stack.

This is called register spilling.

For example:

ASM
movq %rax, 8(%rsp)

might temporarily save %rax.

Later:

ASM
movq 8(%rsp), %rax

loads the value back.

Caller-Saved and Callee-Saved Registers

The calling convention also defines who is responsible for preserving certain registers.

There are two important groups:

ASM
caller-saved registers
callee-saved registers

Caller-Saved Registers

A caller-saved register may be overwritten by the function being called.

If the caller needs that value later, the caller must preserve it.

Conceptually:

ASM
I need this value after the function call.

The called function may overwrite this register.

Therefore I must save it first.

The value may be saved on the stack.

Callee-Saved Registers

A callee-saved register must be restored by the called function if that function changes it.

For example, %rbx is callee-saved under the x86-64 System V calling convention.

A function might do:

ASM
foo:
    pushq %rbx

    # use %rbx

    popq %rbx
    ret

At the beginning:

ASM
pushq %rbx

saves the caller’s original value.

Before returning:

ASM
popq %rbx

restores it.

Function Prologue and Epilogue

You may hear these terms:

ASM
function prologue
function epilogue

The prologue is the setup done when entering a function.

The epilogue is the cleanup done before returning.

A traditional function might look like:

ASM
foo:
    pushq %rbp
    movq %rsp, %rbp
    subq $32, %rsp

    # function body

    addq $32, %rsp
    popq %rbp
    ret

The beginning:

ASM
pushq %rbp
movq %rsp, %rbp
subq $32, %rsp

is the prologue.

The ending:

ASM
addq $32, %rsp
popq %rbp
ret

is the epilogue.

Modern compilers can generate different patterns, especially with optimization enabled.

What Is %rbp?

Traditionally, %rbp is used as a frame pointer.

The problem with %rsp is that it may move while a function executes.

For example:

ASM
pushq %rax
pushq %rbx
subq $32, %rsp

changes %rsp several times.

A frame pointer gives the function a stable reference point.

A traditional setup is:

ASM
pushq %rbp
movq %rsp, %rbp

The stack might then look like:

ASM
Higher addresses

+--------------------------+
| function arguments       |
+--------------------------+
| return address           |
+--------------------------+
| old %rbp                 | <- %rbp
+--------------------------+
| local variable           |
+--------------------------+
| local variable           |
+--------------------------+ <- %rsp

Lower addresses

The function can then use offsets from %rbp.

For example:

ASM
movq -8(%rbp), %rax

could access a local variable.

Because %rbp remains stable, the offset remains easy to reason about.

%rbp Is Often Omitted

Modern compilers frequently omit the traditional frame pointer when optimization is enabled.

Instead, they directly use offsets from:

ASM
%rsp

This frees %rbp for other uses.

So do not expect every function to contain:

ASM
pushq %rbp
movq %rsp, %rbp

Some functions will.

Others will not.

Function Arguments and the Stack

On x86-64 Linux using the System V ABI, the first integer or pointer arguments are normally passed in:

ASM
1st -> %rdi
2nd -> %rsi
3rd -> %rdx
4th -> %rcx
5th -> %r8
6th -> %r9

Additional arguments are usually passed using the stack.

For example:

ASM
foo(a, b, c, d, e, f, g, h);

The first six integer or pointer arguments fit into registers.

The remaining arguments are passed using stack memory.

Stack Alignment

Sometimes you will see stack adjustments that do not appear to store any useful data.

For example:

ASM
subq $8, %rsp
call foo
addq $8, %rsp

One possible reason is stack alignment.

The x86-64 System V ABI requires the stack to follow certain alignment rules around function calls.

The compiler may adjust %rsp simply to satisfy those rules.

So not every stack allocation corresponds to a C variable.

Recursion and the Stack

Consider:

ASM
long factorial(long n)
{
    if (n <= 1)
        return 1;

    return n * factorial(n - 1);
}

Each function call needs its own state.

Calling:

ASM
factorial(4)

creates something like:

ASM
factorial(4)
    factorial(3)
        factorial(2)
            factorial(1)

Each active call may have its own stack frame.

Conceptually:

ASM
Higher addresses

+-------------------------+
| factorial(4) frame      |
+-------------------------+
| factorial(3) frame      |
+-------------------------+
| factorial(2) frame      |
+-------------------------+
| factorial(1) frame      | <- %rsp
+-------------------------+

Lower addresses

When factorial(1) returns, its frame is removed first.

Then factorial(2) continues.

This again follows:

ASM
Last In, First Out

Stack Overflow

The stack has a limited size.

If a program keeps creating stack frames without returning, it can eventually run out of stack space.

For example:

ASM
void bad()
{
    bad();
}

produces:

ASM
bad
 └── bad
      └── bad
           └── bad
                └── ...

Return addresses and stack frames keep accumulating.

Eventually the program runs out of valid stack memory.

This is called a stack overflow.

Large local variables can also consume significant stack space.

For example:

ASM
void foo()
{
    char huge[100000000];
}

How to Read Stack Code

Suppose CS shows:

ASM
foo:
    pushq %rbx
    subq $16, %rsp

    movq %rdi, %rbx
    movq %rsi, 8(%rsp)

    call bar

    movq 8(%rsp), %rdx
    addq %rbx, %rax

    addq $16, %rsp
    popq %rbx
    ret

Read it step by step.

First:

ASM
pushq %rbx

The function saves %rbx.

Then:

ASM
subq $16, %rsp

It reserves 16 bytes of stack space.

Then:

ASM
movq %rdi, %rbx

The first argument is saved in %rbx.

Then:

ASM
movq %rsi, 8(%rsp)

The second argument is saved on the stack.

Then:

ASM
call bar

Another function is called.

After bar returns:

ASM
movq 8(%rsp), %rdx

restores the saved value.

Then:

ASM
addq %rbx, %rax

uses the preserved value together with the result returned by bar.

Finally:

ASM
addq $16, %rsp

releases the stack space.

Then:

ASM
popq %rbx

restores the original %rbx.

Finally:

ASM
ret

returns to the caller.

What initially looked random now tells a clear story:

ASM
save
reserve
store
call
restore
calculate
cleanup
return

Drawing the Stack

One of the best techniques for CS exercises is to draw the stack.

Suppose:

ASM
pushq %rbx
subq $16, %rsp

You can draw:

ASM
Higher addresses

+---------------------------+
| return address            |
+---------------------------+
| saved %rbx                |
+---------------------------+
| stack slot +8             |
+---------------------------+
| stack slot +0             | <- %rsp
+---------------------------+

Lower addresses

Then if you see:

ASM
movq %rdi, 8(%rsp)

update your drawing:

ASM
Higher addresses

+---------------------------+
| return address            |
+---------------------------+
| saved %rbx                |
+---------------------------+
| first argument            | 8(%rsp)
+---------------------------+
|                           | (%rsp)
+---------------------------+ <- %rsp

Lower addresses

This is often much easier than keeping everything in your head.

Functions and Calling Conventions

Now that registers, memory, and the stack are clearer, function calls become easier to understand.

Consider:

ASM
long add(long a, long b)
{
    return a + b;
}

At the assembly level, the CPU does not know that the variables are called a and b.

Instead, programs follow an agreed set of rules called a calling convention.

On x86-64 Linux using the System V ABI:

ASM
1st argument -> %rdi
2nd argument -> %rsi
3rd argument -> %rdx
4th argument -> %rcx
5th argument -> %r8
6th argument -> %r9

An integer return value is normally placed in:

ASM
%rax

So:

ASM
add:
    leaq (%rdi,%rsi), %rax
    ret

becomes much easier to understand.

We know:

ASM
%rdi = a
%rsi = b
%rax = return value

So:

ASM
leaq (%rdi,%rsi), %rax

computes:

ASM
a + b

and stores the result in %rax.

Then:

ASM
ret

returns to the caller.

The important idea is that %rdi does not inherently mean:

ASM
first argument

The hardware does not care.

It has this meaning because of the calling convention.

Comparisons and Condition Codes

Suppose you see:

ASM
cmpq %rsi, %rdi

In AT&T syntax:

ASM
cmp SOURCE, DESTINATION

Conceptually, cmp performs:

ASM
DESTINATION - SOURCE

So:

ASM
cmpq %rsi, %rdi

computes:

ASM
%rdi - %rsi

The subtraction result is discarded.

Only special CPU flags are kept.

Some important flags are:

ASM
ZF = Zero Flag
SF = Sign Flag
CF = Carry Flag
OF = Overflow Flag

For example:

ASM
cmpq %rsi, %rdi
je .L1

means roughly:

ASM
if (rdi == rsi)
    goto L1;

Operand order becomes especially important with greater-than and less-than comparisons.

For example:

ASM
cmpq %rsi, %rdi
jg .L1

means:

ASM
if (rdi > rsi)
    goto L1;

It does not mean:

ASM
if (rsi > rdi)

This is one of the easiest mistakes to make when reading AT&T syntax.

Signed and Unsigned Comparisons

Signed and unsigned values use different conditional jumps.

For signed comparisons:

ASM
jg     jump if greater
jge    jump if greater or equal
jl     jump if less
jle    jump if less or equal

For unsigned comparisons:

ASM
ja     jump if above
jae    jump if above or equal
jb     jump if below
jbe    jump if below or equal

Equality uses:

ASM
je     jump if equal
jne    jump if not equal

So while reading assembly, always ask:

Is this value being treated as signed or unsigned?

Labels and Control Flow

Assembly uses labels to name locations.

For example:

ASM
.L1:
    addq $1, %rax

.L1 represents the location of that instruction.

A jump can then say:

ASM
jmp .L1

which means:

ASM
continue execution at .L1

This is how high-level constructs such as:

ASM
if
else
while
for

become machine-level control flow.

For example:

ASM
if (x == 0) {
    return 1;
}

might involve:

ASM
cmpq $0, %rdi
jne .L1

movl $1, %eax
ret

.L1:

Compiler-generated assembly does not always visually resemble the original C structure.

CS shows patterns such as guarded do-while and jump-to-middle transformations for this reason.

The important skill is following the jumps.

Where Does a Program Start?

Many assembly tutorials contain:

ASM
_start:

and describe it as the beginning of a program.

That is useful when writing standalone assembly programs.

But CS mostly teaches you to understand assembly generated from normal C programs.

For such programs, main() is not literally the first instruction executed.

Conceptually:

ASM
Operating system
       |
       v
program entry point
       |
       v
runtime startup code
       |
       v
main()

For CS, understanding normal functions is usually more important than manually writing _start.

System Calls

Now consider:

ASM
movq $60, %rax
movq $0, %rdi
syscall

Why 60?

Why %rax?

Why %rdi?

The answer is another convention.

A system call is how a normal program requests a service from the operating-system kernel.

Examples include:

ASM
reading
writing
creating processes
mapping memory
exiting

On x86-64 Linux, the system-call convention says that the system-call number goes in:

ASM
%rax

For x86-64 Linux:

ASM
60 = exit

So:

ASM
movq $60, %rax

means:

ASM
perform the exit system call

The first system-call argument goes into:

ASM
%rdi

The exit system call needs an exit status.

So:

ASM
movq $0, %rdi

means:

ASM
exit status = 0

Finally:

ASM
syscall

enters the kernel.

So:

ASM
movq $60, %rax
movq $0, %rdi
syscall

roughly means:

ASM
exit(0);

System Calls Have Their Own Convention

System calls do not use exactly the same register convention as normal function calls.

Normal x86-64 System V function arguments use:

ASM
%rdi
%rsi
%rdx
%rcx
%r8
%r9

Linux x86-64 system calls use:

ASM
1st argument -> %rdi
2nd argument -> %rsi
3rd argument -> %rdx
4th argument -> %r10
5th argument -> %r8
6th argument -> %r9

Notice the fourth argument:

ASM
normal function -> %rcx
system call     -> %r10

One reason is that the syscall instruction itself overwrites %rcx.

System Call Numbers Are Architecture-Specific

The number:

ASM
60

does not universally mean:

ASM
exit

It means exit specifically for the x86-64 Linux system-call ABI.

On Linux ARM64, for example:

ASM
93 = exit

This connects directly back to the beginning of this article:

Low-level behavior depends on both the architecture and the operating-system interface.

Function Calls vs. System Calls

Do not confuse:

ASM
call

with:

ASM
syscall

A normal function call:

ASM
call foo

jumps to another function in your program or a library.

For example:

ASM
call printf

calls the printf library function.

A system call:

ASM
syscall

crosses into the operating-system kernel.

Conceptually:

ASM
Your program
     |
     +-- call foo
     |      normal function
     |
     +-- call printf
     |      library function
     |
     +-- syscall
            operating-system kernel

Connecting C to Assembly

The main skill CS wants you to develop is connecting C code to machine-level behavior.

Consider:

ASM
long load(long *p)
{
    return *p;
}

Using the calling convention:

ASM
%rdi = p

Since p is a pointer, %rdi contains an address.

The assembly could look like:

ASM
load:
    movq (%rdi), %rax
    ret

Now reason through it.

ASM
movq (%rdi), %rax

means:

ASM
treat RDI as an address
read 8 bytes from memory there
place the value into RAX

And because %rax contains the return value:

ASM
return *p;

This small function combines:

ASM
calling conventions
registers
pointers
memory operands
return values

This is exactly the kind of reasoning that becomes useful throughout CS.

How to Read Assembly

Do not try to read assembly like English.

Move through it one instruction at a time.

Ask:

ASM
What values are currently in the registers?

Which registers contain arguments?

Which register contains the return value?

Does this register contain a value or an address?

Are parentheses causing a memory access?

Is memory being read or written?

Did %rsp move?

What is stored on the stack?

What flags did cmp set?

Is the comparison signed or unsigned?

Where can the program jump next?

Did a function call happen?

You can also keep a small table:

ASM
Register    Meaning

%rdi        first argument / pointer p
%rax        return value
%rsp        stack pointer

The meaning changes from function to function, but tracking it makes assembly much easier to understand.

What You Should Know Before Continuing CS:APP

You do not need to memorize the entire x86-64 instruction set.

The most important ideas are:

  1. Assembly is architecture-specific.x86-64, ARM64, and other architectures use different registers and instructions.
  2. CS mainly uses x86-64 AT&T syntax. AT&T usually writes: source, destination
  3. Registers are small storage locations inside the CPU.They can contain normal values or memory addresses.
  4. Register size matters.%rax, %eax, %ax, and %al refer to different portions of the same register.
  5. Writing to %eax clears the upper half of %rax.
  6. Assembly operands can be immediate values, registers, or memory locations.For example:$10 %rax (%rax)all mean different things.
  7. Parentheses usually mean memory is being accessed. movq (%rdi), %rax reads from the address stored in %rdi.
  8. lea performs address calculation without reading memory.
  9. The stack is ordinary memory used heavily by functions.
  10. %rsp points to the top of the stack.
  11. The x86-64 stack grows toward lower addresses.
  12. push stores a value on the stack.
  13. pop removes a value from the stack.
  14. call pushes a return address onto the stack.
  15. ret uses that return address to return to the caller.
  16. Functions may reserve stack frames for local variables and temporary values.
  17. Caller-saved and callee-saved registers explain why values are sometimes pushed onto the stack.
  18. %rbp can act as a frame pointer, but optimized code often omits it.
  19. Calling conventions give registers meaning.
  20. cmp performs a subtraction and keeps only the flags.
  21. Operand order matters in AT&T syntax.
  22. Signed and unsigned comparisons use different jumps.
  23. Labels represent locations in code or data.
  24. System calls follow their own convention.
  25. System-call numbers depend on the architecture and operating system.

The main goal is not to become an assembly programmer.

The goal is to understand what happens underneath the C code you already know.

Once you understand registers, memory operands, pointers, the stack, calling conventions, control flow, and system calls, the assembly in CS stops looking like random instructions and starts looking like another representation of the same program.

Comments

One response to “Assembly 101”

  1. Winston Sketch Avatar

    this shit gass

Leave a Reply

Your email address will not be published. Required fields are marked *