This document explains linker.ld, the linker script used to build the
bare-metal RISC-V bootloader/kernel image for this project. It covers what
the script does, why each part is written the way it is, and how it maps
onto the memory layout of QEMU's virt machine (and later, our own FPGA
SoC).
The compiler and assembler turn source files into object files (.o), each
containing chunks of code and data ("sections") with no fixed address yet.
The linker combines all of those object files into a single executable
image, and it needs to know two things that source code alone can't tell
it:
- Where in the address space each section should end up (memory layout).
- In what order and how those sections should be arranged and combined.
linker.ld answers both questions. Without it, the default linker script
built into the toolchain would produce an image laid out for a generic
hosted system, not for a machine with no OS, no loader, and no MMU, where
we control every byte from address zero of RAM upward.
We target QEMU's RISC-V virt machine as our first platform, since it's
free, fast to iterate on, and well documented. Before moving to our own
FPGA SoC. virt lays out physical address space roughly like this:
| Address range | Purpose |
|---|---|
0x00001000 |
Small mask ROM QEMU places here; sets up a0/a1 and jumps into our image |
0x02000000 (approx.) |
CLINT — timer and software interrupts |
0x0c000000 (approx.) |
PLIC — external interrupt controller |
0x10000000 (approx.) |
UART |
0x10001000+ (approx.) |
VirtIO MMIO devices |
0x80000000 |
DRAM starts here |
This is a QEMU/SiFive convention, not a RISC-V architectural
requirement, the ISA doesn't mandate where RAM lives. virt reserves the
low addresses for boot ROM and memory-mapped devices, and leaves a large,
round address (0x80000000) as the start of usable DRAM so device ranges
never collide with it, even as QEMU adds devices over time.
We currently run QEMU with -m 128M, so our usable RAM is the 128 MB
window from 0x80000000 to 0x87FFFFFF.
OUTPUT_ARCH(riscv)
ENTRY(_start)OUTPUT_ARCH(riscv)labels the produced ELF file'se_machinefield so tools (QEMU, objdump, debuggers) know how to interpret it. It does not change code generation, that's controlled by compiler/assembler flags (-march,-mabi).ENTRY(_start)sets the ELF'se_entryfield to the address of the_startsymbol. When we boot viaqemu-system-riscv64 -kernel our.elf, QEMU reads this field and jumps straight there, this is the very first instruction executed, before any C runtime, stack, or zeroed.bssexists.
MEMORY
{
RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 128M
}This tells the linker the one physical region we're allowed to place code
and data in: 128 MB starting at 0x80000000, readable/writable/executable.
Declaring LENGTH isn't just documentation, the linker will refuse to
build an image that overflows this region, catching a class of bug at
build time instead of as a mystery crash on hardware.
SECTIONS
{
. = ORIGIN(RAM);. is the location counter — the linker's "current address" cursor.
Setting it to ORIGIN(RAM) means the very first byte of our linked image
lands exactly at 0x80000000, matching where execution begins.
.text.init : {
*(.text.init)
} > RAMOur hand-written _start routine (in boot.S, tagged with
.section .text.init) is placed in its own output section, first, ahead of
everything the C compiler generates. This guarantees _start is the
literal first instruction in the image, critical because since that's the
address ENTRY(_start) points at and the address QEMU jumps to.
.text : ALIGN(4) {
*(.text .text.*)
} > RAMAll other compiled code. Aligned to 4 bytes because RISC-V instruction
fetch requires 4-byte alignment for standard 32-bit instructions (2-byte
for compressed instructions under the C extension), a misaligned fetch
address traps. This is a genuine ISA requirement, not a style choice.
.rodata : ALIGN(4) {
*(.rodata .rodata.*)
} > RAMRead-only data: string literals, const globals, jump tables. Kept
separate from .text for clarity now, and so that later, once an MMU is
in the picture, it could be mapped with different page permissions than
executable code.
.data : ALIGN(4096) {
_data_start = .;
*(.data .data.*)
*(.sdata .sdata.*)
_data_end = .;
} > RAMInitialized global/static variables. Aligned to 4096 bytes (one page), not required yet with no MMU active, but this keeps section boundaries page-granular in preparation for Sv39 virtual memory later, so we don't have to revisit alignment when paging is introduced.
_data_start/_data_end are symbols we define ourselves (not
linker-reserved names) that record the location counter at the start and
end of this section. They're exported for future use, e.g. a real boot
ROM that copies .data from flash into RAM before running, which isn't
needed yet since QEMU loads our ELF directly into RAM.
.sdata holds RISC-V small data, variables the compiler accesses via
gp-relative addressing (the linker relaxation optimization) rather than
a full address, for small globals near the global pointer.
.bss : ALIGN(4096) {
_bss_start = .;
*(.bss .bss.*)
*(.sbss .sbss.*)
*(COMMON)
_bss_end = .;
} > RAMUninitialized globals. The ELF format doesn't store actual bytes for
.bss, only its size, so _bss_start/_bss_end are actively used:
our _start code reads these two symbols and manually zeroes that address
range in a loop before jumping into main(), since nothing else will.
*(COMMON) picks up old-style C "common symbols", uninitialized globals
declared without extern in more than one translation unit, a legacy
linkage quirk. They're folded into .bss here defensively, so if one ever
appears (e.g. from a third-party library not compiled with
-fno-common), it still gets zeroed correctly rather than landing
somewhere unexpected.
. = ALIGN(4096);
_end = .;Advances the location counter to the next page boundary, then records
_end, the first free address after everything statically linked. This
is the standard symbol a bump allocator or heap-start pointer would use.
PROVIDE(_stack_top = ORIGIN(RAM) + LENGTH(RAM));
}Defines _stack_top as the last address of RAM (0x88000000 with our
current 128 MB). PROVIDE means "only define this if nothing else already
does," so it can be overridden without a duplicate-symbol error. RISC-V's
stack grows downward, so _start initializes sp to _stack_top and
the stack grows down from the top of RAM toward .bss.
0x80000000 ┌─────────────────────┐
│ .text.init (_start) │
├─────────────────────┤
│ .text │
├─────────────────────┤
│ .rodata │
├─────────────────────┤ (4096-aligned)
│ .data │ _data_start.._data_end
├─────────────────────┤ (4096-aligned)
│ .bss │ _bss_start.._bss_end
├─────────────────────┤ (4096-aligned)
│ (free memory) │ starts at _end
│ ... │
│ ... │ stack grows downward
0x88000000 └─────────────────────┘ _stack_top (top of 128 MB RAM)
| Symbol | Meaning | Used for |
|---|---|---|
_start |
First instruction, entry point | ENTRY(), boot ROM jump target |
_data_start/_data_end |
Bounds of initialized data | Reserved for future flash→RAM copy step |
_bss_start/_bss_end |
Bounds of uninitialized data | Zeroing loop in _start |
_end |
First free address after the static image | Heap/bump allocator base |
_stack_top |
Top of RAM | Initial stack pointer (sp) in _start |
__global_pointer$ |
gp-relative addressing anchor (defined in boot.S/linker together) |
RISC-V small-data relaxation |
- Single flat memory region. No separation of load address vs. run
address per section, fine for a QEMU
-kernelELF boot, but will need revisiting for a higher-half kernel or a real flash-to-RAM boot ROM. - No MMU / paging. Page-sized alignment on
.data/.bssis preparatory, not yet load-bearing, there's no Sv39 page table active. - No multiple memory regions. QEMU's
virtmachine has MMIO device ranges below0x80000000that this script doesn't need to describe, since we never place sections there. A real SoC design with, say, separate boot ROM and RAM regions would need additionalMEMORYentries.
These will be revisited as the project grows, this script covers exactly what's needed for a minimal bare-metal image booting under QEMU today.