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.
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 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:
- Division by zero is silent. By default
sdivreturns 0, and the program carries on with a wrong number. The trap is enabled with two bits:SCB->CCR |= SCB_CCR_DIV_0_TRP_MskandSCB->SHCSR |= SCB_SHCSR_USGFAULTENA_Msk. - Stepping over an instruction does not fix the error. After skipping
sdiv,r0still held the dividend (7) — the "result" turned out to be garbage. A fault handler should report and restart, not continue. - Unaligned
LDR/STRworks, butLDRD/STRD/LDM/STMdoes not. These are exactly what the compiler uses for 64-bit fields and for copying structures: reading through(uint64_t *)(buf + 1)when parsing a packet does not always fail, but only where the compiler decided to read with a pair of registers. - UsageFault, BusFault and MemManage without being enabled in
SHCSRbecome HardFault.HFSRhas the FORCED bit, and the real cause is still inCFSR.
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
- AAPCS: arguments in
r0–r3, then the stack; the result inr0(r0:r1for 64 bits);r4–r11are saved by the callee. - Thumb-2 mixes 16- and 32-bit instructions: 12 functions take 140 bytes on M4 versus
188 on M0, where division,
clzand 64-bit multiplication are library calls. - M4 does a line of C in one instruction:
smull,smlal,ubfx,clz,sdiv,ssat; a choice without a branch is an IT block of up to four instructions. - Long constants live in the literal pool next to the code.
- Division by zero gives 0 by default; unaligned
LDRD/LDMalways fault; without being enabled inSHCSRany fault is a HardFault with FORCED.
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".