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

Object Files, Symbols, and Translation Units

Between 'compile' and 'a finished program' sits a quiet middle world: each source file becomes its own little package of machine code with blanks left in it, and a list of names to fill them. Meet the translation unit, the object file, and the symbols that let separately-built pieces find each other.

Where we are: one stage of four, looked at closely

The first guide in this rung walked the whole pipeline — preprocess, compile, assemble, link — as four stages a single `gcc` command quietly runs end to end. The second guide opened up the preprocessor and showed how `#include` and macros rewrite your text before the real compiler ever sees it. This guide stops in the middle of the road, at the most under-appreciated artifact of the whole journey: the object file, the half-finished package that comes out of compiling one source file but is not yet a runnable program.

Why dwell here? Because almost every real program is built from many source files, not one — and the object file is the unit in which those pieces are produced, stored, and reused. Understanding it is the difference between treating the build as magic and actually knowing why `gcc a.c b.c` works, why changing one file only recompiles that file, and why a typo in a function name surfaces as a cryptic linker error rather than a compiler one. The whole design has a name — separate compilation — and the object file is its keystone.

A translation unit: what the compiler actually compiles

Here is a subtlety the first two guides set up but didn't name. The compiler does not compile your `.c` file as you typed it. It compiles the file after the preprocessor has finished — every `#include` pasted in full, every macro expanded, every `#if` resolved. That fully-expanded blob of source is called a translation unit, and it is the true atom of C compilation. One `.c` file plus everything it drags in through its headers becomes exactly one translation unit.

This explains a rule that otherwise feels arbitrary: the compiler sees one translation unit at a time, and only that one. When it compiles `main.c`, it knows nothing about what is inside `math.c` — they are separate translation units, compiled in separate runs. All `main.c` is allowed to know about a function defined elsewhere is its prototype — the name, the return type, the parameter types — usually delivered through a shared header file. The prototype is a promise: 'a function shaped like this exists somewhere; trust me and call it.' Whether it truly exists is not the compiler's problem. That question is deferred to the linker.

The object file: machine code with holes in it

Compile one translation unit and you get one object file — conventionally a `.o` file. You can ask for it explicitly: `gcc -c main.c` produces `main.o` and stops, skipping the link step entirely (that is what `-c` means: compile, don't link). Inside, an object file is genuine machine code — the same instructions the CPU will eventually run — but it is not yet runnable, for one stubborn reason: it almost certainly refers to things that live in other object files.

Picture `main.o` for a program whose `main()` calls a function `square()` defined over in `math.c`. The compiler emitted the instruction to call `square`, but it has no idea what address `square` will end up at — `math.c` was compiled separately, maybe not even yet. So instead of a real address, the call instruction is left with a blank, a placeholder, and the object file records a note: 'there is a hole here that needs whatever address the name `square` turns out to have.' The object file is, quite literally, machine code with labelled holes punched in it, plus the paperwork describing each hole.

Real object files aren't a free-form heap of bytes; they follow a defined layout — on Linux it's ELF, on macOS Mach-O. The bytes are sorted into named sections: `.text` holds the machine instructions, `.data` holds initialized globals (an `int counter = 5;` at file scope), `.rodata` holds read-only constants like string literals, and `.bss` reserves space for globals that start out zero (it costs almost nothing on disk because it's just 'reserve N bytes of zeros'). Keeping code, mutable data, and constants in separate sections is also what later lets the loader mark code read-only and data non-executable.

Symbols: the names that join the pieces

How does a hole in `main.o` know it wants `square`, and how does `math.o` announce that it has a `square` to offer? Through symbols. Every object file carries a symbol table: a list of named things the file either provides to the world or needs from the world. Each top-level function and global variable becomes a symbol. The symbol `square` in `math.o` is marked defined — here it is, at this offset within my `.text`. The same symbol `square` in `main.o` is marked undefined — I use this name but I don't have it; somebody please supply it.

main.o  symbol table              math.o  symbol table
  main   : DEFINED   (.text)        square : DEFINED   (.text)
  square : UNDEFINED  (needs it)    printf : UNDEFINED (from libc)
  printf : UNDEFINED (from libc)

  relocations in main.o:
    .text + 0x12 -> fill with address of 'square'
    .text + 0x27 -> fill with address of 'printf'
A sketch of two symbol tables. 'main' provides itself and demands 'square' and 'printf'; 'math' provides 'square'. The relocations are the labelled holes.

This defined-versus-undefined split is the whole hinge of linking. You can list these names yourself: on Linux, `nm main.o` prints the symbol table, with a capital `T` next to symbols defined in `.text` and a `U` next to the undefined ones it still needs. If you ever see a linker error like 'undefined reference to `square`', it means exactly this: some object file had `square` marked `U`, and after looking through every object file and library handed to it, the linker never found a matching defined symbol to fill that hole.

From many holes to one program: resolution and relocation

Now the payoff. Compiling produces object files with holes; the linker is the tool that fills them and welds the pieces into one program. It does two distinct jobs, and naming them separately is the clearest way to see why compiling and linking are genuinely different stages — not two words for the same act.

  1. Symbol resolution. The linker gathers every object file (and library) and matches each undefined symbol to exactly one defined symbol of that name. `main.o`'s undefined `square` is bound to the `square` defined in `math.o`. If a name has no definition anywhere, it stays undefined and you get an 'undefined reference' error; if two files define the same name, you get a 'multiple definition' error. Resolution is purely about names.
  2. Layout. The linker lays all the `.text` sections one after another into the final program's address space, then all the `.data`, then `.bss`, and so decides the actual address every function and global will live at. Until this moment, `square` had no address at all — only an offset inside `math.o`.
  3. Relocation. Now that every symbol has a real address, the linker walks the list of labelled holes (the relocations) and patches each one — writing the resolved address of `square` into the blank inside `main`'s call instruction, and so on for every cross-reference. Patch every hole and the machine code finally points at the right places.
  4. Emit the executable. With resolution and relocation done, the linker writes out a single executable file (an ELF executable on Linux) — no undefined symbols left, every address concrete, ready for the loader.

The last step: from file on disk to running process

The executable now sits on disk, but a file is not a running program. When you type `$ ./a.out`, the operating system's loader takes over: it reads the executable's sections, maps `.text` and `.data` into memory at the addresses the linker chose, zeroes out the `.bss` region, sets up the initial stack, and finally jumps to the program's entry point. That entry point isn't `main()` directly — a small piece of C runtime startup code runs first, sets up argc and argv, and only then calls your `main()`.

So the full life of your code is a relay of four hand-offs: the preprocessor produces a translation unit, the compiler-plus-assembler turn each translation unit into an object file of machine code with holes, the linker resolves and relocates all the object files into one executable, and the loader maps that executable into memory and starts it running. Each stage hands a different artifact to the next — source text, then `.o` files, then an executable, then a live process — and a failure at any stage points you at a different culprit. Knowing which artifact you're holding is half of reading a build error correctly, which is exactly where the next two guides go.