C → Assembly: Optimizations, Volatile, and What the Compiler Is Allowed to Do

TL;DR


1. The compiler pipeline (why there are multiple “translations”)

flowchart TD
  A["C source (.c)"] --> B["Frontend to IR (Intermediate Representation)"]
  B --> C["Optimizer (depends on -O level)"]
  C --> D["Backend to assembly (.s)"]
  D --> E["Assembler to object (.o)"]
  E --> F["Linker to ELF (.elf)"]

.

Two consequences:


2. Hands-on lab: one program, many optimization levels

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/opt.c
#include "types.h"
#include "uart.h"

volatile u32 sink;

u32 f(u32 x) {
  u32 a = x * 3u;
  u32 b = x * 3u;      // same expression as a
  u32 c = a + b;

  if ((c & 1u) == 0u) {
    // looks like it matters...
    c += 10u;
  }

  // store result somewhere observable
  sink = c;
  return c;
}

int main(void) {
  u32 r = f(7u);
  uart_puts("f(7)=");
  uart_puthex32(r);
  uart_putc('\n');
  return 0;
}

Build two variants:

1
2
3
4
5
riscv64-unknown-elf-gcc -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -O0 \
  -T src/link.ld src/start.s src/uart.c src/opt.c -o build/opt_O0.elf

riscv64-unknown-elf-gcc -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -O2 \
  -T src/link.ld src/start.s src/uart.c src/opt.c -o build/opt_O2.elf

Run both:

1
2
3
4
5
// Run, check output and use CTRL+a x to exit;
qemu-system-riscv32 -M virt -nographic -bios none -kernel build/opt_O0.elf

// Run, check output and use CTRL+a x to exit;
qemu-system-riscv32 -M virt -nographic -bios none -kernel build/opt_O2.elf

Disassemble both:

1
2
3
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/opt_O0.elf | less

riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/opt_O2.elf | less

What to look for


3. Why variables disappear in optimized builds

Register allocation

At -O2, the compiler tries to keep values in registers and may never materialize them in memory.

Lifetime shrinking

If a variable’s value is used only briefly, it may never exist as a named location.

Inlining

Small functions are often replaced by their body.


4. volatile means “must perform the access”

A volatile object tells the compiler:

This is critical for:

Hands-on: volatile vs non-volatile

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/volatile_demo.c
#include "types.h"
#include "uart.h"

u32 nv_reg;
volatile u32 v_reg;

u32 demo(u32 x) {
  nv_reg = x;
  nv_reg = x;     // might be merged

  v_reg = x;
  v_reg = x;      // must not be merged

  return nv_reg + v_reg;
}

int main(void) {
  u32 r = demo(0x1234u);
  uart_puts("demo=");
  uart_puthex32(r);
  uart_putc('\n');
  return 0;
}

Build optimized and disassemble:

1
2
3
4
5
riscv64-unknown-elf-gcc -g -ffreestanding \ 
  -nostdlib -march=rv32im -mabi=ilp32 -O2 \
  -T src/link.ld src/start.s src/uart.c src/volatile_demo.c -o build/vol_O2.elf

riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/vol_O2.elf | less

What you should observe:


5. Mapping C back to assembly (a practical method)

When you see assembly, ask:

  1. Where are inputs? (usually a0..a7)
  2. Where does the return value go? (usually a0)
  3. Which registers must survive calls? (callee-saved s*)
  4. Which memory stores are observable? (volatile, globals, function calls)

Use compiler-generated assembly as a “bridge”

Generate .s output:

1
2
3
4
5
riscv64-unknown-elf-gcc -S -O0 -ffreestanding \
  -nostdlib -march=rv32im -mabi=ilp32 -o build/opt_O0.s src/opt.c

riscv64-unknown-elf-gcc -S -O2 -ffreestanding \
  -nostdlib -march=rv32im -mabi=ilp32 -o build/opt_O2.s src/opt.c

Compare build/opt_O0.s and build/opt_O2.s.


Exercises

  1. Modify opt.c so sink is not volatile. Predict what changes in -O2.
  2. Add a uart_putc (or any external call) and observe how it “pins” values (calls are optimization barriers).
  3. Write two functions: one tiny, one large. Observe when the tiny one is inlined.

How to test your answers


Summary

You learned what optimizations do, why debugging optimized code can be confusing, and what volatile truly guarantees.

Next: control flow and data access-you’ll learn how if/loops/switch become branches and jump tables, and how loads/stores encode addressing.