Multi-file Programs & Header Files
Real C programs are almost never a single file — they're split across multiple .c source files sharing declarations through .h header files, then combined into one executable by the linker.
Every C program you've written so far probably lived in one .c file, compiled with one command. Real projects don't work that way — a program with any real size gets split into multiple source files, each responsible for one part of the system, with header files acting as the shared contracts between them. This page walks through the whole picture in one pass: what belongs in a header versus a .c file, how #include and the linker actually cooperate to produce one executable, header guards and the multiple-definition problems they (and extern) prevent, how to keep headers lean with forward declarations and opaque types, and how the build itself — compiling each file separately, then linking the results — works and why it's built that way.
Why Use Multiple Files?
A single-file program is fine for a homework assignment, but a project of any real size — a sensor controller, a game, a small database — quickly becomes unmanageable as one giant .c file. The usual fix is to split it by responsibility: main.c drives the program, math_utils.c/.h handles calculations, sensor.c/.h handles a sensor, display.c/.h handles output, and so on.
This split buys several things at once: separation of responsibilities (each file has one job), code reuse (math_utils.c can be dropped into a different project unchanged), easier maintenance (you know exactly where to look for sensor logic), easier testing (a module can be tested in isolation), reduced compilation time for unchanged modules (only the files that changed need recompiling), clearer interfaces (a header spells out exactly what a module offers), and better team development (different people can own different files without constantly colliding in the same one).
Source Files and Header Files
A typical module is really two files working as a pair. calculator.h is the interface — it says what the module offers, without saying how: int add(int a, int b); int subtract(int a, int b);. calculator.c is the implementation — it says how those functions actually work.
The header is what other files are allowed to know about calculator's functions; the .c file is where the actual work happens. Anyone who includes calculator.h can call add() and subtract() without ever seeing how they're implemented.
// calculator.h — the interface
int add(int a, int b);
int subtract(int a, int b);
// calculator.c — the implementation
#include "calculator.h"
int add(int a, int b) {
return a + b;
}
int subtract(int a, int b) {
return a - b;
}What Goes Into a Header File?
A header file exists to describe a module's public interface — everything other files need in order to use the module correctly, and nothing more. That typically includes: function declarations (prototypes), type definitions, struct and enum definitions, typedefs, macros, shared constants, extern declarations for shared globals, and occasionally inline-function definitions where the language allows them.
A useful test: if a piece of code answers "what is available and what does it look like from the outside," it probably belongs in the header. If it answers "how does this actually work internally," it belongs in the .c file instead.
What Goes Into a .c File?
A source file holds the implementation details the header only promised existed: the actual function bodies, local helper logic, and any state the module needs to do its job. A .c file includes its own header (and whatever else it needs) and then supplies the real code.
validate() is marked static, meaning it only exists for calculator.c's own use. It never appears in calculator.h because callers of the module have no business calling it directly — it's an implementation detail, not part of the interface.
// calculator.c
#include "calculator.h"
static int validate(int value) {
// an internal helper — other files never need to call this directly
return value >= 0;
}
int add(int a, int b) {
return a + b;
}Function Declaration vs Definition
These two words describe genuinely different things, and multi-file C leans on the distinction constantly. A declaration tells the compiler that something exists and what its type/signature looks like, without providing a body: int add(int a, int b);. A definition actually provides the implementation: int add(int a, int b) { return a + b; }.
The standard multi-file convention follows directly from this: declarations live in headers so any file that includes the header knows the shape of the function, while the one definition lives in exactly one .c file where the actual work happens.
// declaration (typically in a header) — no body, ends with a semicolon
int add(int a, int b);
// definition (in a .c file) — has a body
int add(int a, int b) {
return a + b;
}Basic Multi-file Example
Putting the pieces together, a minimal three-file project looks like this: calculator.h declares the interface (with a header guard, covered next), calculator.c implements it, and main.c includes both the standard library and the project header to actually use it.
main.c never sees calculator.c's source code — it only sees calculator.h's declarations. That's enough for the compiler to check the call to add() is well-formed; the actual implementation is found later, at link time.
// calculator.h
#ifndef CALCULATOR_H
#define CALCULATOR_H
int add(int a, int b);
int multiply(int a, int b);
#endif // CALCULATOR_H// calculator.c
#include "calculator.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}// main.c
#include <stdio.h>
#include "calculator.h"
int main(void) {
int sum = add(10, 20);
printf("sum = %d\n", sum);
return 0;
}Why Does main.c Need the Header?
Without #include "calculator.h", main.c has no declaration for add() at all. The compiler needs to know a function's parameter types and return type before it can correctly compile a call to it — that's what lets it catch a mistake like passing the wrong number of arguments, or treating an int return value as something else, right at compile time instead of producing subtly broken code.
Including the header is what gives the compiler that type information while it's compiling main.c, well before calculator.c's actual implementation is available to be linked in.
What #include Actually Does
Common mistake: assuming #include performs linking. It doesn't — #include is pure text substitution that happens before compilation even starts. Linking is a separate, later step that combines already-compiled object files; #include never touches it.
Header Inclusion Does Not Mean Linking
Because #include only gives main.c calculator's declarations, not its implementation, main.c including calculator.h does not by itself make add() callable at runtime. The actual code for add() only exists once calculator.c is compiled into an object file, and the executable only comes together once a linker combines main.o and calculator.o.
Including the header only gives main.c enough information to compile a correct call to add(). The actual machine code for add() only becomes part of the program once calculator.c is compiled to calculator.o and the linker stitches main.o and calculator.o together.
main.c --includes--> calculator.h (declarations only)
main.c --compiles to--> main.o
calculator.c --compiles to--> calculator.o
main.o + calculator.o --linker--> program (executable)Translation Units
Each .c file, together with everything its #include directives pull in and every macro the preprocessor expands, forms one translation unit — the actual text the compiler sees for that file. main.c plus calculator.h's contents is one translation unit; calculator.c plus calculator.h's contents is a separate one, compiled independently.
Each translation unit is normally compiled on its own into its own object file, with no knowledge of the other translation units' internals. It's the linker, working afterward, that ties separately compiled translation units together into one program.
Header Guards
If the same header gets #included twice into one translation unit — which happens easily once headers start including other headers — its contents would be pasted in twice, and the compiler would see duplicate declarations (or worse, duplicate definitions). A header guard prevents this using three preprocessor directives wrapped around the header's contents.
The first time this header is included, SENSOR_H isn't defined yet, so #ifndef succeeds, #define SENSOR_H immediately defines it, and the contents are processed normally. Any later #include of the same header in the same translation unit finds SENSOR_H already defined, so #ifndef fails and everything down to #endif is skipped.
Common mistake: forgetting the header guard entirely. Without one, a header that gets pulled in more than once (directly, or indirectly through another header) produces duplicate declarations or duplicate definitions and the build fails with confusing redefinition errors.
#ifndef SENSOR_H
#define SENSOR_H
// ... header contents ...
#endif // SENSOR_HNaming Header Guards
A guard macro name only needs to be unique within the program, but a generic name like #ifndef HEADER_H invites collisions once a project has many headers (or pulls in third-party ones). A distinctive, project-prefixed name avoids that: #ifndef SAMPLEAPP_SENSOR_H is far less likely to collide with anything else than plain SENSOR_H.
It's worth remembering that a header guard is purely a preprocessor mechanism — SENSOR_H (or whatever name is chosen) is a preprocessor macro, not a C variable or object; it never appears in the compiled program and has no runtime existence at all.
#pragma once
Most compilers support #pragma once as a one-line alternative to the classic #ifndef/#define/#endif trio, telling the compiler directly not to include this particular file more than once per translation unit.
It's shorter and eliminates the risk of picking a colliding guard name, but it isn't part of the ISO C standard — it's a widely supported compiler extension rather than guaranteed portable behavior. Traditional #ifndef guards work everywhere a standard C compiler exists, which is why they remain the more portable default, even though #pragma once is common in practice.
#pragma once
int add(int a, int b);
int subtract(int a, int b);System Headers vs Project Headers
The exact directories each form searches (and in what order) is implementation- and compiler-configuration-dependent, but the convention itself is a reliable signal to a reader: angle brackets mean "this comes from outside the project," quotes mean "this is one of ours."
#include <stdio.h> // system header — angle brackets
#include "calculator.h" // project header — quotesPublic vs Private Interfaces
A well-designed module's header exposes only what callers actually need. sensor.h, for example, might expose only sensor_init(), sensor_read(), and sensor_shutdown() — the public interface. sensor.c is free to have additional helper functions, like validate_sensor(), that never appear in the header at all because callers have no business calling them directly.
This is a genuine, if simple, form of encapsulation in C: the header draws the line between what's public (declared there, safe to depend on) and what's private (an implementation detail that could change without warning).
static for File-private Functions
The static keyword, applied to a function at file scope, gives it internal linkage — meaning it's only visible and callable within the translation unit it's defined in. Other .c files, even if they somehow had a declaration for it, could not link against it.
calculate_checksum is a private implementation detail of checksum.c. Because it's static, it's safe to give it a short, generic name without worrying it will clash with a same-named function in a different file.
Common mistake: putting an ordinary (non-static) function's full definition in a widely-included header. If more than one .c file includes that header, each resulting translation unit gets its own copy of the definition, and the linker reports a multiple-definition error. Keep function definitions in .c files; put only the declaration in the header, and mark file-private helpers static.
// checksum.c
static int calculate_checksum(const unsigned char *data, int len) {
int sum = 0;
for (int i = 0; i < len; i++) sum += data[i];
return sum;
}External Variables and extern
Sometimes several files genuinely need to share one real variable — a global configuration flag, say. The right pattern is: exactly one .c file defines it (config.c: int system_mode = 1;), and every other file that needs it declares it with extern, typically by putting that extern declaration in a shared header.
extern int system_mode; in the header tells every file that includes it "this variable exists somewhere — you can use it," without creating storage for it. config.c is the single place that actually defines it and reserves its storage.
// config.h
#ifndef CONFIG_H
#define CONFIG_H
extern int system_mode; // declaration only — no storage created here
#endif // CONFIG_H// config.c
#include "config.h"
int system_mode = 1; // the one real definition — storage lives hereDo Not Define Global Variables in Headers
It's tempting to just write int system_mode; directly in a header and skip the extern dance — but if that header gets included by more than one .c file, each resulting translation unit ends up with its own definition of system_mode, and the linker sees multiple definitions of the same global at link time and fails.
Common mistake: defining a global variable directly in a header instead of using extern + a single .c-file definition. The fix is always the same shape: extern int system_mode; in the header (a declaration, no storage), and the one real int system_mode = 1; in exactly one .c file.
const Globals in Headers
const globals add a wrinkle: under C's linkage rules, const int max_size = 100; written directly in a header can still result in a separate definition in every translation unit that includes it — the same multiple-definition risk as an ordinary global, just easier to overlook because const feels read-only and "safe."
The same extern-in-header, define-in-one-.c-file pattern used for ordinary globals also resolves this for const globals. If a true compile-time constant with no storage at all is enough, a #define macro or an enum constant sidesteps the issue entirely.
// config.h
extern const int max_size; // declaration in the header
// config.c
const int max_size = 100; // the one real definitionTypes in Header Files
Struct definitions that more than one source file needs belong in a header, wrapped in the usual guard, so every file that includes it sees the exact same layout — no risk of two files quietly disagreeing about what a struct Person looks like.
Any .c file that includes person.h can now declare struct Person variables and knows exactly how big they are and what fields they have, without duplicating the definition (and risking it drifting out of sync) in every file that needs it.
// person.h
#ifndef PERSON_H
#define PERSON_H
struct Person {
char name[50];
int age;
};
#endif // PERSON_Htypedef in Headers
A typedef in a header lets every including file refer to a type by a short name instead of repeating a full struct declaration everywhere it's used.
Anywhere sensor_data.h is included, SensorData data; is enough — no file needs to spell out struct { int value; int status; } by hand, and every file agrees on exactly the same layout.
// sensor_data.h
#ifndef SENSOR_DATA_H
#define SENSOR_DATA_H
typedef struct {
int value;
int status;
} SensorData;
#endif // SENSOR_DATA_HEnums in Headers
Enums shared across files follow the same pattern as structs and typedefs — define once in a guarded header, and every file that needs the type includes it.
// sensor_state.h
#ifndef SENSOR_STATE_H
#define SENSOR_STATE_H
typedef enum {
SENSOR_OFF,
SENSOR_ON,
SENSOR_ERROR
} SensorState;
#endif // SENSOR_STATE_HMacros in Headers
Simple interface constants defined with #define, like #define SENSOR_MAX_VALUE 1000, are a common and reasonable thing to put in a header. But macros deserve extra caution when exposed this way, since the preprocessor performs blind text substitution with no type checking and no awareness of operator precedence or evaluation order.
A macro parameter isn't a normal function argument evaluated once — it's pasted in verbatim everywhere it appears in the macro body. Passing an expression with a side effect, like i++, into SQUARE multiplies that side effect and produces undefined behavior instead of the expected i*i.
#define SQUARE(x) ((x) * (x))
int i = 5;
int result = SQUARE(i++); // expands to ((i++) * (i++)) — undefined behavior,
// i is modified twice with no sequence point betweenInclude Dependencies
If sensor.h uses a type defined in types.h (say, a typedef it needs for a function's parameter), sensor.h should #include "types.h" itself, rather than silently assuming whoever includes sensor.h will have already included types.h too.
This makes a header self-sufficient: any file can #include "sensor.h" on its own, in any order relative to other includes, and get a header that compiles cleanly by itself — without needing hidden knowledge of what else has to be included first.
Avoid Unnecessary Includes
Every #include adds a dependency: if the included header later changes, everything that (directly or indirectly) includes it may need recompiling. It's worth minimizing unnecessary header dependencies rather than including a full header just out of habit.
When a header only needs to mention a pointer to some type — not the type's full definition — a forward declaration is often enough, and avoids pulling in the full header (and everything it depends on) at all.
Forward Declarations
Writing struct Sensor; on its own, without a body, declares Sensor as an incomplete type — the compiler knows a struct by that name exists, but not its size or members. That's enough to declare a pointer to it (struct Sensor *sensor;), because a pointer's size doesn't depend on what it points to, but not enough to declare an actual struct Sensor sensor; variable, since the compiler would need to know its size to allocate one.
A header that only ever hands out and receives struct Sensor pointers can use this forward declaration instead of #include "sensor_internal.h", cutting an unnecessary dependency.
struct Sensor; // forward declaration — incomplete type
struct Sensor *sensor; // OK — pointer size doesn't depend on Sensor's contents
// struct Sensor sensor; // ERROR — compiler doesn't know Sensor's size hereOpaque Types
Forward declarations enable a particularly strong form of encapsulation: an opaque type. The header forward-declares the struct and typedefs it, then exposes only functions that hand out and take pointers to it — the struct's actual fields are never shown in the header at all, only inside the .c file that implements the module.
Anyone using Sensor only ever holds a Sensor *, never sees calibration or last_reading, and can never accidentally depend on the internal layout. If sensor.c later adds or reorders fields, every caller keeps compiling unchanged — that's the whole point of hiding the definition.
// sensor.h
#ifndef SENSOR_H
#define SENSOR_H
typedef struct Sensor Sensor; // opaque — no fields visible here
Sensor *sensor_create(void);
int sensor_read(Sensor *s);
void sensor_destroy(Sensor *s);
#endif // SENSOR_H// sensor.c
#include "sensor.h"
#include <stdlib.h>
struct Sensor { // the real definition — hidden from every caller
int calibration;
int last_reading;
};
Sensor *sensor_create(void) {
Sensor *s = malloc(sizeof(Sensor));
if (s) { s->calibration = 0; s->last_reading = 0; }
return s;
}
int sensor_read(Sensor *s) {
return s->last_reading;
}
void sensor_destroy(Sensor *s) {
free(s);
}Typical Module Structure
A moderately sized project usually settles into a shape like: main.c ties everything together, and it depends on sensor.c/.h, display.c/.h, and communication.c/.h, each a self-contained module with its own header, plus a shared types.h that any module needing common type definitions can include.
main.c
|-- includes sensor.h (sensor.c implements it)
|-- includes display.h (display.c implements it)
|-- includes communication.h (communication.c implements it)
sensor.h, display.h, communication.h
|-- each may include types.h for shared type definitionsBuild Process
Each .c file — main.c, sensor.c, display.c, and so on — is compiled independently into its own object file (.o). Only afterward does the linker combine those object files into the final executable. Headers are never compiled into their own object files at all; they're processed as text during each source file's preprocessing step and then vanish — their contents just become part of whichever translation unit included them.
Example Build Command
The simplest way to build a multi-file program is a single command naming every source file: gcc main.c calculator.c -o program. Internally, gcc still compiles each .c file separately and links the results — it's just doing both steps for you in one invocation.
The -c flag tells gcc to compile to an object file and stop there — no linking. Doing the two stages explicitly is exactly what the one-step command does under the hood; separating them is what makes separate compilation and incremental rebuilds possible.
Common mistake: forgetting to list every needed source (or object) file when linking, e.g. running gcc main.c -o program when add() is implemented in calculator.c. Compilation of main.c still succeeds (the header supplied a valid declaration), but linking fails with an "undefined reference to add" style error, because no object file actually defines add(). The fix is simply to include calculator.c (or its already-compiled calculator.o) in the link command.
# one-step build — gcc compiles both files, then links them
gcc main.c calculator.c -o program
# equivalent explicit two-stage build
gcc -c main.c # produces main.o (compile only, no linking)
gcc -c calculator.c # produces calculator.o
gcc main.o calculator.o -o program # link the two object files togetherSeparate Compilation
Because each .c file is compiled into its own independent object file, changing only calculator.c doesn't require recompiling main.c or any other unchanged source file — a build system can simply recompile calculator.o and re-link, reusing every other object file exactly as it was. In a large project with dozens or hundreds of source files, this difference — rebuilding one file versus rebuilding everything — is enormous.
Why Build Systems Matter
Typing out gcc file1.c file2.c file3.c ... by hand works for a handful of files, but it doesn't scale: as a project grows, so does the list of files, compiler flags, include paths, and library paths, and manually remembering to recompile exactly the right files after each change becomes error-prone.
Build systems like Make, CMake, Ninja, and Meson exist to automate exactly this: tracking which files depend on which, compiling only what's changed, linking everything correctly, managing include/library paths, and switching between configurations like debug and release builds — all from a single command like make or cmake --build ., instead of a hand-typed gcc invocation that grows with the project.
Header Dependency Example
Dependencies flow through headers just as much as through .c files. If sensor.h changes — a new function added, a struct field changed — every translation unit that includes it, directly or indirectly, may need recompiling. That typically means both main.c (which includes sensor.h to call sensor functions) and sensor.c (which includes its own header) get rebuilt, even though only sensor.h's text actually changed.
This is exactly why keeping headers focused and dependencies minimal (as covered above) isn't just a style preference — a header that's included everywhere becomes a build-performance bottleneck the moment it changes.
One Definition and Multiple Declarations
A function can legally be declared in as many translation units as you like — that's exactly what happens every time a header with its prototype gets included somewhere new. What a program should have is exactly one definition of that function, somewhere in exactly one .c file; the linker's job is to connect every call site's reference back to that single definition.
Common mistake: letting a function's declaration and its definition drift out of sync — for instance a header declaring int add(int a, int b); while the .c file actually defines double add(double a, double b) { ... }. Depending on the compiler and language mode this can produce a compile error, a link error, or (worst case) silently mismatched behavior. Keep the header's declaration and the .c file's definition textually matching in return type, parameter types, and parameter count.
Common Multi-file Architecture
Larger embedded and systems projects often organize modules into explicit layers: an Application layer on top, calling down into Services, which call down into Drivers, which sit on a Hardware Abstraction layer, which finally talks to actual Hardware. Each layer is made of its own modules, and a module only depends on the layer immediately below it.
This layering keeps dependencies explicit and one-directional. It also makes individual layers testable and replaceable in isolation — swapping out a Driver module for a different sensor chip, say, without touching the Services or Application code above it.
Application
|
v
Services
|
v
Drivers
|
v
Hardware Abstraction Layer
|
v
HardwareBest Practices
A short checklist worth keeping in mind for any multi-file project: keep headers focused on the module's public interface rather than letting implementation details leak in; put actual implementations in .c files, and mark anything file-private static; include a module's own header first inside its own .c file (sensor.c should start with #include "sensor.h") — if the header isn't actually self-contained, this is often what surfaces the problem immediately; always use include guards (or #pragma once); avoid global state where a narrower interface would do, and when state genuinely must be shared, expose it deliberately (extern, not an accidental header-defined global); and keep header dependencies as small as reasonably possible to limit coupling and rebuild times.
Complete Mini Project
Putting everything from this page together — a header interface, a private static helper alongside public implementations, and a main.c that drives it all — here's a complete, buildable math_utils project.
gcc compiles main.c into main.o — it needs math_utils.h to know add() and multiply()'s signatures, but not their implementations.
gcc compiles math_utils.c into math_utils.o — validate() stays static and internal to this object file.
The linker combines main.o and math_utils.o, resolving main.o's calls to add() and multiply() against their definitions in math_utils.o.
The result is one executable, built with: gcc main.c math_utils.c -o program
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif // MATH_UTILS_H// math_utils.c
#include "math_utils.h"
static int validate(int value) {
// a private helper: not declared in math_utils.h, not callable elsewhere
return value != 0;
}
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
if (!validate(a) || !validate(b)) {
return 0;
}
return a * b;
}// main.c
#include <stdio.h>
#include "math_utils.h"
int main(void) {
printf("add(10, 20) = %d\n", add(10, 20));
printf("multiply(5, 6) = %d\n", multiply(5, 6));
return 0;
}The Big Picture
Every idea on this page collapses into one pipeline. Source files and header files are turned by the preprocessor into translation units (after expanding every #include and macro); the compiler turns each translation unit into an object file; and the linker combines all the object files, resolving every reference to its one real definition, into a single executable.
A header answers "what is available?" — its declarations, types, and constants. A .c file answers "how is it implemented?" — the actual code. The preprocessor's job stops at text expansion; the compiler's job is turning expanded text into object code; the linker's job is connecting every reference across every object file to its one real definition.
source files (.c) + header files (.h)
|
v
preprocessing (#include, macros expanded)
|
v
translation units
|
v
compiler
|
v
object files (.o)
|
v
linker
|
v
executableQuick Reference
A compact glossary of everything this page covers: a .c file holds implementation/source code; a .h file holds shared declarations and the module's interface; #include makes a header's contents available to a translation unit (a preprocessing step, not linking); a header guard prevents a header from being processed more than once per translation unit; #pragma once is a common, non-standard alternative to a manual guard; a function declaration describes a function's signature, while a function definition supplies its body; static gives a file-scope function or variable internal linkage, restricting it to its own translation unit; extern declares something that's defined elsewhere, without creating storage for it; a forward declaration introduces an incomplete type or a function signature ahead of its full definition; an opaque type hides a struct's real layout inside a .c file, exposing only pointers to it through the header; a translation unit is a source file after preprocessing; an object file is what the compiler produces from one translation unit; and the linker is what combines object files and resolves every external reference into one finished executable.
What concrete benefits come from splitting a growing C program into multiple files instead of keeping it as one large .c file?
Separation of responsibilities (each file has one job), code reuse across projects, easier maintenance and testing, reduced compilation time since unchanged files don't need recompiling, clearer interfaces between parts of the program, and easier collaboration since different people can work on different files.
What's the difference between a function declaration and a function definition, and where does each normally live in a multi-file project?
A declaration states a function's signature (return type, name, parameter types) without a body, telling the compiler it exists; a definition supplies the actual body. Declarations normally live in header files so any file can see them, while the one definition lives in a single .c file.
Does writing #include "module.h" in main.c cause module.c's code to be linked into main.c? Explain what #include actually does.
No. #include is a preprocessor directive that textually pastes the named file's contents into the including file before compilation starts — it only affects what the compiler sees during compilation. Linking module.c's actual implementation into the final program is a separate, later step performed by the linker on already-compiled object files.
Why is it a problem to write a plain global variable definition, like int system_mode;, directly inside a header file that multiple .c files include?
Each .c file that includes the header becomes its own translation unit, and each would get its own copy of that definition. When the linker tries to combine all the resulting object files, it finds the same global defined more than once and reports a multiple-definition error. The fix is to put only extern int system_mode; in the header and the one real definition in a single .c file.
What is an opaque type in C, and what does it let a module hide from the code that uses it?
An opaque type is one whose full definition (its struct fields) is hidden inside the module's .c file, while the header only forward-declares the type and exposes functions that create, use, and destroy pointers to it. Callers work entirely through pointers and never see or depend on the internal layout, so the module's internals can change freely without breaking anything that uses it.
Write a C program split across a calculator.h header (with an include guard) and a calculator.c source file that declares and implements add() and subtract(), plus a main.c that includes the header and calls both functions.
// ---- calculator.h ---- #ifndef CALCULATOR_H #define CALCULATOR_H int add(int a, int b); int subtract(int a, int b); #endif // CALCULATOR_H // ---- calculator.c ---- #include "calculator.h" int add(int a, int b) { return a + b; } int subtract(int a, int b) { return a - b; } // ---- main.c ---- #include <stdio.h> #include "calculator.h" int main(void) { printf("add(10, 20) = %d\n", add(10, 20)); printf("subtract(20, 10) = %d\n", subtract(20, 10)); return 0; } // build with: gcc main.c calculator.c -o program calculator.h declares the two functions behind a header guard so main.c can call them safely; calculator.c supplies the actual implementations. Building with gcc main.c calculator.c -o program compiles each file into its own object file and links them into one executable, exactly the pattern this page walks through.
Write a C program that defines a struct Person (name and age) inside a guarded header file, and a main.c that includes the header, creates a Person, fills in its fields, and prints them.
// ---- person.h ---- #ifndef PERSON_H #define PERSON_H struct Person { char name[50]; int age; }; #endif // PERSON_H // ---- main.c ---- #include <stdio.h> #include <string.h> #include "person.h" int main(void) { struct Person p; strcpy(p.name, "Alice"); p.age = 30; printf("Name: %s, Age: %d\n", p.name, p.age); return 0; }
Putting struct Person in a guarded header means any file that needs the type can include person.h and get the exact same layout, with no risk of two files disagreeing about the struct's fields. The guard also means including person.h more than once (directly or through another header) can't cause a duplicate-definition error.
Write a C program that shares a global configuration variable across files using extern: config.c defines and initializes the variable, config.h declares it with extern, and main.c uses it through the header.
// ---- config.h ---- #ifndef CONFIG_H #define CONFIG_H extern int system_mode; #endif // CONFIG_H // ---- config.c ---- #include "config.h" int system_mode = 1; // ---- main.c ---- #include <stdio.h> #include "config.h" int main(void) { printf("system_mode = %d\n", system_mode); system_mode = 2; printf("system_mode = %d\n", system_mode); return 0; } // build with: gcc main.c config.c -o program config.h's extern int system_mode; only declares that the variable exists somewhere, without allocating storage for it. config.c supplies the one real definition and initializer. Every file that includes config.h, like main.c, can read and write the same shared variable, and the linker resolves all of those references back to config.c's single definition.
Write a C program that implements a Sensor as an opaque type: the header only forward-declares the type and exposes create/read/destroy functions, while the actual struct fields are defined privately inside the .c file.
// ---- sensor.h ---- #ifndef SENSOR_H #define SENSOR_H typedef struct Sensor Sensor; Sensor *sensor_create(int initial_reading); int sensor_read(Sensor *s); void sensor_destroy(Sensor *s); #endif // SENSOR_H // ---- sensor.c ---- #include "sensor.h" #include <stdlib.h> struct Sensor { int reading; }; Sensor *sensor_create(int initial_reading) { Sensor *s = malloc(sizeof(Sensor)); if (s) { s->reading = initial_reading; } return s; } int sensor_read(Sensor *s) { return s->reading; } void sensor_destroy(Sensor *s) { free(s); } // ---- main.c ---- #include <stdio.h> #include "sensor.h" int main(void) { Sensor *s = sensor_create(42); printf("reading = %d\n", sensor_read(s)); sensor_destroy(s); return 0; } main.c only ever holds a Sensor pointer and calls the three exposed functions — it never sees or depends on the reading field, which is defined only inside sensor.c. That hides the internal representation completely: sensor.c could add or rearrange fields later without breaking any code that uses the module.
Write a C program with a source file that contains both a static (file-private) helper function and a public function that uses it, plus a header exposing only the public function, and a main.c that calls it.
// ---- checksum.h ---- #ifndef CHECKSUM_H #define CHECKSUM_H int compute_checksum(const unsigned char *data, int len); #endif // CHECKSUM_H // ---- checksum.c ---- #include "checksum.h" static int raw_sum(const unsigned char *data, int len) { int sum = 0; for (int i = 0; i < len; i++) { sum += data[i]; } return sum; } int compute_checksum(const unsigned char *data, int len) { return raw_sum(data, len) % 256; } // ---- main.c ---- #include <stdio.h> #include "checksum.h" int main(void) { unsigned char data[] = { 10, 20, 30, 40 }; printf("checksum = %d\n", compute_checksum(data, 4)); return 0; } raw_sum is declared static, giving it internal linkage so it's only visible inside checksum.c — it never appears in checksum.h and cannot be called from main.c or any other file. compute_checksum is the module's real public interface, declared in the header and free to use raw_sum internally without exposing it.
Write a C program that defines a SensorState enum in a shared header and uses it consistently in both a source file that changes the state and a main.c that reports it.
// ---- sensor_state.h ---- #ifndef SENSOR_STATE_H #define SENSOR_STATE_H typedef enum { SENSOR_OFF, SENSOR_ON, SENSOR_ERROR } SensorState; const char *sensor_state_name(SensorState state); #endif // SENSOR_STATE_H // ---- sensor_state.c ---- #include "sensor_state.h" const char *sensor_state_name(SensorState state) { switch (state) { case SENSOR_OFF: return "OFF"; case SENSOR_ON: return "ON"; case SENSOR_ERROR: return "ERROR"; default: return "UNKNOWN"; } } // ---- main.c ---- #include <stdio.h> #include "sensor_state.h" int main(void) { SensorState state = SENSOR_ON; printf("state = %s\n", sensor_state_name(state)); return 0; }
Defining SensorState once in sensor_state.h means sensor_state.c and main.c both work with the exact same set of named values, with no risk of one file using a different numbering or a typo'd constant. sensor_state_name() is declared alongside the enum so callers get a matching public interface for turning a state into readable text.
Write a C program implementing a small math_utils module (header + source) with a static validate() helper and public add()/multiply() functions, plus a main.c that uses both public functions, matching a typical mini-project layout.
// ---- math_utils.h ---- #ifndef MATH_UTILS_H #define MATH_UTILS_H int add(int a, int b); int multiply(int a, int b); #endif // MATH_UTILS_H // ---- math_utils.c ---- #include "math_utils.h" static int validate(int value) { return value != 0; } int add(int a, int b) { return a + b; } int multiply(int a, int b) { if (!validate(a) || !validate(b)) { return 0; } return a * b; } // ---- main.c ---- #include <stdio.h> #include "math_utils.h" int main(void) { printf("add(10, 20) = %d\n", add(10, 20)); printf("multiply(5, 6) = %d\n", multiply(5, 6)); return 0; } // build with: gcc main.c math_utils.c -o program validate() stays static and private to math_utils.c, used only to guard multiply() against zero inputs; add() and multiply() are the module's real interface, declared in math_utils.h so main.c can call them after the three files are compiled separately and linked together with gcc main.c math_utils.c -o program.
Write a C program where a shared SensorData typedef struct lives in one header and is used consistently by two different source files (module_a.c and module_b.c) plus main.c.
// ---- sensor_data.h ---- #ifndef SENSOR_DATA_H #define SENSOR_DATA_H typedef struct { int value; int status; } SensorData; SensorData module_a_make(int value); void module_b_print(SensorData data); #endif // SENSOR_DATA_H // ---- module_a.c ---- #include "sensor_data.h" SensorData module_a_make(int value) { SensorData data; data.value = value; data.status = 1; return data; } // ---- module_b.c ---- #include <stdio.h> #include "sensor_data.h" void module_b_print(SensorData data) { printf("value=%d status=%d\n", data.value, data.status); } // ---- main.c ---- #include "sensor_data.h" int main(void) { SensorData data = module_a_make(99); module_b_print(data); return 0; } // build with: gcc main.c module_a.c module_b.c -o program
Because SensorData is defined once in sensor_data.h, module_a.c and module_b.c (and main.c) all agree on exactly the same layout even though they're compiled as separate translation units — module_a.c builds one, module_b.c reads it, and the linker only needs to resolve the function calls between the resulting object files, not the struct layout itself.
Write a C program that uses a forward declaration (struct Sensor;) in one header to work with a Sensor pointer without pulling in the full struct definition, while the full definition lives in a separate header used only by the implementation file.
// ---- sensor_fwd.h ---- #ifndef SENSOR_FWD_H #define SENSOR_FWD_H struct Sensor; // forward declaration only — incomplete type void print_sensor_pointer(struct Sensor *s); #endif // SENSOR_FWD_H // ---- sensor_full.h ---- #ifndef SENSOR_FULL_H #define SENSOR_FULL_H struct Sensor { int reading; }; #endif // SENSOR_FULL_H // ---- sensor_fwd.c ---- #include <stdio.h> #include "sensor_fwd.h" void print_sensor_pointer(struct Sensor *s) { printf("sensor pointer = %p\n", (void *)s); } // ---- main.c ---- #include "sensor_fwd.h" #include "sensor_full.h" int main(void) { struct Sensor s; s.reading = 7; print_sensor_pointer(&s); return 0; } // build with: gcc main.c sensor_fwd.c -o program sensor_fwd.h only needs struct Sensor * as a parameter type, so a bare forward declaration is enough for it and for sensor_fwd.c, which never touches the struct's fields. main.c needs an actual struct Sensor variable, so it additionally includes sensor_full.h for the complete definition — showing how a forward declaration trims a header's dependencies down to only what that header's own code actually requires.