ARM64: instruction decoding explained
When I started writing decompatty, I was making very good progress, all up until
I got to parsing the __text section, which is when I hit a roadblock. __text holds the program’s encoded instructions, and decoding these instructions isn’t exactly intuitive.
Instruction decoding
Luckily, ARM64 is a relatively simple architecture to decode, in comparison to x86 assembly, which instead uses variable-length instructions. ARM64 leverages fixed-size 32-bit instructions, which are divided into groups of bits, each with its own purpose. This article will only cover a small subset of common ARM64 instructions rather than attempting to cover the entire instruction set.
Base Instructions
This article focuses on four major A64 instruction groups
- Loads and stores (general-purpose registers)
- Data processing (immediate)
- Data processing (register)
- Branches, exception generation, and system instructions
Loads and Stores
The Loads and Stores group contains instructions that transfer data between memory and general-purpose registers.
Therefore, we use the term load when loading data from memory into registers, and the term store when storing register contents to memory. Examples include ldr, str, ldp, and stp, etc.
Data Processing (Immediate)
The Data Processing (Immediate) group contains instructions that perform operations using immediate values encoded
directly within the instruction. Examples include add, sub, mov, and, orr, etc.
Data Processing (Register)
The Data Processing (Register) group contains instructions that perform operations using values stored in registers.
Unlike immediate instructions, the operands are obtained from registers rather than constants encoded directly in the instruction.
Examples include add, sub, and, orr, mul, etc.
Branches, Exception Generation, and System Instructions
The Branches, Exception Generation, and System Instructions group contains instructions that alter program execution, generate exceptions, or interact with the processor’s system-level functionality.
Branch instructions change the address of the next instruction to execute. Exception instructions intentionally cause an exception, while system instructions provide access to processor/system functionality.
Examples include b, bl, br, blr, ret, and hint instructions such as nop.
ret
ret is similar to nop in terms of simplicity (more on nop later). ret stands for “return” and transfers program
execution back to the caller of a function. ret branches to the address stored in Rn, which defaults to x30, the Link Register
(LR).
Consider the following table:
| Instruction | Bitmask | Value |
|---|---|---|
| ret | 0xFFFFFC1F | 0xD65F0000 |
If we look closely, we can notice ret’s bitmask is slightly different, compared to nop’s bitmask. Let’s
break it down into its binary form and analyze it.
// 0xFFFFFC1F
31 24 23 16 15 8 7 0
1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 1
In ARM64 instructions, all 32 bits are labeled, where the most significant bit (MSB) is 31, and the least significant bit (LSB) is 0.
As we can observe from the block above, every bit of the mask is set to 1, except for bits 5-9, but why?
This is because that group of bits represents the register number (Rn). But why 5 bits? This is due to the fact that in the
ARM64 architecture, the field is 5 bits wide, allowing 32 (25) possible register encodings (0-31). The mask essentially takes
an encoded instruction, “strips” it of the variable bits, and then compares it to the ret value to check if it
truly is of the form ret.
uint32_t word = 0xD65F03C0;
uint32_t mask = 0xFFFFFC1Fu;
uint32_t value = 0xD65F0000u;
if ((word & mask) == value) {
// proceed with ret logic
}
The zero bits in our identification mask correspond to bits that may vary between valid encodings of ret. In this case, bits 5-9 encode
Rn, so we can extract that field. This is where we introduce the second phase of decoding, which is extracting the data from the
aforementioned bit group. Let’s inspect our instruction, word.
// 0xD65F03C0
31 24 23 16 15 9 8 7 5 0
1 1 0 1 0 1 1 0 0 1 0 1 1 1 1 1 0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0
Particularly, bits 5-9 (1 1 1 1 0). In order to do this, we have to first isolate these bits, and then
interpret them correctly.
uint32_t word = 0xD65F03C0;
uint32_t mask = 0xFFFFFC1Fu;
uint32_t value = 0xD65F0000u;
uint32_t field_rn = 0x000003E0u; // mask to capture bits 5-9
if ((word & mask) == value) {
uint32_t stripped = (word & field_rn); // word, but stripped of anything that isn't register number data
uint32_t reg = (stripped >> 5); // shift the bits to the last position
printf("ret x%u", reg);
}
Output:
ret x30
For ret instructions, you’ll most likely see the x30 register every time. This is since x30 serves as the
Link Register (LR). Modern disassemblers such as LLVM will omit the x30 register, and simply just output the mnemonic:
$ echo "0xc0 0x03 0x5f 0xd6" | llvm-mc -triple=aarch64 -disassemble
ret
nop
The nop instruction is the simplest, quite literally doing nothing. nop stands for “no operation”, which essentially tells
the processor to “do nothing” for one CPU cycle. Its most common use case is padding & code alignment, as it essentially advances
the program counter by 4 bytes without changing any architectural state.
In ARM64 assembly, most instructions are decoded through a bitmask, and then compared against constant values.
Consider the following table:
| Instruction | Bitmask | Value |
|---|---|---|
| nop | 0xFFFFFFFF | 0xD503201F |
And the following encoded instruction:
0xD503201F
If we apply the nop mask (0xFFFFFFFF) via AND, and the result is equal to the nop constant value,
then we know the current instruction is nop.
uint32_t word = 0xD503201Fu;
uint32_t mask = 0xFFFFFFFFu;
uint32_t value = 0xD503201Fu;
if ((word & mask) == value) {
// proceed with nop logic
}
Since nop’s mask is just 1’s, the arithmetic expression essentially evaluates to:
if (word == value) {
// proceed with nop logic
}