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.
| Register | Name | Role |
|---|---|---|
| R0 to R3 | Argument registers | First four function arguments and the return value; not preserved across calls |
| R4 to R11 | Variable registers | Must be preserved by a called function (callee-saved) |
| R12 | IP | Scratch register, may be changed by the linker veneers; not preserved |
| R13 | SP | Stack pointer; two physical copies exist, MSP and PSP |
| R14 | LR | Link register: return address after BL, or an EXC_RETURN code inside an exception |
| R15 | PC | Program counter |
| xPSR | Program status | Combined APSR flags, IPSR exception number and EPSR Thumb bit |
| PRIMASK | Interrupt mask | Bit 0 = 1 blocks all exceptions with configurable priority |
| FAULTMASK | Fault mask | Blocks everything except NMI (not on M0 and M0+) |
| BASEPRI | Priority mask | Blocks interrupts at or below a priority level (not on M0 and M0+) |
| CONTROL | Control | Bit 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.
| Instruction | Example | Effect |
|---|---|---|
| MOV / MOVS | movs r0, #10 | r0 = 10; the S suffix updates the flags |
| MOVW / MOVT | movw r0, #0x1234 | Load a 16-bit constant into the low or high half (ARMv7-M only) |
| LDR / STR | ldr r1, [r0, #4] | Load the word at address r0 + 4 |
| Post-index | ldr r3, [r0], #4 | Load from r0, then add 4 to r0 (ARMv7-M only) |
| ADD / SUB | adds r2, r2, r3 | r2 = r2 + r3 and update flags |
| CMP | cmp r0, #0 | Subtract and set flags without storing a result |
| B / BNE / BEQ | bne loop | Branch, optionally on a condition |
| BL / BX | bl func, then bx lr | Call a function and return from it |
| CBZ / CBNZ | cbz r1, done | Branch forward if register is zero (ARMv7-M only) |
| IT | ite eq | Make the next one to four instructions conditional (ARMv7-M only) |
| PUSH / POP | push {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.
.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
#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.