← Home / Project 1

Project 1 — Bare Metal QEMU

The simplest possible bare-metal RV64I program: stack setup, arithmetic, a loop — all debuggable step-by-step in Eclipse CDT.

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:

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:

RegisterValueMeaning
a010First operand
a120Second operand
a230Sum (10 + 20)
t05Final loop counter value
t15Loop limit (unchanged)
sp0x80010000Stack pointer
raaddr of haltReturn 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:

TabSettingValue
MainC/C++ Applicationmain.elf
MainDisable auto build☑ checked
DebuggerGDB Command/usr/bin/gdb-multiarch
DebuggerUse remote target☑ checked
DebuggerHost / Portlocalhost / 1234
StartupInit commandsset architecture riscv:rv64
StartupLoad symbolsmain.elf
StartupSet breakpoint at_start
StartupResume☑ 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

CommandWhat it does
-exec info registersShow all integer registers
-exec stepiExecute one machine instruction
-exec x/5i $pcDisassemble 5 instructions from current PC
-exec x/4gx 0x80010000Show 4 × 64-bit words at stack top
-exec x/7i 0x1000Disassemble QEMU reset vector
-exec x/2gx 0x1018Show kernel_entry and fdt_addr data words
-exec p/x $a2Print a2 in hex (should be 0x1e = 30)