ELF Format: Static Linking and Dynamic Linking

ELF format used in different phases of program execution

1. Object Files (.o)

These are intermediate compilation artifacts - not executable on their own. ELF type: ET_REL (relocatable). Key characteristics:

  • Result of compiling a single source file
  • Contain unresolved symbols (references to functions/variables defined elsewhere)
  • Relocatable code that hasn’t been assigned final memory addresses
  • Must be linked together to create executables or libraries
  • Cannot be directly executed

Let’s do an exercise to understand the role of ELF in object files (.o)

Consider this C program

#include <stdio.h>

extern int helper_function(int x);

int global_var = 42;
static int static_var = 100;

int add_numbers(int a, int b) {
    return a + b;
}

int main() {
    int result = add_numbers(5, 3);
    printf("Result: %d\n", result);
    int helper_result = helper_function(result);
    return 0;
}

Let’s compile this file

$ gcc -c main.c -o main.o

Here -c flag tells GCC to only do the compilation step and not invoke linker. Otherwise GCC will invoke the static linker (ld) which will try to resolve the definition for helper_function which results in this error:

$ gcc main.c -o main.o
/usr/bin/ld: /tmp/cc28U7l2.o: in function `main':
main.c:(.text+0x55): undefined reference to `helper_function'
collect2: error: ld returned 1 exit status

The reason it doesn’t give same error for printf is because ld knows its defined in standard C library (libc.so). We will explore this more in a bit.

Now that we have our object file, we can start looking at its ELF headers

$ readelf -h  main.o
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              REL (Relocatable file)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  Entry point address:               0x0
  Start of program headers:          0 (bytes into file)
  Start of section headers:          928 (bytes into file)
  Flags:                             0x0
  Size of this header:               64 (bytes)
  Size of program headers:           0 (bytes)
  Number of program headers:         0
  Size of section headers:           64 (bytes)
  Number of section headers:         14
  Section header string table index: 13

Mainly we can see Type is REL (Relocatable file) which means this ELF is not an executable yet, but an intermediate object file generated by the compiler.

In this state:

  • The code and data for each function or global variable are present,
  • But addresses are not fixed — they’re expressed relative to sections, not absolute memory locations.
  • Any references to external symbols (like printf and helper_function) are recorded as relocation entries in sections such as .rela.text or .rela.data.
  • This file is meant to be processed by the linker, which will:
    • Combine multiple such relocatable files,
    • Resolve symbol references,
    • Apply relocations to produce either an executable (ET_EXEC) or a shared library (ET_DYN).

1. Section Header Table

Next, let’s examine the section header table of the object file

$ readelf -S main.o
There are 14 section headers, starting at offset 0x3a0:

Section Headers:
  [Nr] Name              Type             Address           Offset
       Size              EntSize          Flags  Link  Info  Align
  [ 0]                   NULL             0000000000000000  00000000
       0000000000000000  0000000000000000           0     0     0
  [ 1] .text             PROGBITS         0000000000000000  00000040
       0000000000000063  0000000000000000  AX       0     0     1
  [ 2] .rela.text        RELA             0000000000000000  00000298
       0000000000000060  0000000000000018   I      11     1     8
  [ 3] .data             PROGBITS         0000000000000000  000000a4
       0000000000000008  0000000000000000  WA       0     0     4
  [ 4] .bss              NOBITS           0000000000000000  000000ac
       0000000000000000  0000000000000000  WA       0     0     1
  [ 5] .rodata           PROGBITS         0000000000000000  000000ac
       000000000000000c  0000000000000000   A       0     0     1
  [ 6] .comment          PROGBITS         0000000000000000  000000b8
       000000000000002c  0000000000000001  MS       0     0     1
  [ 7] .note.GNU-stack   PROGBITS         0000000000000000  000000e4
       0000000000000000  0000000000000000           0     0     1
  [ 8] .note.gnu.pr[...] NOTE             0000000000000000  000000e8
       0000000000000020  0000000000000000   A       0     0     8
  [ 9] .eh_frame         PROGBITS         0000000000000000  00000108
       0000000000000058  0000000000000000   A       0     0     8
  [10] .rela.eh_frame    RELA             0000000000000000  000002f8
       0000000000000030  0000000000000018   I      11     9     8
  [11] .symtab           SYMTAB           0000000000000000  00000160
       00000000000000f0  0000000000000018          12     5     8
  [12] .strtab           STRTAB           0000000000000000  00000250
       0000000000000046  0000000000000000           0     0     1
  [13] .shstrtab         STRTAB           0000000000000000  00000328
       0000000000000074  0000000000000000           0     0     1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
  L (link order), O (extra OS processing required), G (group), T (TLS),
  C (compressed), x (unknown), o (OS specific), E (exclude),
  D (mbind), l (large), p (processor specific)

One thing we can notice right away is addresses of all sections are 0. Its because those sections only exist in isolation yet. The compiler does not know where in memory (or file) those sections will finally end up. So, all section addresses (sh_addr) are set to 0 as placeholders.

But we do see sh_offset being populated, it indicates the location of this section in the file, its important to identify the start of a section. For eg: .text section starts at 00000040.

Before diving into each sections, let’s quickly refresh what does each section do.

SectionWhat it meansWhy it exists in .o only
.rela.text, .rela.dataRelocation entries for .text/.dataNeeded by the linker to “fix up” addresses once final layout is known
.symtabFull symbol table (all functions, globals)Used by linker for name resolution
.strtabString table for symbol namesNeeded for symbol table lookup
.shstrtabString table for section namesUsed to label section headers
.bssUninitialized data (size but no content)Merged later
.text, .dataCode and initialized dataThese will be merged into segments in the final ELF

2. Symbol Table

$ readelf -s main.o
Symbol table '.symtab' contains 10 entries:
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     0: 0000000000000000     0 NOTYPE  LOCAL  DEFAULT  UND
     1: 0000000000000000     0 FILE    LOCAL  DEFAULT  ABS main.c
     2: 0000000000000000     0 SECTION LOCAL  DEFAULT    1 .text
     3: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 static_var
     4: 0000000000000000     0 SECTION LOCAL  DEFAULT    5 .rodata
     5: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 global_var
     6: 0000000000000000    24 FUNC    GLOBAL DEFAULT    1 add_numbers
     7: 0000000000000018    75 FUNC    GLOBAL DEFAULT    1 main
     8: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf
     9: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND helper_function

Note: The symbol table doesn’t store the actual string names. Instead, it stores indices into the string table (.strtab section). We see name in the output just for convinience.

nm is a simpler, more readable way to view symbols. It’s perfect for quick lookups

Symbol Types (the letter):

  • Uppercase = GLOBAL (exported, visible to linker)

    • T = Text section (code) - your functions
    • D = Data section (initialized global variables)
    • U = Undefined (needs to be resolved by linker)
  • Lowercase = LOCAL (internal to this file)

    • t = text section (static functions
    • d = data section (static variables
    • b = bss section (uninitialized data)

1. Unresolved Symbols (UND = Undefined)

8: ... NOTYPE  GLOBAL DEFAULT  UND printf
9: ... NOTYPE  GLOBAL DEFAULT  UND helper_function
  • Ndx = UND: Not defined in this object file
  • Value = 0: No address assigned yet
  • These will be resolved by the linker (either from other .o files or shared libraries)
  • This is the hallmark of relocatable object files!

2. Defined Functions (in .text section)

6: 0000000000000000  24 FUNC  GLOBAL DEFAULT  1 add_numbers
7: 0000000000000018  75 FUNC  GLOBAL DEFAULT  1 main
  • Ndx = 1: Located in section 1 (which is .text - see entry 2)
  • Value: Offset within .text section (not final memory address!)
    • add_numbers starts at offset 0
    • main starts at offset 0x18 (24 bytes later)
  • Size: Function sizes in bytes (24 and 75)
  • FUNC type: These are functions
  • GLOBAL bind: Visible to other object files during linking

3. Global vs Static Variables

3: 0000000000000004  4 OBJECT  LOCAL   DEFAULT  3 static_var
5: 0000000000000000  4 OBJECT  GLOBAL  DEFAULT  3 global_var
  • Ndx = 3: Both in section 3 (.data - initialized data)
  • LOCAL vs GLOBAL bind:
    • static_var is LOCAL → only visible within this file (can’t be linked from outside)
    • global_var is GLOBAL → can be accessed by other object files
  • Size = 4: Both are 4-byte integers
  • Notice different offsets: 0 and 4

4. Special Entries


0: ... NOTYPE  LOCAL  DEFAULT  UND     [empty]
1: ... FILE    LOCAL  DEFAULT  ABS     main.c
2: ... SECTION LOCAL  DEFAULT  1       .text

3. Text Section

$  objdump -d -j .text main.o

main.o:     file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <add_numbers>:
   0:	f3 0f 1e fa          	endbr64
   4:	55                   	push   %rbp
   5:	48 89 e5             	mov    %rsp,%rbp
   8:	89 7d fc             	mov    %edi,-0x4(%rbp)
   b:	89 75 f8             	mov    %esi,-0x8(%rbp)
   e:	8b 55 fc             	mov    -0x4(%rbp),%edx
  11:	8b 45 f8             	mov    -0x8(%rbp),%eax
  14:	01 d0                	add    %edx,%eax
  16:	5d                   	pop    %rbp
  17:	c3                   	ret

0000000000000018 <main>:
  18:	f3 0f 1e fa          	endbr64
  1c:	55                   	push   %rbp
  1d:	48 89 e5             	mov    %rsp,%rbp
  20:	48 83 ec 10          	sub    $0x10,%rsp
  24:	be 03 00 00 00       	mov    $0x3,%esi
  29:	bf 05 00 00 00       	mov    $0x5,%edi
  2e:	e8 00 00 00 00       	call   33 <main+0x1b>
  33:	89 45 f8             	mov    %eax,-0x8(%rbp)
  36:	8b 45 f8             	mov    -0x8(%rbp),%eax
  39:	89 c6                	mov    %eax,%esi
  3b:	48 8d 05 00 00 00 00 	lea    0x0(%rip),%rax        # 42 <main+0x2a>
  42:	48 89 c7             	mov    %rax,%rdi
  45:	b8 00 00 00 00       	mov    $0x0,%eax
  4a:	e8 00 00 00 00       	call   4f <main+0x37>
  4f:	8b 45 f8             	mov    -0x8(%rbp),%eax
  52:	89 c7                	mov    %eax,%edi
  54:	e8 00 00 00 00       	call   59 <main+0x41>
  59:	89 45 fc             	mov    %eax,-0x4(%rbp)
  5c:	b8 00 00 00 00       	mov    $0x0,%eax
  61:	c9                   	leave
  62:	c3                   	ret

1. Relocatable addresses (offset-based, not absolute)

0000000000000000 <add_numbers>:
0000000000000018 <main>:
  • Functions start at offsets 0x0 and 0x18 within .text section
  • These are NOT final memory addresses
  • After linking, these will be relocated to actual addresses (like 0x401000)
  • This is what makes it “relocatable”

2. Placeholder addresses needing relocation

2e:	e8 00 00 00 00       	call   33 <main+0x1b>
  • e8 = x86-64 call instruction
  • 00 00 00 00 = temporary placeholder!
  • Should call add_numbers but address unknown yet
  • Linker will patch this with the actual offset

Let’s examine this entry little more deeply to understand what does 33 means here

33 is what objdump calculated as the would-be destination if the call executed right now (even though it’s wrong):

  • Current instruction at: 0x2e
  • Call instruction is 5 bytes: e8 00 00 00 00
  • Next instruction after call: 0x2e + 5 = 0x33
  • The call instruction uses PC-relative addressing: means address to be jumped will be specified as offset to the current instruction
  • The operand 00 00 00 00 means “offset of 0”
  • So objdump assumes the address of add_numbers is at : 0x33 + 0 = 0x33
3b:	48 8d 05 00 00 00 00 	lea    0x0(%rip),%rax
  • Loading address of string from .rodata
  • 00 00 00 00 = placeholder for string address
  • Needs relocation
4a:	e8 00 00 00 00       	call   4f <main+0x37>    # printf
54:	e8 00 00 00 00       	call   59 <main+0x41>    # helper_function
  • Both calls to undefined symbols
  • Both have placeholder addresses
  • Will be resolved by linker

4. Data Section (initialized global/static variables)

$ objdump -s -j .data main.o

main.o:     file format elf64-x86-64

Contents of section .data:
 0000 2a000000 64000000                    *...d...

Note: Don’t use -D flag for viewing .data and related sections, as it will try to disassemble it which will result in nonsense output.

  • 2a 00 00 00 = 0x0000002a = 42 (decimal) = global_var
  • 64 00 00 00 = 0x00000064 = 100 (decimal) = static_var

5. .rodata section (read-only data - string literals)

$ objdump -s -j .rodata main.o

main.o:     file format elf64-x86-64

Contents of section .rodata:
 0000 52657375 6c743a20 25640a00           Result: %d..

That’s our “Result: %d\n” string!

6. .bss section (uninitialized data)

$ objdump -s -j .bss main.o

main.o:     file format elf64-x86-64

For our current code, .bss should exist but be empty (size 0) because we have no uninitialized global variables.

What changes when it becomes an executable?

AspectIn Object File (ET_REL)In Executable (ET_EXEC)
TypeREL (Relocatable)EXEC (Executable)
.rela.*Present — describes where to fix addressesRemoved — relocations are already applied
.symtabFull symbol table (internal + external)Usually replaced by .dynsym (only for dynamic linking) or stripped entirely
.strtabPresent (for linker use)Removed or replaced by .dynstr
.text/.dataNo fixed addressesMapped to virtual memory addresses and grouped into segments
Program HeadersNoneAdded — describe how to load into memory (e.g., PT_LOAD for code/data)
Entry point0x0Actual runtime entry (e.g. _start at 0x401000)
Section Header TableExistsCan be stripped (strip --strip-all) since loader doesn’t need it

2. Executable Files (a.out, /bin/ls)

These are complete, ready-to-run programs. The ELF header marks them with type ET_EXEC (older) or ET_DYN (modern PIE executables). Key characteristics:

  • Contain a program entry point
  • Have all necessary code to start execution
  • Include references to shared libraries they need (stored in dynamic section)
  • Can be directly executed by the OS loader

3. Shared Libraries (.so)

These are collections of reusable code loaded at runtime. ELF type: ET_DYN. Key characteristics:

  • Position-independent code (PIC) that can load at any memory address
  • Can be shared by multiple running programs simultaneously
  • Loaded dynamically at runtime by the dynamic linker (ld.so)
  • Reduce memory usage and disk space through code sharing