Skip to content

Latest commit

 

History

6 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 

Repository files navigation

linker.ld

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).

What a linker script is for

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:

  1. Where in the address space each section should end up (memory layout).
  2. 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.

QEMU virt memory map

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.

The script, section by section

Output architecture and entry point

OUTPUT_ARCH(riscv)
ENTRY(_start)
  • OUTPUT_ARCH(riscv) labels the produced ELF file's e_machine field 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's e_entry field to the address of the _start symbol. When we boot via qemu-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 .bss exists.

Declaring RAM

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.

Section placement

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)
    } > RAM

Our 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.*)
    } > RAM

All 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.*)
    } > RAM

Read-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 = .;
    } > RAM

Initialized 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 = .;
    } > RAM

Uninitialized 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.

Resulting memory layout

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)

Symbols exported for use in jny.S / C code

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

Known limitations (intentional, for this stage)

  • Single flat memory region. No separation of load address vs. run address per section, fine for a QEMU -kernel ELF 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/.bss is preparatory, not yet load-bearing, there's no Sv39 page table active.
  • No multiple memory regions. QEMU's virt machine has MMIO device ranges below 0x80000000 that 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 additional MEMORY entries.

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.

About

Repository that containts 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 maps the memory.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages