JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

ELF in Depth: Sections, Segments, Symbols

You already know an object file goes in and an executable comes out. Now we crack the file open. ELF turns out to have two completely different maps of the same bytes — one for the linker, one for the loader — and a symbol table that ties everything together. Learn to read both maps and the rest of this rung becomes navigation rather than guesswork.

What ELF actually is

Back in the Toolchain rung you watched the assembler turn one translation unit into an object file, then watched the linker stitch object files into an executable and the loader bring it into memory. Each of those tools was reading and writing the same kind of file, and on Linux that file is ELF — the Executable and Linkable Format. The name is the whole point: one format serves both the link side and the load side. An ELF file is not magic; it is a plain byte array on disk with a strict, well-documented layout, and once you can read that layout the entire toolchain stops being a black box.

Three flavours of ELF file all share the format. A relocatable file (the .o that the assembler emits) is unfinished: it has code and data but its addresses are not yet pinned down, and it expects a linker to fix them. An executable file (your a.out) is ready to run at known addresses. And a shared object (a .so, the dynamic library) is a hybrid — relocatable enough to be loaded at many addresses, complete enough to run. Same header structure, same section idea, same symbol table machinery throughout; only the type field and the level of finishing differ. That uniformity is exactly why one tool family can handle the whole pipeline.

The header: the file's table of contents

Every ELF file opens with a fixed-size ELF header, and it is the one part whose location you always know: it sits at file offset 0x0. The very first four bytes are the magic number 0x7f then the ASCII letters 'E', 'L', 'F' — so a hex dump of any ELF file starts 7f 45 4c 46, which is how the kernel and tools recognise the format at a glance. The ELF header then records the essentials: 32-bit or 64-bit, little-endian or big-endian, the file type (relocatable / executable / shared / core), the target machine (x86-64, AArch64, ...), and the entry point — the virtual address where execution begins once the file is loaded.

But the header's most important job is to point at the two other big tables in the file, and this is where ELF's dual nature first shows up. It holds the file offset and entry count of the section header table — the linker's map — and separately the file offset and entry count of the program header table — the loader's map. One small header, two pointers, two entirely different views of the same body of bytes. Notice neither table needs to live at a fixed place; the header tells you where each one starts, so a tool reads the header first and then follows the pointers. Everything else in the file is found by jumping from this table of contents.

Sections: the linker's view

A section is a named, typed run of bytes meant for one purpose, and sections are how the linker thinks. You already met the cast in the Memory rung as runtime regions, but here they are concrete pieces of a file. The code your compiler emitted lives in .text. Initialised globals (a `static int n = 5;`) live in .data. Read-only constants and string literals live in .rodata. The symbol table is itself a section called .symtab, the names those symbols use live in .strtab, and the relocation lists live in sections like .rela.text. Each section header records the section's name, type, size, file offset, and required alignment — a tidy directory entry per piece.

One section deserves special attention because it teaches a real trick: .bss, the home of zero-initialised globals. Write `static int table[1000];` with no initial value and the C standard says every entry starts at zero. Storing a thousand explicit zero bytes in the file would be pure waste, so the .bss section occupies no space on disk at all — its section header records a size but a zero file footprint. At load time the loader simply maps a fresh zero-filled region of that size. This is the first hint that file layout and memory layout are not the same thing: a section can be large in memory yet weigh nothing on disk.

Why does the linker want this fine-grained view? Because linking is fundamentally a merge and resolve job done section by section. When you link three object files, the linker concatenates all their .text sections into one, all their .data into one, all their .rodata into one, and so on, choosing a final address for each merged section. Then — using the symbol table and the relocation entries we will dig into next guide — it patches every reference so the merged result is internally consistent. The section view exists precisely so the linker can group like with like and lay them out deliberately. This per-section merging is what makes separate compilation actually work.

Segments: the loader's view

Now flip to the other map. A segment, described by an entry in the program header table, is a chunk of the file that the loader will map into memory in one go, with one set of permissions. The loader does not care that your code came from .text and your constants from .rodata; it cares only which bytes go where in the address space and what they are allowed to do. So segments are coarse where sections are fine. A typical executable has just a handful: one read-plus-execute segment carrying the code, one read-only segment carrying constants, one read-write segment carrying data. The sections-versus-segments distinction is the heart of this guide — same bytes, two groupings, two purposes.

The relationship between the two views is a grouping: each loadable segment is built from one or more whole sections that happen to need the same permissions. The .text and .init code sections fold into the executable segment; .rodata and friends fold into the read-only segment; .data and .bss fold into the writable segment. This is why the permissions matter so much — code is mapped read-and-execute but not writable (you cannot scribble over your own instructions, a real defence against attacks), constants are read-only, and only true data is writable. The split is not bureaucratic; it is the foundation of memory protection you met when we covered the address-space layout.

ONE ELF FILE, TWO MAPS

  section view (linker)            segment view (loader)
  -------------------------        ----------------------------
  .text    code           ---\
  .init    startup code   ----+--> LOAD  r-x   (executable)
                             /
  .rodata  constants      ---+----> LOAD  r--   (read-only)
  .data    init'd globals ---\
  .bss     zero globals   ----+--> LOAD  rw-   (writable)
  .symtab  symbols        ---/      (not loaded -- link-time only)
  .strtab  names          ---       (not loaded -- link-time only)

fine-grained, by purpose          coarse, by permission
The same file, grouped two ways. The linker reads sections (left); the loader reads segments (right). Several sections fold into one segment when they share permissions, and link-time-only sections like .symtab are not mapped at all.

Symbols: the names that tie it together

Neither map means much without the third pillar: the symbol table. A symbol is just a named thing the linker might need to refer to — a function, a global variable, a section start. When you write a function in one file and call it from another, the call site has no idea where the function will end up; it only knows its name. The symbol table is the directory that turns names into locations. Each entry pairs a name (an index into .strtab) with a value (typically an offset or address), a size, a type (is this a function or an object?), and crucially a binding and a definition status.

The definition status is the distinction that drives linking. A symbol is either defined — this file actually contains the thing, here is its location — or undefined, meaning "I use this name but somebody else must provide it." Call printf() from your code and your .o lists printf as undefined; the C library's .o lists it as defined. The whole job of symbol resolution is matching every undefined symbol to exactly one definition somewhere in the inputs. If two files define the same name you get a multiple-definition error; if no file defines a used name you get the linker error every C programmer eventually meets, the dreaded "undefined reference to ...".

Two more attributes round out the picture and both get their own guide later. A symbol's binding can be global (visible to other files, the default for a non-static function) or local (private to its own file, which is what `static` does — it keeps the name out of everyone else's resolution), and there is a softer third option, the weak symbol, a global symbol that politely yields if a stronger definition exists. Separately, a global symbol carries a visibility that controls whether it is exported from a shared library at all. Together — name, value, type, binding, visibility, defined-or-not — these few fields per symbol are the entire alphabet the linker resolves your program from.

Reading it yourself, and the road ahead

None of this has to stay abstract — the format is open and the tools to read it ship with every toolchain. Run `readelf -h a.out` to print the ELF header, `readelf -S a.out` to list the sections (capital S, the linker's map), `readelf -l a.out` to list the program headers (the loader's map, and watch it print which sections map into which segment), and `readelf -s a.out` to dump the symbol table. The `nm a.out` command shows symbols more compactly, with a letter for each: 'T' for a defined text symbol, 'U' for undefined, 'W' for weak. Pair these with `objdump -d` for disassembly and you can verify every claim in this guide on a real binary in about a minute.

Hold on to the three pillars and the rest of this rung unfolds from them. We have the section view (how the linker groups and merges), the segment view (how the loader maps and protects), and the symbol table (how names become locations). What we have not yet explained is the actual patching: when the linker merges sections and chooses addresses, every reference to a symbol whose address just changed must be corrected. That correction is a relocation, and the next guide opens up the relocation entries and walks symbol resolution end to end. After that we contrast static against dynamic linking, then meet the PLT and GOT that make dynamic calls work, and finally symbol versioning and the dynamic loader itself.