Overview
This project is the absolute minimum needed to run RISC-V assembly on QEMU and step through it with a debugger. There is no C runtime, no operating system, and no external firmware. The program:
- Sets the stack pointer to a safe address
- Adds two numbers (10 + 20 = 30) into registers
- Runs a 5-iteration count loop
- Spins forever at
done:
What you will learn: how the QEMU reset vector jumps to your code, how to write a linker script, how to assemble and link without GCC, and how to connect Eclipse CDT to QEMU's GDB stub.
Project Files
bare_metal_qemu/
├── main.S # the entire program — 20 lines of RV64I assembly
├── linker.ld # places .text at 0x80000000
└── Makefile # build + QEMU launch targets
main.S — Line by Line
# Tell the assembler this belongs in the .text section .section .text .global _start # make _start visible to the linker _start: li sp, 0x80010000 # sp = 0x80010000 (64 KB above code) # MUST be done before any call instruction # call pushes return address on the stack call main # ra = halt, PC = main halt: j halt # infinite loop if main returns (it won't) .global main main: li a0, 10 # a0 = 10 (first function argument register) li a1, 20 # a1 = 20 (second function argument register) add a2, a0, a1 # a2 = a0 + a1 = 30 li t0, 0 # t0 = loop counter, starts at 0 li t1, 5 # t1 = loop limit loop: addi t0, t0, 1 # t0 = t0 + 1 blt t0, t1, loop # branch back if t0 < 5 # (runs 5 times: t0 goes 1,2,3,4,5 then falls through) done: j done # set Eclipse breakpoint here to inspect registers
Register State at done:
| Register | Value | Meaning |
|---|---|---|
a0 | 10 | First operand |
a1 | 20 | Second operand |
a2 | 30 | Sum (10 + 20) |
t0 | 5 | Final loop counter value |
t1 | 5 | Loop limit (unchanged) |
sp | 0x80010000 | Stack pointer |
ra | addr of halt | Return address saved by call |
linker.ld
ENTRY(_start) /* linker entry symbol — where execution begins */ MEMORY { RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M } /* QEMU virt DRAM always starts at 0x80000000 */ SECTIONS { .text 0x80000000 : { *(.text) /* all .text sections from all object files */ *(.text.*) } > RAM .data : { *(.data) *(.data.*) } > RAM .bss : { *(.bss) *(.bss.*) } > RAM }
The
ENTRY(_start) line is what tells QEMU the ELF entry point. QEMU reads this from the ELF header (e_entry) and writes it into the MROM data word at 0x1018 so the reset vector can jump there.Makefile
CROSS = riscv64-unknown-elf- AS = $(CROSS)as LD = $(CROSS)ld # use ld directly — NOT gcc ASFLAGS = -g -march=rv64i -mabi=lp64 LDFLAGS = -T linker.ld -nostdlib all: main.elf main.elf: main.o $(LD) $(LDFLAGS) -o $@ $^ main.o: main.S $(AS) $(ASFLAGS) -o $@ $< qemu-run: all qemu-system-riscv64 -machine virt -nographic -bios none -kernel main.elf qemu: all # paused — waiting for GDB on :1234 qemu-system-riscv64 -machine virt -nographic -bios none -kernel main.elf -s -S
Why
-bios none? Without it QEMU loads OpenSBI which puts your code in S-mode. Since this project runs in M-mode and uses M-mode CSRs that would cause illegal instruction faults. -bios none skips OpenSBI so the reset vector jumps directly to your _start.Build & Run
1
Clone & build
git clone https://github.com/vwire/riscv-bare-metal-qemu.git cd riscv-bare-metal-qemu/bare_metal_qemu make # Output: main.elf (entry point 0x80000000)
Verify the entry point is correct:
riscv64-unknown-elf-readelf -h main.elf | grep "Entry" # Entry point address: 0x80000000
2
Run without debugger
make qemu-run # QEMU starts, program runs, loops silently at 'done:' # No UART output (project has no UART code) # Press Ctrl-A then X to quit QEMU
3
Run with GDB (terminal)
# Terminal 1 — start QEMU paused make qemu # Terminal 2 — connect GDB gdb-multiarch main.elf (gdb) set architecture riscv:rv64 (gdb) target remote localhost:1234 (gdb) break _start (gdb) continue (gdb) stepi # step one instruction at a time (gdb) info registers
Eclipse CDT Debug Setup
1
Create the Eclipse project
File → New → Makefile Project → Empty Project → Cross GCC. Name it bare_metal_qemu. Set the project location to the bare_metal_qemu/ folder from the cloned repo.
2
Configure the builder
Right-click project → Properties → C/C++ Build:
- Uncheck Generate Makefiles automatically
- Build directory:
${workspace_loc:/bare_metal_qemu} - Build command:
make(no quotes) - Environment → PATH: add your toolchain
bin/folder
3
Create debug configuration
Run → Debug Configurations → GDB Hardware Debugging → New:
| Tab | Setting | Value |
|---|---|---|
| Main | C/C++ Application | main.elf |
| Main | Disable auto build | ☑ checked |
| Debugger | GDB Command | /usr/bin/gdb-multiarch |
| Debugger | Use remote target | ☑ checked |
| Debugger | Host / Port | localhost / 1234 |
| Startup | Init commands | set architecture riscv:rv64 |
| Startup | Load symbols | main.elf |
| Startup | Set breakpoint at | _start |
| Startup | Resume | ☑ checked |
4
Debug session
# Terminal — start QEMU paused (must be running before F11) make qemu # Eclipse — press F11 (Debug) # GDB connects, stops at _start # F6 = Step Over, F5 = Step Into, F8 = Resume # Registers panel: Window → Show View → Registers
Useful GDB Console Commands
| Command | What it does |
|---|---|
-exec info registers | Show all integer registers |
-exec stepi | Execute one machine instruction |
-exec x/5i $pc | Disassemble 5 instructions from current PC |
-exec x/4gx 0x80010000 | Show 4 × 64-bit words at stack top |
-exec x/7i 0x1000 | Disassemble QEMU reset vector |
-exec x/2gx 0x1018 | Show kernel_entry and fdt_addr data words |
-exec p/x $a2 | Print a2 in hex (should be 0x1e = 30) |