What Does a Linker Actually Do? A Hands-On Linker Script Experiment
A compiler can generate machine instructions without knowing where those instructions will finally live in memory. We pass C and assembly code into a compiler driver like clang or gcc, and out comes an executable file.
Compilers do not produce executable binaries. They produce relocatable object files filled with unpatched machine code, isolated symbol tables, and explicit “fix-up instructions” called relocations.
The linker turns those separate, address-incomplete pieces into one address-assigned image. It decides where sections and symbols go, resolves references between object files, patches machine instructions and data, and writes the result into an executable format such as ELF.
Compiler:
emits bytes, symbols and relocation requests
Linker script:
describes layout constraints
Linker:
chooses addresses and patches references
Executable format:
records the resulting bytes and metadata
OS loader / bootloader:
maps or copies those bytes into memory
CPU:
begins executing at the entry address
The Linker accepts one or more object files, and generate a final executable file.
object-file bytes + symbols + relocations
↓
assign final addresses
↓
patch address references
↓
executable / kernel
This experiment in this article will create ELF object files, links them using LLVM LLD and a real GNU-style linker script, and lets you inspect every stage.
Make sure you’ve installed the tools we need:
brew install llvm lld
Define the path variable:
export LLVM_BIN="$(brew --prefix llvm)/bin"
export LLD_BIN="$(brew --prefix lld)/bin"
Check them:
"$LLVM_BIN/clang" --version
"$LLD_BIN/ld.lld" --version
"$LLVM_BIN/llvm-readelf" --version
Source codes
Create a entry point in assembly codes:
/* Assembler directive:
* select an executable, allocatable object-file section
*/
.section .text.start, "ax"
/* Assembler directive:
* export _start as a global symbol
*/
.global _start
/* Assembler directive:
* mark _start as an ELF function symbol
*/
.type _start, %function
/* Label:
* assign the name _start to the current address
*/
_start:
/* CPU instruction:
* call kernel_main, storing the return address in x30
*/
bl kernel_main
/* Local label inside _start */
hang:
/* CPU instruction:
* branch to itself forever
*/
b hang
/* Assembler directive:
* record the function size as current_position - _start
*/
.size _start, . - _start
- assembler directives: instructions to the assembler, such as
.section,.global, and.type - AArch64 CPU instructions: instructions executed by the processor, such as
blandb
# main.c
extern int add(int a, int b);
const char message[] = "Hello from the linker";
int initialized_value = 40;
int uninitialized_value;
void kernel_main(void)
{
uninitialized_value =
add(initialized_value, 2);
for (;;) {
}
}
This file deliberately contains several kinds of content:
kernel_main → code
message → read-only data
initialized_value → initialized writable data
uninitialized_value → zero-initialized data
This file provide the add symbol.
# math.c
int add(int a, int b)
{
return a + b;
}
Compile the assembly
"$CC" \
--target=aarch64-none-elf \
-c start.S \
-o start.o
Compile the C files:
"$CC" \
--target=aarch64-none-elf \
-ffreestanding \
-fno-stack-protector \
-fno-pic \
-fno-unwind-tables \
-fno-asynchronous-unwind-tables \
-ffunction-sections \
-fdata-sections \
-O0 \
-c main.c \
-o main.o
"$CC" \
--target=aarch64-none-elf \
-ffreestanding \
-fno-stack-protector \
-fno-pic \
-fno-unwind-tables \
-fno-asynchronous-unwind-tables \
-ffunction-sections \
-fdata-sections \
-O0 \
-c math.c \
-o math.o
Now, we have these files:
ls
main.c
main.o
math.c
math.o
start.S
start.o
Inspect the symbols before linking
file start.o main.o math.o
You should see something like this:
ELF 64-bit LSB relocatable, ARM aarch64
relocatable means these files contain machine code, but their final addresses have not yet been decided.
The llvm-nm utility lists the names of symbols from LLVM bitcode files, object files, and archives.
"$LLVM_BIN/llvm-nm" start.o
0000000000000000 T _start
0000000000000004 t hang
U kernel_main
| T | Text (code) object. Upper-case T means global; lower-case t means local
|U| Undefined symbol. This symbol is referenced in the file but not defined there|
The numbers in the first column (0000000000000000 and 0000000000000004) represent the offset, in bytes, of the symbol within the section in the object file (.o).
Because the machine code of the _start function occupies exactly 4 bytes, 0x04 - 0x00 = 4 bytes.
% /opt/homebrew/opt/llvm/bin/llvm-nm main.o
U add
0000000000000000 D init_i
0000000000000000 T kernel_main
0000000000000000 R msg
0000000000000000 B uninit_i
我们再看一下main.o的symbols。可以看到这些symbols的偏移量都相同,因为偏移量是相对于该符号所在节区起始地址的偏移。这些symbols都在不同的sections中。
Inspect unresolved relocations
"$LLVM_BIN/llvm-readelf" -r start.o
Create layout.ld
LLD’s ELF port implements GNU-style linker-script features, including SECTIONS, input-section matching, address expressions, and KEEP.
ENTRY(_start)
SECTIONS
{
/*
* The location counter.
* Start the image at virtual address 0x400000.
*/
. = 0x400000;
__image_start = .;
/*
* Gather executable input sections.
*/
.text : ALIGN(0x1000)
{
KEEP(*(.text.start))
*(.text)
*(.text.*)
}
/*
* Gather read-only data.
*/
.rodata : ALIGN(0x1000)
{
*(.rodata)
*(.rodata.*)
}
/*
* Gather initialized writable data.
*/
.data : ALIGN(0x1000)
{
*(.data)
*(.data.*)
}
/*
* Gather zero-initialized writable data.
*/
.bss : ALIGN(0x1000)
{
__bss_start = .;
*(.bss)
*(.bss.*)
*(COMMON)
__bss_end = .;
}
__image_end = .;
/DISCARD/ :
{
*(.comment)
*(.note*)
}
}
Link using our Linker Script:
"$LLD_BIN/ld.lld" \
-m aarch64elf \
-T layout.ld \
-Map=kernel.map \
start.o main.o math.o \
-o kernel.elf
"$LLVM_BIN/llvm-nm" -n kernel.elf
-n sorts symbols by address.
Disassemble the linked program
"$LLVM_BIN/llvm-objdump" \
--disassemble \
--symbolize-operands \
kernel.elf