Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 5

Linker Scripts and Memory Layout

Recommended resources

Introduction

In our last lesson, we explored the structure of the vector table and saw that it acts as the central dispatch for all system exceptions, from the initial reset to peripheral interrupts. We concluded with the understanding that for the system to boot correctly, the vector table must reside at the very beginning of the executable memory. Today, we will answer the question of how we ensure that happens. We will delve into the role of the linker script, a crucial but often overlooked file that acts as the architectural blueprint for our firmware.

As a software engineer, you're familiar with the build process where source files are compiled and then linked into a final executable. On a desktop system, a default linker script, in concert with the operating system, manages memory allocation for you. In the bare-metal embedded world, however, we have no such luxury. We are responsible for telling the build tools about our microcontroller's specific memory map—its Flash and SRAM—and precisely where every piece of our code and data should reside. This lesson will explain how the linker script gives us that control.


The Linker's Role and Program Sections

Before we write the blueprint, we need to understand what we are building with. The linker's primary job is to take all the individual object files (.o files) generated by the compiler and merge them into a single executable file (a .elf file in our case). It resolves symbol references between files (like a function in main.c calling a function in delay.c) and, most importantly for us, assigns final memory addresses to everything.

The "everything" is organized into categories called sections. The compiler places different parts of your C code into different standard sections.

This diagram illustrates how different C language elements are sorted into specific output sections by the compiler. These sections are the fundamental units that the linker script will place into the system's final memory map.

The most important sections for a bare-metal program are:

  • .text: This contains your executable machine code (your functions) and any constant data (e.g., const int x = 10;). This section is read-only and lives in Flash memory.
  • .data: This holds global and static variables that are initialized with a non-zero value (e.g., int global_var = 42;). These variables need to be in RAM to be modifiable, but their initial values must be stored in Flash. We'll see how the linker script handles this paradox.
  • .bss (Block Started by Symbol): This is for global and static variables that are uninitialized or initialized to zero (e.g., static int counter;). The C standard guarantees these start at zero, but we don't need to waste space in our firmware file storing all those zeros. The linker script simply reserves space for .bss in RAM, and our startup code will zero it out at boot time.

Any variables local to a function (i.e., not static) are created on the stack when the function is called and are not handled directly by the linker script's section placement.

The following video provides an excellent overview of the build process and a clear explanation of these core sections.

ARM - M: Build process, role of linker and Linker scripts! | Embedded Systems podcast, in Pyjama!

This video from the "inpyjama" channel reviews the C build process and then provides a detailed discussion on the purpose and lifetime of the .text, .data, and .bss sections.

First, watch the overview of the build process. This will be a familiar concept from your software engineering background, but it sets the context for the linker's specific role. Then, pay close attention to the discussion on memory layout and sections. The speakers do a great job clarifying the distinction between these sections and stack/heap memory, and why variables are sorted this way based on their lifetime.


The Anatomy of a Linker Script

A linker script is a text file with its own command language that instructs the linker. While the syntax can seem archaic, a typical script for an embedded system consists of just a few key commands.

This diagram shows the conceptual flow. Object files and a linker script are input to the linker, which then produces a final binary with sections mapped to the physical FLASH and SRAM of the microcontroller.

We will now examine the main components of a linker script. The article "From Zero to main(): Demystifying Firmware Linker Scripts" provides a clear, foundational explanation of these components.

From Zero to main(): Demystifying Firmware Linker Scripts

This article from the Memfault Interrupt blog provides a great introduction to linker scripts.

Please read the section Anatomy of a Linker Script. This gives a concise overview of the four main parts of a script, which we will explore next.

1. The MEMORY Command

The first thing the linker needs to know is the physical memory map of the target device. The MEMORY command is where we describe the available memory regions, their start addresses (ORIGIN), their sizes (LENGTH), and their attributes (r for read, w for write, x for execute).

For a typical STM32 microcontroller, this looks something like this:

MEMORY
{
  FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
  SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}

This tells the linker we have a 512 KB region named FLASH starting at 0x08000000 that is readable and executable, and a 128 KB region named SRAM starting at 0x20000000 that is readable, writable, and executable.

2. The SECTIONS Command

This is the heart of the linker script. Here, we define the layout of our final executable by creating output sections and specifying which input sections from our object files go into them.

A simplified SECTIONS block might look like this:

SECTIONS
{
    /* The .text section, for code and read-only data */
    .text :
    {
        . = ALIGN(4);
        KEEP(*(.isr_vector))  /* Keep the vector table first! */
        *(.text*)            /* All .text sections from all input files */
        *(.rodata*)          /* All .rodata sections */
        . = ALIGN(4);
    } >FLASH

    /* ... other sections ... */
}

Let's break this down:

  • .text : { ... } >FLASH: This defines an output section named .text and instructs the linker to place it in the FLASH memory region we defined earlier.
  • KEEP(*(.isr_vector)): This is a critical line. It tells the linker to take the input section named .isr_vector (which we define in our startup code to contain the vector table) and place it first within the .text output section. The KEEP directive prevents the linker from discarding it during optimization if it thinks it's unused.
  • *(.text*): This is a wildcard that tells the linker to collect all sections whose names start with .text from all input files (*) and place them here.
  • . = ALIGN(4);: The dot (.) is a special symbol called the location counter, which tracks the current address within the output section. ALIGN(4) ensures the location counter is moved to the next 4-byte boundary, which is often required for performance or architectural reasons on ARM processors.

3. The LMA vs. VMA Puzzle: The .data Section

The .data section presents a unique challenge. Its initial values must be loaded into the non-volatile FLASH, but the variables must exist in SRAM at runtime to be writable. This leads to two different addresses for the same section:

  • LMA (Load Memory Address): Where the section is stored in the program binary. For .data, this is in FLASH.
  • VMA (Virtual Memory Address): Where the section is located during execution. For .data, this is in SRAM.

The linker script handles this with the AT keyword:

    .data :
    {
        /* .data contents */
    } >SRAM AT>FLASH

This syntax tells the linker: "The VMA for this section is in SRAM, but its LMA is in FLASH, immediately following the preceding section (which was .text)." The startup code we examined previously is then responsible for copying the .data section from its LMA in Flash to its VMA in RAM before main() is called.

The following video explains this crucial concept very clearly.

ARM - M: Build process, role of linker and Linker scripts! | Embedded Systems podcast, in Pyjama!

The speakers in this video give one of the clearest explanations of why .data needs two addresses and how the linker script's AT keyword solves the problem.

Please watch the segment from the explanation of the AT syntax. Focus on understanding why Flash is not suitable for frequently modified variables and how this leads to the LMA/VMA separation.

4. Creating Symbols for C Code

The final piece of the puzzle is communicating memory layout information back to our C code. How does the Reset_Handler know where the .data section is in Flash and where it needs to be copied in RAM?

The linker script can create symbols that are visible to our C code. We do this by assigning the value of the location counter (.) to a symbol at key points.

    _sidata = LOADADDR(.data); /* Start LMA of .data section in FLASH */

    .data : AT (_sidata)
    {
        _sdata = .;           /* Start VMA of .data section in RAM */
        *(.data*)
        _edata = .;           /* End VMA of .data section in RAM */
    } >SRAM

    .bss :
    {
        _sbss = .;            /* Start of .bss in RAM */
        *(.bss*)
        *(COMMON)
        _ebss = .;            /* End of .bss in RAM */
    } >SRAM

Here, we create several symbols:

  • _sidata: The LMA (load address) of the .data section. This is our copy source.
  • _sdata and _edata: The start and end VMA (runtime addresses) of the .data section in RAM. This is our copy destination.
  • _sbss and _ebss: The start and end of the .bss section in RAM. Our startup code uses these to know which memory region to zero out.

These are the "magic" variables you saw in the startup code from our previous lessons. They aren't magic at all; they are addresses explicitly calculated and exported by the linker.


A Deeper Look at a Real Linker Script

Now that we've covered the fundamental concepts, let's examine how they are applied in a real linker script provided by STM32CubeIDE. The following article provides a detailed line-by-line analysis. You don't need to memorize every detail, but reading through the explanation of the main sections will solidify your understanding.

Demystifying Arm Cortex-M33 Bare Metal: Compile, ...

This article provides a thorough analysis of a typical STM32 linker script, explaining the purpose of each command and section definition.

First, read the section "Basic Concepts about Linker and Object Files" for a great recap on sections and the VMA/LMA distinction. Then, skim through the main parts of the next section. Focus on understanding how the script implements the concepts we've just discussed: The MEMORY block defining RAM and FLASH. The .isr_vector section being placed first using KEEP. The .text and .rodata sections being placed in FLASH. The .data section using the >RAM AT> FLASH syntax. The creation of symbols like _sdata, _edata, _sbss, and _ebss around the .data and .bss sections.


Conclusion

Today we've demystified the linker script, transforming it from a cryptic configuration file into a powerful tool for defining our firmware's architecture. You now understand that the linker script is the essential bridge between your compiled code and the physical hardware of the microcontroller.

Here are the key takeaways:

  • The linker script is a blueprint that tells the linker how to arrange the compiled sections (.text, .data, .bss) into the microcontroller's memory map (FLASH, SRAM).
  • The MEMORY command describes the physical memory regions available on the chip.
  • The SECTIONS command orchestrates the placement of code and data, ensuring critical parts like the vector table (.isr_vector) are correctly positioned.
  • The distinction between LMA (Load Memory Address) and VMA (Virtual Memory Address), managed with the AT keyword, is the key to handling initialized data that is stored in Flash but used in RAM.
  • The script exports symbols (like _sdata, _edata) that provide our C startup code with the addresses it needs to initialize RAM correctly before main() begins.

With this knowledge, you now have a complete picture of the journey from power-on reset, through the startup code's memory initialization (guided by the linker script), to the first instruction in your main() function.

In our next lesson, we will shift our focus from the static memory layout of our program to the dynamic runtime configuration of the microcontroller. We'll begin with one of the most fundamental tasks: configuring the system clock sources and PLL to set the operational speed of the CPU and peripherals.