C / Intermediate: Memory and Reliable Components / Lesson 10
Files, Streams, and Error Handling
Handle a file as a fallible resource and preserve the difference between end of input, invalid data, and I/O failure.
On this page
Lesson goals
- Check acquisition, transfer, and close operations.
- Distinguish EOF from a stored byte and from an error.
- Define text, binary, and persistence boundaries.
Opening establishes an operating contract
fopen returns a FILE pointer on success and null on failure. Its mode is consequential: "r" reads an existing file, "w" creates or truncates, and "a" appends according to the library contract. Add "b" for binary mode where text translation must not occur. Never demonstrate destructive modes against an unknown user path.
Separate path selection from parsing and business logic. A temporary stream from tmpfile is useful for isolated examples because it avoids overwriting an existing file and is removed when closed. A real application needs a documented policy for creation, replacement, permissions, and failure; ISO C alone does not specify all operating-system security behavior.
Read characters into int
fgetc returns either an unsigned-char value converted to int or EOF. Store the result in int so the sentinel remains distinguishable from valid character data. Test the return value before narrowing to a byte. feof reports an already encountered end condition; it does not predict whether the next read will succeed.
The loop below reads a known three-character stream and increments the count only after successful reads. After the loop it checks ferror to distinguish ordinary end of input from an I/O error. The example uses rewind after output to establish the required direction change on an update stream before reading.
#include <stdio.h>
int main(void)
{
FILE *file = tmpfile();
if (file == NULL) return 1;
if (fputs("abc", file) == EOF) { fclose(file); return 1; }
rewind(file);
size_t count = 0;
int ch;
while ((ch = fgetc(file)) != EOF) ++count;
if (ferror(file)) { fclose(file); return 1; }
printf("characters=%zu\n", count);
return fclose(file) == 0 ? 0 : 1;
}
characters=3
The loop counts a, b, and c; EOF itself is never counted as a character.
Transfer counts are part of the result
fread and fwrite return the number of complete elements transferred, not a simple Boolean. Using an element size of one makes the result a byte count and simplifies partial-transfer reasoning. A short read may indicate end of file or error, so inspect stream indicators and interpret it in the format’s context.
The next example uses a fixed synthetic byte array and verifies both transfer counts. It seeks back before reading and compares only initialized bytes. This is a byte transport demonstration, not a portable serialization of arbitrary structs. A real format also needs a version, field definitions, lengths, and rejection rules for incomplete records.
#include <stdio.h>
#include <string.h>
int main(void)
{
const unsigned char source[] = {1, 2, 3, 4};
unsigned char copy[4];
FILE *file = tmpfile();
if (file == NULL) return 1;
if (fwrite(source, 1, sizeof source, file) != sizeof source) { fclose(file); return 1; }
if (fseek(file, 0, SEEK_SET) != 0) { fclose(file); return 1; }
if (fread(copy, 1, sizeof copy, file) != sizeof copy) { fclose(file); return 1; }
puts(memcmp(source, copy, sizeof copy) == 0 ? "same bytes" : "different bytes");
return fclose(file) == 0 ? 0 : 1;
}
same bytes
All four initialized bytes survive the temporary-stream round trip.
Preserve the useful failure information
When an operation fails, capture the relevant context before cleanup can change diagnostic state. perror can explain errno when the failing operation’s contract uses errno; do not assume every standard C function sets it for every failure. Report the operation and synthetic record location alongside the low-level message when useful.
A failed write can become visible only during a later flush or close because streams may buffer output. Check fclose when successful delivery matters. fflush(NULL) flushes output streams, but fflush(stdin) is not a portable C17 input-discard operation. Consume unwanted input using a bounded, explicit record policy instead.
A closed stream is not a durability proof
Standard C buffering and operating-system persistence are different layers. Successful fclose does not by itself specify crash-safe storage on physical media. Durable replacement protocols depend on the operating system and filesystem, and may require platform-specific synchronization and directory handling. Document such assumptions instead of presenting them as ISO C guarantees.
Keep format validation independent from transport. Tests can then use temporary streams, short records, and deliberately malformed synthetic bytes without needing production files. Verify cleanup after parse failures as carefully as after open failures, because the stream remains a live resource even when its data is rejected.
Common errors and fixes
The last character is processed twice.
- Why it happens
- A feof-controlled loop used stale data after a failed read.
- How to fix it
- Make the read return value the loop condition.
- Prevent it next time
- Test empty and exactly-one-character streams.
Byte 255 is confused with EOF.
- Why it happens
- A fgetc result was narrowed into char before testing.
- How to fix it
- Store the return in int and compare with EOF first.
- Prevent it next time
- Review sentinel-returning APIs for a distinct result type.
A save is reported successful despite a close error.
- Why it happens
- Only earlier buffered writes were checked.
- How to fix it
- Check transfer, flush when used, and fclose results.
- Prevent it next time
- Define success at the final required delivery boundary.
Check your understanding
1. A four-byte record read returns two bytes. Explain the safe interpretation and what must not happen next.
Show worked solution
Treat the record as incomplete. Check ferror and feof to distinguish transport failure from ordinary truncation, and report the relevant context. Do not interpret the two unread destination bytes or silently accept a shortened record when the format requires four. Release the stream on the failure path and preserve the application’s previous valid state.
2. Why is writing a raw struct not fixed merely by opening the stream in binary mode?
Show worked solution
Binary mode controls text translations; it does not standardize padding, integer sizes, floating representation, or byte order. A portable format must specify fields and encodings independently, then serialize them explicitly. Binary mode can be necessary for a byte-defined format, but it is not a substitute for defining that format or validating its lengths.
Key takeaways
- A read result controls processing; EOF indicators describe past events.
- Every live stream needs cleanup, including data-rejection paths.
- Specify whether success means library delivery or platform-specific durable storage.