Linker and Linker Scripts: From Theory to Practice
Introduction
If you have read the author's blog series "Deep Dive into C/C++ Compilation Principles," you likely already have a preliminary understanding of linkers. To briefly recap: the compiler is responsible for converting source code into object files, while the linker acts as the final stage in the build process, combining these object files into the final executable program.
Related Reading:
In embedded development, the importance of the linker is often underestimated. In reality, the linker's configuration and optimization strategies directly impact the program's code size, runtime performance, and even determine whether the program can start correctly. This article will take you deep into the working principles of the linker, focusing on writing linker scripts and implementing startup code, helping you build smaller, faster, and more reliable embedded programs.
1. Basic Working Principles of the Linker
Before diving into linker scripts, let's clarify exactly what the linker does. Understanding these basic concepts will help us write and debug linker scripts more effectively.
1.1 The Four Core Tasks of the Linker
The linker's work may seem mysterious, but it can actually be summarized into the following four core tasks:
(1)Symbol Resolution
When you call a function defined in another file within one file, the compiler only knows the function's name, not its actual address. The linker's responsibility is to find the actual definition of this function and establish the connection:
// main.cpp
extern int calculate(int x, int y); // Declaration only
int main() {
return calculate(10, 20);
}(2)Address Assignment
The linker assigns final memory addresses to all code and data in the program. This process seems simple, but it is crucial in embedded systems—because different types of memory (FLASH, RAM) have different physical addresses and access characteristics.
(3)Section Merging
Each object file generated by the compiler contains multiple sections, such as .text (code), .data (initialized data), and .bss (uninitialized data). The linker merges sections of the same type from all files together to form the unified layout of the final executable file.
(4)Library Linking
Programs typically use standard libraries or third-party libraries. The linker is responsible for extracting the required code from these libraries and integrating them into the final executable file.
2. Why Do Embedded Systems Need Custom Linker Scripts?
After understanding the basic work of the linker, you might ask: Don't compilers and linkers automatically complete these tasks? Why do we need to manually write linker scripts? This is because—embedded systems are diverse, and sometimes require mass production, requiring us to consider these details for cost optimization.
2.1 Memory Constraints in Embedded Systems
In embedded systems, memory is a scarce and fragmented resource, fundamentally different from general-purpose computers:
- The startup vector must be placed at a specific address: After reset, the processor reads the interrupt vector table from a fixed address.
- Program code must reside in FLASH: FLASH is non-volatile storage; code is not lost after power-off.
- Read-only constants should reside in FLASH: Fully utilize FLASH space to save precious RAM.
- Runtime variables need to be placed in RAM: RAM is readable and writable, but data is lost after power-off.
- C++ global objects need to be constructed correctly: The calling of constructors requires support from dedicated startup code.
- Stack and heap must also be configured correctly: Ensure the program has sufficient stack space and heap space.
The default strategy of compilers and linkers is designed for general systems and cannot meet these hardware constraints at all. This is why we need linker scripts—it is the configuration file we use to tell the linker "how to organize memory on this specific hardware."
2.2 Core Concepts of Linker Scripts
Before writing a linker script, let's understand a few of the most important concepts:
MEMORY Region Definition Defines the name, origin, and length of physical memory regions. For example:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}SECTIONS Output Section Definition Tells the linker how to organize various input sections (from object files) into output sections and place them in which MEMORY region:
SECTIONS
{
.text : { *(.text*) } > FLASH
.data : { *(.data*) } > RAM AT > FLASH
}Symbol Export Linker scripts can define symbols that will be used in startup code, for example:
__text_start__/__text_end__: Start and end addresses of the.textsection.__data_start__/__data_end__: Start and end addresses of the.datasection.__stack_top__: Stack top address.
Common Control Directives
KEEP(): Prevent certain sections from being optimized out (e.g., interrupt vector tables).PROVIDE(): Provide a default value for a symbol.ASSERT(): Perform constraint checks at link time.
2.3 The Role of Different Sections
Understanding the role of different sections is crucial for writing correct linker scripts:
.text— Executable code section, usually placed in FLASH..rodata— Read-only constant section (e.g., string literals), also placed in FLASH..data— Initialized global/static variables. This section is special: its content resides in FLASH at link time (because initial values need to be saved), but must be copied to RAM at runtime (because variables need to be writable)..bss— Uninitialized global/static variables, existing only in RAM, and need to be zeroed at startup. Since they don't need to save initial values,.bssdoes not occupy FLASH space.
3. Practice: Writing a Complete Linker Script
Enough theory, let's write a real, usable linker script. This example targets ARM Cortex-M microcontrollers, but the principles apply to all embedded platforms.
3.1 Minimal Usable Linker Script
Expand (47 lines)Collapse
ENTRY(Reset_Handler)
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K
}
SECTIONS
{
.isr_vector :
{
KEEP(*(.isr_vector))
} > FLASH
.text :
{
*(.text*)
*(.rodata*)
KEEP(*(.init))
KEEP(*(.fini))
} > FLASH
.data :
{
__data_start__ = .;
*(.data*)
. = ALIGN(4);
__data_end__ = .;
} > RAM AT > FLASH
.bss :
{
__bss_start__ = .;
*(.bss*)
*(COMMON)
. = ALIGN(4);
__bss_end__ = .;
} > RAM
.stack :
{
. = ALIGN(8);
__stack_top__ = .;
. = . + 0x1000; /* 4KB stack */
} > RAM
}3.2 Script Analysis
The key points of this script:
- Interrupt Vector Table (
.isr_vector) must be at the very beginning of FLASH because the processor reads it from a fixed address after reset. - Code Section (
.text) follows immediately, containing all executable code and read-only constants. - Dual Addresses of
.dataSection:AT > FLASHspecifies the Load Address (LMA), i.e., the location of data in FLASH.> RAMspecifies the Virtual Address (VMA), i.e., where data should be in RAM during runtime.- Startup code needs to copy data from LMA to VMA.
- Symbol Export: Symbols like
__data_start__,__data_end__,__bss_start__,__bss_end__will be used by the startup code.
4. Startup Code: Bringing the Linker Script to Life
With the linker script, the program's memory layout is determined. But this is not enough—we need startup code to complete key initialization work so the program can run correctly.
4.1 Complete Startup Code Flow
After the processor resets, it jumps to Reset_Handler to execute. This is the first segment of code in the entire program, and its responsibilities are:
- Disable Interrupts (Optional, depends on platform).
- Copy
.dataSection: Copy initialized data from FLASH to RAM. - Zero
.bssSection: Clear the uninitialized data area. - Call C++ Global Constructors (if using C++).
- Set Stack Pointer.
- Jump to
mainFunction.
4.2 Startup Code Implementation Example
Expand (34 lines)Collapse
extern "C" void Reset_Handler() {
// 1. Copy .data section from FLASH to RAM
extern uint32_t __data_start__, __data_end__;
extern uint32_t __load_data__; // LMA provided by linker script
uint32_t* src = &__load_data__;
uint32_t* dst = &__data_start__;
while (dst < &__data_end__) {
*dst++ = *src++;
}
// 2. Zero .bss section
extern uint32_t __bss_start__, __bss_end__;
dst = &__bss_start__;
while (dst < &__bss_end__) {
*dst++ = 0;
}
// 3. Call C++ global constructors
extern void (*__init_array_start[])();
extern void (*__init_array_end[])();
for (auto func = __init_array_start; func < __init_array_end; ++func) {
(*func)();
}
// 4. Call main
extern int main();
main();
// 5. If main returns, enter infinite loop
while (1) {}
}4.3 Why Are These Steps Necessary?
Why copy .data? Initialized global variables need to save their initial values, which are stored in FLASH (non-volatile). However, the program needs to modify these variables at runtime, and FLASH is typically read-only, so data must be copied to RAM.
Why zero .bss? According to the C/C++ standard, uninitialized global variables should be initialized to 0. However, to save FLASH space, the compiler does not store 0 values for these variables in the image; instead, the program is responsible for zeroing them at startup.
Why call constructors? C++ global objects need to be constructed before main. The compiler places the addresses of these constructors in the __init_array array, and the startup code is responsible for calling them one by one.
5. Special Considerations for C++ Development
If you use C++ for embedded development, you need to pay attention to some additional issues. C++ advanced features (such as global objects, exceptions, RTTI) bring extra complexity to the linking and startup process.
5.1 Global Object Construction Order
C++ has a famous "Static Initialization Order Fiasco":
- Within the same translation unit: The initialization order of objects is consistent with their order of appearance in the code.
- Between different translation units: The initialization order is undefined!
This can lead to a situation where a constructor of one object uses another object that has not yet been constructed. Solutions:
- Avoid dependencies between global objects (Most recommended).
- Use Meyers Singleton (function-local static variables).
- Use
__attribute__((init_priority(100)))(GCC extension, use with caution).
// Meyers Singleton
class Config {
public:
static Config& getInstance() {
static Config instance; // Initialized on first call
return instance;
}
// ...
};5.2 C++ Support in Linker Scripts
Ensure the linker script correctly handles C++ related sections:
SECTIONS
{
/* ... other sections ... */
.ARM.extab : { *(.ARM.extab* .gnu.linkonce.armextab.*) } > FLASH
.ARM.exidx : { *(.ARM.exidx* .gnu.linkonce.armexidx.*) } > FLASH
.init_array : {
PROVIDE_HIDDEN(__init_array_start = .);
KEEP(*(SORT(.init_array.*)))
KEEP(*(.init_array*))
PROVIDE_HIDDEN(__init_array_end = .);
} > FLASH
}If these sections are incorrectly discarded, constructors will not be called, or exception handling will fail.
5.3 Optimization Suggestions
The golden rule for embedded C++ development: Don't use advanced features if you can avoid them.
- Disable Exceptions: Use the
-fno-exceptionscompiler flag (exception handling significantly increases code size). - Disable RTTI: Use the
-fno-rtticompiler flag (runtime type information is rarely used). - Avoid Dynamic Memory Allocation: Embedded systems usually lack complete heap management.
- Put Constants in FLASH: Use
constandconstexprto let data enter the.rodatasection.
6. Link Optimization Techniques and Best Practices
With the basics mastered, let's look at how to further optimize the linking process to reduce code size and improve startup speed.
6.1 Function-Level Linking Optimization
Use the compiler's section options and the linker's garbage collection functionality:
# GCC flags
-ffunction-sections -fdata-sections -Wl,--gc-sectionsThis way, if a function is not called by the program, the linker will automatically remove it from the final image.
6.2 Memory Usage Optimization
Technique 1: Put Constants in FLASH
const char* error_msg = "System Error"; // Stored in .rodata (FLASH)Technique 2: Avoid Non-Zero Initialization of Large Arrays
// Bad: occupies FLASH space
int buffer[1000] = {1, 2, 3, ...};
// Good: occupies only RAM, initialized at runtime
int buffer[1000]; // .bss sectionTechnique 3: Use ASSERT() for Constraint Checks
/* Ensure stack fits in RAM */
ASSERT(__stack_top__ <= ORIGIN(RAM) + LENGTH(RAM), "Stack overflow detected")6.3 Startup Performance Optimization
Measuring Constructor Overhead C++ global object construction can be very time-consuming. You can:
- Use DWT performance counters to measure startup time.
- Check the
.mapfile to see which functions take up a lot of space. - Avoid complex operations in constructors (file I/O, dynamic allocation, peripheral initialization).
Lazy Initialization Defer non-urgent initialization to main or first use:
class Sensor {
public:
Sensor() : initialized(false) {}
void read() {
if (!initialized) {
init_hardware();
initialized = true;
}
// ...
}
private:
bool initialized;
};