ARM Thumb-2 Assembly Basics

Reading assembly is an essential debugging skill even if you rarely write it. Learn the Cortex-M register file (R0–R15, xPSR), the load/store architecture, key Thumb-2 instructions (MOV, LDR/STR, ADD/SUB, CMP, B/BL/BX, PUSH/POP), condition flags N Z C V, the AAPCS calling convention, inline assembly and intrinsics, and how C constructs compile to instructions.

When a HardFault stops your program at address 0x08001A3C, the debugger shows you assembly. When an ISR is too slow, the disassembly shows why. When you write a context switch or startup code, some assembly is unavoidable.

ARM Cortex-M cores execute the Thumb-2 instruction set: a mix of 16- and 32-bit instructions with high code density. The architecture is load/store — arithmetic only works on registers, and memory is accessed only through LDR/STR.

An analogy

Registers are the CPU's hands, memory is the warehouse shelves. The CPU can only work on what it holds in its hands (registers); LDR fetches from a shelf and STR puts it back.

Registers and the load/store model

Cortex-M has 16 visible 32-bit registers. R0–R12 hold data, SP points to the stack, LR holds the return address after BL, and PC is the current instruction address. To add two variables from memory: LDR both into registers, ADD, then STR the result back.

Flags and conditional execution

Instructions ending in S (ADDS, SUBS) and CMP/TST update N (result negative), Z (result zero), C (unsigned carry/no-borrow) and V (signed overflow). Conditional branches test combinations: BEQ (Z=1), BHI (unsigned >, C=1 & Z=0), BGT (signed >). Thumb-2 IT blocks make up to four following instructions conditional without branching.

Calling convention (AAPCS)

Functions receive their first four arguments in R0–R3 (more on the stack) and return in R0. A called function may freely destroy R0–R3 and R12 but must preserve R4–R11 (pushing them if used). The stack must be 8-byte aligned at public interfaces. Knowing this lets you read a crash backtrace and write assembly callable from C.

When to use assembly

Write assembly only for context switching, startup before C runtime exists, special instructions, or proven hot spots. Otherwise let the compiler optimise and use intrinsics. DSP extensions on Cortex-M4/M7 (SMLAD, QADD, SIMD) are accessed via CMSIS-DSP. Always inspect disassembly (objdump -d, the IDE disassembly view) when tuning performance — the compiler's output is the truth.

Formulas

Worked example: Signed overflow

Given: R0 = 0x7FFFFFFF, R1 = 1; ADDS R0, R0, R1

Find: Result and flags.

Answer: R0 = 0x80000000, N=1 Z=0 C=0 V=1

Worked example: Reading a HardFault location

Given: Stacked PC = 0x08001A3C; Disassembly: ldr r3, [r2, #8] with r2 = 0x00000000

Find: Cause.

Answer: NULL pointer dereference of a struct member at offset 8

Worked example: Loop timing

Given: 4-instruction loop, ~5 cycles/iteration; 1000 elements at 168 MHz

Find: Execution time.

Answer: ≈ 30 µs

C function and its Thumb-2 translation

/* C source */
uint32_t sum(const uint32_t *p, uint32_t n)
{
    uint32_t s = 0;
    while (n--) s += *p++;
    return s;
}

@ Typical -O2 output (arm-none-eabi-gcc, Cortex-M4)
sum:
        movs    r3, #0          @ s = 0
        cbz     r1, .done       @ if n == 0 return
.loop:
        ldr     r2, [r0], #4    @ r2 = *p; p += 4  (post-increment)
        subs    r1, r1, #1      @ n--, sets Z flag
        add     r3, r3, r2      @ s += r2
        bne     .loop           @ repeat while n != 0
.done:
        mov     r0, r3          @ return value in R0
        bx      lr              @ return to caller

Arguments arrive in R0 (pointer) and R1 (count), the result returns in R0. The loop is 4 instructions — you can estimate timing by counting (LDR ≈ 2 cycles, others 1, taken branch ≈ 2–4).

Intrinsics and inline assembly

#include "cmsis_gcc.h"

void critical_update(volatile uint32_t *shared, uint32_t v)
{
    uint32_t primask = __get_PRIMASK();  /* save interrupt state      */
    __disable_irq();                      /* CPSID i                   */
    *shared = v;                          /* protected write           */
    __set_PRIMASK(primask);               /* restore (not blindly enable) */
}

static inline uint32_t reverse_bits(uint32_t x)
{
    uint32_t r;
    __asm volatile ("rbit %0, %1" : "=r"(r) : "r"(x));   /* one instruction */
    return r;
}

CMSIS intrinsics (__disable_irq, __WFI, __REV, __CLZ) give access to special instructions without writing assembly files. Saving and restoring PRIMASK makes critical sections nest safely.

Common mistakes

Applications

Key takeaways

Going further