File Handling &
Preprocessor Directives
Make data survive past program exit, and control exactly what the compiler sees before it ever runs — from fopen() to include guards.
Why File Handling Exists
Every variable you’ve used so far lives only in RAM. When your program exits, that memory is reclaimed and the data is gone — int age = 20; simply disappears the moment main() returns.
Some data must survive program exit: a student database, bank records, a sensor log accumulated over days, configuration settings that should persist after a reboot. The solution is the file — data stored permanently on a storage device (SSD, HDD, SD card, flash memory).
Student Database
Records that need to exist across every run of the program, not just the current session.
Sensor Logs
Temperature readings accumulated over days or weeks for later analysis.
Configuration
Settings that should persist even after the device is powered off and on again.
Firmware Images
The actual binary that gets flashed onto a microcontroller — itself a file.
What is a File? Streams
| Property | Memory (RAM) | File |
|---|---|---|
| Lifetime | Temporary — lost when program ends | Permanent — survives program exit |
| Speed | Very fast | Slower |
| Examples | int age; | students.txt, firmware.bin |
C accesses files through streams — think of a stream as a flow of data between your program and a source or destination, whether that’s a keyboard, monitor, or file on disk.
| Stream | Purpose | Example function |
|---|---|---|
stdin | Standard input | scanf() reads from it |
stdout | Standard output | printf() writes to it |
stderr | Standard error output | Used for error messages, kept separate from stdout |
printf() and scanf() call you’ve written since Chapter 1 has been using streams —stdout and stdin respectively. File handling simply extends this same stream-based model to disk files.FILE* & Opening Files
Every file operation goes through a FILE pointer — a handle that represents an open file and tracks its current position, mode, and error state internally.
FILE *fp; /* fp is a "handle" to an open file */To actually open a file, use fopen() — it returns a valid FILE* on success, or NULL on failure.
FILE *fp = fopen("data.txt", "r"); /* filename, mode */
if(fp == NULL)
{
printf("Unable to open file\n");
return 1; /* never assume the file opened successfully */
}NULL FILE* (e.g. calling fprintf(fp, ...) on it) is undefined behaviour and a guaranteed crash.Closing Files & File Modes
Always close a file when you’re done with it —fclose() flushes any buffered data to disk, releases the operating system resources tied to that handle, and prevents resource leaks.
FILE *fp = fopen("data.txt", "r");
/* ... use fp ... */
fclose(fp);The Six File Modes
| Mode | Read | Write | Creates file? | Erases existing? |
|---|---|---|---|---|
"r" | ✓ | ✗ | ✗ (must exist) | ✗ |
"w" | ✗ | ✓ | ✓ | ✓ |
"a" | ✗ | ✓ | ✓ | ✗ |
"r+" | ✓ | ✓ | ✗ (must exist) | ✗ |
"w+" | ✓ | ✓ | ✓ | ✓ |
"a+" | ✓ | ✓ | ✓ | ✗ |
"w" mode truncates it to zero length immediately —even before you write anything. If you meant to preserve existing content and only add new data, you wanted "a" (append), not "w".fprintf() & fscanf()
These are the file-aware counterparts of printf() and scanf() you already know —they take an extra FILE* argument telling them where to write or read.
#include <stdio.h>
int main()
{
/* Writing */
FILE *fp = fopen("marks.txt", "w");
if(fp == NULL) return 1;
fprintf(fp, "Alice %d\n", 95);
fclose(fp);
/* Reading it back */
fp = fopen("marks.txt", "r");
if(fp == NULL) return 1;
char name[30];
int marks;
fscanf(fp, "%s %d", name, &marks);
printf("%s scored %d\n", name, marks); /* Alice scored 95 */
fclose(fp);
return 0;
}The File Operation Lifecycle
Character I/O: fgetc() & fputc()
Sometimes you need to process a file one character at a time. fgetc() reads a single character; fputc() writes one.
int fgetc(FILE *fp); /* returns the character read, or EOF */
FILE *fp = fopen("data.txt", "r");
int ch;
while((ch = fgetc(fp)) != EOF)
{
printf("%c", ch); /* prints every character until EOF */
}
fclose(fp);EOF is typically -1, which doesn’t fit reliably in an unsigned/signed-ambiguous char on every platform. Always store the return value of fgetc() in an int to compare it against EOF correctly.int fputc(int ch, FILE *fp);
FILE *fp = fopen("letters.txt", "w");
fputc('A', fp);
fputc('B', fp);
fputc('C', fp);
fclose(fp);
/* letters.txt now contains: ABC */String I/O: fgets() & fputs()
Reading character-by-character is slow for long text. fgets() reads an entire line at once; fputs() writes a whole string.
char *fgets(char *str, int size, FILE *fp);
int fputs(const char *str, FILE *fp);
/* Reading one line, safely bounded by sizeof(line) */
char line[100];
FILE *fp = fopen("data.txt", "r");
fgets(line, sizeof(line), fp);
printf("%s", line);
fclose(fp);
/* Writing a string */
fp = fopen("output.txt", "w");
fputs("Embedded Systems", fp);
fclose(fp);| Character I/O | String I/O |
|---|---|
fgetc() — one character | fgets() — one line |
fputc() — write one character | fputs() — write one string |
Binary Files
Everything so far has stored text. Sometimes you need to store raw memory exactly as it exists —images, audio, firmware images, sensor logs, or entire structures.
| Property | Text File | Binary File |
|---|---|---|
| Human readable? | Yes | No |
| Stores | Characters (text representation) | Raw bytes (memory as-is) |
| Typical size | Larger | Usually smaller |
| Best for | Logs, configuration, easy editing | Structures, speed, exact memory layout |
b to a mode string ("wb", "rb") to open a file in binary mode. This matters most on Windows, where text mode performs newline translation that would corrupt raw binary data.fwrite() & fread()
fwrite() writes raw binary data; fread() reads it back. Both work in terms of element size and count, making them perfect for writing entire structures or arrays in one call.
size_t fwrite(const void *ptr, size_t size, size_t count, FILE *fp);
size_t fread ( void *ptr, size_t size, size_t count, FILE *fp);
struct Student { int roll; char name[30]; float marks; };
/* Writing */
struct Student s = {101, "Alice", 92.5};
FILE *fp = fopen("student.dat", "wb");
fwrite(&s, sizeof(struct Student), 1, fp); /* 1 record */
fclose(fp);
/* Reading it back */
struct Student loaded;
fp = fopen("student.dat", "rb");
fread(&loaded, sizeof(struct Student), 1, fp);
fclose(fp);
/* loaded now contains exactly what was saved */if(fread(&s, sizeof(s), 1, fp) != 1) { /* handle short read */ }fprintf() produces a text representation, not the raw memory layout —and reading binary data back with fscanf() will misinterpret the bytes. Text functions for text files, binary functions (fwrite/fread) for binary files —never cross the streams.File Position & fseek()
Every open file maintains a current position —an internal cursor that advances automatically as you read or write. Three functions let you inspect and control this cursor: ftell(), rewind(), and fseek().
After reading “ABC”, the position cursor sits at index 3 (pointing at ‘D’) —exactly what ftell() would report.
long ftell(FILE *fp); /* current position */
void rewind(FILE *fp); /* jump to position 0 */
int fseek(FILE *fp, long offset, int origin);
/* Example: file contains "ABCDE" */
fgetc(fp); fgetc(fp); fgetc(fp); /* read "ABC" */
printf("%ld\n", ftell(fp)); /* 3 — position after 3 reads */
rewind(fp); /* back to the very start */
fseek(fp, 5, SEEK_SET); /* jump to byte 5 from start */
fseek(fp, -2, SEEK_END); /* 2 bytes before the end */
fseek(fp, 3, SEEK_CUR); /* 3 bytes forward from here */| Origin constant | Meaning |
|---|---|
SEEK_SET | Offset measured from the beginning of the file |
SEEK_CUR | Offset measured from the current position |
SEEK_END | Offset measured from the end of the file (usually negative) |
fseek(fp, 0, SEEK_END); long size = ftell(fp); rewind(fp); — jump to the end, read the position (which equals the total size), then rewind back to the start before reading.EOF Handling
Every file has an end, and your program must detect it correctly. EOF (End Of File) is a special value most input functions return once there’s nothing left to read.
int ch;
while((ch = fgetc(fp)) != EOF)
{
putchar(ch);
}feof() only becomes true after a read operation has already attempted to go past the end of the file —not before. Checking it as your loop condition before attempting the read can cause one extra (invalid) iteration. Always loop on the return value of the read function itself.Common Mistakes
Best Practices & Memory Tricks
Always check fopen’s return
Never assume a file opened successfully —test against NULL every single time before using the handle.
Close every file you open
Pair every fopen() with exactly one fclose(), even on early-return error paths.
Choose the right mode
Use "a" to preserve existing content; use "w" only when you intend to start fresh.
Match function type to file type
Text functions (fprintf/fscanf) for text files; binary functions (fwrite/fread) for binary files.
“Every fopen needs exactly one fclose”
| Function | Memory hook |
|---|---|
FILE* | A handle — not the file itself, just a reference to it |
fopen() | Open — returns the handle, or NULL on failure |
fprintf() / fscanf() | Text write / text read |
fgetc() / fputc() | One character at a time |
fgets() / fputs() | One line / one string at a time |
fwrite() / fread() | Binary write / binary read |
fseek() / ftell() / rewind() | Move position / report position / jump to start |
fclose() | Close — flush and release |
Interview Questions
fgetc() and fgets() are text-oriented —they read characters or lines and are typically used with text files. fread() is binary-oriented —it reads a specified number of raw bytes (often a whole structure or array) exactly as they're stored in memory, regardless of what those bytes represent.fopen() returns NULL on failure (file not found, no permission, disk error, invalid path). You must always check if(fp == NULL) immediately after calling fopen() and handle the error (print a message, return an error code) before attempting any read/write operation on that handle.feof() only becomes true after a read operation has already tried and failed to read past the end of the file —not proactively before each iteration. Using it as a loop condition can cause one extra, invalid iteration with stale or garbage data. The correct pattern loops directly on the return value of the read function itself, e.g. while(fgets(line, size, fp))."w" truncates the file to zero length immediately on open (destroying any existing content) before allowing writes. "a" preserves all existing content and positions every write at the end of the file. Both create the file if it doesn't already exist.fseek(fp, 0, SEEK_END) to move the position to the end of the file, then call ftell(fp) —which now returns the total byte count (since the position equals the size when at the end). Follow with rewind(fp) to reset the position back to the beginning before reading.Frequently Asked Questions
fwrite/fread) are generally faster and more compact for storing structures, since the raw memory is written and read back directly with no text conversion overhead. Text files (via fprintf/fscanf) are human-readable and easier to inspect, debug, or edit by hand, but are slower and less compact. For configuration files and logs that humans need to read, prefer text. For large datasets or speed-critical applications, prefer binary.What is the Preprocessor?
Before your C source code is actually compiled, it passes through a separate program called the preprocessor. The preprocessor does not compile anything —it transforms your source text, preparing it for the compiler that runs afterward.
The preprocessor handles tasks like inserting header file contents, replacing macros with their definitions, conditionally including or excluding code, and stripping out comments. It recognizes any line beginning with # as a preprocessor directive.
| Directive | Purpose |
|---|---|
#include | Insert the contents of a header file |
#define | Define a macro |
#undef | Remove a macro definition |
#if | Conditional compilation based on an expression |
#ifdef | Conditional compilation: only if a macro exists |
#ifndef | Conditional compilation: only if a macro doesn’t exist |
#else / #elif | Alternative branches for conditional compilation |
#endif | Ends a conditional compilation block |
#include & Header Files
printf() is declared in stdio.h. To use it, you tell the preprocessor to insert that header’s contents directly into your source file before compilation begins.
/* math_utils.h — declarations only */
int add(int a, int b);
int subtract(int a, int b);
/* math_utils.c — implementation */
#include "math_utils.h"
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
/* main.c — usage */
#include <stdio.h>
#include "math_utils.h"
int main()
{
printf("%d\n", add(5, 3)); /* 8 */
return 0;
}A Header File Typically Contains
- Function declarations (prototypes)
- Macros and constants
- Type definitions (
typedef) - Structure and enumeration declarations
add() would need to duplicate its declaration manually —a maintenance nightmare across a multi-file project. Headers centralize the “public interface” of each module, improving organization, reusability, and maintainability.#define & Object-like Macros
#define creates a macro —a pure text substitution performed by the preprocessor before the compiler ever sees your code.
#define PI 3.14159
#define MAX 100
float area = PI * r * r;
int arr[MAX];
/* The compiler never sees PI or MAX — after preprocessing it sees: */
float area = 3.14159 * r * r;
int arr[100];120 through your code, write #define MAX_SPEED 120 once and use if(speed > MAX_SPEED) everywhere. If the limit ever changes, you update one line instead of hunting through the entire codebase.Function-like Macros
Macros can accept parameters too, making them look like function calls —but remember, they’re still pure text substitution, not real function calls.
#define SQUARE(x) ((x) * (x))
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int a = SQUARE(5); /* expands to: ((5) * (5)) → 25 */
int m = MAX(10, 20); /* expands to: ((10)>(20)?(10):(20)) → 20 */Why Parentheses Are Non-Negotiable
2+3) silently corrupts the result.A Subtler Trap: Side Effects
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int x = 5;
int y = MAX(x++, 10);
/* x++ may be expanded (and evaluated) TWICE in the comparison —
the macro's textual nature means side-effecting arguments
behave unpredictably depending on which branch is taken */x++ or a function call with state) into a macro whose definition might reference that parameter more than once.Macro vs Function
| Aspect | Macro | Function |
|---|---|---|
| Processed by | Preprocessor (text substitution) | Compiler, then executed at runtime |
| Call overhead | None | Function call overhead |
| Type checking | None | Yes |
| Argument evaluation | May happen multiple times (or zero) | Always exactly once, before the call |
| Debugger visibility | Sees only the expanded code | Sees the actual function and its name |
const or enum give the compiler type information that #define never does. For small reusable expressions, an inline function (where supported) gets the safety of a real function with comparable performance. Macros remain valuable for things only the preprocessor can do —conditional compilation, stringification, and code that must work across multiple types without true generics.#if / #ifdef / #ifndef
Sometimes different parts of a program should be compiled under different conditions —different operating systems, debug vs release builds, or different hardware targets. This is conditional compilation: code is included or excluded entirely before compilation even happens, unlike a runtime if statement which always gets compiled.
/* #if — evaluate a constant expression */
#define VERSION 2
#if VERSION == 2
printf("Version 2");
#endif
/* #ifdef — compile only if macro IS defined */
#define DEBUG
#ifdef DEBUG
printf("Debug Enabled");
#endif
/* #ifndef — compile only if macro is NOT defined */
#ifndef DEBUG
printf("Release Mode");
#endif
/* #else and #elif — like else / else-if, but for the preprocessor */
#if VERSION == 1
printf("V1");
#elif VERSION == 2
printf("V2"); /* this branch compiles */
#else
printf("Unknown");
#endif
/* #undef — remove a macro definition */
#define SIZE 100
#undef SIZE
/* SIZE is now undefined again */| Directive | Checks |
|---|---|
#ifdef MACRO | Is MACRO currently defined? |
#ifndef MACRO | Is MACRO currently not defined? |
#if expression | Does a constant expression evaluate to non-zero? |
Include Guards
One of the most important patterns in all of C. Imagine main.c includes student.h, which itself includes config.h —and some other file also includes config.h directly. Without protection, the compiler processes config.h’s declarations multiple times, causing redefinition errors.
#ifndef STUDENT_H /* has STUDENT_H been defined yet? */
#define STUDENT_H /* if not, define it now */
struct Student
{
int roll;
};
#endif /* end of the guarded block */How It Plays Out Across Multiple Includes
#pragma once as a one-line shortcut for the same effect. It’s widely supported but not part of the ISO C standard —traditional include guards (#ifndef/#define/#endif) remain fully portable across every standard-conforming compiler.Project Organization & Compiler Workflow
Large C programs split across multiple files —each module gets its own .c (implementation) and .h (declarations) pair.
Project/
├── main.c
├── uart.c
├── uart.h
├── gpio.c
├── gpio.h
├── adc.c
├── adc.h
└── MakefileEasier Maintenance
Each module is small and focused; bugs are easier to isolate and fix.
Code Reuse
A well-organized uart.c/uart.h pair can be dropped into a new project as-is.
Faster Compilation
Only the files that changed need to be recompiled, not the entire project.
Team Development
Different engineers can work on gpio.c and adc.c simultaneously without conflicts.
Complete Compiler Workflow
| Stage | Purpose |
|---|---|
| Preprocessor | Expands #include, #define, and conditional directives |
| Compiler | Translates expanded C source into assembly code |
| Assembler | Converts assembly into machine-readable object code |
| Linker | Combines all object files and libraries into one executable |
| Loader | Loads the finished executable into memory to run it |
main.c, gpio.c, uart.c, timer.c, and adc.c — each peripheral driver compiled separately, then linked together into the final firmware image. Only the modules the application actually uses get linked in, keeping the binary lean.Common Mistakes
Best Practices & Memory Tricks
Uppercase macro names
#define BUFFER_SIZE 256 —visually distinguishes macros from regular variables and function names at a glance.
Parenthesize macro parameters
Wrap every parameter and the entire replacement expression in parentheses to protect against operator precedence surprises.
Keep macros simple
If a macro is getting complex or has side-effect risks, it’s usually a sign you want a real (possibly inline) function instead.
Guard every header file
No exceptions —every .h file needs an #ifndef/#define/#endif guard or #pragma once.
Declarations in .h, code in .c
Headers expose the public interface; source files contain the actual implementation that gets compiled once.
Use conditional compilation deliberately
Reach for #ifdef for platform-specific code and debug builds —not as a substitute for ordinary runtime logic.
“The extra n means NOT”
| Concept | Memory hook |
|---|---|
#include | Insert file — literally pastes the header’s text in |
#define | Text replacement — before compilation, not at runtime |
| Macro | Expanded before the compiler ever sees it |
| Compiler | Only sees code after the preprocessor has run |
| Header (.h) | Declarations — the “menu” |
| Source (.c) | Implementation — the “kitchen” |
Interview Questions
# (preprocessor directives) before actual compilation begins. It expands #include files, substitutes #define macros, evaluates conditional compilation blocks, and strips comments —producing an "expanded" source file that the compiler then translates into assembly.#define SQUARE(x) x*x expands SQUARE(2+3) into 2+3*2+3 (= 11, due to multiplication's precedence), not the intended 25. Wrapping every parameter and the whole expression in parentheses —((x)*(x)) —forces the correct grouping regardless of what expression the caller passes in.#ifndef NAME / #define NAME / ... / #endif) checks whether a unique macro has already been defined; on the first inclusion it defines the macro and compiles the contents, and on every subsequent inclusion the #ifndef check fails and the entire body is skipped.#if/#ifdef/#ifndef) is resolved entirely by the preprocessor before compilation —code in a "false" branch is removed from the source entirely and never reaches the compiler, contributing zero size or overhead to the final binary. A runtime if statement is always compiled into the executable; the condition is evaluated while the program is actually running, and the unused branch still exists in the binary even if it never executes.Frequently Asked Questions
.c files, each translation unit would get its own copy of the function body, typically causing "multiple definition" linker errors. Keeping only declarations (prototypes) in headers lets many files share the interface, while the single implementation in one .c file gets compiled exactly once and linked in wherever needed.#ifndef/#define/#endif guards remain the safer choice, though #pragma once is extremely common and reliable in practice.PI is meant to be a float), no scoping (a macro defined anywhere applies everywhere after that point, regardless of braces), and can silently misbehave with side-effecting arguments. const variables and enum constants give the compiler real type checking and proper scope; inline functions (where supported) get the performance benefits of a macro with the safety guarantees of a real function call. Macros remain essential for things only the preprocessor can do, like conditional compilation.Practice Programs & Chapter Summary
- Create a file and write your name to it using
fprintf(), then read it back and print it. - Read a text file character by character with
fgetc()and count how many characters it contains. - Copy one line from a file to the screen using
fgets(). - Define a macro for PI and a macro for the maximum array size; use both in a small program.
- Create a header file with proper include guards, containing one struct declaration.
- Store five student records (roll, name, marks) in a text file using
fprintf(), then read and display them in a formatted table. - Count the number of words in a text file by reading it line by line.
- Store an array of structures in a binary file with
fwrite(), then read them back withfread()and verify the values match. - Use
fseek()andftell()to determine and print the size of a file in bytes. - Split a simple calculator program into
calc.handcalc.cfiles, called frommain.c.
- Build a student database program: add records, save them to a binary file, read all records back, search by roll number, and update an existing record.
- Write a program using function-like macros for MAX, MIN, CUBE, and ABS of a number; compare the macro versions against equivalent functions and explain any differences in behavior with side-effecting arguments.
- Organize a small embedded project with
main.c,uart.c/uart.h,gpio.c/gpio.h, andtimer.c/timer.h, with proper include guards in every header. - Write a program that uses conditional compilation (
#ifdef DEBUG) to print extra diagnostic messages only when aDEBUGmacro is defined, and verify the binary differs in size with and without it defined.
- Files provide permanent storage that survives program exit;
FILE*is a handle representing an open file. fopen()returnsNULLon failure —always check before using the handle. Always pair everyfopen()with exactly onefclose().- Six file modes (
r,w,a,r+,w+,a+) control whether a file is read, written, created, or truncated. fprintf()/fscanf()handle text I/O;fgetc()/fputc()work character-by-character;fgets()/fputs()work line-by-line.fwrite()/fread()handle raw binary data —never mix text and binary I/O functions on the same data.ftell(),rewind(), andfseek()inspect and control a file's current position;SEEK_SET/SEEK_CUR/SEEK_ENDanchor the offset.- Loop on the return value of the read function itself —never on
while(!feof(fp)), which can cause an extra invalid iteration. - The preprocessor runs before compilation, expanding
#includefiles and#definemacros, and resolving conditional compilation blocks. - Macros are pure text substitution —always parenthesize every parameter and the entire replacement expression to avoid precedence bugs.
- Macros differ from functions: no call overhead or type checking, but arguments can be evaluated multiple times —avoid passing expressions with side effects.
#ifdef/#ifndef/#if/#elif/#else/#endifremove code entirely before compilation, unlike a runtimeifwhich always compiles both branches.- Every header file needs an include guard (
#ifndef/#define/#endif, or#pragma once) to prevent multiple-definition errors. - Large projects split into
.h(declarations) and.c(implementation) file pairs, compiled separately and combined by the linker. - The full build pipeline: Preprocessor → Compiler → Assembler → Linker → Executable.