Sections vs Segments: Two Views of One File

Type this right now

# Compile a simple C program
gcc -o hello hello.c

# Two different views of the same binary:
echo "=== SECTIONS (compiler/linker view) ==="
readelf -S hello | head -40

echo ""
echo "=== SEGMENTS (loader/kernel view) ==="
readelf -l hello

Run both commands. You'll see two completely different lists describing the same file. By the end of this chapter, you'll understand why both exist and how they relate.


The key insight

An ELF file has two parallel indexing systems:

  • Sections — the compiler's and linker's view. Fine-grained. Named. Used during compilation and linking.
  • Segments — the loader's and kernel's view. Coarse-grained. Permission-based. Used when mapping the binary into memory.
         Compile time                         Run time
         ───────────                          ────────
    ┌─────────────────────┐            ┌──────────────────┐
    │  Section Header     │            │  Program Header   │
    │  Table              │            │  Table             │
    │                     │            │                    │
    │  .text              │            │  LOAD (r-x)        │
    │  .rodata            │──────────► │  (code + rodata)   │
    │  .data              │            │                    │
    │  .bss               │──────────► │  LOAD (rw-)        │
    │  .symtab            │            │  (data + bss)      │
    │  .strtab            │            │                    │
    │  .debug_*           │            │  (not loaded)      │
    └─────────────────────┘            └──────────────────┘
      Many small pieces                  Few big chunks

The linker needs sections so it can merge .text from file A with .text from file B. The kernel doesn't care about any of that — it just needs to know which bytes go to which addresses with which permissions.


Sections: the full catalog

Here are the sections you'll encounter most often:

Section     Contents                         Permissions
─────────   ──────────────────────────────   ───────────
.text       Machine code (your functions)    r-x
.rodata     Read-only data (string literals) r--
.data       Initialized global variables     rw-
.bss        Uninitialized globals (zeroed)   rw-
.symtab     Symbol table (function names)    ---  (not loaded)
.strtab     String table (section names)     ---  (not loaded)
.dynsym     Dynamic symbol table             r--
.dynstr     Dynamic string table             r--
.plt        Procedure Linkage Table          r-x
.got        Global Offset Table              rw-
.debug_*    DWARF debug information          ---  (not loaded)
.rel.text   Relocations for .text            ---  (not loaded)
.init       Startup code                     r-x
.fini       Cleanup code                     r-x

Not all sections get loaded into memory. .symtab, .strtab, and .debug_* exist only in the file — the kernel ignores them entirely. They're for the linker, debugger, and tools like nm.

🧠 What do you think happens? If .bss holds uninitialized globals that are all zero, how many bytes does it occupy in the file? Answer: zero. The section header records its size, but no actual bytes are stored. The kernel allocates and zeroes the memory at load time. This is why .bss saves disk space.


Segments: what the kernel actually maps

readelf -l hello
Program Headers:
  Type           Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  PHDR           0x000040 0x0000000000000040 0x0000000000000040 0x0002d8 0x0002d8 R   0x8
  INTERP         0x000318 0x0000000000000318 0x0000000000000318 0x00001c 0x00001c R   0x1
  LOAD           0x000000 0x0000000000000000 0x0000000000000000 0x000628 0x000628 R   0x1000
  LOAD           0x001000 0x0000000000001000 0x0000000000001000 0x000185 0x000185 R E 0x1000
  LOAD           0x002000 0x0000000000002000 0x0000000000002000 0x000114 0x000114 R   0x1000
  LOAD           0x002db8 0x0000000000003db8 0x0000000000003db8 0x000258 0x000260 RW  0x1000
  DYNAMIC        0x002dc8 0x0000000000003dc8 0x0000000000003dc8 0x0001f0 0x0001f0 RW  0x8
  NOTE           0x000338 0x0000000000000338 0x0000000000000338 0x000030 0x000030 R   0x8
  NOTE           0x000368 0x0000000000000368 0x0000000000000368 0x000044 0x000044 R   0x4
  GNU_EH_FRAME   0x00200c 0x000000000000200c 0x000000000000200c 0x00003c 0x00003c R   0x4
  GNU_STACK      0x000000 0x0000000000000000 0x0000000000000000 0x000000 0x000000 RW  0x10
  GNU_RELRO      0x002db8 0x0000000000003db8 0x0000000000003db8 0x000248 0x000248 R   0x1

The key segment types:

TypePurpose
LOADMap these bytes into memory. This is the main event.
INTERPPath to the dynamic linker (/lib64/ld-linux-x86-64.so.2)
DYNAMICDynamic linking information (needed libraries, symbol tables)
NOTEMetadata (build ID, ABI tag)
GNU_STACKStack permissions (notably: no execute)
GNU_RELROMark GOT as read-only after relocation (security)

The mapping: sections merge into segments

This is where the two views connect. readelf -l also shows you which sections fall into each segment:

 Section to Segment mapping:
  Segment Sections...
   00
   01     .interp
   02     .interp .note.gnu.property .note.gnu.build-id .note.ABI-tag
          .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt
   03     .init .plt .plt.got .plt.sec .text .fini
   04     .rodata .eh_frame_hdr .eh_frame
   05     .init_array .fini_array .dynamic .got .data .bss
   06     .dynamic
   07     .note.gnu.property
   08     .note.gnu.build-id .note.ABI-tag
   09     .eh_frame_hdr
   10     (empty — GNU_STACK has no sections)
   11     .init_array .fini_array .dynamic .got

Now the picture becomes clear:

SECTIONS (file)                          SEGMENTS (memory)
─────────────────                        ─────────────────

┌─────────────┐
│   .init     │───┐
├─────────────┤   │
│   .plt      │───┤
├─────────────┤   ├──►  LOAD segment 03 (R E)
│   .text     │───┤     Executable code
├─────────────┤   │
│   .fini     │───┘
├─────────────┤
│   .rodata   │───┬──►  LOAD segment 04 (R)
├─────────────┤   │     Read-only data
│  .eh_frame  │───┘
├─────────────┤
│   .data     │───┐
├─────────────┤   ├──►  LOAD segment 05 (RW)
│   .bss      │───┘     Read-write data
├─────────────┤
│  .symtab    │         NOT LOADED
├─────────────┤         (debug/linker use only)
│  .strtab    │         NOT LOADED
├─────────────┤
│  .debug_*   │         NOT LOADED
└─────────────┘

Multiple sections with the same permissions merge into one segment. The kernel doesn't need to know the difference between .init and .text — both are executable code, so they get one mmap() call with PROT_READ | PROT_EXEC.


Why two views?

The split exists because compilation and execution have different needs.

The linker needs to:

  • Merge .text from dozens of object files into one .text
  • Merge .data from dozens of object files into one .data
  • Apply relocations section by section
  • Keep debug info separate from code

The kernel needs to:

  • Map as few memory regions as possible (fewer page table entries)
  • Set permissions per region (read, write, execute)
  • Know the entry point
  • Know where the dynamic linker is

Sections are the how (fine-grained building blocks). Segments are the what (what goes where in memory with what permissions).

💡 Fun Fact: A fully stripped, statically linked binary can have zero sections and still run. The kernel only reads program headers. You can literally delete the section header table with a hex editor and the binary works fine. Tools like readelf -S will complain, but the kernel won't even notice.


Seeing your code in .text

objdump -d hello | grep -A 15 '<main>'
0000000000001149 <main>:
    1149:   f3 0f 1e fa             endbr64
    114d:   55                      push   rbp
    114e:   48 89 e5                mov    rbp,rsp
    1151:   48 8d 05 ac 0e 00 00    lea    rax,[rip+0xeac]  # 2004 <_IO_stdin_used+0x4>
    1158:   48 89 c7                mov    rdi,rax
    115b:   e8 f0 fe ff ff          call   1050 <puts@plt>
    1160:   b8 00 00 00 00          mov    eax,0x0
    1165:   5d                      pop    rbp
    1166:   c3                      ret

Address 0x1149 — that's in the .text section, which is inside the LOAD segment with R E permissions. The lea instruction at 0x1151 references address 0x2004 — that's the "Hello, ELF!\n" string in .rodata, inside the read-only LOAD segment.

Everything maps. Addresses in the code point to addresses in specific sections, which live in specific segments, which get specific permissions.


Rust comparison

// save as hello.rs, compile: rustc hello.rs
fn main() {
    println!("Hello, ELF!");
}
readelf -l hello_rs | grep LOAD
  LOAD  0x000000 0x0000000000000000 ... R   0x1000
  LOAD  0x009000 0x0000000000009000 ... R E 0x1000
  LOAD  0x05b000 0x000000000005b000 ... R   0x1000
  LOAD  0x07e458 0x000000000007f458 ... RW  0x1000

Same pattern: read-only, executable, read-only data, read-write. The Rust binary just has more of everything because it includes the standard library.


🔧 Task: Map sections to segments by hand

  1. Compile: gcc -o hello hello.c
  2. Run readelf -S hello — note the address and flags of each section
  3. Run readelf -l hello — note the address range and flags of each segment
  4. For each section, determine which segment contains it:
    • Does the section's address fall within the segment's [VirtAddr, VirtAddr+MemSiz) range?
    • Do the permissions match? (A writable section should be in a writable segment)
  5. Verify your mapping against the "Section to Segment mapping" output at the bottom of readelf -l
  6. Find a section that is NOT in any segment. Why isn't it loaded?

This exercise makes the two-view model concrete. Once you can do this mapping by hand, the linker and loader will never be mysterious again.