Part 2 · C Meets the Metal28 min read
Reading What the Compiler Wrote
Your C is a request. The assembly is what the CPU actually runs. When a board HardFaults, the debugger hands you a program counter that points at one instruction, not at a line of C. When a register write “does nothing”, the only way to prove the compiler emitted the store is to look for it. When an interrupt handler is too slow, you count instructions. This lesson teaches you to read the Thumb-2 code that arm-none-eabi-gcc -O2 produces for a Cortex-M4, well enough to match it to the C that generated it. You will not be writing assembly programs; you will be reading the compiler’s output with confidence.
Why firmware engineers read assembly
Section titled “Why firmware engineers read assembly”Four situations come up every week in real firmware work:
- Fault debugging. A HardFault handler gives you the stacked
pc.arm-none-eabi-objdump -dtells you which instruction that is, and the instruction tells you what went wrong: aldrthrough a null pointer, astrto an address with no peripheral behind it, audivby zero with the trap enabled. - Verifying
volatileand ordering. If you wrote a register twice and the scope shows one pulse, the listing shows whether the compiler emitted one store or two. - Timing. A bit-banged protocol or a tight ISR has a cycle budget. You can only count cycles you can see.
- Understanding the optimizer. “Works at
-O0, breaks at-O2” is almost always undefined behavior in your code, and the assembly shows exactly what the compiler assumed.
Compiler bugs exist but are rare. Your bugs are common. Reading the output is how you tell them apart.
Getting assembly out of the toolchain
Section titled “Getting assembly out of the toolchain”There are two views of the same code: the compiler’s assembly output before assembling, and the disassembly of the final object or ELF.
# Compiler output: assembly source, with directives and labelsarm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 \ -O2 -ffunction-sections -S led.c -o led.s
# Add -fverbose-asm to annotate each instruction with the C variable it usesarm-none-eabi-gcc ... -O2 -S -fverbose-asm led.c -o led.s
# Disassembly of a built ELF, interleaved with source (needs -g at compile time)arm-none-eabi-objdump -d -S firmware.elf > firmware.lstThe -S output is easiest for small experiments. The objdump listing is what you use on a real build, because it shows final addresses, which are what the fault handler and the debugger report. Here is the -S output for a function that sets bit 12 of GPIOD_ODR (the green LED on the Discovery board), with some of the boilerplate directives removed:
.syntax unified .cpu cortex-m4 .fpu fpv4-sp-d16 .thumb .section .text.led_on,"ax",%progbits .align 1 .global led_on .thumb_func .type led_on, %functionled_on: ldr r2, .L3 ldr r3, [r2] orr r3, r3, #4096 str r3, [r2] bx lr.L4: .align 2.L3: .word 1073875988 .size led_on, .-led_onLines starting with . are assembler directives, not instructions. .section .text.led_on is there because of -ffunction-sections: each function gets its own section so the linker can discard unused ones. .thumb_func marks led_on as Thumb code, which makes the linker set bit 0 of its address whenever it is used as a function pointer. .word 1073875988 is the constant 0x40020C14 in decimal: the address of GPIOD_ODR, stored in a literal pool right after the function. More on that shortly.
The same function in objdump -d -S output from a linked ELF looks like this:
08000188 <led_on>:void led_on(void){ GPIOD_ODR |= (1u << 12); 8000188: 4a02 ldr r2, [pc, #8] @ (8000194 <led_on+0xc>) 800018a: 6813 ldr r3, [r2, #0] 800018c: f443 5380 orr.w r3, r3, #4096 @ 0x1000 8000190: 6013 str r3, [r2, #0]} 8000192: 4770 bx lr 8000194: 40020c14 .word 0x40020c14Each line is: address, the raw encoding, the instruction. Notice that most encodings are 16 bits (4a02) and one is 32 bits (f443 5380). That mix is Thumb-2, covered below. The .w suffix on orr.w means “wide”, the 32-bit encoding.
Three more tools answer “what is in this binary”:
arm-none-eabi-nm -n firmware.elf # symbols sorted by addressarm-none-eabi-size firmware.elf # text / data / bss totalsarm-none-eabi-readelf -S firmware.elf # section headers$ arm-none-eabi-nm -n firmware.elf08000000 R vector_table08000188 T led_on08000198 T main080001c4 W NMI_Handler20000000 D g_mode20000004 B g_ticks20020000 A _estackThe letter is the symbol type: T code (global), t code (file-local, static), R read-only data, D initialized data, B zero-initialized data, W weak symbol, U undefined (needs another object to provide it), A absolute value set by the linker script. Upper case means global, lower case means local.
$ arm-none-eabi-size firmware.elf text data bss dec hex filename 612 4 8 624 270 firmware.elftext is everything read-only that goes in flash (vector table, code, constants). data is initialized variables: they occupy flash (the initial values) and RAM (the variables). bss is zero-initialized variables: RAM only. Flash used is text + data; static RAM used is data + bss.
readelf -S lists every section with its address, size, and flags (A allocated, X executable, W writable). You will lean on it in Linker scripts.
The register file
Section titled “The register file”The Cortex-M4 has sixteen 32-bit core registers visible to normal code:
| Register | Alias | Role under the AAPCS calling convention |
|---|---|---|
r0-r3 | Arguments and return value; scratch, not preserved across calls | |
r4-r11 | Callee-saved: a function that uses them must restore them | |
r12 | ip | Scratch (intra-procedure call register) |
r13 | sp | Stack pointer |
r14 | lr | Link register: return address after bl |
r15 | pc | Program counter |
You met the calling convention in Functions and the calling convention. Reading assembly is where it pays off: when you see r0 at the start of a function, it is the first argument; when you see r0 before bx lr, it is the return value.
There is also the program status register, xPSR. Its top bits are the condition flags, set by compares and by arithmetic instructions with an s suffix:
| Bit | Flag | Set when |
|---|---|---|
| 31 | N | Result is negative (bit 31 set) |
| 30 | Z | Result is zero |
| 29 | C | Carry out (for subtraction and compare: no borrow) |
| 28 | V | Signed overflow |
| 27 | Q | Saturation occurred (DSP instructions) |
The same register holds the current exception number in its low 9 bits and the Thumb state bit at bit 24. Those parts, and the special registers PRIMASK, BASEPRI, and CONTROL, are covered in The Cortex-M architecture.
Thumb-2 in one page
Section titled “Thumb-2 in one page”Classic Arm cores had two instruction sets: 32-bit Arm and 16-bit Thumb. Cortex-M cores execute only Thumb, and the M3/M4/M7 implement Thumb-2, which mixes 16-bit and 32-bit encodings freely. The compiler picks the 16-bit form when the operands allow it (low registers r0-r7, small immediates) and the 32-bit form otherwise. Dense code, close to full 32-bit capability.
Two syntax rules help when reading:
- An
ssuffix (adds,movs,lsls) means the instruction updates the flags. Many 16-bit encodings always set flags, which is why the compiler emitsmovs r0, #0rather thanmov r0, #0: it is two bytes instead of four, and nothing cares about the flags. .wforces the 32-bit encoding,.nthe 16-bit one.objdumpshows.wwhen it disassembles a 32-bit encoding that has a 16-bit sibling.
Moving values into registers
Section titled “Moving values into registers” movs r0, #5 @ r0 = 5 (16-bit, 0-255 only) mov r1, r2 @ r1 = r2 mov r3, #0x1000 @ 32-bit "modified immediate" movw r0, #0x0C14 @ r0 = 0x00000C14 (low 16 bits, clears top) movt r0, #0x4002 @ r0 = 0x40020C14 (sets top 16 bits)A 32-bit Thumb-2 instruction cannot hold an arbitrary 32-bit constant. It can hold a modified immediate: an 8-bit value rotated to any position (so 0x1000, 0xFF000000, 0x3FC all work), or a repeating byte pattern like 0x00FF00FF or 0xABABABAB. Anything else needs either a movw/movt pair (two instructions, 8 bytes) or a load from a literal pool (one instruction plus a 4-byte word). GCC targeting Cortex-M4 usually chooses the literal pool for addresses.
Loads, stores, and addressing modes
Section titled “Loads, stores, and addressing modes”Arm is a load/store architecture: arithmetic happens only in registers, and memory is touched only by load and store instructions.
| Instruction | Meaning |
|---|---|
ldr r0, [r1] | r0 = *(uint32_t *)r1 |
ldrb r0, [r1] / ldrh | Load byte / halfword, zero-extend to 32 bits |
ldrsb r0, [r1] / ldrsh | Load byte / halfword, sign-extend |
str r0, [r1] / strb / strh | Store word / byte / halfword |
ldr r0, [r1, #8] | Offset: address r1 + 8, r1 unchanged |
ldr r0, [r1, #8]! | Pre-index: r1 += 8, then load from r1 |
ldr r0, [r1], #4 | Post-index: load from r1, then r1 += 4 |
ldr r0, [r1, r2, lsl #2] | Register offset: address r1 + r2 * 4 (array indexing) |
ldr r0, [pc, #12] | PC-relative: load a constant from the literal pool |
ldrd r0, r1, [r2] | Load two words (64-bit values) |
The width of the access matters for hardware. A ldrb from a peripheral register is a byte-wide bus transaction, and some peripherals only respond correctly to 32-bit accesses. Reading the listing tells you which width the compiler chose.
Literal pools and ldr r0, =constant
Section titled “Literal pools and ldr r0, =constant”Back to ldr r2, [pc, #8]. In Thumb state, when an instruction reads pc, it sees the address of the current instruction plus 4, rounded down to a multiple of 4. For the instruction at 0x08000188, that is 0x0800018C. Add 8 and you get 0x08000194, exactly where objdump shows .word 0x40020c14. The compiler parked the constant just after the function’s bx lr (code never falls through into it) and loads it PC-relatively.
In hand-written assembly you write this with a pseudo-instruction:
ldr r0, =0x40020C00 @ assembler picks the encodingThe = form tells the assembler: “get this value into r0 however you can”. If the value fits a mov immediate, the assembler emits a mov. Otherwise it places the constant in the next literal pool (at the end of the section, or wherever a .ltorg directive appears) and emits a PC-relative ldr. That is why ldr r0, =0x40020C00 works even though no instruction can encode that constant. The 16-bit PC-relative ldr can reach only about 1 KB forward, so very long functions get pools placed in the middle, with a branch jumping over them.
Arithmetic, logic, and shifts
Section titled “Arithmetic, logic, and shifts” adds r0, r1, #1 @ r0 = r1 + 1, set flags sub r0, r0, r2 @ r0 = r0 - r2 mul r0, r1, r0 @ r0 = r1 * r0 (low 32 bits) udiv r0, r0, r1 @ r0 = r0 / r1 (unsigned; sdiv for signed) and r0, r0, #0xFF @ r0 &= 0xFF orr r3, r3, #4096 @ r3 |= 0x1000 eor r0, r0, r1 @ r0 ^= r1 bic r3, r3, #0x3000000 @ r3 &= ~0x3000000 (bit clear) lsls r0, r1, #2 @ r0 = r1 << 2 lsrs r0, r1, #2 @ r0 = r1 >> 2 (logical, unsigned) asrs r0, r1, #2 @ r0 = r1 >> 2 (arithmetic, signed) add r0, r1, r1, lsl #1 @ r0 = r1 + (r1 << 1) = r1 * 3bic (bit clear) is the instruction behind every reg &= ~MASK you will write. The last line shows a Thumb-2 feature you will see constantly: the second operand of many data-processing instructions can be shifted for free, so the compiler turns x * 3 into one add.
The choice between lsrs and asrs is how the compiler implements >> on unsigned versus signed types. If you expected one and see the other, check your types.
Compare, test, and branch
Section titled “Compare, test, and branch” cmp r0, r1 @ compute r0 - r1, set flags, discard result tst r0, #0x20 @ compute r0 & 0x20, set flags, discard beq .L5 @ branch if Z == 1 (equal / zero) bne .L5 @ branch if Z == 0 b .L2 @ unconditional branchThe condition suffix is the part you must learn to read fluently, because it carries the signedness of the original C comparison:
| Suffix | Meaning after cmp a, b | Flags |
|---|---|---|
eq / ne | a == b / a != b | Z set / clear |
lt / ge | signed a < b / a >= b | N != V / N == V |
gt / le | signed a > b / a <= b | Z == 0 && N == V / opposite |
lo (cc) / hs (cs) | unsigned a < b / a >= b | C clear / set |
hi / ls | unsigned a > b / a <= b | C && !Z / opposite |
mi / pl | result negative / non-negative | N set / clear |
If your C compares a uint32_t and you see blt, something converted it to signed. If you compare an int and see blo, something converted it to unsigned (the classic -1 < 1u trap from Operators, expressions, and implicit conversions).
Two more branch forms are Thumb-2 specials:
cbz r0, label/cbnz r0, label: compare a low register with zero and branch forward, in one 16-bit instruction, without touching the flags. The compiler loves these for null checks andn == 0early exits.- IT blocks:
it gtfollowed bymovgt r0, r2means “execute the next instruction only ifgt”. Up to four instructions can be predicated (ite,itt,itte, and so on:tfor then,efor else). This removes short branches entirely.
Calls and returns
Section titled “Calls and returns” bl helper @ lr = return address, branch to helper bx lr @ return: branch to the address in lr blx r3 @ call through a function pointer in r3 push {r4, lr} @ save r4 and the return address pop {r4, pc} @ restore r4 and return in one instructionbl overwrites lr, so any function that calls another must save lr first; that is the push {r4, lr} at the top of non-leaf functions. Popping the saved lr value directly into pc returns. Bit 0 of every address loaded into pc this way must be 1 (Thumb state). The linker and compiler take care of that; when it goes wrong you get an INVSTATE UsageFault.
System instructions you will meet
Section titled “System instructions you will meet”| Instruction | What it does |
|---|---|
nop | Nothing, one cycle (timing padding, breakpoint targets) |
bkpt #0 | Halt into the debugger; HardFault if no debugger is attached |
wfi | Sleep until an interrupt arrives |
cpsid i / cpsie i | Disable / enable interrupts (set / clear PRIMASK) |
mrs r0, primask / msr primask, r0 | Read / write a special register |
dmb, dsb, isb | Memory and instruction barriers |
ldrex / strex | Exclusive load/store, the basis of atomic operations |
The barriers and the exclusive access instructions each get proper treatment later (The Cortex-M architecture and Concurrency, atomics, and memory ordering). For now, recognise them when you see them.
Six C snippets and their assembly
Section titled “Six C snippets and their assembly”All of the following were compiled with the course’s target flags at -O2. Different GCC versions may order instructions differently or choose a different register; the structure is what matters.
1. A loop
Section titled “1. A loop”#include <stdint.h>
uint32_t sum(const uint32_t *p, uint32_t n){ uint32_t s = 0; for (uint32_t i = 0; i < n; i++) { s += p[i]; } return s;}sum: cbz r1, .L4 @ n == 0: skip the loop mov r3, r0 @ r3 = p (walking pointer) add r1, r0, r1, lsl #2 @ r1 = p + n*4 (end pointer) movs r0, #0 @ s = 0.L3: ldr r2, [r3], #4 @ r2 = *p, then p += 4 cmp r3, r1 @ reached the end? add r0, r0, r2 @ s += r2 (no 's': flags untouched) bne .L3 bx lr.L4: mov r0, r1 @ r1 is 0 here: return 0 bx lrThe compiler eliminated i completely. It computed an end pointer once and walks p with a post-indexed load. Notice the add between cmp and bne: it does not set flags, so the branch still sees the result of the cmp. The compiler moved it there to give the load result an extra cycle to arrive. When you see your loop counter missing from the listing, this is why.
2. An if
Section titled “2. An if”#include <stdint.h>
int32_t clamp(int32_t x, int32_t lo, int32_t hi){ if (x < lo) { return lo; } if (x > hi) { return hi; } return x;}clamp: cmp r0, r1 blt .L3 @ signed x < lo cmp r0, r2 it gt movgt r0, r2 @ if x > hi: r0 = hi bx lr.L3: mov r0, r1 @ return lo bx lrblt and gt are the signed conditions, because int32_t is signed. Change the types to uint32_t and they become blo and hi. The second if turned into an IT block: no branch, just a conditional move.
3. A function call
Section titled “3. A function call”#include <stdint.h>
uint32_t helper(uint32_t a, uint32_t b);
uint32_t caller(uint32_t x){ return helper(x, 3u) + x;}caller: push {r4, lr} @ save callee-saved r4 and return address mov r4, r0 @ keep x safe across the call movs r1, #3 @ second argument bl helper @ first argument is already in r0 add r0, r0, r4 @ result + x pop {r4, pc} @ restore r4, returnx arrives in r0, but r0 is also where helper puts its result, and helper may trash r0-r3 and r12. So the compiler copies x into r4, which helper must preserve, and saves the caller’s own r4 on the stack to honor the same rule. lr is pushed because bl will overwrite it. Pushing two registers also keeps sp 8-byte aligned, as the AAPCS requires at call boundaries.
4. Struct member access
Section titled “4. Struct member access”#include <stdint.h>
struct sensor { uint8_t id; /* offset 0, then 1 byte padding */ uint16_t raw; /* offset 2 */ uint32_t scale; /* offset 4 */};
uint32_t sensor_value(const struct sensor *s){ return s->raw * s->scale;}sensor_value: ldrh r3, [r0, #2] @ s->raw: halfword load at offset 2 ldr r0, [r0, #4] @ s->scale: word load at offset 4 mul r0, r3, r0 bx lrA struct access is a load with an immediate offset from the struct’s base address. The offsets in the listing are the offsets from Structs, unions, enums, and memory layout, including the padding byte after id. When you reverse-engineer a crash, ldr r0, [r4, #36] tells you “the field at offset 36 of whatever r4 points to”, and offsetof tells you which field that is. The same pattern is how register structs work in the next lesson.
5. A volatile register write
Section titled “5. A volatile register write”#include <stdint.h>
#define ODR_VOLATILE (*(volatile uint32_t *)0x40020C14u)#define ODR_PLAIN (*(uint32_t *)0x40020C14u)
void pulse_volatile(void){ ODR_VOLATILE = 1u; ODR_VOLATILE = 0u;}
void pulse_plain(void){ ODR_PLAIN = 1u; ODR_PLAIN = 0u;}pulse_volatile: ldr r3, .L7 @ r3 = 0x40020C14 movs r2, #1 str r2, [r3] @ first store movs r2, #0 str r2, [r3] @ second store bx lr.L7: .word 1073875988
pulse_plain: ldr r3, .L10 movs r2, #0 str r2, [r3] @ only the final value is stored bx lr.L10: .word 1073875988Without volatile, the first store is a dead store: nothing reads the location between the two writes, so the compiler deletes it. On a GPIO register, that deleted store was your pulse. With volatile, every access in the source becomes exactly one access in the binary, in order. This is the single most valuable thing to check in a listing, and it is why the next lesson puts volatile on every register.
6. A switch and its jump table
Section titled “6. A switch and its jump table”#include <stdint.h>
uint32_t dispatch(uint32_t cmd, uint32_t x){ switch (cmd) { case 0: return x + 1u; case 1: return x * 3u; case 2: return x >> 2; case 3: return x ^ 0xFFu; case 4: return 0u; default: return x; }}dispatch: cmp r0, #4 bhi .L2 @ unsigned cmd > 4: default tbb [pc, r0] @ table branch, byte offsets.L4: .byte (.L8-.L4)/2 @ case 0 .byte (.L7-.L4)/2 @ case 1 .byte (.L6-.L4)/2 @ case 2 .byte (.L5-.L4)/2 @ case 3 .byte (.L3-.L4)/2 @ case 4 .p2align 1.L5: eor r0, r1, #255 bx lr.L6: lsrs r0, r1, #2 bx lr.L7: add r0, r1, r1, lsl #1 bx lr.L8: adds r0, r1, #1 bx lr.L3: movs r0, #0 bx lr.L2: mov r0, r1 bx lrOne range check (bhi catches every value above 4, and because the comparison is unsigned, nothing below 0 exists) and then tbb, table branch byte. It reads the byte at pc + cmd, doubles it, and adds it to pc. The table entries are divided by 2 because Thumb instructions are always halfword aligned, so storing halfword counts doubles the reach of a byte. For dense case values this is constant time no matter how many cases you have. Sparse values (case 1, case 1000, case 50000) get a chain or tree of compares instead. tbh is the halfword-table version for larger functions.
Inline assembly basics
Section titled “Inline assembly basics”Sometimes C has no way to say what you need: a wfi, a barrier, reading sp or PRIMASK. GCC’s extended inline assembly handles this. With -std=c11, spell the keyword __asm (plain asm is a GNU extension not available in strict ISO mode).
#include <stdint.h>
static inline uint32_t read_sp(void){ uint32_t sp; __asm volatile ("mov %0, sp" : "=r" (sp)); return sp;}
static inline uint32_t get_primask(void){ uint32_t value; __asm volatile ("mrs %0, primask" : "=r" (value)); return value;}
static inline void set_primask(uint32_t value){ __asm volatile ("msr primask, %0" : : "r" (value) : "memory");}
static inline void irq_disable(void){ __asm volatile ("cpsid i" : : : "memory");}
static inline void compiler_barrier(void){ __asm volatile ("" : : : "memory");}The template is "instructions" : outputs : inputs : clobbers.
"=r" (sp)means “an output in any general register; after the instruction, copy it tosp”.%0in the template is replaced by that register."r" (value)is an input in any register.volatileon the__asmstops the compiler from deleting or hoisting the statement just because its outputs look unused.- The
"memory"clobber tells the compiler that the instruction may read or write any memory. It must finish all pending stores before the statement and reload values from memory afterwards. Without it, the compiler may move a store to a shared variable across yourcpsid i, outside the critical section you thought you created.
The empty compiler_barrier() emits no instruction at all. It only constrains the optimizer. That distinction between compiler ordering and hardware ordering comes back in Concurrency, atomics, and memory ordering.
mrs and msr are the only way to touch special registers such as PRIMASK, BASEPRI, CONTROL, MSP, and PSP. The CMSIS headers wrap exactly these instructions in functions like __get_PRIMASK(). Keep inline assembly to one or two instructions inside a static inline function with a clear name. Large blocks of inline assembly are hard to get right because you must describe every register and memory effect to the compiler; write a separate .s file instead.
Common mistakes
Section titled “Common mistakes”- Reading
-O0output to understand performance. It stores every variable to the stack after every statement. Fix: always inspect the optimization level you ship. - Assuming the loop counter or variable you are looking for must appear. The optimizer rewrites induction variables and keeps values in registers. Fix: use
-fverbose-asmorobjdump -Sto see which C line each block came from. - Treating
bltandbloas interchangeable. They encode signed versus unsigned comparison. Fix: when a comparison misbehaves, check which condition the compiler used and trace the types. - Forgetting the
"memory"clobber on inline assembly that has side effects on interrupt state or ordering. Fix: add it to everycpsid,cpsie,msr,dsb, andwfiwrapper. - Disassembling an object compiled without
-gand expecting source lines. Fix: compile with-g; it does not change the generated code, only adds debug sections. - Misreading PC-relative offsets. Fix: in Thumb state
pcreads as the instruction address plus 4, aligned down to 4, before adding the offset.
Exercises
Section titled “Exercises”- Compile
sumfrom this lesson with-O0 -Sand with-O2 -S. Count the instructions inside the loop in each. At-O0you should seei,s, andploaded from and stored to[sp, #...]on every iteration; at-O2the loop is four instructions. - Build
led_oninto an object file with-c -gand runarm-none-eabi-objdump -d -Son it. Find the literal pool word and verify by hand that the PC-relative offset in theldrinstruction points at it, using the “address plus 4, aligned down to 4” rule. - Write
uint32_t div10(uint32_t x) { return x / 10u; }and compile at-O2. You will likely see noudivat all, but aumullwith the constant0xCCCCCCCDfollowed by a shift right by 3. Explain in one paragraph why multiplying by that constant and keeping the top bits equals dividing by 10, and why the compiler prefers it. - Take
dispatchand remove cases one at a time until GCC stops emittingtbband uses a chain ofcmp/beqinstead. Then make the case values sparse (0, 10, 100, 1000) and observe what the compiler does. Record the threshold you found for your GCC version. - Put
read_sp()andget_primask()in a small file, call them frommain, and disassemble. Confirm each compiles to a singlemovormrswith no function call. Then removevolatilefrom the__asminget_primaskand call it twice in a row, discarding the first result. Does the compiler delete the firstmrs? Explain why.
Check yourself
Section titled “Check yourself”Question 1:What does ldr r3, [r2], #4 do?
This is post-indexed addressing: the access uses the base register as it is, then writes back the incremented value. It is how compilers walk arrays.
Question 2:You see cmp r0, r1 followed by blo .L5. Which C comparison most likely produced it?
lo is the unsigned "lower" condition (carry clear). A signed less-than would use lt.
Question 3:Why does caller start with push {r4, lr}?
r0-r3 are clobbered by helper, so x moves to r4, which the caller must save for its own caller. lr is saved because bl replaces it.
Question 4:Your C writes a GPIO register twice (1 then 0) but the listing shows a single str of 0. What is the cause?
Without volatile, the compiler only has to produce the final value in memory. volatile forces every access to happen, in order.
Question 5:How does ldr r0, =0x40020C00 get an arbitrary 32-bit constant into r0?
No Thumb instruction can encode that constant directly. The = pseudo-instruction places it in a literal pool and loads it with ldr rX, [pc, #offset], or uses mov if the value happens to fit an immediate.
Question 6:In a tbb jump table the entries are written as (.L8-.L4)/2. Why divide by 2?
The hardware multiplies the table byte by 2 before adding it to pc. Storing halfword counts lets a byte table reach 510 bytes ahead.
Key takeaways
Section titled “Key takeaways”- The CPU runs instructions, not C. Fault addresses, timing, and
volatilecorrectness can only be verified in the listing. arm-none-eabi-gcc -Sshows the compiler’s output;arm-none-eabi-objdump -d -Sshows final addresses with source interleaved;nm,size, andreadelf -Sshow what is in the ELF and where.r0-r3carry arguments and the return value,r4-r11are preserved across calls,lrholds the return address, andpush {..., lr}/pop {..., pc}frame non-leaf functions.- Condition suffixes carry signedness:
lt/gtare signed,lo/hiare unsigned. - Large constants and addresses come from literal pools via PC-relative
ldr;ldr r0, =valueasks the assembler to arrange that. - A struct field access is a load or store at a fixed offset from a base register, which is exactly how register structs map onto hardware.
- Inline assembly needs
volatileand, for anything touching interrupt state or ordering, a"memory"clobber.
Next: Memory-mapped registers in C, where these loads and stores meet real peripheral addresses.