Articles

ARM64: instruction decoding explained

3 min read

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

  1. Loads and stores (general-purpose registers)
  2. Data processing (immediate)
  3. Data processing (register)
  4. 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
}

References

  1. Official ARM documentation
  2. Base ARM instructions