Cortex-M Register Set and Thumb-2 Instructions: A Practical Introduction

You can write a lot of Cortex-M firmware without touching assembly language, but sooner or later you will stare at a disassembly window during a crash, read a startup file, or need a few instructions the compiler will not generate. At that point you need to know what the registers are for and how Thumb-2 instructions read. This article covers the Cortex-M programmer's model, the calling convention that connects C and assembly, and a complete assembly function you can call from a C program.

The Core Registers

Every Cortex-M core has sixteen 32-bit general registers, R0 to R15, plus a few special registers that are accessed only with the MRS and MSR instructions.

RegisterNameRole
R0 to R3Argument registersFirst four function arguments and the return value; not preserved across calls
R4 to R11Variable registersMust be preserved by a called function (callee-saved)
R12IPScratch register, may be changed by the linker veneers; not preserved
R13SPStack pointer; two physical copies exist, MSP and PSP
R14LRLink register: return address after BL, or an EXC_RETURN code inside an exception
R15PCProgram counter
xPSRProgram statusCombined APSR flags, IPSR exception number and EPSR Thumb bit
PRIMASKInterrupt maskBit 0 = 1 blocks all exceptions with configurable priority
FAULTMASKFault maskBlocks everything except NMI (not on M0 and M0+)
BASEPRIPriority maskBlocks interrupts at or below a priority level (not on M0 and M0+)
CONTROLControlBit 0 nPRIV, bit 1 SPSEL selects PSP in thread mode, bit 2 FPCA on cores with an FPU

The application status flags in APSR are N (negative), Z (zero), C (carry) and V (overflow), plus Q for saturation on ARMv7-M. Conditional branches test these flags. On ARMv7E-M cores the GE bits record the results of SIMD instructions.

Modes and Stacks

The processor runs in Thread mode for normal code and Handler mode while servicing an exception. Handler mode always uses the main stack pointer (MSP). Thread mode uses MSP after reset, but an RTOS switches it to the process stack pointer (PSP) by setting CONTROL bit 1, so each task gets its own stack and interrupts keep using the main stack. The IPSR field of xPSR holds the number of the active exception, or zero in Thread mode, which is an easy way for code to check whether it is running inside an interrupt.

Thumb-2 in a Nutshell

Original Thumb was a 16-bit instruction set designed to save memory on ARM7, but it needed the 32-bit ARM set for full functionality. Thumb-2 mixes 16-bit and 32-bit instructions in one stream, giving the code density of Thumb with most of the power of ARM. Cortex-M3, M4 and M7 implement the full Thumb-2 set; Cortex-M0 and M0+ implement a subset that is mostly 16-bit, with only a few 32-bit instructions such as BL, MRS and MSR.

InstructionExampleEffect
MOV / MOVSmovs r0, #10r0 = 10; the S suffix updates the flags
MOVW / MOVTmovw r0, #0x1234Load a 16-bit constant into the low or high half (ARMv7-M only)
LDR / STRldr r1, [r0, #4]Load the word at address r0 + 4
Post-indexldr r3, [r0], #4Load from r0, then add 4 to r0 (ARMv7-M only)
ADD / SUBadds r2, r2, r3r2 = r2 + r3 and update flags
CMPcmp r0, #0Subtract and set flags without storing a result
B / BNE / BEQbne loopBranch, optionally on a condition
BL / BXbl func, then bx lrCall a function and return from it
CBZ / CBNZcbz r1, doneBranch forward if register is zero (ARMv7-M only)
ITite eqMake the next one to four instructions conditional (ARMv7-M only)
PUSH / POPpush {r4, lr}Save registers on the stack

Writing an Assembly Function Called from C

The ARM Architecture Procedure Call Standard (AAPCS) says the caller passes the first four arguments in R0 to R3 and receives the result in R0. A function may freely change R0 to R3 and R12, but must restore R4 to R11 and return through LR. The function below sums an array of words using only R0 to R3, so it needs no stack at all.

sum.s: uint32_t asm_sum(const uint32_t *p, uint32_t n) for Cortex-M3/M4
    .syntax unified
    .cpu    cortex-m3
    .thumb

    .text
    .global asm_sum
    .type   asm_sum, %function

@ r0 = pointer to array, r1 = number of elements
@ returns the 32-bit sum in r0
asm_sum:
    movs    r2, #0          @ r2 = running total
    cbz     r1, done        @ n == 0: return 0
loop:
    ldr     r3, [r0], #4    @ r3 = *p, then p++
    adds    r2, r2, r3      @ total += r3
    subs    r1, r1, #1      @ n--, sets Z when n reaches 0
    bne     loop
done:
    mov     r0, r2          @ result in r0
    bx      lr              @ return to caller

    .size   asm_sum, . - asm_sum
main.c: call the assembly function and read special registers
#include <stdint.h>

extern uint32_t asm_sum(const uint32_t *p, uint32_t n);

static inline uint32_t read_control(void)
{
    uint32_t r;
    __asm volatile ("mrs %0, control" : "=r" (r));
    return r;
}

static inline uint32_t read_ipsr(void)
{
    uint32_t r;
    __asm volatile ("mrs %0, ipsr" : "=r" (r));
    return r;
}

static inline uint32_t read_msp(void)
{
    uint32_t r;
    __asm volatile ("mrs %0, msp" : "=r" (r));
    return r;
}

volatile uint32_t result, control, ipsr, msp;   /* watch these in the debugger */

int main(void)
{
    static const uint32_t data[5] = { 10, 20, 30, 40, 50 };

    result  = asm_sum(data, 5);   /* expect 150 */
    control = read_control();     /* 0 after reset: privileged, MSP */
    ipsr    = read_ipsr();        /* 0: thread mode */
    msp     = read_msp();         /* somewhere near the top of RAM */

    while (1)
        ;
}

Build both files together, for example arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O1 -c sum.s main.c, and link them with your usual startup file and linker script. In the debugger, the watched variables should show a result of 150, CONTROL equal to 0 and IPSR equal to 0. If you set a breakpoint inside an interrupt handler and read IPSR there, it shows the exception number instead, which is 16 plus the IRQ number for peripheral interrupts.

Reading Compiler Output

Compile a C version of the same loop with -O1 and inspect it with arm-none-eabi-objdump -d main.o. You will typically see the same pattern: arguments arriving in r0 and r1, a load with post-increment, an add, a subtract that sets flags and a conditional branch. Recognising this pattern is the main skill needed to follow a fault address back to C source. Note that the exact instructions depend on compiler version and optimisation level.

Common Mistakes

  • Using R4 to R11 in an assembly routine without PUSH and POP; the calling C function then gets corrupted variables.
  • Forgetting .thumb_func or %function type, so the address loses its Thumb bit and the call causes a UsageFault with INVSTATE.
  • Using CBZ, IT or post-indexed loads in code built for Cortex-M0; the assembler rejects them because ARMv6-M does not have them.
  • Writing ADD when the following branch depends on flags; in unified syntax only ADDS updates them.
  • Expecting MSP to always be the stack in use; inside RTOS tasks it is PSP.

Summary

Cortex-M gives you R0 to R12 for data, SP, LR and PC, the xPSR flags and exception number, and the PRIMASK, BASEPRI, FAULTMASK and CONTROL special registers. AAPCS passes arguments in R0 to R3 and expects R4 to R11 to be preserved. Thumb-2 mixes 16-bit and 32-bit instructions, with ARMv7-M adding conveniences such as CBZ, IT and MOVW. With this model in mind, disassembly and fault dumps become readable.