Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

5 Commits
 
 
 
 

Repository files navigation

Assembly for Apple Silicon

What is assembly?

Assembly is a programming language. It's the first abstraction that you can have over the bytecode. And it's the most used programming language, technically...

Assembly code is splitted into parts called instructions. Each instruction can operate on some operands. So imagine instructions as the type of operation that we want to conduct over some operands. An instruction normally is laid out like this:

instruction operand1, operand2, ...

Instructions are the most basic units of code. They are translated into bytecode, so technically you could think a computer as an assembly interpreter. That's why modern programming languages such as C or Rust, actually translate their code to assembly and that into bytecode. That is called compiling.

Since CPUs are different, so is their assesmbly syntax. There are various types of assembly dialects: x86_64, x86, RISC-V or ARM... But actually you can find nuances between the assemblies of the same dialect. For instance, there are various types of ARM assembly, a code compiled for a Raspberry PI (ARMv6) couldn't probably run on an Apple Silicon device (ARM64 / Aarch64).

This book will teach how to write assembly code for the Apple Silicon ARM64 / Aarch64 archiecture

How to compile the examples

First, create a file to put all your code in. It should end with the .S extension, since technically each dialect has its own extension (.asm for x86_64, .S for ARM64).

Then, to compile it, install the Xcode toolkit from Apple. Then, use the as command as follows:

as -arch arm64 file.S -o file.o

Change file.S to your code's filename and file.o to the desired name of the output. .o stands for Object File and it basically is a encapsulation, not ready to execute yet, version of your code. To acually get it running, you should use a linker which basically links object files toghether.

This is the command you need to paste. It's a bit verbose since macOS requires some extra stuff in order to compile the files correctly:

ld file.o -lSystem -syslibroot `xcrun -sdk macosx --show-sdk-path` -e main -arch arm64 -o exec

Change file.o to the name of your Object file, and change exec to the name of the executable you want to generate.

If you have the necessary skills, you can generate a Makefile to compile this faster.

Chapter 1 - Getting Started

How to start a program

To start a program, we need to learn about labels.

Labels are when in the middle of the code you tell the compiler to: "Save this point for later. We're going to call it 'x'". We denote labels like this: name:, and then the code that goes after is basically the one at that label. Labels can denote two main things:

  • A point on the program.
  • The start of a section of a program.

So, for instance, let's have a look at some assembly code for the first time! This code won't run yet, since we need to make some adjustments, but just look and try to understand

main:
other:

This will run:

stateDiagram-v2
    [*] --> main
    main --> other
Loading

So, basically, progressively, main will lead to other. Let's try to do something else:

main:
    b final
other:
final:

b is out first instruction, it means to jump (branch) to a precise label This will run:

stateDiagram-v2
    [*] --> main
    other
    main --> final
Loading

other is never called, so this code will just skip other. Let's actually make this code runable! But we need to do something else first... See, the code runs whatever it puts to the instruction, so this code will run this:

stateDiagram-v2
    [*] --> main
    other
    main --> final
    final --> ???
Loading

The final label is actually just a point in memory. The CPU does not know where the program ends so it will keep executing and it will result into an indefined behaviour. So let's actually terminate the program. This means to call the OS and request that your progrmam closes, if not, it will run into undefined behaviour, as said.

The Registers

The Registers are 31 banks that contain whatever you want to put on them. They are not in memory but rather on the CPU itself, so they are super fast to read and write from. They are notated from x0 to x30. Let's see a couple of things you can do with them

The General Purpose Registers

The General Purpose Registers are the ones you, the programmer, can use nearly always and are the ones that are shared. There is some convention on how and when to use some of them:

Register Names Can be used safely? What are they usually used for?
x0-x7 Yes Normally, they recieve the arguments, the data that some function needs to have. But they can be used with total normality.
x8 Yes This is normally the Indirect Result Location. It basically is specified by the code that calls a function, and it tells: "Hey, the result may be too large... If it is, could you place it in memory here?"
x9-x15 Yes This are the most miscelaneous registers, and you should use them every time you may be able to use them.
x16, x17 No These are reserved for the compiler or linker.
x18 No This is reserved for the macOS system.
x19-x28 Sometimes These registers are expected to be the same after the function call, which means you must restore them. We'll explain this later.
fp (x29) Sometimes This is the Frame Pointer and it's used by the system to manage processes.
lr (x30) Sometimes Manages the return address of a function, changing it may result in undefined behaviour.
sp Sometimes This is the Stack Pointer, we'll cover it later when we tackle the Stack.
xzr Yes (cannot write) This basically gives a 0 value. Not very useful.

The Variants

There are some variants to these registers. Since we're in ARM64, each register is 64 bits (a.k.a u64). But we have some variants.

You can have 32 bit registers by replacing the x with a w in the registers:

  • w0 is technically x0
  • w23 is technically x23

How to store values

We can move values to registers with an instruction mov. But, you need to account addressing. It's very difficult and we're going to cover it in more detail, but for now, we're going to use Immediate Addressing, which basically represents constants that do not change, and they're prefixed with #.

mov x0, #1 // This is a comment, it allows us to tell things without being executed
mov x4, #0x65 // This translates to 101 (0x means hexadecimal)
mov x5, #0b01001 // This translates to 9 (0b means binary)
mov x6, #0o654 // This translates to 428 (0o means octal)

// You can also move the value from one register to another
mov x7, x6 // Now, both 'x7' and 'x6' have 428 stored in them

There are also, two extra ways to move values:

  • movz: clears (sets to zero) the rest of the register when moving the value. Only 16 bits
  • movk: keeps the rest of the register when moving the value. Only 16 bits
  • movn: keeps the rest of the register and moves the inverted of the value. Only 16 bits

But you'll mostly use mov in normal code.

System Calls and Interrupts

Interrupts are super simple things, these are functions that run at higher privileges that the ones you are in. A Kernel (the hardware manager and core of the OS) usually runs two separate parts:

block-beta
    columns 3
    m("User Program"):2

    k("Kernel Services"):2
    space
    space
    s{{"Interrupts"}}:2

    m --> s
    s --> k

Loading

We can call interrupts with the Supervisor Call Instruction (svc) instruction, which takes as operands the number of the interrupt.

You use interrupts to request or do many things that require core operations since you cannot operate with the kernel without using them. Each interrupt is identified with an specific code. But the most important interrupt is 0x80 which is the System Call Interrupt.

Sometimes, you need to ask for things to the Operating System, that's when System Calls come in. System Calls basically are: "Hey macOS! could you display something for me?" or "Hey macOS! could I write this to the file". They are just like a utiltity interrupt, that you can use to do many things! Let's see the structure of a System Call:

sequenceDiagram
    User->>Syscall: The system call number (in x16)
    User->>Syscall: The arguments that the syscall expects (x0-x5)
    Syscall-->>User: The return value (x0)

Loading

What this means is that each System Call expects something in. As we go to someones here, we'll tell the structure of them, since they'll be the most used ones. Apple hasn't said much about the System Call structure of their System, although in the Kernel Source Code, we can see the official list that I'll leave here

Making the First System Call

We had this program over here:

main:
    b final
other:
final:

Let's make it runnable! First, let's set some things that'll stay forever in our code.

Instructions that start with . are called Directives. These affect the compile process (not usually the output), and tell stuff about what our program looks like.

The first issue is that, out program doesn't know where to start. If you recall, in our linking code we have this:

-e main

This -e stands for entry and it's the starting point of our program (main:). The issue is that by default, the compiler will treat main: as a simple marker in our code, it'll be like: "Allright! main is just at offset 0x0, great!". And we need to tell him: "Stop there! main is important, treat it like a global!. Then the linker will export our symbol (label) and it'll be ready to function.

To do that, let's add our first directive:

.global main // This tells the compiler to export the symbol main

Now, the compiler will be aware of the function. But, we have another problem. Darwin (the macOS Kernel), is super picky when talking about alignment. Alignment is when you place your code in a special address that satifies a requisite: Aligning to $n$, declares that the address must be divisible by $2^n$. It is because it's faster to search instructions that way (since each instruction is usually 4 bytes). Let's align by using another directive:

.align 4

So, our code looks like this now:

.global main
.align 4

main:
    b final
other:
final:

But, we're not ready yet. Remember what I said? final will read random bytes and that will cause problems. How can we end this? We can terminate the process. Through a System Call, we can tell macOS to close the process we're running in, so the program ends. It's similar to: return 0; in int main() in C++ or C. Let's do steps:

The Terminate Process System Call is System Call 0x1 and it needs one argument. And that's the Process Exit Code. It's the 0 in return 0;. If the code is 0, everything's allright! Otherwise, the shell might return an error. This is the code for it:

.global main
.align 4

main:
    b final
other:
final:
    mov x0, #0 // The first argument, says that the exit code is 0
    mov x16, #1 // The value here indicates, which system call are we referring
    svc #0x80 // We call interrupt 0x80 (syscall)

Now, our code is complete:

stateDiagram-v2
    [*] --> main
    other
    main --> final
    final --> [*]: System Call 0x1
Loading

Chapter 2 - Create Simple Programs

How to store data

Data is key to your program, and you need to store it somehow. Data is simply something that lives in memory, and that you can access with ease, but first we need to store it.

This is our program:

.global main
.align 4

main:
    mov x0, #0
    mov x16, #1
    svc #0x80

You may be able to understand something of it. It basically represents this:

stateDiagram-v2
    [*] --> main
    main --> [*]: System Call 0x1
Loading

Now, let's store some data!

You can store, many different types of data:

  • .ascii: It stores a string of ASCII characters without a delimiter. Each bytes represents a letter or punctuation sign.
  • .asciz or .string: They store a string of ASCII characters but it puts a 0x00 byte at the end, so you know when it ends. This is called a C string since it is used in the programming language C.
  • .byte: It stores a u8 or an array of them (numbers ranging from 0x00 to 0xFF).
  • .word: It stores a u16 or an array of them.
  • .4byte: It stores a u32 or an array of them.
  • .int: It stores a i32 or an array of them (it can be positive or negative).
  • .8byte: It stores a u64 or an array of them.
  • .quad: it stores a i64 or an array of them (it can be positive or negative).
  • .float and .double: They store decimal numbers. These behave like in programming languages.

Let's store a string that says something!

hello: .ascii "Hello, World!\n"

Tip

You can also specify some alignment for the data, by using .align <n> before the data

Great! Now hello is some place in memory where this string is stored! Why don't we calculate the length of this string? This is not very complicated.

hello_len = . - hello

Let's analyze this. hello_len = is defining a constant. This will just be like a replacement in code. Everytime we put hello_len it'll be substituted by 14. But why the rest?

See, . means, give me the current address we're in. So, that becomes pretty simple:

flowchart TD
p[("'.' is 0xA000 ")]
w("We store the string in memory at '.' [0xA000] ")
ps[("'.' is 0xA000 + len(string)")]
l("We can calulate the length as ('.' [0xA00E] - addr(string) [0xA000]) which is 0xE (14)")
p --> w
w --> ps
ps --> l
Loading

We can actually use . in many other occasions:

b .

Since the CPU updates . after reading an instruction, this will jump (branch) to the same address that the instruction is and will loop forever.

How to load data

When we want to access data, we do not copy the string to some register, instead we just get it by the address where it's stored.

This is our code now:

.global main
.align 4

main:
    mov x0, #0
    mov x16, #1
    svc #0x80 // Exit syscall

hello: .ascii "Hello, World!\n"
hello_len = . - hello

So, let's try getting a reference to that hello string. How? Another instruction. Meet adr which is the Address Instruction. It basically retrieves the address of some code and it places it into a register, like this:

adr x0, hello

Now, x0 will contain the address where hello is stored. So, let's do some string magic. Let me introduce you to another simple instruction: add. add takes a destination, source and value. So, what happens if we add some number to hello?

add x0, x0, #3 // x0 will now point to "lo, World!\n"

Since the address loaded in x0 is just a number, this is totally valid!

Caution

Note that now, hello_len is no valid anymore. So you shouln't use it.

How to print to the screen

Now, with a string in memory, your code should look something like this:

.global main
.align 4

main:
    adr x1, hello

    mov x0, #0
    mov x16, #1
    svc #0x80 // Exit syscall

hello: .ascii "Hello, World!\n"
hello_len = . - hello

Note, that I have changed the register to load from x0 to x1, you'll see why.

Let me introduce you to The Write System Call. This is System Call 0x4. It basically writes to something on our system. It writes to something called a File Descriptor. You can request file descriptors (we'll get to there). But for now, you should know that there are three default file descriptors:

  • The stdin file descriptor: You normally read from and not write to. It is the responsible of getting input. 0x00
  • The stdout file descriptor: You normally write to and not read from. It is the output. 0x01
  • The stderr file descriptor: It's the same as the stdout except for errors. 0x02

So, this system call expects three arguments:

  • The File Descriptor at x0
  • The Buffer Address at x1 (the string we want to print)
  • The Length at x2 (how many character characters do we want to write)

And it returns the Length of the bytes written to the x0 register (for sanity check).

Let's get printing:

.global main
.align 4

main:
    mov x0, #1 // File descriptor set to stdout
    adr x1, hello // Load the string address to x1
    mov x2, hello_len // The length of the bytes we want to write
    mov x16, #4 // The number of the syscall we want to conduct
    svc #0x80 // We call the syscall

    mov x0, #0
    mov x16, #1
    svc #0x80 // The Exit syscall

hello: .ascii "Hello, World!\n"
hello_len = . - hello

Chapter 3 - Arithmetic

Simple Arithmetic Instructions

Arithmetic instructions are the ones that perform calculations over on registers to add, subtract or more to one register. Here's a list of basic operations:

  • add: Takes a destination register, a source register, and then another source:
mov x1, #40
add x0, x1, #40 // x0 = x1 + 40 -> x0 = 40 + 40 -> x0 = 80
  • sub: Takes the same arguments as add:
mov x1, #40
sub x0, x1, #40 // x0 = x1 - 40 -> x0 = 40 - 40 -> x0 = 0
  • mul: Takes a destination register and two source registers:
mov x1, #40
mov x2, #40
mul x0, x1, x2 // x0 = x1 * x2 -> x0 = 40 * 40 -> x0 = 1600
  • neg: Negates a register value and places it in a register:
mov x1, #10
neg x0, x1 // x0 = -x1 -> x0 = -10

Caution

Negation is not implemented in the traditional way. Negating in computing means finding the two's complement of a value. (That's how integers work). So negating 10 would give 0xFFFFFFFFFFFFFFF6. That means you must treat the values with disclosure. If the value is bigger than 9,223,372,036,854,775,807 you must treat it as a negative number.

  • adc: Adds the values and the last carry to a register:
mov x1, #40
adc x0, x1, #40 // x0 = x1 + 40 -> x0 = 40 + 40 -> x0 = 80

Bitwise Operations

Instruction Syntax Name
and and Xd, Xn, Xm Bitwise AND
ands ands Xd, Xn, Xm Bitwise AND, update flags
orr orr Xd, Xn, Xm Bitwise OR
orrs orrs Xd, Xn, Xm Bitwise OR, update flags
eor eor Xd, Xn, Xm Bitwise XOR
eors eors Xd, Xn, Xm Bitwise XOR, update flags
bic bic Xd, Xn, Xm Bit Clear (AND NOT)
bics bics Xd, Xn, Xm Bit Clear, update flags
lsl lsl Xd, Xn, #<shift> Logical Shift Left
lsr lsr Xd, Xn, #<shift> Logical Shift Right
asr asr Xd, Xn, #<shift> Arithmetic Shift Right
ror ror Xd, Xn, #<shift> Rotate Right
rbit rbit Xd, Xn Reverse Bits
clz clz Xd, Xn Count Leading Zeros
cls cls Xd, Xn Count Leading Sign bits

Flags

Flags are useful because they tell us stuff about what the last result was. There are four main flags:

Flag Name What does it tell? Usecase
N It tells us if the result was negative To check whether the number is negative or positive.
Z It tells us if the result was zero To check if two numbers are equal.
C It increments if the last result caried some number To perfomrm operations
V It increments if the last result overflowed Security reasons

By default, the arithmetic instructions don't update the flags, but to make them update, we just add s to end of compatible instructions. Some compatible instructions are:

  • adds
  • subs
  • adcs
  • sbcs
  • ands
  • orrs
  • eors
  • bics
  • movs
  • negs

Decimal Numbers

Decimal Numbers are treated differently that other values, since they're special. They are loaded into some particular registers, called V registers, which are 128-bit wide.

For decimal numbers, we use floats and doubles, which work with single-precision (32-bit) and double-precision (64-bit) numbers respectively.

  • The registers v0-v31 are the full 128-bit registers
  • The registers s0-s31 are the 32-bit lower part, used for floats
  • The registers d0-d31 are the 64-bit lower part, used for doubles
  • The registers q0-q31 are the full parts, used when dealing with SIMD (later)
  • The registers h0-h31 are the list of half precision floats
  • The registers b0-b31 are the byte view

To move a value to a register like this, we use the fmov instruction instead of the mov one. And we also have different arithmetic instructions:

  • fadd for adding
  • fsub for subtracting
  • fmul for multiplying
  • fdiv for dividing

    [!WARNING] None of these instructions expect an Inmediate Addressing Mode. This instruction: fadd d0, d1, #3.2 is invalid. But fmov d1, #3.15 is valid.

Also, we have some instructions that allow conversion between different types:

  • scvtf: Is the Simple Converted to Float and it converts a 32-bit wX value into a single-precision float.

scvtf s0, w0
  • fcvtzs: Converts a double into a 64-bit xN value.

fcvtzs x0, d0

SIMD (Single Instruction Multiple Data)

This mode, processes multiple values in one instruction over a v register, which results super useful for code that requires fast-paced calculations.

For this, SIMD subdivides the v registers into some other values. For instance:

  • .4s 4 x 32-bit floats
  • .2d 2 x 64-bit doubles
  • .8b 8 x 8-bit bytes
  • .4h 4 x 16-bit words
  • .2s 2 x 32-bit ints

Let's for instance, see how to load data into a SIMD. First, let's meet something new:

Indirect Register Addressing Mode is the mode where you obtain the data from the memory allocated at some register. It's notated like [xN] and it's basically this:

sequenceDiagram
    Code->>x0: We load some address (0xA000)
    x0-->>Code: With Indirect Register Addressing Mode, we get the value at 0xA000 in memory
Loading

So, for example, let's try loading some value into a SIMD. For this, we're going to use the ldr instruction, which is super used. Let's see an example of this:

.global main
.align 4

main:
    adr x0, random_bytes // We get the address of 'random_bytes'
    ldr q0, [x0] // We load the contents of random_bytes to the SIMD register q0

    mov x0, #0
    mov x16, #1
    svc #0x80 // Exit syscall
random_bytes: .byte 0x08, 0x09, 0xA, 0xB, 0xC, 0xD, 0xE, 0xF

We can perform operations in these with the floating-point instructions. SIMDs are something advanced.

Chapter 4 - The Program

Sections

A program can be splitted by sections. Each section refers to a thing, has its own alignment, etc...

These are powered though the Mach-O structure on macOS and are different in other systems. We're going to cover two of them. The Mach-O version for macOS, and the ELF version for Linux (since it is the most used):

  • Code section (text). Contains the whole code, it's denoted by the following directive:
.section __TEXT,__text // in macOS (Mach-O)
.text // in Linux (ELF)
  • Constant Strings. Contains the constant strings that the program should use, it's denoted by:
.section __TEXT,__cstring // only exists in macOS
  • Initialized Data. Contains the initialized data that can be written to, it's denoted by:
.section __DATA,__data // in macOS (Mach-O)
.data // in Linux (ELF)
  • Unitialized Data. Contains the uninitialized data that can be written to, denoted by:
.section __DATA,__bss // in macOS (Mach-O)
.bss // in Linux (ELF)
  • Constant Data. Contains the constant data that should not be changed, denoted by:
.section __DATA,__const // only exists in macOS

You do not have to use them necessarally, but the'll keep your code organized and safe.

The Stack

The Stack is basically a space in RAM, where you can push or pull values. So, let's have a look with a diagram:

flowchart TD
    t[("The stack pointer points to where the stack address is. (0xA000)")] --> a
    a@{ shape: curv-trap, label: "Some value 32-bit (4 bytes) is pushed to the stack. The stack grows downwards"} --> f
    f[("The stack pointer now stores 0x9FFC")] --> e
    e@{shape: bow-rect, label: "Some value is pulled from the stack"} --> c
    c[("The stack pointer now stores 0xA000")] --> t
Loading

But... Let's look at this diagram

flowchart TD
    t[("The stack pointer points to where the stack address is. (0x8000). This is the lowest it can get")] --> a
    a@{ shape: curv-trap, label: "Some value 32-bit (4 bytes) is pushed to the stack. The stack causes a Stack Overflow."} --> f
    f[("The stack pointer now stores 0x7FFC. Corrupts data.")] --> e
    e@{shape: bow-rect, label: "Some value is pulled from the stack"} --> c
    c[("The stack pointer now stores 0x800")] --> t

    style a fill:#ffb3b3
    style f fill:#ffb3b3
    style e fill:#ffb3b3
    style c fill:#ffb3b3
Loading

So, we should avoid corrupting the stack. Please, be careful with it. The stack pointer is a register, which can be accessed by using the sp alias. Let's look at how to store some data to the stack:

Storing Data into the Stack

This can be done in two ways. The first is the most normal one, and it's constructed in a couple of ways:

First, we can store two registers and update the stack pointer with the Store Two Registers instruction:

mov x0, #1 // r0 holds 1
mov x1, #3 // r1 holds 3

stp x0, x1, [sp, #-16]! // Stores both r0 and r1 to the stack

This is another new addressing mode. The Pre-Indexed Addressing Mode basically updates the register by the number that we have inputted. It basically means:

flowchart TD
    u("Updates the value of sp, by increasing it to 'n'") --> a
    a("Reads what's in sp")
Loading

Otherwise, we can manually choose to allocate space for the register that's comming with the Store instruction:

mov r0, #1 // r0 holds 1
mov r1, #3 // r1 holds 3
sub sp, sp, #16 // The stack grows downwards

str x0, [sp, #0] // Store it at the most recent allocation
str x1, [sp, #8] // Store it at the previous entry

This is another addressing mode, which basically adds an offset to the code, and retrieves its data:

flowchart TD
    u("Load the value at sp + offset")
Loading

Caution

Now, this is a very difficult thing to understand. But the stack must be always aligned to 16, which means that you need to always push pairs of registers into a stack. Before you jump to a label or call a function, the stack must be aligned.

Loading Data from the Stack

We can load the data with the Load Register instruction:

ldr x2, [sp, #0]
add sp, sp, #16 // Restore the stack. It eats TWO ITEMS

But, if we want to get more than one, we can. With the Load Pair instruction:

ldp x0, x1, [sp], #16 // Load them, after increment the value

This addressing mode is known as Post-Indexing Addressing Mode, and it basically does:

flowchart TD
    u("Load the value at sp") --> a
    a("Update the value of sp, by increasing to it 'n'")
Loading

Functions

Functions are the most important part of every code. They organize and they are useful tools to help you make great things. Why don't we create one?

First, we need to understand two instructions:

  • bl, saves the current instruction address to the x30 register. Then, it jumps to the address specified.
  • ret, goes to the address that x30 contains.

Note that, registers are shared between processes, so, they are kept the same. Let's try creating some simple function

.global main
.align 4

main:
    mov x1, #5
    mov x2, #10

    bl my_add // It jumpts to my_add. This will contain 15.

    mov x0, #0
    mov x16, #0x1
    svc #0x80 // Exit syscall

// We reccommend documenting your assembly functions, since they don't accept structs or something
// my_add x y -> z
// x: x1
// y: x2
// z: x0
my_add:
    add x0, x1, x2
    ret // Return to the original place

Great! But we're a bit limited... We have a problem. Maybe our expensive computation needs more registers, and the user expects them to return intact. The registers from x19-x30 are up to you! You can use them, but you must guarantee that the user will have them back after the function has finished running.

Step 1: Preserve the important ones

You must always preserve x29 and x30 and start a new stack frame. Think of it as the start of a new era. Like the start of a new program stage.

stp x29, x30, [sp, #-16]! // Store both into memory
mov x29, sp // Let's start a new stack frame!

// The function...

add sp, sp, #16 // Reallocate the space you took
ldp x29, x30, [sp], #16 // Restore them both
ret

Step 2: If you want to use registers from x19-x30, save them and load them later.

stp x29, x30, [sp, #-16]! // Store both into memory
mov x29, sp // Let's start a new stack frame!
stp x19, x20, [sp, #-16]! // Store these two

// The function...

ldp x19, x20, [sp], #16 // These are first
ldp x29, x30, [sp], #16 // Restore them both
ret

Let's create some code. Some code that prints something to the screen, like a routine!

.global main
.align 4

main:
    adr x0, string
    mov x1, string_len

    bl print

    mov x0, #0
    mov x16, #1
    svc #0x80 // Exit syscall

// print(string, len) -> nothing
// string: x0
// len: x1
print:
    stp x29, x30, [sp, #-16]!
    mov x29, sp

    mov x5, x0 // string

    mov x0, #1 // stdout
    mov x2, x1 // string
    mov x1, x5 // len
    mov x16, #0x4 // syscall

    svc #0x80 // Write syscall

    ldp x29, x30, [sp], #16
    ret

string: .ascii "Hello, functions!\n"
string_len = . - string

Chapter 5 - Control Flow

What is control flow?

Control Flow is the ability to change the course of a program depending on some conditions that happen during the execution of it. We may basically have: loops and conditionals. Let's have a look at some of these.

Comparing Assembly to C

First, we need to know some basic things. Assembly is clever, super clever. Let's see this code fragment in C:

if (a == 54) {}

If you know something about programming, you may know that the == operator basically checks equality between two variables or numbers. Now, in Assembly, we do not have these operators, but there's a better way, a more clever one. Let me introduce you to the compare (cmp) instruction, which is basically the subs instruction, but it does not save the result to a register. It just updates the flags:

mov x0, #54
mov x1, #54
cmp x0, x1 // Note that, if n is equal to a, it means -> n - n = 0; So a must be n if the result is zero

We also have cmn to compare negative. Which instead is the adds instruction:

mov x0, #-54
mov x1, #-54
cmn x0, x1 // Note that, if n is equal to a, it means -> n + n = 0; So a must be n if the result is zero

Now, if you can recall, if the registers are the same, the zero flag should be set. Let's have a look at advanced branching.

Advanced Branching

This point is going to be about all the types of branching we can encountering in a program. This will be accompained with examples, and will introduce conditional branching.

Regular Branching

Regular branching can be done with two instructions:

  • b is for jumping to a label without it being a subroutine. If the label returns, it will pop the value from the stack and will result in unexpected runtime. Do not use it if you're not sure that that label is a subroutine.
  • br is for jumping to the raw address that a register contains. It should only be used if you actually know what's on that register.

Subroutine Branching

To jump to a label that is a subroutine, use the bl instruction, which will push to the stack the current address, so that the subroutine can return properly.

If you are sure that the address that contains a register is actually a subroutine, call it with blr.

Conditional Branching

Conditional branching can be made in two patterns:

  • You can put b. and then the mnemonic
  • You can just put b and then the mnemonic
Mnemonic (Subinstruction) Description Flag Checking
eq Checks if a == b $Z = 1$
ne Checks if a != b $Z = 0$
lt Checks if a < b (the numbers are signed) $N \not ={V}$
le Checks if a <= b (the numbers are signed) $Z = 1 \space or \space N \not ={V}$
gt  Checks if a > b (the numbers are signed) $Z = 0 \space and \space N = V$
ge Checks if a >= b (the numbers are signed) $N = V$
hi Checks if a > b (unsigned, but faster) $C = 1 \space and \space Z = 0$
ls Checks if a <= b (unsigned, but faster) $C = 0 \space or \space Z = 1$
cs Checks if a >= b (unsigned, but faster) $C = 1$
cc  Checks if a < b (unsigned, but faster) $C = 0$

Now, there're some flags and stuff that are difficult to understand, that's because it's deep hexadecimal and bit computing, and yes, it's difficult. You'll mostly use the basic ones, except when dealing with unsigned numbers.

Also, you can compare to zero with the cbz and not to zero with the cbnz

Conditional Statements

With the learned conditional instructions, we can start making our own first conditional function. Try creating a function that recieves two addresses, two numbers and a number indicating if the addresses are functions. Then, the numbers are equal jump to a part, if not, jump to another. Make sure to account for functions.

You'll use adr to load the label address to a register, beq for checking equality and br to jump wherever the register has stored and blr if the address is a function.

Here's how we implemented it:

.global main
.align 4

main:
    adr x0, isequal
    adr x1, notequal

    mov x2, #4
    mov x3, #4
    mov x4, #0

    bl check_equality

// check_equality(adr1, adr2, n1, n2, isFunction)
// adr1: x0
// adr2: x1
// n1: x2
// n2: x3
// isFunction: x4
check_equality:
    stp x29, x30, [sp, #16]! // let's store these two
    mov x29, sp // We start a new frame

    cmp x2, x3 // compare these two
    bne load_false
    mov x5, x0 // x5 will contain the address we need to call
    b call_fn
load_false:
    mov x5, x1
call_fn:
    cmp x4, #0 // check if it's a function
    ldp x29, x30, [sp], #16
    beq raw_label
    blr x5
    ret
raw_label:
    br x5 // we won't be able to return

isequal:
    mov x0, #0 // zero return code == good!
    mov x16, #1
    svc #0x80 // exit syscall

notequal:
    mov x0, #1 // one return code == bad...
    mov x16, #1
    svc #0x80 // exit syscall

Is this a bit complicated? Let's draw a diagram of what would happen if we were to run this program

flowchart TD
    A@{ shape: sm-circ, label: "Small start" } -->
    B@{ shape: hex, label: "We load the addresses of the function to the designated registers (x0, x1)" } -->
    C@{ shape: hex, label: "We set the two numbers to be 4." } -->
    D@{ shape: hex, label: "We set x4 to 0 indicate that it's not a function." } -->|We call check_equality| E@{ shape: odd, label: "We preserve the registers and start a new frame" } -->
    F@{ shape: subproc, label: "We compare the two registers, we update flags. The Z flag is set since they are equal" } -->
    G@{ shape: diamond, label: "Is the flag Z disabled?" }

    G --> |"Yes (load_false)"| H@{ shape: hex, label: "They're not equal, we set x5 to x1" } -->M
    G --> |No| L@{ shape: hex, label: "They're equal, we set x5 to x0" } -->|call_fn| M

     M@{ shape: subproc, label: "We compare the register x4 to the true value (#1)" } -->
     V@{ shape: hex, label: "Since we're jumping out of the function, we restore the stack" } -->
      N@{ shape: diamond, label: "Is the flag Z enabled?" }

    N --> |"Yes (raw_label)"| Q@{ shape: manual-input, label: "Jump to the address at x5. Note that, we're not going to return (not a function)."}
    N --> |No| W@{ shape: manual-input, label: "We jump to the address at x5, but as a function"} --> X@{ shape: sm-circ, label: "Small start" }
Loading

Now, you just have to be persistent and try use maths to make calculations.

Loops

Loops are easy. We do not have a while or stuff we have in C, but we have labels. This:

b .

Is the same as while (true) {} in C. Jumping is indeed looping. Let's create a simple loop that just adds one to a number and it arrives to some limit:

loop_fn:
    mov x1, #10 // Our maximum will be 10
    mov x0, #0 // We set our counter to 0
loop:
    cmp x0, x1
    beq break
    add x0, x0, #1
    b loop
break:
    ret

You should be able to understand the whole code. It's quite simple. But let's actually have some interesting ideas. Here's where you can start imagining over Assembly. This will be actually 11 loops. Since it will be:

0 1 2 3 4 5 6 7 8  9 10 // BREAK
1 2 3 4 5 6 7 8 9 10 11 // 11 loops in total

So, we need to mov x1, #9 instead of #10.

As a final project, try making a function that calculates the length of a null-terminated string, and then it prints to the screen that string with syscalls, so you know if the length is valid or not.

Before doing that, We need to teach you a simple instruction. The ldrb instruction Loads a raw byte from memory. Use it as so:

ldrb wX, [xN] // load to register X the byte in memory that points N

Here's how we did it:

.global main
.align 4

main:
    adr x1, hello
    bl calc_len // x0 now contains the length
    mov x2, x0
    mov x0, #1
    mov x16, #0x4
    svc #0x80 // write syscall
exit:
    mov x0, #0
    mov x16, #0x01
    svc #0x80 // exit syscall

// calc_len(str) --> len
// str: x1
// len: x0
calc_len:
    mov x0, #0 // initialize counter
    mov x3, x1 // let's preserve the address, 'cause we'll perform operations in it
loop:
    ldrb w2, [x1]
    cmp w2, #0
    beq end
    add x1, x1, #1
    b loop
end:
    sub x0, x1, x3 // Calculate the offset (addr + len - addr) = len
    mov x1, x3
    ret

hello: .asciz "Hello, World!\n"

Chapter 6 - Macros

What are macros?

Macros are simply like a 'copy-paste' of your code. They are not like functions in the sense, that they are expanded in compile time. This means that basically you can create your own aliases for instructions. For instance:

exit 13

Could be expanded

mov x0, #13
mov x16, #0x1
svc #0x80

This is useful, because when you are writing long code, you don't want to repeat something, like this:

stp x29, x30, [sp, #-16]!

This would be super nice to substitute by a macro and etc...

How to define macros

Macros are compile things, since they are not present in the byte-code. Thus, we use a special directive-wise syntax. Let's create two simple macros: push and pop.

.macro push reg1, reg2
    stp \reg1, \reg2, [sp, #-16]!
.endm

.macro pop_regs reg1, reg2
    ldp \reg1, \reg2, [sp], #16
.endm

It's super simple, and super useful. The arguments that the macro takes must be prefixed with \ when using them inside the macro.

Let's call them!

push x29, x30
pop x29, x30

It's as simple as that!

Advanced Macros

Macros are not just that. They can have two other things that make them ideal.

Default Arguments

Default Arguments are just basically placeholders you can give to certain macro values:

.macro load_const reg, val=0
    mov \reg, #\val
.endm

load_const x1 // mov x1, #0
load_const x1, 13 // mov x1, #13

Conditionals

You can introduce some coniditionals that behave differently. These conditionals expand at compile-time, not at runtime. It lets you check whether some register is some other, or whether a value is another.

.macro test val
    .if \val == 0
        mov x0, #0
    .else
        mov x1, #1
    .endif
.endm

Chapter 7 - More System Calls

Assembly is a powerful language, but quite verbose. We decided to skip a couple of subjects like Concurrency and Threads since its a mess and it does not work as expected always. Therefore, we're just going to have a look at some handly syscalls.

How to create a File Descriptor

We talked about File Descriptors earlier and now we're going to create one. There are three steps on reading a file:

flowchart TD
    A(Open the File Descriptor)
    A --> B(Read from the file)
    A --> C(Write to the file)
    B --> D
    C --> D
    D(Close the file descriptor)
Loading

Now, before, we must take in account some constants:

  • Open as Read-Only is code 0x00
  • Open as Write-Only is code 0x01
  • Open as Read/Write is code 0x02
  • Append on each write is code 0x08
  • Create the file if it does not exist is code 0x200

You may create the final Flags by or'ing the bytes, so:
Read + Write + Append + Create = 0x2 | 0x08 | 0x200 = 0x20A

Ok! Now, we need a couple more new syscalls to achieve this:

  • To open the file, we've got the open syscall (0x05). Which takes arguments as follows:

    • x0 should contain the Pointer to the filename
    • x1 should contain the Flags

    This will return the file descriptor in register x0. If the file descriptor is negative, they we're screwed up...

  • To read from the file we've got the read syscall (0x03). Which takes arguments as follows:

    • x0 should contain the File descriptor
    • x1 should contain the Empty buffer
    • x2 should contain the Number of bytes we want to read
  • To write to a file we've got the write syscall (0x04). It's the same as when we were printing to the stdout but with a different file descriptor:

    • x0 should contain the File Descriptor
    • x1 should contain the Full buffer
    • x2 should contain the Length of the buffer
  • To close a file descriptor we've got the close syscall (0x6). Which takes arguments as follows:

    • x0 should contain the File Descriptor to close.

Before you're able to read from a file, remember that we store buffers in the .section __DATA,__bss region with the directive: .space n. Let's create a program that does that! Create a program which it reads 100 bytes from a file of your choice.

Read from stdin

Reading from stdin is super simple. Use the read syscall toghether with 0x00 for stdin. You can try reading 100 bytes from the stdin! But also, try reading until the end of the line. If you read byte per byte and increment the pointer you'll be able to obtain a cool result!

Chapter 8 - Extensions

Extensions are simple pieces of instructions or special flows that happen just in a certain platform with specific instructions that diverge from the standard ARM64 Assembly Specification. In this book, we'll focus on macOS extensions.

Atomics (LSE)

An atomic is a simple way of having a guarantee that a register, address or process doesn't run at two CPU cores at the same time, causing a race condition. Race conditions should be avoid, since they don't ensure a value and they lead to inconsistent variables or even to undefined behaviour.

An Atomic Operation

An Atomic Operation is an operation that is:

  • Indivisible, so it cannot be interrupted
  • Guaranteed to happen completely or fail without running at all
  • Other CPUs never see a "half-done" result. Just the result is computed

Atomics in Assembly

We can see various instructions to guarantee atomics.

Let's take a look:

atomic_function:
    // In this function we're going to use atomics to add a value
    ldxr w1, [x0] // This loads the pointer from x0 to w1
    add w1, w1, #1 // We then do the operation we want
    stxr w2, w1, [x0] // We restore the pointer to x0
    // Note that: if the operation fail, it will fail completely and store a non-zero number in the first register (w2) in stxr
    cbnz atomic_function // Retry

This is one of the safest ways to use a multi-threaded enviornment with assembly.

Newer Atomics

Atomics have changed, and for simple operations, Apple Silicon provides specific functions to make rapid commands with atomics.

  • ldadd takes: a register to store the new value, a register to the old value, and the memory to add.

And more...

There are a lot more atomics than the ones we've covered.

Branch Target Identification (BTI)

This technology is very useful for security matters. It basically says: just jump to a subroutine which has an identification number that says that you can run this code

Apple enables this in the assembler by default, but we want to teach you a bit on how it works:

  • bti c indicates that the target is valid for indirect (pointer) calling (blr)
  • bti j indicates the target is valid for jumps (b)
  • bti jc indicates the target is valid for both

The assembler automatically adds these when compiling, so you shouldn't care a lot. Just know it happens.

Pointer Authentication (PAC)

PAC is an extension that Apple uses a lot. It lets you sign pointers cryptographically. It prevents corruption attacks and more types of different malicious content that a person could do with bits.

Let's see the types that exist:

  • IA and IB are keys for an instruction. Something like to sign return addresses.
  • DA and DB they are all for data pointers. They do the same.

Let's see a couple of instructions.

Basic PAC Instructions

First of all, the computer has to be sure the return address is valid always. Imagine a hacker intercepts the return address, changes it and they it just goes to somewhere else. That isn't great... That's why we have the paciasp instruction.

paciasp signes with PAC the x30 register (the one that store the return address). It is added automatically by Apple, normally combined with bti c.

To check whether the return address is good, we just run autiasp and that will tell us if the x30 contains the same return address.

Also, if you are willing to, you can use IB instead of IA to sign the return address with pacibsp and autibsp (for unknown reasons).

Protect data with PAC

The pacda instruction is used to sign a register and check its contents. It's very useful to intercept other attackers or modifications to one register.

The pacda instruction is used with: a register which is the one to sign, and some extra salt that can be added to produce the same result.

The autda instruction basically checks that signature to keep the values.

my_function:
    mov x0, #13523 // Our dummy value
    mov x1, #1234 // Our salt
    pacda x0, x1 // We sign our x0

    autda x0, x1 // We check for the register

Chapter 9 - Exception Levels and Privilege

An exception level is the mode in which the CPU runs. It basically limits or allows you to execute certain instructions or prohibits you of acting on some registers. Also it defines memory access rights (kernel memory vs user memory).

Apple Silicon has 4 rings. Each higher ring controls the lower rings and a lower ring cannot access resources from higher rings. Rings are also referred as EL (Exception Level).

  • EL0 is for the User Space applications. It needs system calls to actually interface with EL1
  • EL1 is for the Kernel. It can access all the memory, interrupt controller and more. It handles everything.
  • EL2 is for the Hypervisor. It can run a virtual machine and it controls every virtual machine's resources. It restrics EL1 instructions to allow for multiple operating systems to live.
  • EL3 is for the Secure Monitor or TrustZone. It is an ARM specific that basically handles security. For instance: Apple Pay keys live in a Secure World, where it's protected. Applications live in a Normal World.

Here are the ways to go up from privilege:

flowchart TD
    A(EL0) -->|System Calls| B(EL1)
    B -->|Virtualization Traps| C(EL2)
    B -->|Secure Monitor Calls| D(EL3)
Loading

And here the ways to go down from privilege:

flowchart TD
    A(EL2) -->|Hypervision Returns| E(EL1)
    C(EL3) -->|Secure Firmware| D(EL2)
    C -->|Secure Firmware| E
    C -->|Secure Firmware| F(EL0)
    A -->|Hypervision Returns| F
Loading

Each exception level has its own registers and stack available.

Chapter 10 - Interfacing in an OS Enviornment

Using Assembly in your own operating system can be exauhsting if you don't know how to use tools that make the work much easier.

Assembly is the language of open-source. Since Assembly is so literal that is sort of incrustated into an executable, this means that everyone can literaly use tools to translate a binary compiled for macOS thanks to a disassembler.

A disassembler

A disassembler is very useful, it basically turns an executable into a bunch of assembly cose we can read. Let's take a look at what it can give us.

First of all, we're going to compile a simple program to see what the disassembler outputs. If you remember this is one of our simple programs we used to make:

.global main
.align 4

main:
    mov x0, #1
    adr x1, hello
    mov x2, hello_len
    mov x16, #4
    svc #0x80

    mov x0, #0
    mov x16, #1
    svc #0x80

hello: .ascii "Hello, World!\n"
hello_len = . - hello

To disassemble the executable type:

llvm-objdump -d exec

In my case, this gave the following output:

Disassembly of section __TEXT,__text:

00000001000002e0 <main>:
1000002e0: d2800020     mov     x0, #0x1                ; =1
1000002e4: 100000e1     adr     x1, 0x100000300 <hello>
1000002e8: d28001c2     mov     x2, #0xe                ; =14
1000002ec: d2800090     mov     x16, #0x4               ; =4
1000002f0: d4001001     svc     #0x80
1000002f4: d2800000     mov     x0, #0x0                ; =0
1000002f8: d2800030     mov     x16, #0x1               ; =1
1000002fc: d4001001     svc     #0x80

0000000100000300 <hello>:
100000300: 6c6c6548     ldnp    d8, d25, [x10, #-0x140]
100000304: 57202c6f     <unknown>
100000308: 646c726f     <unknown>
10000030c: 21 0a        <unknown>

So let's just take a look!

As you can see the first lines define the main label at address 0x1000002e0. The instructions look familiar! In fact, they are the same we put! Except in the adr call, the disassembler incrusts the actual address that is going to be referenced and places the label besides it.

If we take a look at the hello label we can se an instruction: ldnp d8.... This instruction is pretty weird, isn't it?! Well, the thing is that technically, we have to put the strings in the data section, remember about the sections? Because we never changed sections, the disassembler is malinterpreting the bytes of the string as an instruction. But we can just ignore it.

To really kick things off, let's take a look at some C code. Let's try printing "Hello, World!\n" and disassembling it. This is the code I am going to use:

#include <stdio.h>

int main() {
    printf("Hello, World!\n");
    return 0;
}

After disassembling, we get:

Disassembly of section __TEXT,__text:

0000000100000460 <_main>:
100000460: d10083ff     sub     sp, sp, #0x20
100000464: a9017bfd     stp     x29, x30, [sp, #0x10]
100000468: 910043fd     add     x29, sp, #0x10
10000046c: 52800008     mov     w8, #0x0                ; =0
100000470: b9000be8     str     w8, [sp, #0x8]
100000474: b81fc3bf     stur    wzr, [x29, #-0x4]
100000478: 90000000     adrp    x0, 0x100000000 <_printf+0x100000000>
10000047c: 91128000     add     x0, x0, #0x4a0
100000480: 94000005     bl      0x100000494 <_printf+0x100000494>
100000484: b9400be0     ldr     w0, [sp, #0x8]
100000488: a9417bfd     ldp     x29, x30, [sp, #0x10]
10000048c: 910083ff     add     sp, sp, #0x20
100000490: d65f03c0     ret

Disassembly of section __TEXT,__stubs:

0000000100000494 <__stubs>:
100000494: 90000030     adrp    x16, 0x100004000 <_printf+0x100004000>
100000498: f9400210     ldr     x16, [x16]
10000049c: d61f0200     br      x16

So, let's try making a sense of this. First, I just want to tell you some curiosity that I just loved when a knew. C wants to know the types of all the variables, and that's for a reason. It first has to add every and each variable width to decide how much space to allocate. So, this first instruction sub sp, sp, #0x20 is saving 32 bytes for variables. The next two lines just reserve the space.

  • stp x29, x30, [sp #0x10] stores the frame pointer and the return address into the stack. That consumes 16 bytes.
  • add x29, sp, #0x10 sets the frame pointer for the function.

Note that for all addresses, macOS requires 16 bytes division. So if we have: 16 bytes for the stack pointer and 4 bytes for the u32 that return 0 uses. That's 20 bytes. But we need to make it multiple of 16, so we round up to 32. That is the 32 bytes that sub sp, sp, #0x20 does.

The following code initializes the variable used for return 0.

  • mov w8, #0 prepares the copy for a value 0
  • str w8, [sp, #0x8] does just that! It stores the integer
  • stur wzr, [x29, #-0x4] just creates padding with the zero register

Then we need to calculate the printf function. Let's see how this works. First, we need to account for printf in every enviornment. That is rough without a relocatable format, like the one we've compiled our file to. Let's skip the two following instructions:

  • adrp x0, 0x100000000 basically tells: "Hey! Give me the aproximate address (page address) of the symbol"
  • add x0, x0, #0x4a0 and that just adds this offset to concretely pinpoint the function's address

Finally we bl to printf (that is a thing you already know).

The final four instructions do:

  • ldr w0, [sp, #0x8] loads the stored integer (the return value) to w0
  • ldp x29, x30, [sp, #0x10] loads the previous frame pointer and return address
  • add sp, sp, #0x20 refills the stack pointer and tells the space is just free
  • ret returns to the caller

Finally we can take a look at the section that says <__stubs>. Basically a stub is a function whose address was not resolved at compile time. This includes library functions like printf. Basically, the runtime libraries will replace the stub by the actual printf function.

That was a disassembly!

Debuggers

Debuggers are very useful in terms of putting stop to your program and seeing the actual things that happen. Let's go!

First, launch your debugger in macOS with lldb ./exec, replacing ./exec with your executable path.

Breakpoints

In the command prompt, we're going to set a breakpoint, which is a stop in the code. Let's run the printf example and use this:

b printf

This basically means: Stop at printf.

You can also do:

  • breakpoint set --address <addr>
  • breakpoint set --file <file> --line <line>

And then we can just run to see ourselves stopping in that function.

Stepping

We can step one instruction with stepi, we can use nexti to skip and instruction but skip the calls. And continue to go over until the next breakpoint.

step will step into the next function, next over to the end of the function and finish for running until the function returns.

Inspecting

To inspect the registers, type register read. And register read <reg> to read an specific register.

To read from memory use memory read --format <x for hexadecimal> --size <8 bytes per entry> --count <how_many_entries> <addr or $sp for stack pointer>. It can read ASCII with --format c.

We can inspect the stack with frame info or frame variable (for variables). And we can see the backtrace with bt.

Disassembling

To see the interiors of a function, we can use disassemble --frame or disassemble --name <name>. That allows us to see futher on how a function is implmented.

And you can quit with quit.

Chapter 11 - Optmization and Performance

Apple Silicon processors are beasts, and they leverage a great performance and functions. But you must use them correctly and wisely. This chapter explains performance and various issues that exist within them.

Memory Access

The Cache

The Cache is a super fast memory region situated between the CPU and the Main Memory, in a region called DRAM. Its job is to store copies of recently-used memory for the CPU to read and write much faster.

Caches exploit these two properties:

  • Temporal locality: if you access address adr now, you'll probably access adr again soon.
  • Spatial locality: if you access adr, you'll access the nearby addresses soon probably

The cache is composed of some units:

  • The Cache Line (a cache block)
    • It is the smallest unit transferred between memory and cache
    • It is 64 bytes for Apple Silicon
    • When the CPU loads a byte, the whole line is transferred
  • The Cache Levels
    • Each level has different access velocities. L1 is the fastest, and L2 the slowest
    • The slowest level is DRAM. This level is extremely slow (~200ns)
  • A cache set
    • A whole level is divided into sets
    • Each cache has some ways (8 in Apple Silicon)

We then calculate the address of the cache following the calculations:

  • Level's size (L1 size = 128 KB = 128 * 1024 = 131072 bytes)
  • Line's size = 64 bytes (in Apple Silicon)
  • Number of lines = Level's size / Line's size (N = 131072 / 64 = 2048 lines)
  • Ways = 8
  • Sets = Lines / Ways (2048 / 8 = 256 sets)
  • Index = log2(sets) (log2(256) = 8 bits)

The final address is [tag | index | offset]

Then we have cache misses. Cache misses are basically moments where the write or read to the cache fail:

  • A cold miss happens when is the first time you access and address (has never been cached)
  • A capacity miss happens when the set is too big for the level
  • A conflict miss happens when two address are mapped to the same set

This matters for accessing and modifying speed. You can access L1 in 3 cycles (very fast, a cycle means an instruction run), L2 with ~10 cycles and DRAM with hundreds of cycles.

The CPU automatically accesses the cache, so you don't have to worry about handle it manually. Although, you can force it to prefetch some data. For instance:

prfm pldl1keep, [x0, #64] // This fetches 64 bytes from the address stored at x0 to L1

Prediction Hints

Computers are smart, and predition is an art in where you can help computers pre-fetch data and do more stuff. You can help your computer predict in conditional cases where to jump and trim some cycles with csel.

For instance, let's look at this code:

// Selects the valid address
// x0 -> (address 1)
// x1 -> (address 2)
// x2 -> (value 1)
// x3 -> (value 2)
// if x2 > x3 it will jump to x0
// if x2 < x3 it will jump to x1
select_address:
    cmp x2, x3 
    blt address_1
    blr x0
    ret
address_1:
    blr x1
    ret

This is super inefficient. With csel you can select a value, and that is way faster. Try this:

select_address:
    cmp x2, x3 // We compare the two values
    csel x4, x0, x1, lt // We say: if is 'lt' (less than), move x0 to x4, ifnot move x1 to x4
    blr x4

Much shorter, much faster.

Simple Functions

Try inlining certain small functions. Functions like:

simple_add:
    add w0, w0, w1

Should be inlined. Don't call this function, just replace its contents, or use a macro.

Loop Optimizations

Unrolling

Unrolling consists on when you have this:

add_array:
    ldr w1, [w0], #4
    add w2, w2, w1
    subs x3, x3, #1
    bne add_array

You can kind of predict that the loops may last for at least a couple of cycles. You can unwrap the function by multiplying its runs per call:

add_array_unrolled:
    ldr w1, [x0], #4
    ldr w4, [x0], #4
    ldr w5, [x0], #4
    ldr w6, [x0], #4
    add w2, w2, w1
    add w2, w2, w4
    add w2, w2, w5
    add w2, w2, w6
    subs x3, x3, #4
    bne add_array_unrolled

Instruction Scheduling

Try putting instructions that do similar things next to them. Try to load every piece of memory just when the function starts and try to perform non-blocking operations after.

Chapter 12 - Practices and Standards

To end, we're going to talk about callee-saved vs caller-saved functions.

Callee-saved functions

In callee-saved functions, the caller expects to have every register and argument as it was before calling the function (except the return value). That means to load, restore and perform operations in order to preserve state.

Caller-saved functions

In caller-saved functions, the caller is responsible on knowing that the values feed to function will not be returned after the function execution as it was before.

Thanks

This book just scratches the surface. I encourage you to search for a dissassembler online. These are programs that convert bytecode into actual instructions again. So as they say: Everything if open-source, if you know how to read assembly.

Max Van den Eynde © 2025

About

A Book that explains in Detail ARM64 Assembly

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors