Pointers & Memory · beginner · ~10 min

Pointers and strings

- Walk through a string one character at a time using pointer increment (`s++`) and dereference (`*s`). - Explain why a `char *` and a `char[]` behave the same way when passed to a function (array-to-pointer decay). - Read and write the two `const` positions: `const char *` (read-only characters) versus `char *const` (read-only pointer). - Detect the terminating NUL byte (`'\0'`) correctly and never walk past it. - Compute a string's length with pointer subtraction instead of a counter. - Recognise which strings live in read-only memory and must never be modified.

Overview

A C string is not a real data type. It is just a block of char values in memory, laid out one after another, ending with a special zero byte written '\0' (the NUL terminator). Everything you already know from C strings — that the terminator marks the end — and everything from Pointers and arrays — that an array name gives you the address of its first element — comes together here.

The key idea: because a string is a run of characters ending in NUL, the most natural way to process it is to hold a pointer to a character and keep moving that pointer forward until it lands on the NUL. This is called a pointer walk. Instead of indexing with s[i] and a separate counter, you advance the pointer itself: *s reads the current character, s++ steps to the next one, and the loop stops when *s becomes '\0' (which is zero, and therefore false).

In plain language: imagine a row of mailboxes, each holding one letter, with an empty box at the end. A pointer is your finger resting on one box. You read the letter under your finger, slide your finger one box to the right, and repeat until you reach the empty box. That is exactly how strlen, strcpy, strcmp, and most of the standard string library are written internally. Once you can do a pointer walk, you can read and reason about almost any string code in C.

Why it matters

Nearly every real C program touches strings: file paths, user names, HTTP requests, configuration keys, log lines, command-line arguments. All of them are NUL-terminated character buffers processed by pointer. Understanding the pointer-walk pattern is what lets you read the source of the standard library, understand why an off-by-one error corrupts memory, and write string code that is both fast and correct.

It also matters for safety. A huge share of security bugs in C — buffer overflows, out-of-bounds reads, crashes on modifying string literals — come from mishandling exactly the concepts in this lesson: where the NUL is, whether the memory is writable, and how far the pointer is allowed to move. Getting these right is the foundation of writing C that does not crash or get exploited.

Core concepts

1. A string is characters in memory, ending in NUL

Definition. A C string is a contiguous sequence of char values whose last element is the NUL byte '\0' (numeric value 0). The NUL is what tells every string function where the string ends.

How it works internally. The literal "cat" occupies four bytes, not three: 'c', 'a', 't', '\0'. Functions like strlen do not know the length in advance — they scan forward until they see the zero byte.

char word[] = "cat";

Index:    0     1     2     3
        +-----+-----+-----+-----+
  word: | 'c' | 'a' | 't' |'\0' |
        +-----+-----+-----+-----+
Bytes:   99    97   116    0
           ^                 ^
           |                 stops the walk
         start

When to rely on it / when not. Rely on the NUL for any string that came from a string literal, scanf/fgets, or another standard function — they always terminate. Do NOT assume termination for a raw byte buffer you filled yourself (for example with memcpy or read); if you forgot to add the '\0', string functions will run off the end.

Pitfall. Confusing the character '0' (value 48) with the terminator '\0' (value 0). They are different bytes. Only '\0' ends a string.

Knowledge check: How many bytes does the literal "hi" occupy in memory, and what is the value of the last byte?

2. The pointer walk

Definition. A pointer walk processes a string by keeping a char * and advancing it one character at a time until it points at the NUL.

Plain-language explanation. *s means "the character at the box my pointer is on." s++ means "move my pointer to the next box." The loop for (; *s; s++) continues as long as the current character is non-zero, and stops automatically at the terminator because '\0' is zero, which C treats as false.

Start:      s -> ['c']['a']['t']['\0']
After s++:       ['c'] s->['a']['t']['\0']
After s++:       ['c']['a'] s->['t']['\0']
After s++:       ['c']['a']['t'] s->['\0']   <- *s == 0, loop ends

When to use / when not. Use a pointer walk when you scan left-to-right once (counting, searching, copying). Prefer plain indexing (s[i]) when you need random access, need to go backwards, or when clarity matters more than the tiny efficiency gain.

Pitfall. Advancing the pointer past the NUL. Once *s == '\0', the string is over. Reading *(s+1) after that is out of bounds unless you know more memory follows.

Knowledge check (predict the output): For const char *s = "abc";, how many times does the body of for (; *s; s++) run?

3. Array-to-pointer decay

Definition. When you pass an array (like char word[10]) to a function, or use its name in most expressions, it "decays" to a pointer to its first element. The function receives a char *, not the whole array.

Why it matters here. It is the reason a function written for char * works on char[] and vice versa. Inside the function you cannot tell whether the caller passed an array or a pointer — both arrive as an address.

Expression Type Notes
char a[10]; then a char * (decayed) Address of a[0]
&a[0] char * Same address as above
sizeof a (on the array itself) size_t = 10 Decay does NOT happen for sizeof
char *p = a; char * p now points at a[0]

Pitfall. Expecting sizeof(param) inside a function to give the array length. Because the parameter decayed to a pointer, sizeof returns the pointer size (often 8), not the array size. Pass the length separately.

4. The two positions of const

Definition. const can protect either the characters being pointed at or the pointer itself, depending on which side of the * it sits.

Declaration What is read-only You may... You may NOT...
const char *p the characters move p, read *p write *p = 'x'
char *const p the pointer write *p = 'x' reassign p = other
const char *const p both read *p write *p or move p

How to read it. Read right-to-left from the variable name. char *const p = "p is a const pointer to char." const char *p = "p is a pointer to const char."

When to use. Mark a function parameter const char * whenever the function only reads the string. It documents intent and lets callers pass string literals safely.

Pitfall. Assuming const char * means the pointer cannot move. It can — only the characters are protected.

Knowledge check (explain in your own words): Given char *const p = buf;, why does p = other; fail to compile but p[0] = 'X'; succeed?

Syntax notes

The core loop and the two const forms:

/* Pointer walk: run until the NUL terminator */
for (const char *s = str; *s != '\0'; s++) {
    /* *s is the current character */
}

/* Equivalent, idiomatic short form: *s is 0 (false) at the NUL */
for (; *s; s++) { /* ... */ }

const char *ro   = "hello";  /* characters read-only; pointer can move   */
char        buf[] = "hi";
char *const fixed = buf;      /* pointer fixed; characters can be written */

Key points: *s dereferences (reads the char), s++ advances the pointer, and the loop test *s doubles as "not yet at the terminator" because '\0' equals 0.

Lesson

Strings are pointers under the hood

In C, a char * (a pointer to a character) and a char[] (a character array) decay to the same thing when passed to a function. "Decay" means the array name turns into a pointer to its first element. So both end up as a pointer you can move through.

Many string functions are easiest to write as a pointer walk: start at the first character and keep advancing until you reach the terminating null byte, \0. The null byte marks the end of every C string.

Two kinds of const

The placement of const changes its meaning:

  • const char * means the characters are read-only. You can move the pointer, but you must not modify what it points to.
  • char *const means the pointer itself cannot be reassigned. You can change the characters, but the pointer always points to the same place.

Both forms are useful in APIs to document your intent to callers.

Code examples

#include <stdio.h>
#include <stddef.h>

/* Count how many times character `c` appears in string `s`.
   `s` is const because we only read it. */
size_t count_char(const char *s, char c) {
    size_t n = 0;
    for (; *s; s++)          /* walk until the NUL terminator */
        if (*s == c)
            n++;
    return n;
}

/* Return the length of `s` using pointer subtraction:
   advance a copy to the NUL, then subtract the start. */
size_t length_by_pointer(const char *s) {
    const char *p = s;       /* keep the original start */
    while (*p)               /* stop at '\0' */
        p++;
    return (size_t)(p - s);  /* distance walked = number of chars */
}

int main(void) {
    const char *word = "banana";

    printf("word        = %s\n", word);
    printf("length      = %zu\n", length_by_pointer(word));
    printf("count of a  = %zu\n", count_char(word, 'a'));
    printf("count of z  = %zu\n", count_char(word, 'z'));

    return 0;
}

What it does. count_char walks the string and tallies matches for a target character. length_by_pointer walks a copy of the pointer to the terminator, then uses pointer subtraction (p - s) to get the length — the same technique the exercise "String length by pointer" asks for. main runs both on "banana".

Expected output:

word        = banana
length      = 6
count of a  = 3
count of z  = 0

Edge cases. On the empty string "", *s is '\0' immediately, so both functions return 0 without entering the loop body — correct. The functions never write to s, so passing a string literal is safe.

Line by line

Trace length_by_pointer("banana"):

  1. const char *p = s;p starts at the same address as s, pointing at 'b'.
  2. while (*p)*p is 'b' (non-zero, true), so the loop runs; p++ moves to 'a'.
  3. The loop repeats for each letter. p advances across b a n a n a.
  4. When p reaches the byte after the final 'a', *p is '\0' (zero, false) and the loop stops.
  5. p - s is the number of steps taken. s pointed at index 0, p now points at index 6, so p - s == 6.
Step char at p *p truthy? p - s so far
start 'b' yes 0
1 'a' yes 1
2 'n' yes 2
3 'a' yes 3
4 'n' yes 4
5 'a' yes 5
6 '\0' no -> stop 6

Now trace count_char("banana", 'a'): the walk visits b(no), a(n=1), n(no), a(n=2), n(no), a(n=3), then hits '\0' and returns 3. Pointer subtraction works because array elements are contiguous, so subtracting two char * values yields the number of chars between them.

Common mistakes

1. Modifying a string literal.

char *p = "hello";  /* points into read-only memory */
p[0] = 'H';         /* WRONG: undefined behaviour, usually a crash */

Why it is wrong: string literals often live in a read-only memory segment. Writing to them is undefined behaviour and typically segfaults. Fix by copying into a writable array:

char buf[] = "hello";  /* array copy on the stack, writable */
buf[0] = 'H';          /* fine: buf now holds "Hello" */

Prevent it: declare pointers to literals as const char * so the compiler rejects any write.

2. Forgetting the NUL when building a buffer by hand.

char name[4];
memcpy(name, "john", 4);   /* copies j o h n but NO terminator */
printf("%s\n", name);       /* WRONG: reads past the buffer */

The buffer holds 4 letters and no '\0', so printf("%s") walks off the end. Fix: make room for the terminator and add it (char name[5]; memcpy(name, "john", 5); copies the NUL too, since the literal has 5 bytes).

3. Confusing the two const forms.

const char *p = buf;
p = other;   /* OK: pointer can move */
p[0] = 'X';  /* WRONG: characters are read-only */

Recognise it: if the compiler says "assignment of read-only location," you tried to write through a const char *. Choose the form that matches what you actually need to protect.

4. Advancing past the terminator. Writing s++ again after the loop already found the NUL reads out of bounds. Stop the moment *s == '\0'.

Debugging tips

Compiler warnings and errors:

  • warning: assignment discards 'const' qualifier — you assigned a const char * to a plain char *. Keep the const, or if you truly need to write, copy into a writable buffer.
  • error: assignment of read-only location '*p' — you wrote through a const char *. That is the compiler protecting you.
  • Always compile with -Wall -Wextra; missing terminators and bad const usage often surface as warnings.

Runtime errors:

  • Segmentation fault when writing to a string: you are modifying a string literal. Copy into a char[] first.
  • Garbage or a crash when printing with %s: the buffer is missing its '\0'. Print the raw bytes or check the length to confirm.
  • Run under a memory checker: gcc -g -fsanitize=address prog.c then run the program. AddressSanitizer pinpoints out-of-bounds reads from walking past the NUL.

Logic errors:

  • Length off by one: you may be counting the terminator, or subtracting the wrong pointers. Print p - s mid-loop.

Questions to ask when it does not work:

  1. Is this memory actually writable, or is it a string literal?
  2. Does the buffer have a '\0' within its bounds?
  3. Did my pointer stop exactly at the terminator, or one past it?
  4. Am I comparing against '\0' (value 0) or accidentally '0' (value 48)?

Memory safety

String pointer walks are a classic source of undefined behaviour in C. Watch these:

  • Bounds. A pointer walk is only safe while there is a '\0' somewhere in valid memory. If the buffer is not terminated, the loop reads past the end — an out-of-bounds read that may crash or leak adjacent memory. Always guarantee termination, or track a length and stop at it.
  • Writability / lifetime. String literals ("text") live in read-only memory; writing through a pointer to one is undefined behaviour. A char[] initialised from a literal is a writable copy and is safe to modify, but only while it is in scope — returning a pointer to a local array is a dangling-pointer bug.
  • Initialisation. An uninitialised char buf[100]; contains garbage with no guaranteed '\0'. Do not pass it to a string function until you have written a terminated string into it.
  • Off-by-one with the terminator. A buffer for an n-character string needs n + 1 bytes. Forgetting the extra byte for '\0' is the most common overflow in C. strncpy in particular does not guarantee a terminator when the source is too long — always terminate manually afterward.
  • Prefer safe patterns. Use const char * for read-only parameters so the compiler blocks accidental writes. When copying, ensure the destination is large enough and NUL-terminated. Compile with -Wall -Wextra -fsanitize=address during development.

Real-world uses

Where this shows up. The C standard library itself is built on pointer walks: strlen, strchr, strcmp, and strcpy are all short pointer loops over NUL-terminated buffers. Command-line tools receive their arguments as char *argv[] — an array of string pointers you walk. Parsers for config files, HTTP headers, and log lines scan const char * buffers character by character. Embedded firmware, where every byte counts, favours pointer walks over indexing for compact code.

Beginner best practices:

  • Use const char * for any parameter you only read.
  • Give pointers and buffers meaningful names (cursor, word, line), not just p.
  • Always account for the '\0' when sizing a buffer.
  • Compile clean with -Wall -Wextra and fix every warning.

Advanced best practices:

  • Carry an explicit length alongside the pointer when the data may not be terminated (defensive against malformed input).
  • Prefer bounded functions (snprintf, strnlen) over their unbounded cousins in code that touches untrusted input.
  • Avoid re-scanning: compute a length once and reuse it instead of calling strlen inside a loop condition (which turns an O(n) loop into O(n^2)).
  • Keep ownership clear: document who allocates and who frees a string buffer.

Practice tasks

Beginner 1 — Print each character. Write a function void print_chars(const char *s) that uses a pointer walk to print every character of s on its own line, then stops at the terminator. Requirements: use *s and s++; do not use array indexing. Example: input "hi" prints h then i. Concepts: pointer walk, NUL detection.

Beginner 2 — Count vowels. Write size_t count_vowels(const char *s) returning how many of a e i o u (lowercase only) appear. Requirements: single pointer walk; const parameter. Example: count_vowels("banana") returns 3. Hint: reuse the structure of count_char. Concepts: pointer walk, character comparison, const.

Intermediate 1 — Reimplement strlen. Write size_t my_strlen(const char *s) using pointer subtraction (advance a copy to the NUL, subtract the start). Do not use a counter variable and do not call the library strlen. Example: my_strlen("") returns 0, my_strlen("cat") returns 3. Constraint: no [] indexing. Concepts: pointer subtraction, array-to-pointer decay.

Intermediate 2 — Find last occurrence. Write const char *find_last(const char *s, char c) that returns a pointer to the last c in s, or NULL if absent (like strrchr). Requirements: a single pointer walk that remembers the most recent match. Example: for "banana" and 'a', the returned pointer should point at the final a (offset 5). Hint: keep a const char *last = NULL; and update it on each match. Concepts: pointer walk, returning a pointer, NULL sentinel.

Challenge — In-place reverse. Write void reverse_in_place(char *s) that reverses a writable string in place using two pointers, one from each end, swapping and moving inward until they meet. Requirements: the parameter is char * (not const) because you modify it; must work for even and odd lengths and for the empty string. Input/output example: "abcd" becomes "dcba", "abc" becomes "cba". Constraint: do not allocate a second buffer. Hint: first find the end with a pointer walk, then swap *left and *right while left < right. Concepts: two-pointer technique, writable buffers, why const cannot be used here.

Summary

A C string is a run of char values ending in the NUL byte '\0'. The idiomatic way to process one is a pointer walk: *s reads the current character, s++ advances, and the loop for (; *s; s++) stops automatically at the terminator because '\0' is zero. Because arrays decay to pointers when passed to functions, code written for char * works on char[] and vice versa. The two const positions matter: const char * locks the characters (use it for read-only parameters) while char *const locks the pointer. Pointer subtraction gives a string's length: walk a copy to the NUL, then subtract the start.

Most common mistakes: writing to a string literal (crash — copy into a char[] instead), forgetting the '\0' when sizing a buffer (needs n + 1 bytes), confusing the two const forms, and walking past the terminator. Remember: always know whether your memory is writable, always guarantee a terminator, and reach for const char * whenever you are only reading.

Practice with these exercises