← Back to all posts

Symbols, Sections, and Relocations - A Practical Linker Tutorial

[ Code & Dev ]
[ Linker ] [ ELF ] [ LLVM ] [ LLD ] [ Relocation ]

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 bl and b
# 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