Skip to content

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.

Four situations come up every week in real firmware work:

  • Fault debugging. A HardFault handler gives you the stacked pc. arm-none-eabi-objdump -d tells you which instruction that is, and the instruction tells you what went wrong: a ldr through a null pointer, a str to an address with no peripheral behind it, a udiv by zero with the trap enabled.
  • Verifying volatile and 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.

There are two views of the same code: the compiler’s assembly output before assembling, and the disassembly of the final object or ELF.

Terminal window
# Compiler output: assembly source, with directives and labels
arm-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 uses
arm-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.lst

The -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, %function
led_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_on

Lines 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 0x40020c14

Each 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”:

Terminal window
arm-none-eabi-nm -n firmware.elf # symbols sorted by address
arm-none-eabi-size firmware.elf # text / data / bss totals
arm-none-eabi-readelf -S firmware.elf # section headers
$ arm-none-eabi-nm -n firmware.elf
08000000 R vector_table
08000188 T led_on
08000198 T main
080001c4 W NMI_Handler
20000000 D g_mode
20000004 B g_ticks
20020000 A _estack

The 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.elf

text 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 Cortex-M4 has sixteen 32-bit core registers visible to normal code:

RegisterAliasRole under the AAPCS calling convention
r0-r3Arguments and return value; scratch, not preserved across calls
r4-r11Callee-saved: a function that uses them must restore them
r12ipScratch (intra-procedure call register)
r13spStack pointer
r14lrLink register: return address after bl
r15pcProgram 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:

BitFlagSet when
31NResult is negative (bit 31 set)
30ZResult is zero
29CCarry out (for subtraction and compare: no borrow)
28VSigned overflow
27QSaturation 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.

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 s suffix (adds, movs, lsls) means the instruction updates the flags. Many 16-bit encodings always set flags, which is why the compiler emits movs r0, #0 rather than mov r0, #0: it is two bytes instead of four, and nothing cares about the flags.
  • .w forces the 32-bit encoding, .n the 16-bit one. objdump shows .w when it disassembles a 32-bit encoding that has a 16-bit sibling.
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.

Arm is a load/store architecture: arithmetic happens only in registers, and memory is touched only by load and store instructions.

InstructionMeaning
ldr r0, [r1]r0 = *(uint32_t *)r1
ldrb r0, [r1] / ldrhLoad byte / halfword, zero-extend to 32 bits
ldrsb r0, [r1] / ldrshLoad byte / halfword, sign-extend
str r0, [r1] / strb / strhStore 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], #4Post-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.

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 encoding

The = 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.

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 * 3

bic (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.

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 branch

The condition suffix is the part you must learn to read fluently, because it carries the signedness of the original C comparison:

SuffixMeaning after cmp a, bFlags
eq / nea == b / a != bZ set / clear
lt / gesigned a < b / a >= bN != V / N == V
gt / lesigned a > b / a <= bZ == 0 && N == V / opposite
lo (cc) / hs (cs)unsigned a < b / a >= bC clear / set
hi / lsunsigned a > b / a <= bC && !Z / opposite
mi / plresult negative / non-negativeN 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 and n == 0 early exits.
  • IT blocks: it gt followed by movgt r0, r2 means “execute the next instruction only if gt”. Up to four instructions can be predicated (ite, itt, itte, and so on: t for then, e for else). This removes short branches entirely.
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 instruction

bl 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.

InstructionWhat it does
nopNothing, one cycle (timing padding, breakpoint targets)
bkpt #0Halt into the debugger; HardFault if no debugger is attached
wfiSleep until an interrupt arrives
cpsid i / cpsie iDisable / enable interrupts (set / clear PRIMASK)
mrs r0, primask / msr primask, r0Read / write a special register
dmb, dsb, isbMemory and instruction barriers
ldrex / strexExclusive 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.

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.

#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 lr

The 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.

#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 lr

blt 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.

#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, return

x 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.

#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 lr

A 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.

#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 1073875988

Without 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.

#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 lr

One 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.

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 to sp”. %0 in the template is replaced by that register.
  • "r" (value) is an input in any register.
  • volatile on the __asm stops 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 your cpsid 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.

  • Reading -O0 output 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-asm or objdump -S to see which C line each block came from.
  • Treating blt and blo as 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 every cpsid, cpsie, msr, dsb, and wfi wrapper.
  • Disassembling an object compiled without -g and 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 pc reads as the instruction address plus 4, aligned down to 4, before adding the offset.
  1. Compile sum from this lesson with -O0 -S and with -O2 -S. Count the instructions inside the loop in each. At -O0 you should see i, s, and p loaded from and stored to [sp, #...] on every iteration; at -O2 the loop is four instructions.
  2. Build led_on into an object file with -c -g and run arm-none-eabi-objdump -d -S on it. Find the literal pool word and verify by hand that the PC-relative offset in the ldr instruction points at it, using the “address plus 4, aligned down to 4” rule.
  3. Write uint32_t div10(uint32_t x) { return x / 10u; } and compile at -O2. You will likely see no udiv at all, but a umull with the constant 0xCCCCCCCD followed 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.
  4. Take dispatch and remove cases one at a time until GCC stops emitting tbb and uses a chain of cmp/beq instead. 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.
  5. Put read_sp() and get_primask() in a small file, call them from main, and disassemble. Confirm each compiles to a single mov or mrs with no function call. Then remove volatile from the __asm in get_primask and call it twice in a row, discarding the first result. Does the compiler delete the first mrs? Explain why.

Question 1:What does ldr r3, [r2], #4 do?

Question 2:You see cmp r0, r1 followed by blo .L5. Which C comparison most likely produced it?

Question 3:Why does caller start with push {r4, lr}?

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?

Question 5:How does ldr r0, =0x40020C00 get an arbitrary 32-bit constant into r0?

Question 6:In a tbb jump table the entries are written as (.L8-.L4)/2. Why divide by 2?

  • The CPU runs instructions, not C. Fault addresses, timing, and volatile correctness can only be verified in the listing.
  • arm-none-eabi-gcc -S shows the compiler’s output; arm-none-eabi-objdump -d -S shows final addresses with source interleaved; nm, size, and readelf -S show what is in the ELF and where.
  • r0-r3 carry arguments and the return value, r4-r11 are preserved across calls, lr holds the return address, and push {..., lr} / pop {..., pc} frame non-leaf functions.
  • Condition suffixes carry signedness: lt/gt are signed, lo/hi are unsigned.
  • Large constants and addresses come from literal pools via PC-relative ldr; ldr r0, =value asks 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 volatile and, 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.