From C to Bits: Everything Becomes an Instruction
Rodolfo Azevedo
Institute of Computing, University of Campinas (UNICAMP), Brazil
rodolfo.azevedo@unicamp.br
http://www.ic.unicamp.br/~rodolfo/mo801
Goal of this class
Module 2, Class 5: the compiler's job, reading disassembly, measuring performance, and the first KWS cycle estimate.
Every line of C — an
if, afor, a function call, an array access — becomes a sequence of RV32I instructions. Understanding that mapping is what lets you predict and measure performance, and it is what motivates the rest of this course.
At the end of this class, you should be able to:
- Trace how C constructs (conditionals, loops, function calls, array accesses) map to RV32I instruction sequences.
- Read and interpret RISC-V disassembly output from
objdump -d. - Estimate execution time from instruction count, instruction mix, and CPI.
- Measure cycle counts on real hardware using the
rdcycleCSR and explain what the number means.
The compilation chain
At every stage, you can inspect the output. The disassembly is your most powerful debugging tool — it shows exactly what your processor will execute.
Startup code: crt0.S
Before main() runs, the processor needs a small assembly stub to set up the C runtime environment. In a bare-metal RV32I system, there is no OS to do this:
Without this: sp is undefined (garbage), .bss variables have random values, main() is never called.
The Makefile compiles crt0.S alongside all C files: $(CC) crt0.S main.c -o main.elf.
Linker script: placing code in memory
A linker script tells the linker where each section of the ELF binary lives in the target's address space:
Harvard architecture implication: there is no path from IMEM to DMEM at runtime. Traditionally, .rodata (string literals, const arrays) lives in flash and is read via the instruction bus — but here, our IMEM only executes instructions. All data — even constants — must be in DMEM from the start. The linker script enforces this by placing .rodata in the DMEM region.
ELF sections: what goes where
An ELF binary has named sections. The ones that matter for bare-metal:
| Section | Content | In binary? | In memory? |
|---|---|---|---|
.text |
Machine instructions | ✅ Yes | IMEM |
.rodata |
const arrays, string literals |
✅ Yes | DMEM |
.data |
Initialised global variables | ✅ Yes | DMEM |
.bss |
Uninitialised / zero-init globals | ❌ No (just size) | DMEM (zeroed by crt0.S) |
.stack |
Stack space | ❌ No | DMEM (reserved by linker) |
.bss is not stored in the binary because it is always zero — crt0.S zeroes the memory region at startup. This is why uint32_t arr[1024]; (global, uninitialized) adds 4KB to the memory footprint but not to the .bin file size.
static linkage: one copy per translation unit
A subtle C pitfall when sharing large arrays between files:
Because the array is static, each .c file that #includes weights.h gets its own private copy. With two files including it: 8 KB used instead of 4 KB. With five files: 20 KB. In a processor with 4 KB of DMEM, this is fatal.
Fix: declare in the .h file with extern, define once in a .c file:
This bug was encountered in the course reference implementation: ~16 KB of KWS weights were duplicated across translation units, filling all available DMEM. Diagnosed via riscv-none-elf-nm main.elf | sort -k2 -rn (shows symbol sizes).
Reading a disassembly: simple arithmetic
C source:
Compiled with -O0 (no optimization):
18 instructions for a + b. The compiler with -O0 stores every variable on the stack and reloads it — there is no optimization.
The same function with -O2
2 instructions. Arguments arrive in a0/a1 (ABI convention), the result goes back in a0, and the compiler knows it does not need to spill anything to the stack. The optimization level is not a minor tuning knob — it changes the structure of the code.
The lesson: performance estimates based on C source are meaningless without knowing the optimization level. Always measure on compiled binaries.
What the compiler generates for common patterns
| C construct | Typical RV32I instructions |
|---|---|
a + b, a - b |
add, sub (1 instruction) |
a * b (no M ext.) |
Library call: dozens of instructions |
a[i] |
slli, add, lw (index × 4 + base → load) |
if (a < b) |
blt or slt + beq |
for (i=0; i<N; i++) |
Initialize → bge/blt loop back |
| Function call | jal ra, target |
| Return | jalr x0, ra, 0 (alias: ret) |
struct field access |
lw with immediate offset |
Key insight: memory access is the bottleneck. a[i] is always at least two instructions (address compute + load). A convolution over a tensor is mostly loads and stores surrounding a single add.
The cycle counter: measuring elapsed time
The RV32I privileged spec defines a mcycle CSR (Control and Status Register) — a 64-bit counter incremented every clock cycle. Reading it before and after a computation gives you the exact cycle count:
For Project 1: implement mcycle as a 32-bit register that increments every cycle (the low 32 bits are sufficient for most measurements at 27 MHz).
Case study: int8 dot product cycle count
A dot product of two 8-element int8 arrays, compiled with -O0 on your RV32I core (no M extension):
With -O2 and the M extension (mul instruction):
A single mul instruction vs. a software multiply routine: 8–10× speedup from the extension alone.
KWS inference cycle estimate
The tiny_conv model for keyword spotting:
* Input: 49×40 = 1,960 int8 values (MFCC features)
* Conv2D layer: 49×40 input, kernel K×K, N filters (exact dims from reference impl.)
* Each output value: K×K×C_in multiply-accumulate (MAC) operations
* Total model MACs: several hundred thousand (dominated by the Conv2D layer)
Cycle estimate on your Project 1 core (-O0, no Zmmul), at ~50 cycles/MAC:
$$\text{Total MACs} \times 50 \text{ cycles/MAC} = \text{several million cycles}$$
At 27 MHz: several hundred milliseconds per inference. KWS needs to respond within 1 second. With a naive implementation on a plain RV32I core, you may already be close to the budget.
This is why Project 3 exists.
In-class exercise — predict before you measure
Before running the compiler, predict the cycle count for this function with -O0 and with -O2:
With n = 16 on your RV32I core (no M extension, no cache):
-O0 |
-O2 |
Ratio | |
|---|---|---|---|
| Your prediction (cycles) | |||
Measured with rdcycle |
Think through:
1. With -O0, how many instructions does one loop iteration generate? (Hint: load, sign-extend cast, multiply via mul-absent fallback, add, increment, branch — count them.)
2. With -O2, which instructions does the compiler eliminate or combine?
3. If you had the M extension (mul instruction): how many cycles would i * j cost?
After Lab 3, you will fill in the "Measured" row. Keep your prediction — comparing it to the measurement is more valuable than the number itself.
The roofline preview
A useful mental model: performance is bounded by either compute or memory bandwidth.
- Low arithmetic intensity: touching many bytes per MAC → memory bandwidth is the bottleneck.
- High arithmetic intensity: reusing data heavily → compute is the bottleneck.
A dot product reuses nothing — arithmetic intensity ≈ 0.5 MACs/byte. A matrix multiply reuses rows and columns — intensity = O(N). We will revisit this in Module 5 when designing the accelerator.
Lab 3 out — measure -O0 vs. -O2 on the board
Goal: measure the cycle count of a multiply loop on your Tang Nano 9K running Project 1.
Report:
1. Cycle count with -O0 (no optimization).
2. Cycle count with -O2.
3. Ratio and explanation: which instructions disappeared and why.
4. (Optional) Add the M extension (mul instruction) and report the speedup.
Next module
Module 3 — HW/SW Interfaces: once you can run C programs on your core, the next question is how software talks to hardware peripherals — memory-mapped I/O, a minimal on-chip bus, and Zmmul + CMAC as examples of ISA extension and custom instruction. Project 2 begins.