Arrays & Strings · beginner · ~10 min

C strings

## What you will learn - Explain the C string convention: a `char *` that points at a run of bytes ending in a NUL terminator (`'\0'`). - Tell the difference between a string's **length** (`strlen`) and the **capacity** of the buffer that holds it. - Use `<string.h>` functions like `strlen`, `strcpy`, and `strcat` correctly, and recognize when each one is unsafe. - Choose bounded alternatives (`snprintf`, `strlcpy`, `strlcat`) and prove your destination buffer is big enough. - Avoid the most common string bugs: missing terminators, writing through a read-only literal, and calling `strlen` in a loop condition. - Read raw string memory in a debugger to diagnose corruption.

Overview

What a C string really is

C has no string type. What we call a string is just a plain array of char whose contents end with a special byte: the NUL terminator, written '\0' (a byte with value zero). When you see char *s used as a string, s points at the first byte, and the string continues byte-by-byte until that zero byte is reached.

This builds directly on Character arrays, your prerequisite. There you learned that a char is a single byte and that you can store many of them in an array. A C string adds exactly one rule on top of that array: put a zero byte at the end so the rest of the program knows where the text stops.

The string "hello" stored in a char array:

index:   0    1    2    3    4    5
        +----+----+----+----+----+----+
 bytes:  | h  | e  | l  | l  | o  | \0 |
        +----+----+----+----+----+----+
         'h' 'e'  'l'  'l'  'o'   0

length = 5 (bytes before the \0)
capacity needed = 6 (those 5 + room for the \0)

Why this matters in plain terms

Text is the universal data format. URLs, filenames, HTTP headers, log lines, JSON, configuration files, and even C source code are all strings. Almost every program you will ever write reads or produces text. In C, that means you are constantly working with this byte-array-plus-terminator convention.

Terminology you will meet

  • NUL terminator — the single '\0' byte marking the end. (Note: "NUL" is one L; it is not the same as the NULL pointer.)
  • String literal — text in double quotes in your source, like "hello". The compiler stores it for you and adds the terminator automatically.
  • size_t — the unsigned integer type used for sizes and lengths (what strlen returns).

Everything else about strings in C is just array arithmetic done while keeping a careful eye on that terminator.

Why it matters

Strings are everywhere

Text is how programs talk to humans and to each other. A web server parses HTTP request lines; a shell splits a command into words; a database stores names and addresses; a log writer formats messages. All of that is string handling. If you cannot work with C strings confidently, you cannot write real C programs.

The trade-off C makes

C's bare-bones string model is a double-edged sword.

Upside Downside
Extremely fast — no hidden allocation, no copying you did not ask for You must check every length and every bound yourself
Predictable memory layout, ideal for systems code A single missing terminator can read or write far past your buffer
Works the same on every platform No built-in Unicode, no automatic growth

The security angle

Because the language tracks no length and does no bounds checking, string mistakes are the classic source of memory-corruption bugs. A large share of historically reported C/C++ memory-safety vulnerabilities trace back to buffer overflows, and string copies are one of the most common ways those overflows happen. Learning to handle strings safely is therefore not just about correctness — it is about not shipping exploitable code.

Core concepts

1. The NUL terminator

Definition. The NUL terminator is a single byte with value zero ('\0') placed immediately after the last real character of a string.

Why it exists. C does not store a string's length anywhere. The only way any function knows where the text ends is by scanning forward until it hits this zero byte. The terminator is the end-of-string marker.

How it works internally. strlen, printf("%s", ...), strcpy, and friends all loop reading one byte at a time and stop the instant they read a '\0'. No terminator means no stopping point.

With terminator (correct):        Without terminator (bug):
+---+---+---+----+                 +---+---+---+----+----+----+
| h | i | ! | \0 |  <- strlen=3    | h | i | ! | ?? | ?? | ?? | ...
+---+---+---+----+                 +---+---+---+----+----+----+
strlen stops here ^                strlen keeps reading garbage ----->

When to rely on it. Always, when calling standard library string functions — they assume it is present.

Pitfall. Functions like strncpy may leave the destination without a terminator if the source is too long. After such calls you often must place the '\0' yourself.

Knowledge check (predict the output): A buffer holds the bytes 'a', 'b', 'c' with no zero byte after them. What is the behavior of strlen on that buffer?

2. Length vs. capacity

Definition. Length is how many characters the string currently has (strlen(s), the count of bytes before the NUL). Capacity is how many bytes the underlying buffer can hold.

The key relationship. A buffer must hold at least strlen(s) + 1 bytes — the +1 is room for the terminator. Forgetting that +1 is one of the most common beginner bugs.

char buf[8];  // capacity = 8 bytes

+---+---+---+---+----+---+---+---+
| h | e | l | l | o  |\0 | ? | ? |   length = 5, capacity = 8: fits
+---+---+---+---+----+---+---+---+
  used (6 bytes incl. \0)   spare

When to track capacity. Every time you write into a buffer. Reading is governed by the terminator; writing is governed by capacity.

Pitfall. Computing space as strlen(src) instead of strlen(src) + 1 leaves no room for the terminator and overflows by one byte (an "off-by-one").

3. String literals are read-only

Definition. A string literal such as "hello" is a fixed, unnamed array the compiler stores for you (6 bytes here: 5 characters + NUL).

How it works. Literals typically live in a read-only region of memory. Pointing at one is fine; modifying its bytes is undefined behavior (UB) and commonly crashes.

char *p = "hello";   // p points at a read-only literal
p[0] = 'H';          // UNDEFINED BEHAVIOR — may crash

char a[] = "hello";  // a is YOUR OWN writable copy on the stack
a[0] = 'H';          // fine: a is now "Hello"

When to use which. Use char *p = "..." (or better, const char *p = "...") when you only read. Use char a[] = "..." when you need to modify the text.

Pitfall. char *p = "..." compiles without warning but invites a write-through-literal crash later. Mark such pointers const so the compiler stops you.

4. Length costs time: O(n)

Definition. Because length is not stored, finding it requires scanning the whole string, which is O(n) work (it grows with the string's length).

The classic anti-pattern. Putting strlen(s) in a loop condition re-scans the string on every iteration, turning an O(n) loop into O(n^2).

for (size_t i = 0; i < strlen(s); i++) { ... }   // O(n^2): strlen runs every pass

size_t n = strlen(s);                            // compute once
for (size_t i = 0; i < n; i++) { ... }           // O(n)

Knowledge check (find the bug): Why does for (int i = 0; i < strlen(s); i++) get slower and slower as s grows, even when the loop body is trivial?

Knowledge check (explain in your own words): In one sentence, what is the difference between a string's length and its buffer's capacity, and why must capacity be at least length + 1?

Syntax notes

#include <string.h>   // strlen, strcpy, strcat, etc.
#include <stdio.h>    // snprintf

char s[]  = "hello";          // mutable copy: 6 bytes on the stack (5 + NUL)
const char *p = "hello";      // read-only literal; never write through p

size_t n = strlen(s);          // 5 — counts bytes BEFORE the NUL, not the NUL

char buf[64];
strcpy(buf, s);                // UNSAFE: no size limit; overflows if s is long
snprintf(buf, sizeof buf, "%s", s);   // SAFE: writes at most 63 chars + NUL

char name[16];
snprintf(name, sizeof name, "User-%d", 42);   // formats and bounds in one call

Key points: strlen excludes the terminator; sizeof buf gives the buffer's capacity (works only on real arrays, not on a char *); and snprintf always NUL-terminates as long as the buffer size is at least 1.

Lesson

The convention

A C string is a char * pointing at a sequence of bytes that ends in \0.

The standard library functions in <string.h> all rely on this convention:

  • strlen walks to the NUL.
  • strcpy copies up to and including the NUL.

Why length costs time

Because the length is not stored anywhere, any operation that needs it must scan the whole string. That makes it O(n) (the work grows with the length of the string).

Calling strlen in a loop condition is the classic O(n²) anti-pattern: the scan repeats on every iteration.

Code examples

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

/* Safely build a greeting line from a user-supplied name.
   Demonstrates: strlen, capacity checking, read-only literals,
   and bounded formatting with snprintf. */
int main(void) {
    const char *literal = "hello";   // read-only string literal
    size_t len = strlen(literal);    // 5 — bytes before the NUL
    printf("\"%s\" has length %zu\n", literal, len);  // %zu prints size_t

    /* A writable copy we are allowed to modify. */
    char name[] = "ada";
    name[0] = 'A';                   // OK: name is our own array -> "Ada"

    /* Build "User: Ada (3)" into a fixed buffer, safely. */
    char line[64];
    int written = snprintf(line, sizeof line,
                           "User: %s (%zu)", name, strlen(name));
    if (written < 0) {               // encoding error
        fprintf(stderr, "formatting failed\n");
        return 1;
    }
    if ((size_t)written >= sizeof line) {  // output was truncated
        fprintf(stderr, "warning: output truncated\n");
    }
    printf("%s\n", line);
    return 0;
}

What it does. It measures a string literal's length, makes a writable copy of a name and edits its first character, then formats a labeled line into a 64-byte buffer using snprintf (which can never overflow). It checks snprintf's return value for both errors and truncation.

Expected output:

"hello" has length 5
User: Ada (3)

Edge cases. If name were long enough that "User: ... (n)" exceeded 63 characters, snprintf would truncate to fit and return the length it would have written — which is why the >= sizeof line check matters. Writing literal[0] = 'H' instead of editing name would be undefined behavior because literal points at a read-only literal.

Line by line

Walkthrough of the key example:

Step Code What happens
1 const char *literal = "hello"; literal points at the 6-byte read-only array h e l l o \0.
2 strlen(literal) Scans h,e,l,l,o, stops at \0; returns 5.
3 printf(... %zu ...) Prints "hello" has length 5. %zu is the correct format for size_t.
4 char name[] = "ada"; Allocates a 4-byte writable array on the stack: a d a \0.
5 name[0] = 'A'; Overwrites the first byte; the array is now A d a \0 = "Ada". Legal because name is our own copy.
6 snprintf(line, 64, "User: %s (%zu)", name, strlen(name)) Formats User: Ada (3) into line, writing at most 63 characters plus a guaranteed \0. Returns 13.
7 written < 0 check written is 13, not negative, so no error.
8 (size_t)written >= sizeof line 13 >= 64 is false, so no truncation warning.
9 printf("%s\n", line) Walks line to its \0, printing User: Ada (3).

Memory at step 6:

line (64 bytes):
+---+---+---+---+---+---+---+---+---+---+---+---+---+----+----+...
| U | s | e | r | : |   | A | d | a |   | ( | 3 | ) | \0 | ?? |
+---+---+---+---+---+---+---+---+---+---+---+---+---+----+----+...
  0   1   2   3   4   5   6   7   8   9  10  11  12   13   (unused)

The terminator at index 13 is what makes the later printf("%s", line) stop in the right place.

Common mistakes

Mistake 1: calling strlen on non-terminated data

char buf[3] = { 'a', 'b', 'c' };  // WRONG: no room for, and no, NUL
size_t n = strlen(buf);            // reads past the end -> garbage / crash

Why it is wrong. strlen keeps reading until it finds a zero byte. With no terminator it walks into whatever memory follows. Fix: leave room and terminate.

char buf[4] = { 'a', 'b', 'c', '\0' };  // or: char buf[] = "abc";
size_t n = strlen(buf);                  // 3

How to spot it: lengths that change run to run, or values much larger than expected.

Mistake 2: strlen in the loop condition

for (size_t i = 0; i < strlen(s); i++)   // WRONG: O(n^2)
    s[i] = toupper((unsigned char)s[i]);

Why it is wrong. strlen re-scans the entire string on every iteration. Fix: compute it once.

size_t n = strlen(s);
for (size_t i = 0; i < n; i++)
    s[i] = toupper((unsigned char)s[i]);

How to spot it: programs that are fine on short input but crawl on long input.

Mistake 3: writing through a string literal

char *p = "hello";   // WRONG intent: literal is read-only
p[0] = 'H';          // undefined behavior — often a crash

Fix: make your own writable array, and mark read-only pointers const.

char a[] = "hello";  a[0] = 'H';   // fine
const char *p = "hello";           // compiler now blocks accidental writes

How to spot it: a crash (segmentation fault) on a line that assigns into a char * that was set from a literal.

Mistake 4: off-by-one capacity

char dst[5];
strcpy(dst, "hello");   // WRONG: "hello" needs 6 bytes (5 + NUL)

Why it is wrong. The 6th byte (the terminator) is written one past the end of dst. Fix: size for strlen(src) + 1, or use a bounded copy.

char dst[6];                                  // room for 5 + NUL
snprintf(dst, sizeof dst, "%s", "hello");     // or strlcpy

How to spot it: AddressSanitizer reports a stack-buffer-overflow of exactly one byte.

Debugging tips

Compiler-stage problems

  • warning: passing argument ... discards 'const' qualifier — you are trying to modify a const char *. Either copy the data into a writable buffer or rethink why you need to change a constant.
  • format '%s' expects argument of type 'char *' — you passed an int or a single char where a string pointer was expected.
  • Always build with -Wall -Wextra. These warnings catch many string bugs before you ever run the program.

Runtime problems

  • Segmentation fault on a write — usually writing through a literal, or running off the end of a too-small buffer. Re-check capacity vs. strlen(src) + 1.
  • Garbage or never-ending output from %s — almost always a missing terminator. Cap the print with printf("%.10s\n", s) so the program survives long enough to inspect.

Logic problems

  • An off-by-one (a string that is one char short or one char too long) usually means you confused length and capacity, or forgot the +1 for the terminator.

Concrete steps

  1. Compile with -Wall -Wextra -fsanitize=address and run; ASan pinpoints overflows and use-after-free with exact byte offsets.
  2. In gdb, dump raw bytes to see whether the terminator is present: x/16cb s prints 16 bytes as characters.
  3. Print strlen and the buffer's sizeof next to each other to confirm the data actually fits.

Questions to ask when it does not work

  • Is there definitely a '\0' at the end of this data?
  • Is this buffer big enough for strlen(src) + 1?
  • Am I writing into memory I actually own (not a literal)?

Memory safety

The unbounded functions are the danger

strcpy, strcat, sprintf, and gets are unbounded: they keep writing until they reach a NUL in the source, with no regard for how big the destination is. If the source is longer than the destination, they overflow the buffer — corrupting adjacent memory and creating classic security vulnerabilities. (gets is so dangerous it was removed from the C standard entirely; never use it.)

strcpy(dst /*8 bytes*/, src /*"a very long string"*/):

dst:  [ a   v   e   r   y ][ overflow -> corrupts whatever is next ]
       \__ 8 bytes you own _/ \_____ memory you do NOT own ______/

Use bounded versions instead

Avoid Prefer Why
strcpy snprintf / strlcpy takes a destination size; cannot overrun
strcat snprintf / strlcat bounded append
sprintf snprintf bounded formatting
gets fgets takes a buffer size

Rules of thumb

  • Anywhere you would call strcpy(dst, src), you must be able to prove dst has at least strlen(src) + 1 bytes. If you cannot prove it, use a bounded version.
  • After strncpy, manually set the last byte to '\0' — it does not always terminate.
  • Pass sizeof buf (not a hand-typed number) as the size argument so the limit stays correct if the buffer changes.

Undefined behavior to avoid

  • Reading past a missing terminator (out-of-bounds read).
  • Writing past a buffer's capacity (out-of-bounds write).
  • Writing through a pointer to a string literal.
  • Using sizeof on a char * parameter expecting the buffer size — it returns the pointer size (often 8), not the array length.

Real-world uses

Where this shows up

  • Web servers parse request lines like GET /index.html HTTP/1.1 by scanning strings for spaces and newlines.
  • Shells and command-line tools split a typed command into argument strings.
  • Databases and config systems read keys and values as NUL-terminated text.
  • Networking code assembles and parses protocol headers, which are mostly text.
  • Embedded firmware formats status messages into fixed buffers (where snprintf and known capacities are essential).

Professional best-practice habits

For beginners (do these always):

  • Prefer snprintf for any formatting into a buffer; pass sizeof buf as the size.
  • Treat every literal as read-only; declare such pointers const char *.
  • Compute strlen once, not inside loop conditions.
  • Always leave room for the terminator (+1).

For more advanced work:

  • Check snprintf's return value to detect truncation, and decide deliberately whether truncation is acceptable.
  • Keep length and buffer together when strings grow dynamically (e.g., a small struct holding pointer, length, and capacity) so you are not re-scanning.
  • Validate and bound all external input (file contents, network data, arguments) before copying it.
  • Build CI with -Wall -Wextra -fsanitize=address,undefined so string overflows fail tests instead of shipping.

Practice tasks

Beginner

1. Reimplement strlen. Write size_t my_strlen(const char *s) that returns the number of bytes before the NUL, without calling the library.

  • Input/output: my_strlen("hi") -> 2; my_strlen("") -> 0.
  • Hint: loop a counter forward until s[i] == '\0'.
  • Concepts: NUL terminator, scanning.

2. Length vs. capacity check. Write a main that declares char buf[8], copies "hello" into it only if it fits (strlen(src) + 1 <= sizeof buf), and prints whether it fit.

  • Constraint: do not use strcpy.
  • Hint: use snprintf and compare its return value to sizeof buf.
  • Concepts: capacity, bounded copy.

Intermediate

3. Reimplement strcpy safely. Write int safe_copy(char *dst, size_t dst_size, const char *src) that copies src into dst, never writes past dst_size, always terminates, and returns 0 on success or -1 if src did not fit.

  • Input/output: copying "abc" into an 8-byte buffer returns 0; copying it into a 2-byte buffer returns -1 and leaves dst terminated.
  • Hint: you need strlen(src) + 1 <= dst_size.
  • Concepts: terminator, capacity, error reporting.

4. Uppercase in place. Write void to_upper_inplace(char *s) that uppercases every alphabetic character in a writable string.

  • Constraint: compute strlen once; use toupper((unsigned char)c).
  • Input/output: "aBc!" becomes "ABC!".
  • Concepts: writable arrays, O(n) loop, avoiding strlen in the condition.

Challenge

5. Bounded join. Write int join(char *dst, size_t dst_size, const char *a, const char *b, char sep) that builds a + sep + b into dst, never overflows, always terminates, and returns the number of characters it would have written (like snprintf), so the caller can detect truncation.

  • Input/output: join(buf, 64, "left", "right", '-') puts "left-right" in buf and returns 10.
  • Hint: snprintf(dst, dst_size, "%s%c%s", a, sep, b) does almost all of this — then reason about its return value.
  • Concepts: bounded formatting, truncation detection, capacity.

Summary

What to remember

  • A C string is just a char array (pointed at by a char *) that ends in a NUL terminator, '\0'. There is no string type and no stored length.
  • Length (strlen, bytes before the NUL) is not the same as capacity (buffer size). A buffer must hold at least strlen(s) + 1 bytes.
  • strlen and friends scan for the terminator, so they are O(n) — never put strlen in a loop condition.
  • String literals are read-only; use char a[] = "..." when you need to modify text, and const char * when you only read.
  • The most important safety rule: avoid the unbounded strcpy, strcat, sprintf, and gets. Prefer snprintf, strlcpy, and strlcat, and always pass sizeof buf as the limit.
  • Common bugs: missing terminator, off-by-one capacity, and writing through a literal. Build with -Wall -Wextra -fsanitize=address to catch them early.

Practice with these exercises