Linker Scripts, Sections, and Memory Maps

TL;DR


1. The linker’s job (a practical definition)

Given:

The linker produces:


2. Sections you must know

SectionMeaningFile bytes?Memory bytes?
.textcodeyesyes
.rodataread-only constantsyesyes
.datainitialized globalsyesyes
.bsszero-initialized globalsnoyes

3. VMA vs LMA (why there can be two addresses)

In bare-metal firmware, a common pattern is:

That implies:

We’ll use a simpler single-region RAM layout first, then explain the “copy down” idea.


4. Hands-on: a bare-metal RV32 program with explicit placement

Create:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// src/ld_demo.c
#include "types.h"

volatile u32 g_counter = 0u;      // .bss or .data depending on init
volatile u32 g_init    = 0x1234u; // .data

void _start(void) {
  for (;;) {
    g_counter++;
    g_init ^= 0x1111u;
  }
}

Create a linker script:

/* src/ld_demo.ld */
OUTPUT_ARCH(riscv)
ENTRY(_start)

MEMORY {
  RAM (rwx) : ORIGIN = 0x80000000, LENGTH = 16M
}

SECTIONS {
  . = ORIGIN(RAM);

  .text : {
    *(.text .text.*)
  } > RAM

  .rodata : {
    *(.rodata .rodata.*)
  } > RAM

  .data : {
    *(.data .data.*)
  } > RAM

  .bss : {
    *(.bss .bss.*)
    *(COMMON)
  } > RAM
}

Build:

1
2
3
4
5
6
7
riscv64-unknown-elf-gcc -O0 -g -ffreestanding -nostdlib \
  -march=rv32im -mabi=ilp32 \
  -T src/ld_demo.ld \
  -Wl,-Map=build/ld_demo.map \
  -o build/ld_demo.elf src/ld_demo.c

qemu-system-riscv32 -M virt -nographic -bios none -kernel build/ld_demo.elf

5. Read the map file like a detective

Open the map:

1
less build/ld_demo.map

Things to find:

A map file is an answer key to “why is this at this address?”.


6. Verify section addresses with readelf

1
2
readelf -S build/ld_demo.elf
readelf -s build/ld_demo.elf | grep -E '_start$|g_counter$|g_init$'

You should see:


7. ELF → raw binary and the “no addresses” trap

Create a .bin:

1
riscv64-unknown-elf-objcopy -O binary build/ld_demo.elf build/ld_demo.bin

Now compare sizes:

1
ls -l build/ld_demo.elf build/ld_demo.bin

8. Alignment and why it matters

Two common alignment realities:

If you ever see weird gaps, it’s often alignment.

You can force alignment explicitly:

.text : {
  . = ALIGN(16);
  *(.text .text.*)
} > RAM

9. A preview of startup code (what we’re skipping for now)

In real bare-metal systems, there’s usually a startup routine that:

We’re intentionally keeping early examples minimal so you can see placement without extra machinery.


Exercises

  1. Change ORIGIN(RAM) from 0x80000000 to 0x80200000. Verify _start moved.
  2. Add a new const char msg[] = "hi"; and confirm it appears in .rodata.
  3. Generate a disassembly and confirm that loads/stores to g_counter use its absolute address (or a sequence that computes it).
  4. Try -Wl,--gc-sections along with -ffunction-sections -fdata-sections and observe what unused code/data is removed.

How to test your answers


Summary

You learned how linker scripts define a memory map, how sections are placed, and how to validate placement with map files and readelf.

Next: floating point, endianness, and bit packing-we’ll explain why double constants sometimes show up as two .word values and how to verify them with Python.