9. Standard Library
The C standard library is the small, portable toolbox that every hosted C implementation must provide: formatted I/O, string and memory manipulation, conversions, math, time, and the machinery for reporting failure. It is deliberately minimal — there are no containers, no networking, no threads before C11 — but what it does contain is on every platform you will ever target, which makes knowing it exactly the difference between portable code and code that only compiles on your machine. This chapter covers the stream I/O model in depth, the utility headers you reach for daily, and the several competing conventions C uses to signal that something went wrong.
<a id="91-formatted-and-stream-io"></a>
9.1 Formatted and Stream I/O
Everything in <stdio.h> is built on one abstraction: the stream, a buffered sequence of bytes attached to a file, a terminal, or a pipe. This section covers producing and consuming formatted text, opening and closing files, reading and writing blocks and lines, moving the file position, and controlling the buffer that sits invisibly between your program and the operating system.
printf and Format Specifiers
Theory
C has no way to write a function that prints "whatever you give it". There is no overloading, no runtime type information, and no way for a function to ask "what type was that argument?". So the standard library solves the problem the only way C allows: you pass a format string that describes the arguments, followed by the arguments themselves as variadic parameters.
This makes printf a tiny interpreter. It walks the format string one character at a time, copying ordinary characters straight to the output and, whenever it meets a %, reading a conversion specification that tells it how to pull the next argument off the variadic list and render it.
The consequence is the single most important fact about printf: the format string is a promise the compiler cannot verify by default, and printf cannot check at all. If you write %d and pass a double, printf will read the bytes of a double as though they were an int. There is no error, no exception — just garbage, or a crash. This is undefined behavior, not a mistake that gets caught.
A conversion specification has this shape, with everything but the % and the conversion character optional:
%[flags][width][.precision][length modifier]conversion
%-10.3f
| | | |
| | | +-- conversion: f = decimal floating point
| | +---- precision: 3 digits after the decimal point
| +------- width: pad to at least 10 characters
+--------- flag: '-' means left-justify within the width
The conversion character is what selects the argument type:
| Conversion | Argument type | Prints |
|---|---|---|
%d, %i | int | signed decimal |
%u | unsigned int | unsigned decimal |
%x, %X | unsigned int | hexadecimal (lower/upper) |
%o | unsigned int | octal |
%f, %F | double | decimal notation, 6 digits default |
%e, %E | double | scientific notation |
%g, %G | double | shortest of %e/%f |
%a, %A | double | hexadecimal float (exact, C99) |
%c | int (as char) | one character |
%s | char * | NUL-terminated string |
%p | void * | implementation-defined pointer form |
%% | — | a literal % |
%n | int * | stores characters written so far |
The length modifier adjusts the size of the integer or float argument. Getting this wrong is the most common printf bug in real code, because %d with a long happens to work on platforms where they are the same size and silently breaks elsewhere.
| Modifier | With d/i | With u/x/o |
|---|---|---|
hh | signed char | unsigned char |
h | short | unsigned short |
| (none) | int | unsigned int |
l | long | unsigned long |
ll | long long | unsigned long long |
j | intmax_t | uintmax_t |
z | — | size_t |
t | ptrdiff_t | — |
L | — | long double (with f/e/g) |
Two of these deserve emphasis. %zu is the correct way to print a size_t — which means every sizeof, every strlen, and every array index of that type. And fixed-width types from <stdint.h> have no fixed modifier, because int32_t might be int on one platform and long on another; <inttypes.h> provides macros (PRId32, PRIu64, ...) that expand to the right specifier, concatenated into the format string as adjacent string literals.
The %n conversion writes through a pointer you supply. It exists for measuring output, but because it turns a format string into something that can write memory, it is the payload of classic format-string attacks and is disabled or restricted on many modern platforms. Treat it as a red flag.
The return value is the number of characters written, or a negative value on error. Almost nobody checks it, and for stdout that is usually defensible — but for a file stream, ignoring it means silently losing data on a full disk.
Finally, the family: printf writes to stdout, fprintf to a stream you name, sprintf into a buffer, and snprintf into a buffer with a size limit. Use snprintf, never sprintf — sprintf has no idea how big your buffer is and is a buffer overflow waiting for the right input. snprintf returns the length the output would have had, which is how you detect truncation: if the return value is >= size, the output was cut short.
Examples
#include <stdio.h>
#include <stdint.h>
#include <inttypes.h> /* PRId64, PRIu32, ... */
#include <string.h>
int main(void)
{
int count = 42;
double ratio = 3.14159;
const char *name = "buffer";
size_t len = strlen(name);
int64_t big = 9000000000LL;
unsigned flags = 0xDEADBEEFu;
/* --- basic conversions ------------------------------------------ */
printf("count = %d\n", count); /* 42 */
printf("ratio = %f\n", ratio); /* 3.141590 (6 digits) */
printf("ratio = %.2f\n", ratio); /* 3.14 */
printf("name = %s\n", name); /* buffer */
printf("flags = 0x%08X\n", flags); /* 0xDEADBEEF */
printf("100%%\n"); /* a literal percent */
/* --- the ones people get wrong ---------------------------------- */
printf("len = %zu\n", len); /* %zu for size_t */
printf("big = %" PRId64 "\n", big); /* literal concatenation */
printf("ptr = %p\n", (void *)name); /* MUST cast to void * */
/* --- width, precision, flags ------------------------------------ */
printf("[%10s]\n", "hi"); /* [ hi] */
printf("[%-10s]\n", "hi"); /* [hi ] */
printf("[%10.3f]\n", ratio); /* [ 3.142] */
printf("[%+d] [%+d]\n", 5, -5); /* [+5] [-5] */
printf("[%05d]\n", 42); /* [00042] */
/* Width and precision taken from arguments with '*'. */
int w = 12, p = 4;
printf("[%*.*f]\n", w, p, ratio); /* [ 3.1416] */
return 0;
}
Safe buffer formatting, and how to detect truncation:
#include <stdio.h>
/* Returns 0 on success, -1 if the result did not fit. */
static int make_path(char *out, size_t cap, const char *dir, const char *file)
{
/* snprintf returns the length it WANTED to write, excluding the NUL. */
int n = snprintf(out, cap, "%s/%s", dir, file);
if (n < 0) return -1; /* encoding error */
if ((size_t)n >= cap) return -1; /* truncated: n needed >= cap */
return 0;
}
int main(void)
{
char small[8], big[64];
printf("%d\n", make_path(big, sizeof big, "/etc", "hosts")); /* 0 */
printf("%d\n", make_path(small, sizeof small, "/very/long", "path")); /* -1 */
/* A common two-pass idiom: ask for the size, then allocate it. */
int need = snprintf(NULL, 0, "id=%d;name=%s", 7, "alice");
printf("needs %d bytes plus NUL\n", need);
return 0;
}
Let the compiler check your format strings — this is free and catches nearly every mismatch:
# GCC and Clang understand printf semantics when warnings are enabled.
$ gcc -Wall -Wextra -Wformat=2 prog.c
prog.c:9:20: warning: format '%d' expects argument of type 'int',
but argument 2 has type 'double' [-Wformat=]
Key Takeaways
printfis an interpreter over the format string; it cannot know the real types of its variadic arguments, so a mismatched specifier is undefined behavior, not an error.- Memorize the length modifiers:
%zuforsize_t,%lldforlong long,PRId64-style macros from<inttypes.h>for fixed-width types. %prequires an argument of typevoid *— cast any other pointer explicitly.- Prefer
snprintfoversprintfalways; compare its return value against the buffer size to detect truncation, and call it withNULL, 0to size a buffer first. - Compile with
-Wall -Wformat=2so the compiler verifies format strings for you; never pass a runtime string as the format argument.
🧪 Practice
- Print the same
doublewith%f,%.10f,%e,%g, and%a, and explain in one line what each is best for.- Write a function that formats a
struct { int id; double score; }into a caller-supplied buffer, returns the number of bytes needed, and never overflows regardless of buffer size.- Deliberately pass a
long longto%dand achar *to%d, compile without warnings, and run under-fsanitize=undefined. Describe both outcomes.- Interview-style: "Why is
printf(user_input)a security vulnerability whileprintf("%s", user_input)is not?" Hint: think about what%nand%sdo when there is no matching argument on the stack.
scanf and Input Pitfalls
Theory
scanf is printf run backwards: a format string describes what you expect to read, and matching input is converted and stored through pointers you supply. It looks like the natural counterpart, and for reading a fixed, well-formed data file it is genuinely convenient. For reading anything a human or a network typed, it is a trap-lined path, and experienced C programmers reach for it rarely.
The reasons are worth understanding in detail, because each one is a distinct failure mode.
It does not consume the whole line. scanf("%d", &n) reads the digits and stops. The newline you pressed stays in the buffer. The next scanf("%c", &c) then reads that newline instead of the character you meant to type. This single behavior accounts for most "my program skipped the prompt" bugs.
Whitespace handling is inconsistent between conversions. Most conversions (%d, %f, %s) skip leading whitespace automatically. %c and %[...] do not. So a mixed format string behaves differently depending on which conversion comes next, and the rule is invisible in the code.
On a mismatch it stops and leaves the offending text in the stream. If you ask for %d and the user types hello, scanf converts nothing, returns a count lower than you expected, and — crucially — does not remove hello from the input. A naive retry loop then hits the same text forever. Recovering requires explicitly draining input to the next newline.
%s is gets with extra steps. scanf("%s", buf) reads an unbounded run of non-whitespace characters into buf. There is no size argument. This is a buffer overflow with a friendly face. The fix is a field width: scanf("%63s", buf) for a char buf[64] — note the width is the number of characters excluding the NUL, so it is always one less than the array size, and it must be a literal baked into the format string.
The return value is the only truth. scanf returns the number of items successfully assigned, 0 if the first conversion failed, or EOF on end of input or a read error. Not checking it means using variables that were never written — reading uninitialized memory.
The standard advice, and what production code actually does, is to separate reading from parsing: use fgets to pull in one whole line (bounded, predictable), then use sscanf, strtol, or a hand-written parser on that in-memory string. Now a malformed line affects one iteration instead of corrupting the stream, and the recovery logic is trivial — you already have the whole line and can discard it.
Examples
#include <stdio.h>
int main(void)
{
int n;
char c;
printf("Enter a number: ");
scanf("%d", &n); /* reads 42, leaves '\n' in the input buffer */
printf("Enter a letter: ");
scanf("%c", &c); /* BUG: reads the leftover '\n' immediately */
printf("n=%d c=[%c]\n", n, c); /* c is a newline, not your letter */
return 0;
}
Two fixes, and why the second is better:
#include <stdio.h>
/* Fix 1: a leading space in the format tells scanf to skip whitespace. */
static void patched_scanf(void)
{
int n; char c;
if (scanf("%d", &n) != 1) return; /* ALWAYS check the count */
if (scanf(" %c", &c) != 1) return; /* the space skips '\n' */
printf("n=%d c=[%c]\n", n, c);
}
/* Fix 2: read a whole line, then parse it. This is the robust pattern. */
static void line_based(void)
{
char line[256];
int n;
char c;
if (fgets(line, sizeof line, stdin) == NULL) return;
if (sscanf(line, "%d", &n) != 1) { /* parse a COPY, not the stream */
fprintf(stderr, "not a number: %s", line);
return; /* the bad line is already gone */
}
if (fgets(line, sizeof line, stdin) == NULL) return;
if (sscanf(line, " %c", &c) != 1) return;
printf("n=%d c=[%c]\n", n, c);
}
Bounded string input and stream recovery:
#include <stdio.h>
/* Discard everything up to and including the next newline. */
static void drain_line(FILE *f)
{
int ch;
while ((ch = fgetc(f)) != '\n' && ch != EOF)
; /* body intentionally empty */
}
int main(void)
{
char name[64];
int age;
/* Width 63 = sizeof(name) - 1. The NUL is added on top of the width. */
if (scanf("%63s", name) != 1) return 1;
/* Retry loop that actually terminates, because it drains on failure. */
while (printf("age? "), scanf("%d", &age) != 1) {
if (feof(stdin)) return 1; /* distinguish EOF from bad input */
drain_line(stdin); /* without this: infinite loop */
fprintf(stderr, "please enter digits\n");
}
printf("%s is %d\n", name, age);
return 0;
}
WHY THE NAIVE RETRY LOOP SPINS FOREVER
input buffer: h e l l o \n
^
scanf("%d") --> sees 'h', cannot convert, returns 0
position UNCHANGED -- 'h' is still there
loop retries --> sees 'h', returns 0 ... forever
drain_line() moves the position past '\n', so the next
iteration sees fresh input and the loop can make progress.
| Approach | Bounded? | Recovers from bad input? | Handles a whole line? |
|---|---|---|---|
scanf("%s", buf) | no — overflow | no | no |
scanf("%63s", buf) | yes | only with manual draining | no |
fgets + sscanf | yes | yes, trivially | yes |
fgets + strtol | yes | yes, with error position | yes |
Key Takeaways
scanfleaves the newline in the stream, so a following%corfgetsreads it instead of the user's next entry; a leading space in the format skips whitespace for%c.- Always check the return value — it is the count of successful assignments, and anything less means some variables were never written.
- On a conversion failure
scanfdoes not advance past the bad input, so any retry loop must explicitly drain to the next newline or it will spin forever. %swithout a field width is a buffer overflow; the width must besizeof(buf) - 1and cannot be passed as a variable.- Prefer
fgetsinto a buffer followed bysscanforstrtol: it bounds the read, keeps parsing failures local to one line, and makes recovery automatic.
🧪 Practice
- Reproduce the leftover-newline bug with
scanf("%d")followed byscanf("%c"), then fix it two ways and explain the difference.- Write
read_int(const char *prompt, int *out)that usesfgetsplusstrtol, rejects trailing garbage such as12abc, and returns0/-1.- Feed a program using
scanf("%s", buf)withbuf[8]a 200-character token under AddressSanitizer and report the diagnostic.- Interview-style: "When would you still choose
scanfoverfgetsplus parsing?" Hint: consider trusted, machine-generated, strictly-formatted input where a failure should abort anyway.
fopen, fclose, and Modes
Theory
Before you can read or write a file you need a stream: an object of type FILE that holds the operating system's file handle, a buffer, the current position, and the error and end-of-file flags. fopen creates one; fclose flushes and destroys it. You never manipulate a FILE directly — you only ever hold a FILE * and hand it back to library functions. It is an opaque handle, deliberately.
fopen takes a filename and a mode string, and the mode string is doing more work than its two or three characters suggest. It encodes three independent decisions:
- What operations are allowed — read, write, or both.
- What happens to existing content — preserve it, destroy it, or append to it.
- What happens if the file does not exist — fail, or create it.
The six base modes:
| Mode | Read | Write | If it exists | If it does not exist | Position |
|---|---|---|---|---|---|
"r" | yes | no | opened as-is | fails (NULL) | start |
"w" | no | yes | truncated to zero | created | start |
"a" | no | yes | preserved | created | end; writes always append |
"r+" | yes | yes | opened as-is | fails | start |
"w+" | yes | yes | truncated to zero | created | start |
"a+" | yes | yes | preserved | created | reads anywhere, writes at end |
The two footguns are highlighted: "w" destroys the file's contents the instant fopen succeeds, before you write a single byte, and "r" is the only common mode that will not create a file for you. A surprising amount of data loss traces to a typo of "w" where "r" was meant.
Appending is stronger than "position at end". With "a", every write goes to the end at the moment of the write, regardless of any fseek you performed. That makes appends safe when several processes write to the same log.
A b may be appended ("rb", "w+b") to request binary mode. On POSIX systems it does nothing — text and binary streams are identical. On Windows, text mode translates \n to \r\n on output and back on input, and may treat a Control-Z byte as end of file. So: always use b for non-text data, and understand that a program reading a binary file in text mode is portable only by accident.
C11 adds exclusive modes "wx", "w+x": create the file, but fail if it already exists. This is the standard way to avoid clobbering, and it closes the race window that a "check then create" sequence leaves open.
fopen returns NULL on failure and sets errno. Check it every single time. The failure is not hypothetical: missing file, wrong permissions, a directory where a file was expected, out of file descriptors. Using the NULL return dereferences a null pointer inside the library.
fclose matters more than it looks. Because streams are buffered, data you "wrote" may still be sitting in memory. fclose flushes it, and flushing can fail — a full disk, a broken pipe, a network filesystem error. So fclose returns 0 or EOF, and for any stream you wrote to, checking that return value is the last chance to notice that your output never made it. After fclose the FILE * is dangling; using it, or closing it twice, is undefined behavior.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(void)
{
/* ---- opening for reading: NULL means "handle it", not "ignore it" ---- */
FILE *in = fopen("config.txt", "r");
if (in == NULL) {
/* strerror(errno) turns the error code into a human-readable string */
fprintf(stderr, "cannot open config.txt: %s\n", strerror(errno));
return EXIT_FAILURE;
}
char line[256];
while (fgets(line, sizeof line, in) != NULL)
fputs(line, stdout);
fclose(in); /* read-only stream: failure is uninteresting */
/* ---- writing: check fclose, because that is where flushing happens --- */
FILE *out = fopen("report.txt", "w"); /* TRUNCATES report.txt now */
if (out == NULL) {
perror("report.txt");
return EXIT_FAILURE;
}
fprintf(out, "generated report\n");
if (fclose(out) != 0) { /* buffered bytes are written HERE */
perror("closing report.txt");
return EXIT_FAILURE; /* the data may not be on disk */
}
return EXIT_SUCCESS;
}
Mode selection in practice, including the exclusive-create idiom:
#include <stdio.h>
int main(void)
{
/* Append: safe for logs, even with several writers. */
FILE *log = fopen("app.log", "a");
if (log) { fprintf(log, "started\n"); fclose(log); }
/* Binary: mandatory for any non-text payload, portable everywhere. */
FILE *img = fopen("logo.png", "rb");
if (img) { /* ... fread ... */ fclose(img); }
/* C11 exclusive create: refuses to touch an existing file. No race. */
FILE *lock = fopen("build.lock", "wx");
if (lock == NULL) {
fprintf(stderr, "another build is already running\n");
} else {
fprintf(lock, "pid placeholder\n");
fclose(lock);
remove("build.lock"); /* <stdio.h> also gives remove/rename */
}
return 0;
}
WHAT A MODE STRING DECIDES
"r" "w" "a" "r+" "w+" "a+"
read yes no no yes yes yes
write no yes yes yes yes yes(end)
truncate no YES no no YES no
create no yes yes no yes yes
Add 'b' for binary: "rb", "wb", "a+b" (no-op on POSIX, required on Windows)
Add 'x' to "w": "wx" (C11: fail if the file exists)
Key Takeaways
FILE *is an opaque handle bundling a descriptor, a buffer, a position, and error flags; you only ever pass it to library functions."w"truncates the file at open time, before any write;"r"is the mode that refuses to create a missing file."a"writes at the end of the file at the moment of each write, regardless of seeking — which makes concurrent appends safe.- Add
bfor binary data: it is harmless on POSIX and essential on Windows, where text mode rewrites newlines. - Always check
fopenforNULL, and checkfcloseon written streams — the flush happens there, and it can fail.
🧪 Practice
- Open a non-existent file with
"r","w", and"a"in turn and printstrerror(errno)for each result. Explain the three outcomes.- Write a
copy_file(const char *src, const char *dst)that opens both in binary mode, checks every call, and closes both on every exit path including errors.- Use
"wx"to implement a simple lock file, then run two copies of the program at once and confirm the second one refuses to start.- Interview-style: "Why can
fclosefail, and what should a program do about it?" Hint: think about where the bytes actually are betweenfprintfand the disk.
fread, fwrite, fgets, fputs
Theory
Once a stream is open, four functions cover almost all reading and writing. They split cleanly into two pairs, one for binary blocks and one for text lines, and choosing the wrong pair is a common source of subtly broken code.
fread and fwrite move raw bytes. Their signature is unusual — a size and a count, separately:
size_t fread (void *ptr, size_t size, size_t nmemb, FILE *stream);
size_t fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream);
They transfer size * nmemb bytes and return the number of complete items transferred, not the number of bytes. That return value is the whole error-handling story: a short return means end of file or an error, and you distinguish them with feof and ferror. The conventional call passes size = sizeof(element) and nmemb = count, so the return is directly comparable to count.
Because they copy bytes verbatim, fread/fwrite are the right tools for binary formats — but writing a struct straight to disk bakes in your compiler's padding, your CPU's endianness, and your platform's type sizes. The file is then readable only by the same build. For anything that crosses machines, serialize field by field with an explicit layout.
fgets and fputs move text lines.
char *fgets(char *s, int n, FILE *stream); /* reads at most n-1 chars */
int fputs(const char *s, FILE *stream); /* writes a NUL-terminated string */
fgets reads until a newline, end of file, or n-1 characters — whichever comes first — and always NUL-terminates. Three details matter constantly:
- It keeps the newline if one was read. Almost every program using
fgetsmust strip it. - If the line was longer than the buffer, there is no newline in the result and the rest of the line is still waiting in the stream. Detecting this is exactly "did the result contain a
\n?". - It returns
NULLat end of file or on error, which is the natural loop condition.
fputs writes the string without adding a newline (unlike puts, which appends one). Its size argument does not exist because the string's own NUL terminates it.
gets — which read a line with no size limit at all — was the canonical C buffer overflow and was removed from the language in C11. If you see it, the code predates 2011 or was never compiled with warnings on.
For single characters there are fgetc/fputc and their getc/putc macro forms. Note that fgetc returns an int, not a char, precisely so that EOF (a negative value) is distinguishable from every valid byte, including 0xFF. Storing it in a char first is a real bug that can either lose EOF detection or create a false one.
Examples
#include <stdio.h>
#include <string.h>
/* Read a text file line by line, stripping the newline and detecting
lines too long for the buffer. */
static void read_lines(const char *path)
{
FILE *f = fopen(path, "r");
if (!f) { perror(path); return; }
char line[128];
unsigned long lineno = 0;
while (fgets(line, sizeof line, f) != NULL) {
size_t len = strlen(line);
if (len > 0 && line[len - 1] == '\n') {
line[--len] = '\0'; /* strip the newline fgets kept */
lineno++;
} else if (!feof(f)) {
/* No newline and not at EOF: the line did not fit. The rest of
it is still in the stream and the next fgets will return it. */
fprintf(stderr, "line %lu truncated\n", lineno + 1);
}
printf("[%s]\n", line);
}
if (ferror(f)) perror("read error"); /* NULL can mean EOF *or* error */
fclose(f);
}
Binary block I/O with correct short-count handling:
#include <stdio.h>
#include <stdlib.h>
typedef struct { int id; float score; } Record;
static int save_records(const char *path, const Record *recs, size_t n)
{
FILE *f = fopen(path, "wb"); /* 'b': raw bytes, no translation */
if (!f) return -1;
/* Returns the count of ITEMS written, not bytes. */
size_t written = fwrite(recs, sizeof *recs, n, f);
if (written != n) { fclose(f); return -1; }
return fclose(f) == 0 ? 0 : -1; /* the flush can still fail */
}
static Record *load_records(const char *path, size_t *out_n)
{
FILE *f = fopen(path, "rb");
if (!f) return NULL;
/* Size the file, then allocate exactly once. */
fseek(f, 0, SEEK_END);
long bytes = ftell(f);
rewind(f);
if (bytes < 0 || (size_t)bytes % sizeof(Record) != 0) { fclose(f); return NULL; }
size_t n = (size_t)bytes / sizeof(Record);
Record *recs = malloc(n * sizeof *recs);
if (!recs) { fclose(f); return NULL; }
if (fread(recs, sizeof *recs, n, f) != n) { /* short read = failure */
free(recs); fclose(f); return NULL;
}
fclose(f);
*out_n = n;
return recs;
}
The fgetc return-type trap:
#include <stdio.h>
int main(void)
{
/* WRONG: on platforms where char is signed, the byte 0xFF becomes -1,
which compares equal to EOF and ends the loop early. On platforms
where char is unsigned, EOF (-1) becomes 255 and the loop never ends. */
/* char c; while ((c = fgetc(stdin)) != EOF) ... */
int c; /* RIGHT: int holds every byte plus EOF */
while ((c = fgetc(stdin)) != EOF)
fputc(c, stdout);
return 0;
}
| Function | Unit | Adds/keeps newline | Bounded | Returns |
|---|---|---|---|---|
fgets | line | keeps the \n it read | yes (n-1 chars) | buffer or NULL |
fputs | string | adds nothing | n/a | non-negative or EOF |
puts | string | appends \n | n/a | non-negative or EOF |
fread | items | n/a | yes (size*nmemb) | items read |
fwrite | items | n/a | yes | items written |
fgetc | byte | n/a | n/a | int: byte or EOF |
Key Takeaways
fread/fwritereturn the number of complete items, not bytes; a short count means EOF or error, resolved withfeofandferror.- Writing a raw
structwithfwriteembeds padding, endianness, and type sizes — fine for a private cache, wrong for a portable file format. fgetskeeps the trailing newline, always NUL-terminates, and signals an over-long line by returning a buffer with no\nwhile not at EOF.fputsdoes not append a newline;putsdoes.getswas removed in C11 and must never appear.fgetcreturnsintso thatEOFcannot collide with a valid byte value — storing its result in acharis a genuine bug.
🧪 Practice
- Write a program that counts lines in a file with
fgetsand correctly reports how many lines exceeded a 32-byte buffer.- Save an array of 1000
struct { int; double; }withfwrite, reload it withfread, and compare. Then printsizeofthe struct and explain why the file is not portable.- Implement
cattwice: once withfgetc/fputcand once with a 4096-bytefread/fwriteloop. Time both on a 100 MB file and explain the gap.- Interview-style: "Why does
fgetcreturnintrather thanchar?" Hint: count how many distinct values a function must be able to return.
fseek, ftell, and Positioning
Theory
Every open stream carries a file position indicator: the offset at which the next read or write will happen. Sequential I/O advances it implicitly. Random access means moving it deliberately, and that is what fseek, ftell, and rewind are for.
int fseek(FILE *stream, long offset, int whence);
long ftell(FILE *stream);
void rewind(FILE *stream);
whence selects the reference point, and the three constants are the whole vocabulary:
whence | Meaning | Typical use |
|---|---|---|
SEEK_SET | offset from the beginning | jump to an absolute position |
SEEK_CUR | offset from the current position | skip forward, or back up |
SEEK_END | offset from the end (usually negative or 0) | measure size, read a trailer |
The classic idiom — seek to the end, ask where you are, rewind — measures a file's size:
fseek(f, 0, SEEK_END);
long size = ftell(f);
rewind(f);
This works, is portable, and has two caveats worth knowing. First, long may be 32 bits, capping the measurable size at 2 GB; POSIX offers fseeko/ftello with off_t for large files. Second, on a text stream the standard only guarantees meaningful behavior for offsets obtained from ftell — because of newline translation, byte offsets and character counts can differ. For genuine random access, open the file in binary mode.
fseek also has a side effect that is easy to miss: it clears the end-of-file flag. That is why a loop that hits EOF and then seeks back can continue reading. It also discards any pushed-back character from ungetc.
There is one rule that catches people in update modes ("r+", "w+", "a+"): between a read and a write on the same stream you must call fseek, fflush, or rewind, and between a write and a read you must call one of those or reach end of file. The buffer serves both directions and cannot switch silently. Omitting the call is undefined behavior — usually manifesting as data written to the wrong offset.
For positions too large for long, or for a portable opaque position, C provides fgetpos/fsetpos with an fpos_t. It is a token, not a number: you can store it and return to it, but you cannot do arithmetic on it. That is exactly right for text streams and multibyte encodings, where a "position" is more than an offset.
Finally, seeking is meaningless on non-seekable streams — pipes, terminals, sockets. fseek returns -1 there, and code that assumes a file must handle that.
Examples
#include <stdio.h>
/* Fixed-size records give you O(1) random access: record i lives at
offset i * sizeof(Record). This is the whole idea behind a flat-file
database index. */
typedef struct { int id; char name[32]; double balance; } Record;
static int read_record(FILE *f, size_t index, Record *out)
{
long offset = (long)(index * sizeof(Record));
if (fseek(f, offset, SEEK_SET) != 0) return -1; /* seek can fail */
return fread(out, sizeof *out, 1, f) == 1 ? 0 : -1;
}
static int write_record(FILE *f, size_t index, const Record *in)
{
if (fseek(f, (long)(index * sizeof(Record)), SEEK_SET) != 0) return -1;
if (fwrite(in, sizeof *in, 1, f) != 1) return -1;
return fflush(f); /* required before switching back to reading */
}
int main(void)
{
FILE *db = fopen("accounts.dat", "r+b"); /* read AND write, binary */
if (!db) { perror("accounts.dat"); return 1; }
/* How many records are there? */
fseek(db, 0, SEEK_END);
long bytes = ftell(db);
printf("%ld records\n", bytes / (long)sizeof(Record));
Record r;
if (read_record(db, 5, &r) == 0) /* jump straight to record 5 */
printf("id=%d name=%s\n", r.id, r.name);
r.balance += 100.0;
write_record(db, 5, &r); /* update it in place */
fclose(db);
return 0;
}
Reading a fixed-size trailer, and detecting a non-seekable stream:
#include <stdio.h>
static void read_footer(FILE *f)
{
char magic[8];
/* Negative offset from SEEK_END: the last 8 bytes. */
if (fseek(f, -8L, SEEK_END) != 0) {
/* Pipes and terminals are not seekable -- this is a normal outcome. */
perror("not seekable");
return;
}
if (fread(magic, 1, sizeof magic, f) == sizeof magic)
printf("footer: %.8s\n", magic);
}
int main(void)
{
/* fseek clears the EOF flag, so this loop can run twice. */
FILE *f = fopen("data.bin", "rb");
if (!f) return 1;
int c, count = 0;
while ((c = fgetc(f)) != EOF) count++;
printf("first pass: %d bytes, feof=%d\n", count, feof(f));
rewind(f); /* == fseek(f,0,SEEK_SET) + clearerr(f) */
printf("after rewind: feof=%d\n", feof(f));
read_footer(f);
fclose(f);
return 0;
}
THE POSITION INDICATOR
file: [ b0 b1 b2 b3 b4 b5 b6 b7 b8 b9 ]
^
position = 4
fseek(f, 0, SEEK_SET) -> 0 (start)
fseek(f, 2, SEEK_CUR) -> 6 (relative)
fseek(f, 0, SEEK_END) -> 10 (one past the last byte = size)
fseek(f,-3, SEEK_END) -> 7 (last three bytes)
READ <-> WRITE on an update stream ("r+","w+","a+"):
read ... [ fseek | fflush | rewind ] ... write REQUIRED
write ... [ fseek | fflush | rewind | EOF ] ... read REQUIRED
Key Takeaways
fseekmoves the position relative toSEEK_SET,SEEK_CUR, orSEEK_END;ftellreports it;rewindreturns to the start and clears the error flag too.- The seek-to-end/
ftell/rewind idiom measures file size, butlongmay cap it at 2 GB — usefseeko/ftellofor large files. - Random access is only well defined on binary streams; on text streams, only positions previously returned by
ftellare portable. - On update modes you must call
fseek,fflush, orrewindwhen switching between reading and writing — skipping it is undefined behavior. fseekclears the EOF indicator, and returns-1on non-seekable streams such as pipes and terminals.
🧪 Practice
- Write
file_size(const char *path)usingfseek/ftelland compare its result againstwc -con a text file containing\r\nline endings.- Build a fixed-record file of 100 entries, then write a program that updates entry 42 in place without rewriting the rest.
- Run a program that calls
fseek(stdin, 0, SEEK_SET)both interactively and with input redirected from a file. Explain the difference.- Interview-style: "Why must you call
fflushorfseekbetween a read and a write on an\"r+\"stream?" Hint: there is one buffer, and it has a direction.
Buffering and fflush
Theory
A system call is expensive — it crosses from your process into the kernel. Writing a 10,000-character report one putchar at a time would mean 10,000 of those crossings. The standard library avoids this by putting a buffer between you and the operating system: your writes accumulate in memory, and only when the buffer fills (or something forces the issue) does a single large write happen.
This is a pure performance optimization, and normally invisible. It becomes visible in exactly three situations, and each one confuses people who do not know the buffer is there:
- Output appears out of order relative to another stream, or relative to what a debugger shows.
- A prompt does not appear before the program blocks waiting for input.
- Output vanishes entirely when the program crashes or is killed — the bytes were in the buffer, not on the disk.
C defines three buffering modes:
| Mode | Constant | Flushed when | Default for |
|---|---|---|---|
| Unbuffered | _IONBF | immediately, every write | stderr |
| Line buffered | _IOLBF | on \n, when full, or on input | stdout to a terminal |
| Fully buffered | _IOFBF | only when the buffer fills | files, pipes, stdout redirected |
The critical row is the last one. stdout changes its buffering depending on where it points. Interactively it is line buffered, so printf("x\n") appears at once. Redirect to a file or a pipe and it becomes fully buffered, so nothing appears until 4 KB have accumulated. This is why ./prog | grep foo can seem to hang, and why interleaved printf and fprintf(stderr, ...) output looks correctly ordered on a terminal and scrambled in a log file.
fflush(stream) forces the buffered output out now. fflush(NULL) flushes every output stream. Note the asymmetry: fflush is defined only for output streams. fflush(stdin) is undefined behavior — a widely copied "fix" for the scanf newline problem that happens to work on glibc and does nothing or misbehaves elsewhere. Drain the input with a read loop instead.
Flushing happens automatically in more places than people expect: when the buffer fills, when fclose is called, when the stream is line buffered and a newline is written, when exit or a return from main runs, and (on many implementations) when a line-buffered stream reads from a terminal. It does not happen on abort, on _Exit, or when the process is killed by a signal — which is precisely why a program that crashes appears to have produced less output than it really did.
You can choose the mode yourself with setvbuf, but it must be called after opening the stream and before any I/O on it:
int setvbuf(FILE *stream, char *buf, int mode, size_t size);
Passing NULL for buf lets the library allocate. The common uses are forcing stdout to line-buffered or unbuffered for debugging, and giving a large buffer to a stream doing heavy sequential I/O.
stderr is unbuffered by default for a reason: an error message must survive the crash it is describing.
Examples
#include <stdio.h>
#include <unistd.h> /* sleep -- POSIX, for demonstration */
int main(void)
{
/* The classic missing-prompt bug. No newline, so a line-buffered
stdout holds the text while the program blocks on input. */
printf("Enter your name: "); /* may not appear yet */
fflush(stdout); /* force it out before we block */
char name[64];
if (fgets(name, sizeof name, stdin) == NULL) return 1;
/* Progress output that must appear as it happens. */
for (int i = 0; i < 3; i++) {
printf("step %d... ", i); /* still no newline */
fflush(stdout); /* so flush explicitly */
sleep(1);
}
printf("done\n"); /* newline flushes a line-buffered stream */
return 0;
}
Seeing buffering change behavior, without changing the program:
#include <stdio.h>
int main(void)
{
printf("to stdout\n"); /* buffered (mode depends) */
fprintf(stderr, "to stderr\n"); /* unbuffered: immediate */
printf("to stdout again\n");
return 0;
}
# Terminal: stdout is LINE buffered, so ordering looks natural.
$ ./prog
to stdout
to stderr
to stdout again
# Redirected: stdout is FULLY buffered and flushes at exit, so stderr
# (unbuffered) overtakes it entirely.
$ ./prog > out.txt 2>&1 ; cat out.txt
to stderr
to stdout
to stdout again
# Force line buffering from outside the program, for debugging pipelines:
$ stdbuf -oL ./prog | cat
Choosing a buffering mode explicitly:
#include <stdio.h>
int main(void)
{
/* Unbuffered stdout: every character hits the OS immediately.
Slow, but the output survives a crash mid-line. Must come
before any output on the stream. */
setvbuf(stdout, NULL, _IONBF, 0);
/* A big buffer for a stream doing bulk sequential writes. */
FILE *f = fopen("bulk.dat", "wb");
if (f) {
static char big[1 << 20]; /* 1 MB, static storage */
setvbuf(f, big, _IOFBF, sizeof big); /* fewer, larger writes */
/* ... fwrite loop ... */
fclose(f); /* flushes 'big' */
}
return 0;
}
WHERE YOUR BYTES ACTUALLY ARE
printf("hi")
|
v
+----------------+ fflush / buffer full / fclose / exit
| stdio buffer | -------------------------------------------> write()
| (your process) | |
+----------------+ v
+--------------------+
A crash here loses the buffer contents. | kernel page cache |
Only stderr (unbuffered) is safe by default. +--------------------+
|
fsync v
[ disk ]
Key Takeaways
- Buffering exists to turn many small writes into few system calls; it is invisible until output ordering, prompts, or crash behavior expose it.
stdoutis line buffered on a terminal but fully buffered when redirected to a file or pipe — the same program behaves differently under redirection.stderris unbuffered so error messages survive the failure that produced them.fflushapplies to output streams only;fflush(stdin)is undefined behavior, not a way to clear pending input.- Buffers are flushed by
fclose,exit, and a full buffer — but not byabort,_Exit, or a fatal signal.
🧪 Practice
- Write a program that prints a prompt without a newline and reads a line. Run it interactively and piped through
cat; explain both behaviors and fix it.- Interleave five
printfand fivefprintf(stderr, ...)calls, then compare./progagainst./prog > log 2>&1. Account for the ordering.- Use
setvbufto give a file stream a 1 MB buffer and measure the number ofwritesyscalls withstrace -cagainst the default.- Interview-style: "A program's log file is missing its last few lines after a crash. Why, and what would you change?" Hint: consider where the lines were when the process died, and what
abortskips.
stdin, stdout, stderr
Theory
Every hosted C program starts with three streams already open, no fopen required. They are declared in <stdio.h> and connected by the environment that launched the program:
| Stream | Descriptor | Purpose | Default destination | Default buffering |
|---|---|---|---|---|
stdin | 0 | normal input | keyboard | line buffered |
stdout | 1 | normal output — the program's result | terminal | line buffered to a tty, else full |
stderr | 2 | diagnostics — errors, warnings, progress | terminal | unbuffered |
The separation of stdout from stderr is not decoration; it is the foundation of the entire Unix pipeline model. stdout carries data meant for the next program. stderr carries messages meant for a human. Keeping them apart is what allows this to work:
./convert input.txt > output.txt # data goes to the file
# errors still appear on your screen
./convert input.txt 2> errors.log # or the other way around
./convert input.txt | sort | uniq # diagnostics never pollute the pipe
The practical rule follows directly: anything that is not the program's actual output goes to stderr. Error messages, obviously — but also usage text, progress bars, verbose logging, and "processed 500 files" summaries. A tool that prints a progress bar to stdout cannot be piped.
Because these are ordinary FILE * values, they can be passed like any other stream — which is how you write functions that work equally on a file, on stdout, or on a test buffer. printf(...) is exactly fprintf(stdout, ...), and getchar() is getc(stdin).
The environment can redirect them before your program starts, and your program cannot tell the difference by looking at the FILE *. What it can do is ask whether the stream is a terminal, using the POSIX isatty(fileno(stream)). This is how well-behaved tools decide to enable color only when a human is watching, and to switch from a progress bar to plain log lines when redirected.
freopen re-points an existing stream at a different file, which is the standard way to redirect from inside the program — for instance sending a daemon's stderr to a log file. Closing stdout is legal but rarely useful; note that a printf to a closed or broken stream fails silently unless you check.
One more consequence of the buffering difference: because stderr is unbuffered and stdout usually is not, their relative order in a combined log is not the order in which your program produced them. If you need faithful interleaving, either flush stdout before each stderr write, or send both to the same stream.
Examples
#include <stdio.h>
#include <stdlib.h>
/* A well-behaved filter: data on stdout, everything else on stderr. */
int main(int argc, char **argv)
{
if (argc > 2) {
/* Usage messages are diagnostics -- stderr, and a nonzero exit. */
fprintf(stderr, "usage: %s [file]\n", argv[0]);
return EXIT_FAILURE;
}
FILE *in = stdin; /* default: read from the pipe */
if (argc == 2) {
in = fopen(argv[1], "r");
if (!in) { perror(argv[1]); return EXIT_FAILURE; }
}
char line[1024];
unsigned long n = 0;
while (fgets(line, sizeof line, in)) {
fputs(line, stdout); /* the DATA: pipeable */
if (++n % 1000 == 0)
fprintf(stderr, "\r%lu lines...", n); /* PROGRESS: not data */
}
fprintf(stderr, "\rdone: %lu lines\n", n);
if (in != stdin) fclose(in);
return EXIT_SUCCESS;
}
Writing functions that do not care where the output goes, and detecting a terminal:
#include <stdio.h>
#include <unistd.h> /* isatty, fileno -- POSIX */
/* Takes the stream as a parameter: works with stdout, a file, or a
temporary stream in a unit test. This is the testable shape. */
static void print_table(FILE *out, const int *values, size_t n)
{
for (size_t i = 0; i < n; i++)
fprintf(out, "%3zu | %d\n", i, values[i]);
}
int main(void)
{
int data[] = {10, 20, 30};
/* Color only when a human is actually looking at the output. */
int color = isatty(fileno(stdout));
const char *bold = color ? "\033[1m" : "";
const char *reset = color ? "\033[0m" : "";
printf("%sIndex | Value%s\n", bold, reset);
print_table(stdout, data, 3);
FILE *f = fopen("table.txt", "w");
if (f) { print_table(f, data, 3); fclose(f); } /* same function, a file */
/* Redirect a stream from inside the program (daemon logging pattern). */
if (freopen("daemon.log", "a", stderr) == NULL)
perror("freopen");
fprintf(stderr, "diagnostics now land in daemon.log\n");
return 0;
}
THE THREE STANDARD STREAMS
+-------------------------+
keyboard ----> | 0 stdin |
or a pipe +-------------------------+
| 1 stdout -- DATA | ----> terminal, file, or the
+-------------------------+ next program in a pipe
| 2 stderr -- MESSAGES | ----> terminal (or its own log)
+-------------------------+
./a | ./b connects a's stdout to b's stdin.
Both programs' stderr still go to the screen.
Rule: if piping your output into another program would break
because of a line, that line belongs on stderr.
Key Takeaways
stdin,stdout, andstderrare pre-openedFILE *values on descriptors 0, 1, and 2; nofopenis needed and they may be redirected before your program runs.stdoutis for data the next program might consume;stderris for humans — errors, usage, progress, and logging all belong there.printfisfprintf(stdout, ...); taking aFILE *parameter makes output functions reusable and testable.stderris unbuffered whilestdoutoften is not, so combined output can appear out of order unless you flush.- Use
isatty(fileno(stdout))to decide on color and progress display, andfreopento redirect a standard stream from inside the program.
🧪 Practice
- Write a line-numbering filter that reads
stdinand writesstdout, printing a summary tostderr. Verify that./prog < in.txt | headworks and the summary still shows.- Take an existing program that prints errors with
printfand convert it; then demonstrate a pipeline that was broken before and works after.- Use
isattyto enable ANSI color only for a terminal, and confirm with./prog | catthat no escape codes reach the file.- Interview-style: "Why do Unix programs have two output streams instead of one?" Hint: think about what happens to a diagnostic message when the output is a pipe.
<a id="92-core-utility-headers"></a>
9.2 Core Utility Headers
Beyond I/O, the standard library is a handful of small headers that solve problems every program has: copying bytes, converting text to numbers, sorting, arithmetic, classifying characters, telling the time, and asking what the current machine's types can hold. This section covers each in the depth needed to use it correctly rather than approximately.
string.h and Memory Functions
Theory
<string.h> contains two related families that are easy to confuse. The str* functions operate on NUL-terminated strings and stop at the terminator. The mem* functions operate on raw byte ranges and take an explicit length, ignoring content entirely. Choosing between them is choosing whether "the data" ends at a zero byte or at a count you supply.
The str* family's dependence on the terminator is the source of nearly every bug in it. strlen walks memory until it finds a zero — if there is none, it walks off the end. strcpy copies until it finds a zero in the source, with no knowledge of how large the destination is. This is not a defect in the functions; it is the interface. C strings do not carry their length, so any function taking only a char * cannot know where the buffer ends.
The bounded strn* variants help, but each has a wrinkle:
strncpydoes not do what its name suggests. It copies at mostnbytes, and if the source isnbytes or longer, the result is not NUL-terminated. It also zero-pads a short source out to the fullnbytes, which is a performance cost nobody expects. It was designed for fixed-size fields in old Unix directory entries, not for safe string copying.strncat'snis the number of bytes to append, not the destination's size — so the correct call isstrncat(dst, src, dstsize - strlen(dst) - 1), which requires knowingstrlen(dst)anyway.
Because of this, most projects use one of: snprintf(dst, size, "%s", src) (portable, always terminates, reports truncation), or the BSD/POSIX strlcpy/strlcat (not in the C standard but widely available), or a hand-written wrapper.
The mem* functions are simpler because they take a length and never look for a terminator:
| Function | Does |
|---|---|
memcpy(d, s, n) | copy n bytes; regions must not overlap |
memmove(d, s, n) | copy n bytes; overlap is handled correctly |
memset(p, c, n) | fill n bytes with the byte value c |
memcmp(a, b, n) | compare n bytes, byte by byte |
memchr(p, c, n) | find the first byte c within n bytes |
memcpy with overlapping regions is undefined behavior, and it really does break in practice — optimized implementations copy in whatever order and block size is fastest, so shifting an array left may work while shifting it right corrupts the data. When regions might overlap, use memmove; the cost is negligible.
Two traps deserve their own mention. memcmp on structs is unreliable because padding bytes are uninitialized and compare as garbage even when every field is equal. And memset(p, 0, n) to erase a password may be removed by the optimizer as a dead store if p is not used afterwards — C11 provides memset_s, and platforms offer explicit_bzero, for erasure that must survive.
Comparison functions return a sign, not a boolean: negative, zero, or positive. if (strcmp(a, b)) is true when the strings differ, which reads backwards and is a classic review comment.
Examples
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(void)
{
/* ---- length and comparison ------------------------------------- */
const char *s = "hello";
printf("%zu\n", strlen(s)); /* 5 -- excludes the NUL */
printf("%d\n", strcmp("abc", "abd")); /* negative: 'c' < 'd' */
printf("%s\n", strcmp("a","a") == 0 ? "equal" : "different"); /* == 0 ! */
/* ---- copying: why strncpy is not a safe strcpy ------------------ */
char dst[6];
strncpy(dst, "abcdefgh", sizeof dst); /* copies 6 bytes, NO terminator */
dst[sizeof dst - 1] = '\0'; /* you must terminate yourself */
printf("[%s]\n", dst); /* [abcde] */
/* The portable safe copy: always terminates, and tells you if it fit. */
char safe[6];
int n = snprintf(safe, sizeof safe, "%s", "abcdefgh");
if ((size_t)n >= sizeof safe) printf("truncated (needed %d)\n", n);
/* ---- searching --------------------------------------------------- */
const char *path = "/usr/local/bin/gcc";
const char *base = strrchr(path, '/'); /* LAST '/' */
printf("basename: %s\n", base ? base + 1 : path);
printf("found: %s\n", strstr(path, "local") ? "yes" : "no");
/* ---- tokenizing: strtok MODIFIES its input and keeps global state -- */
char csv[] = "a,b,,c"; /* array, not a literal: writable */
for (char *tok = strtok(csv, ","); tok; tok = strtok(NULL, ","))
printf("tok=[%s]\n", tok); /* note: the empty field is skipped */
return 0;
}
memcpy versus memmove, made concrete:
#include <stdio.h>
#include <string.h>
int main(void)
{
char buf[] = "ABCDEFGH";
/* Shift left by one: destination is BEFORE the source, overlapping. */
memmove(buf, buf + 1, 7); /* correct: handles overlap */
buf[7] = '\0';
printf("%s\n", buf); /* BCDEFGH */
char buf2[] = "ABCDEFGH";
/* memcpy(buf2 + 1, buf2, 7); <-- UNDEFINED BEHAVIOR: regions overlap.
It may appear to work, then break at -O2 or on a different CPU. */
memmove(buf2 + 1, buf2, 7); /* correct */
printf("%s\n", buf2); /* AABCDEFG */
/* memset fills BYTES, so only 0 and -1 give the "obvious" result. */
int arr[4];
memset(arr, 0, sizeof arr); /* all zeros: correct and idiomatic */
memset(arr, 1, sizeof arr); /* NOT 1: each int becomes 0x01010101 */
printf("%d %d\n", arr[0], 0x01010101);
/* memcmp on structs reads padding too -- do not use it for equality. */
struct P { char c; int i; }; /* 3 padding bytes after 'c' */
struct P a, b;
memset(&a, 0, sizeof a); memset(&b, 0, sizeof b); /* only safe because
we zeroed padding */
a.c = b.c = 'x'; a.i = b.i = 7;
printf("memcmp says %s\n", memcmp(&a, &b, sizeof a) == 0 ? "equal" : "differ");
return 0;
}
| Task | Avoid | Use |
|---|---|---|
| Copy a string safely | strcpy, strncpy | snprintf(d, size, "%s", s) or strlcpy |
| Concatenate safely | strcat, strncat | snprintf with both parts, or track the length |
| Copy possibly-overlapping bytes | memcpy | memmove |
| Compare structs for equality | memcmp | field-by-field comparison |
| Erase a secret | memset | memset_s / explicit_bzero |
| Split a string, reentrantly | strtok | strtok_r, or strcspn by hand |
Key Takeaways
str*functions stop at the NUL terminator and cannot know the destination's size;mem*functions take an explicit length and ignore content.strncpydoes not guarantee termination and pads with zeros — it is not a safestrcpy; prefersnprintforstrlcpy.memcpyon overlapping regions is undefined behavior;memmoveis the correct choice whenever overlap is possible.memcmpon structures compares padding bytes as well as fields, so it is not a valid equality test.strcmpand friends return a sign, not a boolean — equality is== 0; andstrtokmodifies its input and holds hidden global state.
🧪 Practice
- Implement
my_strlen,my_strcpy, andmy_strcmpfrom scratch and test them against the library versions on empty strings and single characters.- Write
str_copy(char *dst, size_t cap, const char *src)that always terminates and returns the length it would have needed. Compare it withstrncpyon a source longer than the destination.- Demonstrate
memcpyoverlap corruption: shift an array right by one withmemcpy, compile at-O2, and compare againstmemmove.- Interview-style: "Why is
strncpyconsidered unsafe despite taking a size?" Hint: describe exactly what the buffer contains when the source is longer thann.
stdlib.h Conversions and Random Numbers
Theory
<stdlib.h> is the miscellaneous drawer: memory allocation (covered in Chapter 7), process control, conversions between text and numbers, searching and sorting, and pseudo-random numbers. Two of those groups are worth studying carefully because their obvious functions are the wrong ones.
Text-to-number conversion. The old family is atoi, atol, atof. They are compact and they are unusable in any program that must handle bad input, because they cannot report an error. atoi("hello") returns 0 — indistinguishable from atoi("0"). atoi("99999999999999") is undefined behavior on overflow. There is no way to ask "did that work?".
The correct family is strtol, strtoul, strtoll, strtod, and it reports errors through two channels at once:
long strtol(const char *nptr, char **endptr, int base);
endptris set to the first character not consumed. Comparing it to the input tells you whether anything was converted; checking that it points at the terminator tells you whether the whole string was consumed. This is how you reject"12abc".errnois set toERANGEon overflow or underflow, with the return value clamped toLONG_MAX/LONG_MIN. Because these functions do not clearerrnoon success, you must seterrno = 0before the call.
The base argument accepts 2 through 36, or 0 for automatic detection: a leading 0x means hexadecimal, a leading 0 means octal, otherwise decimal. That is exactly the syntax C literals use, which makes base 0 the right choice for parsing configuration values.
Pseudo-random numbers. rand() returns a value in [0, RAND_MAX], and srand(seed) sets the starting point. Three things about it are non-negotiable knowledge:
- It is deterministic. The same seed produces the same sequence, always. Without calling
srand, the sequence behaves as though seeded with1— which is why an unseeded program prints identical "random" numbers on every run. rand() % nis biased. IfRAND_MAX + 1is not a multiple ofn, the low residues occur more often. WithRAND_MAX = 32767andn = 10000, values 0–2767 are about 50% more likely than the rest. The fix is rejection sampling: discard the values in the uneven tail.- It is not cryptographically secure.
randis typically a linear congruential generator whose entire future is predictable from a couple of outputs. For anything security-relevant use the platform's CSPRNG (getrandom,/dev/urandom,arc4random,BCryptGenRandom).
srand(time(NULL)) is the traditional seeding line. It is fine for a game or a simulation, but note that two processes started in the same second get the same sequence.
Also in <stdlib.h>: abs/labs, div/ldiv (quotient and remainder in one operation, with defined truncation), getenv, system, exit, abort, and the qsort/bsearch pair covered next.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>
#include <string.h>
/* Full-strength string-to-int: rejects garbage, empty input, overflow,
and trailing characters. This is the shape every argument parser needs. */
static int parse_int(const char *s, int *out)
{
char *end;
errno = 0; /* strtol does not clear it */
long v = strtol(s, &end, 10);
if (end == s) return -1; /* no digits at all */
if (*end != '\0') return -1; /* trailing garbage */
if (errno == ERANGE) return -1; /* long overflow */
if (v < INT_MIN || v > INT_MAX) return -1; /* fits long, not int */
*out = (int)v;
return 0;
}
int main(void)
{
const char *tests[] = { "42", " -7", "12abc", "", "99999999999999999999", "0x1F" };
int v;
for (size_t i = 0; i < sizeof tests / sizeof *tests; i++)
printf("%-24s -> %s\n", tests[i],
parse_int(tests[i], &v) == 0 ? "ok" : "rejected");
/* Base 0 auto-detects the prefix, exactly like a C literal. */
printf("0x1F = %ld\n", strtol("0x1F", NULL, 0)); /* 31 */
printf("017 = %ld\n", strtol("017", NULL, 0)); /* 15, octal */
printf("101 = %ld\n", strtol("101", NULL, 2)); /* 5, binary */
/* atoi for comparison: every failure looks like the number zero. */
printf("atoi(\"hello\") = %d\n", atoi("hello")); /* 0 -- indistinguishable */
return 0;
}
Unbiased random integers in a range:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
/* Biased: the leftover values at the top of RAND_MAX favour small results. */
static int biased(int n) { return rand() % n; }
/* Unbiased: reject the uneven tail so every residue is equally likely. */
static int unbiased(int n)
{
/* Largest multiple of n that fits in [0, RAND_MAX]. */
int limit = RAND_MAX - (RAND_MAX % n);
int r;
do { r = rand(); } while (r >= limit); /* discard the tail */
return r % n;
}
int main(void)
{
srand((unsigned)time(NULL)); /* omit this and every run is identical */
long counts[6] = {0};
for (long i = 0; i < 6000000L; i++)
counts[unbiased(6)]++;
for (int i = 0; i < 6; i++)
printf("%d: %ld\n", i, counts[i]); /* each near 1000000 */
/* Reproducibility: a FIXED seed is a feature for tests and simulations. */
srand(12345);
printf("%d %d %d\n", rand(), rand(), rand()); /* same three, every run */
return 0;
}
| Conversion need | Function | Error reporting |
|---|---|---|
| Quick, input is trusted | atoi, atof | none — do not use on real input |
| Signed integer, any base | strtol / strtoll | endptr + errno == ERANGE |
| Unsigned integer | strtoul / strtoull | same (note: it wraps negatives) |
| Floating point | strtod / strtof | same |
| Number to string | snprintf | return value indicates truncation |
Key Takeaways
atoiand friends cannot distinguish a failed conversion from a legitimate zero and are undefined on overflow — usestrtol's family instead.- Check
strtoltwo ways:endptrfor how much was consumed, anderrno == ERANGEfor overflow, after settingerrno = 0first. - Base
0makesstrtolaccept0xhex and leading-zero octal exactly as C source does. randis deterministic and seeded to1by default;srand(time(NULL))is adequate for games and useless for security.rand() % nis biased wheneverndoes not divideRAND_MAX + 1; use rejection sampling for a uniform range.
🧪 Practice
- Write
parse_double(const char *s, double *out)with the same rigor asparse_int, and test it on"1e400","3.14xyz", and" .5".- Measure the bias in
rand() % 3by tallying 30 million samples; then repeat with rejection sampling and compare the distributions.- Write a command-line tool that accepts a numeric argument in decimal, hex, or octal using base 0, and prints a clear error for anything else.
- Interview-style: "Why can
atoinever be used to validate user input?" Hint: enumerate the distinct inputs that all produce the return value0.
qsort and bsearch
Theory
C has no templates and no generics, so the standard library's sort and search work the only way they can: on untyped memory plus a description of it. You tell qsort where the array starts, how many elements it has, how big each one is, and how to compare two of them.
void qsort (void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
void *bsearch(const void *key, const void *base, size_t nmemb, size_t size,
int (*compar)(const void *, const void *));
The comparison function is the whole interface, and it has strict rules:
- It receives
const void *pointers to two elements, which you must cast to the real element type and dereference. For an array ofint, the parameter points at anint, so you cast toconst int *. - It returns negative if the first sorts before the second, zero if they are equivalent, positive if after. The magnitude is irrelevant.
- It must define a consistent total order. If it is inconsistent — returning different answers for the same pair, or saying
a < b,b < c, andc < a— the behavior is undefined, and real implementations can read out of bounds and crash, not merely produce a wrong order.
The classic bug in an integer comparator is return *pa - *pb;. It is correct for small values and overflows for large ones: comparing INT_MIN with 1 overflows and may return a positive value, breaking the order. Write it as an explicit three-way comparison, or use the (a > b) - (a < b) idiom.
Two properties of qsort surprise people. It is not required to be quicksort despite the name — implementations use introsort, merge sort, or whatever they like. And it is not stable: elements that compare equal may be reordered. If stability matters, add a tiebreaker field (such as the original index) to the comparison.
bsearch requires the array to already be sorted by the same comparison function. If it is not, the result is meaningless — not an error, just a wrong answer. It returns a pointer to a matching element or NULL, and when duplicates exist it does not promise which one.
The cost of genericity is real: every comparison is an indirect function call the compiler usually cannot inline, so qsort on an int array is several times slower than a type-specialized sort. That is a fine trade for most code and a bad one in a hot loop — which is why performance-critical C often generates type-specific sorts with macros.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stddef.h> /* ptrdiff_t */
/* ---- comparing integers, without the subtraction overflow bug -------- */
static int cmp_int(const void *a, const void *b)
{
int x = *(const int *)a; /* cast, then dereference */
int y = *(const int *)b;
return (x > y) - (x < y); /* -1, 0, or 1; never overflows */
/* NOT: return x - y; -- overflows for large-magnitude values */
}
/* ---- comparing structs on multiple keys ----------------------------- */
typedef struct { char name[32]; int score; unsigned seq; } Player;
static int cmp_player(const void *a, const void *b)
{
const Player *p = a, *q = b; /* void* converts implicitly */
if (p->score != q->score)
return (q->score > p->score) - (q->score < p->score); /* score DESC */
int by_name = strcmp(p->name, q->name); /* then name ASC */
if (by_name != 0) return by_name;
/* Tiebreaker on insertion order gives a stable result from an
unstable sort -- qsort makes no stability guarantee. */
return (p->seq > q->seq) - (p->seq < q->seq);
}
/* ---- comparing strings stored as char* (note the double indirection) -- */
static int cmp_str(const void *a, const void *b)
{
/* The ELEMENT is a char*, so the parameter is a pointer to char*. */
const char *const *pa = a, *const *pb = b;
return strcmp(*pa, *pb);
}
int main(void)
{
int nums[] = {42, -7, 0, 999, -1000, 3};
size_t n = sizeof nums / sizeof *nums;
qsort(nums, n, sizeof *nums, cmp_int);
for (size_t i = 0; i < n; i++) printf("%d ", nums[i]);
putchar('\n'); /* -1000 -7 0 3 42 999 */
/* bsearch REQUIRES the array to be sorted by the same comparator. */
int key = 42;
int *hit = bsearch(&key, nums, n, sizeof *nums, cmp_int);
printf("42 %s (index %td)\n", hit ? "found" : "missing",
hit ? hit - nums : (ptrdiff_t)-1);
const char *words[] = {"pear", "apple", "fig"};
qsort(words, 3, sizeof *words, cmp_str);
printf("%s %s %s\n", words[0], words[1], words[2]); /* apple fig pear */
Player ps[] = {{"ann", 10, 0}, {"bob", 30, 1}, {"cid", 10, 2}};
qsort(ps, 3, sizeof *ps, cmp_player);
for (int i = 0; i < 3; i++) printf("%s:%d ", ps[i].name, ps[i].score);
putchar('\n'); /* bob:30 ann:10 cid:10 */
return 0;
}
HOW qsort SEES YOUR ARRAY
base ---> [ elem 0 ][ elem 1 ][ elem 2 ] ... nmemb elements
|<-size->|
qsort knows only: the address, the count, the stride, and your comparator.
It computes element i as (char *)base + i * size and hands two such
addresses to compar() as const void *.
YOUR COMPARATOR MUST:
- cast both parameters to (const T *)
- return <0, 0, >0 consistently for every pair
- never overflow (avoid a - b for ints)
- never depend on the addresses, only on the values
The overflow bug, demonstrated:
#include <stdio.h>
#include <limits.h>
static int bad(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b; /* overflows */
}
int main(void)
{
int x = INT_MIN, y = 1;
/* INT_MIN - 1 overflows: undefined, and typically wraps POSITIVE,
claiming INT_MIN sorts AFTER 1. */
printf("bad says %d (expected negative)\n", bad(&x, &y));
return 0;
}
Key Takeaways
qsortandbsearchare generic throughvoid *plus an element size and a comparator; the comparator must cast its parameters back to the real type.- Return negative, zero, or positive — magnitude is ignored — and never write
a - bfor integers, because it overflows. - The comparator must be a consistent total order; an inconsistent one is undefined behavior and can crash, not just misorder.
qsortis not guaranteed to be quicksort and is not stable; add an original-index tiebreaker when stability matters.bsearchassumes the array is already sorted by the same comparator and does not say which of several equal elements it returns.
🧪 Practice
- Sort an array of
struct { char *name; int age; }by age ascending, then by name for ties, and print the result.- Write a comparator using
a - band feed it an array containingINT_MINandINT_MAX. Show that the order is wrong, then fix it.- Sort a 1,000,000-element
intarray withqsortand with a hand-written quicksort specialized forint. Compare the timings and explain the gap.- Interview-style: "What happens if your comparison function is not a consistent ordering?" Hint: think about what the sort algorithm assumes when it decides a partition is finished.
math.h Essentials
Theory
<math.h> provides the functions that hardware and the C language do not: roots, powers, logarithms, trigonometry, rounding, and the classification of special floating-point values. Using it well is mostly about understanding that floating-point arithmetic is approximate and that the header gives you tools for dealing with that.
Three practical points first.
Linking. On Unix-like systems the math functions live in a separate library, so you must link it: gcc prog.c -lm. And -lm must come after the source or object files that use it, because the linker resolves left to right. undefined reference to 'sqrt' almost always means a missing or misplaced -lm.
Types. The default functions take and return double. C99 added float variants with an f suffix (sqrtf, sinf) and long double variants with an l suffix (sqrtl). Using sqrt on a float silently promotes to double and back — correct, but slower, and in tight numeric loops the f variants matter. C99's <tgmath.h> provides type-generic macros that pick the right one.
Comparison. == on floating-point results is almost always wrong, because arithmetic introduces representation error: 0.1 + 0.2 != 0.3. Compare with a tolerance — and a fixed epsilon is only correct near 1.0, so a robust comparison scales the tolerance with the magnitude of the operands.
The header also defines the vocabulary of special values, which arithmetic produces rather than trapping:
| Value | Produced by | Tested with |
|---|---|---|
INFINITY | 1.0/0.0, overflow | isinf(x) |
NAN | 0.0/0.0, sqrt(-1), log(-1) | isnan(x) |
| finite | ordinary arithmetic | isfinite(x) |
| subnormal | underflow toward zero | isnormal(x) |
NAN compares false against everything, including itself. x != x is true exactly when x is NaN — an idiom you will see, though isnan(x) says it more clearly. This property is why a single NaN can silently propagate through a computation and turn every downstream comparison false.
The rounding functions are worth separating because their differences are exactly the ones people get wrong:
| Function | 2.5 | -2.5 | 2.7 | Rule |
|---|---|---|---|---|
floor | 2 | -3 | 2 | toward negative infinity |
ceil | 3 | -2 | 3 | toward positive infinity |
trunc | 2 | -2 | 2 | toward zero |
round | 3 | -3 | 3 | half away from zero |
nearbyint | 2 | -2 | 3 | current mode (default: half to even) |
Note that (int)x performs truncation, not rounding — a frequent off-by-one source.
Finally, some functions exist specifically for accuracy: fma(a,b,c) computes a*b+c with a single rounding, hypot(x,y) computes the hypotenuse without overflowing on the intermediate squares, log1p(x) and expm1(x) stay accurate for tiny x where log(1+x) and exp(x)-1 lose all their significant digits.
Examples
#include <stdio.h>
#include <math.h>
#include <float.h> /* DBL_EPSILON */
#include <stdbool.h>
/* Naive equality fails; a scaled tolerance is the general answer. */
static bool nearly_equal(double a, double b, double rel)
{
double diff = fabs(a - b);
if (diff <= DBL_EPSILON) return true; /* handles a == b == 0 */
double scale = fabs(a) > fabs(b) ? fabs(a) : fabs(b);
return diff <= scale * rel; /* tolerance scales */
}
int main(void)
{
printf("0.1+0.2 == 0.3 ? %s\n", (0.1 + 0.2 == 0.3) ? "yes" : "no"); /* no */
printf("nearly_equal ? %s\n", nearly_equal(0.1+0.2, 0.3, 1e-9) ? "yes":"no");
printf("difference: %.20f\n", (0.1 + 0.2) - 0.3);
/* ---- special values ------------------------------------------- */
double inf = 1.0 / 0.0;
double nan = 0.0 / 0.0;
printf("isinf(inf)=%d isnan(nan)=%d\n", isinf(inf), isnan(nan));
printf("nan == nan is %s\n", (nan == nan) ? "true" : "FALSE"); /* false! */
printf("nan < 1.0 is %s\n", (nan < 1.0) ? "true" : "false"); /* false */
printf("nan >= 1.0 is %s\n", (nan >= 1.0) ? "true" : "false"); /* false too */
/* ---- rounding: five different answers -------------------------- */
double v = -2.5;
printf("floor=%.0f ceil=%.0f trunc=%.0f round=%.0f cast=%d\n",
floor(v), ceil(v), trunc(v), round(v), (int)v); /* -3 -2 -2 -3 -2 */
/* ---- accuracy-preserving forms --------------------------------- */
double tiny = 1e-17;
printf("log(1+x) = %.20g\n", log(1.0 + tiny)); /* 0 -- all digits lost */
printf("log1p(x) = %.20g\n", log1p(tiny)); /* accurate */
printf("hypot = %g\n", hypot(3e300, 4e300)); /* 5e300, no overflow */
/* sqrt(3e300*3e300 + 4e300*4e300) would overflow to infinity. */
return 0;
}
# The math library is separate, and the order matters.
$ gcc prog.c -lm -o prog # correct
$ gcc -lm prog.c -o prog # may fail: -lm resolved before prog.c needs it
/tmp/cc123.o: undefined reference to `sqrt'
FLOATING POINT IS NOT REAL ARITHMETIC
0.1 in binary is a repeating fraction, exactly as 1/3 is in decimal.
The stored double is 0.1000000000000000055511151231257827...
0.1 + 0.2 = 0.3000000000000000444089209850062616...
0.3 = 0.2999999999999999888977697537403456...
^ they differ here
Therefore: == is a bug.
|a-b| <= scale * relative_tolerance is the fix.
NaN's contagion: any comparison with NaN is FALSE
so if (x < 1) ... else ... takes the ELSE branch
and sorting an array containing NaN is undefined.
Key Takeaways
- Link with
-lmon Unix-like systems, placed after the files that need it, or the linker reports undefined references. - Never compare floating-point results with
==; use a tolerance scaled to the magnitude of the operands. - NaN compares false against everything, including itself, so
x != xdetects it — but a NaN silently flips the branch every comparison takes. floor,ceil,trunc, androunddiffer on negatives and on halves; a cast tointtruncates rather than rounds.- Use
hypot,fma,log1p, andexpm1when intermediate overflow or cancellation would destroy accuracy, and thef/lsuffixed variants to match your operand type.
🧪 Practice
0.1 + 0.2with%.20fand explain the digits. Then write a comparison function that treats it as equal to0.3.- Write a function that classifies a
doubleas normal, subnormal, zero, infinite, or NaN, and test it on the values that produce each.- Compute the hypotenuse of
(3e300, 4e300)both directly and withhypot, and explain the difference in the results.- Interview-style: "What does
x != xevaluate to, and when is it useful?" Hint: only one class of value makes it true.
ctype.h Character Classification
Theory
<ctype.h> answers simple questions about single characters: is this a digit, a letter, whitespace, printable? It also converts case. The functions look almost too trivial to discuss, and they hide one genuine trap that produces real crashes.
The classification functions are:
| Function | True for |
|---|---|
isalpha | letters |
isdigit | 0–9 |
isalnum | letters or digits |
isspace | space, tab, newline, vertical tab, form feed, carriage return |
isupper / islower | case |
ispunct | printable, not alphanumeric, not space |
isprint / isgraph | printable (with / without space) |
iscntrl | control characters |
isxdigit | hexadecimal digits |
toupper / tolower | conversion (returns the input unchanged if not applicable) |
The trap is the argument type. Every one of these takes an int, and the standard says the value must be representable as unsigned char, or equal to EOF. Anything else is undefined behavior.
Why does that matter? Because char is signed on most common platforms. A byte such as 0xE9 (é in Latin-1, or part of a UTF-8 sequence) becomes the negative value -23 when stored in a char. Passing that to isalpha indexes the implementation's lookup table at a negative offset — reading memory before the table. On glibc this often happens to work because of padding in front of the table; on other implementations it crashes or returns nonsense.
The fix is mechanical and must be applied every time: cast to unsigned char.
if (isalpha((unsigned char)str[i])) { ... }
This is one of the most common defects found by static analyzers in C codebases, precisely because the buggy form works on ASCII input and fails only on the non-ASCII byte that arrives in production.
A second, subtler point: these functions are locale-dependent. In the default "C" locale, isalpha is true only for the 52 ASCII letters. Call setlocale(LC_CTYPE, "") and the answer can change. That is desirable for text processing and undesirable for parsing a fixed format — if you are parsing a protocol, compare against explicit character ranges rather than asking the locale.
Finally, toupper/tolower return an int and leave characters that have no case conversion unchanged, so they are safe to apply unconditionally to every character in a string.
Examples
#include <stdio.h>
#include <ctype.h>
#include <string.h>
/* The cast is not optional. str[i] may be negative on signed-char platforms. */
static void classify(const char *s)
{
for (size_t i = 0; s[i]; i++) {
unsigned char c = (unsigned char)s[i]; /* cast ONCE, reuse */
printf("'%c' (%3u): %s%s%s%s\n", isprint(c) ? c : '?', c,
isalpha(c) ? "alpha " : "",
isdigit(c) ? "digit " : "",
isspace(c) ? "space " : "",
ispunct(c) ? "punct " : "");
}
}
/* In-place case conversion, applied to every character unconditionally --
toupper leaves non-letters alone. */
static void upcase(char *s)
{
for (; *s; s++)
*s = (char)toupper((unsigned char)*s);
}
/* Trim leading and trailing whitespace, returning a pointer into the
original buffer. Classic use of isspace. */
static char *trim(char *s)
{
while (isspace((unsigned char)*s)) s++; /* skip leading */
if (*s == '\0') return s; /* all whitespace */
char *end = s + strlen(s) - 1;
while (end > s && isspace((unsigned char)*end)) end--;
end[1] = '\0'; /* cut trailing */
return s;
}
int main(void)
{
classify("a1 !");
char word[] = "Hello, World! 42";
upcase(word);
printf("%s\n", word); /* HELLO, WORLD! 42 */
char padded[] = " spaced out ";
printf("[%s]\n", trim(padded)); /* [spaced out] */
return 0;
}
The bug, made visible:
#include <stdio.h>
#include <ctype.h>
#include <limits.h>
int main(void)
{
char c = (char)0xE9; /* 'é' in Latin-1, or a UTF-8 byte */
printf("CHAR_MIN = %d, so char is %s\n",
CHAR_MIN, CHAR_MIN < 0 ? "SIGNED" : "unsigned");
printf("as char: %d\n", c); /* -23 on x86 */
printf("as unsigned char: %d\n", (unsigned char)c); /* 233 */
/* isalpha(c) with c == -23 is UNDEFINED BEHAVIOR: the implementation
indexes a table at [-23]. It may work, crash, or return garbage. */
printf("isalpha correct: %d\n", isalpha((unsigned char)c));
/* EOF (-1) is the ONE negative value these functions must accept --
so that isspace(fgetc(f)) works without a special case. */
printf("isspace(EOF) is legal: %d\n", isspace(EOF));
return 0;
}
WHY THE CAST MATTERS
The implementation is typically a table lookup:
extern const unsigned short *__ctype_b;
#define isalpha(c) (__ctype_b[(c)] & _ISalpha)
index -23 index 0 index 233
| | |
... other data ... [ ? ] ... [ table starts ] ... [ correct entry ]
^
reading HERE is out of bounds
char 0xE9 -> signed: -23 -> out-of-bounds read (UB)
unsigned: 233 -> correct entry
Key Takeaways
- Every
<ctype.h>function takes anintwhose value must fit inunsigned charor equalEOF; passing a plain negativecharis undefined behavior. - Always write
isalpha((unsigned char)c)— the unfixed form works on ASCII and fails on the first non-ASCII byte. EOFis deliberately accepted so that classifying the result offgetcneeds no special case.toupperandtolowerreturn the input unchanged when no conversion applies, so they can be applied to every character unconditionally.- These functions are locale-sensitive; for parsing fixed formats and protocols, compare against explicit ranges instead.
🧪 Practice
- Write
count_words(const char *s)usingisspace, correctly handling leading, trailing, and repeated whitespace.- Write a validator that accepts an identifier only if it starts with a letter or underscore and contains only alphanumerics and underscores.
- Pass a
charholding0xFFtoisalphawithout a cast, then with one, and inspect both under-fsanitize=undefinedor a static analyzer.- Interview-style: "Why do the
<ctype.h>functions takeintinstead ofchar?" Hint: there is one value they must accept that is not a character at all.
time.h Dates and Timing
Theory
<time.h> covers two different jobs that people constantly conflate: telling the time (what wall-clock date is it?) and measuring durations (how long did that take?). They need different clocks, and using the wrong one is the classic mistake.
The header defines three types:
time_t— a calendar time, in practice the number of seconds since the Unix epoch, 1970-01-01 00:00:00 UTC. The standard does not require that encoding, but every mainstream implementation uses it.struct tm— a broken-down time: separate fields for year, month, day, hour, minute, second, plus weekday and day-of-year. Three fields have traps:tm_yearcounts years since 1900,tm_monis 0-based (January is 0), whiletm_mdayis 1-based.clock_t— processor time, measured in units ofCLOCKS_PER_SEC.
The conversion functions form a small graph:
time(NULL) ------> time_t <----- mktime(struct tm *) [local time -> time_t]
|
+---------------+---------------+
| |
localtime(&t) gmtime(&t) [time_t -> struct tm]
| |
+---------------+---------------+
|
strftime(...) [struct tm -> text]
difftime(end, start) returns the difference in seconds as a double. Use it rather than subtracting time_t values directly, since time_t's arithmetic properties are not guaranteed.
For measuring durations, time() has one-second resolution — far too coarse for anything but long operations. The alternatives:
| Clock | Measures | Resolution | Affected by |
|---|---|---|---|
time() | wall clock | 1 second | clock changes, NTP |
clock() | CPU time used by the process | CLOCKS_PER_SEC | not sleeping or blocking |
timespec_get (C11) | wall clock | nanoseconds (typically) | clock changes |
clock_gettime(CLOCK_MONOTONIC) (POSIX) | elapsed time | nanoseconds | nothing — never jumps |
The distinction between clock() and wall time is important: clock() measures CPU consumption, so a program that sleeps for ten seconds registers almost no clock() time, and a program using four threads can register more clock() time than wall time elapsed. For "how long did the user wait?", use a monotonic wall clock; for "how much CPU did this burn?", use clock().
Two portability warnings. localtime and gmtime return a pointer to a static buffer, so a second call overwrites the first result, and they are not thread-safe; POSIX provides localtime_r/gmtime_r that write into a caller-supplied struct tm, and C11 offers localtime_s. Similarly, ctime and asctime are static-buffer functions with fixed formats — use strftime instead, which gives you full control and cannot overflow because you pass the buffer size.
mktime has a useful side effect: it normalizes an out-of-range struct tm. Setting tm_mday = 32 and calling mktime yields the first of the next month, which makes date arithmetic easy. It also interprets the fields as local time and fills in tm_wday and tm_yday for you. Set tm_isdst = -1 to let it determine daylight saving itself.
Examples
#include <stdio.h>
#include <time.h>
#include <string.h>
int main(void)
{
/* ---- what time is it? ------------------------------------------- */
time_t now = time(NULL);
if (now == (time_t)-1) { perror("time"); return 1; }
struct tm local;
/* localtime_r (POSIX) writes into our struct: reentrant and safe.
Plain localtime() returns a pointer to a shared static buffer. */
localtime_r(&now, &local);
char buf[64];
/* strftime: you control the format and pass the buffer size. */
strftime(buf, sizeof buf, "%Y-%m-%d %H:%M:%S %Z", &local);
printf("local: %s\n", buf);
struct tm utc;
gmtime_r(&now, &utc);
strftime(buf, sizeof buf, "%Y-%m-%dT%H:%M:%SZ", &utc); /* ISO 8601 */
printf("utc: %s\n", buf);
/* ---- building a specific date: mind the two offsets -------------- */
struct tm when;
memset(&when, 0, sizeof when); /* zero every field first */
when.tm_year = 2026 - 1900; /* years SINCE 1900 */
when.tm_mon = 9 - 1; /* 0-based: 8 == September */
when.tm_mday = 9; /* 1-based, unlike the month */
when.tm_hour = 12;
when.tm_isdst = -1; /* let mktime work out DST */
time_t t = mktime(&when); /* also fills tm_wday / tm_yday */
strftime(buf, sizeof buf, "%A, %d %B %Y", &when);
printf("built: %s\n", buf);
/* mktime normalizes out-of-range values -- easy date arithmetic. */
when.tm_mday += 45; /* 45 days later, crossing months */
mktime(&when);
strftime(buf, sizeof buf, "%Y-%m-%d", &when);
printf("+45d: %s\n", buf);
printf("elapsed since epoch: %.0f s\n", difftime(t, (time_t)0));
return 0;
}
Measuring durations with the right clock:
#include <stdio.h>
#include <time.h>
static void workload(void)
{
volatile double x = 0; /* volatile: not optimized away */
for (long i = 0; i < 200000000L; i++) x += i;
}
int main(void)
{
/* CPU time consumed by this process -- excludes sleeping and blocking. */
clock_t c0 = clock();
/* Monotonic wall-clock elapsed time: never jumps backwards, immune to
NTP adjustments and daylight saving. This is what a stopwatch needs. */
struct timespec w0, w1;
clock_gettime(CLOCK_MONOTONIC, &w0); /* POSIX */
workload();
clock_gettime(CLOCK_MONOTONIC, &w1);
clock_t c1 = clock();
double cpu = (double)(c1 - c0) / CLOCKS_PER_SEC;
double wall = (w1.tv_sec - w0.tv_sec) + (w1.tv_nsec - w0.tv_nsec) / 1e9;
printf("cpu %.3f s\n", cpu);
printf("wall %.3f s\n", wall);
/* Sleeping would make wall >> cpu; four busy threads would make cpu > wall. */
/* Portable C11 alternative when POSIX is unavailable: */
struct timespec ts;
if (timespec_get(&ts, TIME_UTC) == TIME_UTC)
printf("c11 utc: %lld.%09ld\n", (long long)ts.tv_sec, ts.tv_nsec);
return 0;
}
Common strftime specifier | Produces |
|---|---|
%Y / %m / %d | 4-digit year / 2-digit month / 2-digit day |
%H / %M / %S | hour (00-23) / minute / second |
%A / %B | full weekday name / full month name |
%Z / %z | timezone name / +hhmm offset |
%j | day of year (001-366) |
%F / %T | %Y-%m-%d / %H:%M:%S |
Key Takeaways
time_tis a scalar calendar time;struct tmis broken-down, withtm_yearcounted from 1900 andtm_mon0-based whiletm_mdayis 1-based.- Use
clock()for CPU time consumed and a monotonic wall clock (clock_gettime(CLOCK_MONOTONIC)ortimespec_get) for elapsed time —time()'s one-second resolution is useless for benchmarking. localtimeandgmtimeshare a static buffer and are not thread-safe; prefer the_r(POSIX) or_s(C11) variants.- Format with
strftime, which is bounded and configurable, rather thanctime/asctime. mktimenormalizes out-of-range fields, which makes it the simplest tool for date arithmetic, and fills in the weekday and day-of-year.
🧪 Practice
- Print the current time in both local time and UTC in ISO 8601 form, and explain the offset you see.
- Write
days_between(int y1,int m1,int d1, int y2,int m2,int d2)usingmktimeanddifftime, and verify it across a daylight-saving boundary.- Time a function that calls
sleep(2)with bothclock()and a monotonic clock, and explain why the two results differ so much.- Interview-style: "Why should a stopwatch use a monotonic clock rather than the wall clock?" Hint: consider what happens when NTP corrects the system time mid-measurement.
limits.h and float.h
Theory
C's fundamental types have no fixed sizes. int is at least 16 bits, long at least 32; the exact width is chosen by the implementation. This is what lets C compile efficiently on an 8-bit microcontroller and a 64-bit server alike, and it means hardcoding a range or a width is a portability bug waiting for a new target.
<limits.h> and <float.h> are the escape hatch: they tell you, at compile time, what the current implementation's types can actually hold. Every value is a constant expression, so it works in #if, in array sizes, and in static_assert.
The integer limits from <limits.h>:
| Macro | Meaning | Minimum guaranteed magnitude |
|---|---|---|
CHAR_BIT | bits in a char | 8 |
SCHAR_MIN / SCHAR_MAX | signed char range | -127 / 127 |
UCHAR_MAX | unsigned char max | 255 |
CHAR_MIN / CHAR_MAX | plain char range | implementation-defined signedness |
SHRT_MIN / SHRT_MAX | short | ±32767 |
INT_MIN / INT_MAX | int | ±32767 |
LONG_MIN / LONG_MAX | long | ±2147483647 |
LLONG_MIN / LLONG_MAX | long long (C99) | ±9223372036854775807 |
UINT_MAX, ULONG_MAX, ... | unsigned maxima | — |
Two of these carry information beyond the obvious. CHAR_BIT is not guaranteed to be 8 — historic DSPs used 9, 16, or 32 — so the portable expression for the width of a type is sizeof(T) * CHAR_BIT, never sizeof(T) * 8. And CHAR_MIN tells you whether plain char is signed on this platform, which decides whether the <ctype.h> cast discussed earlier is merely good hygiene or actively load-bearing.
Note also the asymmetry: on two's-complement machines INT_MIN is -INT_MAX - 1, so -INT_MIN overflows and abs(INT_MIN) is undefined. Any code that negates a signed value must consider the minimum.
<float.h> describes the floating-point types, and the important macros are less obvious:
| Macro | Meaning |
|---|---|
FLT_MAX / DBL_MAX | largest finite value |
FLT_MIN / DBL_MIN | smallest normalized positive value (not the smallest positive) |
FLT_EPSILON / DBL_EPSILON | difference between 1.0 and the next representable value |
FLT_DIG / DBL_DIG | decimal digits guaranteed to round-trip (typically 6 / 15) |
FLT_MANT_DIG / DBL_MANT_DIG | bits of mantissa (typically 24 / 53) |
FLT_RADIX | the base of the exponent (2 in practice) |
DBL_EPSILON is not a general-purpose comparison tolerance. It is the gap between 1.0 and its successor. Near 1e10 the gap between representable doubles is far larger; near 1e-10 it is far smaller. This is why the scaled comparison from the math section multiplies epsilon by the operands' magnitude.
The practical use of all this is in two places: detecting overflow before it happens (comparing against INT_MAX rather than checking the result afterwards, since signed overflow is undefined and the check may be optimized away), and compile-time assertions about the platform, using C11's static_assert or an #if on these macros.
For code that needs an exact width rather than a guaranteed minimum, the answer is <stdint.h>: int32_t, uint64_t, and the INT32_MAX-style limits that come with them.
Examples
#include <stdio.h>
#include <limits.h>
#include <float.h>
#include <assert.h> /* static_assert in C11 */
int main(void)
{
/* ---- what is this machine actually like? ------------------------ */
printf("CHAR_BIT = %d\n", CHAR_BIT);
printf("char is %s\n", CHAR_MIN < 0 ? "SIGNED" : "unsigned");
printf("int : %d bits, [%d, %d]\n",
(int)(sizeof(int) * CHAR_BIT), INT_MIN, INT_MAX);
printf("long : %d bits, [%ld, %ld]\n",
(int)(sizeof(long) * CHAR_BIT), LONG_MIN, LONG_MAX);
printf("size_t max = %zu\n", (size_t)-1); /* unsigned wraps: legal */
/* ---- the asymmetry that bites ----------------------------------- */
printf("INT_MIN == -INT_MAX - 1 ? %s\n",
INT_MIN == -INT_MAX - 1 ? "yes" : "no");
/* -INT_MIN and abs(INT_MIN) are UNDEFINED -- there is no positive
counterpart to represent. Guard before negating. */
/* ---- floating point ---------------------------------------------- */
printf("DBL_DIG=%d DBL_EPSILON=%g DBL_MAX=%g DBL_MIN=%g\n",
DBL_DIG, DBL_EPSILON, DBL_MAX, DBL_MIN);
printf("1.0 + DBL_EPSILON != 1.0 ? %s\n",
(1.0 + DBL_EPSILON != 1.0) ? "yes" : "no");
printf("1e10 + DBL_EPSILON != 1e10 ? %s\n",
(1e10 + DBL_EPSILON != 1e10) ? "yes" : "NO -- epsilon is relative");
/* ---- compile-time platform assumptions --------------------------- */
static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
static_assert(sizeof(int) >= 4, "this code assumes a 32-bit int");
return 0;
}
Overflow checks that are actually correct:
#include <stdio.h>
#include <limits.h>
#include <stdbool.h>
/* WRONG: signed overflow is undefined, so the compiler may assume it
never happened and delete the check entirely.
int sum = a + b;
if (sum < a) return false; */
/* RIGHT: test the operands BEFORE performing the operation. */
static bool safe_add(int a, int b, int *out)
{
if (b > 0 && a > INT_MAX - b) return false; /* would exceed the max */
if (b < 0 && a < INT_MIN - b) return false; /* would exceed the min */
*out = a + b;
return true;
}
static bool safe_mul(int a, int b, int *out)
{
if (a > 0 && b > 0 && a > INT_MAX / b) return false;
if (a < 0 && b < 0 && a < INT_MAX / b) return false;
if (a > 0 && b < 0 && b < INT_MIN / a) return false;
if (a < 0 && b > 0 && a < INT_MIN / b) return false;
*out = a * b;
return true;
}
int main(void)
{
int r;
printf("%d %d\n", safe_add(INT_MAX, 1, &r), safe_add(2, 3, &r)); /* 0 1 */
printf("%d\n", safe_mul(INT_MAX / 2, 3, &r)); /* 0 */
/* GCC and Clang also offer checked builtins that compile to a
single instruction plus a flag test: */
if (__builtin_add_overflow(INT_MAX, 1, &r))
printf("builtin caught the overflow\n");
return 0;
}
WHY EPSILON IS RELATIVE
doubles are not evenly spaced -- the gap grows with the magnitude:
near 1.0 |--|--|--|--| gap = DBL_EPSILON ~ 2.2e-16
near 1e6 |----|----|----| gap ~ 1.2e-10
near 1e16 |--------|-------| gap ~ 2.0 (integers stop being exact)
So: fabs(a-b) < DBL_EPSILON only works near 1.0
fabs(a-b) < max(|a|,|b|) * rel works at every scale
Key Takeaways
- Type sizes are implementation-defined;
<limits.h>and<float.h>report the actual ranges as compile-time constants usable in#ifandstatic_assert. CHAR_BITis not guaranteed to be 8 — writesizeof(T) * CHAR_BITfor a type's width — andCHAR_MIN < 0tells you whether plaincharis signed.INT_MINhas no positive counterpart, so negating it or callingabson it is undefined behavior.- Check for signed overflow by testing the operands against
INT_MAX/INT_MINbefore the operation; testing the result afterwards is undefined and may be optimized away. DBL_EPSILONis the gap next to 1.0, not a universal tolerance — scale it by the operands' magnitude, and use<stdint.h>when you need exact widths.
🧪 Practice
- Write a program that prints the size, bit width, and range of every fundamental integer type on your machine, then compare the output with a 32-bit build (
gcc -m32).- Implement
safe_subandsafe_divin the style ofsafe_add, handling theINT_MIN / -1case explicitly.- Add
static_assertchecks to a small program requiring 8-bit bytes, a 64-bitlong long, and IEEE-754 doubles (__STDC_IEC_559__), then deliberately break one.- Interview-style: "Why is
if (a + b < a)not a valid overflow check for signed integers?" Hint: the check's premise is something the standard says cannot happen.
<a id="93-error-handling"></a>
9.3 Error Handling
C has no exceptions. Every failure must be represented as a value, checked by the caller, and propagated by hand — which makes error handling a design problem rather than a language feature. This section covers the conventions the standard library uses, the tools for reporting and aborting, the rarely-justified non-local jump, and how to design a propagation scheme that a whole codebase can follow.
Return Codes as Convention
Theory
Without exceptions, a C function that can fail has exactly one channel to say so: its return value, or a pointer parameter it writes through. There is no enforcement — nothing stops a caller from ignoring the result — so error handling in C is a matter of discipline supported by convention.
Unfortunately, the standard library itself uses at least four different conventions, and knowing which is which is a prerequisite for using it correctly:
| Convention | Success | Failure | Examples |
|---|---|---|---|
| Zero is success | 0 | non-zero | fclose, raise, atexit |
| Negative is failure | >= 0 | -1 or negative | POSIX open, read; printf |
NULL is failure | valid pointer | NULL | malloc, fopen, strchr |
| Sentinel value | any other value | EOF, (time_t)-1 | fgetc, time |
| Count of successes | expected count | fewer | fread, scanf |
The lesson is not that C is inconsistent (though it is) but that you must read the documentation for the return value of every library function you call, and that within your own code you should pick one convention and hold to it everywhere.
For new code, the most common choice is: 0 for success, negative for failure, with distinct negative codes for distinct causes. It composes well (if (do_thing() < 0) goto fail;), it leaves the positive range free for meaningful counts, and it matches POSIX. The alternative — an enum of named error codes — is more readable and more type-checkable, and is what most modern C libraries do.
The pattern for a function that must return both a value and a status is the out-parameter: the status is returned, the value is written through a pointer. This avoids the trap of trying to encode failure inside the value's own range, which fails as soon as every value is legitimate (a parsed integer can be -1).
Three practices make return-code discipline survivable:
- Check every call that can fail, at the call site. The cost of a missed check is usually an error that surfaces far from its cause.
- Never bury the assignment inside the condition when it hides the check — but do use
if ((f = fopen(...)) == NULL)where it keeps the check adjacent to the call. - Make ignoring a result loud. GCC and Clang support
__attribute__((warn_unused_result)), which turns a silently discarded status into a compiler warning.
The single-exit cleanup ladder using goto is the idiomatic C solution to the "many resources, many failure points" problem. It is one of the few places goto is not just acceptable but preferred, because the alternative — nested conditionals or duplicated cleanup on every return — is measurably more error-prone.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* A project-wide error enum: readable, greppable, and type-checked. */
typedef enum {
OK = 0,
ERR_INVALID = -1, /* caller passed something nonsensical */
ERR_NOT_FOUND = -2,
ERR_NO_MEMORY = -3,
ERR_IO = -4
} Status;
/* Map a code to text once, so every layer reports it identically. */
static const char *status_str(Status s)
{
switch (s) {
case OK: return "success";
case ERR_INVALID: return "invalid argument";
case ERR_NOT_FOUND: return "not found";
case ERR_NO_MEMORY: return "out of memory";
case ERR_IO: return "I/O error";
}
return "unknown error";
}
/* Value AND status: the status is returned, the value goes to an
out-parameter. Necessary whenever every value is a legal result. */
static Status read_file(const char *path, char **out, size_t *out_len)
{
if (!path || !out) return ERR_INVALID;
*out = NULL;
FILE *f = fopen(path, "rb");
if (!f) return ERR_NOT_FOUND;
Status st = ERR_IO; /* pessimistic default */
char *buf = NULL;
if (fseek(f, 0, SEEK_END) != 0) goto done;
long size = ftell(f);
if (size < 0) goto done;
rewind(f);
buf = malloc((size_t)size + 1);
if (!buf) { st = ERR_NO_MEMORY; goto done; }
if (fread(buf, 1, (size_t)size, f) != (size_t)size) goto done;
buf[size] = '\0';
*out = buf;
if (out_len) *out_len = (size_t)size;
buf = NULL; /* ownership transferred to caller */
st = OK;
done: /* ONE exit point, ONE cleanup path */
free(buf); /* free(NULL) is a no-op, so this is safe */
fclose(f);
return st;
}
int main(void)
{
char *text;
size_t len;
Status st = read_file("data.txt", &text, &len);
if (st != OK) { /* check at the call site, always */
fprintf(stderr, "read_file: %s\n", status_str(st));
return EXIT_FAILURE;
}
printf("%zu bytes\n", len);
free(text);
return EXIT_SUCCESS;
}
Making ignored results impossible to miss:
/* GCC/Clang: warn when a caller drops the status on the floor. */
#if defined(__GNUC__)
# define MUST_CHECK __attribute__((warn_unused_result))
#else
# define MUST_CHECK
#endif
MUST_CHECK int save_config(const char *path);
void caller(void)
{
save_config("app.conf"); /* warning: ignoring return value ... */
/* If ignoring really is intentional, say so explicitly: */
(void)save_config("app.conf");
}
THE CLEANUP LADDER
acquire A ---- fail ----> return err
|
acquire B ---- fail ----> goto free_A
|
acquire C ---- fail ----> goto free_B
|
work ---- fail ----> goto free_C
|
status = OK
|
v
free_C: release C <-- labels in REVERSE acquisition order,
free_B: release B so each failure enters at the right rung
free_A: release A and unwinds everything already acquired
return status
Each resource is released exactly once, on every path, with no
duplicated cleanup code and no nesting past two levels.
Key Takeaways
- C has no exceptions, so failure is a return value the caller must explicitly check; nothing in the language enforces it.
- The standard library mixes conventions — zero-is-success, negative-is-failure,
NULL, sentinels, and short counts — so read the documented return value of every function you call. - In your own code pick one convention (commonly an
enumof codes with0as success) and apply it everywhere. - When a function must return both a value and a status, return the status and write the value through an out-parameter.
- The
gotocleanup ladder with labels in reverse acquisition order gives single-exit resource release without nesting or duplication.
🧪 Practice
- Take a function that opens two files and allocates a buffer, written with nested
ifs, and rewrite it as agotocleanup ladder. Compare the number of cleanup statements.- Define a
Statusenum with five codes and astatus_strfunction, then convert a small program to use it consistently.- Add
warn_unused_resultto three functions in an existing program, compile with-Wall, and fix every warning it produces.- Interview-style: "How would you return both a parsed integer and a parse-failure indication from one function?" Hint: consider what happens if the valid range includes every possible sentinel.
errno and perror
Theory
Return codes tell you that something failed. errno tells you why. It is a modifiable lvalue of type int, declared in <errno.h>, that library functions set to a positive code identifying the specific cause — ENOENT for a missing file, EACCES for a permissions problem, ENOMEM for exhausted memory.
Four rules govern its use, and every one of them is a source of bugs when ignored.
1. Only check errno after a function has indicated failure. Successful calls are permitted to set errno to anything — and they do; a successful printf may leave ENOTTY behind from an internal isatty check. Checking errno to decide whether something failed is a false-positive machine. The correct order is: check the return value first; only then consult errno.
2. errno is sticky. Nothing clears it. It holds whatever the last failing call left there, possibly from minutes ago. When a function's own success does not set it — as with strtol, which only sets ERANGE on overflow — you must set errno = 0 before the call so that a nonzero value afterwards means something.
3. It is fragile. Any intervening library call can overwrite it. If you need the code, save it immediately:
if (fopen(path, "r") == NULL) {
int saved = errno; /* capture BEFORE anything else runs */
log_message("open failed"); /* this may clobber errno */
fprintf(stderr, "%s\n", strerror(saved));
}
4. It is per-thread. Despite looking like a global variable, errno is required to be thread-local — in practice it is a macro expanding to a function call such as (*__errno_location()). That is why you must never declare it yourself as extern int errno; include <errno.h>.
Two functions turn a code into text:
perror(const char *s)writess, a colon, a space, the message for the currenterrno, and a newline, tostderr. It is the fastest correct way to report a failure, and passing the filename or operation assmakes the message useful.strerror(int errnum)returns the message as a string, so you can put it in your own format. It returns a pointer to a possibly-static buffer that a later call may overwrite, and it is not required to be thread-safe; POSIX'sstrerror_rand C11'sstrerror_swrite into a caller-supplied buffer.
The C standard itself defines only three codes — EDOM, ERANGE, EILSEQ — and everything else comes from POSIX or the platform. The commonly encountered ones are worth recognizing: ENOENT (no such file), EACCES (permission denied), EEXIST (already exists), ENOMEM, EINVAL, EAGAIN/EWOULDBLOCK, EINTR (interrupted by a signal), EPIPE (write to a closed pipe).
EINTR deserves a note: a blocking call interrupted by a signal returns -1 with errno == EINTR, and the correct response is usually to retry, not to report failure.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
int main(void)
{
/* ---- the basic pattern: return value first, then errno ---------- */
FILE *f = fopen("/nonexistent/path", "r");
if (f == NULL) {
/* perror: fast and correct. The prefix should identify the target. */
perror("/nonexistent/path"); /* -> ...: No such file or directory */
/* strerror: same information, your own formatting. */
fprintf(stderr, "open failed (errno %d): %s\n", errno, strerror(errno));
/* Distinguishing causes is the whole point of having errno. */
switch (errno) {
case ENOENT: fprintf(stderr, "hint: check the path\n"); break;
case EACCES: fprintf(stderr, "hint: check permissions\n"); break;
case EMFILE: fprintf(stderr, "hint: too many open files\n"); break;
default: break;
}
}
/* ---- functions that only set errno on failure need a reset ------ */
errno = 0; /* REQUIRED before strtol */
long v = strtol("99999999999999999999", NULL, 10);
if (errno == ERANGE)
printf("overflowed, clamped to %ld\n", v);
/* ---- saving errno before it can be clobbered -------------------- */
if (fopen("also-missing", "r") == NULL) {
int saved = errno; /* capture immediately */
fprintf(stderr, "cleaning up...\n"); /* may overwrite errno */
fclose(stdout); /* so may this */
fprintf(stderr, "reason: %s\n", strerror(saved)); /* still correct */
}
return 0;
}
The anti-patterns, side by side:
#include <stdio.h>
#include <errno.h>
#include <string.h>
static void wrong(const char *path)
{
FILE *f = fopen(path, "r");
/* WRONG 1: testing errno instead of the return value. A previous
unrelated failure makes this report an error that never happened. */
if (errno != 0) { perror("open"); return; }
/* WRONG 2: reporting errno after other calls have run. */
if (f == NULL) {
fprintf(stderr, "could not open the file\n"); /* clobbers errno */
perror("open"); /* now meaningless */
}
if (f) fclose(f);
}
static void right(const char *path)
{
FILE *f = fopen(path, "r");
if (f == NULL) { /* the RETURN VALUE decides */
perror(path); /* errno consulted immediately */
return;
}
fclose(f);
}
THE errno PROTOCOL
1. errno = 0 (only for functions that set it solely on failure)
|
2. ret = library_call()
|
3. did the RETURN VALUE indicate failure?
| |
no yes
| |
errno is MEANINGLESS 4. saved = errno (immediately)
-- do not read it 5. report / branch on 'saved'
Never: if (errno) ... after a call whose result you did not check
Never: extern int errno; it is thread-local, include <errno.h>
Key Takeaways
- Check the return value to learn whether a call failed; consult
errnoonly afterwards, to learn why. - Successful calls may set
errnoarbitrarily, and nothing clears it — seterrno = 0before functions likestrtolthat signal only on failure. - Save
errnointo a local variable immediately, because any intervening library call can overwrite it. errnois thread-local and usually a macro; include<errno.h>and never declare it yourself.perroris the quickest correct report;strerrorgives the text for custom formatting, withstrerror_r/strerror_sfor thread-safe use.
🧪 Practice
- Write a program that attempts to open the same path for reading, for writing in a read-only directory, and as a directory, printing
errnoandstrerrorfor each. Identify all three codes.- Demonstrate the sticky-
errnobug: make a call fail, then make a successful call, then printerrno. Explain the value.- Write a wrapper
FILE *open_or_report(const char *path, const char *mode)that saveserrno, logs a formatted message, and returnsNULL, leavingerrnounchanged for the caller.- Interview-style: "Why is
if (errno != 0)an unreliable way to detect that a call failed?" Hint: think about what a successful call is permitted to do.
assert and NDEBUG
Theory
An assertion is an executable statement of something you believe is always true. assert(expr) from <assert.h> evaluates expr; if it is false, it prints the failing expression, the file, and the line to stderr and calls abort, terminating the program immediately.
The point is not error handling. It is the opposite: an assertion documents a condition that, if violated, means the program has a bug and continuing would only cause more damage further from the cause. That distinction determines when to use one:
| Situation | Tool | Why |
|---|---|---|
| A file might not exist | return code + errno | expected in normal operation |
| The user typed a letter where a number goes | validation + message | the environment, not your code |
malloc returned NULL | return code | possible on any run |
A function's caller passed NULL where the contract forbids it | assert | a bug in the caller |
An internal invariant broke (head != NULL inside a non-empty list) | assert | a bug in this module |
A switch reached a case the enum cannot produce | assert | a bug somewhere |
The rule of thumb: assert what must be true if the code is correct; handle what might be false even when the code is correct.
assert is a macro, and its behavior is controlled by NDEBUG. If NDEBUG is defined before including <assert.h>, assert(expr) expands to nothing at all — the expression is not evaluated. Release builds conventionally define it (-DNDEBUG, which is what CMake's Release configuration adds automatically).
That erasure creates the single most important rule about assertions: never put anything with a side effect inside one. assert(remove_item(list) == 0) works in debug and silently does nothing in release, deleting the operation itself. If you need the operation, do it outside and assert on the result.
Because the message includes the stringized expression, you can attach an explanation using the && idiom — a string literal is always true, so it does not change the condition but does appear in the output:
assert(ptr != NULL && "caller must supply a destination buffer");
C11 adds static_assert(expr, message), which is checked at compile time and costs nothing at run time. It is strictly better whenever the condition is a constant expression: platform assumptions, struct sizes, enum-to-array-length agreement.
Assertions are not a security mechanism. They are disabled in the builds that face real users, and abort on a triggered assertion is itself a denial of service. Validate untrusted input with real checks; assert only about your own code's internal consistency.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
/* Compile-time checks: no run-time cost, and they fail the BUILD. */
static_assert(sizeof(int) >= 4, "this code assumes at least a 32-bit int");
typedef enum { RED, GREEN, BLUE, COLOR_COUNT } Color;
static const char *color_names[] = { "red", "green", "blue" };
static_assert(sizeof color_names / sizeof *color_names == COLOR_COUNT,
"color_names is out of sync with the Color enum");
/* Preconditions: violations mean the CALLER has a bug. */
static void buffer_write(char *dst, size_t cap, size_t offset, const char *src)
{
assert(dst != NULL && "destination must not be NULL");
assert(src != NULL);
assert(offset < cap && "offset must be inside the buffer");
assert(strlen(src) + offset < cap && "write would overflow");
memcpy(dst + offset, src, strlen(src) + 1);
}
/* Invariants: violations mean THIS module has a bug. */
typedef struct { int *data; size_t len, cap; } Vec;
static void vec_check(const Vec *v)
{
assert(v != NULL);
assert(v->len <= v->cap); /* the core invariant */
assert(v->cap == 0 || v->data != NULL); /* capacity implies storage */
}
static int vec_pop(Vec *v)
{
vec_check(v);
assert(v->len > 0 && "pop from an empty vector");
return v->data[--v->len];
}
int main(void)
{
char buf[16];
buffer_write(buf, sizeof buf, 0, "hello");
printf("%s\n", buf);
/* Exhaustive switch: the default asserts that the enum cannot widen
without this code being updated. */
Color c = GREEN;
switch (c) {
case RED: case GREEN: case BLUE:
printf("%s\n", color_names[c]); break;
case COLOR_COUNT:
default:
assert(0 && "unhandled Color value");
}
return 0;
}
The side-effect trap and how NDEBUG changes everything:
#include <stdio.h>
#include <assert.h>
static int counter = 0;
static int next(void) { return ++counter; }
int main(void)
{
/* CATASTROPHIC: in a release build this line vanishes entirely,
so next() is never called and 'counter' never advances. */
assert(next() == 1);
/* CORRECT: perform the operation, then assert about its result. */
int v = next();
assert(v == 2);
printf("counter = %d\n", counter);
return 0;
}
# Debug build: assertions live.
$ gcc -g prog.c -o prog && ./prog
prog: prog.c:12: main: Assertion `v == 2' failed.
Aborted (core dumped)
# Release build: NDEBUG erases them, expression and all.
$ gcc -O2 -DNDEBUG prog.c -o prog && ./prog
counter = 1 # next() inside the assert was never called
assert | static_assert | error handling | |
|---|---|---|---|
| Checked | run time | compile time | run time |
| Cost in release | none (erased) | none | the check itself |
| On failure | abort | build error | your recovery path |
| Use for | internal invariants, contracts | platform and layout assumptions | anything the environment can cause |
| Safe with side effects | no | n/a | yes |
Key Takeaways
assertdocuments conditions that must hold if the code is correct; it is for bugs, not for expected failures such as missing files or bad user input.- Defining
NDEBUGerases assertions entirely, expression included — so an assertion must never contain a side effect the program depends on. - Append
&& "explanation"to make the abort message self-documenting without changing the condition. - Prefer C11's
static_assertwhenever the condition is a constant expression: it fails the build instead of the run, at zero cost. - Assertions are absent from the builds users run, so they are not input validation and not a security control.
🧪 Practice
- Add preconditions and an invariant check to a small stack implementation, then trigger each one and read the abort messages.
- Write an
assertcontaining a side effect, build with and without-DNDEBUG, and show that the program's behavior differs.- Use
static_assertto keep an enum and its name table in sync, then add an enum member without updating the table and observe the build failure.- Interview-style: "Should
assertbe used to check thatmallocsucceeded?" Hint: ask whether aNULLreturn means your code is wrong, and whether release builds should skip the check.
setjmp and longjmp
Theory
<setjmp.h> provides C's only non-local jump: a way to abandon the current call stack and resume execution at a point saved earlier in an outer function. It is the closest C comes to exceptions, and it is a sharp enough tool that most codebases correctly decide not to use it.
int setjmp(jmp_buf env); /* save the current execution context */
void longjmp(jmp_buf env, int val); /* jump back to it */
setjmp stores the machine's registers — the stack pointer, the program counter, the callee-saved registers — into env, and returns 0. Later, from anywhere deeper in the call stack, longjmp(env, val) restores that context: execution resumes as though setjmp had just returned again, this time with the value val. So one setjmp call site appears to return twice, and the return value distinguishes the two cases. (If val is 0, setjmp returns 1 instead, so the "jumped here" case is never mistaken for the initial one.)
NORMAL RETURN longjmp
main main
| setjmp -> 0 | setjmp -> 0 ......... <-+
v v |
parse parse |
| | |
v v |
token token |
| | longjmp(env, 1) --------+
v (unwinds three
returns up one frame at a time frames at once, running NO cleanup)
The dangers are specific, and each one has produced real bugs:
Local variables can be indeterminate after a jump. Only objects declared volatile, or of static storage duration, are guaranteed to hold their values across a longjmp. Everything else may have been in a register the jump restored to an older value. Variables in the setjmp frame that are modified between the setjmp and the longjmp must be volatile.
No cleanup runs. Frames are discarded, not unwound. Every malloc not yet freed, every FILE * not yet closed, every lock still held in the abandoned frames leaks. C++ destructors and Go's defer do this work; C does nothing.
The setjmp frame must still be alive. If the function that called setjmp has already returned, its stack frame is gone and longjmp into it is undefined behavior — typically an immediate, unexplainable crash.
Where setjmp may appear is restricted. The standard permits it only as the whole controlling expression of an if, while, switch, or as a full expression statement, optionally compared against a constant. int x = setjmp(env); is not portable.
Legitimate uses do exist: aborting a deeply recursive parser or interpreter from its innermost frame, recovering from a fatal condition in a long-running server, and implementing coroutine-like control in embedded systems. Even then, the discipline required — volatile on everything relevant, and centralized cleanup at the jump target — means a plain error-code return is usually clearer and always safer.
Examples
#include <stdio.h>
#include <setjmp.h>
#include <stdlib.h>
#include <string.h>
/* A recursive-descent parser that abandons the whole parse on a syntax
error, rather than threading an error code through every level. */
static jmp_buf parse_error;
static char error_msg[128];
static void fail(const char *msg)
{
/* Save the reason somewhere that survives the jump: a static object. */
snprintf(error_msg, sizeof error_msg, "%s", msg);
longjmp(parse_error, 1); /* unwind every frame at once */
}
static const char *cursor;
static int parse_number(void)
{
if (*cursor < '0' || *cursor > '9') fail("expected a digit");
int v = 0;
while (*cursor >= '0' && *cursor <= '9') v = v * 10 + (*cursor++ - '0');
return v;
}
static int parse_expr(void) /* number ('+' number)* */
{
int v = parse_number();
while (*cursor == '+') { cursor++; v += parse_number(); }
if (*cursor != '\0') fail("trailing characters");
return v;
}
int main(void)
{
const char *inputs[] = { "1+2+3", "4+", "7x" };
for (int i = 0; i < 3; i++) {
/* setjmp must be the whole controlling expression. */
if (setjmp(parse_error) == 0) { /* 0 == the initial call */
cursor = inputs[i];
printf("%-6s = %d\n", inputs[i], parse_expr());
} else { /* nonzero == arrived via longjmp */
printf("%-6s ! %s\n", inputs[i], error_msg);
}
}
return 0;
}
The volatile requirement, and the leak that comes free with every jump:
#include <stdio.h>
#include <setjmp.h>
#include <stdlib.h>
static jmp_buf env;
static void deep(void) { longjmp(env, 1); }
int main(void)
{
int plain = 0; /* value after a longjmp is INDETERMINATE */
volatile int reliable = 0; /* guaranteed to survive the jump */
/* This allocation is leaked by the jump: no frame is unwound and
nothing calls free(). The pointer itself may also be lost. */
char *volatile buf = malloc(64);
if (setjmp(env) == 0) {
plain = 42; /* may live only in a register */
reliable = 42; /* volatile: forced to memory */
deep(); /* never returns */
} else {
printf("plain = %d (indeterminate -- may print 0 or 42)\n", plain);
printf("reliable = %d (guaranteed 42)\n", reliable);
free(buf); /* cleanup must happen HERE, by hand */
}
return 0;
}
| Return codes | setjmp/longjmp | |
|---|---|---|
| Control flow | visible at each level | invisible jump |
| Cleanup | at each level, explicit | none — you must centralize it |
| Local variables | always valid | only volatile and static survive |
| Debuggability | straightforward | stack disappears |
| Cost | one comparison per level | a register save and restore |
| Suitable for | almost everything | deep recursion, interpreters, fatal recovery |
Key Takeaways
setjmpsaves an execution context and returns 0;longjmpresumes there, makingsetjmpappear to return a second time with a nonzero value.- Only
volatileand static-storage objects are guaranteed to retain their values across alongjmp; everything else is indeterminate. - No cleanup runs during the jump — allocations, open files, and held locks in the abandoned frames leak unless the jump target releases them.
- Jumping into a function that has already returned is undefined behavior, and
setjmpmay only appear as a whole controlling expression. - Prefer error codes; reserve non-local jumps for deep recursion, interpreters, and fatal-error recovery where threading a code through every frame is genuinely impractical.
🧪 Practice
- Write a recursive function 20 levels deep that
longjmps out of the innermost call, and print how many frames' cleanup code was skipped.- Modify a variable between
setjmpandlongjmpwithoutvolatile, compile at-O0and-O2, and compare the value printed after the jump.- Convert the parser example to use return codes instead, and compare the two versions on lines of code and on where cleanup lives.
- Interview-style: "Why must variables modified between
setjmpandlongjmpbe declaredvolatile?" Hint: think about what the optimizer is allowed to keep in a register, and whatlongjmprestores.
exit, atexit, and abort
Theory
A C program can end in several ways, and they differ in exactly which cleanup happens. Knowing the differences matters because the wrong choice loses data or, worse, produces no diagnostic.
Normal termination. Returning from main and calling exit(status) are equivalent — returning from main behaves as if exit were called with that value. exit performs, in order:
- Calls every function registered with
atexit, in reverse order of registration. - Flushes and closes all open streams.
- Removes files created by
tmpfile. - Returns control to the host environment with the status.
The status has exactly two portable values: EXIT_SUCCESS and EXIT_FAILURE from <stdlib.h>. 0 is also guaranteed to mean success. Anything else is implementation-defined — and on POSIX, only the low 8 bits reach the shell, so exit(256) is seen as 0.
atexit(void (*fn)(void)) registers a callback for normal termination. At least 32 registrations must be supported. The LIFO order matters: it lets a later-initialized subsystem tear down before the earlier one it depends on. The callbacks take no arguments and return nothing, so any state they need must be global or static. A callback that itself calls exit produces undefined behavior.
_Exit(status) (C99) terminates immediately: no atexit handlers, and whether streams are flushed is implementation-defined. Its real use is after fork, in the child, when running the parent's exit handlers and flushing the parent's buffers a second time would be wrong.
Abnormal termination. abort() raises SIGABRT, which by default terminates the process with an abnormal status and typically writes a core dump. It runs no atexit handlers and does not flush streams — which is precisely why buffered output disappears when a program aborts, and why stderr is unbuffered. abort is what a failed assert calls, and it is the right response to a corrupted invariant: stop now, preserve the state for a debugger, do not run cleanup code that might make things worse.
There is one more path: quick_exit (C11) with handlers registered by at_quick_exit, which runs a separate handler list and skips stream flushing. It exists for the case where the normal handlers might deadlock.
return from main / exit | _Exit | abort | |
|---|---|---|---|
atexit handlers | yes, LIFO | no | no |
| Streams flushed | yes | implementation-defined | no |
tmpfile files removed | yes | no | no |
| Core dump | no | no | typically yes |
| Status seen by the shell | your value | your value | signal (SIGABRT) |
| Use when | normal end, fatal but orderly | after fork in the child | invariant broken, want a core |
One design caution: exit from deep inside a library function is antisocial. It denies the application any chance to recover, log, or clean up its own state. Libraries should return errors; only the program's top level should decide to terminate.
Examples
#include <stdio.h>
#include <stdlib.h>
static FILE *logfile;
/* atexit handlers take no arguments and return nothing, so whatever
they need must be global or static. */
static void close_log(void)
{
if (logfile) { fputs("shutting down\n", logfile); fclose(logfile); }
puts("2. close_log ran");
}
static void release_cache(void) { puts("1. release_cache ran"); }
int main(void)
{
logfile = fopen("app.log", "a");
/* Registered first, so it runs LAST -- handlers are LIFO, which lets
later subsystems tear down before the earlier ones they depend on. */
if (atexit(close_log) != 0) { perror("atexit"); return EXIT_FAILURE; }
if (atexit(release_cache) != 0) { perror("atexit"); return EXIT_FAILURE; }
puts("0. main is finishing");
return EXIT_SUCCESS; /* identical to exit(EXIT_SUCCESS) */
/* Output order: 0. main, 1. release_cache, 2. close_log */
}
The three exits, and what each one loses:
#include <stdio.h>
#include <stdlib.h>
static void handler(void) { fprintf(stderr, "handler ran\n"); }
int main(int argc, char **argv)
{
atexit(handler);
printf("buffered output (no newline flush guarantee when redirected)");
if (argc < 2) {
fprintf(stderr, "usage: %s exit|_exit|abort\n", argv[0]);
return EXIT_FAILURE;
}
switch (argv[1][0]) {
case 'e': /* exit */
exit(EXIT_SUCCESS); /* handler runs, buffer flushed */
case '_': /* _exit */
_Exit(EXIT_SUCCESS); /* no handler; flushing unspecified */
default: /* abort */
abort(); /* no handler, NO flush, core dump */
}
}
$ ./prog exit > out.txt 2>&1 ; cat out.txt
handler ran
buffered output (no newline flush guarantee when redirected)
$ ./prog abort > out.txt 2>&1 ; echo "status=$?" ; cat out.txt
status=134 # 128 + SIGABRT(6)
# the buffered printf output is GONE
TERMINATION PATHS
return from main ---+
|
exit(status) -------+---> atexit handlers (LIFO)
flush + close all streams
remove tmpfile() files
-> host environment, status
_Exit(status) ----------> (nothing guaranteed)
-> host environment, status
use in the child after fork()
abort() ----------------> raise(SIGABRT)
NO handlers, NO flush
-> core dump, abnormal status
use when an invariant is already broken
Key Takeaways
- Returning from
mainand callingexitare equivalent: both runatexithandlers in reverse registration order, then flush and close every stream. - Use
EXIT_SUCCESSandEXIT_FAILURE; other values are implementation-defined, and POSIX only passes the low 8 bits to the shell. atexithandlers take no arguments, so their state must be global, and callingexitfrom inside one is undefined._Exitskips handlers and flushing — its purpose is the child process afterfork;abortskips both and produces a core dump for post-mortem debugging.- Libraries should return errors rather than call
exit; only the top level of a program should decide to terminate it.
🧪 Practice
- Register three
atexithandlers that print their names, and confirm the order. Then explain why LIFO is the useful order for teardown.- Write a program that buffers output and then aborts, and show that the output is lost. Fix it two ways: flush first, or write to
stderr.- Compare
exit(1),exit(256), andabort()by checking$?in the shell after each, and account for all three values.- Interview-style: "Why should a library function never call
exit?" Hint: consider who owns the decision to terminate, and what the caller loses.
Designing Error Propagation
Theory
The previous topics gave you mechanisms. This one is about the design decision that ties them together: when an error occurs deep in a call stack, how does it reach the code that can actually do something about it, carrying enough information to be useful?
Start by classifying failures, because different classes deserve different mechanisms:
| Class | Example | Mechanism |
|---|---|---|
| Expected | file missing, connection refused, bad user input | return code, propagated |
| Exceptional but recoverable | out of memory, disk full | return code, usually propagated to the top |
| Programming error | null pointer where the contract forbids it, broken invariant | assert / abort |
| Unrecoverable environment failure | cannot map the address space at startup | log and exit at the top level |
Mixing these is the most common design mistake: aborting on a missing file (denying the caller any recovery), or returning a code for a violated internal invariant (letting corrupted state spread).
For the propagated classes, a few principles produce codebases that stay debuggable:
One convention per project. Whatever you choose — enum Status, negative errno-style codes, bool plus an out-parameter — apply it uniformly. Mixed conventions mean every call site requires looking up which one applies, and the lookups get skipped.
Errors must carry context. ERR_IO tells you nothing about which file. Two workable approaches: a richer error object (code plus a message buffer plus perhaps a source location), or logging the context at the point of failure while returning only a code. The second keeps signatures simple and is enough for most programs.
Handle at the level that has the information to decide. A parser cannot know whether a malformed line should abort the program, be skipped, or be reported to the user — so it should not decide. It reports; the caller with the policy decides. Errors get handled where policy lives, usually near the top.
Do not lose the original cause. When a low-level error is translated into a higher-level one, the specific reason must survive somewhere, or you get "operation failed" with nothing to act on.
Every function must leave a consistent state on failure. This is the guarantee that makes recovery possible. The strongest useful form is: on failure, the function has changed nothing — allocations released, partial writes rolled back, out-parameters untouched. The goto cleanup ladder is how you achieve it in practice.
Fail fast on the paths where continuing is worse. Detecting a broken invariant and continuing turns one bug into a corrupted data structure that fails somewhere unrelated, hours later.
The result of applying these is a codebase where every function either succeeds and produces its output, or fails cleanly and says why — and where the messages a user sees name the actual file, the actual line, and the actual reason.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h> /* err_make takes printf-style arguments */
#include <errno.h>
/* ---------------------------------------------------------------------
A richer error type: a code the caller can branch on, plus context a
human can act on. Small enough to return by value.
--------------------------------------------------------------------- */
typedef enum { E_OK = 0, E_IO, E_PARSE, E_NOMEM, E_RANGE } ErrCode;
typedef struct {
ErrCode code;
char detail[192]; /* what failed, and where */
} Error;
static const Error NO_ERROR = { E_OK, "" };
/* Build an error with printf-style context, at the point of failure. */
static Error err_make(ErrCode code, const char *fmt, ...)
{
Error e; e.code = code;
va_list ap; va_start(ap, fmt);
vsnprintf(e.detail, sizeof e.detail, fmt, ap);
va_end(ap);
return e;
}
/* Wrap a lower-level error without losing its message. */
static Error err_wrap(Error inner, const char *context)
{
Error e; e.code = inner.code;
snprintf(e.detail, sizeof e.detail, "%s: %s", context, inner.detail);
return e;
}
/* ---------------------------------------------------------------------
Layer 1: parsing. Reports; does not decide policy.
--------------------------------------------------------------------- */
typedef struct { char key[32]; long value; } Setting;
static Error parse_line(const char *line, unsigned lineno, Setting *out)
{
const char *eq = strchr(line, '=');
if (!eq)
return err_make(E_PARSE, "line %u: missing '='", lineno);
size_t klen = (size_t)(eq - line);
if (klen == 0 || klen >= sizeof out->key)
return err_make(E_PARSE, "line %u: key length %zu out of range",
lineno, klen);
memcpy(out->key, line, klen);
out->key[klen] = '\0';
errno = 0;
char *end;
out->value = strtol(eq + 1, &end, 0);
if (end == eq + 1)
return err_make(E_PARSE, "line %u: '%s' has no numeric value",
lineno, out->key);
if (errno == ERANGE)
return err_make(E_RANGE, "line %u: value for '%s' out of range",
lineno, out->key);
return NO_ERROR;
}
/* ---------------------------------------------------------------------
Layer 2: loading. Adds context, owns cleanup, still no policy.
Note the failure guarantee: on error, *out is untouched and nothing leaks.
--------------------------------------------------------------------- */
static Error load_config(const char *path, Setting **out, size_t *out_n)
{
FILE *f = fopen(path, "r");
Setting *arr = NULL;
Error err = NO_ERROR;
if (!f)
return err_make(E_IO, "%s: %s", path, strerror(errno));
size_t cap = 16, n = 0;
arr = malloc(cap * sizeof *arr);
if (!arr) { err = err_make(E_NOMEM, "%s: allocating settings", path); goto done; }
char line[256];
for (unsigned lineno = 1; fgets(line, sizeof line, f); lineno++) {
line[strcspn(line, "\r\n")] = '\0'; /* strip the newline */
if (line[0] == '\0' || line[0] == '#') continue;
if (n == cap) {
Setting *bigger = realloc(arr, cap * 2 * sizeof *arr);
if (!bigger) { err = err_make(E_NOMEM, "%s: growing to %zu",
path, cap * 2); goto done; }
arr = bigger; cap *= 2;
}
err = parse_line(line, lineno, &arr[n]);
if (err.code != E_OK) { /* propagate with context */
err = err_wrap(err, path);
goto done;
}
n++;
}
if (ferror(f)) { err = err_make(E_IO, "%s: read error", path); goto done; }
*out = arr; *out_n = n; /* commit only on success */
arr = NULL; /* ownership transferred */
done:
free(arr); /* NULL on success: no-op */
fclose(f);
return err;
}
/* ---------------------------------------------------------------------
Layer 3: the top. This is where POLICY lives -- and the only place
that decides to terminate.
--------------------------------------------------------------------- */
int main(int argc, char **argv)
{
if (argc != 2) {
fprintf(stderr, "usage: %s <config>\n", argv[0]);
return EXIT_FAILURE;
}
Setting *settings;
size_t count;
Error err = load_config(argv[1], &settings, &count);
switch (err.code) {
case E_OK:
break;
case E_IO: /* recoverable: fall back to defaults */
fprintf(stderr, "warning: %s -- using defaults\n", err.detail);
return EXIT_SUCCESS;
case E_PARSE:
case E_RANGE: /* the user can fix this: be specific */
fprintf(stderr, "config error: %s\n", err.detail);
return EXIT_FAILURE;
case E_NOMEM: /* nothing to do but stop */
fprintf(stderr, "fatal: %s\n", err.detail);
return EXIT_FAILURE;
}
for (size_t i = 0; i < count; i++)
printf("%s = %ld\n", settings[i].key, settings[i].value);
free(settings);
return EXIT_SUCCESS;
}
WHERE ERRORS ARE DETECTED, PROPAGATED, AND HANDLED
main() <-- HANDLED here: policy lives at the top
| decides: warn and continue / report and exit / retry
|
+-- load_config() <-- PROPAGATED, with context added
| | adds the filename; owns cleanup; guarantees no partial state
| |
| +-- parse_line() <-- DETECTED here: knows exactly what is wrong
| reports "line 7: 'timeout' has no numeric value"
| decides NOTHING about what should happen next
Each layer adds what only it knows:
parse_line knows the line number and the offending token
load_config knows the filename
main knows whether a bad config should be fatal
A checklist for auditing a function's error behavior:
| Question | If the answer is no |
|---|---|
| Does every failure path return an error? | silent corruption |
| Does every failure path release everything it acquired? | leaks under error conditions |
| Are out-parameters left untouched on failure? | the caller uses garbage |
| Does the error say which object failed? | undebuggable in production |
| Is the original cause still recoverable from the message? | "operation failed" support tickets |
| Are internal invariants asserted rather than returned? | corrupted state spreads |
Key Takeaways
- Classify each failure as expected, exceptional, a programming error, or an environment failure — each class gets a different mechanism, and mixing them is the core design mistake.
- Pick one error convention per project and apply it everywhere; mixed conventions cause skipped checks.
- Errors are detected where the detail is known, propagated with added context, and handled where the policy lives — usually the top level.
- Guarantee that a failing function leaves nothing behind: no leaks, no partial writes, and out-parameters untouched, which the
gotocleanup ladder makes routine. - Assert internal invariants and abort on them, but return errors for anything the environment can cause, so callers keep the ability to recover.
🧪 Practice
- Take a function with three failure points that currently returns
-1for all of them, and give it distinct codes plus a context message for each.- Audit an existing function against the checklist above and fix every row where the answer is no.
- Write a two-layer program where the inner layer detects an error and the outer layer adds context, then produce an error message naming both the file and the line.
- Interview-style: "Where in a call stack should an error be handled?" Hint: think about which layer holds the information needed to choose between retrying, falling back, and giving up.