Timeline
Timeline
2025-06-22
init
This article introduces the basics of AArch64 assembly language, covering fundamental data types, instruction and pointer widths, differences in register access speed, and the mapping relationships between various registers (such as integer, pointer, floating-point, and vector registers) and C language data types. It also summarizes the functions of special registers like the stack frame pointer and link register, as well as the mechanism for indirect return results.
preknowledge
1 byte has 8 bits
- char has 1 byte
- short has 2 bytes
- int has 4 bytes
Each address represents a storage unit of one byte
1 | gcc -E hello.c -o hello.i |
All AARCH64 instructions are 4 bytes in width.
All AARCH64 pointers are 8 bytes in width†.
While this is technically true, typically only the lower 39, 42 or 48 bits of addresses in Linux systems are used - i.e. the virtual address space of an ARM Linux process is smaller than 64 bits. The upper bits are set to zero when considering the address as an 8-byte value.
register
register access speed

This says that if we liken accessing a register (which can be done at least once per CPU Clock Cycle) to one second, accessing RAM would be like a 3.5 to 5.5 minute wait.
register type
- rn means register “of some type” number n.
The kind of register is specified by a letter. Which register within a given type is specified by a number. There are some exceptions to this. Here is an introductory summary:
| Letter | Type |
|---|---|
| x | 64 bit integer or pointer |
| w | 32 bit or smaller integer |
| d | 64 bit floats (doubles) |
| s | 32 bit floats |
Some register types have been left out.
(Chapter 9.1)(Cortex-A Series Programmer’s Guide for ARMv8-A)

- x29 is the frame pointer (FP)
- x30 is the link register (LR, i.e., the return address)
- When the return value type of a function is too large (e.g., exceeding 64 bits, or being a large structure or union) to be passed directly through general-purpose registers (X0~X7), the caller pre-allocates a block of memory as a “storage area for the return result” and passes the address of this area to the called function — the address of this memory block is the “Indirect Result Location.” After the called function finishes execution, it writes the result to this address rather than returning it via registers.
The registers used for floating point types (and vector operations) are coincident:

qregisters are a massive 16 bytes wide - quad words. (alias for vn, mainly used inSIMD/Neoninstructions)vregisters are also 16 bytes wide and are synonyms for theqregisters.dregisters fordoubleswhich are 8 bytes wide - double precision. 2 perv.sregisters forfloatswhich are 4 bytes wide - single precision. 4 perv.hregisters forhalf precision floatswhich are 2 bytes wide. 8 perv.bregisters for byte operations. 16 perv.
register and C type
Integers
| This declares an integer | This IS an integer |
|---|---|
| char | wn |
| short | wn |
| int | wn |
| long | xn |
Pointers
| This declares a pointer | This IS a pointer |
|---|---|
| type * | xn |
All pointers are stored in x registers. X registers are 64 bits long but many operating systems do not support 64 bit address spaces because keeping track of that big of an address space itself would use a lot of space. Instead OS’s typically have 48 to 52 bit address spaces.
Floating Point
| This declares a float | This IS a float |
|---|---|
float | sn |
double | dn |
__fp16(half) | hn |


vn is the actual physical register name,recommended to use, supports the most types of access (floating-point + SIMD)
qn is an alias for vn, mainly used inSIMD/Neoninstructions (Single Instruction - Multiple Data)
instructions
preknowledge
EVERY AARCH64 instruction is 4 bytes wide. Everything the CPU needs to know about what the instruction is and what variation it might be plus what data it will use will be found in those 4 bytes.
- Most (but not all) AARCH64 instructions have three operands. These are read in the following way:
1 | op ra, rb, rc |
means:
1 | ra = rb op rc |
examples:
1 | sub x0, x0, x1 ; means x0 = x0 - x1 |
- [ ]
the[and]serve the same purpose as the asterisk in C and C++ indicating “dereference.” It means use what’s inside the brackets as an address for going out to memory.
when a ! is at the end of [] , for example:
1 | stp x21, x30, [sp, -16]! |
Lastly, the exclamation point means that the stack pointer should be changed (i.e. the -16 applied to it) before the value of the stack pointer is used as the address in memory to which the registers will be copied. Again, this is a predecrement.
it means:
sp = sp - 16(stack pointer moves down by 16 bytes)- Store
x29into[sp], storex30into[sp + 8]
Corresponds to:
1 | ldp x29, x30, [sp], 16 |
it means:
- From
[sp]read 8 bytes intox29, from[sp + 8]read 8 bytes intox30 sp = sp + 16(release stack frame space)
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
x29 is the stack frame register, but it is not mandatory to save
memory access
ldr
load register
1 | ldr x0, [sp] // load 8 bytes from address specified by sp |
When misaligned accesses to RAM are made, the processor must slow down and access each byte individually. This is a big performance hit. Properly aligned access is critical to performance.
str
store register
1 | str x0, [sp] // store 8 bytes to address specified by sp |
Casting between integer types is in some cases accomplished by
andingwith255and65535(forcharandshort) or :Whenever a narrower portion of a register is written to, the remainder of the register is zero’d out. That is:
ldrboverwrites the least significant byte of anxregister and zeros out the upper 7 bytes.
ldp
load pair, same as ldr but load a pair of value
stp
store pair, same as str but load a pair of value
offsets
1 | 1) LDR Xt, [Xn|SP{, #pimm}] ; 64-bit general registers |
simmcan be in the range of -256 to 255 (10 byte signed value).pimmcan be in the range of 0 to 32760 in multiples of 8.
three patterns
- Normal offset mode
1 | LDR Xt, [Xn, #pimm] |
From
Xn + pimm’s address load data intoXt; address registerXnunchanged;
pimmis apositive immediate, must be a multiple of 8, with a maximum of 32760.
- Post-indexed addressing mode
1 | LDR Xt, [Xn], #simm |
First use
Xnoriginal value as the address to load data intoXt, then usesimmto updateXn;** address registerXnchanges after reading memory**;
- Pre-indexed addressing mode
1 | LDR Xt, [Xn, #simm]! |
First
Xn = Xn + simm, then useXnas the address to load data intoXt, address registerXnchanges before reading memory;
pseudo instruction
1 | ldr x1, =label |
the assembler puts the address of the label into a special region of memory called a “literal pool.” What matters is this region of memory is placed immediately after (therefore nearby) your code.
Then, the assembler computes the difference between the address of the current instruction (the
ldritself) and the address of the data in the literal pool made from the labeled data.The assembler generates a different
ldrinstruction which uses the difference (or offset) of the data relative to the program counter (pc). Thepcis non-other the address of the current instruction.Because the literal pool for your code is located nearby your code, the offset from the current instruction to the data in the pool is a relatively small number. Small enough, to fit inside a four byte
ldrinstruction.
1 | ldr x1, [pc, offset to in literal pool] |
A downside of this approach is that the literal pool, from which the address is loaded, resides in RAM. This means each of these
ldrpseudo instructions incurs a memory reference.
literal pool
compare
1 | ldr x1, =q |
aarch64
1 | main // expose main to linker |
disasembling the binary machine code:
1 | 0000000000007a0 <main>: |
and
1 | 000000000011010 <q>: |
It says
000000000011010 <q>:. This means that what comes next is the data corresponding to what is labeledqin our source code. Notice the relocatable address of11010. We will explain “relocatable address” below.Now, look at the disassembled code on the line beginning with
7b8. It readsldr x1, 11010. So the disassembled executable is saying “go to address 11010 and fetch its contents” which are our1122334455667788.
| Instruction | Meaning |
|---|---|
| ldr r, =label | Load the address of the label into r |
| ldr r, label | Load the value found at the label into r |
relocation of address when executing
None of the addresses we have seen so far are the final addresses that will be used once the program is actually running. All addresses will be relocated.
One reason for this is a guard against malware. A technique called Address Space Layout Randomization (ASLR) prevents malware writers from being able to know ahead where to modify your executable in order to accomplish their nefarious purposes.
64 bit ARM Linux kernels allocate 39, 42 or 48 bits for the size of a process’s virtual address space. Notice 42 and 48 bit values require 6 bytes to hold them. A virtual address space is all of the addresses a process can generate / use. Further, all addresses used by processes are virtual addresses.
using this can avoid literal pool
1 | adrp x0, s |
examples
loading (storing) various sizes of integers
| Instruction | Meaning |
|---|---|
ldr x0, [x1] | Fetches a 64 bit value from the address specified byx1and places it inx0 |
ldr w0, [x1] | Fetches a 32 bit value from the address specified byx1and places it inw0 |
ldrh w0, [x1] | Fetches a 16 bit value from the address specified byx1and places it inx0 |
ldrb w0, [x1] | Fetches an 8 bit value from the address specified byx1and places it inx0 |
- Pointers and longs use
xregisters. - All other integer sizes use
wregisters where the instruction itself specifies the size.
array indexing
1 | long Sum(long * values, long length) |
Notice we’re using the index variableifor nothing more than traipsing through the array. This is fantastically inefficient (in this case).
1 | long Sum(long * values, long length) |
Notice we don’t use an index variable any longer. Instead, we use the pointer itself for both the dereferencing and to tell us when to stop the loop.
1 | Sum |
faster memory copy
Suppose you needed to copy 16 bytes of memory from one place to another. You might do it like this:
1 | void SillyCopy16(uint8_t * dest, uint8_t * src) |
This is especially silly as why would you go through 16 loops when you could have simply:
1 | void SillyCopy16(uint64_t * dest, uint64_t * src) |
in aarch64
1 | SillyCopy16: // 1 |
using ldp
1 | SillyCopy16: |
using q register
1 | SillyCopy16: |
indexing through an array of struct
1 |
|
Line 11tells us that somewhere else, there is a function calledFindOldestPerson. That function must have a.globalspecifying the same name so that the linker can reconcile the reference toFindOldestPerson.
gccwith-O2or-O3optimization renderedOriginalFindOldestPerson()into 18 lines of assembly language.
1 | FindOldestPerson // 1 |
control flow
cmp
compare
discards the result of the subtraction but keeps a record of whether or not the result was less than, equal to or greater than zero. It sets the condition bits
br
Branch to Register
1 | br <register> |
unconditional jump, similar to
1 | goto *(ptr) |
ble
Branch less or equal
bl
Branch with Link
jump to a function (subroutine) address and save the return address tox30register (also calledlr,Link Register)
cbz
Compare and Branch if Zero
1 | cbz <register>, <label> |
if<register>value is 0, jump to<label>。
otherwise continue to the next instruction.
csel
Conditional Select
1 | csel <dest>, <src1>, <src2>, <condition> |
if condition<condition>is met, then assign<src1>value to<dest>;
otherwise assign<src2>value to<dest>。
examples:
1 | cmp w2, w5 |
this isBranchless conditional assignment, which is more efficient thanif-else.
this is equal to
1 | w2 = (w2 > w5) ? w2 : w5; |
shift Operations
lsl
Logical Shift Left
The LSL instruction performs multiplication by a power of 2.
lsr
Logical Shift Right
The LSR instruction performs division by a power of 2.
asr
Arithmetic Shift Right
The ASR instruction performs division by a power of 2, preserving the sign bit.
ror
rotate right
The ROR instruction performs a bitwise rotation, wrapping the bits rotated from the LSB into the MSB.
That is:RORinstruction executionBitwise right rotateoperation:Bits rotated out from the least significant bit (LSB) are reinserted into the most significant bit (MSB) position.
bit manipulation
mvn
The mvn (Move Not) instruction performs a bitwise NOT on the operand and places the result into the destination register.
orr
The orr (bitwise inclusive OR) instruction performs abitwise ORoperation on two operands and writes the result to the destination register
bfi
bfi (Bit Field Insert) is a bit field insertion operation.
1 | bfi <Xd>, <Xn>, #<lsb>, #<width> |
<Xd>: destination register (result written here)<Xn>: source register (low-order bits taken from here)<lsb>: Starting bit position in the destination register for insertion (least significant bit as start)<width>: Number of bits to insert (width)
Assumption:
Xd = 0b1111 0000, Xn = 0b1011 (only low 4 bits used), lsb=1, width=3
Execution:
1 | bfi Xd, Xn, #1, #3 |
Result:
Insert the low 3 bits 011 of Xn into bits 1~3 of Xd, replacing the original value
The result is Xd = 1111 0110
ubfm
ubfm = Unsigned BitField Move
Basic format:
1 | ubfm <dst>, <src>, #lsb, #msb |
<dst>: Destination register
<src>: Source register
lsb: Starting bit (low bit index)
msb: Ending bit (high bit index)
This instruction extracts an unsigned bit field (a contiguous sequence of bits) from src, places it into the low bits of dst (starting from bit 0), and clears or ignores the other bits
That is:
- From the lsb bit of src, take up to the msb bit
- Extract this bit field
- Right-align it into the low bits of dst (bit 0), and clear all other bits
Example:
1 | ubfm w1, w2, #8, #15 |
- Extract bits 8 to 15 (8 bits total) from w2
- Place it into bits 0~7 of w1
ubfiz
ubfiz (Unsigned Bit Field Insert Zeroed) inserts a low-order bit field of an unsigned number into a specified position of another register, but the destination register is cleared before insertion.
It is actually a specialized form of ubfm (Unsigned Bit Field Move) and has similar semantics to UBFM.
Instruction format:
1 | ubfiz <dst>, <src>, #lsb, #width |
Simply put: ubfiz = insert the low width bits of src into dst starting at bit lsb, and clear all other positions.
Where:
<src>: source register (e.g., w1)
<dst>: destination register (e.g., w2), where the final result is placed
lsb: starting bit position for insertion in the destination (starting from 0)
width: number of bits to insert (from<src>counting from the lowest bit)
All other bits of the destination register will be cleared.
For example:
1 | ubfiz w1, w1, #3, #5 |
The meaning is as follows:
- Extract from the lowest 5 bits (bit 0 to bit 4) of w1
- Insert into bits 3 to 7 of the destination (w1) register
- All other bits of w1 (bits 0-2 and 8-31) are cleared
other
adr
Address
adrp
Address of page
1 | .rodata |
- Function: Load the symbol
fmtlocated in thepage address of the 4KB aligned pageintox0. adrp= Address of Page。- It ignores the lower 12 bits of the symbol address, retaining only the higher bits.
- For example, if
fmtthe address is0x400123, thenadrp x0, fmtwill0x400000load intox0。 adrp x0, fmtwillfmtround the address down to the nearest4KB boundary(i.e., clear the lower 12 bits)
Why not directly use
ldr x0, =fmt?
- Under ARM64, using
ldr x0, =fmtmay implicitly introducea literal pool, which is detrimental to relocatable code, especially in dynamic linking or PIE (Position Independent Executable) environments. adrp+addisRecommended relocatable code writing (relocatable and PIC-compliant)。- The dynamic linker (ld.so) under Linux supports this mode better.
| Instruction | Meaning | Supported offset range | Commonly used for |
|---|---|---|---|
adr | Getnear the current instructionaddress | ±1MB | Local jumps, temporary variables, etc. |
adrp | Getthe high address part aligned to 4KB page | ±4GB (page-aligned offset) | Get global variable addresses, strings, constant table addresses, etc. |
smaddl
Signed Multiply Add Long
Two32-bit signed integerAfter multiplication, add a64-bit integer, the result is stored in a64-bit register.
1 | smaddl <Xd>, <Wn>, <Wm>, <Xa> |
Perform the following operation:
1 | Xd = (int64_t)(int32_t)Wn * (int64_t)(int32_t)Wm + Xa; |
programming
if statement
if
1 | if (a > b) |
in aarch64
1 | // Assume value of a is in x0 |
- If
a > bthenx0 - x1will be greater than zero. - If
a == bthenx0 - x1will be equal to zero. - If
a < bthenx0 - x1will be less than zero.
ble means branch (a jump or goto) if the previous computation showsless than or equal tozero
a rule of thumb
In the higher level language, you want to enter the following code block if the condition is true.
In assembly language, you want to avoid the following code block if the condition is false.
temporary label
The target of the branch instruction is given as1f. This is an example of a temporary label.
There are a lot of braces used in C and C++. Since labels frequently function as equivalents to{and}, there can be a lot of labels used in assembly language. But label is only a position label, it is not a scope
A temporary label is a label made using just a number. Such labels can appear over and over again (i.e. they can be reused). They are made unique by virtue of their placement relative to where they are being used.
1flooksforward in the code for the next label1.1blooks in thebackward direction for the most recent label1.
if / else
1 | if (a > b) |
There are two branches built into this code!
in aarch64:
1 | // Assume value of a is in x0 |
a complete example
1 | main |
Line 11is one way of loading the address represented by a label. In this case, the labelTcorresponds to the address to the first letter of the C string “TRUE”.Line 15loads the address of the C string containing “FALSE”.
The occurrences of.ascizonline 23andline 24are invocations of an assembler directive the creates a C string. Recall that C strings are NULL terminated. The NULL termination is indicated by thezwhich ends.asciz.
There is a similar directive.asciithat does not NULL terminate the string.
loop
while loop

1 | while (a >= b) { |
aarch64:
1 | // Assume value of a is in x0 |
for loop
1 | for (set up; decision; post step) |

1 | for (long i = 0; i < 10; i++) |
aarch64 (the flow chart on the left)
1 | // Assume i is implemented using x0 |
aarch64 (the flow chart on the right)
1 | // Assume i is implemented using x0 |
continue
1 | for (long i = 0; i < 10; i++) { |
in aarch64
1 | // Assume i is implemented using x0 |
another one
1 | // Assume i is implemented using x0 |
break
The implementation ofbreakis very similar to that ofcontinue.
1 | for (long i = 0; i < 10; i++) { |
aarch64:
1 | // Assume i is implemented using x0 |
structs
alignment
Data members exhibit natural alignment.
That is:
- a
longwill be found at addresses which are a multiple of 8. - an
intwill be found at addresses which are a multiple of 4. - a
shortwill be found at addresses which are even. - a
charcan be found anywhere.
example
1 | struct { |
Layout:
| Offset | Width | Member |
|---|---|---|
| 0 | 8byte | a |
| 8 | 2byte | b |
| 10 | 2 | – gap – |
| 12 | 4byte | c |
1 | struct Foo { |
A hex dump will show:
1 | aaaa aaaa aaaa aaaa bbbb 0000 cccc cccc |
Notice the gap filled in which zeros. Note, if this were a local variable, the zeros might be garbage.
change the order:
1 | struct Foo { |
A hex dump will show:
1 | aaaa 00bb cccc cccc |
Notice there is only one byte of gap before theint cstarts.
why are the zeros to the left of the b’s?
This ARM processor is running as a little endian machine.
defining structs
1 | struct Foo { |
Here is one way of defining and accessing the struct:
- Hardcoded field offset
1 | .rodata |
:lo12:fmtwill be replaced by the assembler withfmtthe lower 12 bits of the address.
adrp x0, fmtwillfmtround the address down to the nearest4KB boundary(i.e., clear the lower 12 bits), then load this “page base address” intox0。
For example:
Iffmt = 0x12345678, then:adrp x0, fmtwill yield0x12345000(lower 12 bits cleared)
- another way to define a structs is
Using.equpseudo-instruction to define symbolic constants
1 | main // main function declaration |
- the third way:(Linux only)
Using.structand field labels to automatically derive offsets
1 | .rodata |
using structs
To summarize usingstructs:
- All
structshave a base address - The base address corresponds to the beginning of the first data member
- All subsequent data members are offsets relative to the first
- In order to use a
structcorrectly, you must have first calculated the offsets of each data member - Sometimes there will be padding between data members due to the need to align all data members on natural boundaries.
this pointer in c++
- Every non-static method call employs a hidden first parameter. That’s it. That’s the slight of hand. The hidden argument is the this pointer.
1 | TestClass tc; |
It seems we only passed one parameter test_string. But in fact, the compiler passed two parameters:
The first is the this pointer: that is, the address of tc, passed to register x0
The second is test_string, passed to register x1
In the assembly, we see:
1 | adrp x1, _test_string |
const
The meaning and function of
constonlypartiallytranslates to assembly language.
constlocal variables andconstparameters are just like any other data to assembly language.The constant nature of
constlocal variables and parameters is implemented solely in the compiler.constglobals are made constant by the hardware. Attempting to modify a variable protected in this manner will be like poking a dragon. Best not to poke dragons.
switch and jump table
When the C++ optimizer is enabled, it will look at your cases and choose between three different constructs for implementing your
switch.And, it can use any combination of the following! Compiler writers are smart!
- It may emit a long string of
if / elseconstructs. - It may find the right
caseusing a binary search. - Finally, it might use a jump table.
Suppose our cases are largely consecutive. Given that all branch instructions are the same length in bytes, we can do math on the switch variable to somehow derive the address of the case we want.
1 |
|
Notice that thecasevalues are all, in this case, consecutive.
1 | jt: b 0f |
fmeans forward,bmeans backward
At addressjtthere are a sequence of branch statements… jumps if you will. Being in a sequence, this is an example of a jump table. We’ll compute the index into this array of instructions and then branch to it.
1 | lsl x0, x0, 2 |
- Line 2 loads the base address of the “instruction array” starting at address
jt.
complete example
1 |
|
implement falling through
If there is no break falling the code for a case, control will simply fall through to the next case
Here is a snippet from the program linked just above
1 | 0: ldr x0, =ZR |
implementing gaps
The example above present shows 8 consecutive cases. What if there was no code for case 4? In other words, what if case 4 didn’t exit?
Here is the result:
1 | 2: ldr x0, =TW |
other strategies for implementing switch
As indicated above, an optimizer has at least three tools available to it to implement complexswitchstatements. And, it can combine these tools.
- For example, suppose your cases boil down to two ranges of fairly consecutive values. For example, you have cases 0 to 9 and also cases 50 to 59. You can implement this as two jump tables with an
if / elseto select which one you use.
Suppose yourswitchstatement,casevalues are mainly concentrated intwo small contiguous ranges, for example: one set iscase 0tocase 9, another set iscase 50tocase 59, then you can usetwo jump tablesto handle these two ranges, and then use anotherif / elseto decide which jump table to use.
- Suppose you have a large
switchstatement with widely rangingcasevalues. In this case, you can implement a binary search to narrow down to a small range in which another technique becomes viable to narrow down to a singlecase.
Suppose you have acasestatement with manyswitchbranches, and thesecasevalues havea large difference in numerical range, such as case 10, case 1000, case 50000…, then you canfirst use binary search to narrow down the search range, limiting the target value to asmaller range, and then within this range use other techniques (such as jump tables, linear comparisons, etc.) to determine whichcasebranch it corresponds to.
- You might have need to implement hierarchical jump tables, for example.
“Hierarchical jump tables” are an optimization structure suitable for the following scenarios:
casevalues are verysparse、with an extremely wide range(e.g.,case 0, case 1000, case 2000...)- but they aredense within local ranges(e.g.,
1000~1009,2000~2009)
You can:
- First, use a “first-level jump table” to jump based on high bits or rangesto a sub-jump table (sub-range).
- Then perform specific jumps within the sub-jump table。
This forms a “hierarchical structure” — a tree-like jumping process.
strategies for implementing if-else
If you do choose to implement a long chain ofif / elsestatements, consider how frequently a given case might be chosen. Put the most common cases at the top of theif / elsesequence.
This is known as making the common case fast.
Making the common case fast is one of the Great Ideas in Computer Science. One, you would do well to remember no matter what language you’re working with.
functions
bottom line concept
Theblinstruction stands for Branch with Link. The Link concept is what enables a function (or method) to return to the instruction after the call.
Branch-with-link computes the address of the instruction following it.
It places this address into register
x30and then branches to the label provided. It makes one link of a trail of breadcrumbs to follow to get back following aret.
This is why it is absolutely essential to backupx30inside your functions if they call other functions themselves.
a example
1 |
|
The program hung and had to be killed with ^C.
Somebody calledmain()- it’s a function and someone called it with ablinstruction. At the momentmain()entered, the address to which it needed to return was sitting inx30.
Then,main()called a function - in this caseputs()but which function is called doesn’t matter - it called a function. In doing so, it overwrote the address to whichmain()needed to return with the address of line 7 in the code. That is whereputs()needs to return.
So, when line 7 executes it puts the contents ofx30into the program counter and branches to it.
Here is a fixed version of the code:
1 |
|
In the AARCH64 Linux style calling convention, values are returned inx0and sometimes also returned in other scratch registers though this is uncommon.(Note thatx0could also bew0or the first floating point register if the function is returning afloatordouble.)
If your functions call any other functions,x30must be backed up on the stack and then restored intox30before returning.
A function with more than one return value is not supported by C or C++ but they can be written in assembly language where the rules are yours to break.
inline functions
Functions that are declared as inline don’t actually make function calls. Instead, the code from the function is type checked and inserted directly where the “call” is made after adjusting for parameter names.
passing parameters to functions
How parameters are passed to functions can be different from OS to OS. This chapter is written to the standard implemented for Linux.
For the purposes of the present discussion, we assume all parameters arelong intand are therefore stored inxregisters.
Up to 8 parameters can be passed directly via scratch registers.(These are
x0throughx7) Each parameter can be up to the size of an address, long or double (8 bytes).Scratch means the value of the register can be changed at will without any need to backup or restore their values across function calls.
This means that you cannot count on the contents of the scratch registers maintaining their value if your function makes any function calls.
a example
1 | long func(long p1, long p2) |
is implemented as:
1 | func: add x0, x0, x1 |
If you are the author of both the caller and the callee and both are in assembly language, you can play loosey goosey with how you return values. Specifically, you can return more than one value. But if you do so, you give up the possibility of calling these functions from C or C++.
const
1 | long func(const long p1, const long p2) |
how would the assembly language change?
Answer: no change at all!
constis an instruction to the compiler ordering it to prohibit changing the values ofp1andp2. We’re smart humans and realize that our assembly language makes no attempt to changep1andp2so no changes are warranted.
passing pointers
1 | void func(long * p1, long * p2) |
1 | func: ldr x2, [x0] |
The value ofx0on return is, in the general sense, undefined because this is avoidfunction.
passing reference
1 | long func(long & p1, long & p2) |
1 | func: ldr x0, [x0] |
Passing by reference is also an instruction to the compiler to treat pointers a little differently - the differences don’t show up here so there the only change to our pointer passing version is how we return the answer.
more than eight parameters
1 |
|
1 |
|
After executingLine 24, the stack will have:
1 | sp + 0 former contents of frame pointer |
After executingLine 27, the stack will have:
1 | sp + 0 9 |
After executingLine 14, the stack will have:
1 | sp + 0 return address for SillyFunction |
This means thatLine 18fetchesp9from memory and puts its value into x2 (where it becomes the third argument toprintf()).
In AArch64, the stack space is oftenaligned to 16-byte boundariesallocated, but you mayonly write a portion of the data, and the rest remains uninitialized, so we call it**“garbage” (undefined content)**。
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
The stack pointer in ARM V8 can only be manipulated in multiples of 16.
examples of calling some common C runtime functions
There are, by the way, two broad types of functions within the C runtime.
Some are implemented largely in the C runtime itself.
Others that exist in the C runtime act as wrappers for functions implemented within the OS itself. These are called “system calls”.
For the purposes of calling functions in the C runtime, there is no practical difference between these two types. Note however, there are ways of calling system calls directly using thesvcinstruction.
“C runtime”(C runtime) refers to a set of functions, variables, and basic mechanisms that provide support during program execution,mainly used to support the C standard library and program initialization/termination. This system is commonly called theC runtime library, and common implementations on different platforms include:
- Under GNU/Linux,glibc
- Under Windows,MSVCRT
- Under macOS,libSystem.dylib (which includes libc)
What does the C runtime do?
- Program initialization
- Before
main()execution, the C runtime sets up the stack, initializes global variables, calls constructors, etc. - The typical entry point is
_start→__libc_start_main()→main()。
- Before
- Provides standard library functions
- Such as
printf(),malloc(),exit(),fopen(), etc. These functions are implemented or wrapped by the C runtime.
- Such as
- Manages resources
- For example, lifecycle management of memory allocation, file handles, threads, etc.
- Provides system call wrappers
- For example, when you call
write(), it actually calls awrapper provided by C runtime, ultimately throughsyscallorsvcinstruction to access the kernel.
- For example, when you call
system calls
Many C runtime functions are just wrappers for system calls. For example if you call open() from the C runtime, the function will perform a few bookkeeping operations and then make the actual system call.
What IS a system call?
The short answer is a system call is a sort-of function call that is serviced by the operating system itself, within its own private region of memory and with access to internal features and data structures.
Our programs run in “userland”. The technical name for userland on the ARM64 processor is EL0 (Exception Level 0).
We can operate within the kernel’s space only through carefully controlled mechanisms - such as system calls. The technical name for where the kernel (or system) generally operates is called EL1.
There are two higher Exception Levels (EL2 and EL3) which are beyond the scope of this book.
Mechanism of making a system call
First, like any function call, parameters need to be set up. The first parameter goes in the first register, etc.
Second, a number associated with the specific system call we wish to make is loaded in a specific register (w8).
Finally, a special instruction svc causes a trap which elevates us out of userland into kernel space. Said differently, svc causes a transition from EL0 to EL1. There, various checks are done and the actual code for the system call is run.
A description of returning from a system call is beyond the scope of this book. Hint: just as there’s a special instruction that escalates from EL0 to EL1, there is a special instruction that does the reverse.
the number associated with a particular system call
reference:
example getpid()
1 |
|
Written in assembly language using C runtime
1 | main |
And finally: calling the system call directly
1 | main |
We chose getpid() because it doesn’t require any parameters. Using the C runtime, we simply bl to it. Calling the system call directly is different in that we must first load x8 with the number that corresponds to getpid() for the AARCH64 architecture.
1 | /* Perry Kivolowitz |
floating point
what are floating point numbers?
reference
IEEE 754
register
There are four highest level ideas relating to floating point operations on AARCH64.
- There is another complete register set for floating point values.
- There are alternative instructions just for floating point values.
- There are exotic instructions that operate on sets of floating point values (SIMD).
- There are instructions to go back and forth to and from the integer registers.

The above figure showsdifferent views and access methods of the SIMD (Single Instruction, Multiple Data) register V0 in the ARM64 architecture, includingarrangement specifiers of different bit widths and lane indices。
illustration
This figure usesthe V0 register as an example, showinghow to access its content using different arrangement specifiers:
| Level | Type | Description |
|---|---|---|
| Bottom level | V0 | Entire 128-bit V0 register |
| Upward | V0.2D,V0.4S,V0.8H,V0.16B | Access V0 with data views of different sizes: - D = 64-bit(2 × 64bit) - S = 32-bit(4 × 32bit) - H = 16-bit(8 × 16bit) - B = 8-bit(16 × 8bit) |
| Next level up | V0.2D[0],V0.4S[0]etc. | Index of each lane, for example: - V0.4S[2]Represents the 3rd 32-bit unit- V0.16B[15]Represents the 16th 8-bit byte |
| Top level | B0,H0,S0,D0 | Is an alias ofV0Accessed by bit width (only accesses the lowest bit data) |
truncation towards zero
truncate
In C and C++, truncation is what we get from:
1 | integer_variable = int(floating_variable); // C++ |
The instruction is fcvtz - convert towards zero. Then, the choice as to whether to produce a signed or unsigned result is defined by the final letterL u or s.
| Mnemonic | Meaning |
|---|---|
| fcvtzu | Truncate (always towards 0) producing an unsigned int |
| fcvtzs | Truncate (always towards 0) producing a signed int |
fcvtzu: Float Convert to Unsigned integer, with truncation toward zerofcvtzs: Float Convert to Signed integer, with truncation toward zero
this instruction which completely discards the fractional value is said by the ARM documentation as doing rounding not truncating.
The the choice of source register defined whether you are converting a double or single precision floating point value.
| Source Register | Converts a |
|---|---|
| dX | doubleto an integer |
| sX | floatto an integer |
| Destination Register | Converts a |
|---|---|
| xX | 64 bit integer |
| wX | 32 bit or less integer |
Examples wheredis adoubleandfis afloat:
| C++ | Instruction |
|---|---|
int32_t(d) | fcvtzs w0, d0 |
uint32_t(d) | fcvtzu w0, d0 |
int64_t(d) | fcvtzs x0, d0 |
uint64_t(d) | fcvtzu x0, d0 |
example
1 |
|
Notice all the values were truncated to the whole number that is closer to zero.
Truncation Away From Zero
Truncation away from zero is not as easy. In fact, it cannot be performed with a single instruction.
In C (and C++):
1 | iv = (int(fv) == fv) ? int(fv) : int(fv) + ((fv < 0) ? -1 : 1); |
If the fv is already equal to a whole number, the integer value will be that whole number. Other wise the iv is the whole number further away from zero.
In C++, a more sophisticated version would require
1 | template <typename T> |
floor() always truncates downward (towards more negative).
ceil() always truncates upwards (towards more positive).
1 | RoundAwayFromZero: |
frintp(Round toward +∞)frintm(Round toward -∞)frintz(Round toward 0)frinta(Round to nearest, tie away from 0)frintn(Round to nearest, tie to even)
rounding conversion
rounding
An instruction which does what we normally think of as rounding is frinta. This is the conversion “to nearest with ties going away.” So, 5.5 goes to 6 as one would expect from “rounding.”
converting an integer to a float point value
In C / C++:
1 | double_var = double(integer_var); // C++ |
Is handled by two instructions:
scvtfconverts a signed integer to a floating point valueucvtfconverts an unsigned integer to a floating point value
The name of the destination register controls which kind of floating point value is made. For example, specifying dX makes a double etc.
floating point literals
Recall that all AARCH64 instructions are 4 bytes long. Recall also that this means that there are constraints on what can be specified as a literal since the literal must be encoded into the 4 byte instruction. If the literal is too large, an assembler error will result.
Given that floating point values are always at least 4 bytes long themselves, using floating point literals is extremely constrained. For example:
1 | fmov d0, 1 // 1 |
Line 1 will pass muster but Line 2 will cause an error.
To load a float, you could translate the value to binary and do as the following:
1 |
|
printf() only knows how to print double precision values. When you specify a float, it will convert it to a double before emitting it.
Translating floats and doubles by hand isn’t a common practice for humans, though compilers are happy to do so.
Instead for us humans, the assembler directives .float and .double are used more frequently to specify float and double values putting them into RAM.
a example:
1 | main |
| Instruction | Full Name / Abbreviation | Function | Common Usage Example |
|---|---|---|---|
.req | register require(Unofficial Abbreviation) | Givean alias to a register | foo .req x0Indicates that from now on, writingfoois equivalent tox0 |
.equ | equate | Define aconstant symbol | BUF_SIZE .equ 64meansBUF_SIZE = 64 |
On Linux, just as w/x0 through w/x7 are scratch registers and used to pass parameters, s/d0 and s/d7 are as well beginning with the 0 register. That is:
- Integer parameter passing: x0 ~ x7 (or 32-bit w0 ~ w7) are used to pass the first 8 integer-type parameters (int, pointer, long, etc.). More than 8 are passed via the stack.
- Floating-point parameter passing: d0 ~ d7 (64-bit double type) or s0 ~ s7 (32-bit float type) are used to pass the first 8 floating-point parameters. More than 8 floating-point parameters are also passed via the stack.
Fitting 32 bits into a 32 bit bag
1 | ldr s0, =0x3fc00000 // Pseudo-instruction! We thought it directly loads 0x3fc00000 into s0 |
The compiler cannot directly hardcode an arbitrary 32-bit value into an instruction (because an ARM instruction itself is only 32 bits).
So it actually does:
- Write the literal value 0x3fc00000 to some location in memory (usually near the bottom of the current function).
- Generate an ldr instruction to load this value from that address using PC-relative load. This area is called a literal pool, which is a collection of constants.
We expected line 6 to read:
1 | ldr s0, =0x3fc00000 |
Instead we find:
1 | b+ 0x784 <main+4> ldr s0, 0x7a0 <main+32> |
Scan downward to find 0x7a0:
1 | 0x7a0 <main+32> .inst 0x3fc00000 ; undefined |
| Pseudo-instruction | Actual effect | Actual assembly seen in GDB |
|---|---|---|
ldr s0, =0x3fc00000 | Load constant intos0register | ldr s0, #literal_addrliteral_addr: .inst 0x3fc00000 |
ldr x0, =fmt | Load string pointer address | ldr x0, #literal_addrliteral_addr: .inst 地址值 |
.inst 0x3fc00000 | Manually insert a 32-bit data (not necessarily a valid instruction) | Store constants (not executed) |
.instMeaning:
- Full name:
.inst= insert instruction - Purpose: Directly insert the machine code of an ARM instruction (usually a 32-bit hexadecimal value)
1 | .inst 0xd65f03c0 // It is actually the ret instruction |
In this example, the machine code 0xd65f03c0 after .inst is the 32-bit encoding of the ret instruction. That is:
1 | ret |
Equivalent to:
1 | .inst 0xd65f03c0 |
In the above example, you can use.instDefine an address and load from that address
Why not usemov reg, #imm?
- mov has immediate value encoding limitations and cannot load arbitrary 32-bit values.
- When out of range, you must use ldr to load from memory.
fmov
The fmov instruction is used to move floating point values in and out of floating point registers and to some degree, moving data between integer and floating point registers.
loading floating point numbers as immediate values
Just as we saw with integer registers, some values can be used as immediate values and some cannot. It comes down to how many bits are necessary to encode the value. Too many bits… not enough room to fit in a 4 byte instruction plus the opcode.
For example, this works:
1 | mov x0, 65535 |
but this does not:
1 | mov x0, 65537 |
The constraints placed on immediate values for fmov are much tighter because floating point numbers are far more complex than integers.
fmov d0, #immWhether it works depends on whether the floating-point number can be precisely represented within the 8-bit encoding space:
| Structure | Bits | Description |
|---|---|---|
| Sign bit | 1 bit | Indicates positive or negative |
| Exponent part | 3 bits | Controls magnitude (multiply by power of 2) |
| Mantissa part | 4 bits | Can only be composed of combinations of 1/2, 1/4, 1/8, 1/16 |
1 | fmov d0, 1.0 // ✅ OK: Integer 1 is 2⁰, exponent can be encoded |
Large floats cannot use fmov; use ldr instead.
fmov is a ‘bit copier’, not a ‘precision converter’. To change numerical precision, you must use the fcvt family.
half precision
Support for half precision (16 bit) floating point values does exist but there is no complete agreement on how different compilers support them. Indeed, there are not one but two competing half precision formats out there. These are the IEEE and GOOGLE types. Further still, many open source developers have created their own implementations with potentially clashing naming conventions.
1 | __fp16 Foo(__fp16 g, __fp16 f) { |
compiles to:
1 | fcvt s1, h1 |
Notice each half precision value is converted to single precision. So, from C and C++ working with half precision values can be inefficient.
On the other hand, if you are willing to use intrinsics and one of the SIMD instruction sets offered by ARM, then knock yourself out. Be aware that doing so ties your code to the ARM processor in ways which you might regret later.
bit manipulation
Bit fields are a feature of the C and C++ language which completely hide what is often called “bit bashing”.
the ordering of bits in a bit field is not guaranteed to be the same on different platforms and even between different compilers on the same platform.
Bit fields are a syntax used to precisely control the number of binary bits occupied by members within a struct, commonly used in space-sensitive scenarios such as hardware registers and protocol headers.
Syntax format
1 | struct 结构体名 { |
example:
1 | struct BF { |
- a uses 1 bit, can represent 0 or 1
- b uses 2 bits, can represent 0 ~ 3
- c uses 5 bits, can represent 0 ~ 31
The three members occupy a total of 1 + 2 + 5 = 8 bits, i.e., 1 byte
- Although each member is one bit wide, the overall size is usually aligned to an integer (here it is 1 byte, because 8 bits make exactly one byte).
- Different compilers may have slight differences in bit-field alignment and padding details.
- They can be accessed like ordinary members:
1 | struct BF bf; |
The compiler automatically handles masking and shifting for bit-fields.
Consider a data structure for which there will be potentially millions of instances in RAM. Or, perhaps billions of instances on disc. Suppose you need 8 boolean members in every instance. The C++ standard does not define the size of a bool instead leaving it to be implementation dependent. Some implementations equate bool to int, four bytes in length. Some implement bool with a char, or 1 byte in length.
Let’s assume the smallest case and equate a bool with char. Our struct, for which there may be millions or billions of instances requires 8 bool so therefore 8 bytes. Times millions or billions.
Bit fields can come to your aid here by using a single bit per boolean value. In the best case, 8 bytes collapse to 1 byte. In a worse case, 8 x 4 = 32 bytes collapsed into 1.
Assuming the smallest unit, where each bool is 1 byte:
1 | struct S { |
This struct is 8 bytes in size (1 byte × 8 bools). With a million instances, it occupies 8MB of memory; with a billion instances, it’s 8GB. For a 4-byte bool implementation, the size becomes 32 bytes, and per 100 million instances, it’s 3.2GB.
Solution: Use bit-fields to compress boolean values
Use bit-fields to define 8 boolean values as 1-bit each:
1 | struct S { |
8 1-bit members together occupy exactly 1 byte.
This compresses 8 bytes into 1 byte, saving a lot of space.
In Computer Science there is an eternal tension between space and time. The following is a law:
If you want something to go faster, it will cost more memory.
If you want to save memory, what you’re doing will take more time.
This law shows up here… recall the example of where we wanted to save memory by collapsing 8 bool into 1 byte? To save that memory we will slow down because accessing the right bits takes a couple of instructions where overwriting a bool implemented as an int takes just one instruction.
As for the assembly language that bit field will produce, it depends upon optimization level. Unoptimized, the code produced will be much longer and cumbersome than the “sophisticated” assembly language.
endian
the ARM swing both ways: the litte-endian and the big-endian. But:
The standard toolchain emits little endian code. It is a big task to install the big-endian version of the toolchain.
Here is a quote from Wikipedia:
1 | ARM, C-Sky, and RISC-V have no relevant big-endian deployments, and can be considered little-endian in practice. |
The common Intel processors are also little-endian.
assembly macros
An early innovation in assemblers was the introduction of a macro capability. Given what could be considered a certain amount of tedium in coding in asm, macros provide a simple form of meta programming where a series of statements can be encapsulated by a single macro. Think of a macro as an early form of C++ templated function (kinda but not really).
Here’s an example of an assembly language macro:
1 | .macro LLD_ADDR xreg, label |
This gets expanded to:
1 | adrp x0, fmt@PAGE |
gcc on Linux does not run assembly language files through the C pre-processor if the asm file ends in .s but WILL if the file ends in .S
General Use
AASCIZ
AASCIZ label, string
This macro invokes .asciz with the string set to string and the label set to label. In addition, this macro ensures that the string begins on a 4-byte-aligned boundary.
PUSH_P, PUSH_R, POP_P and POP_R
These macros save some repetitive typing. For example:
1 | PUSH_P x29, x30 |
resolves to:
1 | stp x29, x30, [sp, -16]! |
START_PROC and END_PROC
Place START_PROC after the label introducing a function.
Place END_PROC after the last ret of the function.
These resolve to: .cfi_startproc and .cfi_endproc respectively.
MIN and MAX
Handy more readable macros for determining minima and maxima. Note that the macro performs a cmp which subtracts src_b from src_a (discarding the results) in order to set the flags to be interpreted by the following csel.
Signature:
1 | MIN src_a, src_b, dest |
The smaller of src_a and src_b is put into dest.
Signature:
1 | MAX src_a, src_b, dest |
The larger of src_a and src_b is put into dest.
MOD
MOD macro used above is defined as:
1 | .macro MOD src_a, src_b, dest, scratch |
GLABEL
Mark a label as global, Makes a label available externally.
Signature:
1 | GLABEL label |
An underscore is prepended.
CRT
Calling CRT(C runtime) functions
If you create your own function without an underscore, just call it as usual.
If you need to call a function such as those found in the C runtime library, use this macro in this way:
1 | CRT strlen |
MAIN
Declaring main()
Put MAIN on a line by itself. Notice there is no colon.
errno
The externally defined errno is accessed via a CRT function which isn’t seen when coding in C and C++. The function is named differently on Mac versus Linux. To get the address of errno use:
1 | ERRNO_ADDR |
This macro makes the correct CRT call and leaves the address of errno in x0.
Loads and Stores
GLD_PTR
Loads the address of a label and then dereferences it where, on Apple the label is in the global space and on Linux is a relatively close label.
Signature:
1 | GLD_PTR xreg, label |
When this macro finishes, the specified x register contains what 64 bit value lives at the specified label.
GLD_ADDR
Loads the address of the label into the specified x register. No dereferencing takes place. On Apple machines, the label will be found in the global space.
Signature:
1 | GLD_ADDR xreg, label |
When this macro completes, the address of the label is in the x register.
LLD_ADDR
Similar to GLD_ADDR this macro loads the address of a “local” label.
Signature:
1 | LLD_ADDR xreg, label |
When this macro completes, the address of the label is in the x register.
LLD_DBL
Signature:
1 | LLD_DBL xreg, dreg, label |
When this macro completes, a double that lives at the specified local label will sit in the specified double register.
LLD_FLT
Signature:
1 | LLD_FLT xreg, sreg, label |
When this macro completes, a float that lives at the specified local label will sit in the specified single precision register.
performance
Undoing Stack Pointer Changes
A small tip concerning undoing changes to the stack pointer. You might think that changes to the stack made by str or stp and their cousins must be undone with ldr or ldp and their cousins.
This depends.
If you need to get back the original contents of a register pushed onto the stack, then an ldr or ldp is appropriate. However, if you don’t need to get the original contents of a register back, then it is faster to undo a change to the stack using addition.
Take for example the use of printf(). On Apple Silicon systems, you must send arguments to printf() by pushing them onto the stack. However, when printf() completes, you have no need for the values that you pushed. As shown above, simply add the right (multiple of 16) to the stack pointer. This is faster as the addition makes no reference to RAM (or caches) as the ldr would.
other stuff
let the assembler itself calculate the length for you
1 | main |
atomic operations
Load Linked, Store Condition
1 |
|
LL/SC is an optimistic concurrency control mechanism. Its general logic is:
Load-Linked (LDAXR): Loads the value at an address and ‘watches’ whether that address is modified. You can modify this value (e.g., add 1).
Store-Conditional (STLXR): Attempts to write this new value; if the address content has not been modified by others in the meantime, the write succeeds; otherwise, it fails. Success or failure is indicated by the return value of STLXR (0 means success, non-zero means failure).

Implementations of operations on atomic variables were improved in the second version of ARMv8, called ARMv8.1. The load linked and store conditional instructions are still available but several new instructions were added which perform certain operations such as addition, subtraction and various bitwise operations in a single atomic instruction.
For example:
1 | mov w1, 1 |
does the same work of atomically adding one to the value in memory pointed to by x0.
spin-lock
Here is the source code to the spin-lock for ARM V8.
Lock
1 | Lock: |
stlxr: If the exclusive tag is still valid (no one has taken the lock), then write the value of w3*x0, and put the result into w2 (0 indicates success)
- ldaxr dereferencing the lock itself (once again an int32_t) and marks the location of the lock as being hopefully, exclusive.
- Having gotten the value of the lock, its value is inspected and if found to be non-zero, we branch back to attempting to get it again - this is the spin.
- If the contents of the lock is 0, its value in w1 is changed to non-zero. Note, this could be made a bit better if a value of 1 was stored in another w register and simply used directly on line 10.
stlxr w2, w3, [x0]conditionally stores the changed value back to the location of the lock. If the stlxr returns 0, we got the lock. If not, we start over - somebody else got in there ahead of us. Perhaps this happened because we were descheduled. Perhaps we lost the lock to another thread running on a different core.
unlock
1 | Unlock: |
All it does is set to value of the lock to zero. The correct operation of the lock requires that no bad actor simply stomps on the lock by calling Unlock without first owning the lock. Just say no to lock stompers.
dmb ishsets up a data memory barrier across each processor - it makes sure threads running on different cores see the update correctly. This code seemed to work without this line but intuition suggests it could be important. In Lock() the stlxr instruction has an implied data memory barrier.
Summary (from a pseudocode perspective)
- Lock(x0):
1 | do { |
- Unlock(x0):
1 | *x0 = 0; // unlock |

