Skip to content
VLSI Mentor

C Part I · Module 11 · Building Multi-File C Programs

What a Translation Unit Is

The previous chapter used a term repeatedly without defining it, and the term is about to start doing real work. A translation unit is not a file — it is what is left after the preprocessor has finished, which means your file with every header pasted into it, every macro replaced, and every branch of conditional compilation that was not taken deleted outright. That is the only thing the compiler proper ever sees. This chapter measures how much of a typical translation unit you actually wrote, which is less than you would guess, and then establishes the three consequences that the rest of the module depends on: why a header is indistinguishable from text you typed, why including one twice can break a build, and why two files that look related are compiled as though the other did not exist.

Intermediate18 min readCPart IFundamentalsBuild

Part I · Module 11 · Chapter 11.2 · What a Translation Unit Is

1. The Definition

A translation unit is a source file after preprocessing — that is, your .c file with every #include replaced by the contents of the named file (recursively), every macro expanded, every conditional-compilation branch resolved, and every comment removed.

The C standard describes translation in a sequence of phases. Three of them are directly observable and are all you need here:

  1. Directives are executed and macros are replaced. #include pastes, #define substitutes, #if deletes.
  2. The result — the translation unit — is translated: parsed, type-checked, turned into an object file.
  3. Object files are linked into a program.

The standard lists eight phases in total; the rest handle details like splitting into tokens and concatenating adjacent string literals. What matters is the boundary: everything in phase 1 is textual and knows nothing about C's grammar, and everything after it knows nothing about where the text came from.

2. How Much of It Did You Write?

Azvya Education Pvt. Ltd.VLSI Mentor
seven lines
#include <stdio.h>

int main(void)
{
    printf("hello\n");
    return 0;
}

Run the preprocessor alone with -E and count:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ wc -l < tiny.c
7
$ cc -std=c17 -E tiny.c | wc -l
573
$ cc -std=c17 -E tiny.c | grep -vE '^\s*$|^#' | wc -l
274

Seven lines of yours became 573. Strip blank lines and the preprocessor's own bookkeeping and there are still 274 lines of real C.

Where did they come from?

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -E tiny.c | grep -oE '^# [0-9]+ "[^"]+"' | sed 's/.*"\(.*\)"/\1/' | sort -u | wc -l
42

Forty-two distinct files. One #include <stdio.h> pulled in stdio.h, which included _stdio.h, Availability.h, AvailabilityInternal.h, and thirty-eight more.

For comparison, a file with no includes at all:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -E bare.c | wc -l      /* bare.c is: int main(void){return 0;} */
8

And before any of that, the compiler has already defined a few hundred macros for you:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -dM -E - < /dev/null | wc -l
480
$ cc -std=c17 -dM -E - < /dev/null | grep -E "__STDC_VERSION__|__STDC__ "
#define __STDC_VERSION__ 201710L
#define __STDC__ 1

__STDC_VERSION__ is 201710L — that is Chapter 1.3's C17 baseline, stated by the compiler itself. It is a useful thing to check when you are not sure which standard a build is actually using.

The useful consequence is not the size. It is that the compiler has no idea which lines you wrote. By the time type checking happens, a declaration from a header and a declaration you typed are the same thing in the same unit, indistinguishable.

3. Watching the Preprocessor Work

Azvya Education Pvt. Ltd.VLSI Mentor
before
#define LIMIT 10
#define TWICE(x) ((x) + (x))

int main(void)
{
    int n = LIMIT;
    int m = TWICE(n);
#if LIMIT > 5
    n = n + 1;              /* kept */
#else
    n = n - 1;              /* removed before the compiler sees it */
#endif
    return n + m;
}
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -E phases.c | grep -vE '^\s*$|^#'
Azvya Education Pvt. Ltd.VLSI Mentor
after — this is the translation unit
int main(void)
{
    int n = 10;
    int m = ((n) + (n));
    n = n + 1;
    return n + m;
}

Four things happened, all textual.

LIMIT became 10. Not "a constant named LIMIT" — the token 10, in the source.

TWICE(n) became ((n) + (n)). The macro's text with the argument substituted. Note that n now appears twice, which is harmless here and is the source of a real family of bugs that Chapter 11.6 is about.

The #else branch is gone. Not skipped at run time, not compiled and discarded — absent. The compiler never saw n = n - 1; and could not have reported an error in it. That is the whole reason Chapter 11.7 exists as a separate chapter: code in an unselected branch is not checked at all.

The comments are gone, along with the directives themselves.

-E is the tool to reach for whenever a macro is not doing what you expect. It shows you what the compiler is actually being asked to compile, which is frequently not what you believe you wrote.

4. A Header Is Text

This follows directly, and it is the fact the next chapter is built on.

Azvya Education Pvt. Ltd.VLSI Mentor
dup.h
int shared_value;
struct item { int id; };
Azvya Education Pvt. Ltd.VLSI Mentor
dup1.c — included twice, deliberately
#include "dup.h"
#include "dup.h"
int main(void){ return 0; }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ cc -std=c17 -E dup1.c | grep -vE '^\s*$|^#'
int shared_value;
struct item { int id; };
int shared_value;
struct item { int id; };
int main(void){ return 0; }

The text is simply there twice. And the result is asymmetric, which is the interesting part:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
./dup.h:2:8: error: redefinition of 'item'
1 error generated.

One error, not two.

  • int shared_value; twice is fine. Chapter 11.1 §5 explained why: at file scope with no initialiser it is a tentative definition, and repeating one in a single translation unit is explicitly allowed.
  • struct item { int id; }; twice is an error. A structure type may be declared repeatedly but defined only once per translation unit.

So double inclusion is not uniformly harmful — it is harmful in exactly the cases where the header contains a definition that cannot repeat. Since a useful header is mostly type definitions, in practice it is always harmful. Chapter 11.3 is the mechanism that prevents it.

Notice also where the error is reported:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
In file included from usesbroken.c:1:
./broken.h:1:22: error: non-void function 'bad_line' should return a value

The compiler names the header, the line within it, and the chain of inclusion that reached it. The # 12 "file.h" lines in the -E output are how it keeps track: they are not C, they are the preprocessor telling the compiler where each stretch of text originated, so that diagnostics can point back at the file you can actually edit.

5. One Translation Unit at a Time

Every .c file in a project becomes its own translation unit, compiled independently, with no knowledge of any other. Two consequences, both measurable.

The same file-local name may exist in many units.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
    /* ta.c */  static int helper(void) { return 1; }
                int from_a(void) { return helper(); }

    /* tb.c */  static int helper(void) { return 2; }
                int from_b(void) { return helper(); }
Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ ./twoTU
a=1 b=2

Two functions called helper, both present, no collision, each used by its own file. The symbol tables show why:

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
$ nm ta.o                  $ nm tb.o
T _from_a                  T _from_b
t _helper                  t _helper

Lowercase t — file-local, as Chapter 11.1 §2's legend had it. The static keyword is what made them local, and Chapter 11.4 is where that is taught properly. The point here is the independence: each translation unit is compiled as though it were the only one, so a private name in one cannot clash with a private name in another.

And nothing crosses the boundary unless you declare it. A function defined in ta.c is invisible in tb.c until tb.c contains a declaration of it. That is not a restriction the linker imposes; it is a consequence of the compiler literally never reading ta.c while compiling tb.c.

6. What a Translation Unit Is Not

Not a file. One file plus forty-two headers was one translation unit in §2.

Not your program. A program is several translation units linked together, and no compilation ever sees more than one of them.

Not a unit of scope you can rely on by accident. A static object's "one per translation unit" behaviour is a deliberate property of internal linkage, not a side effect of file layout. Chapter 11.4 draws that distinction.

Not fixed by the source you wrote. Change a -D flag on the command line, or a header on the include path, and the same .c file becomes a different translation unit. This is why "it compiles on my machine" is a statement about a translation unit rather than about a file, and Chapter 11.7 is about keeping that variation manageable.

7. What You Should Be Able to Say Now

  1. A translation unit is a source file after preprocessing — includes pasted, macros expanded, unselected branches deleted, comments removed.
  2. Measured: a 7-line program became 573 lines (274 of real C) from 42 files.
  3. 480 macros were predefined before the first line; __STDC_VERSION__ was 201710L, confirming the C17 baseline.
  4. Preprocessing is textual: LIMIT became the token 10, TWICE(n) became ((n) + (n)) with n appearing twice.
  5. An unselected #if branch is absent from the translation unit and is never checked.
  6. -E shows the translation unit; use it whenever a macro surprises you.
  7. A header is text. Including it twice puts the text in twice — measured.
  8. The effect is asymmetric: a repeated tentative definition was fine, a repeated struct definition was redefinition of 'item'.
  9. Diagnostics name the header and the inclusion chain, because # line "file" markers record where each stretch of text came from.
  10. Each .c is compiled independently — two files each with static int helper produced t _helper in both and ran as a=1 b=2.
  11. The translation unit is the boundary of every check the compiler performs.
  12. So a cross-file mistake is invisible until you make it intra-unit — which is what a header is for.

8. Exercises

Exercise 1 — predict the expansion. Write what the preprocessor produces.

Azvya Education Pvt. Ltd.VLSI Mentor
Snippet
#define N 4
#define SQ(x) (x * x)
int a[N];
int b = SQ(N);
int c = SQ(N + 1);
#ifdef DEBUG
int d;
#endif

Then say which line of your answer would surprise someone who thinks macros are functions, and which chapter covers it.

Exercise 2 — count the files. Using -E, work out how many files contribute to a translation unit that includes <stdio.h>, <stdlib.h> and <string.h>. Say what that implies about compile times and what Chapter 11.3's guards are doing for you.

Exercise 3 — the asymmetry. Explain why int shared_value; twice is legal and struct item { int id; }; twice is not, naming the rule from Chapter 11.1 that decides each.

Exercise 4 — find the invisible error. A #if branch that is never selected contains retrun 0;. Say what the compiler reports, why, and what you would do to check that branch.

Exercise 5 — two units, one name. Two files each define static void log_line(void) differently, and a third calls neither. Say whether it links, what nm shows, and what changes if static is removed from both.

Exercise 6 — the boundary. Give two mistakes a compiler can catch and two it structurally cannot, and for each of the second pair say what would make it catchable.

Exercise 7 — the moving target. The same .c file compiles on one machine and not another, with no source change. Give three things that could differ, using §6.

Exercise 8 — use the tool. A macro is producing the wrong result. Write the exact command that shows you the translation unit, and say what you would look for first.

9. Summary

  • A translation unit is a .c file after preprocessing — the only thing the compiler proper sees.
  • Measured: 7 lines → 573, from 42 files; 480 macros predefined; __STDC_VERSION__ = 201710L.
  • Preprocessing is textual — LIMIT → 10, TWICE(n) → ((n) + (n)).
  • An unselected branch is deleted and never checked.
  • -E prints the translation unit. Use it when a macro surprises you.
  • A header is text; including it twice duplicates it — fine for a tentative definition, fatal for a struct definition.
  • Diagnostics name the header and the inclusion chain.
  • Each .c compiles independently — two file-local helpers coexisted as t _helper.
  • The translation unit is the boundary of every compiler check, which is why a cross-file mismatch needs a header to become visible.

Section 4 ended with a problem and named the chapter that solves it. A header has to be includable by many files — that is its whole purpose — and it also has to tolerate being included more than once in a single translation unit, which happens constantly as soon as headers include other headers. The second requirement is not automatic, and the measured redefinition of 'item' is what happens without it. The next chapter covers the mechanism that makes a header safe to include repeatedly, and the harder editorial question that comes with it: of everything in your project, exactly which things belong in a header at all, and which must stay in the .c file — a decision with a rule behind it, and the rule follows directly from Chapter 11.1's distinction.

Standards & specifications

Governing standard
ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)

Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.

This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.

Where this fits

Part of the C Programming curriculum.