What the instruments' firmware, and every compiled program, is really made of. You start knowing nothing about assembly. By Sunday evening you can read compiler output for an ARM Cortex-M, write functions that follow the calling convention, and drive GPIO pins through their registers.
Every box runs on a Cortex-M simulator built into this page, so nothing needs installing and nothing can brick a board. After each run you see the registers, the flags, and eight LEDs on GPIO port A. The simulator stops any loop that runs past 200,000 instructions, so a mistake never freezes the page.
There are 8 lessons and 19 exercises, each with a Check button that runs your code against several inputs, a Hint and the Solution. Progress is kept in this browser. The Python course first is a good idea but not required.
Reading compiler output, then two capstones: a limit check over a sweep and a bias search.
70 min
How this simulator differs from a real chip
It runs Thumb instructions in unified syntax, the form GCC and the ARM docs use. It is lenient about instruction encodings: any constant works with mov, and both add and adds work everywhere.
Conditional execution of non-branch instructions (IT blocks) is left out; use a branch.
bl print_int, print_hex, print_char and print_str are helpers built into the page that stand in for a debug console.
RAM is 64 KB at 0x20000000, the stack starts at the top of it, and GPIOA is at 0x40020000. Clocks and pin modes need no setup.
What the CPU actually does, registers, MOV, ADD, SUB, and numbers in hex.
Every program you have ever run, including the Python on the bench PC and the firmware in the instruments on the rack, ends up as a list of very small instructions that a CPU does one at a time. Assembly is that list written as text, one instruction per line.
This course teaches the ARM Cortex-M family: the core inside STM32, nRF, RP2040-class and most other modern microcontrollers. The page runs a simulator of one, so every box is live. Press Run and the result shows the CPU's registers afterwards. Values that changed are highlighted.
A Cortex-M has sixteen 32-bit registers, tiny storage slots inside the CPU. r0 to r12 are for your data. sp (stack pointer), lr (link register) and pc (program counter) have special jobs that later lessons explain. Instructions mostly take registers in and put a register out.
Try it
Ctrl+Enter runs
Read each line as operation, destination, sources. add r2, r0, r1 means r2 = r0 + r1. The destination comes first. A number needs a # in front; without it, Python's habit of just writing 5 gives an error here. Comments start with @ (or ; or //).
bl print_int calls a small routine built into this page that prints r0 as a signed number. Real boards print through a debug probe or a UART; you will meet bl properly in lesson 5.
Hexadecimal. Registers are 32 bits, and hex (base 16, written 0x…) shows bits far better than decimal: each hex digit is exactly four bits. 0xFF is 255, eight ones. 0x20000000 is where RAM starts on this chip. The register display shows each value in hex with the signed decimal in brackets.
Try it
Ctrl+Enter runs
Why ldr r3, =1000000 and not mov? An instruction is only 16 or 32 bits long, so a mov can only carry a small or simply patterned number inside it. ldr rX, =value tells the assembler to store the constant nearby and load it. When in doubt, use ldr = for constants. This simulator accepts any value with either, but writing ldr = for big numbers is the habit that works on a real assembler.
.equ NAME, value gives a constant a name, like an ALL_CAPS constant in Python.
Exercise 1.1
Gain from two powers
To do
Power levels in tenths of a dB keep everything in whole numbers. r0 holds Pout (dBm×10) and r1 holds Pin (dBm×10). Put the gain, Pout − Pin, in r2. The checker tries several values, so do not hard-code the answer.
One instruction: sub r2, r0, r1.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r0 = Pout (dBm x10), r1 = Pin (dBm x10)
sub r2, r0, r1
Exercise 1.2
Multiply by 10 without MUL
To do
Small chips sometimes lack a fast multiplier, and compilers love tricks like this one. Put r0 × 10 into r1 using only add (and lsl if you peek ahead: shifting left by n multiplies by 2ⁿ). Hint: 10x = 8x + 2x.
add r1, r0, r0 gives 2x. Add that to itself to get 4x, again for 8x, then add the 2x you saved. Or with shifts: lsl r2, r0, #3 (8x) and lsl r3, r0, #1 (2x), then add them.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r0 holds x. Put 10 * x in r1 without using mul.
lsl r2, r0, #3 @ 8x
lsl r3, r0, #1 @ 2x
add r1, r2, r3 @ 10x
Quick check
After mov r0, #3 then add r0, r0, r0 then add r0, r0, r0, what is r0?
3 doubled is 6, doubled again is 12. Each add uses the r0 left by the line before.
Day 1 · Saturday · Lesson 2 · about 60 min
Flags, branches and loops
CMP and the NZCV flags, labels, B and conditional branches, if/else and loops.
There is no if in assembly. Instead, a compare sets four status flags, and a branch jumps to a label or not depending on them.
N negative: the result's top bit is set
Z zero: the result was zero
C carry: unsigned overflow (for a compare, "no borrow", meaning first ≥ second unsigned)
V overflow: signed overflow
cmp r0, r1 works out r0 − r1, throws the answer away and keeps only the flags. Then a conditional branch reads them: beq (equal), bne (not equal), bgt/bge/blt/ble for signed numbers, and bhi/bhs/blo/bls for unsigned ones. Plain b always jumps.
A label is a name followed by a colon. It marks a place in the code you can branch to.
Try it
Ctrl+Enter runs
That shape (compare, branch to the "then" part, fall through to the "else" part, jump over) is what every if/else compiles to.
A loop is a branch backwards. Instructions ending in s (like subs, adds, movs) also set the flags, so a count-down loop needs no separate compare: subs r1, r1, #1 then bne loop repeats until r1 reaches zero.
Try it
Ctrl+Enter runs
The same loop in Pythonwhat test_engine.py would write
total = 0
for i in range(10, 0, -1):
total += i
The Python version hides the counter, the compare and the jump. In assembly you write all three, and the step count in the result shows what each one costs.
Exercise 2.1
The larger of two readings
To do
Put the larger of r0 and r1 (signed) into r2. The checker tries negative values and a tie.
mov r2, r0
cmp r1, r0
ble done
mov r2, r1
done:
Use the signed branches (ble, bgt), not bls/bhi, or -3 will look bigger than 30.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r2 = max(r0, r1), signed
mov r2, r0
cmp r1, r0
ble done
mov r2, r1
done:
Exercise 2.2
Count the frequency points
To do
A sweep runs from r0 to r1 inclusive in steps of r2 (all in MHz, all positive). Put the number of points in r3 by actually looping: start at r0, add the step until you pass r1, and count. For 10000 to 12000 by 500 the answer is 5.
r0 holds -1 and r1 holds 1. After cmp r0, r1, which branch is taken?
Signed, -1 < 1. Unsigned, -1 is 0xFFFFFFFF, the biggest number there is, so it is also higher. The bits are the same; the branch decides how to read them.
Day 1 · Saturday · Lesson 3 · about 55 min
Bits and masks
AND, ORR, EOR, BIC, shifts and TST: setting, clearing and testing bits like a driver does.
Hardware is controlled one bit at a time. A status register might use bit 3 for "overcurrent" and bits 4 to 7 for a channel number. These instructions are how firmware reads and changes individual bits:
orr rd, rn, #mask sets the bits that are 1 in the mask
bic rd, rn, #mask (bit clear) clears them
eor rd, rn, #mask flips them
and rd, rn, #mask keeps only them (everything else becomes 0)
lsl / lsr shift left / right; asr shifts right keeping the sign
tst rn, #mask is an AND that only sets flags, so bne means "some of those bits were set"
A mask for bit n is 1 << n. The assembler works this out for you: #(1 << 3) is 8.
Try it
Ctrl+Enter runs
Bits in Pythonan illustration in the bench's style
# SCPI status bytes are bit fields too. *ESR? bit 5 is "command error":
esr = int(self.query("*ESR?"))
if esr & (1 << 5):
...
The drivers mostly read the error queue as text, but any instrument's *STB? or *ESR? reply is a bit field, and Python's &, |, << are the same operations as and, orr, lsl here.
Exercise 3.1
Set and clear without disturbing
To do
r0 holds a control register. Set bit 5 (enable output) and clear bit 2 (fault latch), leaving every other bit exactly as it was. The result stays in r0.
orr r0, r0, #(1 << 5) then bic r0, r0, #(1 << 2).
Tried it? Reading the answer after a real attempt is how this sticks.
@ set bit 5 and clear bit 2 of r0, keep the rest
orr r0, r0, #(1 << 5)
bic r0, r0, #(1 << 2)
Exercise 3.2
Pull a field out of a status word
To do
A supply's status word in r0 packs the active channel number in bits 4 to 7. Put that channel number (0 to 15) in r1, and put 1 in r2 if bit 3 (overcurrent) is set, else 0.
Shift the field down to bit 0, then mask it: lsr r1, r0, #4 and and r1, r1, #0xF. Same idea for bit 3 with #1 as the mask.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r1 = bits 7..4 of r0, r2 = bit 3 of r0
lsr r1, r0, #4
and r1, r1, #0xF
lsr r2, r0, #3
and r2, r2, #1
Exercise 3.3
Count the set bits
To do
Put the number of 1 bits in r0 into r1. Loop: test the lowest bit, add it to the count, shift right, and stop when r0 becomes zero.
Use lsr, not asr: with asr a negative number never reaches zero.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r1 = number of set bits in r0
movs r1, #0
loop:
cmp r0, #0
beq done
and r2, r0, #1
add r1, r1, r2
lsr r0, r0, #1
b loop
done:
Quick check
What does and r1, r0, #0xF0 keep?
0xF0 is 1111 0000 in binary: ones in bits 4 to 7. AND keeps exactly those and zeroes the rest without moving them.
Day 1 · Saturday · Lesson 4 · about 65 min
Memory, arrays and pointers
LDR and STR, addresses, walking an array, words versus bytes.
Registers are few; memory is big. Memory is a long row of bytes, each with an address. On this chip RAM starts at 0x20000000. A 32-bit word takes four bytes, so word number i of an array lives at base + 4×i.
ldr r1, [r0] loads the word at the address in r0. Square brackets mean "the memory at".
ldr r1, [r0, #8] loads from r0 + 8 (the third word). r0 is unchanged.
ldr r1, [r0], #4 loads from r0, then adds 4 to r0: perfect for walking an array.
str r1, [r0] stores r1 to memory. The data moves right to left, unlike everything else.
ldrb / strb move one byte; ldrh / strh a 16-bit halfword.
A register holding an address is a pointer. Pointers are the heart of C and of every driver.
Try it
Ctrl+Enter runs
Each exercise from here starts with data already in RAM; the prompt says where. The result display shows the registers, and lists memory when it matters.
Exercise 4.1
Sum a sweep
To do
r0 points at an array of signed words (readings in dBm×10), and r1 holds how many there are. Put their sum in r2. Use post-indexed loads.
r0 points at an array of r1 signed words (at least one). Put the largest value in r2 and its index (0-based) in r3. If the peak appears twice, report the first.
Load the first element into r2 and set r3 = 0. Keep a running index in another register (say r4 = 1) and loop over the rest: load, cmp r5, r2, ble skip, then copy the value to r2 and the index to r3. A strict greater-than keeps the first peak.
Tried it? Reading the answer after a real attempt is how this sticks.
@ r2 = max value, r3 = index of the first max
ldr r2, [r0], #4 @ first element is the best so far
movs r3, #0
movs r4, #1 @ index of the next element
loop:
cmp r4, r1
bge done
ldr r5, [r0], #4
cmp r5, r2
ble skip
mov r2, r5
mov r3, r4
skip:
adds r4, r4, #1
b loop
done:
Exercise 4.3
Copy bytes
To do
Copy r2 bytes from the address in r0 to the address in r1 (a tiny memcpy). The source holds the text "SCPI". Use ldrb and strb.
A loop with ldrb r3, [r0], #1 and strb r3, [r1], #1, counting r2 down to zero. Check for zero first.
Tried it? Reading the answer after a real attempt is how this sticks.
r0 holds 0x20000010. After ldr r1, [r0, #4], what is in r0?
An offset in brackets without ! only computes the address. Only post-index [r0], #4 or pre-index with ! writes the new address back.
Day 2 · Sunday · Lesson 5 · about 65 min
Functions and the stack
BL and BX LR, the calling convention, PUSH and POP, and functions that call functions.
bl label (branch with link) jumps to a function and saves the return address in lr. The function ends with bx lr to jump back. That is all a function call is.
The calling convention (AAPCS) is the agreement that lets C, Rust and your assembly call each other:
arguments go in r0, r1, r2, r3; the result comes back in r0
a function may trash r0 to r3 and r12
it must give back r4 to r11 and sp exactly as it found them
If a function uses r4 to r11, or calls another function (which overwrites lr), it saves them on the stack first: push {r4, lr} at the start, pop {r4, pc} at the end. Popping the saved lr straight into pc is the return. The stack is just RAM that sp points into; push moves sp down and stores, pop loads and moves it up.
Try it
Ctrl+Enter runs
When there is a label called main, the simulator starts there; otherwise it starts at the first line. A function's code would run again if execution fell into it, which is why main ends with b end.
The same split in Cwhat a compiler turns into the code above
int square(int x) { return x * x; }
int main(void) {
print_int(square(12));
}
Paste C like this into Compiler Explorer (godbolt.org) with an ARM GCC and -O1 -mcpu=cortex-m4 -mthumb, and the output is the assembly you are learning. That is the fastest way to see how C maps onto instructions.
Exercise 5.1
Write calc_gain as a function
To do
Write a function gain that takes Pout in r0 and Pin in r1 (dBm×10) and returns Pout − Pin in r0. Then in main, call it with 335 and 210 and leave the result in r0. The checker also calls your gain directly.
In main: ldr r0, =335, mov r1, #210, bl gain. The function is sub r0, r0, r1 then bx lr.
Tried it? Reading the answer after a real attempt is how this sticks.
main:
ldr r0, =335
mov r1, #210
bl gain
b end
gain:
sub r0, r0, r1
bx lr
end:
Exercise 5.2
A function that calls a function
To do
Write sum_squares that takes a in r0 and b in r1 and returns a² + b² in r0, by calling the square function given below twice. It must keep b safe across the first call (r1 may be trashed by square, so save b in r4) and it must save lr. Follow the convention: push and pop r4 too.
...but r1 gets trashed by the next call. Swap roles: put a² in r4 after saving b somewhere it survives. One way: push {r4, r5, lr}, keep b in r5 and a² in r4.
Tried it? Reading the answer after a real attempt is how this sticks.
main:
mov r0, #3
mov r1, #4
bl sum_squares @ expect 25
b end
sum_squares:
push {r4, r5, lr}
mov r5, r1 @ b survives in r5
bl square
mov r4, r0 @ a*a survives in r4
mov r0, r5
bl square @ r0 = b*b
add r0, r0, r4
pop {r4, r5, pc}
square: @ deliberately trashes r1 to r3, as it is allowed to
mul r0, r0, r0
mov r1, #0
mov r2, #0
mov r3, #0
bx lr
end:
Quick check
A function does bl helper but never saved lr. What goes wrong when it reaches bx lr?
bl helper overwrote lr with the address after itself. The function's own return address is gone, so bx lr jumps back into the function.
Day 2 · Sunday · Lesson 6 · about 60 min
Talking to hardware: GPIO
Memory-mapped registers, ODR, BSRR and IDR, read-modify-write, and driving LEDs.
On a microcontroller, peripherals are controlled through memory-mapped registers: special addresses where a store does something physical. Writing a 1 into the right bit of the right address makes a pin go high and an LED light.
This simulator models GPIO port A of an STM32F4 at 0x40020000:
ODR (+0x14), output data: bit n drives pin PAn
BSRR (+0x18), bit set/reset: writing 1 to bit n sets pin n; writing 1 to bit n+16 clears it; zeros do nothing
IDR (+0x10), input data: read-only, reflects the input pins (a button, say)
The result display shows pins PA7 to PA0 as LEDs. On a real chip you would also enable the port's clock and set MODER to output first; the simulator skips that step.
Try it
Ctrl+Enter runs
Read-modify-write. To change one pin through ODR you must load ODR, change your bit with orr/bic, and store it back, or you wipe the other pins. If an interrupt changes ODR between your load and store, its change is lost. That is exactly why BSRR exists: one store, only your bits, nothing to race with.
Exercise 6.1
Toggle one LED safely
To do
Flip PA3 using ODR with read-modify-write, leaving every other pin alone. ODR starts with some pins already on (the checker sets them).
A pushbutton on PA0 reads 1 in IDR bit 0 when pressed. If it is pressed, turn PA5 on; if not, turn PA5 off. Use BSRR so no other pin changes. The checker runs your code with the button up and down.
Load IDR, tst r0, #1, beq off. In the "on" path store #(1 << 5) to BSRR; in the "off" path store #(1 << (5 + 16)). Use ldr r2, =value for the bigger constant.
Tried it? Reading the answer after a real attempt is how this sticks.
Show a number on the LEDs as a bar graph. r0 holds a level from 0 to 8. Light that many LEDs starting from PA0: level 3 lights PA0 to PA2 (ODR = 0b111). Pins above PA7 must stay off. Write ODR directly.
A shift by a register works: mov r2, #1, lsl r2, r2, r0, sub r2, r2, #1, then store r2 to ODR.
Tried it? Reading the answer after a real attempt is how this sticks.
Why does the STM32 have BSRR when ODR can already set pins?
Writing ODR means load, modify, store: an interrupt in between can lose a change. BSRR's set and reset bits act on only the pins you name, in one store.
Day 2 · Sunday · Lesson 7 · about 55 min
Numbers on a microcontroller
Signed and unsigned, overflow, MUL and division, and fixed-point scaling of ADC readings.
A register is 32 bits with no idea whether they mean a signed or an unsigned number. Two's complement makes the same adder work for both: -1 is 0xFFFFFFFF, -2 is 0xFFFFFFFE. Only the instructions you choose decide how bits are read: bgt/blt, asr and sdiv treat them as signed; bhi/blo, lsr and udiv as unsigned.
Numbers wrap. 0xFFFFFFFF + 1 is 0 with the carry flag set; 0x7FFFFFFF + 1 becomes the most negative number with the overflow flag set. C does not check for you, and neither does the CPU.
Most small microcontrollers have no floating point, so firmware uses fixed point: keep millivolts, or dBm×10, as integers. Scaling an ADC code is the classic case. A 12-bit ADC with a 3.3 V reference reads 0 to 4095, so millivolts = code × 3300 / 4095. Multiply first, then divide, or the integer division throws away all the precision.
Try it
Ctrl+Enter runs
Exercise 7.1
ADC code to millivolts, rounded
To do
Convert the 12-bit ADC code in r0 to millivolts in r0, rounded to the nearest mV: mV = (code × 3300 + 2047) / 4095. Adding half the divisor before dividing is how integer code rounds.
A gate DAC takes millivolts, and the part is damaged outside −3000 to −500 mV. Clamp the signed value in r0 into that range, in place. The checker tries values inside, below and above.
Load the limits with ldr r1, =-3000 and ldr r2, =-500. cmp r0, r1, bge not_low, mov r0, r1. Then the same against r2 with ble. Signed branches only.
Tried it? Reading the answer after a real attempt is how this sticks.
r0 holds 0xFFFFFFF6. Which instruction makes r1 = -5?
0xFFFFFFF6 is -10 signed. asr keeps the sign bit and gives -5; lsr shifts in a zero and gives a huge positive number.
Day 2 · Sunday · Lesson 8 · about 70 min
Putting it together
Reading compiler output, then two capstones: a limit check over a sweep and a bias search.
You can now read most of what a C compiler produces for a Cortex-M. Here is the bench's limit check, reduced to integers, in C and in the assembly GCC writes for it. Every instruction is one you know.
C versiona fixed-point cut of evaluate_limits
int count_out_of_limits(const int *v, int n, int lo, int hi) {
int bad = 0;
for (int i = 0; i < n; i++)
if (v[i] < lo || v[i] > hi) bad++;
return bad;
}
Arguments arrive in r0 to r3 in order: v (a pointer), n, lo, hi. The answer goes back in r0.
What the compiler writesarm-none-eabi-gcc -O1 -mcpu=cortex-m4 -mthumb, lightly tidied
count_out_of_limits:
push {r4, r5}
mov r4, #0 @ bad = 0
cmp r1, #0
ble .L2
.L4: ldr r5, [r0], #4 @ v[i], then advance the pointer
cmp r5, r2
blt .L5 @ below lo
cmp r5, r3
ble .L3 @ not above hi
.L5: adds r4, r4, #1
.L3: subs r1, r1, #1
bne .L4
.L2: mov r0, r4
pop {r4, r5}
bx lr
The compiler turned i < n into a count-down in r1 and the array index into a walking pointer, the same tricks you used in lesson 4. Labels starting with .L are the compiler's own.
Capstone
Capstone 1: a limit check over a sweep
To do
Without looking back at the compiler's version, write limit_check(v, n, lo, hi) following the calling convention, returning in r0 the number of readings outside [lo, hi]. main is written for you. Then add a second result: store the index of the first failing reading (or −1 if none) to the word at address 0x20000F00.
Save what you use: push {r4-r7}. Keep the count in r4, the index in r5, the first-fail index in r6 (start it at -1 with mvn r6, #0). In the loop, branch to a fail block when below lo or above hi; there, add 1 to the count, and if r6 is still -1 copy the index into it. At the end store r6 with ldr r7, =0x20000F00 and str r6, [r7], mov r0, r4, pop {r4-r7}, bx lr.
Tried it? Reading the answer after a real attempt is how this sticks.
main:
ldr r0, =0x20000000 @ six readings, dBm x10
mov r1, #6
ldr r2, =300 @ lo
ldr r3, =330 @ hi
bl limit_check @ expect 2 (298 and 340), first fail index 3
b end
limit_check:
push {r4-r7}
movs r4, #0 @ count
movs r5, #0 @ index
mvn r6, #0 @ first failing index = -1
loop:
cmp r5, r1
bge finish
ldr r7, [r0], #4
cmp r7, r2
blt fail
cmp r7, r3
ble next
fail:
adds r4, r4, #1
cmp r6, #-1
bne next
mov r6, r5
next:
adds r5, r5, #1
b loop
finish:
ldr r7, =0x20000F00
str r6, [r7]
mov r0, r4
pop {r4-r7}
bx lr
end:
Capstone
Capstone 2: a bias search
To do
The DC2_BIAS step in test_engine.py raises the gate voltage until the drain current reaches its target. Here is that loop in miniature. The table at 0x20000000 holds the drain current (mA) the part draws at gate DAC code 0, 1, 2, … (rising, 64 entries). Write code that leaves in r0 the **first DAC code whose current is at least the target in r1**, and also writes that code to the gate DAC register at 0x20000F04. If no code reaches the target, leave −1 in r0 and write nothing. Stop searching as soon as you find it: the checker counts steps.
Walk the table with a pointer and a code counter from 0 to 63: load the current, cmp r3, r1, bge found. After the loop, mvn r0, #0 and finish. At found, move the counter to r0 and store it with ldr r4, =0x20000F04, str r0, [r4].
Tried it? Reading the answer after a real attempt is how this sticks.
@ table of 64 currents at 0x20000000, target mA in r1
@ r0 = first code with current >= target (or -1); also store it at 0x20000F04
ldr r2, =0x20000000
movs r0, #0
loop:
cmp r0, #64
bge not_found
ldr r3, [r2], #4
cmp r3, r1
bge found
adds r0, r0, #1
b loop
not_found:
mvn r0, #0
b done
found:
ldr r4, =0x20000F04
str r0, [r4]
done:
Quick check
In the compiler's version, why does it push {r4, r5} but not r0 to r3?
That is the calling convention: r0 to r3 and r12 are caller-saved scratch; a function that uses r4 to r11 must restore them.
After the weekend
Onto real hardware
The same instructions, run on a board on your desk.
1. See what compilers write
Open Compiler Explorer at godbolt.org, choose an "ARM GCC" compiler and add -O1 -mcpu=cortex-m4 -mthumb. Type small C functions and read the output. Change -O1 to -O2 and see what the optimiser does differently.
2. A toolchain
sudo apt install gcc-arm-none-eabi gdb-multiarch openocd qemu-system-arm
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -c blink.s -o blink.o
arm-none-eabi-objdump -d blink.o # disassemble what you built
A Nucleo or "Black Pill" STM32F4 board and its built-in ST-Link let you step through your code instruction by instruction in GDB and watch the same registers you saw here.
3. The references that matter
The Cortex-M4 Generic User Guide from ARM: every instruction, its flags and its timing.
Your chip's reference manual (RM0090 for the STM32F4): the GPIO chapter lists MODER, IDR, ODR and BSRR exactly as used here.
The AAPCS, the procedure call standard: the calling convention in full.
4. Practice
Blink an LED with a delay loop on a real board, then replace the loop with the SysTick timer.
Write count_out_of_limits in C, compile it, and compare with your capstone.
Call your assembly function from a C main: declare it with extern int limit_check(const int*, int, int, int);.
Reference
Cheat sheet
The instructions and conventions from the course on one page.