Linkingtarget

Object Files

What is actually in a `.o`: sections holding code and data, a symbol table saying what is defined and what is needed, relocation records saying which bytes to patch, and debug metadata. `.bss` occupies no bytes in the file at all, and understanding why explains the whole format.

The question

What is inside a .o file, and why is .bss free?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

One translation unit's output as a container of independently addressable, attributed byte regions plus the metadata needed to combine it with others. Not "machine code in a file": code, initialised data, uninitialised data, read-only data, symbols and relocations are all separate because the linker treats them differently — it concatenates some, sums the sizes of others, resolves a third kind and executes a fourth as arithmetic.

What this phase may assume or do

A section may be merged with another only if their attributes agree — the same permissions, the same allocation behavior, the same merge semantics — which is why .text cannot absorb .rodata even though both are read-only in the final image, and why .bss is never concatenated with .data. A symbol may be recorded as defined only if this translation unit actually contains its storage or code; anything else must be undefined and left for resolution. And every byte the compiler could not compute must carry a relocation record: a location it did not record is a placeholder that will silently ship as zero.

Key points

  • An object file is sections plus a symbol table plus relocations plus debug metadata — four kinds of content the linker treats in four different ways.
  • .bss occupies no file space because every byte of it is zero: the header records a size, and the loader maps zero-filled anonymous memory.
  • Initialising a large global to a non-zero value moves it from .bss to .data and adds its full size to the binary.
  • .text and .rodata are separate so that code can be mapped without write permission and constants without execute permission.
  • Symbols carry a name, a section, an offset and flags — no types — which is why languages with overloading must encode types into the name.
  • Sections serve the linker and segments serve the loader; the same bytes are described twice because the two consumers want opposite granularities.
  • ELF, Mach-O and PE differ most in dynamic-linking policy: flat versus two-level namespaces, and export-everything versus export-nothing defaults.

The four sections everyone meets

targetThese are ELF section names, used on Linux and most Unix-likes. Mach-O organises the same content as sections inside segments with different names — __TEXT,__text, __TEXT,__cstring, __DATA,__data, __DATA,__bss — and PE/COFF on Windows uses .text, .rdata, .data and .bss with its own header structure and an import table that has no ELF equivalent. The distinction between file-backed and zero-filled sections exists in all three.

A section is a named byte region with attributes: is it code, is it writable, does it occupy space in the file, does it get loaded into memory. Four of them cover almost everything a C program produces, and the differences between them are entirely about those attributes rather than about what the programmer wrote.

.text holds the machine code and is read-only and executable. .rodata holds string literals and constant tables and is read-only and not executable — a separation that exists so the loader can map code without write permission and constants without execute permission, which is the whole of W^X. .data holds initialised mutable globals and is read-write, and its initial values must be in the file because they are arbitrary. .bss holds mutable globals that start as zero.

And that last one is the interesting case. Because every byte of .bss starts as zero, the file does not need to store them: it only needs to record how many there are. The section header carries a size and no file offset, the loader maps a zero-filled anonymous region of that size, and a ten-megabyte zero array costs ten megabytes of address space and nothing on disk. Move it into .data by initialising it to anything non-zero and the binary grows by ten megabytes, which is a genuinely surprising build-size regression the first time someone hits it.

The standard sections and what distinguishes them (ELF names)target
SectionHoldsIn the file?In memory
.texttargetMachine codeYes — the bytes are arbitraryRead + execute, shared between processes running the same binary
.rodatatargetString literals, constant tables, jump tables, vtablesYesRead-only, no execute — separate from .text so neither needs the other's permission
.datatargetGlobals with a non-zero initialiserYes — the initial values must be storedRead + write, private per process, copy-on-write from the mapping
.bsstargetGlobals initialised to zero, or not initialisedNo — only a size is recordedRead + write, mapped as zero-filled anonymous memory
.symtabtargetThe full symbol tableYes; removed by stripNot loaded — it is link-time and debug-time metadata only
.rela.texttargetRelocation records for .textYes, in a relocatable objectNot loaded — consumed by the linker and discarded
.debug_*targetDWARF line tables, variable locations, type informationYes, and usually most of the file sizeNot loaded — read by debuggers from the file

The symbol table and the relocation list

The symbol table is a list of names with, for each, whether this file defines it or merely needs it, which section it lives in and at what offset, its size, and its binding and visibility. That is the input to symbol resolution, and it is a much smaller data structure than people expect — a name, a section index, an offset and a few flags. There is no type information; see [[name-mangling]] for what the name has to carry as a result.

The relocation list is the more interesting half, because it is the compiler admitting what it could not do. Each record names an offset within a section, a symbol, a relocation type and often an addend. The type is a small opcode selecting an arithmetic form: absolute 64-bit, PC-relative 32-bit, GOT-relative, PLT-relative. The linker executes that arithmetic once the addresses exist, and [[relocations]] covers what each form is for.

Reading both together is a genuinely useful diagnostic skill, and one command produces it: objdump -dr file.o disassembles the code with the relocation records interleaved at the instructions they patch. Every zero-displacement call with a relocation under it is an inter-unit reference. Every one without is a local call the compiler already resolved.

A relocatable object, read three ways (x86-64 ELF)
1$ objdump -h hello.o # sections: sizes, and note bss has no file offset
2Idx Name Size VMA File off Algn
3 0 .text 0000001b 0000000000000000 00000040 2**1
4 1 .data 00000004 0000000000000000 0000005c 2**2
5 2 .bss 00989680 0000000000000000 00000060 2**5 <- 10 MB, zero bytes stored
6 3 .rodata 0000000e 0000000000000000 00000060 2**0
7
8$ nm hello.o # symbols: T defined text, B bss, U undefined
90000000000000000 T main
100000000000000000 B buffer
11 U printf
12
13$ objdump -dr hello.o # code with the relocations interleaved
140000000000000000 <main>:
15 0: 48 8d 3d 00 00 00 00 lea rdi,[rip+0x0]
16 3: R_X86_64_PC32 .rodata-0x4
17 7: e8 00 00 00 00 call c <main+0xc>
18 8: R_X86_64_PLT32 printf-0x4

Compare the .bss line with the others: it has a size of ten megabytes and the same file offset as the section after it, which is the format's way of saying it occupies no file space. Then compare nm's three letters: T and B mean this file provides the storage, U means it does not and the linker must find it somewhere. Those three letters are the entire input to symbol resolution.

Three formats, one idea

ELF, Mach-O and PE/COFF are the three formats in wide use, and they encode the same content differently enough that tooling does not transfer. ELF is used on Linux, the BSDs and most embedded systems; Mach-O on Apple platforms; PE/COFF on Windows. All three have named sections, a symbol table and relocations, and all three distinguish file-backed data from zero-filled data.

The differences that actually matter in practice are about dynamic linking rather than about the static content. ELF resolves dynamic symbols through a global offset table and a procedure linkage table, with a flat namespace and interposition — which is what makes LD_PRELOAD possible. Mach-O uses a two-level namespace where each undefined symbol records which library it is expected to come from, which makes accidental interposition much harder. PE uses an import table listing, per DLL, the functions imported, and the loader patches an indirection table at load time; a Windows DLL also must explicitly export what it provides, whereas an ELF shared object exports everything with default visibility.

That last difference is why -fvisibility=hidden is standard advice on ELF and unnecessary on Windows: the platforms have opposite defaults, and [[shared-libraries]] is where that argument lives.

Three object formatstarget
AspectELF (Linux, BSD)Mach-O (Apple)PE/COFF (Windows)
Container structuretargetSections, grouped into segments for loadingSegments containing sectionsSections with a data directory
Dynamic symbol namespacetargetFlat — any library can define any symbol, first definition winsTwo-level — each reference records its expected libraryPer-DLL import table
Default export policytargetEverything with default visibility is exportedEverything, unless hidden or filtered by an export listNothing, unless explicitly exported
InterpositiontargetSupported and used — LD_PRELOADRestricted by the two-level namespace; DYLD_INSERT_LIBRARIES exists and is heavily entitlement-gatedNot by design; achieved by patching the import table
Symbol prefixtargetNone — C foo is fooA leading underscore — C foo is _fooNone on x86-64; a leading underscore on 32-bit x86
Debug informationtargetDWARF in .debug_* sectionsDWARF, usually left in the object files and collected into a .dSYM bundlePDB in a separate file, referenced by a build ID

Why the format has this shape

typicalThat debug sections dominate object file size is typical of unoptimized and -g builds with DWARF, where they routinely exceed the code by several times. It is not universal: builds without -g have none, and formats that reference an external symbol file — Windows PDBs, Apple .dSYM bundles — move the bulk out of the binary by default.

Every structural choice in an object file exists to serve one of two consumers with different needs. The linker wants fine-grained, attributed pieces it can select, merge, discard and patch — which argues for many small sections with rich metadata. The loader wants a very small number of large, page-aligned regions it can mmap with a single permission each — which argues for few segments and no metadata at all.

The format serves both by keeping them as separate views of the same bytes. A relocatable object has a section header table and no program headers, because nothing loads it. An executable has both: sections for the tools and segments for the kernel, with the segments grouping many sections into typically three or four mappable regions. readelf -lW prints the mapping and shows exactly which sections landed in which segment.

The separation is also why stripping works. .symtab and .debug_* are not in any loadable segment, so removing them changes the file and not the process. It is why a stripped binary runs identically and cannot be debugged, and why splitting debug information into a companion file — objcopy --only-keep-debug, or -gsplit-dwarf, or a .dSYM bundle — is possible at all. See [[symbolication]].

How it works

The steps, in the order the compiler takes them.

  • The compiler emits each kind of content into its own section: code into .text, constants into .rodata, initialised globals into .data, and zero-initialised globals into .bss as a size only.
  • For every definition with external linkage it adds a symbol table entry naming the section and offset; for every unresolved reference it adds an undefined entry.
  • For every byte it could not compute — any reference to an address — it emits a relocation record naming the offset, the symbol, the arithmetic form and any addend.
  • Debug information is emitted into separate non-loadable sections describing line tables, variable locations and types.
  • The linker merges like sections, resolves the symbols, assigns addresses and applies the relocations, then groups the result into loadable segments with permissions.
  • It writes a program header table describing those segments, which is what the loader reads; the section headers survive for the tools and are removed by strip.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • A binary grows by megabytes after an apparently trivial change, because a large global array acquired a non-zero initialiser and moved from .bss to .data.
  • A program faults on a write to what looks like an ordinary variable, because it is a string literal in .rodata and the pointer was written through — the classic char* s = "abc"; s[0] = 'X';.
  • A stripped production binary produces a crash report with no symbols, and the debug file that would have decoded it was never archived.
  • Two objects fail to link with an alignment or attribute conflict, because a section was created by inline assembly or a linker script with attributes that do not match the compiler-generated one of the same name.
  • A section is garbage-collected away because the only reference to it was from hand-written assembly or a linker script the collector did not see, and the symptom is a null function pointer or a missing table at runtime.
  • Object files are unexpectedly enormous and the build is slow for reasons nothing in the source explains, because -g is on and DWARF is several times the size of the code.

When it helps

  • Diagnosing binary size: size and objdump -h attribute growth to a section, and a section names a cause — code, constants, initialised data, or debug information.
  • Understanding memory layout at runtime: which parts of a process are shared between instances, which are copy-on-write and which are anonymous all follow from which section a thing was in.
  • Reading link and load failures, most of which name a section, a symbol or a relocation type, all three of which are structures in this file.

When it hurts

  • Assuming section names are portable. .text exists everywhere and __TEXT,__text and PE's .rdata mean tooling and linker scripts do not transfer between platforms.
  • Reasoning about runtime memory from file size. .bss is the counterexample by construction, and a small binary can map a very large address space.

What it costs

Every one of these is paid by something.

  • Fine-grained sections buy the linker the ability to garbage-collect and reorder at function granularity, and cost object file size, more relocation records and more link-time work.
  • Keeping debug information in the binary buys the ability to debug and symbolicate the exact artifact you shipped, and costs binary size, link time, and distribution bandwidth — which is why split debug files exist and why they create an archiving obligation.
  • Separating .rodata from .text buys the ability to map constants without execute permission, and costs a second mapping and some locality — code and its adjacent constant tables are no longer contiguous.
  • A rich, self-describing format buys tooling that can inspect and rewrite binaries after the fact, and costs parsing complexity and a large surface for format-level security issues in every tool that reads it.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A flat binary image with no metadata at all, which is what embedded firmware and boot sectors use: no sections, no symbols, no relocations, loaded at a fixed address. Minimal and unlinkable after the fact.
  • A bytecode container carrying types and structure, such as a JVM class file or a .NET assembly. The symbol table is typed, so name mangling is unnecessary and reflection is possible — at the cost of requiring a runtime that understands the format.
  • WebAssembly modules, which have typed imports and exports and no relocations in the traditional sense because the module is position-independent by construction — see [[wasm-model]].
  • Fat or universal binaries, which package several architectures' object code in one container and let the loader choose. Convenient distribution, proportionally larger files.

See it for yourself

The flag, dump or tool that shows you this directly.

  • Sections and sizes: objdump -h file.o, or size -A file.o for a summary that makes .bss versus .data immediately visible.
  • Symbols: nm file.o with T for a defined text symbol, D/B for data and bss, U for undefined, lowercase for local. nm -C demangles.
  • Code with relocations: objdump -dr file.o — the single most informative view, showing exactly which instructions carry placeholders.
  • Segments versus sections in a linked binary: readelf -lW app prints the section-to-segment mapping the loader will use.
  • On Apple platforms: otool -l, otool -tv and nm; on Windows: dumpbin /headers, /symbols and /imports.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "An object file is machine code." It is machine code plus data plus a symbol table plus relocations plus debug metadata, and in a -g build the code is usually the smallest part.
  • ".bss is free." It is free on disk. It occupies exactly as much memory as .data would at runtime, and it is zeroed by the kernel, which is real work at first touch.
  • "Sections and segments are the same thing." Sections are for the linker and the tools; segments are the few permission-bearing regions the loader maps. One executable describes both.
  • "Stripping a binary makes it faster or smaller in memory." It makes the file smaller. The stripped sections were never loaded, so the running process is unchanged.

Misconceptions

The claim, and what is actually true.

A big .bss makes the binary big.
.bss contributes a size field and no bytes. That is exactly why moving an array out of it by initialising it is a surprising size regression.
The symbol table is needed to run the program.
.symtab is not in any loadable segment. A fully stripped statically linked binary runs identically and cannot be debugged.
Object files from different platforms are interchangeable if the architecture matches.
The container format differs, the symbol conventions differ, and the dynamic-linking model differs. Same architecture is necessary and nowhere near sufficient.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

A .o file holds the compiled code and data of one source file, split into labelled sections, plus a list of the names it provides, a list of the names it still needs, and a list of the places the linker must fill in an address. One section, .bss, holds variables that start out as zero — and since they are all zero, the file records only how many bytes there are rather than storing them.

practical

Three commands cover most questions. size -A tells you where the bytes went. nm -C tells you what a file provides and needs. objdump -dr shows the code with the unfilled references marked, which turns "why is this call to a zero address" into an obvious answer. When a binary grows unexpectedly, compare size -A before and after: it is usually .data (an initialiser was added), .rodata (string or table growth) or a debug section.

advanced

The design tension worth noticing is that the file is a data structure for two consumers with opposite requirements, and the format resolves it by describing the same bytes twice. The section view is fine-grained and metadata-rich, because the linker wants to select, discard, merge and patch individual functions. The segment view is coarse and metadata-free, because the loader wants three or four page-aligned mmap calls and nothing else. Everything that follows — that stripping does not change the process, that split debug files work, that --gc-sections needs -ffunction-sections, that a relocatable object has no program headers at all — is a consequence of maintaining both views over one set of bytes. It is a good example of a format designed around who reads it rather than around what it contains.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetSection names, the nm letter codes and the relocation type spellings are ELF conventions. Mach-O uses segment-qualified names such as __TEXT,__text and prefixes C symbols with an underscore; PE/COFF uses .rdata where ELF uses .rodata and requires explicit exports where ELF exports by default. Tooling and linker scripts do not transfer between the three.
typicalThat the compiler places zero-initialised globals in .bss and non-zero ones in .data is typical of C and C++ toolchains on all three formats, and is what the formats are designed for. It is a compiler choice rather than a language rule: a const zero array may go to .rodata instead, and small objects may be merged or placed in special sections for locality.
implementationWhether debug information lives in the binary, in a split .dwo file, in a .dSYM bundle or in a PDB is a toolchain and platform choice that changes the file sizes in this lesson dramatically without changing the running program at all.

If you were asked this in an interview

  • Why does a ten-megabyte zero-initialised array cost nothing in the binary, and what makes it cost ten megabytes?
  • What is in a .o besides machine code?
  • What is the difference between a section and a segment, and who reads each?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — How a runtime lays out its own code and data when it generates them at execution time
    A JIT produces the same kinds of content — code, constants, metadata — without an object file, and has to solve permissions and layout itself. What that machinery looks like inside a running process is owned there; the ahead-of-time container is here.