Arrays, Strings, and <string.h> Foot-Cannons

C strings are not a type. They are a convention: a pointer to a byte buffer terminated by a '\0'. The standard library has a whole family of functions to manipulate them, and about half of those functions are unsafe by default — they were designed in the 1970s for programs that fit in 64 KB of RAM and had no adversarial input. This file makes you fluent in what’s safe, what’s not, and what to reach for instead.

The ML-engineer angle: you’ll be parsing config files, tokenizing input, formatting output, and shuttling strings between C and Python (via ctypes or CFFI). Every one of these operations has a safe form and an unsafe form, and the compiler will happily let you write the unsafe form.

Arrays Decay to Pointers (the single most confusing C fact)

An array is not a pointer. But in almost every expression, an array decays to a pointer to its first element. Function parameters especially: writing void f(int a[10]) is exactly the same as void f(int *a). The [10] is documentation, not a check.

void f(int a[10]) { printf("%zu\n", sizeof(a)); }  // prints 8, not 40.
                                                    // a is a pointer, sizeof(ptr)=8.

int main(void) {
    int arr[10];
    printf("%zu\n", sizeof(arr));   // prints 40 (10 * 4 bytes). Real array.
    f(arr);                         // arr decays to int* here.
}

Consequences you must internalize:

  • Inside a function that takes T arr[], you cannot get the length from sizeof. Pass a size_t n alongside every array parameter.

  • &arr (address of array) is a different type from arr (decayed pointer). &arr has type int(*)[10], useful in rare cases; arr has type int*. Both point to the same memory but with different pointer arithmetic.

  • You cannot copy an array with =. int a[10] = b; is a compile error. Use memcpy or a loop.

C99 gave us VLAs (variable-length arrays: int arr[n]; where n is a runtime value). They’re allocated on the stack, so a large n blows your stack. C11 made VLAs optional; C23 keeps them optional. Do not use VLAs. Allocate with malloc for runtime-sized buffers. This is Linux kernel policy, and it’s the pragmatic default.

The <string.h> Foot-Cannons

Here are the standard functions, ranked by how likely they are to hurt you:

Do Not Use

Function

Why it’s dangerous

gets(buf)

No bounds check. Removed from C in C11. If you see it in old code, delete on sight.

strcpy(dst, src)

No length limit. Overflows if src is longer than dst.

strcat(dst, src)

Same problem, plus requires walking dst first. Slow and unsafe.

sprintf(buf, fmt, ...)

No bounds check. Same as strcpy but with formatting.

scanf("%s", buf)

No width limit → overflow. Use scanf("%99s", buf) for a 100-byte buf.

Use With Care (they have footguns)

Function

Footgun

strncpy(dst, src, n)

Does not guarantee null-termination. If strlen(src) >= n, dst is not null-terminated. Also pads with \0 up to n if shorter — wastes cycles for long buffers.

strncat(dst, src, n)

The n is max chars to append, not total size of dst. Off-by-one waiting to happen.

strtok(str, delim)

Not thread-safe — uses a static internal state. Also destructively modifies the input string.

atoi(s), atol(s)

No error signaling. atoi("garbage") returns 0 with no way to distinguish from atoi("0"). Use strtol with an errno check.

Safe Defaults

Function

Why it’s the good one

snprintf(buf, n, fmt, ...)

Bounded. Always null-terminates (when n > 0). Returns what would have been written, so you can detect truncation with if (r >= (int)n) truncated. This is your default for any string formatting.

memcpy(dst, src, n)

Simple, fast, no null-termination concerns. Requires you to know sizes.

memmove(dst, src, n)

Like memcpy but safe when regions overlap.

strtok_r(str, delim, &saveptr)

Reentrant version of strtok. Thread-safe. POSIX; also in C11’s optional Annex K equivalents.

strtol(s, &end, base)

Robust integer parsing with error detection via errno and end pointer.

strlcpy / strlcat (BSD, glibc 2.38+, macOS)

Bounded, always null-terminates, returns intended length. Not in the C standard but ubiquitous now.

The One Idiom That Replaces Half of <string.h>

Whenever you’d reach for strcpy, strcat, or sprintf, reach for snprintf instead:

char path[PATH_MAX];
int r = snprintf(path, sizeof(path), "%s/%s.log", dir, name);
if (r < 0 || (size_t)r >= sizeof(path)) {
    // handle error or truncation
    return -1;
}
// path is guaranteed null-terminated here.

Memorize this pattern. It’s your workhorse for string building. Ninety percent of the buffer-overflow CVEs in C code from the last 30 years would not exist if this had been the default.

strncpy — Why It’s the Wrong Tool for Almost Every Job

People reach for strncpy thinking “the n makes it safe.” It does not do what you think:

char dst[8];
strncpy(dst, "hello world", sizeof(dst));
// dst now contains 'h','e','l','l','o',' ','w','o' — NO null terminator.
// If you then printf("%s", dst) → reads past the buffer → UB.

And if the source is shorter:

char dst[1024];
strncpy(dst, "hi", sizeof(dst));
// dst[0]='h', dst[1]='i', dst[2..1023] all set to '\0'.
// You just wrote 1024 bytes for a 2-char string.

strncpy was designed for a specific use case (fixed-size records in old Unix, padded with nulls). It is not a safe strcpy. Reach for snprintf or strlcpy instead.

strtok_r — The Reentrant Tokenizer

strtok fails in threaded code because it stashes the parse state in a static variable. strtok_r (POSIX; equivalent strtok_s in C11 Annex K if your compiler supports it) takes an explicit state pointer:

char text[] = "one,two,three";
char *saveptr = NULL;
char *tok = strtok_r(text, ",", &saveptr);
while (tok) {
    puts(tok);
    tok = strtok_r(NULL, ",", &saveptr);
}
// Note: strtok_r modifies `text` in place, replacing delimiters with '\0'.
// The input must be mutable — it cannot be a string literal.

When writing your own tokenizer for anything nontrivial, seriously consider not using strtok at all — write a small strsplit that returns a char ** array. You’ll implement one in Project 1 of this phase.

What Most People Get Wrong About Strings

They conflate “has an n” with “safe.” The C99-era strn* family is not the safe family; strncpy and strncat both have footguns worse than their unbounded siblings in some cases. The real safe family is: snprintf, memcpy/memmove, strlcpy/strlcat where available, and hand-rolled bounded copies where they aren’t. If your project can require POSIX, strtok_r, strdup, getline (bounded stdin reader) are your friends.

The second thing: they treat the buffer size and the string length as the same thing. They are not. sizeof(buf) includes the null terminator; strlen(s) does not. Off-by-one errors here account for a lot of embarrassing crashes. Rule: snprintf takes the buffer size (including null), returns the string length (excluding null). Memorize the asymmetry.

Exercises

  1. Write size_t safe_strlcpy(char *dst, const char *src, size_t dsz); from scratch. Test against edge cases: empty src, dsz==0, dsz==1, src longer than dsz.

  2. Rewrite a use of sprintf in an existing codebase (your own or an open-source project) as snprintf with truncation handling.

  3. Write a char **strsplit(const char *s, char delim, size_t *out_n); that allocates. Free every allocation. Run under ASan. Do not use strtok.

  4. Read the man page for snprintf end to end. man 3 snprintf. Yes, the whole thing.


Return to README.md · Next: 03_structs_unions_bitfields.md