C Strings: Null Termination, Standard Functions, and Buffer Safety

Understand C strings as null-terminated character arrays, use the core <string.h> functions correctly (strlen, strcpy, strncpy, strcat, strcmp, strchr, strstr), recognize classic buffer-overflow risks, and distinguish mutable string storage from read-only string literals.

C has no dedicated string type — a 'string' in C is simply a convention: an array of char values that ends with a special sentinel byte, '\0' (the null character, value zero), marking where the text stops. Every standard string function relies on that convention being upheld correctly, and every classic C string bug — from a missing terminator to a buffer overflow — comes from that convention being violated somewhere. This lesson covers how C strings are actually laid out in memory, the standard library functions used to manipulate them, and the real, historically significant safety hazards involved — hazards that Objective-C's NSString was built specifically to eliminate.

C Strings as Null-Terminated Byte Sequences

A C string is a char array whose contents are text, followed immediately by a '\0' byte (numeric value 0, called the 'null terminator' or 'NUL character' — not to be confused with the NULL pointer, an unrelated concept covered in the next lesson). There is no separate length field stored anywhere; the only way any string function knows where the text ends is by scanning forward byte by byte until it finds that '\0'. This has a direct, important consequence: the character array backing a string of length N must actually be at least N+1 bytes long, to leave room for the terminator.

Declaring char name[] = "SampleApp"; creates a 10-element array: the 9 characters of "SampleApp" plus one trailing '\0' the compiler adds automatically. You can also build a string manually, character by character: char name[10] = {'S','a','m','p','l','e','A','p','p','\0'}; produces an identical array — the string literal syntax is just convenient shorthand for exactly this.

Because strings are just arrays under this convention, everything from the previous lesson about arrays applies directly: no automatic bounds checking, decay to a char * when passed to a function, and the requirement to track size (or rely on the terminator) yourself. Every C string function that takes a plain char * — as opposed to one that also takes an explicit length — trusts that the terminator is present and correctly placed; if it isn't, the function keeps reading (and potentially writing) past the end of the actual buffer into whatever memory happens to follow.

• A C string is a char array ending in '\0' (the null terminator), not a distinct type. • char name[] = "SampleApp"; allocates 10 bytes: 9 characters + 1 terminator, added automatically. • The backing array for an N-character string must be at least N+1 bytes to hold the terminator. • String functions locate the end of a string by scanning for '\0' — there is no stored length.

Visualizing Memory: SampleApp

It helps to see exactly what char name[] = "SampleApp"; puts in memory: nine ordinary character bytes, laid out contiguously, immediately followed by one zero byte. Every function in <string.h> that takes a plain string argument walks forward from the given address one byte at a time and stops the instant it reads that zero byte — it never needs to be told how long the string is, because the terminator itself is the stop signal.

This is also exactly why an empty string, char empty[] = "";, still occupies one byte of memory, not zero: it is a single '\0' and nothing else. strlen(empty) correctly returns 0 (zero characters of actual text), even though the array itself takes up 1 byte of storage.

• The terminator is data, just like any other byte — it occupies real, addressable memory. • An 'empty' C string is still a 1-byte array containing only '\0'. • strlen() counts characters before the terminator, not the array's total storage size.

Core <string.h> Functions

strlen(const char *s) scans forward from s until it finds '\0' and returns the count of bytes before it — the string's length, not counting the terminator. strcpy(char *dest, const char *src) copies src, including its terminator, into dest, and returns dest; critically, strcpy has absolutely no idea how big the dest buffer actually is, and will happily write past its end if src is longer than dest can hold. strncpy(char *dest, const char *src, size_t n) is the 'safer' cousin: it copies at most n bytes, which bounds the write — but it comes with its own gotcha, covered below, around when it does and doesn't null-terminate the result.

strcat(char *dest, const char *src) appends src onto the end of whatever is already in dest (finding the existing terminator first, then overwriting it and everything after with src plus a fresh terminator) — it has the exact same unchecked-buffer-size danger as strcpy. strcmp(const char *a, const char *b) compares two strings lexicographically and returns 0 if they're equal, a negative number if a sorts before b, and a positive number if a sorts after b — note this means you must check strcmp(a, b) == 0 for equality, not just treat the return value as a boolean, a very common beginner mistake.

strchr(const char *s, int c) searches s for the first occurrence of character c and returns a pointer to it (or NULL if not found). strstr(const char *haystack, const char *needle) searches for the first occurrence of an entire substring needle inside haystack, returning a pointer to where it starts (or NULL if absent) — the string equivalent of a substring search.

• strlen(s) — length in characters, not counting the terminator. • strcpy(dest, src) — copies including the terminator; performs NO bounds checking on dest's capacity. • strncpy(dest, src, n) — copies at most n bytes; may leave dest unterminated if src is n bytes or longer. • strcat(dest, src) — appends src onto dest; same unchecked-buffer danger as strcpy. • strcmp(a, b) — returns 0 for equal, negative if a<b, positive if a>b; never treat the result as a plain boolean. • strchr(s, c) / strstr(haystack, needle) — find a character / substring, return a pointer to it or NULL.

Buffer Overflow: The Classic strcpy Scenario

Consider char buffer[8]; strcpy(buffer, "This string is far too long");. The source string is 28 characters plus a terminator — 29 bytes — but buffer only has room for 8. strcpy does not know or check this; it copies all 29 bytes anyway, writing 21 bytes past the end of buffer into whatever memory happens to sit adjacent to it on the stack. Depending on the function's stack layout, this can silently corrupt other local variables, or — in the worst historical case — overwrite the function's saved return address, which an attacker can exploit to redirect program execution to injected code. This exact pattern is the textbook definition of a stack buffer overflow.

This is not a hypothetical: unchecked strcpy/strcat calls on attacker-influenced input (form fields, network data, file contents) are one of the most exploited vulnerability classes in the history of C and C++ software, responsible for decades of CVEs including some of the internet's most consequential early security incidents. The fix is to always use a length-bounded copy and to always know your destination buffer's true capacity: strncpy(buffer, src, sizeof(buffer) - 1); buffer[sizeof(buffer) - 1] = '\0'; truncates safely instead of overflowing, at the cost of losing data beyond the buffer's capacity.

Note the extra manual buffer[sizeof(buffer)-1] = '\0'; step: strncpy does *not* guarantee a null-terminated result if src is exactly n bytes or longer — it simply stops copying at n bytes without necessarily appending a terminator. Forgetting that final manual termination is itself a common, more subtle bug that trades a loud overflow for a quiet unterminated-string bug instead.

• strcpy/strcat write however many bytes the source needs, with zero regard for the destination's actual size. • Writing past a stack buffer's end can corrupt adjacent variables or, in the worst case, saved return addresses. • Prefer strncpy(dest, src, sizeof(dest) - 1) followed by a manual dest[sizeof(dest)-1] = '\0';. • strncpy does not guarantee null-termination if src is n bytes or longer — always terminate manually as a safety net.

String Literals: Read-Only Memory

A string literal written directly in source code, like "hello", is stored by the compiler in a read-only section of the program's memory (often called the 'rodata' segment), and the program is generally not permitted to modify it at runtime. When you write char *p = "hi";, p is a pointer that points at that read-only literal — you have not created your own mutable copy of the text, only a pointer aimed at the compiler's fixed copy.

Attempting to modify a string literal through such a pointer — p[0] = 'H'; after char *p = "hi"; — is undefined behavior. On many modern systems it will crash immediately with a segmentation fault because the operating system enforces that memory page as read-only; on some older or less strict systems it might silently 'work' (which is arguably worse, because the bug goes unnoticed until it doesn't). Either way, it is always incorrect code, even when it happens not to crash.

• A string literal ("text" in source code) is stored in read-only memory. • char *p = "hi"; — p points AT the literal; it does not copy it into mutable storage. • Writing through such a pointer (p[0] = 'H';) is undefined behavior, often a crash (segfault).

Mutable vs Immutable Storage: buf[] vs *p

This is the distinction that trips up nearly every C beginner, and it is worth memorizing precisely: char buf[] = "hi"; declares an array and *copies* the characters 'h', 'i', '\0' into that array's own, freshly-allocated, mutable storage (typically on the stack). Because buf owns its own memory, buf[0] = 'H'; is perfectly legal and changes buf to "Hi".

char *p = "hi";, by contrast, declares a pointer variable and points it directly at the compiler's read-only string literal — no copy is made anywhere. p itself (the pointer variable) can be reassigned to point somewhere else entirely (p = "bye"; is fine, because that only changes what address p holds), but writing through p into the characters it points at (p[0] = 'H';) is illegal because those characters live in read-only memory that p merely references.

The rule of thumb: if you need to modify a string's contents in place, declare it as an array (char buf[N] or char buf[] = "..."), which owns writable memory. If you only need to read a fixed piece of text, or want a pointer that can be reassigned to point at different strings over time, char *p = "..." is fine — just never write through it. This same mutable-array-versus-pointer-to-fixed-data distinction reappears, in a very similar shape, once you start working with const in the pointers lesson, and conceptually echoes the difference between Objective-C's mutable NSMutableString and immutable NSString.

• char buf[] = "hi"; — copies the text into its own writable array storage; buf[0] = 'H' is legal. • char *p = "hi"; — points at a read-only literal, makes no copy; p[0] = 'H' is undefined behavior. • The pointer variable p itself can be reassigned (p = "bye";); it's writing through it that's forbidden. • Rule of thumb: need to mutate contents → use an array; only need to read → a pointer to a literal is fine.

String Declaration Forms and Core Functions

sizeof(dest) - 1 reserves room for the manually-added terminator. Never assume strncpy null-terminates its destination for you.

#include <string.h>

char name[] = "SampleApp";       // mutable, owns its storage (10 bytes incl. '\0')
char *label = "SampleApp";       // pointer to a read-only literal, no copy made

size_t len = strlen(name);          // 9 (does not count '\0')

char dest[20];
strcpy(dest, name);                 // unsafe: no bounds check on dest's capacity
strncpy(dest, name, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';       // always terminate manually after strncpy

strcat(dest, "!");                  // appends onto the existing terminator
int same = strcmp(dest, name);      // 0 only if fully equal

char *exclaim = strchr(dest, '!');  // pointer to '!' inside dest, or NULL
char *sub = strstr(dest, "App");    // pointer to "App..." inside dest, or NULL

Code Examples

Looping from index 0 through strlen(name) inclusive (note the <=) shows every character byte plus the trailing terminator, making the null-termination convention visible byte by byte instead of treating the string as a black box. Expected result: strlen = 9 name[0] = 'S' (83) name[1] = 'a' (97) ... name[8] = 'p' (112) name[9] = '0' (0)

The commented-out strcpy call would overflow the 8-byte small buffer by 21 bytes with no warning from the compiler or a crash guarantee at runtime; the strncpy + manual termination pattern instead truncates predictably and never writes outside small's bounds. Expected result: Truncated safely: This str buf is an array that owns its own bytes, so mutating buf[0] is safe; p is a pointer aimed at the compiler's fixed literal, so reassigning p is fine but writing through it is not — these are two fundamentally different kinds of storage even though both lines look similar. Expected result: Hi bye

Common mistake: Treating char *p = "..."; as if it behaves the same as char buf[] = "..."; — they look similar but point at completely different kinds of memory.

#include <stdio.h>
#include <string.h>

int main(void) {
    char name[] = "SampleApp";
    printf("strlen = %zu\n", strlen(name));
    for (size_t i = 0; i <= strlen(name); i++) {
        printf("name[%zu] = '%c' (%d)\n", i, name[i] == '\0' ? '0' : name[i], name[i]);
    }
    return 0;
}
#include <stdio.h>
#include <string.h>

int main(void) {
    char small[8];

    // DANGEROUS: source is longer than 'small' can hold
    // strcpy(small, "This string is far too long"); // undefined behavior, corrupts memory

    // SAFE alternative: bounded copy with manual termination
    const char *src = "This string is far too long";
    strncpy(small, src, sizeof(small) - 1);
    small[sizeof(small) - 1] = '\0';
    printf("Truncated safely: %s\n", small);
    return 0;
}
#include <stdio.h>

int main(void) {
    char buf[] = "hi";  // owns writable storage
    buf[0] = 'H';        // legal — buf now holds "Hi"
    printf("%s\n", buf);

    char *p = "hi";      // points at a read-only literal
    // p[0] = 'H';        // undefined behavior — often crashes (segfault)
    p = "bye";            // legal — just repoints p, doesn't mutate anything
    printf("%s\n", p);
    return 0;
}

"SampleApp" in Memory

9 visible characters plus one trailing '\0' terminator — every standard string function scans forward until it hits that zero byte.

char name[] = "SampleApp";

  index:  0    1    2    3    4    5    6    7    8    9
        ┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
  name: │ S  │ a  │ m  │ p  │ l  │ e  │ A  │ p  │ p  │ \0 │
        └────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
                                                        ▲
                                          strlen() stops here (9)

Important Rules

Every character array meant to hold a C string must have room for the text plus one extra byte for '\0'.

Never use strcpy/strcat on a buffer without first confirming the source cannot exceed the destination's capacity.

After strncpy, always manually terminate the destination — it is not guaranteed for you.

Never write through a char * that points at a string literal.

Use strcmp's return value against 0 for equality — never treat it as a plain boolean.

Why Objective-C Built NSString

Decades of strcpy/strcat buffer overflows in C and C++ codebases — including some of the most damaging security incidents in computing history — are a direct reason Objective-C's NSString was designed as an immutable-by-default, bounds-safe, reference-counted object rather than a raw char array: it manages its own memory, tracks its own length, and simply cannot be overflowed the way a fixed char buffer can.

char small[8]; strcpy(small, "This string is far too long");

char small[8]; strncpy(small, "This string is far too long", sizeof(small) - 1); small[sizeof(small) - 1] = '\0';

strcpy copies every byte of the source, including its terminator, with no regard for how big the destination buffer actually is — writing 21 bytes past the end of an 8-byte buffer is a classic stack buffer overflow.

char *p = "hi"; p[0] = 'H'; // crash or undefined behavior char buf[] = "hi"; buf[0] = 'H'; // legal, buf owns writable memory

A char * initialized from a string literal points at read-only memory; only an array declaration like char buf[] = "..." copies the text into storage you're actually allowed to modify.

if (strcmp(a, b)) { printf("equal\n"); } if (strcmp(a, b) == 0) { printf("equal\n"); } strcmp returns 0 for equal strings, so treating its result as a plain boolean is backwards — a truthy (nonzero) result actually means the strings differ.

What marks the end of a C string in memory? A. A stored length field at the start of the array B. A trailing '\0' (null terminator) byte C. The array's sizeof value D. Nothing — C strings are not terminated

B. A trailing '\0' (null terminator) byte — C strings carry no separate length field; every standard string function locates the end by scanning forward until it finds the '\0' terminator byte.

Why is strcpy(dest, src) dangerous when dest is a fixed-size buffer? A. It always fails to compile B. It copies src's bytes into dest with no check that dest is large enough, risking a buffer overflow C. It automatically truncates src to fit dest D. It only works with string literals

B. It copies src's bytes into dest with no check that dest is large enough, risking a buffer overflow — strcpy has no idea how big dest actually is; if src (including its terminator) is longer than dest's capacity, strcpy writes past the end of dest, corrupting adjacent memory — the classic buffer overflow scenario.

What is the key difference between char buf[] = "hi"; and char *p = "hi";? A. There is no difference — both behave identically B. buf owns its own writable copy of the text; p merely points at a read-only string literal C. p is faster but otherwise identical to buf D. buf cannot be printed with printf, only p can

B. buf owns its own writable copy of the text; p merely points at a read-only string literal — buf is an array that copies the characters into its own mutable storage, so buf[0] = 'H' is legal; p is a pointer aimed at the compiler's fixed, read-only literal, so writing through it is undefined behavior.

What does strcmp(a, b) return when the two strings are exactly equal? A. 1 B. -1 C. true D. 0

D. 0 — strcmp returns 0 for equal strings, a negative value if a sorts before b, and a positive value if a sorts after b — equality must be checked with strcmp(a, b) == 0, not by treating the result as a boolean.

Write a C function int countChar(const char *s, char target) that counts how many times target appears in s, using a manual loop that checks for the null terminator (do not use strchr in a loop). Then call it on "SampleApp" counting the letter 'S' — note C is case-sensitive.

Loop for (int i = 0; s[i] != '\0'; i++) and compare s[i] to target inside the loop.