Strings

A C string is a char array plus a promise: the first '\0' (NUL) byte marks the end. There is no length field anywhere — every standard function walks the bytes until it finds the NUL. That design is the source of C strings' speed and of their most famous disasters. This page makes the model, and the safe idioms, second nature.

The NUL-Terminated Model

"Hello" occupies six bytes, not five: five letters plus the terminating '\0'. Array sizes must always leave room for it, and every string function trusts it to exist — overwrite or omit the NUL and functions read past your buffer looking for the end that never comes.

C string as NUL-terminated char array

Figure 1 — "Hi" occupies 3 bytes; strlen counts until the NUL. No length is stored anywhere.

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

int main(void) {
    char word[6] = {'H', 'e', 'l', 'l', 'o', '\0'};  // explicit NUL
    char easy[]  = "Hello";                          // compiler adds the NUL
    printf("strlen = %zu, bytes = %zu\n",
           strlen(easy), sizeof(easy));              // 5 and 6 — the NUL counts
    return 0;
}

The pair strlen (logical length) and sizeof (array capacity) look alike and are not — the first measures until NUL, the second the whole array. Confusing them is a classic off-by-one bug.

Literals, Arrays, and Pointers

There are three distinct things that all "look like strings", and only one of them is writable:

DeclarationStorageWritable?
char *s = "Hi"string literal (typically read-only .rodata)No — modifying it is UB
char s[] = "Hi"local array, copied at initYes
char s[3]; s[0]='H'; ...explicit array built by handYes
#include <stdio.h>

int main(void) {
    char *lit = "Hi";            // points into immutable storage
    char  arr[] = "Hi";          // own 3-byte copy on the stack
    // lit[0] = 'X';             // UB — may crash on modern OSes
    arr[0] = 'X';                // fine: arr is ours
    printf("%s\n", arr);         // "Xi"
    return 0;
}

Reassigning the pointer is fine (lit = "bye") — the confusion is that char * invites writing through it. When a function takes const char *, it promises to treat the data as read-only; respect that contract on both sides.

The string.h Toolbox

The standard library ships a complete toolkit — and a set of traps. The key functions, with their safety notes:

FunctionDoesSafety note
strlen(s)length until NULRuns forever if not NUL-terminated
strcpy(dst, src)copy, incl. NULWrites unbounded — overflow risk
strncpy(d, s, n)copy up to n bytesDoes NOT always add NUL — pad it yourself
strcat/snprintfappend / formatUse snprintf for safe appending
strcmp(a, b)lexicographic compareReturn 0 means equal
strstr(hay, needle)find substringReturns pointer or NULL
strchr/strrchrfind a characterFirst / last occurrence
#include <stdio.h>
#include <string.h>

int main(void) {
    char name[32];
    snprintf(name, sizeof(name), "%-15s | %3d", "ada", 7);  // safe formatting
    printf("'%s'\n", name);

    // find a substring and report which side it starts at
    const char *text = "the quick brown fox";
    const char *hit = strstr(text, "brown");
    if (hit != NULL) {
        printf("found at offset %td\n", hit - text);   // 10
    }
    return 0;
}

The Safe Copy Idiom

Copying text into a fixed buffer is the most common string operation — and the most common exploit. The disciplined pattern: bound every write, and always enforce a final NUL yourself, because the library may not.

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

int main(void) {
    const char *src = "a very long title that overflows small buffers";
    char dst[16];

    // SAFE: copy at most 15 bytes, then force the terminating NUL
    strncpy(dst, src, sizeof(dst) - 1);   // reserves one byte for '\0'
    dst[sizeof(dst) - 1] = '\0';          // strncpy may have omitted it

    printf("'%s'\n", dst);                // truncated but well-formed
    return 0;
}

Classic String Bugs

Every string exploit in history is one of these three. Learn the shapes and your review eye will catch them on sight:

  • Buffer overflow — copying an unchecked source into a fixed buffer. The gets() function could not even be given a bound and was removed from the standard for exactly this; never mourn it.
  • Missing NUL — strncpy of a source as long as the buffer leaves no terminator; later strlen/printf walk into foreign memory.
  • Off-by-one — declaring char s[5] for "Hello": five letters plus NUL is six bytes.
char bad[5];
strcpy(bad, "Hello");       // BUG: writes 6 bytes into 5 — 'o' lands outside

char worse[3];
strncpy(worse, "abc", 3);   // no room for NUL was reserved
printf("%s", worse);        // BUG: reads past the array hunting for '\0'

Fix both with the safe idiom above — reserve the terminator slot and set it explicitly.

Dynamic Strings

When text must grow beyond a guessable size, build it on the heap with realloc. A minimal, correct grow-by-doubling loop is the seed of a real string library — the study projects page grows it into one.

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

int main(void) {
    size_t cap = 8, len = 0;
    char *buf = malloc(cap);
    if (buf == NULL) return 1;
    const char *words[] = {"one", "two", "three"};
    for (size_t i = 0; i < 3; i++) {
        size_t need = len + strlen(words[i]) + 1;   // +1 for space or NUL
        if (need > cap) {                           // grow by doubling
            cap *= 2;
            char *grown = realloc(buf, cap);
            if (grown == NULL) { free(buf); return 1; }
            buf = grown;
        }
        len += snprintf(buf + len, cap - len, "%s%s", i ? " " : "", words[i]);
    }
    printf("joined: '%s'\n", buf);
    free(buf);
    return 0;
}

Next: data structures — linked lists, vectors, and hash tables built from these tools.