greateric's blog

By greateric, history, 3 months ago, In English

If you were ever curious but were confused by the youtube tutorials. I'm going to try to make this as concise and useful as possible. Also, x86 is way easier to code in than ARM and if you disagree, you're wrong. And Intel syntax is better than AT&T syntax

This assumes you have a basic understanding of how pointers work in C

First program & compiling

Make a file named hello.s:

.intel_syntax noprefix

.section .rodata
mystring:
    .ascii "Hello, World!\n"
mystring_end:

.section .text
.globl main
.type main, @function
main:
    endbr64
    push rbp
    mov rbp, rsp

    mov eax, 1  # write
    mov rdi, 1  # stdout
    lea rsi, mystring[rip]
    mov rdx, mystring_end - mystring
    syscall

    xor eax, eax

    leave
    ret

Compile and run with

$ gcc -o hello hello.s
$ ./hello

In assembly, you are writing CPU instructions, so you only have access to low-level things:

  • Registers: the main ones are rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp, and r8...r15
  • Memory (dereferencing pointers, writing to the stack, etc.)
  • System calls

The C stack

Your CPU only knows how to execute instructions, so how do we have functions be able to call other functions or themselves? The answer is the stack: each function has its own stack frame. For example, if main has its stack frame, and if it calls printf, then a new stack frame will be created for printf. Once printf is done executing, we will pop printf's stack frame and go back where we left off to main.

I'm now going to explain the actual nitty gritty of how this works; it may be a bit hard to follow along so if you're confused please let me know! I think it is fun to understand this though.

Two registers are dedicated to managing the stack: rbp: base pointer, and rsp: stack pointer. The base pointer points to the beginning/base of the stack frame, while the stack pointer points to the end. Also be aware that in x86, the stack grows downward, i.e., the main stack frame will be at a higher address in memory, and as more data is pushed onto the stack, the address decreases.

Here's an example of how it works in practice.

Suppose your code section looks like this (remember that code/instructions are also loaded in memory)

address instruction
main:
...
5598    load pointer corresponding to format string into rdi
55a0    load integer into rsi
55a8    call printf
55b0    set exit code to 0
55b8    leave
55c0    ret
...
printf:
57d8    endbr64
57e0    push rbp
57e8    mov rbp, rsp
57f0    random instruction that does printing
57f8    another random instruction that does printing
5800    yet another random instruction that does printing
5808    leave
5810    ret

So, we're currently on the stack frame of main() and we're about to execute the instruction call printf at 55a8. After we're done executing printf, we need to execute the instruction at 55b0. So, we push the address 55b0 onto the stack. This is called the return address, the CPU will jump to 55b0 after it's done executing printf.

Now we jump to the address of printf, 57d8. The first instruction is always endbr64, without getting too deep, it's a security feature; if you try to call a function and the first instruction is not endbr64, the CPU will segfault (if the security feature is enabled).

Then, push rbp pushes the previous (main's) base pointer to the stack. Then mov rbp, rsp makes rbp point to the beginning of the new stack frame, which is the current rsp. Note that the CPU automatically moves rsp to the end of the stack whenever anything is pushed or poped.

The stack will look something like:

address     data
7ffe6590    <random data in the stack for main, like main's local variables>
7ffe6588    <random data in the stack for main, like main's local variables>
7ffe6580    <random data in the stack for main, like main's local variables>
7ffe6578    <return address telling us where to go back to in main()>
7ffe6570    <main's rbp value saved>     # rbp now points to 7ffe6570
7ffe6568
7ffe6560
# rsp always points to the end of the stack, which is `7ffe6570` for now, but it may move down if `printf` has local variables, etc. that it wants to put on the stack.

Eventually, printf will finish executing. The leave instruction is kind of like a macro — it will dereference rbp and set rbp to that value. rbp is a pointer to the beginning of the frame but it also points to main's previous rbp value (see the figure above at 7ffe6570). So this restores rbp back to its previous value when we go back to main(). It also automatically resets the stack pointer to the beginning of the frame, which effectively "deletes" everything in the current stack frame.

Finally, ret will read the return address, which is now the last thing in the stack, and jump to the instruction we were previously at. The rbp and rsp registers are restored to the same places that they were before printf was called.

Arithmetic operations

You can perform arithmetic operations on registers. For example, add eax, ecx performs eax = eax + ecx.

Note that the letter you prefix a register with denotes its size: rax is the full 64-bit register, eax is the lower 32 bits of the register, ax is the lower 16 bits of the register, and ah is the upper byte of ax, and al is the lower byte of ax. The same thing applies for rbx, etc. For r8 through r15, it's r8 for full size, r8d for 32 bits, r8w for 16 bits, and r8b for 8 bits.

So add eax, ecx performs 32-bit addition and add rax, rcx performs 64-bit addition. For addition and subtraction, the same instruction does both signed and unsigned addition/subtraction because of some magic in the representation of integers called 2's complement.

For multiplication, it's a bit more complicated: mul rxx multiples rax by rxx and places the lower 64 bits in rax and upper 64 bits in rdx. If you do mul exx, it multiplies eax by exx and places the lower 32 bits in eax and upper 32 bits in edx.

That's unsigned multiplication. Signed multiplication is imul.

For division, it's also a little weird. div rxx takes the 128-bit value rdx:rax and divides it by rxx, places the quotient in rax and remainder in rdx. So if you want to do 64 bit division, you have to make sure to zero out rdx. Then idiv rxx is the same but for signed division. You may need to sign extend: cqo sign extends rax to rdx:rax, and cdq sign extends eax to edx:eax. (Sign extension is required if you're dealing with negative numbers.)

The multiplication and division stuff is a bit complicated, but luckily we're in the age of AI so you don't have to memorize this.

Moving data around

You've probably seen the move instruction: mov rax, rdx copies rdx into rax (and similar for 32-bit register views).

To move between registers and memory, you can do something like mov DWORD PTR [rbp-8], 572. [rbp-8] means dereference the pointer rbp-8, so we're dereferencing the location 8 bytes below the base of the current stack frame. DWORD PTR [rbp-8] means we're interpreting it as a double word (32 bits). We also have QWORD PTR (64 bits), WORD PTR (16 bits), and BYTE PTR. So this writes the number 572 into the 32-bit integer located at [rbp-8].

Here are a few more examples. Can you tell what they mean?

  1. mov eax, DWORD PTR [rbp-20]
  2. mov QWORD PTR [rbp-8], rbx
Spoiler

If you look at assembly generated by GCC, you'll see a lot of moves relative to rbp. That's because we like to reference variables by their location relative to the base of the stack frame. For example, if you declare int a, b, c; in a function, GCC may decide to place a at [rbp-4], b at [rbp-8], and c at [rbp-12].

We also have lea for load effective address. It's typically useful for pointer arithmetic (you can also just use add/sub but lea is typically more idiomatic and faster).

For example,

# This loads `rbx + rax` as an address into `rdi`. It's equivalent to `rdi = rbx + rax` so you can technically replace it with `add` instructions. In practice this could mean that `rbx` was a pointer to an array of chars and `rax` is the index, so this is `&rbx[rax]`.
# Note that we have brackets here but the memory is not actually dereferenced.
lea rdi, [rbx + rax]

# If `rbx` is a pointer to an array of 4-byte ints, then this is like `&rbx[rax]`.
# Note that you can't just put arbitrary arithmetic here, lea only allows `[base + size*index + offset]`. And size can only be 1, 2, 4, 8.
lea rdi, [rbx + 4*rax]

# If `rbx` is a pointer to 8-byte structs, and `field` is located at a 3-byte offset in the struct, then this is like `&rbx[rax].field`.
lea rdi, [rbx + 8*rax + 3]

Sections

There are .section directives. The sections are:

  • .text: code
  • .data: global variables, that can be pre-initialized to a certain value
  • .bss: global variables, that are automatically zero-initialized
  • .rodata: read-only constants, such as literal strings in C

Global variables

This in C:

int32_t y = 1000;
int64_t z = 67;
char s[] = "forcescode";

Is this in assembly:

.section .data

.globl y
y:
    # Also .byte, .short
    .long 1000

.globl z
.align 8  # Unaligned data will not cause errors in modern x86_64, but it is slower.
z:
    .quad 67

.global s
s:
    # .string or .asciz null-terminate the string, .ascii does not
    .string "forcescode"

Note that .globl is a directive for it to be visible to outside files or not. It's like static vs non-static in C.

For bss, you just reserve space:

.section .bss

.globl myarray
myarray:
    .zero 24000  # 24,000 bytes

String literals

Typically, you'll put these in rodata:

.section .rodata

mystring:
    .string "codeforces"

Also, typically when referencing data or bss values, you need instruction pointer relative addressing. Because your code needs to be position-independent, you can't have a fixed address to reference the global variable, you need it relative to rip, the instruction pointer register.

# Equivalent
lea rdi, mystring[rip]
lea rdi, [rip + mystring]

Both of these reference mystring, but as an offset relative to rip. At link time, the offset of mystring relative to rip is calculated, and then the code will correctly reference mystring.

Basic calling convention

I'll probably go in more detail on this in part 2. For now, here's the basics of how to call other functions correctly.

You pass the first 6 arguments in registers: rdi, rsi, rdx, rcx, r8, r9. The classic way to remember it is " Diana's silky dress costs 89".

Additionally, for variable argument functions like printf or scanf, you also need to zero out eax. (I'll explain why later.)

For example, if you wanted to printf("The answer is: %d\n", answer), you would

lea rdi, location_of_string[rip]  # Assuming the string literal is in .rodata
mov esi, [pointer to variable `answer`]
xor eax, eax  # "Better" way to zero out a register than `mov eax, 0`
call printf

Then, the function's return value is given in rax/eax/ax/al (depending on if the return value is a 16-bit or 32-bit or 64-bit or pointer). That's also why we do xor eax, eax at the end of main(), because that's like saying return 0.

Note that this is grossly oversimplified and that the real calling convention is a lot more complex than this. But this should be good enough for now. The other gotcha that you should know for now is that some registers may be overwritten by the function that you call, while others might not. This is called caller vs callee saved registers; for now you can assume that none of your registers are safe and to always save them to memory.

Example exercise

Write a program that reads two 32-bit signed integers from standard input, and tells tells the user "The sum of your two integers is ." Use scanf and printf from the C stdio. You do not need to worry about overflow.

How to allocate local variables, read this before attempting
Answer

In part 2, I'll probably cover more details about the calling convention (passing more arguments, structs, caller and callee saved registers) and talk about conditionals and branching. See you then!

  • Vote: I like it
  • +38
  • Vote: I do not like it

»
3 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

You could just have posted a link to a textbook, and provided better content in fewer words. This reads like an uninspired and rambling information dump.

  • »
    »
    3 months ago, hide # ^ |
     
    Vote: I like it 0 Vote: I do not like it

    Well, I tried my best. I don’t think textbooks cover this stuff very well, I learned it all mostly from talking to my professor in one of my systems courses.

  • »
    »
    3 months ago, hide # ^ |
     
    Vote: I like it +2 Vote: I do not like it

    Also, beware that rsp is required to be 16-byte aligned, or else you will get segfault.

    and this is just misinformation without the relevant context, the ABI only requires rsp to be 16-byte aligned just before calling something

  • »
    »
    3 months ago, hide # ^ |
     
    Vote: I like it +17 Vote: I do not like it

    You could just have posted a link to a textbook, and provided better content in more words. This reads like an uninspired and rambling complaint.

»
3 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

Auto comment: topic has been updated by greateric (previous revision, new revision, compare).

»
3 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

bro codeforces isn't your university notebook

»
3 months ago, hide # |
Rev. 2  
Vote: I like it +7 Vote: I do not like it

Upvoted. All who downvote this blog are idiots. x86-64 asm and ARM asm are both nice(ok, x86-64 version is a bit more readable). Waiting first contest in asm only(you can do inline assembly right?)

  • »
    »
    3 months ago, hide # ^ |
     
    Vote: I like it +4 Vote: I do not like it

    lol in our architecture course it was exclusively arm and I did not like it

    • »
      »
      »
      3 months ago, hide # ^ |
       
      Vote: I like it +7 Vote: I do not like it

      I read this: this and this. Yes, I know it's "useless," but at least it's fun, and no one's stopping me from doing it. In addition, at the university I had a course purely(not operation systems/computer architecture) on x86-64 asm, which was also cool.

»
3 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

intrinsics did NOT exist for this bro :sob:

»
3 months ago, hide # |
 
Vote: I like it +8 Vote: I do not like it

Not a bad tutorial. Here are my 2 cents: I think you might want to mention, that you're using the system V ABI calling convention+syscalls, so the code wouldn't exhibit the same behavior on e.g. Windows, which has a diff ABI. Are you planning to dive into optimization in asm?

  • »
    »
    3 months ago, hide # ^ |
     
    Vote: I like it 0 Vote: I do not like it

    Ah shoot I did not realize. Probably not too much advanced stuff because I low key don't know it :sob: I just learned the basics a while ago and thought it was funny

»
3 months ago, hide # |
Rev. 2  
Vote: I like it +8 Vote: I do not like it

deleted

»
3 months ago, hide # |
 
Vote: I like it +8 Vote: I do not like it
»
3 months ago, hide # |
 
Vote: I like it +8 Vote: I do not like it

Assembly has got its own aura ig!

»
2 months ago, hide # |
 
Vote: I like it +8 Vote: I do not like it

cool mnemonic!