RayBench EmbeddedInteractive engineering labs
ARCHITECTURE

Microcontroller Assembly Lab

A practical Cortex-M assembly course from registers and flags to stack frames, exceptions, linking, and C interoperability.

Reviewed 2026-08-226,071 wordsFirmware learners who want to read disassembly, debug faults, and understand what C becomes.

Use assembly as an observation tool

The purpose is not to replace C with handwritten assembly. It is to expose the machine contracts that compilers, debuggers, interrupt handlers, and startup code rely on. Learners first manipulate registers and condition flags, then connect each instruction to encoding, memory traffic, and architectural state.

Make the stack concrete

Push, pop, call, return, local storage, and exception entry all modify a real region of memory. The lab draws addresses, stack growth, saved registers, alignment, and return state. ABI rules are introduced as interoperability requirements, so a function is correct only when its caller-visible obligations are preserved.

Bridge into firmware debugging

Disassembly becomes valuable when source-level assumptions fail. Learners reconstruct parameters, inspect volatile register accesses, recognize optimized loops, decode exception frames, and compare linked addresses with the memory map. Challenges include invalid returns, corrupted stacks, wrong vector entries, and alignment faults.

What you will be able to do

  • Trace register and flag changes instruction by instruction
  • Construct and inspect valid stack frames
  • Call between C and assembly using the platform ABI
  • Diagnose control-flow and memory faults from machine state

Registers, PC, and the fetch-decode-execute loop

The core fetches a Thumb halfword from the address in PC, decodes it, operates on registers (or memory through load/store), writes the result, and advances PC by 2 (or 4 for BL). That loop is the whole machine; everything else - branches, interrupts, faults - is a controlled way of changing where the loop reads next.

ARMv6-M exposes sixteen 32-bit registers: r0-r12 general purpose, SP (r13) the full-descending stack pointer, LR (r14) the link register, PC (r15) the program counter.

Registers have no type. The same 32 bits are an unsigned count, a signed offset, an address, or four packed bytes depending only on which instruction reads them.

When an instruction READS the PC (LDR literal, ADR, ADD with PC), it observes the current instruction's address + 4 - a pipeline artifact the architecture froze into a contract.

Real Cortex-M0 hardware boots by loading SP from flash offset 0x0 and PC from the reset vector at 0x4. This lab shortcuts that: SP starts at STACK_TOP (0x20001000) and execution starts at your _start label.

Thumb instructions are mostly 16-bit; BL is the one 32-bit encoding you will meet here. The listing pane shows every emitted halfword so you can watch PC step 2 or 4.

This emulator charges one cycle per instruction - a documented simplification. Real M0 timing differs (branches 3 cycles taken, loads 2), but relative reasoning still transfers.

Vocabulary

PC (r15)
Address of the executing instruction; reads back as address + 4 due to the architectural pipeline offset.
SP (r13)
Full-descending stack pointer: points at the last pushed word and grows toward lower addresses.
LR (r14)
Link register: BL drops the return address here so the callee can get back.
fetch-decode-execute
The hardware loop: read halfword at PC, decode, perform, advance.
Thumb state
The 16-bit instruction encoding family; the only state Cortex-M cores execute - bit 0 of branch targets stays set to 1 to say so.

Equations

PC advance
PC_next = PC + 2 (16-bit) or PC + 4 (BL) - Sequential execution is nothing more than this addition.
PC read value
PC_read = instruction_address + 4 - What LDR literal, ADR and ADD PC observe - budget for it in address math.

Common mistakes

  • Treating SP as a free register - one careless MOVS into r13's neighborhood and every PUSH after that corrupts something else.
  • Forgetting the +4 PC read offset when hand-computing PC-relative addresses.
  • Assuming registers keep types: storing a pointer, then ADDing it like a small integer without meaning to.

APSR flags and two's complement: N/Z/C/V without folklore

After a flag-setting instruction: N copies bit 31, Z says the result was zero, C is the unsigned carry-out for addition but NOT-borrow for subtraction (C=1 means no borrow), and V flags signed overflow. The subtraction convention exists so the same flags serve signed and unsigned comparisons cleanly.

ADDS sets C when the unsigned sum overflows 32 bits. SUBS and CMP set C = NOT borrow: C=1 exactly when the left operand is unsigned greater-or-equal to the right.

V detects signed overflow: adding two same-sign values and getting the opposite sign, or the equivalent for subtraction. N and V together drive the signed conditions (GE/LT/GT/LE).

CMP is SUBS with the result thrown away; TST is ANDS with the result thrown away. Both exist purely to set flags.

MULS updates N and Z only - C and V pass through unchanged on ARMv6-M (this emulator's test suite pins that behavior).

MOVS and the logic group (ANDS/ORRS/EORS/BICS/MVNS) update N and Z and leave C and V alone - handy when you need to preserve a carry across a move.

Flags persist until the next flag-setting instruction. Loads, stores, and branches never touch them; a stray MOVS between CMP and the branch silently rewrites the verdict.

Vocabulary

APSR
Application Program Status Register holding N, Z, C, V - the four bits every conditional branch reads.
C after SUBS
NOT borrow: C=1 means the subtraction needed no borrow, i.e. unsigned left >= right.
V (overflow)
The signed result could not be represented in 32-bit two's complement.
two's complement
-x = (NOT x) + 1; one adder circuit serves signed and unsigned math, flags disambiguate.

Equations

negation
-x = (~x) + 1 - Why SUBS is implemented as ADDS with the inverted operand and carry-in 1 - the source of the NOT-borrow convention.
signed overflow (add)
V = (~(a ^ b) & (a ^ result)) >> 31 - Same-sign operands, opposite-sign result.

Common mistakes

  • Porting x86 intuition: on ARM, C=1 after SUBS means NO borrow - inverted from x86's CF.
  • Branching on stale flags because an innocent-looking MOVS or ADDS rewrote them between the CMP and the Bcc.
  • Using signed conditions (BGT/BLT) on unsigned quantities like addresses or sizes - use BHI/BLO/BHS/BLS instead.

Memory, load/store architecture, and endianness

ARM is a load/store architecture: arithmetic touches registers only, and memory moves through LDR/STR and friends. On ARMv6-M an unaligned halfword or word access is not slow - it is a HardFault. Words live little-endian: the least significant byte sits at the lowest address.

The RB-M0 map: flash (code, literals, .word/.asciz data) from 0x00000000; 4 KiB of RAM at 0x20000000-0x20000FFF; peripherals at 0x40000000+; NVIC at 0xE000E100+. Writes to flash HardFault.

Word LDR/STR immediate offsets encode 5 bits scaled by 4: reachable offsets are 0, 4, ..., 124 from the base register. Halfword reaches 0-62 by 2; byte 0-31 by 1. Bigger reach needs SP-relative (0-1020) or a register offset.

Register-offset addressing LDR rt, [rn, rm] adds two registers - the natural form for array walks where the index lives in a register.

Alignment on v6-M is strict: LDR/STR need address % 4 == 0, LDRH/STRH need % 2 == 0. LDRB/STRB are always safe. The fault is the diagnostic - watch for it in the Debug chapter.

Little-endian: after storing 0x11223344 at address A, the byte at A is 0x44. LDRB from A proves it in one instruction.

LDRSB/LDRSH (register-offset forms only on v6-M) sign-extend on the way in; SXTB/SXTH/UXTB/UXTH do the same for values already in registers.

Vocabulary

load/store architecture
ALU operands come from registers only; memory is reached exclusively through explicit load/store instructions.
alignment fault
A HardFault raised when a word/halfword access address is not a multiple of its size - v6-M never fixes it up silently.
little-endian
Least significant byte at the lowest address; what you see in a hexdump is byte order, not print order.
effective address
base register + (scaled immediate | register) - the address the bus actually sees.

Equations

effective address
EA = Rn + imm5 * size (size = 4, 2, 1) - Why word offsets jump by 4: the encoding stores the scaled count, not bytes.

Common mistakes

  • Loading a word from an address you computed with byte arithmetic - alignment faults arrive exactly when the input data changes size.
  • Reading a hexdump as if the word were written left-to-right - endianness inverts your eyeballs, not the machine.
  • Trying LDR rt, [rn, #imm] with an offset that is not a multiple of 4 - the encoding cannot represent it and the assembler refuses.

Immediates, literal pools, and how constants really arrive

MOVS carries only an 8-bit immediate, so wide constants come from memory: LDR rd, =constant assembles into a PC-relative word load, and the assembler parks the constant in a literal pool after your code. The constant costs 4 bytes of flash plus one load.

MOVS rd, #imm8 zero-extends 0-255. ADDS/SUBS carry #imm3 in the three-operand form, or #imm8 when destination and source are the same register - the assembler here picks the encoding for you and tells you when neither fits.

LDR rd, =value emits LDR rd, [PC, #offset]: the effective address is Align(PC_read, 4) + offset, offset 0-1020 in steps of 4, forward only. This lab places the pool after the last line of your program and dedupes repeated constants.

The pool is flash, so pooled constants are read-only by construction - exactly how real firmware keeps them.

You can always build constants arithmetically: MOVS + LSLS + ADDS assembles 0x40001000 in four instructions. It costs cycles and registers but no pool entry - a real trade on v6-M.

LDR rd, =label loads the ADDRESS of the label; LDR rd, label (no =) loads the WORD AT the label. Confusing the two is a classic silent bug.

ADR rd, label computes Align(PC,4) + offset without touching memory - the cheap way to get a nearby address, used with the .asciz banner in the demo program.

Vocabulary

immediate
A constant packed inside the instruction encoding itself - free to load, brutally range-limited on Thumb-1.
literal pool
Words of constant data the assembler embeds in flash for PC-relative LDR to fetch.
PC-relative
Addressing measured from Align(PC_read, 4) - position-independent by construction.
ADR
Address computation into a register: label address without a memory access.

Equations

literal address
EA = Align(PC + 4, 4) + imm8 * 4 - Word-aligned PC base plus a scaled forward offset - range 0-1020 bytes.

Common mistakes

  • MOVS r0, #256 - one past the edge; the assembler's error message points you at LDR =.
  • Expecting LDR r0, =label to read the variable - it hands you the address; the value needs a second load.
  • Letting PC walk into a literal pool or .word data - the core decodes your constants as instructions and usually faults a few steps later.

Shifts, rotates, and bit surgery

LSLS/LSRS/ASRS/RORS move bits and push the last evicted bit into C; ANDS/ORRS/EORS/BICS apply masks. Field extraction is a shift-left to discard high bits followed by a shift-right to right-justify. The carry flag turns shifting into counting - LSRS + ADCS is a branchless popcount step.

LSLS fills with zeros from the right and multiplies by 2^n; LSRS fills from the left and divides unsigned; ASRS replicates the sign bit and divides signed (rounding toward minus infinity).

Every shift's carry-out is the last bit shifted past the edge - that is data, not a by-product. LSRS r0, r0, #1 then ADCS harvests one bit per loop iteration.

Immediate shifts encode 1-31 directly; writing #32 for LSRS/ASRS is legal UAL and encodes as imm5=0. Register-count shifts use the low byte of the count register, so shifts of 32+ behave per the architecture: result 0 (or sign-fill), carry defined by the exact amount.

There is no rotate-left: ROR by (32 - n) is the same thing. There is also no RRX on v6-M - carry does not rotate through.

BICS rd, rm clears the mask bits: rd = rd AND NOT rm - the register-sized equivalent of reg &= ~mask.

REV/REV16/REVSH byte-swap words and halfwords - endianness conversion for protocol buffers without four shifts and three ORRs.

Vocabulary

barrel shifter
The ALU-adjacent hardware that makes any shift amount cost the same single cycle.
mask
A register whose set bits select which bits an ANDS/ORRS/EORS/BICS touches.
carry-out
The last bit evicted by a shift - readable via C, the hinge of bit-counting loops.
sign extension
Replicating bit 31 (ASRS) or bit 7/15 (SXTB/SXTH) so signed values survive a width change.

Equations

multiply/divide by powers of two
x << n == x * 2^n ; x >> n == x / 2^n (unsigned) - The zero-cost arithmetic idiom - and why compilers love power-of-two buffer sizes.
field extract
field = (x << (31 - hi)) >> (31 - hi + lo) - Two shifts isolate bits [hi:lo] without a mask constant.

Common mistakes

  • Writing LSRS #0 expecting a no-op - UAL spells shift-by-32 as #32, and 0 is not an accepted immediate here.
  • Using ASRS on unsigned data - the sign replication manufactures 0xFF... prefixes from nowhere.
  • BICS operand order confusion: BICS r0, r1 clears r1's bits FROM r0, not the reverse.

Branches, condition codes, and structured loops

A comparison sets flags; a conditional branch reads them. Loops are backward branches guarded by a flag test; if/else is a forward conditional branch over the taken block. On v6-M your budget is Bcc reaching about +/-256 bytes and B about +/-2 KB - and there is no CBZ/CBNZ (that is ARMv7-M).

The fourteen conditions split by interpretation: EQ/NE read Z; HS(CS)/LO(CC)/HI/LS are the UNSIGNED family reading C and Z; GE/LT/GT/LE are the SIGNED family reading N and V.

Bcc encodes a signed 8-bit halfword offset: targets within -256..+254 bytes of PC_read. B (always) encodes 11 bits: -2048..+2046. BL reaches +/-16 MB. The assembler tells you when a hop is too far - restructure or invert the test.

The canonical counted loop: MOVS rN, #count; body; SUBS rN, rN, #1; BNE body. SUBS sets Z on the last pass for free - no CMP needed.

Since SUBS also sets C and N, the same loop tail supports unsigned (BHS) or signed (BGE) exits - pick the family that matches your counter's meaning.

Branch-to-self (B .) encodes 0xE7FE - the idle loop you will see in every vendor's startup code, and this lab's way to 'stop' without halting.

ARMv6-M has no IT blocks and no CBZ/CBNZ; every conditional path costs a real branch. Straight-line flag arithmetic (like the ADCS popcount) is how you go branchless.

Vocabulary

condition code
The two-letter suffix (EQ, NE, HS, LO, MI, PL, VS, VC, HI, LS, GE, LT, GT, LE) selecting which flags take the branch.
unsigned family
HS/LO/HI/LS - correct for sizes, counts, addresses; reads C (and Z for HI/LS).
signed family
GE/LT/GT/LE - correct for two's complement quantities; reads N versus V.
branch range
How far the encoded offset reaches: Bcc ~256 B, B ~2 KB, BL ~16 MB.

Equations

branch target
target = PC + 4 + SignExtend(imm * 2) - Offsets are halfword counts from PC_read - the assembler does this arithmetic, you debug it.
GT condition
GT = !Z && (N == V) - Signed greater-than: non-zero and the sign/overflow pair agree.

Common mistakes

  • Using BGT on unsigned values - a size of 0x80000000 reads as negative and the signed test lies.
  • Reaching for CBZ/CBNZ from ARMv7-M habit - v6-M does not have them; CMP #0 + BEQ is the spelling here.
  • Clobbering flags between the CMP and its branch with an intervening MOVS or ADDS.

The stack: PUSH/POP, full-descending discipline, AAPCS basics

PUSH pre-decrements SP by 4 per register and stores the list with the lowest-numbered register at the lowest address; POP mirrors it back. The AAPCS contract makes r0-r3 and r12 caller-owned scratch and r4-r11 callee-saved: touch r4-r7 inside a function and you owe a PUSH/POP pair.

Full-descending: SP points at the last pushed word and moves down. This lab starts SP at 0x20001000 (one past the top RAM byte) - the first PUSH writes 0x20000FFC.

PUSH {r4, lr} stores r4 at [SP_new] and LR at [SP_new + 4]: register number, not list order, decides placement. POP {r4, pc} reverses it - and popping into PC is the return.

On v6-M the PUSH list may include LR but not PC; the POP list may include PC but not LR. The pairing PUSH {rlist, lr} / POP {rlist, pc} is the canonical prologue/epilogue.

AAPCS: arguments and results ride r0-r3; a callee may trash r0-r3 and r12 freely but must return r4-r11 (and SP) exactly as received. The Stack Guardian challenge enforces this with sentinel values.

AAPCS also wants SP 8-byte aligned at public call boundaries; exception entry hardware enforces it with the realignment bit you will meet in chapter 10. Pure-assembly leaf code can run word-aligned, but mixed C/asm cannot.

SP-relative load/store (LDR/STR rt, [sp, #imm], imm 0-1020) is how locals work without a frame pointer: SUB SP, #16 opens a frame; ADD SP, #16 closes it.

Vocabulary

full-descending
SP points at the last used word and grows downward - both facts matter when reading a stack dump.
caller-saved (r0-r3, r12)
The callee may destroy them; if the caller cares, the caller saves them.
callee-saved (r4-r11)
The callee must restore them before returning - the PUSH/POP tax for using them.
prologue/epilogue
PUSH {regs, lr} on entry, POP {regs, pc} on exit - the function's bookends.

Equations

SP after PUSH
SP_new = SP_old - 4 * N - N = list size; lowest-numbered register lands at [SP_new].

Common mistakes

  • Asymmetric PUSH/POP lists - SP drifts and the eventual POP {pc} loads a data word into PC.
  • Forgetting to PUSH LR in a function that itself calls BL - the second call overwrites the first return address.
  • Assuming the stored order follows how you typed the list - hardware orders by register number, always.

Subroutines: BL, BX, LR, and the callee-saved contract

BL stores the address of the next instruction in LR (bit 0 set to mark Thumb) and branches; the leaf-function return is BX LR. A function that calls another must first PUSH {lr} - and the POP {pc} that restores it is both return and restore in one instruction.

BL is the 32-bit encoding pair (you can see both halfwords in the listing); it reaches +/-16 MB - effectively anywhere in a microcontroller.

BX rm branches to the address in a register; BLX rm does the same while linking - the function-pointer call. Bit 0 of the target must be 1 (Thumb); the hardware clears it from PC.

Leaf functions (no BL inside) can leave LR alone and return with BX LR at zero stack cost - the cheapest call ABI on any architecture.

Non-leaf functions PUSH {lr} in the prologue; POP {pc} then returns directly. Returning from handlers works the same way because of the EXC_RETURN magic (chapter 10).

Return values ride r0 (r1 for the high half of a 64-bit result); arguments arrive in r0-r3, spilling to the stack past four - AAPCS again.

Recursion falls out for free: every activation PUSHes its own LR and locals; depth is bounded only by the 4 KiB RAM - try it and watch SP dive.

Vocabulary

BL
Branch with link: LR = next instruction address | 1, then jump - the 32-bit Thumb pair.
BX / BLX
Branch (and link) to a register value - returns and function pointers respectively.
leaf function
Calls nothing, so LR stays live in the register and returning is one BX LR.
linkage
The LR-based chain of return addresses - a stack only when functions save it.

Equations

return address
LR = BL_address + 4, bit0 = 1 - BL is 4 bytes long; the +4 lands on the instruction after it, the |1 says Thumb.

Common mistakes

  • Calling a second function without saving LR - the outer return address is gone and the program orbits the inner caller forever.
  • Hand-building a jump target with bit 0 clear - BX to an even address without the Thumb bit faults on real cores.
  • Using a callee-saved register 'just briefly' without the PUSH/POP pair - works until the caller's loop counter mysteriously resets.

Memory-mapped I/O: LEDs, a button, and a UART with plain stores

Peripherals ARE addresses. STR to GPIO_ODR (0x40000000) drives the eight LEDs; LDR from GPIO_IDR (0x40000004) reads the button on bit 0; STR to UART_TXD (0x40001000) transmits the low byte. Every device driver you will ever write is these three idioms with more registers.

GPIO_ODR bits 0-7 are the LEDs, 1 = lit. Reading it back returns the last value written - real output data registers do the same, which is what makes read-modify-write possible.

GPIO_IDR is read-only input: bit 0 follows the button. Writing it faults here (a teaching choice - real hardware usually ignores you, which is worse, because nothing tells you).

The single-bit update idiom: LDR current, EORS/ORRS/BICS the bit, STR back. Without the read step you rewrite all eight LEDs every time.

UART_TXD consumes the low byte of any store: 'H' is 72 is 0x48 - characters are just small integers with good PR.

Peripheral state changes between reads - IDR can differ across two adjacent LDRs. That is not a bug; it is the property that makes polling loops work at all.

Real cores mark peripheral regions Device memory (no caching/reordering); this emulator is naturally sequential, but the habit of one-load-one-decision transfers as-is.

Vocabulary

memory-mapped I/O
Hardware registers reached by ordinary load/store at fixed addresses - the entire Cortex-M I/O model.
ODR / IDR
Output and input data registers - write to drive pins, read to sample them.
read-modify-write
Load register, alter the target bits, store back - the only way to change one bit of a shared register.
polling
Reading a status/input register in a loop until it changes - simple, power-hungry, latency-bounded by loop length.

Equations

LED n test
lit = (ODR >> n) & 1 - One shift and one mask read any single bit.

Common mistakes

  • Blind writes to a shared register - your STR to ODR erases the other seven LEDs because you skipped the LDR.
  • Writing the input register expecting to simulate a press - IDR reflects the pin; only the pin (the button) changes it.
  • Polling with stale data: hoisting the LDR out of the loop turns a live register into a constant.

Exceptions for real: NVIC, hardware stacking, EXC_RETURN

With the IRQ enabled in NVIC_ISER and pended by the event, the core finishes the current instruction, pushes eight words (r0-r3, r12, LR, return address, xPSR - r0 at the lowest address), realigns SP to 8 bytes if needed (recorded in stacked xPSR bit 9), sets LR to the EXC_RETURN value 0xFFFFFFF9, and jumps to the handler. BX LR with that magic value unstacks everything and resumes as if nothing happened.

NVIC registers here: ISER 0xE000E100 (write 1 to enable), ICER 0xE000E180 (write 1 to disable), ISPR 0xE000E200 (write 1 to pend from software), ICPR 0xE000E280 (write 1 to unpend). Bit n is IRQn: bit 0 the button, bit 1 the timer.

The stacked frame, lowest address first: r0, r1, r2, r3, r12, LR, return address, xPSR - 32 bytes. The return address is the instruction the interrupted code runs next (for faults: the faulting instruction itself).

Exactly because r0-r3 and r12 are in the frame, a handler may clobber them freely - hardware stacking IS the caller-save of the AAPCS, performed by silicon. r4-r11 remain the handler's responsibility.

EXC_RETURN 0xFFFFFFF9 means 'return to Thread mode, main stack'. Any PC load of 0xFFFFFFFX via BX LR or POP {pc} triggers unstacking; ordinary code addresses can never collide with it.

Real Cortex-M fetches handler addresses from a vector table at 0x0 (IRQ0's entry at offset 0x40). This lab uses the CMSIS naming convention directly - label a handler Button_IRQHandler or Timer_IRQHandler and the core vectors to it; same idea, one less indirection.

Pending clears automatically when the exception activates - one button edge, one handler run. Equal-priority exceptions never preempt each other (this lab's two IRQs share one priority, so handlers here never nest - true NVIC behavior, not a shortcut).

Vocabulary

NVIC
The Nested Vectored Interrupt Controller: per-IRQ enable and pending bits plus the priority machinery.
hardware stacking
The automatic eight-word push (r0-r3, r12, LR, return address, xPSR) before the first handler instruction.
EXC_RETURN
The 0xFFFFFFFX cookie in LR whose load into PC triggers hardware unstacking instead of a normal branch.
pending vs active
Pending = requested and waiting; active = handler running. The pend bit clears at activation.
xPSR bit 9
Records whether entry inserted a 4-byte realignment so the return can undo it - the 8-byte alignment guarantee.

Equations

frame layout
[SP+0]=r0 ... [SP+12]=r3, [SP+16]=r12, [SP+20]=LR, [SP+24]=return, [SP+28]=xPSR - Where a HardFault handler goes digging - return address at +24.

Common mistakes

  • Enabling the peripheral but not NVIC_ISER (or vice versa) - both gates must open or the IRQ pends forever silently.
  • Saving r0-r3 manually in the handler 'to be safe' - harmless, but it means you have not understood what the frame already did.
  • Clobbering r4-r7 in a handler without a PUSH/POP pair - the interrupted code's loop counter changes value mid-flight, the classic once-a-week bug.
  • Returning by branching to the stacked return address directly instead of through EXC_RETURN - skips unstacking and executes with a 32-byte-shifted stack.

Timers, ISRs, and shared-state hazards - the volatile lesson in assembly

The timer (LOAD 0x40002004, CTRL 0x40002000 with EN=bit0/IE=bit1, VAL 0x40002008) pends IRQ1 each time the down-counter wraps - a period of LOAD+1 cycles. Any main-loop sequence that loads, modifies, and stores shared state can be interrupted between those instructions, and the ISR's update silently vanishes. CPSID i / CPSIE i brackets are the assembly-level critical section.

Writing TIM_LOAD also reloads the running counter; TIM_CTRL bit 0 starts the countdown and bit 1 lets each wrap pend IRQ1. Period = LOAD + 1 cycles (the count visits LOAD, LOAD-1, ..., 0).

The lost-update race, spelled out: main executes LDR r1,[ticks]; the ISR fires here and does its own load-add-store; main resumes with the stale r1, adds, stores - the ISR's increment is overwritten. No instruction misbehaved; the interleaving did.

In assembly every load and store is visible, so there is no compiler-cached-my-variable problem (C's volatile lesson) - but atomicity is still yours to enforce. A single word-sized LDR or STR is atomic here; sequences are not.

CPSID i sets PRIMASK (mask all configurable interrupts); CPSIE i clears it. Pending requests are not lost while masked - they deliver on CPSIE. Keep the bracketed region to a handful of instructions: its length adds directly to worst-case interrupt latency.

WFI parks the core until an interrupt needs service - the idle loop that costs no cycles. A pending-but-masked interrupt still wakes WFI (the standard sleep pattern relies on exactly that subtlety).

The single-writer discipline beats locking: let ONLY the ISR write the tick count and the main loop only read it - one atomic word read needs no critical section at all.

Vocabulary

down-counter
Counts LOAD..0 then reloads; the wrap is the event. Period = LOAD + 1 ticks.
lost update
Two read-modify-write sequences interleave; the slower one overwrites the faster one's store.
critical section
Instructions that must not be interleaved with an ISR - bracketed by CPSID i / CPSIE i.
PRIMASK
The 1-bit mask CPSID/CPSIE toggle; pending IRQs wait behind it rather than disappearing.
single-writer rule
One context owns each shared word's writes; readers need no lock for a single atomic word.

Equations

timer period
T = (LOAD + 1) cycles - LOAD=99 fires every 100 cycles - the Heartbeat challenge's arithmetic.
latency cost
worst_latency += critical_section_cycles - Every masked cycle is a cycle an urgent IRQ waits.

Common mistakes

  • Setting TIM_CTRL's IE bit but never enabling IRQ1 in NVIC_ISER - the wrap pends into a void.
  • A read-modify-write of shared state in the main loop without CPSID/CPSIE - works in testing, loses updates in the field.
  • A critical section spanning a loop - interrupt latency balloons from nanoseconds to milliseconds.
  • Reading TIM_VAL in the ISR expecting 0 - it reloaded and kept counting the moment the IRQ pended.

Reading disassembly, counting cycles, and debugging faults

Read the fault message, then the evidence: the stacked frame holds the faulting instruction's address at [SP+24] (v6-M faults are precise), and the register panel holds the machine's exact state. A HardFault_Handler that copies the stacked PC to a known RAM word is a two-instruction flight recorder - the same technique production firmware uses.

Everything escalates to HardFault on v6-M - unaligned access, flash write, undefined instruction, bad PC. The message differs; the mechanism (vector to HardFault_Handler if you defined one, stack the precise faulting address) does not.

The stacked return address at [SP+24] points AT the faulting instruction for precise faults - cross-reference it against the listing pane's addresses and you have the culprit line.

A minimal fault recorder: HardFault_Handler: LDR r0, =LOG_ADDR; LDR r1, [sp, #24]; STR r1, [r0]; B . - four instructions, no calls, nothing that can double-fault.

This lab's cycle counter is exact for its 1-instruction-1-cycle model. Real Cortex-M0: taken branches 3 cycles, loads/stores 2, MULS 1 or 32 depending on the implementation, plus flash wait states - treat emulator cycle counts as instruction counts and lower bounds.

Reading a listing: LDR rd, [pc, #n] lines are your literal pool loads; 0xE7FE is branch-to-self; a D-prefixed halfword after F000-F7FF is the second half of a BL, never an instruction of its own.

PC walking into data is the signature 'undefined instruction' fault: your .word constants decode as garbage. The fix is structural - data after the final branch, or an explicit B over it.

Vocabulary

precise fault
The stacked address IS the faulting instruction - v6-M gives you this; some bigger cores make you work for it.
fault recorder
A handler that copies the stacked frame to reserved RAM - post-mortem state without a debugger attached.
flight data
The eight stacked words: enough to reconstruct what the code was doing at impact.
wait state
Extra cycles real flash charges per fetch at speed - one reason silicon timing beats any simulator.
lockup
A fault inside the fault handler on real silicon - why recorders stay minimal and callless.

Equations

faulting address
fault_pc = Mem[SP + 24] - The one offset worth memorizing for v6-M post-mortems.

Common mistakes

  • A fault handler fancy enough to fault - calls, pools out of range, unaligned frame peeks; keep recorders to straight-line loads and stores.
  • Trusting emulator cycle counts as silicon timing - they are instruction counts; the TRM and a scope are the truth.
  • Reading [SP+24] after pushing more registers in the handler - your own PUSH moved the frame; index from the entry SP.
  • Ignoring the first fault because 'it recovered' - the corrupted state that caused it is still there, waiting.

More in Architecture

  • Cortex-MArm Cortex-M programmers model, Thumb-2 ISA, memory-mapped peripherals, and NVIC interrupt handling. Interactive register and stack simulator.
  • BootLab 0 → 100Design a reliable bootloader and firmware-update system from reset handoff and flash geometry through image authentication, A/B slots, atomic metadata, trial boot, rollback, recovery, and exhaustive power-cut injection.
  • NVICNVIC for Cortex-M: priority levels, tail-chaining, late-arrival, and vector table. Interactive lab for interrupt latency and preemption.
  • How to Read Any CPUThe six questions that pin down any processor's contract with software: registers, PC and stack, status flags, load/store or memory operands, exception entry, and the memory model. Asked once in the abstract, then answered by every architecture in this section.
  • 8051 / MCS-518051/MCS-51 simulator: 4 register banks, SFR map, bit-addressable RAM, and MOV/CJNE/DJNZ instructions. Observe accumulator and PSW flags.
  • Cortex-AArm Cortex-A model: MMU page tables, exception levels EL0-EL3, and GIC. Interactive simulator for virtual memory translation.
  • AVRAVR architecture: Harvard 8-bit RISC, 32 general-purpose registers, SRAM, and EEPROM. Interactive register and memory access lab.
  • Assembly Across ISAsAssembly for Arm Thumb, RISC-V, AVR, and 8051: mnemonics, addressing modes, and stack frames. Interactive assembler/disassembler.
  • Cortex-RArm Cortex-R real-time core: MPU regions, tightly-coupled memory, and dual-core lockstep. Interactive MPU configuration lab.
  • Architecture MapInteractive architecture map comparing Arm Cortex-M/A/R, RISC-V, AVR, and 8051 ISA, memory maps, and interrupt models.
  • RISC-VRISC-V ISA: RV32I/RV64I base integer set, M/S/U privilege levels, and CSR registers. Interactive instruction execution simulator.
  • ArchitectureArchitecture from reset to reliable products: bootloaders and firmware recovery, Arm Cortex-M/A/R, NVIC, RISC-V, AVR, 8051, and a live Cortex-M0 lab.

References and further reading