C Basics · beginner · ~10 min
By the end of this lesson you will be able to: - Split a C program into `.h` (interface) and `.c` (implementation) files cleanly. - Explain the difference between a *declaration* and a *definition*, and why C needs both. - Use `#include` correctly, and know when to use `"quotes"` versus `<angle brackets>`. - Write include guards (`#ifndef`/`#define`/`#endif`) and `#pragma once` to prevent double-inclusion. - Compile and link a multi-file program from the command line. - Decide what belongs in a header (the public interface) and what stays private in a `.c` file.
So far your programs have lived in a single .c file. That works for tiny exercises, but real programs are built from many files written by many people. Header files are how C lets one file use functions and types that are written in another file.
Think of a header as a menu and the source file as the kitchen. The menu (.h) tells you what dishes exist and what they are called, without revealing how they are cooked. The kitchen (.c) does the actual cooking. A caller only needs to read the menu to place an order; it never needs to walk into the kitchen.
This builds directly on the Functions lesson. There you learned that a function has a prototype (its name, return type, and parameter list) and a body (the code that runs). A header file is simply the natural home for that prototype, so that any other file can call your function without copying the prototype by hand.
Why does C need this at all? The C compiler reads one file at a time and works top-to-bottom. When it sees a call to sum(2, 3), it must already know that sum exists and what types it takes and returns — otherwise it cannot generate correct machine code or catch type mistakes. The header supplies that knowledge. Later, a separate step called linking connects the call to the real function body, which may live in a completely different .c file.
Key terms you will meet: declaration (a promise that something exists), definition (the real thing), translation unit (one .c file plus everything it includes), preprocessor (the text-substitution step that runs before compilation), and linker (the tool that stitches compiled pieces together).
Header files are the foundation of how C scales beyond toy programs:
<stdio.h> to get printf without ever seeing its source..c file can be compiled on its own. Change one file and you only recompile that file, not the whole project — critical when a codebase has thousands of files.Get headers wrong and you get the two most common build errors beginners hit: undefined reference (the linker cannot find a definition) and multiple definition / redefinition (the same thing was defined or included twice). Understanding headers removes most of those for good.
A declaration introduces a name and its type but does not allocate storage or provide code. A definition is the one place where the actual thing is created.
int sum(int a, int b); — a declaration (a function prototype). It promises that sum exists somewhere.int sum(int a, int b) { return a + b; } — a definition. It supplies the body.The rule of thumb: headers declare, .c files define. A name may be declared many times (every file that includes the header sees the declaration), but it must be defined exactly once across the whole program. This is called the One Definition Rule.
When to use: put a prototype in a header whenever a function is meant to be called from another file. Keep the definition in exactly one .c file.
Pitfall: putting a function body in a header. Every file that includes the header then gets its own copy of the definition, and the linker complains about multiple definitions.
Knowledge check: Is int sum(int a, int b); a declaration or a definition? What is the difference between the two?
#includeBefore the real compiler runs, a step called the preprocessor handles all the lines that start with #. The directive #include "foo.h" is the simplest: it literally copies the entire text of foo.h into the current file at that exact spot. Nothing clever happens — it is pure copy-and-paste of text.
main.c after preprocessing (one translation unit)
+-----------------------+ +-----------------------------+
| #include "math.h" | --> | int sum(int a, int b); | <- pasted from math.h
| int main(void){ | | int main(void){ |
| return sum(2,3); | | return sum(2,3); |
| } | | } |
+-----------------------+ +-----------------------------+
The combined text the compiler actually sees — your .c file plus everything it pulled in — is called a translation unit.
Quotes vs. angle brackets:
| Form | Search order | Use for |
|---|---|---|
#include "foo.h" |
your project directory first, then system paths | your own headers |
#include <stdio.h> |
system/compiler include paths only | standard and library headers |
Pitfall: including a .c file (#include "helper.c"). This pastes the definitions in and then they also get compiled separately, causing multiple-definition errors. Include .h files, compile .c files.
Knowledge check: After preprocessing, what does the compiler actually see when a file contains #include "math.h"?
Headers often include other headers. It is easy for the same header to be pasted into one translation unit twice — for example if both a.h and b.h include types.h, and your .c includes both. The second paste re-declares everything, and for types like structs that is an error.
An include guard stops this. It uses a preprocessor macro as a one-time flag:
First time math.h is pasted: Second time math.h is pasted:
MATH_H not defined yet MATH_H is already defined
-> define MATH_H -> #ifndef is false
-> keep the declarations -> skip everything to #endif
#ifndef MATH_UTILS_H // if this macro is NOT defined...
#define MATH_UTILS_H // ...define it now, so next time we skip
// declarations here
#endif // end of the guarded region
The modern one-line equivalent is #pragma once at the top of the header. It is supported by every mainstream compiler and is harder to get wrong (no name to clash), though the #ifndef form is the most portable and universally guaranteed by the standard.
When to use: every header you write should have a guard. There is no downside.
Pitfall: copy-pasting a header to make a new one and forgetting to change the guard macro name. Two different headers with the macro UTILS_H will silently shadow each other — the second one's contents get skipped.
Knowledge check (predict the result): Two different headers both start with #ifndef UTIL_H / #define UTIL_H. A .c file includes both. What happens to the declarations in the second header, and why?
A header is your module's public interface. Put in it only what callers need:
struct, enum, typedef) shared across files.Keep out of it: function bodies, private helper functions, and large internal data. Anything that does not need to be public stays in the .c file (and can be marked static so it is invisible outside that file).
Pitfall: dumping everything into one giant header that every file includes. A change forces the entire project to recompile, and unrelated code becomes coupled. Keep headers small and focused.
A header/source pair has a predictable shape.
/* math_utils.h - the interface (declarations only) */
#ifndef MATH_UTILS_H // include guard opens
#define MATH_UTILS_H
int sum(int a, int b); // prototype: name + types, no body, ends with ;
int product(int a, int b); // another declaration
#endif // include guard closes
/* math_utils.c - the implementation (definitions) */
#include "math_utils.h" // include OUR OWN header to keep declarations in sync
int sum(int a, int b) { // definition: prototype + body, no trailing ;
return a + b;
}
int product(int a, int b) {
return a * b;
}
Notes:
} and no semicolon..c file includes its own .h. This is a good habit: if the header and the body ever disagree on types, the compiler catches it immediately."quotes" for your own headers, <angle brackets> for system headers.C code is normally split across two kinds of files:
.h) declares functions, types, and macros. A declaration says what something is called and what its shape is, without providing the actual code..c) defines them. A definition provides the real implementation.#include worksThe line #include "foo.h" literally pastes the contents of foo.h into the source file at that exact point, before the compiler runs.
Headers often include other headers. Without protection, the same header can be pasted in more than once, causing errors.
To prevent this double-inclusion, wrap the header in an include guard:
#ifndef FOO_H
#define FOO_H
// ... declarations ...
#endif
The modern shortcut #pragma once does the same job in one line.
A header is effectively your library's public interface. Keep it as small as possible, and expose only what callers need.
/* ---------- math_utils.h : the public interface ---------- */
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int sum(int a, int b); /* declare: callers may use these */
int product(int a, int b);
#endif /* MATH_UTILS_H */
/* ---------- math_utils.c : the implementation ---------- */
#include "math_utils.h" /* keep definitions in sync with declarations */
int sum(int a, int b) {
return a + b;
}
int product(int a, int b) {
return a * b;
}
/* ---------- main.c : a caller ---------- */
#include <stdio.h> /* system header: angle brackets */
#include "math_utils.h" /* our header: quotes */
int main(void) {
int a = 6, b = 7;
printf("sum = %d\n", sum(a, b)); /* call across files */
printf("product = %d\n", product(a, b));
return 0;
}
What it does. Three files form one program. math_utils.h declares two functions. math_utils.c defines them. main.c includes the header so it knows the prototypes, then calls the functions. The header is included in two places (its own .c and main.c), which is exactly the normal case the include guard protects against.
How to build it (each .c is compiled separately, then linked together):
gcc -Wall -Wextra -std=c11 main.c math_utils.c -o app
./app
Expected output:
sum = 13
product = 42
Edge cases. If you forget to list math_utils.c on the compile line, the code still compiles (the prototype satisfies the compiler) but fails to link with an "undefined reference to sum" error — the classic sign that a definition is missing. If you accidentally #include "math_utils.c", you will get a "multiple definition" error instead.
Trace what happens when you run gcc main.c math_utils.c -o app. The toolchain works in stages.
Stage 1 - preprocessing (per file).
main.c: #include <stdio.h> pastes in the standard I/O declarations; #include "math_utils.h" pastes in the two prototypes. The include guard fires for the first time, so MATH_UTILS_H becomes defined and the declarations are kept.math_utils.c: #include "math_utils.h" pastes the same two prototypes in, guarded the same way.Stage 2 - compilation (per file). Each preprocessed translation unit is compiled to an object file independently.
| File | Sees prototypes? | Sees sum body? |
Result |
|---|---|---|---|
main.c |
yes (from header) | no | main.o with an unresolved call to sum |
math_utils.c |
yes (from header) | yes (in same file) | math_utils.o containing the real sum code |
Because main.c has the prototype, the compiler knows sum takes two ints and returns an int, so it generates a correct call and type-checks the arguments — even though the body is nowhere in this file.
Stage 3 - linking. The linker takes main.o and math_utils.o and resolves references. It finds the unresolved sum in main.o and matches it to the sum defined in math_utils.o. Same for product. With every reference resolved, it produces the executable app.
Stage 4 - run. main sets a = 6, b = 7. sum(6, 7) runs the linked body and returns 13; product(6, 7) returns 42. printf prints both lines. main returns 0.
The key insight: the header lets the compiler trust that sum exists, and the linker later supplies the actual code. Declaration and definition meet at link time.
1. Defining a function in the header.
/* utils.h - WRONG */
int sum(int a, int b) { return a + b; } /* a definition in a header */
Every .c that includes utils.h now contains its own copy of sum's body. The linker sees the same function defined multiple times: multiple definition of 'sum'. Fix: declare in the header, define once in a .c.
/* utils.h - RIGHT */
int sum(int a, int b); /* declaration only */
2. Forgetting to compile/link the .c file.
gcc main.c -o app # WRONG: math_utils.c is missing
Compiles fine (the prototype is enough), then fails: undefined reference to 'sum'. Fix: list every source file: gcc main.c math_utils.c -o app. Recognize it by the word reference (a link-time problem) rather than a compile error.
3. No include guard.
When a header that defines a struct or typedef is pasted in twice, you get redefinition of 'struct ...'. Fix: wrap every header in #ifndef/#define/#endif or put #pragma once at the top.
4. Reused guard macro after copy-paste.
You copy utils.h to strutils.h but leave #ifndef UTILS_H unchanged. Now whichever is included second is silently emptied, and you get undefined reference errors for functions you did declare. Fix: give each header a unique guard name matching the file (STRUTILS_H), or use #pragma once.
5. Including a .c file. #include "helper.c" pastes definitions in and the file is also compiled normally, causing duplicate definitions. Fix: include .h files only; compile .c files.
Compiler errors (one file at a time):
implicit declaration of function 'sum' — you called sum without its prototype in scope. You forgot to #include the header, or the include is below the call. Fix the include.redefinition of 'struct point' / 'foo' redeclared — the same header was pasted twice. Add or fix the include guard.error: expected ';' right after an #include line — the included header is missing a semicolon on its last prototype; the error often points at the next file because of pasting.Linker errors (whole program):
undefined reference to 'sum' — a definition is missing. Either you forgot to compile the .c that defines it, or the function name/signature does not match the declaration. Confirm the .c is on the build command and the signatures match exactly.multiple definition of 'sum' — defined more than once: a body in a header, or a .c included by mistake. Move the body to one .c.Concrete steps when a multi-file build fails:
gcc -c math_utils.c. This isolates which file is broken.gcc -E main.c | less. You can watch your header get pasted in.-Wall -Wextra. Implicit-declaration warnings catch missing includes early.Questions to ask: Does every function I call have a prototype in scope? Is every function I call defined in exactly one compiled .c? Does each header have a unique guard? Did I accidentally include a .c?
Headers themselves declare interfaces, so the main "safety" concerns here are about correctness and robustness rather than memory corruption — but they directly prevent a class of subtle bugs:
int sum(int, int) but the .c defines long sum(long, long), the compiler may not catch it across files, and the linker can match them anyway. Calls then pass the wrong-sized arguments, which is undefined behavior and can corrupt values silently. Defense: always #include a function's own header inside its .c file so the compiler verifies the body against the declaration..c but not the .h, and callers compile against the old shape. Defense: the single-source-of-truth rule — the prototype lives in the header only, and the .c includes that header.#define MAX 100 in a widely-included header silently rewrites any token MAX everywhere. Prefer static const int MAX = 100; or an enum, and use unique, prefixed macro names.int counter = 0; in a header defines counter in every translation unit (multiple-definition error or, worse, duplicate storage). Declare with extern int counter; in the header and define it once in a .c.None of these crash immediately, which is what makes them dangerous: they produce wrong results or fragile builds. Treating the header as the one authoritative contract eliminates them.
Real-world use. Every C library you have ever used works this way. The C standard library gives you <stdio.h>, <stdlib.h>, <string.h> — declarations only — while the actual code lives in a precompiled libc. Operating-system APIs (the Linux kernel headers, the Windows SDK, POSIX <unistd.h>) are headers you include to call functions implemented elsewhere. SQLite ships as sqlite3.h plus a single implementation file. SDL, OpenSSL, zlib — all the same pattern: a public header is the contract, the binary library is the kitchen.
Professional best practices.
Beginner habits:
foo.h (interface) and foo.c (implementation), named the same.#pragma once..c includes its own header first, then other project headers, then system headers..c files define. Definitions live in exactly one place.-Wall -Wextra so missing includes and mismatches surface early.Advanced habits:
static in the .c.extern for shared globals (declare in header, define once).enums or static const rather than macros to keep type checking.Beginner 1 - Split a single file. Take a one-file program that has int square(int n) and main. Move square into geometry.c and its prototype into geometry.h with a proper include guard. Have main.c include the header. Build with gcc main.c geometry.c -o app and confirm the output is unchanged. Concepts: declaration vs. definition, #include.
Beginner 2 - Add a second function. Extend the previous module with int cube(int n). Declare it in the header, define it in geometry.c, and call both from main. Input n = 3 should print square = 9 and cube = 27. Concepts: growing a public interface, keeping header and source in sync.
Intermediate 1 - Trigger and fix each error. Deliberately (a) remove geometry.c from the build command, (b) move a function body into the header, and (c) delete the include guard and include the header twice. For each, record the exact error message and which stage (compile vs. link) produced it, then fix it. Concepts: undefined reference, multiple definition, redefinition.
Intermediate 2 - Shared type and a constant. Create point.h declaring struct Point { int x, y; };, a prototype int manhattan(struct Point a, struct Point b);, and a shared enum { MAX_COORD = 1000 };. Implement manhattan (sum of absolute coordinate differences) in point.c. Include point.h from two different .c files to prove the include guard works. Concepts: types in headers, include guards protecting structs, enums vs. macros.
Challenge - A mini math library. Build a reusable module mathx with header mathx.h and source mathx.c exposing at least: int gcd(int a, int b), long ipow(int base, int exp), and int clamp(int v, int lo, int hi). Requirements: include guard, the .c includes its own header, an internal static helper that is NOT declared in the header, input validation (e.g. clamp must handle lo > hi sensibly), and a main.c that exercises every function. Build all files together. Hint: keep the header free of bodies; verify there are no warnings under -Wall -Wextra. Concepts: full interface/implementation separation, static for private helpers, robustness.
.h) holds declarations — the public interface. A source file (.c) holds definitions — the implementation. Headers declare; .c files define.#include is pure text substitution done by the preprocessor: it pastes a header's contents into the file. Use "quotes" for your own headers, <angle brackets> for system headers.#ifndef/#define/#endif) or #pragma once to stop double-inclusion..c include its own header so the compiler verifies the body matches the declaration. Keep headers small and focused — they are your module's contract.