x86-64 assembly from the ground up

The stack and functions

push and pop on the stack, call and ret as a pair, and a function that the program can use twice without writing the write call twice.

Part 7 of 12

Intermediate18 min readBack to Courses

The stack grows downwards

rsp is the stack pointer: the address of the last thing pushed. push subtracts eight from it, writes the value there, and pop reads the value back and adds eight. The stack therefore grows towards lower addresses, which looks odd on paper and is perfectly ordinary in use.

It is a last-in-first-out pile. Whatever was pushed most recently is the first thing a pop returns, so a function can save a register at the top of its body and restore the same register at the bottom without knowing anything about what happens in between.

The stack is just memory, and rsp is just a register. That is worth saying because it makes the rest of this chapter mechanical rather than magical: pushing is a store, popping is a load, and the address used is whatever rsp happens to hold.

call and ret are a push and a jump

call takes a label, pushes the address of the instruction after the call, then jumps to that label. ret pops the address back into the instruction pointer, so execution resumes exactly where it left off. The two instructions are partners, and a function that returns through a ret must have been reached by a call.

That pushed address is why the entry point of every program in this course ends with the exit call rather than a ret: nothing called it, so there is no return address to pop.

Arguments are wherever the caller and the function agree to put them. Here the convention is the one the kernel uses for write: an address in rsi, a count in rdx. A function that uses rbx, r12 or rbp must push and pop it, because those registers belong to the caller and it will want them back.

A function in the middle of a program

_start:
  mov rsi, a        ; where the bytes start
  mov rdx, 11       ; how many of them
  call write_line   ; push the return address, then jump
  mov rax, 60       ; ... and execution comes back to here
  xor rdi, rdi
  syscall

write_line:
  push rbx          ; rbx belongs to the caller
  mov ebx, 1
  mov eax, 1
  mov rdi, rbx
  syscall
  pop rbx           ; give it back unchanged
  ret               ; pop the return address and go there

One function, two calls

Run it, then add a third message and a third call. The function does not change at all: only the address and the count you hand it are different.

Output
Click "Run" to execute your code

The second call is the interesting one. rbx holds the address of the second message across the first call, which only works because the function pushed it and popped it again before returning.

The stack remembers where to come back to

Each call pushes one address: the place to resume when the called function returns. A second call made from inside that function pushes another address on top, and its ret removes only that one. The outer return address is still underneath, untouched, which is why functions can call functions to any depth.

The stack pointer is the bookkeeping. It always points at the most recent thing pushed, so ret does not need to know which call it belongs to; it just pops the top address. Keep the pushes and pops balanced and the chain works without any names or IDs.

This is also why a function cannot simply jump to its caller. The address it needs is data on the stack, and ret is the instruction that reads it back into the instruction pointer.

What a function owes its caller

Registers are shared by everyone, so a convention decides who saves what. In the convention used here, rax, rcx, rdx, rsi, rdi, r8, r9, r10 and r11 belong to the caller: a function may overwrite them freely. rbx, rbp, r12 through r15 and rsp belong to the callee, which must put them back exactly as it found them.

Arguments and return values are part of the same agreement. This course passes an address in rsi and a count in rdx, and a function that computes something would leave the result in rax. None of that is enforced by the processor; it is a habit the caller and callee share.

Saving a callee-saved register costs two lines, a push at the top and a pop before the ret. Skipping them is faster and wrong: the caller's value is gone, and the bug shows up far from the function that caused it.

Three stack mistakes. A push without its matching pop leaves ret reading the saved value as an address. A pop without a push steals the return address early. And a function that uses rbx without saving it corrupts a value the caller still needs.

Try these next

  • Add a third call inside write_both and confirm the output grows by one line.
  • Write a function that returns a value in rax and have the caller print a line based on it.
  • Remove the pop rbx from the first example and explain where the program ends up.

A function that calls another function

Run it. `_start` calls `write_both`, and `write_both` calls `write_line` twice. Then add a third message and a third call inside `write_both`; neither the entry point nor the inner function changes.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does call put on the stack?