How C Header Files Share Declarations Across Modules

This article explores how header files facilitate modular programming in C by enabling clean separation between declarations and definitions. It explains the mechanics of the C preprocessor, how function prototypes and data types are exposed to multiple translation units, the necessity of include guards to prevent duplicate definitions, and how the linker ultimately resolves these declarations into a unified executable.

The Role of Declarations and Definitions

In C, the compiler processes each source code file (.c) independently into a separate object file (.o or .obj). This independent unit of compilation is known as a translation unit. To use a function, variable, or data type defined in another translation unit, the compiler must first be informed of its existence, name, and type signature.

  • Declaration: Informs the compiler about the name and interface of an entity without allocating memory or providing implementation details (e.g., void calculate_sum(int a, int b); or extern int global_counter;).
  • Definition: Allocates storage for a variable or provides the executable code body for a function. Under C's One Definition Rule (ODR), a definition must exist only once across the entire project.

Header files (.h) serve as centralized repositories for declarations, allowing multiple source files to import identical interface specifications without duplicating implementations.

How the Preprocessor Processes Headers

Header files do not directly participate in the compilation or linking phases. Instead, they are handled by the C preprocessor via the #include directive:

  • #include <filename.h> instructs the preprocessor to search standard system library directories.
  • #include "filename.h" directs the preprocessor to search the local project directory before searching system paths.

When the preprocessor encounters an #include directive, it physically copies and pastes the entire content of the referenced header file into the source file at that exact position. Once preprocessing completes, the compiler compiles the resulting expanded text.

Sharing Elements Across Modules

1. Function Prototypes

Functions are shared by placing their prototypes inside the header file and their definitions inside a corresponding .c file:

// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H

int add(int a, int b);

#endif
// math_utils.c
#include "math_utils.h"

int add(int a, int b) {
    return a + b;
}

Any other module wishing to use add includes math_utils.h. The compiler uses the prototype to verify argument types and return values during compilation, generating an unresolved symbol reference to be completed at link time.

2. Global Variables with extern

To share a global variable, it must be declared in the header file using the extern keyword. This informs the compiler that storage is allocated elsewhere:

// config.h
#ifndef CONFIG_H
#define CONFIG_H

extern int max_connections;

#endif

The variable is then defined once, without extern, in a single .c file:

// config.c
#include "config.h"

int max_connections = 100; // Memory allocated here

If extern were omitted in the header file, every source file including the header would attempt to define the variable, causing duplicate symbol errors during linking.

3. Custom Types and Structs

Unlike functions and variables, custom data structures (struct, union, enum) and typedef declarations must have their complete structural layouts visible to any code that instantiates them or accesses their members:

// user.h
#ifndef USER_H
#define USER_H

typedef struct {
    int id;
    char username[32];
} User;

void print_user(const User *u);

#endif

Defining types within headers ensures that all modules have a consistent view of the structure's size and member offsets in memory.

Preventing Redefinition with Include Guards

Because headers often include other headers, a single translation unit may end up including the same header file multiple times. To avoid compiler errors regarding duplicate type definitions, header files use include guards:

#ifndef MODULE_NAME_H
#define MODULE_NAME_H

/* Declarations go here */

#endif /* MODULE_NAME_H */

Alternatively, many modern compilers support the non-standard but widely adopted #pragma once directive placed at the top of the file, which instructs the preprocessor to include the file only once per translation unit.

The Linking Phase

Once each .c file has been preprocessed and compiled into an object file using the declarations in the headers, the linker steps in. The linker collects all object files, matches the unresolved references (calls to functions or accesses to extern variables) with their single definitions, and combines them into the final binary executable or library. Header files ensure that this process is type-safe and consistent across all participating modules.