The preprocessor, multi-file programs and make · about 55 min

Multi-file programs and headers

Why this matters

No real C program lives in one file. The Linux kernel has tens of thousands of .c files; even a small tool is usually split into a few modules: parsing, the core logic, output. Splitting code makes it easier to understand (each file has one job), faster to build (only changed files are recompiled), and reusable (a module can move to another project). C's way of doing this is simple and very mechanical, and once you understand it, linker errors stop being mysterious.

Translation units and separate compilation

The compiler only ever looks at one file at a time. A .c file, after the preprocessor has pasted in all its headers, is called a translation unit. Each translation unit is compiled into its own object file, knowing nothing about the others. The linker then joins the object files and resolves references between them.

  geometry.h  (declarations)
      |  #include              #include  |
      v                                  v
  main.c ----gcc -c----> main.o     geometry.c ----gcc -c----> geometry.o
                         U distance                            T distance
                         U midpoint                            T midpoint
                         T main                                U sqrt
                              \                               /
                               +------- linker (ld) ---------+
                                            |      + libc, libm
                                            v
                                         shapes

The header is the contract between them. main.c includes it so the compiler knows how to call distance; geometry.c includes it so the compiler can check that the definitions match that contract.

A complete example

geometry.h, the interface:

#ifndef GEOMETRY_H
#define GEOMETRY_H

struct point {
    double x;
    double y;
};

double distance(struct point a, struct point b);
struct point midpoint(struct point a, struct point b);

#endif

geometry.c, the implementation:

#include <math.h>
#include "geometry.h"

double distance(struct point a, struct point b)
{
    double dx = b.x - a.x;
    double dy = b.y - a.y;
    return sqrt(dx * dx + dy * dy);
}

struct point midpoint(struct point a, struct point b)
{
    struct point m = { (a.x + b.x) / 2, (a.y + b.y) / 2 };
    return m;
}

main.c, a user of the module:

#include <stdio.h>
#include "geometry.h"

int main(void)
{
    struct point a = {0, 0};
    struct point b = {3, 4};
    struct point m = midpoint(a, b);
    printf("distance: %.1f\n", distance(a, b));
    printf("midpoint: (%.1f, %.1f)\n", m.x, m.y);
    return 0;
}

Build it in separate steps, so you can see each one:

$ gcc -std=c17 -Wall -Wextra -c geometry.c
$ gcc -std=c17 -Wall -Wextra -c main.c
$ nm geometry.o
0000000000000000 T distance
0000000000000099 T midpoint
                 U sqrt
$ nm main.o
                 U distance
0000000000000000 T main
                 U midpoint
                 U printf
$ gcc main.o geometry.o -o shapes -lm
$ ./shapes
distance: 5.0
midpoint: (1.5, 2.0)

-c means "compile only, do not link", producing main.o and geometry.o. The U entries in main.o are satisfied by the T entries in geometry.o; sqrt comes from libm and printf from libc. You can also do it in one command, gcc main.c geometry.c -o shapes -lm, which runs the same steps with temporary object files.

If you forget a file, the compiler is still happy (it saw the declarations) but the linker is not:

$ gcc main.c -o shapes
/usr/bin/ld: /tmp/ccSjCV2N.o: in function `main':
main.c:(.text+0x51): undefined reference to `midpoint'
/usr/bin/ld: main.c:(.text+0x84): undefined reference to `distance'
collect2: error: ld returned 1 exit status

What goes in a header

A header is pasted into every file that includes it, so it may only contain things that are fine to repeat in many translation units:

in the header (.h)in the implementation (.c)
function declarations (prototypes)function definitions (bodies)
struct, union, enum and typedef definitionsvariable definitions: int count = 0;
macros and constants (#define)helper functions only this file uses
extern declarations of shared variablesthe main function (in exactly one file)
#includes the header itself needs#includes only the implementation needs

Defining a type in a header is fine because a type creates no storage or code; every file just learns the layout. Defining a variable or a function body there is not, because each including file would get its own copy:

$ cat bad.h
struct point { double x, y; };
int counter = 0;
$ gcc u1.c u2.c -o prog        # both include bad.h
/usr/bin/ld: /tmp/ccYQoNV0.o:(.bss+0x0): multiple definition of `counter'; /tmp/cc847cng.o:(.bss+0x0): first defined here
collect2: error: ld returned 1 exit status

Include guards, again

Without a guard, including the same header twice in one file, which easily happens indirectly when a.h and b.h both include point.h, repeats the struct definition:

In file included from twice.c:2:
bad.h:1:8: error: redefinition of 'struct point'
    1 | struct point { double x, y; };
      |        ^~~~~
In file included from twice.c:1:
bad.h:1:8: note: originally defined here

The #ifndef GEOMETRY_H / #define GEOMETRY_H / #endif wrapper makes the second inclusion empty. The guard name must be unique across the project; the usual convention is the file name in upper case with _H. Remember what a guard does not do: it does not stop two different translation units from each getting a copy. That is why it cannot rescue int counter = 0; in a header.

Why the implementation includes its own header

geometry.c needs struct point from the header anyway, but even a module without shared types should include its own header. But the real reason to include it is checking. Suppose the header says double distance(struct point a, struct point b); and someone changes the definition in the .c file to take three parameters. If the .c file includes its header, the compiler sees both and reports conflicting types for 'distance'. If it does not, both files compile, they link fine (the linker only matches names, not types), and calls pass the wrong arguments at run time: undefined behavior. Always include a module's own header first in its .c file.

Organizing a small project

A common layout:

  project/
    Makefile
    src/
      main.c
      geometry.c
      parse.c
    include/
      geometry.h
      parse.h

Built with gcc -Iinclude src/*.c -o shapes -lm. -Iinclude adds the directory to the search path for #include "...". Keep each header minimal: include only what the header itself needs (for example <stddef.h> for size_t), and let .c files include the rest.

Common mistakes

Recap

Going further: take any program you wrote earlier in this track and split it into a module and a main.c. Then delete the module's #include of its own header, change a parameter type in the .c file only, and see that the program still builds. That experience is the best argument for the rule.

Exercises

Exercise 1 · quiz

What belongs in a header?

list.h is included by five different .c files. Which of these belong in it? Select all that apply.

Answer
  • struct node { int value; struct node *next; };

  • struct node *list_push(struct node *head, int value);

  • #define LIST_MAX 1000

Headers hold what every user of the module must see: type definitions, function declarations (prototypes), macros and constants. They must not hold definitions that create storage or code, because the header is pasted into five files, so a variable definition or function body would exist five times and the linker would report "multiple definition". Variables shared across files are declared in the header with extern and defined in exactly one .c file (next lesson).

Exercise 2 · quiz

Which stage complains?

counter.h contains int counter = 0; (with a correct include guard). Both a.c and b.c include it, and you build with gcc a.c b.c -o prog. What happens?

Answer
  • The linker reports "multiple definition of `counter'"

An include guard only works within one translation unit: it stops the header from being pasted twice into the same .c file. a.c and b.c are compiled separately, each gets its own copy of the header, and each object file defines a symbol counter. The compiler is happy with each file on its own; only the linker, which sees both objects, finds two definitions. The fix: extern int counter; in the header and int counter = 0; in one .c file.

Exercise 3 · write code

Implement a header's interface

The file strutil.h (already provided next to your main.c) declares a small module:

#ifndef STRUTIL_H
#define STRUTIL_H

#include <stddef.h>

size_t count_char(const char *s, char c);   /* how many times c occurs in s */
void to_upper_str(char *s);                 /* convert s to upper case in place */

#endif

In main.c, include "strutil.h" and write definitions of both functions with exactly the same parameter and return types as the declarations. Then keep the given main, which prints:

count_char("banana", 'a') = 3
HELLO, WORLD 42

The checker also links your file (with your main renamed) against a separate test program that includes strutil.h and calls your functions, exactly as another .c file in a real project would.

File: main.c

Starting point

#include <stdio.h>

/* include the module's header here, and define its two functions */

int main(void)
{
    char text[] = "Hello, World 42";
    printf("count_char(\"banana\", 'a') = %zu\n", count_char("banana", 'a'));
    to_upper_str(text);
    printf("%s\n", text);
    return 0;
}

Example

expected output

count_char("banana", 'a') = 3
HELLO, WORLD 42
Hint 1

Add #include "strutil.h" with double quotes, so the compiler looks in the same directory.

Hint 2

Copy each prototype from the header and replace its semicolon with a body. Including the header in the implementation lets the compiler check that they match.

Hint 3

For upper case, use toupper((unsigned char)*s) from <ctype.h> on each character until the '\0'.

Reference solution
#include <ctype.h>
#include <stdio.h>
#include "strutil.h"

size_t count_char(const char *s, char c)
{
    size_t n = 0;
    for (; *s != '\0'; s++) {
        if (*s == c)
            n++;
    }
    return n;
}

void to_upper_str(char *s)
{
    for (; *s != '\0'; s++)
        *s = (char)toupper((unsigned char)*s);
}

int main(void)
{
    char text[] = "Hello, World 42";
    printf("count_char(\"banana\", 'a') = %zu\n", count_char("banana", 'a'));
    to_upper_str(text);
    printf("%s\n", text);
    return 0;
}

Exercise 4 · task in a shell

Build a program from three files

Your workspace contains a main.c that uses two functions it does not define:

double c_to_f(double celsius);
double f_to_c(double fahrenheit);

Working in the terminal, create the missing module:

  1. temp.h: an include guard (#ifndef TEMP_H / #define TEMP_H / #endif) around the two prototypes. No function bodies in the header.
  2. temp.c: includes "temp.h" and defines both functions (F = C * 9 / 5 + 32, and the reverse). No main in this file.
  3. Compile both files into an executable called convert: gcc -std=c17 -Wall -Wextra -o convert main.c temp.c, and run ./convert.

Do not change main.c. Use nano temp.h (or vi) to create the files. The check compiles temp.c on its own with warnings as errors, and runs your convert.

Reference solution
cat > temp.h <<'EOF'
#ifndef TEMP_H
#define TEMP_H

double c_to_f(double celsius);
double f_to_c(double fahrenheit);

#endif
EOF
cat > temp.c <<'EOF'
#include "temp.h"

double c_to_f(double celsius)
{
    return celsius * 9.0 / 5.0 + 32.0;
}

double f_to_c(double fahrenheit)
{
    return (fahrenheit - 32.0) * 5.0 / 9.0;
}
EOF
gcc -std=c17 -Wall -Wextra -o convert main.c temp.c
./convert