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
printfandhelper_function) are recorded as relocation entries in sections such as.rela.textor.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.
| Section | What it means | Why it exists in .o only |
|---|---|---|
.rela.text, .rela.data | Relocation entries for .text/.data | Needed by the linker to “fix up” addresses once final layout is known |
.symtab | Full symbol table (all functions, globals) | Used by linker for name resolution |
.strtab | String table for symbol names | Needed for symbol table lookup |
.shstrtab | String table for section names | Used to label section headers |
.bss | Uninitialized data (size but no content) | Merged later |
.text, .data | Code and initialized data | These 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
.ofiles 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
.textsection (not final memory address!)add_numbersstarts at offset 0mainstarts 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_varis LOCAL → only visible within this file (can’t be linked from outside)global_varis 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
.textsection - 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 instruction00 00 00 00= temporary placeholder!- Should call
add_numbersbut 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
callinstruction uses PC-relative addressing: means address to be jumped will be specified as offset to the current instruction - The operand
00 00 00 00means “offset of 0” - So
objdumpassumes the address ofadd_numbersis 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_var64 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?
| Aspect | In Object File (ET_REL) | In Executable (ET_EXEC) |
|---|---|---|
| Type | REL (Relocatable) | EXEC (Executable) |
| .rela.* | Present — describes where to fix addresses | Removed — relocations are already applied |
| .symtab | Full symbol table (internal + external) | Usually replaced by .dynsym (only for dynamic linking) or stripped entirely |
| .strtab | Present (for linker use) | Removed or replaced by .dynstr |
| .text/.data | No fixed addresses | Mapped to virtual memory addresses and grouped into segments |
| Program Headers | None | Added — describe how to load into memory (e.g., PT_LOAD for code/data) |
| Entry point | 0x0 | Actual runtime entry (e.g. _start at 0x401000) |
| Section Header Table | Exists | Can 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