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

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

Section 7б taught you to read what the compiler has built. Here we write it ourselves — where there is no way around assembly or where it gives a noticeable gain: start-up code, atomic operations, DSP instructions, task switching. Each of these places has a rule that the compiler follows by itself and a human easily breaks: the Thumb bit in an address, stack alignment, saving r4–r11. Here each rule is broken on purpose, so that you can see what exactly breaks.

The test bench is _verify/asm7v: start-up code and handlers in assembly (start7v.S), functions for the experiments (asm7v.S), a C program that calls them (run7v.c). Everything was built with arm-none-eabi-gcc for Cortex-M4 and run in QEMU (machine netduinoplus2) with the switches -icount and -singlestep: time is counted by the number of instructions, and an interrupt can come between any two of them, as on silicon. The rules were checked against the Cortex-M4 core manual from the pack. The check is _verify/asm7v_check.py.

Terms used in the documentation

Term Meaning
assembler directive an instruction to the assembler, not a command to the processor
Thumb state, T bit the Thumb state, the T bit in EPSR
INVSTATE the "invalid state" fault: a branch without the Thumb bit
stack alignment (8-byte) aligning the stack to 8 bytes
veneer, long branch stub a linker insert for a long branch
exclusive access, LDREX/STREX a read that sets a tag, and a write that succeeds only if the tag was not cleared
SIMD, dual 16-bit two 16-bit operations in one 32-bit instruction
saturation the result stops at the limit instead of overflowing
context switch, PendSV switching tasks, the pendable system call

7в.1 The .S file: directives and the Thumb bit

An assembly file is named with a capital .S: it goes through the preprocessor, and #include, #define, #ifdef work. A file with a lower-case .s goes to the assembler as is. The skeleton of one function:

    .syntax unified                 @ единый синтаксис ARM/Thumb — как в документации
    .cpu    cortex-m4
    .thumb

    .section .text.good_func, "ax", %progbits   @ своя секция: --gc-sections выбросит, если не нужна
    .global good_func               @ видна из других файлов
    .type   good_func, %function    @ это функция Thumb: в её адресе бит 0 = 1
good_func:
    movs    r0, #42
    bx      lr
    .size   good_func, . - good_func            @ размер — для отладчика и nm -S
Directive What it does If forgotten
.syntax unified one instruction syntax for ARM and Thumb, as in the manual the old syntax: adds r0, r0, #1 — "not supported in Thumb16 mode", ldrlo — "Thumb does not support conditional execution"
.thumb what follows are Thumb instructions the assembler expects ARM instructions: "attempt to use an ARM instruction on a Thumb-only processor" on every line
.section …, "ax" where to put the code: a — occupies memory, x — executable everything in one .text, the linker's garbage collection is powerless
.global the name is visible to the linker "undefined reference" when called from C
.type …, %function the label is a Thumb function an address without bit 0 — see below
.size the size of the function the debugger and nm -S do not know the boundaries
1: … 1b / 1f a local label: the nearest 1 backward or forward —
ldr r0, =_sdata load a 32-bit constant: the assembler itself puts it in the pool —

The Thumb bit. Cortex-M has only Thumb instructions, and the sign of this is a one in bit 0 of every address branched to by BX, BLX, POP {pc} and an exception vector. The assembler sets this bit only on labels marked as a function. The experiment — two identical functions, one with .type forgotten:

good_func (.type present) bad_func (.type forgotten)
symbol in the object file FUNC, value 1 NOTYPE, value 0
the address taken in C 0x080000D7 — odd 0x080000DC — even
a direct call from C bl — works the same bl — works
a call through a pointer returned 42 UsageFault, CFSR = 0x00020000 (INVSTATE), PC in the frame 0x080000DC

A direct call forgives the mistake: bl does not change the processor state, and the linker places it as usual. Everything that takes an address breaks: a function pointer, a callback table, the vector table. That is why the error shows up not where it was made but when the function is first called through a pointer. In the bench's vector table .word Reset_Handler gave 0x08000041, .word PendSV_Handler — 0x08000095: bit 0 is set because both labels are declared as functions.

A limitation of the model. If the even address is also not a multiple of 4 (bad_func2, 0x080001BA), QEMU 7.2 reports not INVSTATE but CFSR = 0x01000000 (UNALIGNED). According to the manual, a branch with the T bit cleared is a state fault; on silicon expect INVSTATE for any even address.


7в.2 Start-up code in assembly

Before main someone has to lay out the memory: copy the initial values of .data from Flash memory to RAM and zero .bss. In section 4в this is done in C; in assembly it is seventeen instructions:

Reset_Handler:
    ldr     r0, =_sdata             @ куда: .data в ОЗУ
    ldr     r1, =_edata
    ldr     r2, =_sidata            @ откуда: образ .data во флеш-памяти
1:  cmp     r0, r1
    ittt    lo                      @ три условные команды; переход — последней в блоке
    ldrlo   r3, [r2], #4
    strlo   r3, [r0], #4
    blo     1b
    ldr     r0, =_sbss
    ldr     r1, =_ebss
    movs    r2, #0
2:  cmp     r0, r1
    itt     lo
    strlo   r2, [r0], #4
    blo     2b
    bl      main
3:  b       3b

The code is 42 bytes, plus five addresses in the pool (20 bytes). The boundaries _sdata, _edata, _sidata, _sbss, _ebss are set by the linker script (section 4в); the assembler takes them as ordinary names.

Checking with a warm restart. In QEMU RAM is zero at start-up anyway, and zeroing .bss would prove nothing. So on the first start the program corrupts the variables and itself jumps to the reset vector — as after a watchdog reset, without switching the power off:

Variable Section First start Written before the jump After the restart
init_word = 0x12345678 .data 0x12345678 0xDEADBEEF 0x12345678
zero_word .bss 0 0xCAFEF00D 0
boot_no .noinit 1 — 2

.data and .bss are restored. The boot_no counter lies in the .noinit section, which the start-up code does not touch, and it survived the restart. After power-up there is garbage there, so a check word is kept next to it: in the experiment this is boot_magic, and if it is not equal to 0xB007B007, the counter is zeroed and the word is written. In the linker script the section is described separately from .bss: .noinit (NOLOAD) : { *(.noinit) } > RAM. This is how the cause of the last reset and a crash counter are kept.


7в.3 Calling C from assembly: the stack is a multiple of 8

The calling convention requires that on entry to any function sp is a multiple of 8. The compiler counts on this: in a function with a variable number of arguments it rounds the address of the next 64-bit number up to a multiple of 8 (add r3, r3, #7 and bic r3, r3, #7 in vsum64). And it rounds the real address in memory, not the offset.

An assembly function that pushed an odd number of words onto the stack before bl breaks this silently. The experiment: the C function vsum64(3, 0x1_00000002, 0x3_00000004, 0x5_00000006) is called through two assembly wrappers: one pushes two words onto the stack, the other three.

Stack a multiple of 8 Stack a multiple of 4
sp on entry to the called function 0x2001FFC8 0x2001FFC4
the first number 0x1_00000002 — correct 0x2_00000005 — half of it from garbage in r1
the sum of three 0x9_0000000C — correct 0xC_00000009
error message — none

All three numbers were read shifted by a word: the low and high halves got mixed up. There is neither a fault nor a warning.

Where va_arg takes 64-bit numbers with the stack a multiple of 8 and a multiple of 4

The same will happen with printf("%lld") and printf("%f") (a double is also 8 bytes), but not in an interrupt: on exception entry the processor aligns the stack itself (the STKALIGN bit in SCB->CCR) — except on F1, where the bit is off after reset and you enable it yourself (section 7а.2). The rule for your own code: push an even number of registers — push {r4, lr}, not push {lr}.


7в.4 A long call: the linker veneer

BL reaches ±16 MB. From Flash memory (0x08000000) to RAM (0x20000000) it is 384 MB, and a function that was moved to RAM (for example, to erase Flash memory without stalling) cannot be called with a direct bl.

The linker does this itself. The function ram_add lies in the .ramfunc section inside .data, it is copied by the start-up code together with the variables, and at the place of the call a veneer appeared:

__ram_add_veneer:
    ldr.w   pc, [pc]                @ загрузить адрес прямо в pc
    .word   0x20000001              @ ram_add в ОЗУ, бит Thumb на месте

Eight bytes and one extra read on every call. ram_add(40, 2) returned 42. If veneers are undesirable (a hot loop, strict cycle counting), the function is declared with __attribute__((long_call)) or built with -mlong-calls: then the compiler itself puts the address into a register and branches through it — in the experiment ldr r3, [pc, #4] and bx r3.


7в.5 LDREX/STREX: an increment that is not lost

counter++ is three instructions: read, add, write. If an interrupt between the read and the write also increments counter, its increment is lost: the main code writes its own value, computed from the old one.

The experiment: main increments the counter 20 000 times, the SysTick interrupt every 97 cycles also adds one. Losses = 20 000 + number of interrupts − the result.

Method Interrupts Lost STREX retries
counter++ (ldr, add, str) 177 107 —
LDREX/STREX 213 0 71
cpsid i … cpsie i around ++ 248 0 —

The number of interrupts differs because loops of different length run for different times.

The increment loop: an interrupt between the read and the write loses the increment; after LDREX it forces a retry

The losses are easy to calculate. The compiler's loop is five instructions: ldr, subs, add, str, bne. An interrupt that comes after ldr, subs or add takes away the increment: three places out of five. 107 out of 177 is 60 %.

The loop with exclusive access:

inc_ldrex:                          @ uint32_t inc_ldrex(volatile uint32_t *p, uint32_t n)
    movs    r12, #0                 @ сколько раз STREX не прошёл
1:  ldrex   r2, [r0]                @ прочитать и поставить метку
    adds    r2, r2, #1
    strex   r3, r2, [r0]            @ записать, только если метка цела; r3 = 0 — записано
    cbz     r3, 2f
    adds    r12, r12, #1
    b       1b                      @ метку сняли — прочитать заново
2:  subs    r1, r1, #1
    bne     1b
    mov     r0, r12
    bx      lr

According to the manual, the core clears the exclusive access tag on CLREX, on any STREX and on any exception. An interrupt between ldrex and strex — two places out of the six instructions of the loop — makes strex return 1, and the loop reads again: 71 retries for 213 interrupts, exactly a third.

What to choose:

Why the experiment ran with -singlestep. Without this switch QEMU checks for interrupts only between blocks of instructions, and the whole ldr–str loop passes without interrupts: there are no losses, and the race is not visible. On silicon an interrupt comes between any instructions.


7в.6 DSP instructions: two operations for one

Cortex-M4 has instructions that treat a 32-bit register as a pair of 16-bit numbers. The dot product of int16_t samples is the basis of filters and correlation:

dot_smlad:                          @ n кратно 4 и больше нуля, массивы выровнены на 4
    push    {r4-r7}
    movs    r3, #0
1:  ldmia   r0!, {r4, r5}           @ четыре отсчёта a
    ldmia   r1!, {r6, r7}           @ четыре отсчёта b
    smlad   r3, r4, r6, r3          @ r3 += a0*b0 + a1*b1
    smlad   r3, r5, r7, r3          @ r3 += a2*b2 + a3*b3
    subs    r2, r2, #4
    bne     1b
    mov     r0, r3                  @ результат
    pop     {r4-r7}
    bx      lr
64 samples Loop instructions per sample Loop instructions in total Result
C, -O2 5 (ldrh, ldrh, cmp, smlabb, bne) 320 −247 190 400
smlad 1.5 (six instructions per four samples) 96 −247 190 400

The compiler used smlabb — multiply-accumulate of one pair — but did not produce the paired smlad: for that it needs to know that the arrays are aligned and the length is a multiple of 4. In C the same instructions are provided by the CMSIS functions __SMLAD, __QADD16, __SSAT.

Saturation. Two pairs of 16-bit numbers: (30 000 + 10 000) and (−30 000 + −10 000):

Instruction Upper half Lower half Register
sadd16 — ordinary addition −25 536 25 536 0x9C4063C0
qadd16 — with saturation 32 767 −32 768 0x7FFF8000

Ordinary addition overflows and changes sign — in audio this is a loud click. With saturation the signal stops at the limit — you hear only a clipped peak. According to the manual, the 16- and 8-bit QADD/QSUB do not set the Q flag: you cannot tell from the flag whether saturation occurred.


7в.7 Task switching with your own hands

This is what assembly in an operating system kernel is needed for: saving the registers of one task and fetching the registers of another can be done only with instructions that C does not have. Section 23 shows how this is done in FreeRTOS; here is the minimum, two tasks:

PendSV_Handler:
    mrs     r0, psp                 @ стек уходящей задачи: r0–r3, r12, lr, pc, xPSR уже в нём
    stmdb   r0!, {r4-r11}           @ дописать то, что процессор не сохранил
    ldr     r1, =task_sp
    ldr     r2, =task_cur
    ldr     r3, [r2]
    str     r0, [r1, r3, lsl #2]    @ task_sp[cur] = вершина её стека
    eor     r3, r3, #1              @ следующая из двух
    str     r3, [r2]
    ldr     r0, [r1, r3, lsl #2]
    ldmia   r0!, {r4-r11}
    msr     psp, r0
    bx      lr                      @ EXC_RETURN 0xFFFFFFFD: остальное процессор достанет сам

SysTick pends PendSV every 20 001 cycles. Both tasks are the same assembly loop: it puts its own tag into r4–r11, spins 3000 iterations and checks that the tag is intact. Then the same experiment with PendSV without stmdb and ldmia:

PendSV Code Rounds A / B Corrupted registers A / B
saves r4–r11 38 bytes 3955 / 3935 0 / 0
does not save 30 bytes 3955 / 3936 1600 / 1592

Over 400 switches each task returned about 200 times, and each time found its neighbour's tag in r4–r11: 200 × 8 = 1600. The round count, which the tasks keep in r0–r3, is the same in both experiments: these registers, like r12, lr, pc and xPSR, are saved by the processor on exception entry.

How to start the first task. The frame of the second task is assembled by hand as if it had already been interrupted: eight words for what the processor will pop (in r0 — the argument, in pc — the loop address, in xPSR — a one in the T bit, 0x01000000), and below them eight words for r4–r11. The first task is started differently: msr psp with its stack, CONTROL = 2 (thread mode runs on PSP), isb and a branch into the loop. Without the T bit in xPSR the return from PendSV into the new task ends in an INVSTATE fault — the same Thumb bit rule as in 7в.1.

PendSV — at the lowest priority. NVIC_SetPriority(PendSV_IRQn, 0xFF): the function shifts the number into the significant bits, and the register ends up holding 0xF0. Then the switch happens only when all other handlers have finished, and r4–r11 hold the task's registers, not those of an interrupted handler. In the bench the condition is met by itself: there are no other interrupts, all exceptions have priority 0, and PendSV waits for SysTick to end. A separate experiment (_verify/fixw2a3/pend7v) adds an interrupt with priority 0x80 that puts a tag into r4–r11 and runs longer than the tick period. PendSV with priority 0 preempts its handler: all 99 of its runs found foreign values in r4–r11 — 792 registers, and as many were corrupted in each task. With 0xF0 — not a single one.

A real kernel adds to this masking through BASEPRI while choosing the task (section 28) and, if the tasks compute on the FPU, the registers s16–s31 (section 7а).


7в.8 Pitfalls

A function from .S crashes when called through a pointer, but works directly. .type name, %function is forgotten: the address is even, CFSR = 0x00020000 (INVSTATE).

printf("%lld") or "%f" prints garbage after a call from assembly. The stack is not a multiple of 8: an odd number of words was pushed before bl. There is no error — only wrong numbers.

A variable without an initial value is not zero. The start-up code does not zero .bss. In the emulator RAM is zero at start-up, and the error is not visible; on the board after a reset there are leftovers of earlier work.

A counter sometimes falls short. ++ on a variable shared with an interrupt is three instructions. Use LDREX/STREX, atomic_fetch_add or a short cpsid i.

The race does not reproduce in the emulator. QEMU without -singlestep checks for interrupts between blocks of instructions.

Tasks "see" each other's variables. PendSV does not save r4–r11, and the compiler keeps local variables in them.

Registers get corrupted in interrupt handlers. PendSV has a higher priority than other interrupts and preempts them: it is given the lowest priority.

A task crashes with INVSTATE on the first switch to it. In the frame assembled by hand xPSR = 0 instead of 0x01000000.

ldmia from an unaligned array is a UsageFault. For smlad through ldm an int16_t array must be aligned to 4 (__attribute__((aligned(4)))).

A call to a function in RAM is suddenly slower. Every call goes through a linker veneer — an extra read from Flash memory.


7в.9 Summary

Full sources

start7v.S — the start-up code entirely in assembly: the vector table, Reset_Handler, entry into the fault handler and PendSV switching two tasks.

/* Раздел 7в: стартовый код целиком на ассемблере — таблица векторов, Reset_Handler,
 * вход в обработчик отказа и PendSV с переключением двух задач.
 * С -DNO_R4R11 PendSV переключает только указатель стека, без r4–r11 (ошибка для опыта). */
    .syntax unified
    .cpu    cortex-m4
    .thumb

/* ── таблица векторов: адреса обработчиков с единицей в бите 0 ── */
    .section .isr_vector, "a", %progbits
    .global vectors
vectors:
    .word   _estack                 @ 0: начальный MSP
    .word   Reset_Handler           @ 1
    .word   Default_Handler         @ 2: NMI
    .word   HardFault_Handler       @ 3
    .word   Default_Handler         @ 4: MemManage
    .word   Default_Handler         @ 5: BusFault
    .word   UsageFault_Handler      @ 6
    .word   0, 0, 0, 0              @ 7–10: зарезервировано
    .word   Default_Handler         @ 11: SVCall
    .word   Default_Handler         @ 12: DebugMon
    .word   0                       @ 13
    .word   PendSV_Handler          @ 14
    .word   SysTick_Handler         @ 15

/* ── сброс: скопировать .data, обнулить .bss, вызвать main ── */
    .section .text.Reset_Handler, "ax", %progbits
    .global Reset_Handler
    .type   Reset_Handler, %function
Reset_Handler:
    ldr     r0, =_sdata             @ куда: .data в ОЗУ
    ldr     r1, =_edata
    ldr     r2, =_sidata            @ откуда: образ .data во флеш-памяти
1:  cmp     r0, r1
    ittt    lo
    ldrlo   r3, [r2], #4
    strlo   r3, [r0], #4
    blo     1b
    ldr     r0, =_sbss
    ldr     r1, =_ebss
    movs    r2, #0
2:  cmp     r0, r1
    itt     lo
    strlo   r2, [r0], #4
    blo     2b
    bl      main
3:  b       3b
    .size   Reset_Handler, . - Reset_Handler

    .section .text.Default_Handler, "ax", %progbits
    .global Default_Handler
    .type   Default_Handler, %function
Default_Handler:
    b       .
    .size   Default_Handler, . - Default_Handler

/* ── отказ: какой стек был у прерванного кода — по биту 2 EXC_RETURN в lr ── */
    .section .text.fault_entry, "ax", %progbits
    .global HardFault_Handler, UsageFault_Handler
    .type   HardFault_Handler, %function
    .type   UsageFault_Handler, %function
HardFault_Handler:
UsageFault_Handler:
    tst     lr, #4
    ite     eq
    mrseq   r0, msp
    mrsne   r0, psp
    b       fault_c                 @ void fault_c(uint32_t *frame); lr всё ещё EXC_RETURN
    .size   HardFault_Handler, . - HardFault_Handler

/* ── PendSV: сохранить уходящую задачу, достать следующую ── */
    .section .text.PendSV_Handler, "ax", %progbits
    .global PendSV_Handler
    .type   PendSV_Handler, %function
PendSV_Handler:
    mrs     r0, psp                 @ стек уходящей задачи: r0–r3, r12, lr, pc, xPSR уже в нём
#ifndef NO_R4R11
    stmdb   r0!, {r4-r11}           @ дописать то, что процессор не сохранил
#endif
    ldr     r1, =task_sp
    ldr     r2, =task_cur
    ldr     r3, [r2]
    str     r0, [r1, r3, lsl #2]    @ task_sp[cur] = вершина её стека
    eor     r3, r3, #1              @ следующая из двух
    str     r3, [r2]
    ldr     r0, [r1, r3, lsl #2]
#ifndef NO_R4R11
    ldmia   r0!, {r4-r11}
#endif
    msr     psp, r0
    bx      lr                      @ EXC_RETURN 0xFFFFFFFD: остальное процессор достанет сам
    .size   PendSV_Handler, . - PendSV_Handler

/* ── первая задача: перейти на PSP и прыгнуть в её цикл ── */
    .section .text.start_first, "ax", %progbits
    .global start_first
    .type   start_first, %function
start_first:                        @ void start_first(uint32_t seed, uint32_t *stat, uint32_t *sp)
    msr     psp, r2
    movs    r3, #2                  @ CONTROL.SPSEL = 1: поток работает на PSP
    msr     control, r3
    isb
    b       task_loop
    .size   start_first, . - start_first

asm7v.S — assembly functions for the section's experiments: .type, calling C with the stack, LDREX/STREX, SMLAD and saturating additions, the task loop.

/* Раздел 7в: функции на ассемблере для опытов в QEMU. */
    .syntax unified
    .cpu    cortex-m4
    .thumb

/* ── 1. одна и та же функция с .type и без ── */
    .section .text.good_func, "ax", %progbits
    .global good_func
    .type   good_func, %function    @ метка — функция Thumb: в её адресе бит 0 = 1
good_func:
    movs    r0, #42
    bx      lr
    .size   good_func, . - good_func

    .section .text.bad_func, "ax", %progbits
    .p2align 2
    .global bad_func                @ .type забыт: для компоновщика это просто адрес
bad_func:
    movs    r0, #42
    bx      lr

/* ── 3. вызов C-функции со стеком, выровненным на 8 и на 4 ── */
    .section .text.call_aligned, "ax", %progbits
    .global call_aligned, call_misaligned
    .type   call_aligned, %function
    .type   call_misaligned, %function
call_aligned:                       @ int64_t call_aligned(int64_t (*fn)(void))
    push    {r4, lr}                @ два слова: кратность 8 сохранена
    blx     r0
    pop     {r4, pc}
    .size   call_aligned, . - call_aligned

call_misaligned:                    @ то же, но sp сдвинут на одно слово
    push    {r4, lr}
    sub     sp, sp, #4
    blx     r0
    add     sp, sp, #4
    pop     {r4, pc}
    .size   call_misaligned, . - call_misaligned

/* ── 5. атомарный инкремент: LDREX/STREX, возвращает число повторов ── */
    .section .text.inc_ldrex, "ax", %progbits
    .global inc_ldrex
    .type   inc_ldrex, %function
inc_ldrex:                          @ uint32_t inc_ldrex(volatile uint32_t *p, uint32_t n)
    movs    r12, #0                 @ сколько раз STREX не прошёл
1:  ldrex   r2, [r0]
    adds    r2, r2, #1
    strex   r3, r2, [r0]            @ r3 = 0 — записано, 1 — метка снята, повторить
    cbz     r3, 2f
    adds    r12, r12, #1
    b       1b
2:  subs    r1, r1, #1
    bne     1b
    mov     r0, r12
    bx      lr
    .size   inc_ldrex, . - inc_ldrex

/* ── 6. скалярное произведение int16: две пары за команду SMLAD ── */
    .section .text.dot_smlad, "ax", %progbits
    .global dot_smlad
    .type   dot_smlad, %function
dot_smlad:                          @ int32_t dot_smlad(const int16_t *a, const int16_t *b, uint32_t n), n кратно 4
    push    {r4-r7}
    movs    r3, #0
1:  ldmia   r0!, {r4, r5}           @ четыре отсчёта a
    ldmia   r1!, {r6, r7}           @ четыре отсчёта b
    smlad   r3, r4, r6, r3          @ r3 += a0*b0 + a1*b1
    smlad   r3, r5, r7, r3          @ r3 += a2*b2 + a3*b3
    subs    r2, r2, #4
    bne     1b
    mov     r0, r3
    pop     {r4-r7}
    bx      lr
    .size   dot_smlad, . - dot_smlad

/* ── 6. два 16-битных сложения одной командой: с насыщением и без ── */
    .section .text.lanes, "ax", %progbits
    .global add16_wrap, add16_sat
    .type   add16_wrap, %function
    .type   add16_sat, %function
add16_wrap:                         @ uint32_t add16_wrap(uint32_t a, uint32_t b)
    sadd16  r0, r0, r1
    bx      lr
    .size   add16_wrap, . - add16_wrap
add16_sat:
    qadd16  r0, r0, r1
    bx      lr
    .size   add16_sat, . - add16_sat

/* ── 7. цикл задачи: кладёт метку в r4–r11, крутится, проверяет, что метка цела ── */
    .section .text.task_loop, "ax", %progbits
    .global task_loop
    .type   task_loop, %function
task_loop:                          @ void task_loop(uint32_t seed, uint32_t stat[2]) — не возвращается
    mov     r4, r0                  @ пользуется только r0–r3, r12 (их сохраняет процессор)
    add     r5, r0, #1              @ и r4–r11 (их обязан сохранить PendSV)
    add     r6, r0, #2
    add     r7, r0, #3
    add     r8, r0, #4
    add     r9, r0, #5
    add     r10, r0, #6
    add     r11, r0, #7
    movw    r2, #3000
1:  subs    r2, r2, #1              @ пауза: здесь задачу и прервут
    bne     1b
    movs    r3, #0                  @ сколько регистров испорчено
    cmp     r4, r0
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #1
    cmp     r5, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #2
    cmp     r6, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #3
    cmp     r7, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #4
    cmp     r8, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #5
    cmp     r9, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #6
    cmp     r10, r2
    it      ne
    addne   r3, r3, #1
    add     r2, r0, #7
    cmp     r11, r2
    it      ne
    addne   r3, r3, #1
    ldr     r2, [r1, #4]            @ stat[1] += испорченные
    add     r2, r2, r3
    str     r2, [r1, #4]
    ldr     r2, [r1]                @ stat[0] += 1 круг
    adds    r2, r2, #1
    str     r2, [r1]
    b       task_loop
    .size   task_loop, . - task_loop

/* ── 2. та же забытая .type, но по адресу, не кратному 4 (для сравнения поведения модели) ── */
    .section .text.bad_func2, "ax", %progbits
    .p2align 2
    nop
    .global bad_func2
bad_func2:
    movs    r0, #42
    bx      lr

Next: section 8 "Input/output ports (GPIO)".

Open in the interactive handbook