Linker Scripts for Embedded Systems: A Practical Introduction

v1p3r

Member
Joined
22 May 2026
Messages
29
Reaction score
64
Points
18
I keep mentioning that most programmers will never encounter a linker script unless they work with embedded systems, but I've never actually explained why. After repeating this in my previous articles on Doppelgänger and writing your first crypter, it's finally time to dig into the details. In this post, I'll demystify linker scripts and show you exactly why they're crucial for embedded development.

Disclaimer: The embedded gods may cringe at some of my simplifications, but that's precisely why I can explain the basics without drowning in complexity.



The ATmega328p

The ATmega328p is arguably the most famous microcontroller, thanks to the Arduino project. Any electronics hobbyist probably has a few Arduino UNOs lying around or bare 328p chips on breadboards with a crystal and reset button. Despite modest specs, it's proven itself in thousands of projects worldwide.

What's a microcontroller? Think of it as a complete mini-computer on a single chip, packing CPU, memory, and peripherals. These typically use a Harvard architecture —separate address spaces for code and data—unlike your computer's Von Neumann architecture with unified memory. Each has tradeoffs, but the Harvard design will matter later.

ATmega328p specs:
- AVR CPU up to 16 MHz (downclockable for power savings)
- 32 KB Flash for code
- 2 KB RAM
- 1 KB EEPROM
- Peripherals: GPIO, ADC, UART/serial, DAC, PWM, timers, etc.

Yes, those are kilobytes . We're operating at a completely different scale—orders of magnitude below your computer's gigabytes.



Memory Types and Firmware

Flash (32 KB): Non-volatile memory that retains data when powered off. This stores your program code. While you might want to use it for data too, Flash has drawbacks: writing is slow and often requires block-level operations. You typically need a hardware programmer to flash the device (hence the term "flashing").

RAM (2 KB): Static RAM on a separate bus—you can't execute code from it. Variables live here during execution. Unlike Flash, RAM loses everything when power is cut.

EEPROM (1 KB): A middle ground—easier to write than Flash but too slow for regular RAM. Used for configuration values that change occasionally but must survive power cycles. Writing requires function calls. Fun fact: EPROMs (predecessors) needed UV light to erase, visible through a window on the chip!

What is firmware? It's the code and data destined for Flash memory. "Flashing the device" means writing firmware to Flash—same concept whether you're programming a router, phone, or Arduino.



What's Actually in Firmware?

The firmware contains code and some data:
- Read-only data (strings, numeric constants)
- Initialization values for global variables

Example: `int foo = 42;`
- The value `42` lives in Flash (as initialization data)
- The variable `foo` lives in RAM during execution
- The Harvard architecture prevents direct RAM-to-Flash mixing, but special instructions (like `LPM` on AVR) allow reading Flash content
- Startup code copies initialization values from Flash to RAM before `main()` runs

So for a simple embedded system, the firmware = code + initialization/constant values that get flashed to the device.



Using Standard Tools for Embedded Systems

The Arduino IDE uses `avr-gcc` (GCC port for AVR microcontrollers) with the full toolchain: gcc, objdump, ld, etc. It produces ELF files—just like your Linux system. But the microcontroller can't execute ELF directly; extra steps are needed to create flashable firmware.

This is where the linker script enters.

Arduino stores toolchains under your home folder (e.g., `.arduinoXX/`), including `avr-gcc` and `avrdude` for AVR boards. Navigate to `ldscripts/` and you'll find linker scripts for various boards. For ATmega328p, it uses `avr5.x`.



The AVR5.x Linker Script

Memory Layout (simplified):


Code:
MEMORY {
  text      (rx)   : ORIGIN = 0, LENGTH = __TEXT_REGION_LENGTH__
  data      (rw!x) : ORIGIN = __DATA_REGION_ORIGIN__, LENGTH = __DATA_REGION_LENGTH__
  eeprom    (rw!x) : ORIGIN = 0x810000, LENGTH = __EEPROM_REGION_LENGTH__
  fuse      (rw!x) : ORIGIN = 0x820000, ...
  lock      (rw!x) : ORIGIN = 0x830000, ...
}




-  text  = Flash memory (code) starting at address 0
-  data  starts at `0x800060`—a virtual address. The first `0x60` bytes of RAM are reserved for registers (per the datasheet), hence the offset. The `0x800000` prefix is stripped during binary generation, leaving just `0x60`.


 Sections:


  
.text { /    contents    / } > text
.data {
  PROVIDE(__data_start = .);
     (.data)    (.data   )    (.rodata)    (.rodata   )
  . = ALIGN(2);
  _edata = .;
  PROVIDE(__data_end = .);
} > data AT> text



Virtual vs. Physical Addresses

The `> data` specifies the virtual address (where the linker places sections in its linear memory model). The `AT> text` specifies the physical address —where it actually lives in Flash.

ELF Program Headers Example:

Code:
LOAD 0x000094 0x00000000 0x00000000 0x001ec 0x001ec R E  → .text
LOAD 0x000280 0x00800100 0x000001ec 0x00006 0x00006 RW  → .data
LOAD 0x000286 0x00800106 0x00800106 0x00000 0x00029 RW  → .bss
  


Notice `.data` has:
-  Virtual:  0x00800100
-  Physical:  0x000001ec (right after the `.text` section)

The startup code copies data from physical Flash address 0x000001ec to virtual RAM address 0x00800100 at boot. `.bss` (uninitialized data) has no physical data; both addresses match.



Why This Complexity?

Why not just use real addresses everywhere? Because this scheme lets us:
1. Reuse existing tools across different architectures
2. Keep toolchains simple with a unified linear address space for optimization
3. Enable relocations and section movement freely

Memory Map Comparison:

Code:
| Von Neumann (PC) | Harvard (Microcontroller) |
|                  |                           |
| Linear memory: Stack → Free → Data → Code | FLASH: Bootloader → rodata → Code |
| (single address space) | RAM: Stack → Heap → Data |
| | (parallel spaces with overlapping addresses) |

On Harvard architectures, address `0x100` could mean Flash or RAM depending on the access method. Compilers generate special instructions (like `pgm_read_byte`) to distinguish.

The Toolchain Flow:
1. Compiler produces relocations for variables
2. Linker maps variables to virtual addresses (e.g., `0x800100` → physical RAM `0x100`)
3. Linker patches code with correct addresses using relocation info
4. Objcopy extracts raw binary from ELF using physical addresses



Flashing the Device

Physical addresses drive the flashing process. From the ELF, `objcopy` generates a raw binary.

Bootloaders: Hobbyist boards often include a bootloader at the end of Flash (see the datasheet's "Boot Loader Support"). This small program waits for a signal (serial data or GPIO) at boot. If detected, it enters self-programming mode using `SPM` instructions, allowing firmware updates over serial without special hardware. This convenience comes at the cost of lost Flash space.

avrdude handles flashing for AVR microcontrollers, also programming fuses, lock bits, and EEPROM.

Fuses configure: clock speed, bootloader size/start address, boot mode, watchdog, debugging, etc.

Lock bits protect program memory and bootloader from unauthorized reading/writing.

Critical warning: Misconfigure fuses can permanently brick your device!



Summary

Embedded development uses standard toolchains (avr-gcc, binutils) with specialized linker scripts that map the device's exact memory layout. This abstraction enables powerful optimizations while keeping the toolchain architecture-agnostic. The Harvard architecture forces separate code/data spaces, but linker scripts cleverly handle this with virtual vs. physical addresses, letting startup code initialize RAM from Flash before your program runs.

Hopefully this clarifies why linker scripts are essential in embedded systems—not just academic trivia. The same principles apply across different microcontrollers, even if the specifics vary.



Comments, corrections, and questions welcome! As I said, I'm no expert, but I'll do my best to answer.