As C programs grow beyond a single file, you need a way to share code between files without duplicating it everywhere. This is where the split between header files and implementation files comes in. It’s a simple convention but one that may feel strange when coming from languages that don’t have header files (I’m looking at you JS, C#, Python).
The Problem We’re Solving
Imagine you’ve written a useful function for calculating distances:
// In geometry.c
float distance(float x1, float y1, float x2, float y2) {
float dx = x2 - x1;
float dy = y2 - y1;
return sqrt(dx * dx + dy * dy);
}Now you want to use this function in another file, main.c. The C compiler processes each file independently before the linker combines them, so when it compiles main.c, it has no idea that distance exists over in geometry.c. You need to tell the compiler about the function before you can call it.
You could just copy the declaration into main.c:
// In main.c
float distance(float x1, float y1, float x2, float y2); // Declaration
int main(void) {
float d = distance(0, 0, 3, 4); // Now the compiler knows about it
return 0;
}This works, but it doesn’t scale. If you use distance in ten different files, you’ve got ten copies of the declaration. If you later change the function signature — say, adding a third dimension — you have to update all ten files. Miss one and you’ve got a bug that might not show up until runtime.
Header files solve this by giving you one place to put declarations that multiple files can share.
Headers: The Public Interface
A header file (.h) contains declarations: function prototypes, type definitions, macros, and extern variable declarations. It describes what exists without providing the actual implementation.
// geometry.h
#ifndef GEOMETRY_H
#define GEOMETRY_H
// Function declarations - the public interface
float distance(float x1, float y1, float x2, float y2);
float triangle_area(float base, float height);
float circle_area(float radius);
#endifThis file says “these functions exist and here’s how to call them.” It doesn’t say how they work. That’s not the header’s job.
Implementation Files: The Actual Code
An implementation file (.c) contains definitions: the actual function bodies, variable initialisations, and any private helper functions. It includes its own header to ensure the declarations and definitions stay in sync.
// geometry.c
#include <math.h>
#include "geometry.h" // Include our own header
// Private helper function - only visible in this file
static float square(float x) {
return x * x;
}
// Public function definitions
float distance(float x1, float y1, float x2, float y2) {
return sqrt(square(x2 - x1) + square(y2 - y1));
}
float triangle_area(float base, float height) {
return 0.5f * base * height;
}
float circle_area(float radius) {
return 3.14159f * square(radius);
}Notice that geometry.c includes geometry.h. This is good practice because the compiler will catch any mismatch between your declarations and definitions. If you declare float distance(float, float, float, float) in the header but define double distance(...) in the source file, the compiler will complain.
Using the Header
Any file that wants to use your geometry functions just includes the header:
// main.c
#include <stdio.h>
#include "geometry.h" // Now we know about distance, triangle_area, etc.
int main(void) {
float d = distance(0, 0, 3, 4);
printf("Distance: %.2f\n", d); // Prints 5.00
float area = circle_area(10);
printf("Circle area: %.2f\n", area);
return 0;
}The #include "geometry.h" directive tells the preprocessor to paste the contents of that file right here. After preprocessing, the compiler sees the function declarations and knows how to handle the calls. Later, the linker connects these calls to the actual function definitions in geometry.o.
Header Guards: Preventing Double Inclusion
You might have noticed these lines wrapping the header content:
#ifndef GEOMETRY_H
#define GEOMETRY_H
// ... header contents ...
#endifThese are header guards, and they solve a subtle but important problem: what happens if a header gets included twice?
This can happen more easily than you might think. Say main.c includes both geometry.h and physics.h, and physics.h itself includes geometry.h because it uses some geometry functions internally:
// main.c
#include "geometry.h"
#include "physics.h" // This also includes geometry.h internallyWithout header guards, the contents of geometry.h would be pasted twice. For function declarations, this might just cause a warning. But if the header defines a struct or a typedef, you’d get a “redefinition” error — the compiler sees the same type being defined twice. Header guards prevent this.
The first time the preprocessor encounters #include "geometry.h", it processes these lines:
#ifndef GEOMETRY_H // Is GEOMETRY_H undefined? Yes, so continue.
#define GEOMETRY_H // Define GEOMETRY_H (now it exists).
// ... contents ... // Process the header contents.
#endif // End of the conditional block.If the same header gets included again later in the same translation unit:
#ifndef GEOMETRY_H // Is GEOMETRY_H undefined? No, it's defined now.
// Skip everything until #endif.
#endifThe second inclusion is effectively empty. The header contents only appear once, no matter how many times the header is included.
The name you use (like GEOMETRY_H) should be unique. The convention is to use the filename in uppercase with underscores replacing dots and any path separators. Some projects add a prefix or suffix to further reduce collision risk.
Angle Brackets vs Quotes
You’ll notice two styles of include:
#include <stdio.h> // Angle brackets
#include "geometry.h" // QuotesAngle brackets tell the preprocessor to look in the system include directories — places where the standard library headers live. Quotes tell it to look in the current directory first (or directories you specify), then fall back to system directories.
The rule of thumb: use angle brackets for standard library and system headers, quotes for your own headers and third-party libraries you’ve added to your project.
What Goes Where?
Here’s a quick guide for deciding what belongs in headers versus implementation files.
Headers should contain function declarations (prototypes), struct and union definitions, typedef declarations, enum definitions, macro definitions, extern variable declarations, and static inline function definitions (small functions meant to be inlined).
Implementation files should contain function definitions (the actual code), static (file-private) functions and variables, and the definitions of extern variables declared in headers.
The principle is that headers describe the interface while implementation files provide the behaviour. Anything that other files need to know about goes in the header. Anything that’s an internal implementation detail stays in the .c file.
A Complete Example
Let’s put it all together with a small string utilities module.
// string_utils.h
#ifndef STRING_UTILS_H
#define STRING_UTILS_H
#include <stddef.h> // For size_t
// Check if a string is empty or NULL
int string_is_empty(const char *str);
// Count occurrences of a character in a string
size_t string_count_char(const char *str, char c);
// Check if string starts with a given prefix
int string_starts_with(const char *str, const char *prefix);
#endif// string_utils.c
#include <string.h>
#include "string_utils.h"
int string_is_empty(const char *str) {
return str == NULL || str[0] == '\0';
}
size_t string_count_char(const char *str, char c) {
if (str == NULL) return 0;
size_t count = 0;
while (*str) {
if (*str == c) count++;
str++;
}
return count;
}
int string_starts_with(const char *str, const char *prefix) {
if (str == NULL || prefix == NULL) return 0;
return strncmp(str, prefix, strlen(prefix)) == 0;
}// main.c
#include <stdio.h>
#include "string_utils.h"
int main(void) {
const char *text = "hello, world";
if (!string_is_empty(text)) {
printf("The string has %zu 'l' characters\n",
string_count_char(text, 'l')); // Prints 3
}
if (string_starts_with(text, "hello")) {
printf("It starts with 'hello'\n");
}
return 0;
}To compile this, you’d typically run:
gcc -c string_utils.c -o string_utils.o
gcc -c main.c -o main.o
gcc string_utils.o main.o -o programOr more simply:
gcc string_utils.c main.c -o programThe compiler compiles each .c file into an object file, then the linker combines them. The header file is never compiled directly — it’s just pasted into whichever .c files include it.
Why This Matters
The header/implementation split does several things for you. It keeps your public interface separate from implementation details, so users of your code only see what they need. It enables separate compilation, so changing one .c file doesn’t require recompiling everything. It provides a natural place for documentation — the header shows what functions are available and what they do. And it scales cleanly as projects grow, because dependencies are explicit and manageable.
This pattern is so fundamental to C that you’ll see it everywhere: in the standard library, in operating system APIs, in every library you’ll ever use. Get comfortable with it, and structuring C projects becomes straightforward.
As always, thanks for joining me on this journey into embedded development!
