Shared build system for the project's bare-metal RISC-V targets: compiles the freestanding cross-toolchain build, links against
linker.ld, and boots the result in QEMU.
This repo holds a single, minimal Makefile. It isn't meant to live inside any one project repo, it's the build recipe every RV64 bare-metal component in this project (starting with jny) pulls in and reuses, so the compile/link/run invocation stays identical across all of them instead of drifting copy by copy.
The whole file is three variables and three targets:
CC = riscv64-elf-gcc
CFLAGS = -march=rv64gc -mabi=lp64d -mcmodel=medany -nostdlib -ffreestanding -Iinclude
SRCS = $(wildcard src/*.c src/*.s)
compile:
$(CC) $(CFLAGS) -T linker.ld $(SRCS) -o bin/main.elf
clean:
rm -f bin/main.elf
run:
qemu-system-riscv64 -machine virt -bios none -kernel bin/main.elf -nographicriscv64-elf-gcc— the bare-metal (ELF) cross compiler installed byenv.sh, not the host's nativegcc.-march=rv64gc— targets the base RV64I integer ISA plus theG(IMAFD: integer mul/div, atomics, single- and double-precision float) andC(compressed instructions) extensions. This has to match what the hardware/QEMU actually implements, code built for extensions the target doesn't have will trap.-mabi=lp64d— the calling convention:long/pointers are 64-bit, and floating-point arguments are passed in FPU registers (thed= double-precision hard-float ABI). Must agree with-marchincludingF/D, mismatching the ABI and ISA is a common source of silent miscompilation.-mcmodel=medany— "medium, any": code and data can be linked anywhere in a ±2 GiB window without assuming a fixed load address near zero. Bare-metal images placed at0x80000000(seelinker.ld) need this; the defaultmedlowmodel assumes the low 2 GiB and would generate broken addressing.-nostdlib— don't link libc, libgcc's runtime startup, or the default CRT objects. There's no OS underneath to provide them, and pulling them in would silently drag in code (like_startexpecting anargv/envpa hosted OS would set up) that doesn't make sense on bare metal.-ffreestanding— tells GCC this is a freestanding environment:mainisn't guaranteed to be the entry point, standard library functions aren't assumed to exist, and the compiler won't assume hosted semantics (e.g. it won't optimize a loop into amemsetcall expecting a libc that isn't there).-Iinclude— adds a localinclude/directory to the header search path, this is where per-project headers pulled from repos likecsr.h,extensions.h, andgprintf.hend up.
$(wildcard src/*.c src/*.s) picks up every .c and .s file under src/ automatically, so adding a new source file doesn't require touching this Makefile. The trade-off: there's no per-object compilation and no header dependency tracking, every make compile recompiles and relinks everything from scratch. That's a deliberate simplification for this stage of the project (see Known limitations below), not an oversight.
A single $(CC) invocation compiles and links $(SRCS) in one step against -T linker.ld, the linker script that places _start at the base of RAM and lays out .text/.rodata/.data/.bss (see the linker.ld repo for the full breakdown). Output is bin/main.elf.
Removes bin/main.elf. Nothing else is generated, so there's nothing else to remove yet.
qemu-system-riscv64 -machine virt -bios none -kernel bin/main.elf -nographic-machine virt— QEMU's generic RISC-V board, the same memory maplinker.ldis written against.-bios none— skips the default OpenSBI firmware QEMU would otherwise load first. Without it, our own_start(viaENTRY(_start)inlinker.ld) is the literal first instruction executed, matching the M-mode, no-BIOS model this project targets.-kernel bin/main.elf— QEMU loads the ELF directly into RAM and jumps to its entry point; there's no bootloader stage doing that yet.-nographic— redirects the serial console to the terminal instead of opening a display window, since there's no framebuffer or display driver at this stage.
riscv64-elf-gcc/riscv64-elf-binutilsandqemu-system-riscv, both installed byenv.sh- A
linker.ldat the repo root (fromlinker.ld) src/(and optionallyinclude/) populated by the consuming project
This Makefile doesn't provide any of those itself, it assumes they're already in place.
Pulled into a project repo alongside linker.ld and any needed headers:
make compile # build bin/main.elf
make run # boot it in QEMU
make clean # remove the built ELFThis project keeps shared, non-source infrastructure (env.sh, linker.ld, Makefile, headers like csr.h) as single-purpose repos rather than duplicating them into every consumer. Other repos declare what they need in a puller.toml, and pff pulls the files straight from source, no submodules, no package registry. This repo's pulled.toml is what makes that possible:
[setup]
files = [
["./Makefile", "./"],
]It declares that ./Makefile is a file this repo exposes for other repos to pull, and where it lands (./, the consumer's root) when they do. One canonical build recipe, reused everywhere it's needed, changes propagate by re-pulling instead of copy-pasting.
- No incremental builds. Every
make compilerebuilds and relinks every source file; there's no per-object compilation or header dependency tracking. Fine while the source tree is small, this will need real object-file rules once it isn't. - Single hardcoded target.
-march=rv64gc/-machine virtassume one target: QEMU'svirtmachine. Once the project moves to its own FPGA SoC, this will need to become parameterized rather than fixed. - No test/debug targets. No
gdbintegration (riscv64-elf-gdb, installed byenv.sh, isn't wired in here) and no automated test running yet.
These will be revisited as the project grows past a single QEMU target.
Early stage. This README and the Makefile itself will be updated as the project's build requirements grow, especially once FPGA targets and multiple images enter the picture.