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