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
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.oChange 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 execChange 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.
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
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
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 --> ???
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 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 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. |
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:
w0is technicallyx0w23is technicallyx23
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 themThere are also, two extra ways to move values:
movz: clears (sets to zero) the rest of the register when moving the value. Only 16 bitsmovk: keeps the rest of the register when moving the value. Only 16 bitsmovn: 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.
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
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)
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
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 mainThis -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 mainNow, 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
.align 4So, 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
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 #0x80You may be able to understand something of it. It basically represents this:
stateDiagram-v2
[*] --> main
main --> [*]: System Call 0x1
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..ascizor.string: They store a string of ASCII characters but it puts a0x00byte 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 from0x00to0xFF)..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)..floatand.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 = . - helloLet'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
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.
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 = . - helloSo, 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, helloNow, 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.
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 = . - helloNote, 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
stdinfile descriptor: You normally read from and not write to. It is the responsible of getting input.0x00 - The
stdoutfile descriptor: You normally write to and not read from. It is the output.0x01 - The
stderrfile descriptor: It's the same as thestdoutexcept 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 = . - helloArithmetic 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 = 80sub: Takes the same arguments asadd:
mov x1, #40
sub x0, x1, #40 // x0 = x1 - 40 -> x0 = 40 - 40 -> x0 = 0mul: 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 = 1600neg: Negates a register value and places it in a register:
mov x1, #10
neg x0, x1 // x0 = -x1 -> x0 = -10Caution
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| 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 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:
addssubsadcssbcsandsorrseorsbicsmovsnegs
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-v31are the full 128-bit registers - The registers
s0-s31are the 32-bit lower part, used for floats - The registers
d0-d31are the 64-bit lower part, used for doubles - The registers
q0-q31are the full parts, used when dealing with SIMD (later) - The registers
h0-h31are the list of half precision floats - The registers
b0-b31are 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:
faddfor addingfsubfor subtractingfmulfor multiplyingfdivfor dividing[!WARNING] None of these instructions expect an Inmediate Addressing Mode. This instruction:
fadd d0, d1, #3.2is invalid. Butfmov d1, #3.15is valid.
Also, we have some instructions that allow conversion between different types:
scvtf: Is the Simple Converted to Float and it converts a 32-bitwXvalue into a single-precision float.
scvtf s0, w0fcvtzs: Converts a double into a 64-bitxNvalue.
fcvtzs x0, d0This 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:
.4s4 x 32-bit floats.2d2 x 64-bit doubles.8b8 x 8-bit bytes.4h4 x 16-bit words.2s2 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
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, 0xFWe can perform operations in these with the floating-point instructions. SIMDs are something advanced.
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 macOSYou do not have to use them necessarally, but the'll keep your code organized and safe.
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
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
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:
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 stackThis 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")
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 entryThis 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")
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.
We can load the data with the Load Register instruction:
ldr x2, [sp, #0]
add sp, sp, #16 // Restore the stack. It eats TWO ITEMSBut, 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 valueThis 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'")
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 thex30register. Then, it jumps to the address specified.ret, goes to the address thatx30contains.
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 placeGreat! 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.
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
retstp 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
retLet'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 = . - stringControl 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.
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 zeroWe 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 zeroNow, if you can recall, if the registers are the same, the zero flag should be set. Let's have a look at 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 can be done with two instructions:
bis 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.bris for jumping to the raw address that a register contains. It should only be used if you actually know what's on that register.
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 can be made in two patterns:
- You can put
b.and then the mnemonic - You can just put
band then the mnemonic
| Mnemonic (Subinstruction) | Description | Flag Checking |
|---|---|---|
eq |
Checks if a == b | |
ne |
Checks if a != b | |
lt |
Checks if a < b (the numbers are signed) | |
le |
Checks if a <= b (the numbers are signed) | |
gt |
Checks if a > b (the numbers are signed) | |
ge |
Checks if a >= b (the numbers are signed) | |
hi |
Checks if a > b (unsigned, but faster) | |
ls |
Checks if a <= b (unsigned, but faster) | |
cs |
Checks if a >= b (unsigned, but faster) | |
cc |
Checks if a < b (unsigned, but faster) |
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
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 syscallIs 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" }
Now, you just have to be persistent and try use maths to make calculations.
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:
retYou 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"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 13Could be expanded
mov x0, #13
mov x16, #0x1
svc #0x80This 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...
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
.endmIt'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, x30It's as simple as that!
Macros are not just that. They can have two other things that make them ideal.
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, #13You 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
.endmAssembly 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.
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)
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
opensyscall (0x05). Which takes arguments as follows:x0should contain the Pointer to the filenamex1should 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
readsyscall (0x03). Which takes arguments as follows:x0should contain the File descriptorx1should contain the Empty bufferx2should contain the Number of bytes we want to read
-
To write to a file we've got the
writesyscall (0x04). It's the same as when we were printing to the stdout but with a different file descriptor:x0should contain the File Descriptorx1should contain the Full bufferx2should contain the Length of the buffer
-
To close a file descriptor we've got the close syscall (
0x6). Which takes arguments as follows:x0should 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.
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!
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.
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 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
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 // RetryThis is one of the safest ways to use a multi-threaded enviornment with assembly.
Atomics have changed, and for simple operations, Apple Silicon provides specific functions to make rapid commands with atomics.
ldaddtakes: 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.
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 cindicates that the target is valid for indirect (pointer) calling (blr)bti jindicates the target is valid for jumps (b)bti jcindicates the target is valid for both
The assembler automatically adds these when compiling, so you shouldn't care a lot. Just know it happens.
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.
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).
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 registerAn 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)
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
Each exception level has its own registers and stack available.
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 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 = . - helloTo 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, #0x10sets 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, #0prepares the copy for a value0str w8, [sp, #0x8]does just that! It stores the integerstur 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, 0x100000000basically tells: "Hey! Give me the aproximate address (page address) of the symbol"add x0, x0, #0x4a0and 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 w0ldp x29, x30, [sp, #0x10]loads the previous frame pointer and return addressadd sp, sp, #0x20refills the stack pointer and tells the space is just freeretreturns 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 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.
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.
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.
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.
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.
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.
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
adrnow, you'll probably accessadragain 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 L1Computers 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
retThis 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 x4Much shorter, much faster.
Try inlining certain small functions. Functions like:
simple_add:
add w0, w0, w1Should be inlined. Don't call this function, just replace its contents, or use a macro.
Unrolling consists on when you have this:
add_array:
ldr w1, [w0], #4
add w2, w2, w1
subs x3, x3, #1
bne add_arrayYou 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_unrolledTry 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.
To end, we're going to talk about callee-saved vs caller-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.
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.
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