The RISC-V Cross-Compilation Toolchain
A cross-compilation toolchain is a compiler, assembler, linker and object-file kit that runs on one machine (the host) and produces code for a different one (the target). For a from-scratch RV32 system on a chip there is no other option: the target has no operating system, no filesystem and — at Stage 0 — no working processor to run a compiler on. The confusing part for a beginner is that the toolchain’s name and the toolchain’s output are only loosely coupled. On the machine this note was written on, the only installed RISC-V compiler is
riscv64-linux-gnu-gcc— a 64-bit, Linux-hosted compiler — and it nonetheless emits perfectly good 32-bit bare-metal RV32IMC, because the register width and the C runtime are selected by-march,-mabiand a handful of link flags, not by the program’s name. This note works through what each of those knobs actually controls, using real commands and real output from GCC 16.1.1 20260501 (Red Hat Cross 16.1.1-1), GNU binutils 2.46-1.fc44, clang 22.1.8 and rustc 1.98.0, and ends with the only thing that matters: how to check that what came out is what you asked for, rather than assuming it.The output of this rung is not a program. It is the ability to answer, for any binary you produce for the rest of the project, three questions: which instructions are in it, where will they be placed, and what does it expect the runtime to have already done for it. Those three questions are exactly what Linker Scripts and Memory Layout, Boot ROM and the Reset Vector and Bare-Metal Rust each answer one piece of.
Mental Model — Four Programs, Four Artifacts
The word “compiler” hides four distinct programs, and every confusing toolchain error message comes from not knowing which of the four produced it. gcc is a driver: it does no compiling itself, it runs cc1, as, and collect2/ld in sequence and passes each of them a different subset of your flags. That is why -march produces a cc1: error: while -nostdlib produces a ld: cannot find — they are complaints from different programs.
flowchart LR C["main.c<br/>C source"] -->|"cc1<br/>-march -mabi -O -ffreestanding"| S["main.s<br/>RISC-V assembly"] S -->|"as<br/>-march (validates opcodes)"| O["main.o<br/>ELF32 relocatable<br/>e_flags set here"] ASM[".S source<br/>start.S"] -->|"cpp then as"| O2["start.o"] O --> L{"ld / collect2"} O2 --> L LGCC["libgcc.a<br/>__udivsi3 __muldi3"] -.->|"only if -lgcc"| L LDS["link.ld<br/>MEMORY + SECTIONS"] -->|"-T"| L L -->|"resolves symbols<br/>assigns addresses"| E["fw.elf<br/>ELF32 executable<br/>VMA + LMA fixed"] E -->|"objcopy -O binary"| B["fw.bin<br/>raw image"] E -->|"objcopy -O verilog"| H["fw.hex<br/>for $readmemh"] E -->|"objdump readelf nm size"| I["inspection<br/>did I get what I asked for?"]
The four stages of a bare-metal build and what each consumes and emits. What it shows: -march/-mabi are consumed twice — once by the compiler proper, which decides which instructions to generate, and once by the assembler, which decides which instructions it will accept; the linker never sees them, it sees only the ELF e_flags word the assembler stamped into each object. The insight to take: the arrows into ld are the whole of bare-metal building. A hosted build has three more inputs there (crt1.o, libc.a, a default linker script) that are supplied silently; a bare-metal build’s job is to refuse all three and supply start.S and link.ld in their place. Everything in the rest of this note is about one of those arrows.
Two facts about this diagram are worth stating explicitly because they are the source of most beginner confusion. First, the ELF flags word is written by the assembler, not the linker — by the time ld runs, the ABI decision is already baked into every .o and ld’s only job is to check they all agree. Second, the addresses are assigned by the linker and by nothing else; cc1 emits position-relative code and symbolic references, and until -T link.ld says otherwise, the compiler has no idea your ROM starts at 0x80000000.
The Target Triple, and Why a riscv64 GCC Builds RV32 Code
A GNU target triple is a string of the form <arch>-<vendor>-<os>-<libc/abi> that names what a toolchain was configured to produce. The installed compiler reports its own:
$ riscv64-linux-gnu-gcc -dumpmachine
riscv64-linux-gnuRead that as: architecture riscv64, vendor elided, OS linux, C library gnu (glibc). The riscv-gnu-toolchain README documents the two families it can build: “The bare-metal (Newlib, Picolibc) tools are prefixed riscv64-unknown-elf-, the Linux tools riscv64-unknown-linux-{gnu,musl,uclibc}-.” The -elf triple is the “correct” one for this project; -linux-gnu is what a distribution ships, because distributions cross-build Linux userspace.
Here is the point that trips people up. The triple fixes the defaults, not the capability. The riscv64 in the name means “if you pass no -march/-mabi, you get RV64GC with the LP64D ABI” — nothing more. Demonstrated:
$ riscv64-linux-gnu-gcc -c main.c -o def.o && file def.o
def.o: ELF 64-bit LSB relocatable, UCB RISC-V, RVC, double-float ABI, version 1 (SYSV), not stripped
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -c main.c -o main.o && file main.o
main.o: ELF 32-bit LSB relocatable, UCB RISC-V, RVC, soft-float ABI, version 1 (SYSV), not strippedSame binary, same driver, two different machines. GCC’s RISC-V back end is a single back end parameterized by XLEN; -march=rv32* sets XLEN to 32 and the code generator, the assembler and the ELF writer all follow. Confirm from the compiler’s own predefined macros — this is the definitive check, and it is the macro every piece of portable RISC-V assembly keys off (riscv-tests uses #if __riscv_xlen == 64 throughout):
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -dM -E -x c /dev/null | grep __riscv
#define __riscv 1
#define __riscv_arch_test 1
#define __riscv_c 2000000
#define __riscv_cmodel_medlow 1
#define __riscv_compressed 1
#define __riscv_div 1
#define __riscv_float_abi_soft 1
#define __riscv_i 2001000
#define __riscv_m 2000000
#define __riscv_misaligned_slow 1
#define __riscv_mul 1
#define __riscv_muldiv 1
#define __riscv_xlen 32
#define __riscv_zca 1000000
#define __riscv_zmmul 1000000What the triple does constrain is the runtime libraries that were built alongside the compiler, and this is where a Linux-hosted cross-compiler and a bare-metal one genuinely differ. Ask GCC which library variants it has:
$ riscv64-linux-gnu-gcc --print-multi-lib
.;
lib32/ilp32;@march=rv32imac@mabi=ilp32
lib32/ilp32d;@march=rv32imafdc@mabi=ilp32d
lib64/lp64;@march=rv64imac@mabi=lp64
lib64/lp64d;@march=rv64imafdc@mabi=lp64d
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -print-libgcc-file-name
/usr/lib/gcc/riscv64-linux-gnu/16/lib32/ilp32/libgcc.aThat is a multilib build: one compiler, several sets of runtime libraries, one per -march/-mabi combination. Note that -march=rv32imc is served by the rv32imac variant — GCC’s multilib reuse rules map a requested ISA onto the nearest superset variant that was actually built. So libgcc.a — the compiler’s own helper library, holding routines like __udivsi3 and __muldi3 — does exist for RV32 on this machine. What does not exist is glibc for RV32, which is exactly what a hosted link discovers:
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 main.c -o main.elf
/usr/bin/riscv64-linux-gnu-ld: cannot find crt1.o: No such file or directory
/usr/bin/riscv64-linux-gnu-ld: cannot find -latomic_asneeded: No such file or directory
/usr/bin/riscv64-linux-gnu-ld: cannot find -lc: No such file or directory
collect2: error: ld returned 1 exit statusFor a bare-metal project this error is not a problem to fix — it is the toolchain telling you that you were about to link a C runtime you do not want. The fix is to stop asking for it, which is the subject of a later section. The practical conclusion for the definitely-not-esp32 build: you do not need to spend six hours building riscv-gnu-toolchain to start Stage 0. A distribution riscv64-linux-gnu-gcc compiles, assembles, links and disassembles RV32IMC bare-metal code correctly today. You will want the -elf toolchain later, when you want a C library that is not glibc — and that is a Stage 5 concern, not a Stage 0 one.
Uncertain
Verify: that every distribution’s
riscv64-linux-gnucross-GCC ships anlib32/ilp32multilib. Reason: the--print-multi-liboutput above was measured on exactly one package,gcc-riscv64-linux-gnu-16.1.1-1.fc44on Fedora 44; multilib selection is a build-time--with-multilib-generator=decision made by each packager, and the riscv-gnu-toolchain README notes that “Multilib is only available for the Newlib and Linux/glibc toolchains”. To resolve: run--print-multi-libon the machine in question before relying on-lgccbeing available for RV32. If it is absent, the fallback is to avoid the operations that need libgcc (64-bit multiply, division without the M extension) or to build the-elftoolchain. uncertain
-march and -mabi Are Two Independent Choices
This is the single most important distinction in the note. -march names an instruction set: what the hardware must be able to execute. -mabi names a calling convention: how functions pass arguments and return values to each other. They are related but not the same choice, and the failure mode when you conflate them is silent data corruption rather than a compile error.
GCC’s own documentation states the split plainly (GCC RISC-V Options):
Specify integer and floating-point calling convention. ABI-string contains two parts: the size of integer types and the registers used for floating-point types. […] The default for this argument is system dependent; if you want a specific calling convention you should specify one explicitly.
Read ilp32 as int, long and pointers are 32 bits, and no floating-point values are passed in F registers. Read lp64d as long and pointers are 64 bits (int stays 32), and floating-point values up to 64 bits wide are passed in F registers. The d/f suffix is about argument passing, not about whether the FPU exists.
flowchart TD START["choose -march<br/>= what the silicon executes"] --> XLEN{"rv32* or rv64*?"} XLEN -->|rv32| A32["ABI must be ilp32 family"] XLEN -->|rv64| A64["ABI must be lp64 family"] A32 --> F32{"does -march include F / D?"} A64 --> F64{"does -march include F / D?"} F32 -->|"neither"| OK1["ilp32 only<br/>soft-float ABI"] F32 -->|"F only"| OK2["ilp32 or ilp32f"] F32 -->|"F and D"| OK3["ilp32, ilp32f or ilp32d"] F64 -->|"neither"| OK4["lp64 only"] F64 -->|"F only"| OK5["lp64 or lp64f"] F64 -->|"F and D"| OK6["lp64, lp64f or lp64d"] OK1 --> RULE["rule: the ABI may ask for<br/>LESS than the ISA provides,<br/>never MORE"] OK3 --> RULE OK6 --> RULE BAD1["rv32imc + lp64<br/>cc1: ABI requires '-march=rv64'"]:::err BAD2["rv64imc + ilp32<br/>cc1: ABI requires '-march=rv32'"]:::err BAD3["rv32imc + ilp32d<br/>cc1: requested ABI requires<br/>'-march' to subsume 'D'"]:::err classDef err fill:#fdd,stroke:#c33
Which -march/-mabi pairs GCC accepts, with the three rejection messages measured verbatim. What it shows: the XLEN halves must match exactly, and the float-ABI suffix must be a subset of the float extensions in -march. The insight to take: the arrow is one-directional. -march=rv32imfdc -mabi=ilp32 is perfectly legal — you have an FPU and you choose not to use it for argument passing, which is what you want when linking against a soft-float library. The reverse, promising an ABI the hardware cannot honour, is the error.
Every rejection above was measured, not recalled:
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=lp64 -c main.c -o /dev/null
cc1: error: ABI requires ‘-march=rv64’
$ riscv64-linux-gnu-gcc -march=rv64imc -mabi=ilp32 -c main.c -o /dev/null
cc1: error: ABI requires ‘-march=rv32’
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32d -c main.c -o /dev/null
cc1: error: requested ABI requires ‘-march’ to subsume the ‘D’ extension
$ riscv64-linux-gnu-gcc -march=rv32imfc -mabi=ilp32f -c main.c -o /dev/null # acceptedThe RISC-V psABI gives the same rule from the specification side — “The LP64* ABIs are only compatible with RV64* ISAs. The ILP32* are compatible with RV32* and RV64* ISAs” — and defines each named ABI in terms of the two ELF fields that encode it:
| ABI name | ELF class | Float-ABI e_flags bits | Meaning |
|---|---|---|---|
ilp32 | ELFCLASS32 | EF_RISCV_FLOAT_ABI_SOFT (0x0000) | 32-bit pointers, no FP args in registers |
ilp32f | ELFCLASS32 | EF_RISCV_FLOAT_ABI_SINGLE (0x0002) | as above, float args in fa0–fa7 |
ilp32d | ELFCLASS32 | EF_RISCV_FLOAT_ABI_DOUBLE (0x0004) | as above, double args in fa0–fa7 |
ilp32e | ELFCLASS32 | soft + EF_RISCV_RVE (0x0008) | RV32E: only x0–x15, six argument registers |
lp64 / lp64f / lp64d / lp64q | ELFCLASS64 | soft / single / double / quad (0x0006) | 64-bit long and pointers |
Named ABIs and their ELF encoding, from the psABI’s “Named ABIs” section. What it shows: the ABI is not metadata bolted on the side — it is two bits of e_flags plus EI_CLASS, which is precisely what makes it linker-checkable. The insight to take: EF_RISCV_RVC (0x0001) sits in the same word and is set when the object contains compressed instructions, which is why every -march=rv32imc object in this note reports Flags: 0x1, RVC, soft-float ABI.
The most instructive demonstration is a mismatch that the ISA cannot detect but the ABI can. Compile two files with identical -march and different -mabi:
$ riscv64-linux-gnu-gcc -march=rv32imfc -mabi=ilp32 -c f1.c -o f1.o # calls scale()
$ riscv64-linux-gnu-gcc -march=rv32imfc -mabi=ilp32f -c f2.c -o f2.o # defines scale()
$ riscv64-linux-gnu-readelf -h f1.o | grep Flags
Flags: 0x1, RVC, soft-float ABI
$ riscv64-linux-gnu-readelf -h f2.o | grep Flags
Flags: 0x3, RVC, single-float ABIThe caller marshals its two float arguments into the integer registers:
00000000 <go>:
c: 0007a707 flw fa4,0(a5) # load the constants into F registers
14: 0007a787 flw fa5,0(a5)
18: e00705d3 fmv.x.w a1,fa4 # <-- move them OUT to a1
1c: e0078553 fmv.x.w a0,fa5 # <-- and a0, because ilp32 says so
20: 00000097 auipc ra,0x0 # call scaleThe callee reads them from the floating-point registers:
00000000 <scale>:
8: fea42627 fsw fa0,-20(s0) # <-- expects them in fa0
c: feb42427 fsw fa1,-24(s0) # <-- and fa1, because ilp32f says so
18: 10f777d3 fmul.s fa5,fa4,fa5Both fragments are legal RV32IMFC. A processor would execute both without a fault and scale would multiply two uninitialised registers. The only thing standing between you and that bug is the ELF flags check:
$ riscv64-linux-gnu-gcc -march=rv32imfc -mabi=ilp32 -nostdlib -nostartfiles -Wl,-e,go f1.o f2.o -o mix.elf
/usr/bin/riscv64-linux-gnu-ld: f2.o: can't link single-float modules with soft-float modules
/usr/bin/riscv64-linux-gnu-ld: failed to merge target specific data of file f2.oThat is the whole argument for why the psABI bothers to encode the ABI in e_flags, and why the psABI says flatly that “Linking different ABIs’ code together is not supported.”
The -march string is more than a list of letters
Two further behaviours of -march bite bare-metal projects specifically. First, GCC canonicalises and versions the string, and you can read the result back out of the object’s RISC-V attributes section:
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -c main.c -o a.o
$ riscv64-linux-gnu-readelf -A a.o
Attribute Section: riscv
File Attributes
Tag_RISCV_stack_align: 16-bytes
Tag_RISCV_arch: "rv32i2p1_m2p0_c2p0_zmmul1p0_zca1p0"rv32imc expands to base I version 2.1, M 2.0, C 2.0, plus the implied Zmmul (multiply-without-divide) and Zca (the integer subset of C). Note what is not there: Zicsr. Since GCC’s default -misa-spec=20191213, control-and-status-register access and fence.i are separately-named extensions rather than part of the base I, and the assembler enforces it:
$ riscv64-linux-gnu-gcc -march=rv32i -mabi=ilp32 -c csrtest.c -o /dev/null
csrtest.c:1: Error: unrecognized opcode `csrr a5,mhartid', extension `zicsr' required
csrtest.c:2: Error: unrecognized opcode `fence.i', extension `zifencei' required
$ riscv64-linux-gnu-gcc -march=rv32i_zicsr_zifencei -mabi=ilp32 -c csrtest.c -o /dev/null # accepted
$ riscv64-linux-gnu-gcc -misa-spec=2.2 -march=rv32i -mabi=ilp32 -c csrtest.c -o /dev/null # also acceptedFor definitely-not-esp32 this is a Stage 6 landmine placed at Stage 0: the moment you write a trap handler you must change -march=rv32imc to -march=rv32imc_zicsr, or the assembler will reject csrw mtvec, t0 with an error that names the extension but not the fix. See Zicsr Extension. The -misa-spec=2.2 escape hatch exists because the older 2.2 unprivileged specification folded Zicsr and Zifencei into I; riscv-tests sidesteps the whole question by building with -march=rv32g, which expands to rv32i2p1_m2p0_a2p1_f2p2_d2p2_zicsr2p0_zifencei2p0_... and therefore includes both.
Second, -mcmodel decides how the compiler forms addresses of static objects. The psABI defines medlow as producing a 32-bit address literal via lui+addi, covering “the whole RV32 address space”, and medany as producing a ±2 GiB PC-relative address via auipc. On RV32 medlow (the GCC default) is sufficient even for a ROM at 0x80000000; on RV64 it is not, which is why riscv-tests passes -mcmodel=medany unconditionally. The difference is visible in the generated code — the Stage-0 firmware below uses lui a5,0x80000; addi a5,a5,88 to form 0x80000058, which is medlow doing exactly what the psABI describes.
Refusing the C Runtime: -ffreestanding, -nostdlib, -nostartfiles
A hosted C program does not begin at main. It begins at _start in crt1.o, which sets up the stack, zeroes .bss, runs static constructors, calls __libc_start_main, and only then calls main — see _start and __libc_start_main for the Linux version of that dance. On bare metal none of that exists, and asking for it produces the cannot find crt1.o error shown earlier. The flags that refuse it are three separate switches with three separate meanings, and GCC’s Link Options documentation distinguishes them precisely:
| Flag | Removes startup files (crt1.o, crti.o, crtbegin.o…) | Removes standard libraries (-lc, -lgcc, …) | Stage it belongs to |
|---|---|---|---|
-nostartfiles | yes | no | you have your own _start but still want a libc |
-nodefaultlibs | no | yes | you have your own libc but want the standard crt*.o |
-nolibc | no | libc only, keeps libgcc | hosted-ish kernels |
-nostdlib | yes | yes | the bare-metal default |
-ffreestanding | — (compile-time, not link-time) | — | tells the compiler there is no hosted environment |
What each “refuse the runtime” flag actually removes, per the GCC manual. What it shows: -nostdlib is the union of -nostartfiles and -nodefaultlibs; -ffreestanding is in a different category entirely, since it affects code generation rather than linking. The insight to take: the row that costs people a day is -nostdlib removing libgcc.a as well as libc.a, which the manual warns about explicitly and which produces link errors for symbols you never wrote.
-ffreestanding is a language switch, not a linker one. The C standard, as quoted in GCC’s Standards chapter, defines two environments: a hosted one where “all the library facilities are provided and startup is through a function int main (void)”, and a freestanding one “where the handling of program startup and termination are implementation-defined”. The chapter names the exact case at hand: “An OS kernel is an example of a program running in a freestanding environment.” Practically, -ffreestanding stops the compiler from assuming that a function called printf behaves like the standard printf, and flips a macro you can test:
$ echo "__STDC_HOSTED__" | riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -E -P -x c -
1
$ echo "__STDC_HOSTED__" | riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -ffreestanding -E -P -x c -
0What -ffreestanding does not do is stop GCC calling memcpy, memset, memcmp and memmove. The manual is explicit that these are the documented exceptions: “The compiler may generate calls to memcmp, memset, memcpy and memmove. These entries are usually resolved by entries in libc. These entry points should be supplied through some other mechanism when this option is specified.” A struct assignment or an array initialiser can turn into a memcpy call at any optimisation level, so a no-libc project needs four hand-written functions with exactly those names in scope. This is the same trap no_std Rust hits, and one reason Bare-Metal Rust projects pull in a small compiler_builtins-adjacent crate.
The libgcc case is worth reproducing because the symptom is so opaque. Take a core without the M extension and divide:
$ riscv64-linux-gnu-gcc -march=rv32ic -mabi=ilp32 -O2 -c divtest.c -o d2.o
$ riscv64-linux-gnu-objdump -dr d2.o
00000000 <d>:
4: 00000097 auipc ra,0x0
4: R_RISCV_CALL_PLT __udivsi3
8: 000080e7 jalr raWith -march=rv32imc that same function is a single divu a0,a0,a1. Without M, GCC emits a call to a helper that lives in libgcc.a — and -nostdlib throws libgcc.a away:
$ riscv64-linux-gnu-gcc -march=rv32ic -mabi=ilp32 -O2 -ffreestanding -nostdlib -nostartfiles useit.c divtest.c -o out.elf
ld: divtest.c:(.text+0x4): undefined reference to `__udivsi3'
ld: divtest.c:(.text+0x16): undefined reference to `__muldi3'
$ riscv64-linux-gnu-gcc -march=rv32ic -mabi=ilp32 -O2 -ffreestanding -nostdlib -nostartfiles useit.c divtest.c -lgcc -o out.elf # linksThe GCC manual states the rule: “In most cases, you need libgcc.a even when you want to avoid other standard libraries. In other words, when you specify -nostdlib or -nodefaultlibs you should usually specify -lgcc as well.” Note also __muldi3 — a 64-bit multiply calls a helper even on RV32IMC, because mul/mulhu give you a 64-bit product of 32-bit operands, not a 64-bit × 64-bit multiply. A long long in your kernel is a libgcc dependency.
Which libc, If Any: newlib, picolibc, and Nothing
Three positions are defensible for an RV32 SoC project, and the right one changes as the project climbs the ladder.
| Option | What you get | What it costs | When it fits |
|---|---|---|---|
No libc (-nostdlib -lgcc + your own memcpy/memset/memcmp/memmove) | complete control; a .text measured in tens of bytes | you write everything, including printf | Stages 0–5: boot ROM, UART, first hello |
| picolibc | full C library sized for small RAM; per-thread errno via TLS costing “only 4 bytes”; BSD-style licence throughout | a --enable-picolibc toolchain build, and a handful of stubs (_write, _sbrk) | when you want printf("%d") on the UART without writing it |
| newlib | the traditional embedded libc; what riscv64-unknown-elf-gcc builds by default | a large _impure_ptr reentrancy structure per thread; needs the same syscall stubs | when a vendor SDK or an existing BSP already assumes it |
The three libc positions for a bare-metal RISC-V target. What it shows: the choice is really about how much of the C standard library you are willing to fund out of a few tens of kilobytes of block RAM. The insight to take: picolibc exists precisely because newlib’s per-thread reentrancy structure is expensive on a microcontroller — its README says it “was formed by blending code from Newlib and AVR Libc” and that its thread-local approach means “typically, this means you use only 4 bytes for errno”. For a microkernel with a handful of tasks, that difference is architectural, not cosmetic.
The riscv-gnu-toolchain build system exposes exactly this choice as five build targets — make (newlib), make picolibc, make linux (glibc), make musl, make uclibc — and warns that a full clone “takes around 6.65 GB of disk and download size” and needs “about 8 GiB of disk space to complete the process”. That cost is the reason to start on a distribution cross-compiler and defer the -elf toolchain until you actually want a libc. For the Rust half of the project the equivalent question does not arise: riscv32imc-unknown-none-elf is a no_std target with no C library at all, and rustup target add installs it in seconds.
The Inspection Kit: objdump, readelf, nm, size
These four programs are the entire Stage 0 deliverable. The rule that makes them useful is: never conclude anything about a binary that you have not read out of the binary.
| Tool | The one question it answers | The flag you will actually use |
|---|---|---|
file | did I get 32-bit or 64-bit, and which float ABI? | (none) |
readelf -h | entry point, ELF class, e_flags | — |
readelf -S / -l | which sections exist, at which VMA; which segments load, at which LMA | -S, -l |
readelf -A | the canonicalised, versioned -march string | — |
objdump -d | what instructions did I actually get? | -dr to see relocations too |
nm -n | where did each symbol land, in address order? | -n, -u for undefined |
size | how big is .text/.data/.bss? does it fit? | -A for per-section |
objcopy | turn the ELF into something a memory model can load | -O binary, -O verilog |
The bare-metal inspection kit. What it shows: each tool answers a different one of “what is in it / where does it go / how big is it”. The insight to take: nm -n is the underrated one. Sorting symbols by address is how you catch a linker script that placed a symbol in the wrong region — including the _sbss == _ebss bug demonstrated below, which no other tool makes obvious.
The MOC’s Stage 0 program is “compile int main(){return 42;} and disassemble it”. Here it is, with the toolchain named:
$ cat main.c
int main(void) { return 42; }
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -c main.c -o main.o
$ riscv64-linux-gnu-objdump -d main.o
main.o: file format elf32-littleriscv
Disassembly of section .text:
00000000 <main>:
0: 1141 addi sp,sp,-16
2: c606 sw ra,12(sp)
4: c422 sw s0,8(sp)
6: 0800 addi s0,sp,16
8: 02a00793 li a5,42
c: 853e mv a0,a5
e: 40b2 lw ra,12(sp)
10: 4422 lw s0,8(sp)
12: 0141 addi sp,sp,16
14: 8082 retRead the left column. 1141, c606, 853e, 8082 are four hex digits — 16-bit compressed instructions from the C extension. 02a00793 is eight — a 32-bit addi a5,x0,42. The compressed encoding cannot express a 12-bit immediate of 42 in the c.li form used here at -O0, so the assembler falls back to the 32-bit form; mv a0,a5 (853e = c.mv) and ret (8082 = c.jr ra) both compress. That mixture in one listing is the whole reason RV32IMC halves code size, and the reason the MOC insists the compressed decoder is the last thing you build: at Stage 2 you want -march=rv32i so every instruction is four bytes and your program counter only ever advances by 4.
The same file under clang 22.1.8, for comparison — same ISA, same ABI, different register allocation at -O0:
$ clang --target=riscv32 -march=rv32imc -mabi=ilp32 -c main.c -o main-clang.o
$ file main-clang.o
main-clang.o: ELF 32-bit LSB relocatable, UCB RISC-V, RVC, soft-float ABI, ...
$ llvm-objdump -d main-clang.o
00000000 <main>:
0: 1141 addi sp, sp, -0x10
8: 4501 li a0, 0x0
a: fea42a23 sw a0, -0xc(s0)
e: 02a00513 li a0, 0x2a
18: 8082 retWhat the Linker Actually Produced
Here is the artifact Stage 5 will need: a firmware image linked to a real memory map, with its own _start, no C runtime, and a UART write in it.
/* link.ld — first attempt. It has a bug; see below. */
OUTPUT_ARCH("riscv")
ENTRY(_start)
MEMORY {
ROM (rx) : ORIGIN = 0x80000000, LENGTH = 64K
RAM (rwx) : ORIGIN = 0x80010000, LENGTH = 64K
}
SECTIONS {
.text : { KEEP(*(.init)) *(.text .text.*) } > ROM
.rodata : { *(.rodata .rodata.*) } > ROM
_sidata = LOADADDR(.data) + SIZEOF(.data);
.data : { _sdata = .; *(.data .data.*) _edata = .; } > RAM AT > ROM
.bss (NOLOAD) : { _sbss = .; *(.bss .bss.*) *(COMMON) _ebss = .; } > RAM
_stack_top = ORIGIN(RAM) + LENGTH(RAM);
}$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -ffreestanding \
-nostdlib -nostartfiles -T link.ld -O2 start.S blink.c -o fw.elf
$ riscv64-linux-gnu-readelf -h fw.elf | grep -E "Class|Type|Entry|Flags"
Class: ELF32
Type: EXEC (Executable file)
Entry point address: 0x80000000
Flags: 0x1, RVC, soft-float ABIEntry point address: 0x80000000 is the whole point: it matches the reset vector the RTL will drive into the program counter. Now the sections, and the two program headers that matter:
$ riscv64-linux-gnu-readelf -S fw.elf
[ 1] .text PROGBITS 80000000 001000 000034 00 AX
[ 2] .note.gnu.bu[...] NOTE 80000034 001034 000024 00 A
[ 3] .srodata PROGBITS 80000058 001058 000008 00 A
[ 4] .eh_frame PROGBITS 80000060 001060 000028 00 A
[ 5] .eh_frame_hdr PROGBITS 80000088 001088 000014 00 A
[ 6] .sbss NOBITS 80010000 002000 000004 00 WA
$ riscv64-linux-gnu-readelf -l fw.elf
LOAD 0x001000 0x80000000 0x80000000 0x0009c 0x0009c R E 0x1000
LOAD 0x001000 0x80010000 0x8000009c 0x00000 0x00004 RW 0x1000flowchart TB subgraph FILE["fw.elf on disk / fw.bin in ROM"] H["ELF header + program headers"] T[".text 0x34 bytes<br/>_start, main"] RO[".srodata 8 bytes<br/>the string 'hi'"] CRUFT[".note.gnu.build-id + .eh_frame<br/>+ .eh_frame_hdr = 96 bytes of<br/>host-toolchain habit"] end subgraph MAP["address space at run time"] ROMR["ROM 0x80000000<br/>.text .rodata<br/>VMA == LMA"] RAMR["RAM 0x80010000<br/>.data (LMA in ROM)<br/>.bss (NOLOAD)<br/>stack grows down from 0x80020000"] end T --> ROMR RO --> ROMR CRUFT -->|"linked in anyway"| ROMR ROMR -.->|"crt0 copies .data<br/>from _sidata to _sdata"| RAMR ROMR -.->|"crt0 zeroes _sbss.._ebss"| RAMR
The layout of a bare-metal ELF and where each piece ends up. What it shows: the two-address idea that a hosted programmer never meets — a section has a VMA (the address the code will use) and an LMA (the address it is stored at). .data lives in RAM but is stored in ROM, which is what > RAM AT > ROM in the linker script means and what the second LOAD header records (VirtAddr 0x80010000, PhysAddr 0x8000009c). The insight to take: the two dashed arrows are code you have to write. Nothing copies .data or zeroes .bss for you; that is crt1.o’s job in a hosted build and yours here. See Linker Scripts and Memory Layout.
Two things in that listing are defects, and both are the kind of thing only inspection catches.
Defect one: 96 bytes of host-toolchain cruft. .note.gnu.build-id, .eh_frame and .eh_frame_hdr are orphan sections — sections the linker script never mentions, which ld places automatically. They exist for a Linux runtime that does exception unwinding and build-ID lookup. On a 64 KiB ROM they are 0.15% of your budget for nothing.
Defect two, and it is a real bug: _sbss == _ebss. Read nm -n:
$ riscv64-linux-gnu-nm -n fw.elf
80000058 r msg
8000009c A _sidata
80010000 B _ebss
80010000 B _edata
80010000 B _sbss
80010000 B _sdata
80010000 B counter
$ riscv64-linux-gnu-objdump -t fw.elf | grep counter
80010000 g O .sbss 00000004 countercounter — an uninitialised global — is at 0x80010000, and the _sbss.._ebss range a crt0 would zero is 0x80010000..0x80010000: empty. The reason is that RISC-V GCC puts small objects into .sdata/.sbss/.srodata (the “small data” sections, addressed cheaply relative to gp), and the linker script names only .bss and .rodata. The orphan-placement rule silently put .sbss somewhere plausible, so nothing failed loudly — the program links, boots, and reads garbage out of a global on a real ROM where RAM powers up dirty. Naming the small-data sections fixes both defects at once:
.rodata : { *(.rodata .rodata.*) *(.srodata .srodata.*) } > ROM
.data : { _sdata = .; *(.data .data.*) *(.sdata .sdata.*) _edata = .; } > RAM AT > ROM
.bss (NOLOAD) : { _sbss = .; *(.sbss .sbss.*) *(.bss .bss.*) *(COMMON) _ebss = .; } > RAM
/DISCARD/ : { *(.eh_frame) *(.eh_frame_hdr) *(.comment) *(.note.*) }$ riscv64-linux-gnu-nm -n fw2.elf | grep -E "_sbss|_ebss|counter"
80010000 B _sbss
80010000 B counter
80010004 B _ebss # <-- the range now covers counter
$ ls -l fw.bin fw2.bin
-rwxr-xr-x. 1 linman linman 156 fw.bin
-rwxr-xr-x. 1 linman linman 60 fw2.bin156 bytes to 60. The lesson generalises: a linker script that does not name a section does not fail — it guesses. Checking nm -n after every linker-script change is the habit this rung is supposed to install.
Getting the image into a simulator
Two objcopy outputs matter for Verilator. -O binary produces a flat image starting at the lowest load address, which a C++ testbench can fread into a memory array:
$ riscv64-linux-gnu-objcopy -O binary fw2.elf fw2.bin
$ xxd fw2.bin | head -2
00000000: 1701 0200 1301 0100 1120 01a0 b707 0080 ......... ......-O verilog produces text that Verilog’s $readmemh can consume directly, with @address directives at each discontinuity:
$ riscv64-linux-gnu-objcopy -O verilog --verilog-data-width=4 fw2.elf fw2.vh
$ cat fw2.vh
@20000000
00020117 00010113 A0012011 800007B7
...Uncertain
Verify: that
objcopy’s Verilog address directive is divided by--verilog-data-width. Reason: measured, not documented. The ELF’s lowest load address is0x80000000and the emitted directive is@20000000— exactly0x80000000 / 4— but the binutils objcopy manual says only that the option “controls the number of bytes converted for each output data element. The input target controls the endianness of the conversion”, and says nothing about the address. Measured with binutils 2.46-1.fc44. To resolve: readbfd/verilog.cin the binutils source, or check against a second binutils version. Practical consequence: an address directive in word units is exactly right for areg [31:0] mem [...]array based at zero and exactly wrong if you assumed bytes, so a loader that trusts the file’s addresses without--change-addresseswill silently place your image 4× too high or too low. The flat-binary route (-O binaryplus aod -An -tx4 -v | tr -s ' ' '\n'pipeline) avoids the question entirely and is what the harness in The riscv-tests Suite uses. uncertain
The LLVM Half of the Toolchain, and Rust’s Target List
LLVM ships a single cross-compiler for every target, so clang --target=riscv32 needs no separate installation. llvm-objdump -d disassembles the same ELF32 files GNU objdump does, and — usefully for Stage 2 debugging — decodes compressed instructions in a slightly more literal style (llvm-objdump on 952e gives add a0, a0, a1, i.e. the expansion of c.add). The two toolchains agree on e_flags, so their objects interlink.
Rust’s target names encode -march and -mabi in one string. The installed toolchain lists twenty-one RV32 targets; the ones that matter here:
$ rustc --print target-list | grep riscv32
riscv32i-unknown-none-elf
riscv32im-unknown-none-elf
riscv32imc-unknown-none-elf # <-- RV32IMC, no_std, ilp32
riscv32imac-unknown-none-elf
riscv32imc-esp-espidf # <-- the ESP32-C3 target
riscv32gc-unknown-linux-gnu
$ rustup target list --installed
riscv32imc-unknown-none-elf
x86_64-unknown-linux-gnuThat riscv32imc-unknown-none-elf and riscv32imc-esp-espidf differ only in the last two fields is a neat illustration of the triple’s structure: same ISA, same ABI, different runtime environment. The ESP32-C3 is the same RV32IMC machine this project is building.
A no_std Rust program compiles to an object indistinguishable in ABI terms from the GCC one:
$ rustc --target riscv32imc-unknown-none-elf -O -C panic=abort --emit=obj rmain.rs -o rmain.o
$ file rmain.o
rmain.o: ELF 32-bit LSB relocatable, UCB RISC-V, RVC, soft-float ABI, ...
$ riscv64-linux-gnu-objdump -d rmain.o
00000000 <_start>:
0: 10000537 lui a0,0x10000
4: 06800593 li a1,104 # 'h'
8: 00b50023 sb a1,0(a0)
c: 06900593 li a1,105 # 'i'
10: 00b50023 sb a1,0(a0)
14: 45a9 li a1,10 # '\n'
16: 00b50023 sb a1,0(a0)
1a: a001 j 1a <_start+0x1a>And it links with the GNU linker against the same linker script:
$ riscv64-linux-gnu-gcc -march=rv32imc -mabi=ilp32 -nostdlib -nostartfiles \
-T link2.ld -Wl,-e,_start rmain.o -o rust.elf
$ riscv64-linux-gnu-readelf -h rust.elf | grep -E "Entry|Flags"
Entry point address: 0x80000002
Flags: 0x1, RVC, soft-float ABI
$ riscv64-linux-gnu-size rust.elf
text data bss dec hex filename
30 0 0 30 1e rust.elfThirty bytes of .text for a working UART write loop, produced by rustc and placed by ld. That interoperability is the technical basis for Stage 8: the Rust microkernel and the assembly trap entry stub are separate objects joined by the linker, and the only thing that makes it work is that both stamped Flags: 0x1 into their ELF headers. Note the entry point: 0x80000002, not 0x80000000 — *(.text.*) matched the panic handler’s section before _start’s. If a boot ROM hard-wires the reset vector to 0x80000000, that two-byte offset is a hang. KEEP(*(.init)) first in the script is the fix, and readelf -h is how you notice. See Bare-Metal Rust.
Failure Modes and Common Misunderstandings
“I need riscv32-unknown-elf-gcc before I can start.” No. Every command in this note ran on a riscv64-linux-gnu compiler, including building and running the official The riscv-tests Suite. The name selects defaults; -march/-mabi override them. Build the -elf toolchain when you want newlib or picolibc, not before. (Note also that the canonical bare-metal prefix upstream is riscv64-unknown-elf-, not riscv32- — a 64-bit-configured toolchain with multilib is the normal way to build RV32, which is why the riscv-tests Makefile tries riscv$(XLEN)-unknown-elf- and falls back to the other XLEN.)
“The compiler accepted it, so the ABI must match.” It did not check. -march and -mabi disagreeing between two translation units is not a compile error — it is a link error, and only because the psABI put the float ABI in e_flags. If you ever bypass the linker’s check (objcopy a raw blob in, hand-assemble a stub), nothing will catch it and floats will arrive in the wrong register file.
“-nostdlib removes libc.” It removes libc and libgcc. The symptom is undefined reference to __udivsi3 or __muldi3 for code you never wrote. Add -lgcc after your objects.
“-ffreestanding means no library calls.” It means the compiler stops assuming standard semantics for standard names. It still emits memcpy/memset/memcmp/memmove calls, by explicit documented exception. Supply all four.
“-march=rv32imc gives me CSRs.” It does not. readelf -A shows rv32i2p1_m2p0_c2p0_zmmul1p0_zca1p0, with no zicsr. Use -march=rv32imc_zicsr the moment you write a trap handler, or -misa-spec=2.2 if you prefer the old bundling.
“The linker script covers .bss.” Only if you also name .sbss. RISC-V GCC’s small-data sections (.sdata, .sbss, .srodata) are separate section names and are placed as orphans if unmentioned — with the _sbss == _ebss result shown above, where the bss-clearing loop clears nothing and an uninitialised global reads whatever the RAM powered up with.
“The disassembly matches so the image is right.” The disassembly is of the ELF. The simulator loads the hex. Between them sits objcopy, whose Verilog address directives are in words, and whose flat-binary output starts at the lowest load address — which is not necessarily your ROM base if an orphan section landed lower. Check readelf -l for the actual LMAs before writing the loader.
Verifying the Toolchain Produces What You Asked For
The discipline this rung installs is a four-line check to run after any flag change, before believing anything downstream:
$ file fw.elf # 1. XLEN and float ABI
$ riscv64-linux-gnu-readelf -h fw.elf | grep -E "Entry|Flags" # 2. reset vector and e_flags
$ riscv64-linux-gnu-readelf -A fw.elf # 3. the canonicalised -march, with versions
$ riscv64-linux-gnu-nm -n fw.elf # 4. every symbol, in address orderLine 1 catches an XLEN or float-ABI mistake in one word. Line 2 catches an entry point that does not match the reset vector — the single most common “the core fetches garbage” cause. Line 3 catches a missing zicsr or an unexpected extension before the assembler surprises you. Line 4 catches a symbol in the wrong memory region, the _sbss == _ebss class of bug, and a .data whose LMA is not in ROM.
Add size -A when you are close to a block-RAM limit, and objdump -d | grep -c on a compressed-instruction pattern when you want to know how much the C extension actually bought you on a real image. All four commands are cheap enough to put in the Makefile as a verify target, and a Makefile that prints them on every build is a Makefile that never lets you debug the wrong binary.
Alternatives and When to Choose Them
| Approach | Strength | Weakness |
|---|---|---|
Distribution riscv64-linux-gnu-gcc (used throughout this note) | installed in seconds; full multilib for RV32; builds and links bare-metal fine | no RV32 libc; hosted-oriented defaults leave orphan .eh_frame/.note sections behind |
riscv64-unknown-elf-gcc from riscv-gnu-toolchain | newlib or picolibc included; bare-metal defaults; the toolchain riscv-tests documents | ~6.65 GB clone, ~8 GiB build, hours of wall time |
| clang/LLVM | one binary targets everything; no separate install; excellent diagnostics | needs an external linker script and lld or GNU ld anyway; slightly different codegen makes cross-checking a feature, not a nuisance |
rustup + riscv32imc-unknown-none-elf | no_std out of the box; cargo handles builds; links against GNU ld | Rust only; still needs GNU binutils for objcopy/objdump on the final image |
| Vendor SDK (ESP-IDF for ESP32-C3) | everything pre-integrated, working printf, working flash tooling | designed around one chip’s memory map and peripherals; hides exactly what this project exists to learn |
The honest recommendation for this project: distribution GCC plus rustup for Stages 0 through 5, and revisit only when a real libc becomes the bottleneck. Keeping two compilers around (GCC and clang) is worth the disk space for a different reason — when a program behaves differently on your core than you expect, building it with the other toolchain and diffing the disassembly separates “my core is wrong” from “the compiler emitted something I did not anticipate”.
Production Notes
The riscv-tests suite is the best worked example of a production bare-metal RISC-V build, and it is worth reading its isa/Makefile as a template. Its complete compile line is:
RISCV_GCC_OPTS ?= -static -mcmodel=medany -fvisibility=hidden -nostdlib -nostartfiles
$(RISCV_GCC) -march=rv32g -mabi=ilp32 $(RISCV_GCC_OPTS) \
-I../env/p -Imacros/scalar -T../env/p/link.ld rv32ui/add.S -o rv32ui-p-addEvery element has been covered above: -nostdlib -nostartfiles to refuse the C runtime, -T for the memory map, -mcmodel=medany because the link address is 0x80000000, -march=rv32g because the test environment’s reset code needs zicsr and zifencei (both of which rv32g includes and rv32imc does not), and -mabi=ilp32 because these are RV32 tests with no floating point in the harness. That command line built all 42 rv32ui-p-* tests successfully on the riscv64-linux-gnu-gcc used throughout this note — 42 built, 0 failed — which is the strongest available evidence for the claim that a distribution cross-compiler is enough to start.
The RISC-V specification versions relevant to any of this: the ratified RISC-V ISA Manual version 20240411 (Unprivileged and Privileged volumes, published 2024-05-09) and the later public release 20250508 (published 2025-05-12), both from riscv/riscv-isa-manual. GCC’s -misa-spec defaults to 20191213, the release that split Zicsr and Zifencei out of the base I — the reason the two error messages above exist at all. Version claims here are as of 2026-09-04; the ISA manual repository also publishes near-daily rolling riscv-isa-release-<sha>-<date> tags which are not ratified specifications and should not be cited as such.
See Also
- definitely-not-esp32 MOC — the project this rung belongs to; this is Stage 0
- Computer Architecture MOC — the concept companion
- RV32IMC — the exact ISA string this note keeps passing to
-march - Zicsr Extension — why
-march=rv32imcis not enough once traps exist - RISC-V Instruction Formats — how to read the hex in an
objdumplisting by hand - Linker Scripts and Memory Layout — the
.text/.data/.bssplacement this note only sketches - Boot ROM and the Reset Vector — why
Entry point addresshas to match the RTL - The riscv-tests Suite — the first real consumer of this toolchain, at Stage 4
- Verilator — what consumes the
objcopyoutput - Bare-Metal Rust — the
no_stdhalf of the same story - ELF Format · ELF Sections vs Segments · The ELF Symbol Table — the container these tools inspect
- _start and __libc_start_main — what a hosted startup does, so you know what you are refusing
- ESP32-C3 — the reference part, and
riscv32imc-esp-espidf