Frontend Labs
⋯

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.


Generated from real-life-c-guide.md · 8 Oct 2026 16:36