Embedded HandbookApps
All sections › II. Core and system mechanisms
STM32F407, STM32F405, STM32F030 · level advanced · 16 min read
Open in the interactive handbook

7б. Cortex-M assembly: Thumb-2 through the eyes of C

There is no need to write firmware in assembly. You have to read it all the time: the debugger stopped at HardFault amid unfamiliar instructions, a function suddenly became twice as big, a "simple" division drags a library along with it. This section is about how to read what the compiler has produced, and what the Cortex-M4 core can do that Cortex-M0 cannot.

Everything shown is the output of arm-none-eabi-gcc -O2 and objdump on twelve small functions (_verify/thumb7b/funcs.c) for two cores, and the assembly functions and fault traps were run in QEMU (machine netduinoplus2, Cortex-M4F). The rules were checked against the Cortex-M4 core manual from the pack. The check is _verify/thumb7b_check.py. How to write assembly yourself — start-up code, atomics, DSP, task switching — is in section 7в; x86-64 assembly is in section 144.

You get a listing on your own machine in two ways. The compiler's assembly — arm-none-eabi-gcc -O2 -mcpu=cortex-m4 -mthumb -S file.c (with -fverbose-asm — also with variable names). A disassembly of finished firmware — arm-none-eabi-objdump -d -S app.elf: the -S switch interleaves the code with the source if it was built with -g. In CubeIDE and Keil the same is shown by the Disassembly window during debugging.

Terms used in the documentation

Term Meaning
Thumb-2 instruction set the Thumb-2 set: 16- and 32-bit instructions mixed together
16-bit / 32-bit encoding (.n / .w) the short / long form of an instruction
calling convention (AAPCS) the rules for passing arguments and saving registers
IT block (If-Then) an "if — then" block: up to four conditional instructions
literal pool constants placed next to the code
pre-indexed / post-indexed addressing an offset applied before / after the access
divide-by-zero trap (DIV_0_TRP) a fault on division by zero
UsageFault, UFSR the "wrong usage" fault and its cause register

7б.1 Registers and the calling convention

The core has sixteen 32-bit registers: r0–r12 general purpose, sp (r13) — the stack pointer, lr (r14) — the return address, pc (r15) — the program counter. Plus xPSR with the flags N, Z, C, V.

Who holds what is decided by the calling convention (AAPCS). All its rules can be seen in the compiler output:

Register Role Where it is seen in the experiment
r0–r3 the first four arguments; r0 — the result add6: a…d are added straight from r0–r3
stack the fifth argument and beyond add6: ldr r1, [sp, #0] and ldr r2, [sp, #4] are e and f
r0:r1 a 64-bit result mul64: smull r0, r1, r0, r1
r4–r11 saved by the callee if it touches them asm_minmax: push {r4, r5} … pop {r4, r5}
r12, lr corrupted by a call the caller does not count on them after bl

Return from a function is bx lr: a branch to the address in lr. A function that itself calls someone saves lr on the stack: push {r4, lr} at the start and pop {r4, pc} at the end — the return is done straight by loading into pc.

The sixteen Cortex-M registers and their roles under the calling convention: r0–r3 — arguments and result, r0:r1 — a 64-bit result, r4–r11 saved by the callee, r12 and lr corrupted by a call, sp — the stack with the fifth argument and beyond, pc — the program counter; examples from the experiment — add6 takes e and f from the stack, mul64 returns the result through smull


7б.2 Sixteen and thirty-two bits

In Thumb-2 instructions are 16- and 32-bit, mixed together. The short ones work mostly with r0–r7 and small constants; the long ones can do everything. In an objdump listing the long form where a short one could have been is marked by the suffix .w (ldr.w, and.w), the short one by .n (bne.n).

Twelve functions in one file, -O2:

Cortex-M4 (Thumb-2) Cortex-M0 (Thumb)
.text code 140 bytes 188 bytes
16-bit instructions 35 75
32-bit instructions 13 4 — only bl
constants in the pool 4 bytes 16 bytes
library calls none __aeabi_lmul ×2, __clzsi2, __aeabi_idiv

M0 has a third more code, and that is without the library functions themselves: they will be added at link time.

The size of each of the twelve functions in bytes: Cortex-M4 versus Cortex-M0

The gain is not everywhere: sum_array on M4 is 28 bytes versus 26, rcc_enable 20 versus 16. Where M0 gets by with short instructions, M4 has the long ldr.w, str.w, add.w — they do more, but also take twice the space.


7б.3 One line of C — one instruction

The main thing Thumb-2 gives compared with M0's Thumb is instructions that do a whole line of C at once:

C line Cortex-M4 Cortex-M0
(int64_t)a * b smull r0, r1, r0, r1 a call to __aeabi_lmul
acc + (int64_t)a * b smlal r0, r1, r2, r3 a call to __aeabi_lmul + adds/adcs
a > b ? a : b cmp; it lt; movlt r0, r1 cmp; bge; movs
(x >> 5) & 0x3F ubfx r0, r0, #5, #6 lsls #21; lsrs #26
__builtin_clz(x) clz r0, r0 a call to __clzsi2
a / b sdiv r0, r0, r1 a call to __aeabi_idiv
saturation to int16_t ssat r0, #16, r0 two comparisons, two branches, two constants
__builtin_bswap32(x) rev r0, r0 rev r0, r0

All functions were run on M4 in QEMU: max2(-5, 3) = 3, get_field(0x7E0) = 0x3F, lead_zeros(1) = 31, sat16(40000) = 32767, sat16(−40000) = −32768, mac64(10¹², 100000, −300000) = 970 000 000 000 (0xE1_D8816400), swap_bytes(0x12345678) = 0x78563412.

The IT block. The instruction it lt makes the next instruction conditional: movlt executes only if after cmp the result was "less than". A block holds up to four instructions (itt, ite, itete…). There are no branches — so there is no pipeline flush either. M0 has no IT blocks, and the same max2 uses a bge branch.

Not every "obvious" instruction will be chosen. For writing the field (x & ~(0x3F << 5)) | ((v & 0x3F) << 5) M4 has bfi, but GCC produced lsls + and.w + bic.w + orrs — 14 bytes instead of a possible 6. The compiler is not obliged to recognise every pattern; if this is a hot spot — use __builtin or inline assembly.


7б.4 Constants: the literal pool

A register address is a 32-bit number, and only a small constant fits into an instruction. So the compiler puts the number next to the code and reads it with an ldr relative to pc:

rcc_enable (Cortex-M4):
  74:  ldr    r2, [pc, #12]        ; r2 = 0x40023000 — из пула по адресу 0x84
  76:  ldr.w  r3, [r2, #2096]      ; + 0x830 = 0x40023830 (RCC_AHB1ENR)
  7a:  orr.w  r3, r3, #1
  7e:  str.w  r3, [r2, #2096]
  82:  bx     lr
  84:  .word  0x40023000

The M4 trick: the pool holds the base 0x40023000, and the low bits went into the offset of the 32-bit ldr.w — one base can be shared among many registers of the block. On M0 the offset is short, and the whole address 0x40023830 goes into the pool.

A debugger that disassembles sequentially will show .word as a meaningless instruction — it is data, not code.


7б.5 A loop and addressing

sum_array, цикл (Cortex-M4):         sum_array, цикл (Cortex-M0):
  ldr.w  r2, [r3, #4]!                 ldmia  r3!, {r2}
  cmp    r3, r1                        adds   r0, r0, r2
  add    r0, r2                        cmp    r3, r1
  bne.n  64                            bne.n  98

[r3, #4]! — first add 4 to r3, read at the new address and leave r3 advanced: the pointer steps by itself, with no separate adds. M0 has no such addressing; the compiler used ldmia r3!, {r2} — "load multiple registers", here one, with the same pointer advance.


7б.6 A function in assembly by hand

A .S file (capital letter — goes through the preprocessor) next to the C files, called from C by the same AAPCS rules:

asm_minmax:                       @ void asm_minmax(const int32_t *p, uint32_t n, int32_t out[2]), n ≥ 1
    push    {r4, r5}              @ r4, r5 — «чужие»: сохранить и вернуть
    ldr     r3, [r0], #4          @ r3 = *p, затем p += 4
    mov     r4, r3                @ минимум
    mov     r5, r3                @ максимум
    subs    r1, r1, #1
    beq     2f
1:  ldr     r3, [r0], #4
    cmp     r3, r4
    it      lt
    movlt   r4, r3
    cmp     r3, r5
    it      gt
    movgt   r5, r3
    subs    r1, r1, #1            @ флаги ставит само вычитание — отдельный cmp не нужен
    bne     1b
2:  strd    r4, r5, [r2]          @ две записи одной командой
    pop     {r4, r5}
    bx      lr

[r0], #4 is the post-indexed form: read at r0, then advance. The first element is read before n is checked, so the function needs n ≥ 1: with n = 0 the subtraction gives 0xFFFFFFFF, and the loop runs far beyond the array. Results in QEMU:

Call Result
asm_minmax({5, −3, 12, 7, −8, 0}, 6) min −8, max 12
asm_minmax({12}, 1) min 12, max 12
asm_sum({1, 2, 3, 4, 5, 0xFFFFFFFF}, 6) 0x0000000E — the carry out of the top bit is lost, as in C
asm_sum(…, 0) 0 — immediate exit by cbz

cbz/cbnz — "compare with zero and branch" in a single 16-bit instruction; but according to the manual only for r0–r7, only forward by 4…130 bytes and not inside an IT block.

The asm_sum loop is four instructions, the same as the compiler's: hand-written assembly is not faster on simple code. Its place is what cannot be expressed in C: exception entry (section 7а), task switching, an exact sequence of instructions.


7б.7 Division by zero and unaligned access

Two cases in which Cortex-M4 behaves differently from what a C programmer expects. An experiment in QEMU; the UsageFault handler records the cause registers and steps over the offending 32-bit instruction:

Case What happened
7 / 0, CCR.DIV_0_TRP = 0 (the default) the result is 0, no exception at all
7 / 0, DIV_0_TRP = 1, UsageFault enabled UsageFault, CFSR = 0x02000000 (DIVBYZERO); the saved PC = 0x08000590 — the address of the sdiv itself
LDR from an odd address read 0x14131211 — it works
LDRD from the same address UsageFault, CFSR = 0x01000000 (UNALIGNED) — always, without any settings
7 / 0 with the trap, but UsageFault disabled in SHCSR HardFault, HFSR = 0x40000000 (FORCED), the same cause in CFSR

Conclusions:

A limitation of the model. According to the manual, with CCR.UNALIGN_TRP = 1 an unaligned LDR also faults. QEMU 7.2 does not model this: in the experiment LDR from an odd address was read even with the bit set. On silicon it is a UsageFault UNALIGNED.


7б.8 Inline assembly

When you need one or two instructions in the middle of C, a .S file is not needed:

static uint32_t ldrd_at(const uint8_t *p)
{
    uint32_t lo, hi;
    __asm volatile("ldrd %0, %1, [%2]" : "=r"(lo), "=r"(hi) : "r"(p) : "memory");
    return lo ^ hi;
}

"=r" — output into any register, "r" — input, "memory" — warn the compiler that the instruction reads memory. For frequent cases there are ready CMSIS functions: __CLZ, __REV, __SSAT, __DSB, __disable_irq — this is the same inline assembly, but verified.


7б.9 Pitfalls

Meaningless instructions in the middle of a function in the disassembler. This is the literal pool: data after bx lr.

Division by zero "works". sdiv returns 0; enable DIV_0_TRP and USGFAULTENA at least in the debug build.

HardFault when reading a structure from a buffer. An unaligned pointer and LDRD/LDM — CFSR = 0x01000000. Copy through memcpy into an aligned variable.

All faults are HardFault. UsageFault, BusFault, MemManage are not enabled in SHCSR; the cause is still in CFSR.

Code for M0 is noticeably larger. Without libraries — by a third (188 bytes versus 140, 7б.2), and with __aeabi_idiv, __aeabi_lmul and __clzsi2 from libgcc — even more: M0 has no hardware division, 64-bit multiplication or clz.

Hand-written assembly corrupts the caller's variables. If you touched r4–r11, save them; lr too, if there is a bl inside.

A conditional instruction outside an IT block. GNU as will not assemble movlt without it lt by default: "thumb conditional instruction should be in IT block". With the switch -mimplicit-it=always it inserts it lt itself.


7б.10 Summary

Full sources

asm7b.S — two assembly functions called from C under the AAPCS convention.

/* Раздел 7б: две функции, написанные на ассемблере вручную, — вызываются из C по соглашению AAPCS:
 * аргументы в r0…r3, результат в r0, r4…r11 надо сохранить, если трогаешь. */
    .syntax unified
    .thumb
    .text

    .global asm_sum             /* uint32_t asm_sum(const uint32_t *p, uint32_t n) */
    .type asm_sum, %function
    .thumb_func
asm_sum:
    movs    r2, #0              /* сумма */
    cbz     r1, 2f              /* n == 0 — сразу выход */
1:  ldr     r3, [r0], #4        /* r3 = *p; p += 4 — одна команда */
    adds    r2, r2, r3
    subs    r1, r1, #1          /* флаги ставит сама команда вычитания — отдельное сравнение не нужно */
    bne     1b
2:  mov     r0, r2
    bx      lr
    .size asm_sum, . - asm_sum

    .global asm_minmax          /* void asm_minmax(const int32_t *p, uint32_t n, int32_t out[2]), n ≥ 1 */
    .type asm_minmax, %function
    .thumb_func
asm_minmax:
    push    {r4, r5}            /* r4, r5 — «чужие»: сохранить и вернуть */
    ldr     r3, [r0], #4
    mov     r4, r3              /* минимум */
    mov     r5, r3              /* максимум */
    subs    r1, r1, #1
    beq     2f
1:  ldr     r3, [r0], #4
    cmp     r3, r4
    it      lt                  /* следующая команда выполнится, только если r3 < r4 */
    movlt   r4, r3
    cmp     r3, r5
    it      gt
    movgt   r5, r3
    subs    r1, r1, #1
    bne     1b
2:  strd    r4, r5, [r2]        /* две 32-битные записи одной командой */
    pop     {r4, r5}
    bx      lr
    .size asm_minmax, . - asm_minmax

run7b.c — the firmware of the QEMU experiment: assembly functions, division by zero, unaligned access and HardFault.

/* Раздел 7б: прогон в QEMU netduinoplus2 (STM32F405, Cortex-M4F).
 * 1. Ассемблерные функции из asm7b.S и функции из funcs.c на настоящем наборе команд.
 * 2. Деление на ноль: без ловушки результат 0; с CCR.DIV_0_TRP — UsageFault.
 * 3. Невыровненный доступ: LDR по нечётному адресу работает, LDRD — UsageFault; с CCR.UNALIGN_TRP падает и LDR.
 * 4. Если UsageFault не включён в SHCSR — та же ошибка поднимается до HardFault (HFSR.FORCED).
 * Обработчик ошибки запоминает регистры состояния и перешагивает виновную 32-битную команду. */
#include <stdint.h>

#define RCC_APB2ENR (*(volatile uint32_t *)0x40023844)
#define USART1_SR   (*(volatile uint32_t *)0x40011000)
#define USART1_DR   (*(volatile uint32_t *)0x40011004)
#define USART1_CR1  (*(volatile uint32_t *)0x4001100C)
#define SCB_CCR     (*(volatile uint32_t *)0xE000ED14)
#define SCB_SHCSR   (*(volatile uint32_t *)0xE000ED24)
#define SCB_CFSR    (*(volatile uint32_t *)0xE000ED28)
#define SCB_HFSR    (*(volatile uint32_t *)0xE000ED2C)

uint32_t asm_sum(const uint32_t *p, uint32_t n);
void asm_minmax(const int32_t *p, uint32_t n, int32_t out[2]);
int max2(int a, int b);
uint32_t get_field(uint32_t x);
uint32_t set_field(uint32_t x, uint32_t v);
int lead_zeros(uint32_t x);
int divide(int a, int b);
int sat16(int x);
int64_t mac64(int64_t acc, int32_t a, int32_t b);
uint32_t swap_bytes(uint32_t x);
int add6(int a, int b, int c, int d, int e, int f);

static void putc1(char c)
{
    while (!(USART1_SR & (1u << 7))) {
    }
    USART1_DR = (uint32_t)c;
}

static void puts1(const char *s)
{
    while (*s)
        putc1(*s++);
}

static void puthex(uint32_t v)
{
    static const char hex[] = "0123456789abcdef";
    puts1("0x");
    for (int i = 28; i >= 0; i -= 4)
        putc1(hex[(v >> i) & 0xF]);
}

static void putdec(int32_t v)
{
    char b[12];
    int i = 0;
    uint32_t u = v < 0 ? 0u - (uint32_t)v : (uint32_t)v;
    if (v < 0)
        putc1('-');
    do {
        b[i++] = (char)('0' + u % 10);
        u /= 10;
    } while (u);
    while (i)
        putc1(b[--i]);
}

static void kv(const char *k, uint32_t v)
{
    puts1(k);
    puts1("=");
    puthex(v);
    puts1("\n");
}

static void kd(const char *k, int32_t v)
{
    puts1(k);
    puts1("=");
    putdec(v);
    puts1("\n");
}

/* что увидел обработчик ошибки */
volatile uint32_t f_kind, f_cfsr, f_hfsr, f_pc;

void fault_c(uint32_t *frame, uint32_t kind)
{
    f_kind = kind;
    f_cfsr = SCB_CFSR;
    f_hfsr = SCB_HFSR;
    f_pc = frame[6];
    SCB_CFSR = f_cfsr;          /* биты сбрасываются записью единицы */
    SCB_HFSR = f_hfsr;
    frame[6] += 4;              /* перешагнуть виновную команду: SDIV и LDRD здесь 32-битные */
}

__attribute__((naked)) void UsageFault_Handler(void)
{
    __asm volatile("tst lr, #4 \n ite eq \n mrseq r0, msp \n mrsne r0, psp \n movs r1, #6 \n push {lr} \n bl fault_c \n pop {pc}");
}

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile("tst lr, #4 \n ite eq \n mrseq r0, msp \n mrsne r0, psp \n movs r1, #3 \n push {lr} \n bl fault_c \n pop {pc}");
}

static void report(const char *name)
{
    puts1(name);
    puts1(": fault=");
    putdec((int32_t)f_kind);
    puts1(" cfsr=");
    puthex(f_cfsr);
    puts1(" hfsr=");
    puthex(f_hfsr);
    puts1(" pc=");
    puthex(f_pc);
    puts1("\n");
    f_kind = f_cfsr = f_hfsr = f_pc = 0;
}

static const uint32_t words[6] = {1, 2, 3, 4, 5, 0xFFFFFFFFu};
static const int32_t vals[6] = {5, -3, 12, 7, -8, 0};
static uint8_t buf[16] __attribute__((aligned(8))) = {0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
                                                      0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F};
volatile int zero = 0;
volatile int seven = 7;

static uint32_t ldrd_at(const uint8_t *p)
{
    uint32_t lo, hi;
    __asm volatile("ldrd %0, %1, [%2]" : "=r"(lo), "=r"(hi) : "r"(p) : "memory");
    return lo ^ hi;
}

int main(void)
{
    RCC_APB2ENR |= 1u << 4;
    USART1_CR1 = (1u << 13) | (1u << 3);

    kv("asm_sum6", asm_sum(words, 6));
    kv("asm_sum0", asm_sum(words, 0));
    int32_t mm[2];
    asm_minmax(vals, 6, mm);
    kd("asm_min", mm[0]);
    kd("asm_max", mm[1]);
    asm_minmax(vals + 2, 1, mm);
    kd("asm_min1", mm[0]);
    kd("asm_max1", mm[1]);

    kd("max2", max2(-5, 3));
    kv("get_field", get_field(0x7E0u));
    kv("set_field", set_field(0xFFFFFFFFu, 0x15u));
    kd("clz1", lead_zeros(1));
    kd("clz_top", lead_zeros(0x80000000u));
    kd("sat_hi", sat16(40000));
    kd("sat_lo", sat16(-40000));
    kd("sat_mid", sat16(-123));
    int64_t m = mac64(1000000000000LL, 100000, -300000);
    kv("mac_lo", (uint32_t)m);
    kv("mac_hi", (uint32_t)((uint64_t)m >> 32));
    kv("swap", swap_bytes(0x12345678u));
    kd("add6", add6(1, 2, 3, 4, 5, 6));

    /* деление на ноль */
    kd("div_ok", divide(seven * 6, seven));
    kd("div0_notrap", divide(seven, zero));
    report("div0_notrap");
    SCB_SHCSR |= 1u << 18;                  /* USGFAULTENA */
    SCB_CCR |= 1u << 4;                     /* DIV_0_TRP */
    kd("div0_trap_result", divide(seven, zero));
    report("div0_trap");

    /* невыровненный доступ */
    kv("ldr_odd", *(volatile uint32_t *)(void *)(buf + 1));
    report("ldr_odd");
    kv("ldrd_even", ldrd_at(buf));
    report("ldrd_even");
    ldrd_at(buf + 1);
    report("ldrd_odd");
    SCB_CCR |= 1u << 3;                     /* UNALIGN_TRP */
    (void)*(volatile uint32_t *)(void *)(buf + 1);
    report("ldr_odd_trap");
    SCB_CCR &= ~(1u << 3);

    /* UsageFault выключен — ошибка поднимается до HardFault */
    SCB_SHCSR &= ~(1u << 18);
    divide(seven, zero);
    report("div0_escalated");
    puts1("done\n");
    for (;;) {
    }
}

Next: section 7в "Cortex-M assembly in practice: start-up code, atomics, DSP and task switching".

Open in the interactive handbook