7. Strings in C: buffers, ownership, and why gets() was removed
7.1 A "string" is a convention, not a type
#include <string.h>
#include <stdio.h>
int main(void) {
char greeting[20] = "hello"; // 6 bytes used: 'h''e''l''l''o''\0', 14 unused
printf("%zu %zu\n", strlen(greeting), sizeof(greeting)); // 5 20
strcat(greeting, ", world"); // caller's job to ensure the buffer is big enough
printf("%s\n", greeting);
return 0;
}
strlen walks memory until it finds '\0' — there is no length prefix,
no bounds. Every buffer-related CVE in C's history traces back to this one
design decision, made in 1972 to save a byte per string on a PDP-11.
7.2 The functions to prefer, and why
| Avoid | Because | Prefer |
|---|---|---|
gets() |
No way to bound the read; removed from the C11 standard entirely | fgets(buf, sizeof buf, stdin) |
strcpy(dst, src) |
No bound on src's length vs. dst's capacity |
strlcpy (BSD/macOS) or snprintf(dst, n, "%s", src) |
strcat(dst, src) |
Same problem, compounded (walks dst first) |
strlcat, or track length yourself |
sprintf(buf, ...) |
No bound on output length | snprintf(buf, sizeof buf, ...) — always |
scanf("%s", buf) |
Unbounded read into buf |
scanf("%19s", buf) (explicit width) or fgets + parse |
#include <stdio.h>
#include <string.h>
int main(void) {
char name[32];
fgets(name, sizeof name, stdin); // safe: stops at 31 chars + '\0'
name[strcspn(name, "\n")] = '\0'; // fgets keeps the trailing newline — strip it
char msg[64];
snprintf(msg, sizeof msg, "hello, %s!", name); // always bounded, always '\0'-terminated
printf("%s\n", msg);
return 0;
}
War story. strcpy/strcat/sprintf without bounds checking are
exactly the family of functions behind decades of stack-buffer-overflow
CVEs, starting with the 1988 Morris Worm (which exploited gets() in
fingerd) through to modern IoT firmware CVEs today. -D_FORTIFY_SOURCE=2
(Section 30) adds runtime bounds checks to these calls when the compiler
can determine buffer sizes, but the real fix is: default to the bounded
variant by default.
7.3 C23: better ergonomics, same underlying model
C23 doesn't change the string model (still no built-in string type), but
it removes friction: nullptr replaces the type-unsafe NULL macro,
typeof lets you write generic string helpers without _Generic
boilerplate, and [[nodiscard]] on a function like a custom
safe_strdup forces callers to check the returned pointer for
allocation failure.
// C23
#include <stdlib.h>
#include <string.h>
[[nodiscard]] char *safe_strdup(const char *s) {
size_t n = strlen(s) + 1;
char *copy = malloc(n);
if (copy) memcpy(copy, s, n);
return copy;
}
🔎 Checkpoint: strings and buffers
1. Predict what happens — will these two both "work," and if not, why does one look fine while quietly being wrong?
#include <string.h>
int main(void) {
char buf6[6];
strcpy(buf6, "hello"); // "hello" is 5 letters + '\0' = 6 bytes
char buf5[5];
strcpy(buf5, "hello"); // same source string, smaller destination
return 0;
}
Answer
The first is correct: "hello" needs exactly 6 bytes (h e l l o \0)
and buf6 has exactly 6. The second overflows buf5 by one byte.
Try compiling this with this guide's own recommended flags from §2.2
(-Wall -Wextra -Werror) and something interesting happens: it
doesn't compile at all. On a modern hardened toolchain (Apple's Clang,
or GCC/Clang with glibc's _FORTIFY_SOURCE — §30), the compiler can see
at compile time that a 6-byte literal can't fit in a 5-byte array, warns
-Wfortify-source, and -Werror turns that into a hard build failure
before the buggy code ever runs. Drop -Werror and it becomes just a
warning — the code compiles, but strcpy has been silently rewritten
into a checked __strcpy_chk call that aborts the process
immediately at runtime with a diagnostic, instead of letting the
overflow happen. Either way, this exact overflow never gets a chance to
silently corrupt memory — but only because the compiler could prove it
at compile time. The classic "silently corrupts whatever memory sits
after buf5, might even look fine in testing" danger from §29.1 is
still completely real the moment the source string comes from somewhere
the compiler can't see in advance, like user input:
strcpy(buf5, some_variable) — no compiler on earth can prove that one
safe or unsafe ahead of time, so no such protection kicks in. Same bug,
same fix (snprintf/strlcpy, §7.2) — this compile-time special case
just happens to be caught for you,
where a runtime-determined one still isn't.
2. Spot the bug:
#include <stdio.h>
#include <string.h>
int main(void) {
char line[256];
fgets(line, sizeof line, stdin);
if (strcmp(line, "quit") == 0) {
printf("goodbye\n");
}
return 0;
}
The user types quit and presses enter, but "goodbye" never prints. Why?
Answer
fgets keeps the trailing newline in the buffer (§7.2's fgets note) —
line actually holds "quit\n", not "quit", so strcmp never
matches. The fix is line[strcspn(line, "\n")] = '\0'; right after the
fgets call, exactly as shown in §7.2's snprintf example.
3. Write it yourself: write void safe_concat(char *dst, size_t dst_cap, const char *a, const char *b) that writes a followed by b
into dst, truncating safely if dst is too small — never overflowing,
no matter how long a/b are.
One correct answer
#include <stdio.h>
void safe_concat(char *dst, size_t dst_cap, const char *a, const char *b) {
snprintf(dst, dst_cap, "%s%s", a, b); // snprintf always bounds to dst_cap, truncating cleanly
}
int main(void) {
char buf[16];
safe_concat(buf, sizeof buf, "hello, ", "world! this part gets truncated safely");
printf("%s\n", buf);
return 0;
}
This is the entire lesson of §7.2 in one line: reach for snprintf by
default any time you're building a string into a fixed buffer, instead
of reasoning about strcat/strcpy's lengths by hand.
4. Conceptual: why can't you use sizeof to get the length of a
char * variable the way you can on a char[] array — and what's the
one function that does give you that length?
Answer
sizeof on a pointer variable gives the pointer's own size (8 bytes on
a 64-bit machine) — the same array-decay fact from §6.1's checkpoint,
not the length of what it points to. C has no way to recover "how many
bytes were allocated here" from a bare pointer; the only way to know a
C string's length is strlen, which walks memory until it finds the
'\0' terminator (§7.1) — an O(n) scan, not a stored value, unlike
Redis's sds strings in §34, which keep a length field precisely to
avoid this.