5. Arrays, Strings, and Pointers
This chapter is the heart of C. Arrays give you contiguous storage, pointers give you the addresses of that storage, and strings are what happens when you combine the two with a terminating zero byte — a design that makes C fast and flexible while placing every bounds check squarely on you. Understanding how arrays decay into pointers, what pointer arithmetic actually computes, and why a missing '\0' is a security bug rather than a typo is what separates C that works from C that only appears to.
<a id="51-arrays"></a>
5.1 Arrays
An array is a fixed-size, contiguous block of elements of one type, accessed by index. This section covers how to declare and initialize one, what happens at the boundaries, how multiple dimensions are laid out in memory, and the decay rule that quietly turns almost every array into a pointer.
Declaration and Initialization
Theory
When you need one integer you declare int x;. When you need a thousand, you do not want a thousand names — you want one name and a number to pick between them. That is an array: a fixed number of elements of the same type, laid out back to back in memory, with no gaps and no header.
That last part matters. An array in C is not an object with a length field, a capacity, or bounds information. It is just the elements. int a[5] occupies exactly 5 * sizeof(int) bytes and nothing else. This is why arrays are as fast as manually managed memory, and why the language cannot check anything for you at run time — there is nothing to check against.
The declaration reads outward from the name: int a[5]; means "a is an array of 5 int". The size must be a constant expression known at compile time (except for VLAs, covered later in this section).
Initialization has several forms, and the rules reward knowing them precisely:
int a[5] = {1, 2, 3, 4, 5}; /* all five given */
int b[5] = {1, 2}; /* b[2..4] are ZERO -- not garbage */
int c[5] = {0}; /* the classic "zero the whole array" */
int d[] = {1, 2, 3}; /* size deduced: 3 */
int e[5] = {}; /* C23: empty braces, all zero */
The key rule: if you provide any initializer, every element you did not mention is zero-initialized (as if by = 0 for scalars). That is why = {0} works — you initialize the first element explicitly and the rest by the rule. If you provide no initializer at all, storage duration decides: automatic arrays hold indeterminate values, static and global arrays are zeroed (Chapter 4.2).
C99 added designated initializers, which let you set specific elements by index and let the rest fall to zero. They make sparse tables readable and order-independent:
int lookup[10] = { [3] = 42, [7] = 99 }; /* everything else is 0 */
Giving more initializers than the declared size is an error. Character arrays get a special form covered in Chapter 5.4: char s[] = "hi"; creates a 3-byte array ('h', 'i', '\0').
To get the element count, never hard-code a number that can drift out of sync with the declaration. Use the sizeof idiom:
size_t n = sizeof a / sizeof a[0]; /* total bytes / bytes per element */
This works only where a is a real array. Inside a function that received the array as a parameter, a is a pointer and the idiom silently computes nonsense — the single most important caveat in this chapter, developed under "Array Decay to Pointers".
Examples
#include <stdio.h>
int global_arr[4]; /* static storage: zero-initialized */
int main(void)
{
int uninit[4]; /* automatic: INDETERMINATE, not zero */
int full[5] = {10, 20, 30, 40, 50};
int partial[5] = {10, 20}; /* -> {10, 20, 0, 0, 0} */
int zeroed[5] = {0}; /* -> all zero */
int deduced[] = {1, 2, 3, 4}; /* size is 4 */
int sparse[8] = { [2] = 7, [5] = 9 }; /* C99 designated init */
/* The count idiom: recomputed by the compiler, never drifts. */
size_t n = sizeof deduced / sizeof deduced[0];
printf("deduced has %zu elements, %zu bytes\n", n, sizeof deduced);
printf("partial : ");
for (size_t i = 0; i < 5; i++) printf("%d ", partial[i]);
printf("\nsparse : ");
for (size_t i = 0; i < 8; i++) printf("%d ", sparse[i]);
printf("\nglobal : %d %d (zeroed by the standard)\n",
global_arr[0], global_arr[3]);
(void)uninit; /* reading it would be undefined behavior */
(void)full; (void)zeroed;
return 0;
}
int full[5] = {10, 20, 30, 40, 50}; on a machine with 4-byte int
address: 0x100 0x104 0x108 0x10c 0x110
+-------+-------+-------+-------+-------+
| 10 | 20 | 30 | 40 | 50 |
+-------+-------+-------+-------+-------+
index: [0] [1] [2] [3] [4]
sizeof full = 20 bytes. No length field, no bounds, no header --
the array IS the 20 bytes.
Key Takeaways
- An array is a contiguous block of same-typed elements with no stored length —
sizeof ais the only record of its size, and only where the declaration is visible. - The size in
int a[N]must be a compile-time constant (VLAs excepted). - Any initializer zero-fills the elements you omit;
= {0}is the idiom for zeroing an array. - No initializer means indeterminate values for automatic arrays and zeros for static/global ones.
- Use designated initializers (
[3] = 42) for sparse tables, andsizeof a / sizeof a[0]for the element count — never a hard-coded number.
🧪 Practice
- Declare an
intarray of 6 elements initialized with only the first two values, print all six, and explain the last four.- Write a program that prints
sizeofand the element count for arrays ofchar,int, anddoublewith the same number of elements.- Build a
const char *day_name[7]table using designated initializers so the days appear out of order in the source but print in order.- Interview-style: "What is the difference between
int a[5] = {0};andint a[5];at file scope versus inside a function?" Hint: two independent rules are in play — the initializer rule and the storage-duration rule.
Indexing and Bounds
Theory
Indexing looks like a language feature but is really arithmetic. The standard defines it exactly:
a[i] is *(a + i)
The subscript operator is defined as pointer addition followed by a dereference. a decays to a pointer to its first element, a + i advances by i elements (not i bytes — see "Pointer Arithmetic"), and * reads what is there.
Two consequences follow immediately. First, indexing is zero-based not by convention but by construction: a[0] is *(a + 0), the element at the start. The valid indices for int a[5] are 0 through 4, and a[5] is one past the end.
Second, because addition commutes, a[i] and i[a] are the same expression. 3["hello"] is legal C and evaluates to 'l'. This is a curiosity rather than a technique, but it proves the point: there is no special "array indexing" machinery, only pointer arithmetic.
Now the part that defines C's reputation. There is no bounds checking. None. Writing a[7] on a five-element array is undefined behavior, and the compiler generates the same instruction it would for a valid index: compute the address, access it. What happens next depends on what lives there:
- Nothing visible — the memory belongs to your process and you corrupted an unrelated variable.
- A crash — the address is unmapped.
- A security hole — you overwrote a return address or a function pointer.
Undefined behavior also means the compiler may assume it never happens, and optimize accordingly. This is why out-of-bounds bugs can change behavior between -O0 and -O2 and vanish when you add a printf to investigate.
The permanent defenses are three:
Always derive the bound from the array. for (size_t i = 0; i < sizeof a / sizeof a[0]; i++), or a named constant used in both the declaration and the loop.
Watch the off-by-one. <= in a loop condition, or looping to n when indices stop at n-1, is the most common single bug in C. Half-open ranges (i < n) exist precisely to avoid it.
Use the tools. -fsanitize=address catches out-of-bounds accesses at run time with a precise report; -fsanitize=undefined catches more; -Warray-bounds catches some at compile time. There is no reason to debug memory corruption by hand in 2026.
One legal edge case worth knowing: computing (but not dereferencing) the address one past the end is defined, which is what makes for (int *p = a; p != a + n; p++) valid. One before the beginning is not.
Examples
#include <stdio.h>
int main(void)
{
int a[5] = {10, 20, 30, 40, 50};
size_t n = sizeof a / sizeof a[0];
/* a[i] is literally *(a + i) -- all four forms are identical. */
printf("%d %d %d %d\n", a[2], *(a + 2), *(2 + a), 2[a]);
/* Correct traversal: half-open range, bound derived from the array. */
for (size_t i = 0; i < n; i++)
printf("a[%zu] = %d\n", i, a[i]);
/* Pointer traversal: 'a + n' is a legal address to COMPUTE,
though *(a + n) would be undefined to read. */
for (int *p = a; p != a + n; p++)
printf("%d ", *p);
putchar('\n');
/* --- The classic bugs, all undefined behavior --- */
/* for (size_t i = 0; i <= n; i++) sum += a[i]; off-by-one: reads a[5] */
/* a[5] = 99; one past the end */
/* a[-1] = 99; before the beginning */
/* int i = 10; printf("%d", a[i]); far out of bounds */
return 0;
}
int a[5]; valid indices 0..4
a[0] a[1] a[2] a[3] a[4] | a[5]
+------+------+------+------+------+· · · · ·
| 10 | 20 | 30 | 40 | 50 | ???
+------+------+------+------+------+· · · · ·
^ ^
a a + 5
address is LEGAL to compute
(loop sentinel), ILLEGAL to read
What a[5] = 99 might hit: another local, a saved register,
the return address, or nothing at all.
The compiler emits the write either way.
# Let the sanitizer find it instead of guessing:
gcc -g -fsanitize=address,undefined -o prog prog.c && ./prog
# ==12345==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x...
# WRITE of size 4 at 0x... thread T0
# #0 0x... in main prog.c:14
Key Takeaways
a[i]is defined as*(a + i); indexing is pointer arithmetic, which is why indices start at 0 and whyi[a]compiles.- C performs no bounds checking; an out-of-range access is undefined behavior that may corrupt memory silently.
- Valid indices for
T a[N]are0toN-1; the addressa + Nmay be computed but never dereferenced. - Derive loop bounds from the array itself and prefer half-open ranges (
i < n) to avoid off-by-one errors. - Develop with
-fsanitize=address,undefined; it turns silent corruption into a precise diagnostic.
🧪 Practice
- Write a loop that deliberately reads
a[n]on a 5-element array. Run it at-O0, then at-O2, then under-fsanitize=address, and compare the three outcomes.a[2],*(a + 2), and2[a]for the same array and explain why they agree.- Write
sum_array(const int *a, size_t n)with an intentional<=bug, observe the wrong total, then fix it and explain what the extra iteration read.- Interview-style: "Why does C not bounds-check arrays, and what would it cost if it did?" Hint: think about what an array actually is in memory, and what extra data every access would need to consult.
Multidimensional Arrays
Theory
C has no true two-dimensional array. What it has is arrays of arrays, and the distinction explains everything that follows.
int grid[3][4]; declares an array of 3 elements, each of which is an array of 4 int. Read the declaration left to right for dimensions and inside out for meaning: grid is an array[3] of array[4] of int. Therefore grid[1] is not a scalar — it is an entire int[4] — and grid[1][2] indexes into that inner array.
The memory layout is row-major: all of row 0, then all of row 1, then all of row 2, in one contiguous block of 12 integers. There are no pointers involved, no row table, no indirection. The address of grid[i][j] is:
base + (i * columns + j) * sizeof(int)
which the compiler computes for you — and which is exactly why the column count must be part of the type. Given only "a pointer to some ints", nothing can compute i * columns.
Row-major layout has a real performance consequence. CPUs load memory in cache lines (typically 64 bytes) and prefetch forward. Iterating rows in the outer loop and columns in the inner loop walks memory sequentially and hits cache; swapping the loops strides across memory and can be several times slower for a large matrix, purely from cache misses.
Initialization can be nested or flat. Nested braces are clearer and let each row zero-fill independently:
int g[3][4] = {
{1, 2, 3, 4},
{5, 6}, /* -> {5, 6, 0, 0} */
{0} /* -> {0, 0, 0, 0} */
};
int flat[2][3] = {1, 2, 3, 4, 5, 6}; /* legal: fills in row-major order */
Only the first dimension may be omitted when there is an initializer, since the compiler can count the rows but must be told the row width.
The same reasoning extends to three dimensions and beyond: int cube[2][3][4] is an array of 2 arrays of 3 arrays of 4 ints, laid out with the rightmost index varying fastest.
Do not confuse int grid[3][4] with int *rows[3] (an array of 3 pointers, each to a separately allocated row). Both support x[i][j] syntax, but the first is one contiguous block with computed offsets, and the second is a table of pointers with a real indirection per access. That comparison belongs to "Arrays of Pointers" in Chapter 5.3.
Examples
#include <stdio.h>
int main(void)
{
int grid[3][4] = {
{ 1, 2, 3, 4},
{ 5, 6, 7, 8},
{ 9, 10, 11, 12}
};
/* grid[i] is an int[4], not an int. */
printf("sizeof grid = %zu\n", sizeof grid); /* 48 = 3*4*4 */
printf("sizeof grid[0] = %zu\n", sizeof grid[0]); /* 16 = 4*4 */
printf("sizeof grid[0][0] = %zu\n", sizeof grid[0][0]); /* 4 */
size_t rows = sizeof grid / sizeof grid[0]; /* 3 */
size_t cols = sizeof grid[0] / sizeof grid[0][0]; /* 4 */
for (size_t i = 0; i < rows; i++) {
for (size_t j = 0; j < cols; j++)
printf("%3d ", grid[i][j]);
putchar('\n');
}
/* Proof of contiguity: read the whole thing as one flat block. */
int *flat = &grid[0][0];
printf("flat: ");
for (size_t k = 0; k < rows * cols; k++) printf("%d ", flat[k]);
putchar('\n');
/* The compiler's address formula, made explicit. */
printf("grid[2][1] = %d, flat[2*4 + 1] = %d\n", grid[2][1], flat[2 * 4 + 1]);
return 0;
}
int grid[3][4] -- row-major, ONE contiguous block of 12 ints
grid[0] grid[1] grid[2]
+---+---+---+---+ +---+---+---+---+ +---+---+---+---+
| 1 | 2 | 3 | 4 | | 5 | 6 | 7 | 8 | | 9 |10 |11 |12 |
+---+---+---+---+ +---+---+---+---+ +---+---+---+---+
0 1 2 3 4 5 6 7 8 9 10 11 <- flat offset
address of grid[i][j] = base + (i*4 + j) * sizeof(int)
^
the column count MUST be in the type
Cache behavior of the two loop orders:
/* FAST: sequential memory access, one cache line serves several elements. */
for (size_t i = 0; i < ROWS; i++)
for (size_t j = 0; j < COLS; j++)
sum += m[i][j];
/* SLOW: each step jumps COLS*sizeof(int) bytes -- a cache miss per access. */
for (size_t j = 0; j < COLS; j++)
for (size_t i = 0; i < ROWS; i++)
sum += m[i][j];
Key Takeaways
int a[R][C]is an array ofRarrays ofCints, stored contiguously in row-major order — there is no row-pointer table.a[i]has typeint[C], sosizeof a[0]gives the row size andsizeof a / sizeof a[0]gives the row count.- The address formula is
base + (i*C + j)*sizeof(T), which is why the column count is part of the type and cannot be omitted. - Only the leftmost dimension may be left empty when an initializer is present.
- Iterate with the last index in the innermost loop; the reverse order strides across cache lines and is measurably slower.
🧪 Practice
- Declare
int m[2][3], fill it with nested braces, and printsizeofform,m[0], andm[0][0]. Explain each number.- Access a 2D array through a flat
int *pointer and verify thatflat[i*cols + j] == m[i][j]for every element.- Time summing a 2000x2000
intmatrix row-major versus column-major, and report the ratio.- Interview-style: "Why can you omit the first dimension of a 2D array parameter but not the second?" Hint: write out the address formula and see which numbers it needs.
Array Decay to Pointers
Theory
This is the single most important rule in C, and the one that causes the most confusion, so it is worth stating exactly:
In most expressions, an array is implicitly converted ("decays") to a pointer to its first element.
a of type int[5] becomes an int * pointing at a[0]. The conversion is automatic, invisible, and happens before almost anything else. It is why you can write p = a;, why a[i] works as pointer arithmetic, and why passing an array to a function passes an address.
Why does the language do this? Because copying arrays implicitly would be expensive and usually unwanted. C's designers made arrays "second-class" values: you cannot assign one array to another, pass one by value, or return one. What you manipulate instead is a pointer to the first element — cheap, uniform, and explicit about the fact that no copy occurred.
There are exactly three exceptions, and memorizing them is worth the effort because everything surprising about arrays traces back to one of them:
| Context | Result | Why it matters |
|---|---|---|
sizeof a | Size of the whole array in bytes | The one place the real size survives |
&a | Pointer to the array: type int (*)[5] | Different type from &a[0], though the same address |
String literal initializing a char array | Copies the characters | char s[] = "hi" creates an array, not a pointer |
(C11 adds _Alignof, which behaves like sizeof here.)
The &a exception deserves a picture. For int a[5]:
adecays toint *— pointing at the first element.a + 1moves 4 bytes.&a[0]isint *— the same address, the same arithmetic.&aisint (*)[5]— the same address, but+1moves 20 bytes, past the whole array.
All three print identically with %p. Only the types differ, and the type is what determines the arithmetic.
The decay rule is also why the sizeof count idiom breaks across function boundaries. Inside void f(int a[5]), the parameter's type is adjusted to int *; the 5 is documentation the compiler ignores. sizeof a yields the pointer size, and any element count derived from it is wrong. GCC and Clang warn with -Wsizeof-array-argument — heed it.
Finally, note that decay applies to the outer dimension only. A 2D array int g[3][4] decays to int (*)[4] — a pointer to an array of 4 ints — not to int **. Those two types are not interchangeable, and confusing them is a common source of type errors.
Examples
#include <stdio.h>
void takes_pointer(int a[5]) /* the '5' is ignored: really int *a */
{
printf("inside : sizeof a = %zu (a POINTER)\n", sizeof a);
}
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
/* --- Exception 1: sizeof sees the real array --- */
printf("caller : sizeof a = %zu (the whole ARRAY)\n", sizeof a);
takes_pointer(a); /* decays here */
/* --- Decay in ordinary expressions --- */
int *p = a; /* implicit: p = &a[0] */
printf("a[2]=%d p[2]=%d *(a+2)=%d\n", a[2], p[2], *(a + 2));
/* --- Exception 2: &a has a different TYPE, same address --- */
printf("a = %p\n", (void *)a);
printf("&a[0] = %p\n", (void *)&a[0]);
printf("&a = %p (same address, type int (*)[5])\n", (void *)&a);
printf("a + 1 = %p (+4 bytes: one int)\n", (void *)(a + 1));
printf("&a + 1 = %p (+20 bytes: one whole array)\n", (void *)(&a + 1));
/* --- Exception 3: string literal initializing a char array --- */
char s[] = "hi"; /* COPIES 3 bytes into a modifiable array */
const char *q = "hi"; /* points at a read-only literal */
printf("sizeof s = %zu, sizeof q = %zu\n", sizeof s, sizeof q); /* 3, 8 */
/* --- 2D decay: to int (*)[4], NOT int ** --- */
int g[3][4] = {{0}};
int (*row)[4] = g; /* correct type for a decayed 2D array */
/* int **wrong = g; would not compile -- incompatible */
printf("row+1 advances %zu bytes\n",
(size_t)((char *)(row + 1) - (char *)row)); /* 16 */
return 0;
}
int a[5];
a -> decays to int * : points at a[0], +1 moves 4 bytes
&a[0] -> int * : identical to the above
&a -> int (*)[5] : SAME address, +1 moves 20 bytes
sizeof a -> 20 : no decay here
+----+----+----+----+----+
| a0 | a1 | a2 | a3 | a4 |
+----+----+----+----+----+
^ ^
| &a + 1 (past the whole array)
a, &a[0], &a (one address, three types)
|
a + 1 --> points here (a[1])
Key Takeaways
- In nearly every expression an array converts to a pointer to its first element; this is why arrays cannot be assigned, copied, or passed by value.
- The three exceptions are
sizeof(and_Alignof),&array, and a string literal initializing achararray. a,&a[0], and&ashare an address but not a type —&aisT (*)[N], so its arithmetic steps over the entire array.- Inside a function, an array parameter is a pointer:
sizeofon it gives the pointer size, so always pass the length separately. - A 2D array decays to
T (*)[C], never toT **.
🧪 Practice
sizeoffor an array inmainand inside a function that takes it asint a[10]. Explain both values and note the compiler warning.a,&a[0],&a,a + 1, and&a + 1forint a[5]and calculate the byte differences.- Try to assign
int **p = g;for a 2D arrayg, read the error, then write the correct declaration.- Interview-style: "Are arrays and pointers the same thing in C?" Hint: the answer is no, and the useful reply names the three contexts where the difference is visible.
Passing Arrays to Functions
Theory
Following directly from decay: you cannot pass an array to a function in C. You pass a pointer to its first element, and the callee receives an address, not a copy. This is efficient — no matter how large the array, the call costs one pointer — but it has two consequences the caller must handle.
The callee does not know the length. It must be told. Every array-taking function in C therefore has a companion length parameter, and getting that convention right is your responsibility. The standard library's memcpy(dst, src, n) and fgets(buf, size, stream) are the model.
The callee can modify the caller's data. There is no copy to protect the original, so mark the parameter const when you do not intend to write through it. const int *a is enforced by the compiler and documents the contract in the signature.
The three parameter spellings are equivalent — pick one and be consistent:
void f(int *a, size_t n); /* honest: it really is a pointer */
void f(int a[], size_t n); /* conventional: signals "array" */
void f(int a[5], size_t n); /* the 5 is documentation only */
C99 adds a fourth, genuinely different form: void f(int a[static 5]) is a promise from the caller that a points to at least 5 elements and is not null. Compilers use it for warnings and optimization; it does not change the parameter's type.
Multidimensional arrays need more care, because the address formula needs the column count:
void f(int g[3][4]); /* adjusted to: int (*g)[4] */
void f(int g[][4]); /* same thing */
void f(int (*g)[4]); /* the actual type */
void f(size_t r, size_t c, int g[r][c]); /* C99 VLA parameter: fully general */
Note the parentheses in int (*g)[4] — "pointer to array of 4 int". Without them, int *g[4] means "array of 4 pointers to int", a completely different type (Chapter 5.3).
If you genuinely want a copy — pass-by-value semantics for a block of data — wrap the array in a struct. Structs are copied on assignment and on passing, which is one practical reason to prefer them for small fixed buffers:
struct vec3 { double v[3]; };
void f(struct vec3 v); /* the whole struct, array included, is copied */
Returning arrays has the same restriction: you cannot return an array type. Return a pointer to storage that outlives the call (heap or static), fill a caller-supplied buffer (the usual and safest choice), or return a struct.
Examples
#include <stdio.h>
#include <string.h>
/* Read-only: 'const' documents and enforces that we do not write. */
long sum(const int *a, size_t n)
{
long total = 0;
for (size_t i = 0; i < n; i++) total += a[i];
return total;
}
/* Mutating: no const, because we intend to modify the caller's array. */
void scale(int *a, size_t n, int factor)
{
for (size_t i = 0; i < n; i++) a[i] *= factor;
}
/* C99: 'static 5' promises at least 5 elements and a non-null pointer. */
int first_five_sum(const int a[static 5])
{
return a[0] + a[1] + a[2] + a[3] + a[4];
}
/* 2D: the column count must be in the type. */
void print_grid(size_t rows, const int g[][4])
{
for (size_t i = 0; i < rows; i++) {
for (size_t j = 0; j < 4; j++) printf("%3d ", g[i][j]);
putchar('\n');
}
}
/* Fully general 2D via C99 VLA parameters: dimensions come first. */
long sum_2d(size_t rows, size_t cols, const int g[rows][cols])
{
long total = 0;
for (size_t i = 0; i < rows; i++)
for (size_t j = 0; j < cols; j++)
total += g[i][j];
return total;
}
/* Copy semantics, achieved by wrapping the array in a struct. */
struct vec3 { double v[3]; };
struct vec3 doubled(struct vec3 in) /* 'in' is a genuine copy */
{
for (int i = 0; i < 3; i++) in.v[i] *= 2;
return in; /* the caller's original is untouched */
}
/* Caller-supplied output buffer: the safest way to "return" an array. */
int make_range(int *out, size_t cap, int start, int count)
{
if ((size_t)count > cap) return -1; /* never write past the caller's buffer */
for (int i = 0; i < count; i++) out[i] = start + i;
return 0;
}
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
size_t n = sizeof a / sizeof a[0];
printf("sum = %ld\n", sum(a, n));
scale(a, n, 10);
printf("after scale = %ld (caller's array WAS modified)\n", sum(a, n));
printf("first_five = %d\n", first_five_sum(a));
int g[2][4] = {{1, 2, 3, 4}, {5, 6, 7, 8}};
print_grid(2, g);
printf("sum_2d = %ld\n", sum_2d(2, 4, g));
struct vec3 v = {{1.0, 2.0, 3.0}};
struct vec3 d = doubled(v);
printf("struct copy : v.v[0]=%.1f d.v[0]=%.1f\n", v.v[0], d.v[0]);
int buf[8];
if (make_range(buf, 8, 100, 4) == 0)
printf("range : %d %d %d %d\n", buf[0], buf[1], buf[2], buf[3]);
return 0;
}
Caller Callee: sum(const int *a, size_t n)
+--------------------+
| a[0] a[1] ... a[4] |<--------- a (a COPY of the address)
+--------------------+ n (a copy of the length)
^
one array, no copy made. The callee reads and writes
the caller's memory directly -- 'const' is the only guard.
Key Takeaways
- Arrays are never passed by value; the callee receives a pointer, so always pass an explicit length alongside it.
int *a,int a[], andint a[5]are the same parameter type;int a[static 5]adds a caller promise of at least 5 elements.- Use
const T *for read-only parameters — it is compiler-enforced documentation. - 2D parameters need the column count:
int (*g)[4], or C99int g[rows][cols]with the dimensions passed first. - To get copy semantics, wrap the array in a struct; to "return" an array, fill a caller-supplied buffer and take its capacity as a parameter.
🧪 Practice
- Write
void reverse(int *a, size_t n)that reverses an array in place, and verify from the caller that the original was modified.- Write a function that takes
const int *and try to assign to an element; read the compiler error and explain whatconstprotected.- Write
long sum_2d(size_t rows, size_t cols, const int g[rows][cols])and call it with two differently shaped arrays.- Interview-style: "How would you write a function that returns a new array of results?" Hint: there are three viable designs; compare who allocates and who frees in each.
Variable-Length Arrays
Theory
Until C99, array sizes had to be compile-time constants. If the size was only known at run time, you either allocated on the heap with malloc or over-allocated a fixed maximum and hoped. C99 introduced variable-length arrays: automatic arrays whose size is an ordinary run-time expression.
void f(size_t n)
{
int buf[n]; /* a VLA: size determined when control reaches here */
}
The size is evaluated once, on entry to the block, and the array lives on the stack like any other automatic object (Chapter 4.2), disappearing at the end of its block. This makes VLAs cheap — a stack-pointer adjustment rather than an allocator call — and automatically cleaned up.
Their most useful form is as function parameters, where they finally allow a genuinely generic 2D array function:
void print(size_t rows, size_t cols, int g[rows][cols]);
The dimensions must be declared before use, which is why they come first in the parameter list. This is a real gain over int (*g)[4], which hard-codes the width.
The restrictions follow from being run-time-sized:
- No initializer.
int a[n] = {0};is invalid — write a loop ormemset. - No
staticor file scope. VLAs are strictly automatic. - Not a struct member. (Flexible array members solve a different problem; Chapter 6.1.)
sizeofis evaluated at run time for a VLA and yields the actual byte count — the one case wheresizeofis not a compile-time constant.gotomay not jump into a VLA's scope, since that would skip the allocation.
Now the serious caveat. A VLA whose size comes from untrusted input is a stack-overflow vulnerability. The stack is small (about 8 MB on Linux, 1 MB on Windows) and there is no way to detect failure — no null return, no error code. int buf[n] with a large n simply moves the stack pointer past the guard page, and the program crashes or, worse, the allocation skips the guard page entirely and corrupts other memory. Always validate the size against a fixed cap before declaring a VLA, or use malloc, which reports failure by returning NULL.
Support is also uneven: C11 made VLAs optional (a conforming implementation may define __STDC_NO_VLA__), and MSVC has never supported them. The Linux kernel removed VLAs entirely for the security reasons above. C23 requires VLA parameter types while keeping VLA objects optional.
The practical rule: use VLA parameter types freely for multidimensional functions; use VLA objects only for small, bounded, internally computed sizes; use malloc for anything large or externally influenced.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* The best use of VLAs: a genuinely generic 2D parameter.
Note the dimensions must be declared BEFORE the array that uses them. */
long sum_matrix(size_t rows, size_t cols, const int m[rows][cols])
{
long total = 0;
for (size_t i = 0; i < rows; i++)
for (size_t j = 0; j < cols; j++)
total += m[i][j];
return total;
}
/* A VLA object, with the size safely bounded. */
void process(size_t n)
{
if (n == 0 || n > 1024) { /* MANDATORY: bound the size */
fprintf(stderr, "n out of range\n");
return;
}
int buf[n]; /* no initializer allowed */
memset(buf, 0, sizeof buf); /* sizeof is a RUN-TIME value here */
for (size_t i = 0; i < n; i++) buf[i] = (int)(i * i);
printf("n=%zu sizeof buf=%zu last=%d\n", n, sizeof buf, buf[n - 1]);
}
/* The safe alternative when the size is large or untrusted. */
int process_heap(size_t n)
{
int *buf = malloc(n * sizeof *buf);
if (!buf) return -1; /* failure is REPORTABLE, unlike a VLA */
for (size_t i = 0; i < n; i++) buf[i] = (int)i;
printf("heap: last = %d\n", buf[n - 1]);
free(buf);
return 0;
}
int main(void)
{
#if defined(__STDC_NO_VLA__)
puts("This implementation does not support VLAs.");
#else
process(10);
process(2000); /* rejected by the bound check */
int m[3][4] = {{1,2,3,4},{5,6,7,8},{9,10,11,12}};
printf("sum_matrix = %ld\n", sum_matrix(3, 4, m));
int wide[2][6] = {{1,1,1,1,1,1},{2,2,2,2,2,2}};
printf("sum_matrix = %ld (different shape, same function)\n",
sum_matrix(2, 6, wide));
#endif
process_heap(1000000); /* far too big for the stack */
return 0;
}
| Aspect | VLA int a[n] | malloc(n * sizeof(int)) |
|---|---|---|
| Where | Stack | Heap |
| Cost | Pointer adjustment | Allocator call |
| Failure reported? | No — crash | Yes — returns NULL |
| Cleanup | Automatic at block exit | Manual free |
| Max practical size | Kilobytes | Gigabytes |
| Lifetime | Until block ends | Until you free it |
| Portability | Optional since C11; no MSVC | Universal |
Stack with a VLA
+------------------+ <- stack base
| main's frame |
+------------------+
| process's frame |
| n, i |
| buf[n] <-- size chosen at RUN TIME; the stack pointer just moves
+------------------+
| ... |
+------------------+
| GUARD PAGE | <- a huge n jumps straight past this:
+------------------+ no diagnostic, no NULL, just corruption
Key Takeaways
- A VLA is an automatic array whose size is a run-time expression, allocated on the stack and freed at block exit.
- VLAs take no initializer, cannot be
staticor at file scope, and cannot be struct members;sizeofon one is evaluated at run time. - VLA parameters (
void f(size_t r, size_t c, int g[r][c])) are the feature's best use — genuinely generic multidimensional functions. - A VLA sized from untrusted input is a stack-overflow vulnerability with no way to detect failure; always bound the size or use
malloc. - VLA objects are optional since C11 (
__STDC_NO_VLA__) and unsupported by MSVC; prefer heap allocation for anything large or portable.
🧪 Practice
- Write a function taking
size_t nthat declaresint buf[n], fills it, and printssizeof buf. Confirm the size changes withn.- Call it with
n = 100000000and observe the crash. Then add a bound check and amallocfallback.- Write
void transpose(size_t r, size_t c, const int in[r][c], int out[c][r])and test it on a non-square matrix.- Interview-style: "Why did the Linux kernel remove all VLAs from its codebase?" Hint: think about kernel stack sizes, attacker-controlled lengths, and what failure looks like when there is no return value to check.
<a id="52-pointer-fundamentals"></a>
5.2 Pointer Fundamentals
A pointer holds the address of an object. That one idea gives C its ability to modify a caller's data, walk arrays efficiently, build linked structures, and manage memory directly. This section builds pointers from the ground up: the two operators, what the type means, the null pointer, the arithmetic, and the qualifiers that keep it all honest.
Address-of and Dereference Operators
Theory
Every object in a running program lives somewhere in memory, and every location has a numeric address. Think of memory as a very long street of numbered houses, each holding one byte. A pointer is a slip of paper with a house number written on it.
Two operators connect values and addresses, and they are exact inverses:
&x— the address-of operator. Yields the address wherexlives. "Where is it?"*p— the dereference (or indirection) operator. Yields the object at the addresspholds. "What is there?"
So *&x is x, always. Reading the declaration syntax the same way helps: int *p; can be read as "*p is an int", which is why the * is conventionally attached to the name rather than the type.
Why is this worth having? Three reasons dominate:
- Modifying a caller's variable. C passes everything by value (Chapter 4.1), so a function that must change something takes its address.
- Avoiding copies. Passing a 4 KB struct copies 4 KB; passing its address copies 8 bytes.
- Building structures whose shape is not known in advance — linked lists, trees, dynamically sized buffers.
Two constraints keep the operators safe. & requires an lvalue: something with a location. &42 and &(x + 1) are errors because literals and temporaries have no address. And * requires a pointer that actually points at a valid, live object — dereferencing a null, uninitialized, or dangling pointer is undefined behavior.
Note the syntactic overload: * means multiplication between two operands, dereference before one; & means bitwise AND between two operands, address-of before one. Context disambiguates them.
Finally, %p is the correct printf specifier for a pointer, and the argument must be cast to void * — it is a variadic function with no conversion (Chapter 4.4).
Examples
#include <stdio.h>
void increment(int *p) /* takes an ADDRESS, so it can modify the original */
{
(*p)++; /* parentheses matter: *p++ would increment the POINTER */
}
int main(void)
{
int x = 42;
int *p = &x; /* p holds the address of x */
printf("x = %d\n", x);
printf("&x = %p\n", (void *)&x); /* where x lives */
printf("p = %p\n", (void *)p); /* the same address */
printf("*p = %d\n", *p); /* what is there: 42 */
printf("&p = %p\n", (void *)&p); /* p itself has an address too */
*p = 100; /* write THROUGH the pointer */
printf("after *p = 100, x = %d\n", x); /* 100 -- same object */
increment(&x);
printf("after increment(&x), x = %d\n", x); /* 101 */
/* The operators are inverses: *&x is just x. */
printf("x = %d, *&x = %d (the * undoes the &)\n", x, *&x);
/* int *bad; *bad = 5; undefined: uninitialized pointer */
/* int *q = &42; error: literals have no address */
return 0;
}
Memory (addresses simplified)
address contents name
-------- ------------- ------
0x7ffc10 | 42 | x
0x7ffc18 | 0x7ffc10 | p <- p HOLDS the address of x
&x -> 0x7ffc10 "where is x?"
p -> 0x7ffc10 same value
*p -> 42 "what is at 0x7ffc10?"
&p -> 0x7ffc18 p is an object too, with its own address
p
+-----------+ +-------+
| 0x7ffc10 | -------> | 42 | x
+-----------+ +-------+
Key Takeaways
&xgives the address of an object;*pgives the object at an address. They are inverses:*&xisx.- Read
int *p;as "*pis anint" — the*belongs with the name. - Pointers exist to let functions modify a caller's data, to avoid copying large objects, and to build dynamic structures.
&needs an lvalue: literals and arithmetic results have no address.- Dereferencing an uninitialized, null, or dangling pointer is undefined behavior; print pointers with
%pand a(void *)cast.
🧪 Practice
- Declare an
int, take its address, print the value through the pointer, then modify it through the pointer and print the original variable.- Write
void swap(int *a, int *b)and prove frommainthat both variables changed.&x,p, and&pforint x; int *p = &x;and explain in one sentence what each address refers to.- Interview-style: "What does
*p++do, and how does it differ from(*p)++?" Hint: consult the precedence of postfix++versus unary*, and say which object each version modifies.
Pointer Declaration and Types
Theory
A pointer is not just "an address". It is an address plus a type, and the type is what makes the pointer useful.
Consider the address 0x1000. On its own it says nothing: is there an int there? A char? The first byte of a struct? The pointer's type answers three questions the hardware cannot:
- How many bytes to read or write when you dereference.
*(char *)ptouches 1 byte;*(double *)ptouches 8. - How to interpret those bytes — as a two's complement integer, an IEEE 754 float, a pointer, and so on.
- How far
p + 1moves — bysizeof(*p)bytes, not by one byte. This is the whole basis of pointer arithmetic.
All object pointers are the same size on a given platform (8 bytes on typical 64-bit systems), because they all hold an address. But they are not the same type, and C will not implicitly convert between them — int *p = &some_double; is a constraint violation, not a warning to ignore.
Declaration syntax has one classic trap:
int *a, b; /* a is int *, b is plain int -- the * binds to the NAME */
int *a, *b; /* both are pointers */
int* a, b; /* SAME as the first line: the spacing is a lie */
The * is part of the declarator, not the type specifier. This is why int *p; (star with the name) is the dominant C style, while int* p; (star with the type) reads as if it distributes across the declaration when it does not. The safest habit is one declaration per line.
Pointers must be initialized before use. An uninitialized pointer holds whatever was on the stack — dereferencing it is undefined behavior and may corrupt anything. Initialize to NULL if you have no target yet.
Some types worth naming:
| Declaration | Meaning |
|---|---|
int *p | pointer to int |
const int *p | pointer to constant int (cannot write *p) |
int *const p | constant pointer to int (cannot reassign p) |
void *p | pointer to unknown type (no arithmetic, no dereference) |
int **p | pointer to pointer to int |
int (*p)[5] | pointer to array of 5 int |
int (*p)(void) | pointer to function returning int |
struct node *p | pointer to a struct — may point to an incomplete type |
That last row is worth noting: you can declare a pointer to a type whose definition you have not seen. This is what makes opaque pointers (Chapter 4.3) and self-referential structs possible.
Examples
#include <stdio.h>
int main(void)
{
int i = 65;
double d = 3.14;
char c = 'A';
int *pi = &i;
double *pd = &d;
char *pc = &c;
/* Every pointer is the same SIZE ... */
printf("sizeof(int*)=%zu sizeof(double*)=%zu sizeof(char*)=%zu\n",
sizeof pi, sizeof pd, sizeof pc);
/* ... but the type decides how much is read and how far +1 moves. */
printf("sizeof(*pi)=%zu sizeof(*pd)=%zu sizeof(*pc)=%zu\n",
sizeof *pi, sizeof *pd, sizeof *pc);
printf("pi+1 advances %zu bytes\n", (size_t)((char *)(pi + 1) - (char *)pi));
printf("pd+1 advances %zu bytes\n", (size_t)((char *)(pd + 1) - (char *)pd));
printf("pc+1 advances %zu bytes\n", (size_t)((char *)(pc + 1) - (char *)pc));
/* Same address, different interpretation. */
unsigned char *bytes = (unsigned char *)&i; /* legal: char* may alias anything */
printf("i = %d, first byte = 0x%02x\n", i, bytes[0]);
/* The declaration trap. */
int *a, b; /* a is a pointer; b is a plain int */
a = &i; b = 5;
printf("a points to %d, b = %d\n", *a, b);
/* double *wrong = &i; ERROR: incompatible pointer types */
return 0;
}
One address, three interpretations
memory: ... 41 00 00 00 1f 85 eb 51 b8 1e 09 40 ...
^
0x1000
(char *)0x1000 -> reads 1 byte -> 0x41 = 'A'
(int *)0x1000 -> reads 4 bytes -> 65 (little-endian)
(double *)0x1000 -> reads 8 bytes -> some float value
The bytes never changed. The TYPE decided what they mean.
Key Takeaways
- A pointer's type determines how many bytes a dereference touches, how they are interpreted, and how far
p + 1moves. - All object pointers are typically the same size, but they are distinct types and do not implicitly convert.
- The
*binds to the declarator:int *a, b;declares one pointer and oneint— declare one variable per line. - Always initialize pointers; use
NULLwhen there is no target yet. - A pointer to an incomplete type is legal, which is what enables opaque handles and self-referential structs.
🧪 Practice
- Declare pointers to
char,int, anddouble, and print bothsizeof(pointer)andsizeof(*pointer)for each.- Write
int *a, b;, assign to both, and print their types' behavior; then fix the declaration so both are pointers.- Take the address of an
int, cast it tounsigned char *, and print all four bytes in hex. Explain the order you see.- Interview-style: "If all pointers are 8 bytes, why does C need pointer types at all?" Hint: give three things the compiler cannot do without the type, one of which involves
+ 1.
NULL Pointers
Theory
Sometimes a pointer needs to mean "nothing here": a search found no match, a list has no next node, an allocation failed. C reserves one value for that — the null pointer — guaranteed to compare unequal to any valid object address.
NULL is a macro from <stddef.h> (and <stdio.h>, <stdlib.h>, and others), defined as an implementation-defined null pointer constant, usually ((void *)0) or plain 0. Any pointer assigned the constant 0 becomes null, which is why if (p == 0) and if (p == NULL) are equivalent. C23 adds nullptr, a properly typed null pointer constant that avoids the ambiguities of 0 in variadic and generic contexts.
Do not confuse three different "nothings":
| Thing | What it is | Where used |
|---|---|---|
NULL | Null pointer constant | Pointers |
'\0' | The character with value 0 | String terminator |
0 | The integer zero | Arithmetic |
They may share a bit pattern, but conflating them in code obscures intent.
Dereferencing a null pointer is undefined behavior. In practice, on hosted systems, the page at address 0 is deliberately left unmapped so the hardware traps the access — that is what a segmentation fault usually is. That is a courtesy of the operating system, not a guarantee of the language: on some embedded systems address 0 is real memory, and the write silently corrupts it.
Idiomatically, the null test is written by truthiness, because any nonzero scalar is true (Chapter 3.1):
if (p) { /* p is not null */ }
if (!p) { /* p is null */ }
Three habits make null handling reliable:
Check what can fail. malloc, fopen, strchr, getenv, and most search functions return NULL on failure. Checking is not optional.
Initialize to NULL. An uninitialized pointer is worse than a null one: it holds garbage that may point at valid memory, so the bug does not trap — it corrupts.
Null out after freeing. free(p); p = NULL; converts a dangling pointer (undefined behavior, unpredictable) into a null pointer (an immediate, reproducible crash). And since free(NULL) is explicitly a no-op, a double free becomes harmless.
That last guarantee is worth remembering as a design pattern: several standard functions accept NULL and do nothing, which lets cleanup code stay branch-free. Writing your own destructors the same way (if (!p) return;) is good practice.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Sentinel convention: NULL means "not found". */
const char *find(const char *hay, char needle)
{
for (; *hay; hay++)
if (*hay == needle) return hay;
return NULL;
}
/* Destructor style: tolerate NULL so callers need no guard. */
void destroy(char *p)
{
if (!p) return; /* the same courtesy free() extends to us */
free(p);
}
int main(void)
{
int *p = NULL; /* initialize: no target yet */
printf("p is %s\n", p ? "not null" : "null");
/* *p = 5; undefined behavior -- typically a segmentation fault */
/* Every allocation can fail. */
int *buf = malloc(10 * sizeof *buf);
if (buf == NULL) { /* mandatory check */
fprintf(stderr, "allocation failed\n");
return EXIT_FAILURE;
}
buf[0] = 1;
free(buf);
buf = NULL; /* dangling -> null: turns silent UB into a clean crash */
free(buf); /* explicitly safe: free(NULL) does nothing */
/* Sentinel returns must be tested before use. */
const char *hit = find("hello", 'z');
printf("find 'z': %s\n", hit ? hit : "(not found)");
/* fopen is the classic forgotten check. */
FILE *f = fopen("/definitely/not/here", "r");
if (!f) { perror("fopen"); }
else { fclose(f); }
destroy(NULL); /* harmless by design */
return 0;
}
Three states a pointer can be in
valid p ---> [ live object ] dereference: fine
null p ---> (address 0) dereference: traps immediately
dangling p ---> [ freed / dead ] dereference: UNDEFINED, may
appear to work, may corrupt
free(p); p = NULL; moves a pointer from the worst state to the
second-best one: failures become loud and
reproducible instead of silent and random.
Key Takeaways
NULLis the "points to nothing" value, guaranteed distinct from any valid object address; C23 adds the typednullptr.- Keep
NULL,'\0', and0distinct in your code even where they share a representation. - Dereferencing null is undefined behavior; the segmentation fault you usually get is the OS being helpful, not a language guarantee.
- Always check functions that signal failure with
NULL—malloc,fopen,strchr,getenv. - Initialize pointers to
NULLand set them toNULLafterfree;free(NULL)is a guaranteed no-op, so write your own destructors to tolerate it too.
🧪 Practice
- Write a program that dereferences a
NULLpointer, run it, and note the exact signal and message. Then run it under-fsanitize=undefined.- Write a
find_indexfunction returning a pointer to a matching element orNULL, and handle both cases at the call site.- Write a cleanup function that takes a pointer, frees it, and demonstrate that calling it twice is safe when you null the caller's copy — then explain why passing
char **would be needed to null it inside the function.- Interview-style: "Why is setting a pointer to NULL after
freerecommended, and when does it not help?" Hint: consider a second pointer to the same block, and what a struct field copy does.
Pointer Arithmetic
Theory
Pointer arithmetic is what makes C's array traversal fast and its buffer bugs possible. The rule is simple and easy to misremember:
Adding an integer
nto a pointer advances it byn * sizeof(*p)bytes, not bynbytes.
The pointer type supplies the scale. For int *p on a 32-bit-int machine, p + 1 moves 4 bytes; for double *q, q + 1 moves 8; for char *c, c + 1 moves 1. The compiler multiplies for you, which is precisely why a[i] can be defined as *(a + i) and still land on element i rather than byte i.
Four operations are defined on pointers:
| Operation | Result | Notes |
|---|---|---|
p + n, p - n | Pointer | Scaled by the element size |
p++, p-- | Pointer | Move one element |
p - q | ptrdiff_t | Number of elements between them, not bytes |
p == q, p < q | int | Comparison; ordering is only meaningful within one array |
Pointer subtraction is the one people misuse. q - p gives the element count, so &a[5] - &a[2] is 3, not 12. Its type is ptrdiff_t from <stddef.h>, printed with %td.
The boundaries are strict, and violating them is undefined behavior even if you never dereference:
- You may point anywhere from the first element to one past the last.
a + n(one past the end) is valid to compute and compare, and that is what makesfor (p = a; p != a + n; p++)correct.- Dereferencing
a + nis undefined. So is computinga - 1. - Arithmetic and comparison between pointers into different objects is undefined — the compiler is entitled to assume it does not happen, so it is not merely "unportable".
There is no arithmetic on void * (its element size is unknown) or on function pointers, though GCC permits void * arithmetic as an extension treating it as char *.
The idiom this all enables is the pointer walk:
for (const int *p = a; p != a + n; p++)
total += *p;
Is it faster than indexing? On modern compilers, essentially never — they generate the same code. Choose whichever is clearer; indexing usually is, except when walking a null-terminated string, where the pointer form is natural.
Finally, mind precedence: *p++ parses as *(p++) — dereference the old value, then advance the pointer. To increment the pointee, write (*p)++. This distinction appears in real code constantly, most famously in while (*dst++ = *src++);.
Examples
#include <stdio.h>
#include <stddef.h>
int main(void)
{
int a[6] = {10, 20, 30, 40, 50, 60};
int *p = a; /* decay: p points at a[0] */
/* Arithmetic is scaled by the element size. */
printf("p -> %d\n", *p);
printf("p+1 -> %d (advanced %zu bytes)\n", *(p + 1),
(size_t)((char *)(p + 1) - (char *)p));
printf("p+3 -> %d\n", *(p + 3));
/* Subtraction yields ELEMENTS, not bytes. */
int *q = &a[5];
ptrdiff_t gap = q - p;
printf("q - p = %td elements (%zu bytes apart)\n",
gap, (size_t)((char *)q - (char *)p));
/* The canonical walk: 'a + n' is a legal sentinel. */
long total = 0;
for (const int *it = a; it != a + 6; it++) total += *it;
printf("total = %ld\n", total);
/* Comparison orders pointers within one array. */
printf("p < q : %s\n", (p < q) ? "yes" : "no");
/* Precedence: *p++ vs (*p)++ */
p = a;
int v = *p++; /* read a[0], THEN advance p */
printf("v = %d, now *p = %d\n", v, *p); /* 10, then 20 */
p = a;
(*p)++; /* increment a[0] itself */
printf("a[0] is now %d\n", a[0]); /* 11 */
/* --- Undefined, even without dereferencing --- */
/* int *before = a - 1; before the first element */
/* int x = *(a + 6); one past the end, dereferenced */
/* int b[3]; if (a < b) { } different objects */
return 0;
}
int a[6]; int *p = a;
a[0] a[1] a[2] a[3] a[4] a[5] (one past end)
+-----+-----+-----+-----+-----+-----+· · ·
| 10 | 20 | 30 | 40 | 50 | 60 |
+-----+-----+-----+-----+-----+-----+· · ·
^ ^ ^ ^
p p+1 p+5 p+6
|<--- 4 bytes --->| |
legal to compute and compare,
undefined to dereference
q - p counts ELEMENTS: &a[5] - &a[0] = 5 (not 20)
Key Takeaways
p + nadvances byn * sizeof(*p)bytes; the pointer's type supplies the scale.p - qyields aptrdiff_telement count, not a byte count; print it with%td.- The valid range is the first element through one-past-the-end; computing
a - 1or dereferencinga + nis undefined behavior. - Arithmetic or ordering comparison between pointers into different objects is undefined, not merely unportable.
*p++dereferences then advances the pointer;(*p)++increments the pointed-to value.
🧪 Practice
p,p+1, andp+2forint *,char *, anddouble *pointers into their own arrays, and report the byte differences.- Compute
&a[7] - &a[2]for anint a[10]and explain why the answer is 5 rather than 20.- Rewrite an indexed array-sum loop as a pointer walk using
!=againsta + n, and confirm identical results.- Interview-style: "Why is
a + nlegal buta - 1undefined for an arraya[n]?" Hint: think about what a loop needs as a termination sentinel, and about arrays placed at the very start of a memory segment.
Pointers and Arrays Relationship
Theory
Beginners are often told "arrays and pointers are the same in C". They are not, and the confusion causes real bugs. The accurate statement is narrower:
An array expression decays to a pointer to its first element in most contexts, and an array parameter is a pointer. Otherwise, arrays and pointers are different types with different behavior.
Here is the full comparison:
| Property | int a[5] | int *p |
|---|---|---|
| What it is | 5 contiguous ints | One variable holding an address |
sizeof | 20 (whole array) | 8 (the pointer) |
| Can be reassigned | No — a = ... is an error | Yes — p = ... |
| Storage for elements | Yes, it is the storage | No, points elsewhere |
&x type | int (*)[5] | int ** |
Increment x++ | Illegal | Legal |
| Where allocated | Where declared | Wherever it points |
The decisive difference: an array name is not a modifiable lvalue. a = p; does not compile. a designates storage; it is not a variable holding a value that could be changed. A pointer, by contrast, is an ordinary variable whose value happens to be an address.
The syntactic overlap comes from decay plus the definition a[i] == *(a + i). Because both an array and a pointer can appear where a pointer is expected, both support [], *, and arithmetic. That is convenience, not identity.
The most damaging place this bites is across translation units. If one file has:
char buf[100]; /* an ARRAY: buf IS 100 bytes */
and another declares:
extern char *buf; /* a POINTER: WRONG */
then the second file reads the first 8 bytes of the character data and treats them as an address. It compiles, it links, and it crashes or corrupts memory at run time — the compiler never sees both declarations at once (Chapter 4.3). The header must say extern char buf[];.
Two more places where the difference is visible and useful:
String initialization. char s[] = "hi"; copies 3 bytes into a writable array. char *p = "hi"; stores the address of a read-only literal. Writing through p is undefined behavior. This is Chapter 5.4's central point.
Function parameters. Here the array really does become a pointer: void f(int a[5]) has parameter type int *. This adjustment happens only in parameter declarations, and only to the outermost dimension.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
int a[5] = {1, 2, 3, 4, 5};
int *p = a; /* legal: array decays */
/* Both support the same syntax ... */
printf("a[2]=%d p[2]=%d *(a+2)=%d *(p+2)=%d\n",
a[2], p[2], *(a + 2), *(p + 2));
/* ... but they are NOT the same type. */
printf("sizeof a = %zu (the array)\n", sizeof a); /* 20 */
printf("sizeof p = %zu (the pointer)\n", sizeof p); /* 8 */
p++; /* fine: p is a variable */
printf("after p++, *p = %d\n", *p);
/* a++; ERROR: a is not a modifiable lvalue */
/* a = p; ERROR: cannot assign to an array */
/* &a and &p have different types. */
int (*pa)[5] = &a; /* pointer to array of 5 int */
int **pp = &p; /* pointer to pointer to int */
printf("(*pa)[0] = %d, **pp = %d\n", (*pa)[0], **pp);
/* What a wrong 'extern char *buf;' does: the CHARACTERS get read
as if they were an address. */
char buf[8] = "ABCDEFG";
unsigned long long as_address;
memcpy(&as_address, buf, sizeof as_address); /* reinterpret the bytes */
printf("buf's bytes read as an address: 0x%llx\n", as_address);
return 0;
}
char buf[8] = "ABCDEFG"; (the ARRAY: the bytes themselves)
0x2000: 41 42 43 44 45 46 47 00
A B C D E F G \0
Correct view (extern char buf[]):
buf --> 0x2000, and the data starts right there.
Wrong view (extern char *buf):
"buf" is read as a POINTER stored at 0x2000,
so its value is 0x0047464544434241 <- the letters, as an address
Dereferencing that lands somewhere meaningless.
Key Takeaways
- Arrays and pointers are distinct types; the overlap comes from decay and from
a[i] == *(a + i). - An array name is not a modifiable lvalue: it cannot be assigned, incremented, or copied wholesale.
sizeofand&reveal the difference —sizeof ais the whole array, and&ahas typeT (*)[N].- Declaring an array as
extern T *in another file is a serious bug that compiles and links but misinterprets data as an address. - A parameter written as an array is adjusted to a pointer; that is the one place they truly coincide.
🧪 Practice
- Write a program that prints
sizeoffor an array and for a pointer to its first element, then trya++and record the error.- Define
char buf[16] = "hello";in one file, declareextern char *buf;in another, and observe the run-time failure. Fix it withextern char buf[];.- Declare
int (*pa)[5]andint **ppfor the same array and pointer, and print what each dereference chain yields.- Interview-style: "When is an array not a pointer?" Hint: name the contexts where decay does not happen, and the assignment rule that has no pointer equivalent.
const with Pointers
Theory
const means "this cannot be modified through this name". With pointers there are two things that could be constant — the pointer and the pointee — so there are four combinations, and reading them correctly is a small skill worth acquiring.
The reliable technique is to read the declaration right to left from the name:
int *p; /* p is a pointer to int */
const int *p; /* p is a pointer to int-that-is-const */
int const *p; /* identical to the line above */
int *const p; /* p is a const pointer to int */
const int *const p; /* p is a const pointer to int-that-is-const */
The rule that resolves the ambiguity: const applies to whatever is immediately to its left, unless there is nothing there, in which case it applies to the right. That is why const int * and int const * mean the same thing, while int *const is different — the const is right of the *, so it qualifies the pointer.
| Declaration | Can change *p? | Can change p? |
|---|---|---|
int *p | Yes | Yes |
const int *p | No | Yes |
int *const p | Yes | No |
const int *const p | No | No |
The overwhelmingly common one is const int * (also written const char * for strings): a read-only view of data you do not own. Use it on every parameter you do not intend to write through. It costs nothing at run time and buys three things: the compiler catches accidental writes, callers can pass const data and string literals, and the signature documents the contract.
Two subtleties matter.
Conversions are one-way. int * converts implicitly to const int * — adding a restriction is always safe. The reverse requires an explicit cast, and modifying an object that was originally defined const is undefined behavior even if you cast the qualifier away. const is a promise about the object, not just a view.
const is shallow. const struct S *p prevents you from writing p->field, but if field is itself a pointer, *(p->field) is still writable. Likewise, a const pointer to non-const data protects only the pointer.
For two-level pointers there is a genuine wart: char ** does not convert to const char **, even though it looks like it should. The reason is that allowing it would let you smuggle a const object's address into a non-const pointer. The workaround is const char *const *.
Examples
#include <stdio.h>
/* The workhorse: a read-only view. Callers may pass literals and const data. */
size_t count_char(const char *s, char c)
{
size_t n = 0;
for (; *s; s++) /* modifying s (the pointer) is fine */
if (*s == c) n++;
/* *s = 'x'; ERROR: s points to const char */
return n;
}
int main(void)
{
int x = 10, y = 20;
/* 1. Everything modifiable. */
int *p1 = &x;
*p1 = 11; p1 = &y;
/* 2. Pointer to const: the DATA is protected. */
const int *p2 = &x;
/* *p2 = 12; ERROR: read-only data */
p2 = &y; /* fine: the pointer itself is not const */
/* 3. Const pointer: the POINTER is fixed. */
int *const p3 = &x;
*p3 = 13; /* fine: the data is writable */
/* p3 = &y; ERROR: cannot reassign a const pointer */
/* 4. Both. */
const int *const p4 = &x;
/* *p4 = 14; ERROR */
/* p4 = &y; ERROR */
printf("x=%d y=%d *p2=%d *p4=%d\n", x, y, *p2, *p4);
/* Adding const is implicit; removing it needs a cast (and is dangerous). */
int *mutable_p = &x;
const int *view = mutable_p; /* fine: adds a restriction */
/* int *back = view; ERROR without a cast */
int *back = (int *)view; /* legal here only because x
was not defined const */
*back = 99;
printf("x = %d\n", x);
/* const applies to the ORIGINAL object, not just the view: */
const int frozen = 5;
int *cheat = (int *)&frozen;
/* *cheat = 6; UNDEFINED BEHAVIOR: frozen was defined const */
(void)cheat;
printf("count_char = %zu\n", count_char("hello world", 'l'));
return 0;
}
Read right to left from the name:
const int *p p -> pointer -> to int -> that is const
|________| | "pointer to const int": data locked, pointer free
|
int *const p p -> const -> pointer -> to int
|____| | "const pointer to int": pointer locked, data free
const int *p int *const p
p [ can move ] p [ FIXED ]
| |
v v
[ locked data ] [ writable data ]
Key Takeaways
- Read declarations right to left from the name;
constqualifies what is immediately to its left, or to its right if nothing is there. const T *pprotects the data;T *const pprotects the pointer; both can be combined.- Use
const T *on every parameter you do not write through — it is free, compiler-enforced documentation. T *converts implicitly toconst T *, but casting the other way and then writing to an originally-constobject is undefined behavior.constis shallow, andchar **does not implicitly convert toconst char **; useconst char *const *.
🧪 Practice
- Declare all four
constcombinations for a pointer toint, then attempt both a pointer reassignment and a write through each, recording which four attempts fail.- Write
void print_all(const char *const *strings, size_t n)and call it with an array of string literals.- Add
constto the parameters of a function you wrote earlier and confirm it still compiles at every call site.- Interview-style: "What does
const char * const * pmean, and why can't I pass achar **to it?" Hint: peel one level at a time, and consider what a single-levelconstconversion would let a caller smuggle through.
void Pointers
Theory
Sometimes a function must handle memory without knowing its type. memcpy copies bytes; qsort sorts anything; malloc returns storage that could become anything. C's tool for this is void * — a pointer to an unspecified type, C's closest thing to a generic pointer.
Its defining property: any object pointer converts to void * and back implicitly, without a cast and without losing information. That round trip is guaranteed, which is what makes generic APIs possible.
The price is that a void * carries no type, so two operations are forbidden:
- You cannot dereference it.
*vpis meaningless — how many bytes would it read? - You cannot do arithmetic on it.
vp + 1has no defined step size. (GCC and Clang allow it as an extension, treating it aschar *;-Wpedanticflags it.)
To use the data you must convert to a concrete pointer type first. In C that conversion is implicit — int *p = malloc(n); needs no cast. In fact, casting the result of malloc is discouraged in C: it is redundant, and before C99 it could mask a missing <stdlib.h> include, hiding the implicit-declaration bug (Chapter 4.1). (C++ requires the cast, which is the source of the disagreement you will see in older code.)
The generic-function pattern combines void * with an explicit element size, and often a callback:
void qsort(void *base, size_t n, size_t size,
int (*cmp)(const void *, const void *));
qsort never knows what it is sorting. It moves size bytes at a time and asks your comparison function to order any two elements. The comparator receives const void * and casts back to the real type — the safety C gives up in exchange for genericity. If you cast to the wrong type there, nothing catches it.
Two related points:
void * is for object pointers only. Converting a function pointer to void * is not guaranteed by the standard (POSIX requires it to work so dlsym can function). Use a function pointer type instead.
char * and unsigned char * are the byte-level view. When you need to inspect or copy raw bytes, cast to unsigned char *: those types are explicitly allowed to alias any object, and arithmetic on them steps one byte at a time.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* A generic swap: works for any type, given the element size. */
void generic_swap(void *a, void *b, size_t size)
{
unsigned char *pa = a, *pb = b; /* byte view: arithmetic is per-byte */
for (size_t i = 0; i < size; i++) {
unsigned char t = pa[i];
pa[i] = pb[i];
pb[i] = t;
}
}
/* A qsort comparator: the caller's type knowledge is restored by casting. */
int cmp_int(const void *a, const void *b)
{
int x = *(const int *)a; /* cast back to the REAL type */
int y = *(const int *)b;
return (x > y) - (x < y); /* avoids overflow of x - y */
}
/* A generic printer driven by an explicit tag -- void* carries no type. */
typedef enum { T_INT, T_DOUBLE, T_STRING } Type;
void print_any(const void *value, Type t)
{
switch (t) {
case T_INT: printf("int: %d\n", *(const int *)value); break;
case T_DOUBLE: printf("double: %g\n", *(const double *)value); break;
case T_STRING: printf("string: %s\n", (const char *)value); break;
}
}
int main(void)
{
int i = 42;
double d = 3.14;
char s[] = "hello";
print_any(&i, T_INT);
print_any(&d, T_DOUBLE);
print_any(s, T_STRING);
int x = 1, y = 2;
generic_swap(&x, &y, sizeof x);
printf("swapped ints: x=%d y=%d\n", x, y);
double p = 1.5, q = 2.5;
generic_swap(&p, &q, sizeof p); /* the same function, another type */
printf("swapped doubles: p=%g q=%g\n", p, q);
int arr[] = {5, 2, 9, 1, 7};
size_t n = sizeof arr / sizeof arr[0];
qsort(arr, n, sizeof arr[0], cmp_int);
printf("sorted: ");
for (size_t k = 0; k < n; k++) printf("%d ", arr[k]);
putchar('\n');
/* malloc returns void*: the conversion is implicit, no cast needed. */
int *buf = malloc(n * sizeof *buf); /* NOT (int *)malloc(...) in C */
if (!buf) return EXIT_FAILURE;
memcpy(buf, arr, n * sizeof *buf);
printf("copied first = %d\n", buf[0]);
free(buf);
/* void *vp = &i; *vp; ERROR: cannot dereference void* */
/* vp + 1; not portable: no element size */
return 0;
}
void * : an address with the type erased
int i = 42;
void *vp = &i; <- implicit, no cast, no information lost
*vp ERROR (how many bytes? interpreted how?)
vp + 1 ERROR (step by what?)
*(int *)vp 42 <- restore the type, then dereference
Genericity in C = void* (the bytes)
+ size_t (how many)
+ a callback or tag (what they mean)
Key Takeaways
void *is a typeless object pointer; conversions to and from any object pointer type are implicit and lossless.- It cannot be dereferenced or used in arithmetic — convert to a concrete type first.
- Do not cast
malloc's return value in C; the conversion is automatic and the cast can hide a missing header. - Generic APIs pair
void *with an explicit element size and usually a callback (qsort,bsearch,memcpy). - Type safety is your responsibility across a
void *boundary; useunsigned char *for byte-level work and function pointer types for functions.
🧪 Practice
- Write
void print_bytes(const void *p, size_t n)that printsnbytes in hex, and call it on anintand adouble.- Write a
qsortcomparator for an array ofdoubleand one for an array ofstruct { char name[16]; int score; }ordered by score.- Implement
generic_swapand use it on twostructvalues, verifying the contents actually exchanged.- Interview-style: "Why can't you dereference or do arithmetic on a
void *?" Hint: both operations need one number that the type would normally supply.
<a id="53-advanced-pointer-use"></a>
5.3 Advanced Pointer Use
With the fundamentals in place, pointers compose: pointers to pointers, arrays of pointers, and pointers to functions. These are the building blocks of dynamic data structures, argument vectors, and the callback-driven designs that give C its polymorphism. This section closes with the technique for reading any declaration, however tangled.
Pointers to Pointers
Theory
A pointer is an object with an address, so you can take a pointer to it. int **pp holds the address of an int *, which holds the address of an int. This is not cleverness for its own sake — two levels of indirection solve two specific problems that one level cannot.
Problem 1: modifying a caller's pointer. C passes by value (Chapter 4.1), so a function receiving int *p gets a copy of the address. Assigning to p inside the function changes only the copy. If the function must make the caller's pointer point somewhere else — allocate a buffer, advance a cursor, unlink a list node — it needs the address of the pointer: int **.
This is the difference between "modify what the pointer points at" (one level) and "modify the pointer itself" (two levels). The same reasoning that turned swap(int, int) into swap(int *, int *) turns alloc(int *) into alloc(int **).
Problem 2: dynamic tables of pointers. A char ** can point at the first element of an array of char *, which is how argv, environment vectors, and dynamically allocated 2D arrays are represented. Each element is itself a pointer to a separately allocated block.
The classic application is the linked-list deletion loop, where struct node ** lets you treat "the head pointer" and "some node's next field" uniformly, eliminating the special case for removing the first element. That trick — hold a pointer to the pointer you might need to change — is one of the more elegant idioms in C.
Reading multi-level dereferences is mechanical: each * peels one level.
int x = 5;
int *p = &x; /* *p is x */
int **pp = &p; /* *pp is p, **pp is x */
int ***ppp = &pp; /* ***ppp is x */
Beyond three levels, readability collapses; a struct or a typedef is almost always the better design.
Examples
#include <stdio.h>
#include <stdlib.h>
/* Fails: modifies a COPY of the caller's pointer. */
void alloc_broken(int *p, size_t n)
{
p = malloc(n * sizeof *p); /* the caller never sees this */
if (p) p[0] = 1;
}
/* Works: receives the ADDRESS of the caller's pointer. */
int alloc_ok(int **p, size_t n)
{
*p = malloc(n * sizeof **p); /* writes THROUGH to the caller's variable */
if (!*p) return -1;
(*p)[0] = 1;
return 0;
}
/* The idiom: a pointer-to-pointer removes the head special case entirely. */
struct node { int value; struct node *next; };
void remove_value(struct node **head, int target)
{
struct node **link = head; /* points at the head pointer */
while (*link) {
if ((*link)->value == target) {
struct node *dead = *link;
*link = dead->next; /* rewrite whichever pointer
led here -- head or ->next */
free(dead);
return;
}
link = &(*link)->next; /* advance to the next LINK */
}
}
int main(void)
{
int x = 5;
int *p = &x;
int **pp = &p;
printf("x=%d *p=%d **pp=%d\n", x, *p, **pp);
**pp = 50; /* reaches all the way to x */
printf("after **pp = 50, x = %d\n", x);
int *buf = NULL;
alloc_broken(buf, 10);
printf("after alloc_broken, buf is %s\n", buf ? "set" : "still NULL");
if (alloc_ok(&buf, 10) == 0) {
printf("after alloc_ok, buf[0] = %d\n", buf[0]);
free(buf);
}
/* Linked list: build 1 -> 2 -> 3 on the heap. */
struct node *head = NULL;
for (int v = 3; v >= 1; v--) { /* prepend, so it ends up 1,2,3 */
struct node *n = malloc(sizeof *n);
if (!n) return EXIT_FAILURE;
n->value = v;
n->next = head;
head = n;
}
remove_value(&head, 1); /* removes the HEAD, no special case */
remove_value(&head, 3); /* removes the tail, same code */
for (struct node *n = head; n; n = n->next)
printf("list node: %d\n", n->value); /* only 2 remains */
while (head) { struct node *dead = head; head = head->next; free(dead); }
return 0;
}
int x = 5; int *p = &x; int **pp = &p;
pp p x
+--------+ +--------+ +------+
| &p |--->| &x |--->| 5 |
+--------+ +--------+ +------+
0x30 0x20 0x10
*pp -> p (the pointer)
**pp -> 5 (the value)
Why the list idiom works:
head ---> [1] ---> [2] ---> [3] ---> NULL
^ ^
| |
link points AT head link points AT node[1].next
(so *link = ... rewrites head, with identical code)
Key Takeaways
T **holds the address of aT *; each*peels one level of indirection.- To let a function change the caller's pointer (not just the pointee), pass the pointer's address.
- Arrays of pointers (
argv, string tables, ragged 2D arrays) are naturally reached through aT **. - Holding a pointer-to-pointer to the current link makes linked-list insertion and deletion uniform, with no head special case.
- More than two or three levels is a design smell; introduce a struct or typedef instead.
🧪 Practice
- Write
void set_to_null(int **p)that sets the caller's pointer toNULL, and prove frommainthat it worked.- Write both
alloc_brokenandalloc_okabove and explain, in terms of copies, why only one succeeds.- Implement
remove_valueand use it to delete the first, a middle, and the last element of a list.- Interview-style: "Why does a function that frees a list need
struct node **rather thanstruct node *?" Hint: after the last free, what should the caller's head pointer contain, and who can set it?
Arrays of Pointers
Theory
An array of pointers is a table of addresses. Each element points somewhere else — typically to strings or to separately allocated blocks of differing sizes.
The declaration needs care, because [] binds tighter than *:
char *names[3]; /* array of 3 pointers to char */
char (*p)[3]; /* pointer to an array of 3 char */
The parentheses are the whole difference. Read char *names[3] as: names is an array (of 3) of pointer to char.
The primary use case is ragged data — rows of different lengths. A 2D array char words[3][20] reserves 20 bytes for every word, wasting space and imposing a maximum. char *words[3] = {"hi", "elephant", "a"}; stores three pointers to literals of exactly the sizes needed.
The trade-off against a true 2D array is worth stating explicitly:
char grid[3][20] | char *table[3] | |
|---|---|---|
| Memory layout | One contiguous 60-byte block | 3 pointers + 3 separate blocks |
| Row lengths | All identical | Independent |
| Access cost | Computed offset | One pointer dereference |
| Cache behavior | Excellent | Depends on where rows landed |
| Rows swappable | Copy the bytes | Swap two pointers |
| Total size known by | sizeof grid | Neither — must track it |
Sorting is the classic illustration: sorting char *table[] swaps 8-byte pointers, while sorting char grid[][20] copies 20 bytes per swap.
The most familiar array of pointers in all of C is argv. int main(int argc, char *argv[]) receives a table of argc string pointers, with argv[argc] guaranteed to be NULL. Because a parameter's outermost array dimension decays, char *argv[] and char **argv are the same declaration.
Building a dynamic 2D array as an array of pointers means two allocation levels — one for the row table, one per row — and freeing must mirror that in reverse. It is more code than a single flat malloc(rows * cols * sizeof(T)) with manual indexing, and worse for cache locality, but it permits ragged rows and row swapping. Choose deliberately.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int compare_strings(const void *a, const void *b)
{
/* a and b point AT the array elements, which are themselves char*. */
const char *const *pa = a, *const *pb = b;
return strcmp(*pa, *pb);
}
int main(int argc, char *argv[]) /* identical to char **argv */
{
/* Ragged strings: each pointer targets a literal of its own length. */
const char *words[] = {"pear", "fig", "banana", "kiwi"};
size_t n = sizeof words / sizeof words[0];
printf("sizeof words = %zu (%zu pointers)\n", sizeof words, n);
for (size_t i = 0; i < n; i++)
printf(" words[%zu] = %-8s (%zu chars)\n", i, words[i], strlen(words[i]));
/* Sorting moves POINTERS, not characters. */
char *sortable[] = {"pear", "fig", "banana", "kiwi"};
qsort(sortable, 4, sizeof sortable[0], compare_strings);
printf("sorted:");
for (size_t i = 0; i < 4; i++) printf(" %s", sortable[i]);
putchar('\n');
/* argv is an array of pointers, NULL-terminated by the standard. */
printf("argc = %d\n", argc);
for (int i = 0; argv[i] != NULL; i++)
printf(" argv[%d] = %s\n", i, argv[i]);
/* A dynamic ragged 2D array: two levels of allocation. */
size_t rows = 3;
size_t lens[] = {2, 5, 9};
int **table = malloc(rows * sizeof *table); /* the row table */
if (!table) return EXIT_FAILURE;
for (size_t i = 0; i < rows; i++) {
table[i] = malloc(lens[i] * sizeof **table); /* each row, own length */
if (!table[i]) { /* unwind on failure */
while (i--) free(table[i]);
free(table);
return EXIT_FAILURE;
}
for (size_t j = 0; j < lens[i]; j++) table[i][j] = (int)(i * 10 + j);
}
printf("ragged: table[2][8] = %d\n", table[2][8]);
for (size_t i = 0; i < rows; i++) free(table[i]); /* rows first ... */
free(table); /* ... then the table */
return 0;
}
char *words[4] = {"pear", "fig", "banana", "kiwi"};
words (a contiguous array of 4 pointers)
+---------+---------+---------+---------+
| ptr | ptr | ptr | ptr |
+----|----+----|----+----|----+----|----+
| | | |
v v v v
"pear\0" "fig\0" "banana\0" "kiwi\0" <- separate, sized to fit
Contrast with char grid[4][8]: ONE 32-byte block, every row padded to 8
+--------+--------+--------+--------+
|pear\0 |fig\0 |banana\0|kiwi\0 |
+--------+--------+--------+--------+
contiguous and cache-friendly, but a 9-char word does not fit
Key Takeaways
T *a[N]is an array of pointers;T (*a)[N]is a pointer to an array — the parentheses decide.- Arrays of pointers hold ragged data efficiently and make swapping or sorting rows a pointer-sized operation.
- A true 2D array is one contiguous block with better cache behavior but uniform row sizes.
argvis an array ofargcstring pointers with a guaranteedNULLatargv[argc].- A dynamic ragged array needs one allocation for the table plus one per row, freed in reverse order.
🧪 Practice
- Build a
const char *months[12]table and print each entry with its length.- Sort an array of
char *withqsortandstrcmp, then explain what physically moved during the sort.- Allocate a ragged
int **with rows of lengths 1..5, fill each row with its index, print it, and free it correctly.- Interview-style: "When would you choose
int **over a flatint *for a matrix?" Hint: weigh cache locality and allocation count against the need for ragged rows and O(1) row swaps.
Pointers to Functions
Theory
Functions live in memory too, and a function's name — like an array's — decays to a pointer to it in most expressions. A function pointer stores that address, letting you store, pass, and call functions as data. This is how C achieves polymorphism without inheritance: the caller decides at run time which code executes.
The declaration mirrors the function's signature, with the name wrapped in parentheses:
int add(int, int); /* a function */
int (*fp)(int, int); /* a POINTER to such a function */
int *gp(int, int); /* WRONG: a function returning int * */
Without the parentheses, * binds to the return type, and you have declared a function, not a pointer. This is the same precedence rule as char *a[3] versus char (*a)[3].
The type includes the parameter types and the return type, and they must match exactly — a function pointer is strongly typed. Calling through an incompatible function pointer is undefined behavior, not a reinterpretation.
The syntax is forgiving in one respect: assignment and calls work with or without the extra operators.
fp = add; fp = &add; /* identical: the name decays */
fp(2, 3); (*fp)(2, 3); /* identical: calling dereferences it */
The older (*fp)(2, 3) style makes the indirection visible; fp(2, 3) is what modern code writes. Pick one.
A typedef makes function pointers readable, and is close to mandatory once they appear in parameter lists or structs:
typedef int (*BinaryOp)(int, int); /* BinaryOp is now a usable type name */
BinaryOp op = add;
Two constraints to remember: function pointers may not be converted to void * portably (POSIX guarantees it so dlsym works, the C standard does not), and you cannot do arithmetic on them. Also note that a null function pointer is possible and must be checked before calling, just like a data pointer.
The uses follow in the next topic, but the shape is already visible: any time you want to parameterize behavior rather than data — a comparison, an event handler, a state transition — a function pointer is C's answer.
Examples
#include <stdio.h>
int add(int a, int b) { return a + b; }
int subtract(int a, int b) { return a - b; }
int multiply(int a, int b) { return a * b; }
/* A typedef turns an unreadable type into a usable name. */
typedef int (*BinaryOp)(int, int);
/* Taking behavior as a parameter: the caller chooses the operation. */
int apply(BinaryOp op, int a, int b)
{
if (!op) return 0; /* function pointers can be NULL too */
return op(a, b);
}
/* Returning a function pointer -- readable thanks to the typedef. */
BinaryOp select_op(char symbol)
{
switch (symbol) {
case '+': return add;
case '-': return subtract;
case '*': return multiply;
default: return NULL;
}
}
int main(void)
{
int (*fp)(int, int) = add; /* the raw declaration */
printf("fp(7, 3) = %d\n", fp(7, 3)); /* modern call syntax */
printf("(*fp)(7, 3) = %d\n", (*fp)(7, 3)); /* classic, identical */
fp = &subtract; /* '&' is optional and equivalent */
printf("after fp = &subtract: %d\n", fp(7, 3));
printf("apply(multiply, 7, 3) = %d\n", apply(multiply, 7, 3));
/* Choosing behavior at run time. */
const char *symbols = "+-*/";
for (const char *s = symbols; *s; s++) {
BinaryOp op = select_op(*s);
if (op) printf("7 %c 3 = %d\n", *s, op(7, 3));
else printf("7 %c 3 = (unsupported)\n", *s);
}
/* The type must match exactly. */
/* double (*wrong)(int, int) = add; ERROR: incompatible types */
return 0;
}
Declaration anatomy
int (*fp)(int, int);
^^^^ ^^^^^^^^ ^
| | +-- returns int
| +------------- takes (int, int)
+-------------------- fp is a pointer
Without the parentheses:
int *gp(int, int); -> gp is a FUNCTION returning int*
What is stored
fp text segment (code)
+--------+ +------------------------+
| 0x4005 |---------> | add: push rbp ... |
+--------+ | ret |
+------------------------+
Assigning fp = subtract just changes the stored address.
Key Takeaways
- A function name decays to a pointer to the function;
f,&f,fp(...), and(*fp)(...)are all equivalent forms. - Declare with the name in parentheses:
int (*fp)(int, int). Without them you declare a function returning a pointer. - Function pointer types must match the signature exactly; calling through an incompatible type is undefined behavior.
- Use a
typedefas soon as a function pointer appears in a parameter, return type, or struct member. - Function pointers do not portably convert to
void *and support no arithmetic; check forNULLbefore calling.
🧪 Practice
- Declare a function pointer for
int f(int), assign three different functions to it in turn, and call each.- Write
int apply(int (*op)(int, int), int a, int b)and pass it three different operations.- Write
select_opreturning a function pointer, first without a typedef and then with one; compare readability.- Interview-style: "What is the difference between
int (*f)(void)andint *f(void)?" Hint: apply the precedence of()versus*and describe what each name denotes.
Callbacks and Dispatch Tables
Theory
Function pointers become powerful when they are used as callbacks — you hand a function to another function so it can call you back — and as dispatch tables, arrays or structs of function pointers that replace long switch chains.
Callbacks invert control. qsort knows how to sort but not how to compare; you know how to compare but not how to sort. You pass a comparator, and qsort calls it whenever it needs an ordering decision. The standard library uses this shape in qsort, bsearch, and atexit; POSIX uses it for signal handlers and thread entry points; every GUI and event loop is built on it.
A well-designed callback API includes a context parameter — an opaque void * passed straight back to the callback. Without one, the callback can only reach global state, which breaks reentrancy and thread safety. C's own qsort famously lacks this (hence the non-portable qsort_r), so when you design a callback interface, include the context from the start.
Dispatch tables replace branching with indexing. Instead of
switch (opcode) {
case OP_ADD: return do_add(a, b);
case OP_SUB: return do_sub(a, b);
...
}
you build an array indexed by opcode:
static int (*const ops[])(int, int) = { [OP_ADD] = do_add, [OP_SUB] = do_sub };
return ops[opcode](a, b);
The gain is not primarily speed — compilers turn dense switches into jump tables anyway — but structure. Adding a case means adding one table entry rather than editing control flow, the table can be data-driven, and the set of handlers becomes inspectable at run time. The cost is that you must bounds-check the index yourself and handle null entries, since neither is automatic.
Structs of function pointers are C's vtables. Group related operations into a struct, and you have an interface that different implementations can satisfy:
struct writer {
int (*write)(void *ctx, const char *data, size_t n);
void (*close)(void *ctx);
void *ctx;
};
A file writer, a socket writer, and an in-memory buffer writer all provide the same struct with different function pointers. Callers use them identically. This is precisely how the Linux kernel's file_operations and countless C libraries achieve polymorphism.
Always initialize function pointer tables fully (designated initializers make gaps explicit as NULL) and check before calling — a null entry called is an immediate crash.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ---------- 1. A callback with a context parameter ---------- */
/* The context is what makes the callback reusable and thread-safe. */
void for_each(const int *a, size_t n, void (*fn)(int, void *), void *ctx)
{
for (size_t i = 0; i < n; i++) fn(a[i], ctx);
}
static void accumulate(int value, void *ctx)
{
long *total = ctx; /* the caller's state, not a global */
*total += value;
}
static void print_item(int value, void *ctx)
{
const char *label = ctx;
printf("%s%d ", label, value);
}
/* ---------- 2. A dispatch table ---------- */
typedef enum { OP_ADD, OP_SUB, OP_MUL, OP_COUNT } OpCode;
static int op_add(int a, int b) { return a + b; }
static int op_sub(int a, int b) { return a - b; }
static int op_mul(int a, int b) { return a * b; }
/* 'const' table of function pointers, indexed by opcode. */
static int (*const dispatch[OP_COUNT])(int, int) = {
[OP_ADD] = op_add,
[OP_SUB] = op_sub,
[OP_MUL] = op_mul,
};
int execute(OpCode op, int a, int b)
{
if (op < 0 || op >= OP_COUNT || dispatch[op] == NULL)
return 0; /* YOU must bounds-check, not the compiler */
return dispatch[op](a, b);
}
/* ---------- 3. A struct of function pointers: C's vtable ---------- */
struct writer {
int (*write)(void *ctx, const char *s);
void (*close)(void *ctx);
void *ctx;
};
static int stdout_write(void *ctx, const char *s) { (void)ctx; return fputs(s, stdout); }
static void stdout_close(void *ctx) { (void)ctx; }
static int buffer_write(void *ctx, const char *s)
{
char *buf = ctx;
strcat(buf, s); /* demo only: assumes capacity */
return 0;
}
static void buffer_close(void *ctx) { printf("[buffer holds: %s]\n", (char *)ctx); }
/* One caller, many implementations -- the polymorphism C actually has. */
void emit_report(struct writer *w)
{
w->write(w->ctx, "report line 1\n");
w->write(w->ctx, "report line 2\n");
w->close(w->ctx);
}
int main(void)
{
int data[] = {1, 2, 3, 4, 5};
size_t n = sizeof data / sizeof data[0];
long total = 0;
for_each(data, n, accumulate, &total); /* context carries the result */
printf("total = %ld\n", total);
for_each(data, n, print_item, "#");
putchar('\n');
printf("dispatch: %d %d %d\n",
execute(OP_ADD, 7, 3), execute(OP_SUB, 7, 3), execute(OP_MUL, 7, 3));
struct writer to_stdout = { stdout_write, stdout_close, NULL };
emit_report(&to_stdout);
char buf[128] = "";
struct writer to_buffer = { buffer_write, buffer_close, buf };
emit_report(&to_buffer); /* same function, different behavior */
return 0;
}
Dispatch table: an array indexed by opcode
dispatch[] code
+---------+ +-----------+
| OP_ADD |---->| op_add |
+---------+ +-----------+
| OP_SUB |---->| op_sub |
+---------+ +-----------+
| OP_MUL |---->| op_mul |
+---------+ +-----------+
execute(op, a, b) -> dispatch[op](a, b)
^
one indexed call replaces the whole switch
Struct of function pointers = one interface, many implementations
emit_report(w) ---> w->write ---> stdout_write (writes to the terminal)
or buffer_write (appends to memory)
or socket_write (sends over a network)
Key Takeaways
- Callbacks invert control: the library calls your function when it needs a decision it cannot make itself.
- Always include a
void *context parameter in callback APIs; without it, callbacks depend on globals and lose reentrancy. - Dispatch tables replace
switchchains with an indexed array of function pointers, making the handler set data-driven and extensible. - You must bounds-check the index and null-check the entry yourself — the language checks neither.
- A struct of function pointers plus a context is C's vtable, and the standard way to write polymorphic interfaces.
🧪 Practice
- Write
void map(int *a, size_t n, int (*fn)(int))that applies a transformation in place, and call it with a squaring and a negating function.- Build a four-entry calculator dispatch table indexed by an enum, including a division handler that reports divide-by-zero.
- Extend
for_eachwith a callback that finds the maximum, using the context pointer to carry the running maximum out.- Interview-style: "How would you implement virtual functions in C?" Hint: describe what a struct of function pointers stored inside (or beside) each object gives you, and where the
thispointer comes from.
Reading Complex Declarations
Theory
C declarations are notoriously hard to read, and the reason is structural: a declaration mimics the syntax of the expression that uses the variable. int *p means "the expression *p has type int". Once you internalize that, even char *(*(*x[3])(int))[5] becomes decodable — it just takes discipline.
The systematic method is the spiral rule, or more reliably the "right-left" procedure:
- Find the identifier.
- Look right. If you see
[, say "array of"; if(, say "function returning". Consume all of them at this nesting level. - Look left. If you see
*, say "pointer to". - When you hit a closing parenthesis, move out one level and repeat from step 2.
- Finish with the base type.
Two precedence facts drive everything: [] and () bind tighter than *. That is why parentheses appear so often — they exist purely to defeat that precedence.
Work through the four cases that matter in practice:
| Declaration | Reading | Meaning |
|---|---|---|
int *a[5] | a, array of 5, pointer to, int | Array of 5 pointers |
int (*a)[5] | a, pointer to, array of 5, int | Pointer to an array of 5 |
int *f(void) | f, function returning, pointer to, int | Function returning a pointer |
int (*f)(void) | f, pointer to, function returning, int | Pointer to a function |
The difference in each pair is one set of parentheses, and it changes the type completely.
Two tools help. cdecl (a command-line program, also at cdecl.org) translates in both directions. And the compiler itself will tell you: declare the variable, then deliberately assign an int to it and read the error message, which names the exact type.
The best practice, though, is not to need the skill. typedef breaks any complex declaration into readable pieces:
typedef int (*Handler)(int, char *); /* one clear name */
typedef Handler HandlerTable[10]; /* an array of them */
Handler *get_handlers(void); /* trivially readable now */
Nested function pointers in particular should always be typedef'd. If a declaration takes more than a few seconds to parse, that is a defect in the code, not a challenge to rise to.
Examples
#include <stdio.h>
int value = 42;
int square(int x) { return x * x; }
int arr5[5] = {1, 2, 3, 4, 5};
/* Function returning a pointer to int. */
int *get_value(void) { return &value; }
/* Function returning a pointer to an array of 5 int -- typedef'd for sanity. */
typedef int Row5[5];
Row5 *get_row(void) { return &arr5; }
int main(void)
{
/* 1. array of 5 pointers to int */
int *a[5];
a[0] = &value;
printf("1. *a[0] = %d\n", *a[0]);
/* 2. pointer to an array of 5 int */
int (*b)[5] = &arr5;
printf("2. (*b)[2] = %d, sizeof *b = %zu\n", (*b)[2], sizeof *b);
/* 3. function returning pointer to int */
int *(*fn_ret_ptr)(void) = get_value;
printf("3. *fn() = %d\n", *fn_ret_ptr());
/* 4. pointer to function returning int */
int (*fn_ptr)(int) = square;
printf("4. fn_ptr(6) = %d\n", fn_ptr(6));
/* 5. array of 3 pointers to functions returning int */
int (*table[3])(int) = { square, square, square };
printf("5. table[1](5)= %d\n", table[1](5));
/* The same thing, made readable with typedefs. */
typedef int (*IntFn)(int);
IntFn clean_table[3] = { square, square, square };
printf("6. clean[2](4)= %d\n", clean_table[2](4));
printf("7. (*get_row())[4] = %d\n", (*get_row())[4]);
return 0;
}
Decoding int (*table[3])(int)
step 1: find the identifier table
step 2: look right -> [3] table is an array of 3
step 3: look left -> * ... of pointers to
step 4: out one level, right -> (int) ... functions taking (int)
step 5: base type ... returning int
Reading: "table is an array of 3 pointers to functions
taking an int and returning an int"
The precedence that causes all the confusion:
[] and () bind tighter than *
int *a[5] -> *(a[5]) -> array of pointers
int (*a)[5] -> (*a)[5] -> pointer to array
# Let a tool do it when in doubt:
cdecl> explain int (*table[3])(int)
declare table as array 3 of pointer to function (int) returning int
Key Takeaways
- A declaration mirrors the expression that uses the identifier:
int *psays*pis anint. - Read outward from the identifier — right first for
[]and(), then left for*— obeying parentheses. []and()bind tighter than*; parentheses exist to override that, and their presence flips the meaning entirely.int *a[5]is an array of pointers,int (*a)[5]a pointer to an array;int *f()returns a pointer,int (*f)()is a pointer to a function.- Prefer
typedeffor anything beyond one level of indirection — needing the spiral rule is a sign the declaration should be split.
🧪 Practice
- Write out, in English, the meanings of
char **argv,int (*daytab)[13],void (*signal(int, void (*)(int)))(int).- Declare "an array of 5 pointers to functions taking
doubleand returningchar *", then rewrite it with two typedefs.- Take one complex declaration from your own code or a system header and decode it step by step, then verify with
cdecl.- Interview-style: "What does
void (*signal(int, void (*)(int)))(int);declare?" Hint: start atsignal, note it is a function first, and identify what its second parameter and its return type have in common.
<a id="54-strings"></a>
5.4 Strings
C has no string type. It has a convention: a sequence of characters ending in a zero byte, manipulated through pointers. This section covers that convention, the standard functions built on it, and the specific ways it goes wrong — because more C security vulnerabilities come from string handling than from anything else.
Null-Terminated Character Arrays
Theory
Other languages store a string as a length plus the bytes. C stores only the bytes, and marks the end with a null terminator — a byte with value zero, written '\0'.
That single design decision explains nearly everything about C strings:
- A "string" is just a
chararray whose contents happen to end with'\0'. There is no string type, no length field, no capacity. - Any function that processes a string must scan to find the end.
strlenis O(n), not O(1). - A
char *can point into the middle of a string and the result is still a valid string — the tail from that point on. This is whystrchrcan return a pointer and callers can use it directly. - If the terminator is missing, every string function runs off the end of the array, reading whatever follows until it finds a zero byte somewhere in unrelated memory. This is the root of the buffer overflow.
The storage must always be one byte larger than the visible text. char s[6] = "hello" holds 'h' 'e' 'l' 'l' 'o' '\0' — five characters, six bytes. Getting this off by one is the most common single mistake in C.
Declaration forms:
char a[] = "hi"; /* array of 3: 'h','i','\0' -- size deduced */
char b[10] = "hi"; /* 10 bytes: "hi\0" then 7 more zeros */
char c[3] = {'h','i','\0'}; /* explicit, identical to a */
char d[2] = "hi"; /* LEGAL but NOT a string: no room for '\0' */
That last case is a genuine C quirk: initializing a char array with a string literal that exactly fills it drops the terminator silently, producing a character array that is not a string. Any strlen or printf("%s") on it runs off the end.
Distinguish two lengths for any buffer:
| Measure | For char buf[10] = "hi" | How to get it |
|---|---|---|
| Buffer size (capacity) | 10 | sizeof buf — only where the array is visible |
| String length (content) | 2 | strlen(buf) — scans to '\0' |
sizeof counts the box; strlen counts what is in it. Confusing them, especially after an array decays to a pointer in a function, is a reliable source of overflow.
Also note that 'a' (a char literal, an int in C) and "a" (a string literal, a 2-byte array) are entirely different things.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
char a[] = "hello"; /* 6 bytes: 5 chars + '\0' */
char b[10] = "hi"; /* 10 bytes, rest zero-filled */
char c[6] = {'h','e','l','l','o','\0'}; /* identical to a */
printf("a: sizeof=%zu strlen=%zu\n", sizeof a, strlen(a)); /* 6, 5 */
printf("b: sizeof=%zu strlen=%zu\n", sizeof b, strlen(b)); /* 10, 2 */
printf("c: \"%s\"\n", c);
/* The bytes, made visible. */
printf("a bytes:");
for (size_t i = 0; i < sizeof a; i++)
printf(" %02x(%c)", (unsigned char)a[i], a[i] ? a[i] : '.');
putchar('\n');
/* A pointer into the middle is still a valid string. */
const char *tail = a + 2;
printf("tail = \"%s\"\n", tail); /* "llo" */
/* Manual traversal: the terminator IS the loop condition. */
size_t n = 0;
for (const char *p = a; *p != '\0'; p++) n++;
printf("manual strlen = %zu\n", n);
/* Truncating by writing a terminator -- no data is moved. */
char word[] = "hello world";
word[5] = '\0';
printf("truncated = \"%s\" (but sizeof is still %zu)\n", word, sizeof word);
/* THE TRAP: exactly-sized initializer drops the terminator. */
char bad[2] = "hi"; /* legal C; NOT a string */
printf("bad[0]=%c bad[1]=%c -- no '\\0' anywhere\n", bad[0], bad[1]);
/* printf("%s", bad); would read past the array until a random 0 */
return 0;
}
char a[] = "hello";
index: 0 1 2 3 4 5
+----+----+----+----+----+----+
| h | e | l | l | o | \0 |
+----+----+----+----+----+----+
0x68 0x65 0x6c 0x6c 0x6f 0x00
sizeof a = 6 <- the box
strlen(a) = 5 <- scans until it hits 0x00
a + 2 ---------------^ "llo" is a valid string too
Missing terminator: char bad[2] = "hi";
+----+----+ ? ? ? ?
| h | i | ?? | ?? | ?? | 00 |
+----+----+----+----+----+----+
^^^^^^^^^^^^^^^^^^
strlen keeps reading into memory
that is not yours
Key Takeaways
- A C string is a
chararray terminated by'\0'; there is no length field, so every operation scans. - Always allocate
strlen + 1bytes; forgetting the terminator's byte is the classic off-by-one. sizeofgives the buffer capacity (only where the array is in scope);strlengives the content length by scanning.- A pointer into the middle of a string is itself a valid string; writing
'\0'truncates in place without moving data. char s[N] = "..."with an exactly-fitting literal silently omits the terminator, producing a non-string.
🧪 Practice
- Declare
char s[20] = "test"and print bothsizeof sandstrlen(s), explaining the gap.- Write your own
size_t my_strlen(const char *s)with a pointer walk, and verify it againststrlen.- Build a
chararray by hand without a terminator, pass it toprintf("%s"), and observe the garbage. Add the terminator and rerun.- Interview-style: "What is the cost of
strlenand why does it matter in a loop?" Hint: considerfor (i = 0; i < strlen(s); i++)on a long string and count total character reads.
String Literals and Immutability
Theory
A string literal — anything in double quotes — is not just syntax for characters. It is an array object with static storage duration that exists for the entire run of the program. Where that array lives, and whether you may write to it, is the source of one of C's nastiest crashes.
The critical distinction:
char arr[] = "hello"; /* COPIES 6 bytes into a writable array */
char *ptr = "hello"; /* points at the literal itself: READ-ONLY */
arr is an array initialized from the literal — you own those bytes and may modify them. ptr is a pointer holding the literal's address. Modifying a string literal is undefined behavior, and on every mainstream platform it is an immediate segmentation fault, because the compiler places literals in a read-only section (.rodata) that the loader maps without write permission.
The literal's type is char[N], not const char[N] — a historical wart kept for compatibility with pre-standard code. That is why char *p = "hello"; compiles at all despite being a loaded gun. Modern compilers warn about it (-Wwrite-strings makes literals const), and C23 finally changed the rules so that assigning a literal to a plain char * is deprecated.
The fix is a habit, not a flag: always declare pointers to literals as const char *. Then the compiler stops the write at compile time instead of the CPU stopping it at run time.
Two more properties are worth knowing.
Identical literals may be shared. Compilers commonly merge duplicate literals, so "hello" == "hello" may be true — or may not. Never compare strings with ==; that compares addresses. Use strcmp.
Adjacent literals concatenate at compile time. "hello" " " "world" is one literal, which is how long strings are split across source lines cleanly.
Finally, the lifetime rules differ in a way that matters for returning strings. A literal has static storage duration, so returning const char * to one from a function is perfectly safe. Returning a pointer to a local char array is the dangling-pointer bug from Chapter 4.1.
Examples
#include <stdio.h>
#include <string.h>
/* Safe: the literal outlives the function -- static storage duration. */
const char *status_name(int code)
{
switch (code) {
case 0: return "ok";
case 1: return "warning";
default: return "error";
}
}
/* UNSAFE: returns a pointer to an automatic array that dies here. */
/* const char *broken(void) { char buf[] = "temp"; return buf; } */
int main(void)
{
char arr[] = "hello"; /* a writable COPY in this frame */
const char *ptr = "hello"; /* points at read-only storage */
arr[0] = 'H'; /* fine: we own these bytes */
printf("arr = %s\n", arr);
/* ptr[0] = 'H'; UNDEFINED: usually a segfault.
Declaring ptr as 'const char *' turns this into a COMPILE error. */
printf("sizeof arr = %zu (the array)\n", sizeof arr); /* 6 */
printf("sizeof ptr = %zu (the pointer)\n", sizeof ptr); /* 8 */
/* Literals may be merged by the compiler -- never rely on it,
and never compare strings with ==. */
const char *x = "shared";
const char *y = "shared";
printf("x == y : %s (implementation-defined!)\n", (x == y) ? "true" : "false");
printf("strcmp : %d (0 means equal -- THIS is the correct test)\n",
strcmp(x, y));
/* Adjacent literals concatenate at compile time. */
const char *long_line = "This is one literal "
"split across two source lines.";
printf("%s\n", long_line);
printf("status : %s / %s\n", status_name(0), status_name(9));
return 0;
}
Process memory
+------------------------+
| .rodata (READ-ONLY) |
| "hello\0" <---------+---- const char *ptr
+------------------------+
| .data / .bss |
+------------------------+
| heap |
+------------------------+
| stack |
| arr: h e l l o \0 | <- char arr[] = "hello": a COPY, writable
+------------------------+
arr[0] = 'H'; -> fine
ptr[0] = 'H'; -> writes into a read-only page -> SIGSEGV
char arr[] = "hi" | char *ptr = "hi" | |
|---|---|---|
| What it is | An array holding a copy | A pointer to the literal |
| Writable | Yes | No (undefined behavior) |
sizeof | 3 | 8 (the pointer) |
| Lifetime | Its enclosing block | Whole program |
| Safe to return | No (if automatic) | Yes |
Key Takeaways
- A string literal is a static-lifetime array, normally placed in read-only memory; modifying one is undefined behavior.
char a[] = "x"copies into a writable array;char *p = "x"merely points at the literal.- Always write
const char *for pointers to literals so the compiler catches writes at compile time. - Never compare strings with
==— that compares addresses, and identical literals may or may not be merged. - Returning a literal from a function is safe; returning a local
chararray is not.
🧪 Practice
- Write
char *p = "hello"; p[0] = 'H';, run it, and record the crash. Change the declaration toconst char *and note that the failure moves to compile time.- Compare two identical literals with
==and withstrcmp, and explain which result is trustworthy.- Write a function returning
const char *for an enum-to-name lookup, and explain why no allocation is needed.- Interview-style: "Why does
char *s = "hi"; s[0] = 'H';compile but crash?" Hint: separate what the type system permits from where the loader put the bytes.
strlen, strcpy, strcmp, strcat
Theory
<string.h> provides the classic four. All of them share one assumption — their inputs are properly null-terminated — and three of them share one danger: they write to a destination whose size they do not know.
size_t strlen(const char *s) scans until '\0' and returns the count, excluding the terminator. It is O(n). Calling it in a loop condition turns an O(n) loop into O(n squared) — hoist it into a variable.
char *strcpy(char *dst, const char *src) copies src including its terminator into dst and returns dst. It has no idea how big dst is. If src is longer, it writes past the end. This is the canonical buffer overflow, and the reason strcpy appears in essentially every security-critical CVE list.
int strcmp(const char *a, const char *b) compares byte by byte and returns a negative, zero, or positive value — not necessarily -1/0/1. The correct test for equality is strcmp(a, b) == 0. The ordering is by unsigned char value, so it is ASCII/byte order, not locale-aware alphabetical order (strcoll handles that).
char *strcat(char *dst, const char *src) appends src to the end of dst. It must first strlen(dst) to find the end, then copy — so repeated strcat in a loop is quadratic. It also has no size knowledge, so it overflows just as readily as strcpy.
The pattern is clear: the destination's capacity is never a parameter, so the caller must guarantee it. The required space for strcpy is strlen(src) + 1; for strcat it is strlen(dst) + strlen(src) + 1.
Two other traps:
Overlapping buffers are undefined for all of these. Use memmove when regions may overlap.
strcmp on a non-terminated array runs off the end. Every one of these functions assumes a valid string.
Return values are chosen for chaining (strcpy returns dst), which is occasionally handy but often just hides the destination. Modern code should reach for the bounded variants in the next topic, and use snprintf for anything involving concatenation.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
/* --- strlen: O(n), hoist it out of loops --- */
const char *s = "hello";
size_t len = strlen(s); /* 5, terminator excluded */
printf("strlen = %zu\n", len);
/* BAD: for (size_t i = 0; i < strlen(s); i++) rescans every iteration */
for (size_t i = 0; i < len; i++) putchar(s[i]);
putchar('\n');
/* --- strcpy: caller must guarantee capacity --- */
char dst[16];
strcpy(dst, "hello"); /* needs strlen(src)+1 = 6 */
printf("dst = %s\n", dst);
/* Do the arithmetic BEFORE copying. */
const char *src = "a longer string";
if (strlen(src) + 1 <= sizeof dst) strcpy(dst, src);
else printf("refused: %zu bytes needed, %zu available\n",
strlen(src) + 1, sizeof dst);
/* char small[4]; strcpy(small, "overflow"); BUFFER OVERFLOW */
/* --- strcmp: sign, not magnitude --- */
printf("strcmp(\"apple\",\"banana\") = %d (negative: apple sorts first)\n",
strcmp("apple", "banana"));
printf("strcmp(\"abc\",\"abc\") = %d (zero: equal)\n",
strcmp("abc", "abc"));
printf("strcmp(\"b\",\"a\") = %d (positive)\n", strcmp("b", "a"));
/* The ONLY correct equality test: */
const char *a = "abc", *b = "abc";
if (strcmp(a, b) == 0) puts("equal");
/* if (a == b) compares ADDRESSES, not contents */
/* Byte order, not alphabetical order: */
printf("strcmp(\"Zebra\",\"apple\") = %d ('Z'=90 < 'a'=97)\n",
strcmp("Zebra", "apple"));
/* --- strcat: appends, needs room for BOTH --- */
char buf[32] = "Hello";
if (strlen(buf) + strlen(", world") + 1 <= sizeof buf)
strcat(buf, ", world");
printf("strcat -> %s\n", buf);
/* Repeated strcat is quadratic: each call rescans from the start. */
char acc[64] = "";
for (int i = 0; i < 5; i++) strcat(acc, "ab"); /* fine here, bad at scale */
printf("acc = %s\n", acc);
/* The modern replacement for building strings: */
char out[32];
int written = snprintf(out, sizeof out, "%s has %zu chars", s, len);
printf("snprintf -> %s (would need %d bytes)\n", out, written);
return 0;
}
| Function | Reads | Writes | Knows destination size? | Safe alternative |
|---|---|---|---|---|
strlen | src | — | n/a | strnlen (POSIX) for untrusted input |
strcpy | src | dst | No | snprintf, strlcpy, checked memcpy |
strcmp | both | — | n/a | strncmp for a bounded prefix |
strcat | both | dst | No | snprintf with an offset |
strcat(dst, src) does this:
1. scan dst for '\0' <- O(strlen(dst)) EVERY call
2. copy src there, with '\0'
dst: [H][e][l][l][o][\0][ ][ ][ ][ ]
^ found here
src: [!][\0]
dst: [H][e][l][l][o][!][\0][ ][ ][ ]
Nobody checked that dst had room. That check is YOUR job.
Key Takeaways
- All four functions require null-terminated inputs and undefined behavior follows if that fails.
strlenis O(n) — hoist it out of loop conditions.strcpyandstrcatdo not know the destination size; the caller must ensurestrlen(src) + 1andstrlen(dst) + strlen(src) + 1bytes respectively.strcmpreturns a sign, not -1/0/1; test== 0for equality, and remember it orders by byte value, not locale.- Overlapping source and destination are undefined; use
memmove. For building strings, prefersnprintf.
🧪 Practice
- Implement
my_strlen,my_strcpy, andmy_strcmpfrom scratch and test them against the library versions.- Write a program that overflows a small buffer with
strcpy, run it under-fsanitize=address, and read the report.- Measure the difference between
for (i = 0; i < strlen(s); i++)and a hoisted length on a one-megabyte string.- Interview-style: "Why is
strcmpreturning a negative number rather thanfalse?" Hint: think about what a sorting routine needs from a comparison, and whatqsort's comparator contract requires.
strncpy and Bounded Variants
Theory
The n-suffixed functions take a size limit, which sounds like the fix for the overflow problem. It partly is — but strncpy is not the safe strcpy, and believing it is has caused a great many bugs.
char *strncpy(char *dst, const char *src, size_t n) has two surprising behaviors:
- If
srcis longer thann, no terminator is written. You get exactlyncharacters and an unterminated buffer — a non-string that the nextstrlenwill run off. - If
srcis shorter thann, the remainder is padded with zeros — all the way tonbytes. Copying a 3-character string into a 4096-byte buffer writes 4096 bytes.
Neither is a bug. strncpy was designed in the 1970s for fixed-width fields in file records, not for safe string copying. Using it as a safe strcpy requires manually terminating every time:
strncpy(dst, src, sizeof dst - 1);
dst[sizeof dst - 1] = '\0'; /* MANDATORY */
The related bounded functions behave more sensibly:
strncat(dst, src, n)appends at mostncharacters and always adds a terminator — so it needsn + 1free bytes, and itsnmeans "from src", not "total size". The correct call isstrncat(dst, src, sizeof dst - strlen(dst) - 1), which is error-prone enough thatsnprintfis usually better.strncmp(a, b, n)compares at mostncharacters; genuinely useful for prefix tests.memcpy(dst, src, n)copies exactlynbytes with no terminator logic; when you already know the length, it is the fastest and clearest choice.
The practical recommendation for new code is snprintf: it always terminates (given a nonzero size), it handles concatenation and formatting in one call, and it returns the length it would have written, so truncation is detectable:
int n = snprintf(buf, sizeof buf, "%s/%s", dir, file);
if (n < 0 || (size_t)n >= sizeof buf) { /* truncated */ }
Platform-specific options exist. BSD's strlcpy/strlcat always terminate and return the length they tried to create, which makes truncation detectable; they are available on macOS and BSD, and glibc added them in version 2.38. C11 Annex K adds strcpy_s and friends, but the annex is optional, rarely implemented outside MSVC, and widely criticized — do not build portable code on it.
The unifying principle: truncation is not the same as safety. A silently truncated path, command, or hostname can itself be a vulnerability. Always detect truncation and decide deliberately what to do about it.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
/* --- Trap 1: no terminator when src is too long --- */
char dst[6];
memset(dst, 'X', sizeof dst); /* poison, to make the effect visible */
strncpy(dst, "hello world", 5); /* copies 5 chars, NO '\0' */
printf("bytes:");
for (size_t i = 0; i < sizeof dst; i++) printf(" %c", dst[i]);
printf(" <- last byte is still 'X', not '\\0'\n");
/* printf("%s", dst); would read past the array */
/* The mandatory fix-up: */
char safe[6];
strncpy(safe, "hello world", sizeof safe - 1);
safe[sizeof safe - 1] = '\0'; /* ALWAYS do this with strncpy */
printf("safe = \"%s\" (truncated deliberately)\n", safe);
/* --- Trap 2: zero-padding to the full n --- */
char padded[16];
strncpy(padded, "hi", sizeof padded); /* writes 2 chars + 14 zero bytes */
printf("padded = \"%s\", strlen=%zu, but 16 bytes were written\n",
padded, strlen(padded));
/* --- strncat: n counts SOURCE chars and always terminates --- */
char buf[16] = "Hello";
strncat(buf, ", world!!!", sizeof buf - strlen(buf) - 1); /* room for '\0' */
printf("strncat -> \"%s\"\n", buf);
/* --- strncmp: prefix comparison --- */
const char *path = "/usr/local/bin";
if (strncmp(path, "/usr", 4) == 0) puts("path starts with /usr");
/* --- The recommended tool: snprintf, with truncation DETECTED --- */
char out[16];
const char *dir = "/very/long/directory", *file = "name.txt";
int need = snprintf(out, sizeof out, "%s/%s", dir, file);
if (need < 0) {
puts("snprintf: encoding error");
} else if ((size_t)need >= sizeof out) {
printf("snprintf: TRUNCATED -- needed %d bytes, had %zu\n",
need + 1, sizeof out);
}
printf("out = \"%s\" (always terminated)\n", out);
/* --- memcpy when the length is already known --- */
char exact[6];
const char *src = "hello";
size_t n = strlen(src);
if (n + 1 <= sizeof exact) {
memcpy(exact, src, n + 1); /* copy the terminator explicitly */
printf("memcpy -> \"%s\"\n", exact);
}
return 0;
}
| Function | Always terminates? | Pads? | Truncation detectable? | Verdict |
|---|---|---|---|---|
strcpy | Yes | No | n/a (overflows) | Avoid |
strncpy | No | Yes, to n | No | Only for fixed-width fields |
strncat | Yes | No | No | Awkward n semantics |
strlcpy (BSD) | Yes | No | Yes (return value) | Good, not universal |
snprintf | Yes | No | Yes (return value) | Recommended default |
memcpy | n/a | No | n/a | Best when length is known |
Key Takeaways
strncpyis not a safestrcpy: it omits the terminator when the source is too long and zero-pads tonwhen it is short.- If you use
strncpy, passsizeof dst - 1and writedst[sizeof dst - 1] = '\0'yourself, every time. strncat'sncounts source characters, not total buffer size, and it always appends a terminator.- Prefer
snprintffor copying and building strings: it always terminates and its return value detects truncation. - Truncation is not automatically safe — detect it and handle it, because a shortened path or command can be its own vulnerability.
🧪 Practice
- Copy an 11-character string into a 6-byte buffer with
strncpy, print the raw bytes, and show that no terminator was written.- Use
strncpyon a short string into a large buffer and demonstrate that the whole buffer was written.- Write
int safe_copy(char *dst, size_t cap, const char *src)usingsnprintfthat returns -1 on truncation, and test both paths.- Interview-style: "Is
strncpysafe?" Hint: the useful answer distinguishes "does not overflow the destination" from "produces a valid string", and names what the caller must still do.
Tokenizing with strtok
Theory
Splitting a string on delimiters — a CSV line, a path, a command — is common enough that C provides strtok. It works, but its interface makes it one of the most dangerous convenience functions in the standard library, and understanding why teaches a lot about C API design.
char *strtok(char *str, const char *delim);
The usage is a two-phase loop: pass the string on the first call, then NULL on every subsequent call to continue from where it left off.
for (char *tok = strtok(buf, ",;"); tok != NULL; tok = strtok(NULL, ",;"))
puts(tok);
Three properties determine everything about how it can be used:
1. It modifies the input. strtok finds each delimiter and overwrites it with '\0', then returns a pointer into your buffer. No copies are made — which is fast, but means you cannot pass a string literal (undefined behavior, Chapter 5.4) or a buffer you still need intact. Copy first if you need the original.
2. It keeps hidden static state. That is how it remembers its position between calls. Consequences: you cannot tokenize two strings at once, you cannot call it from a function that is itself called from within a tokenizing loop, and it is not thread-safe — two threads calling strtok corrupt each other's state.
3. It treats runs of delimiters as one. "a,,b" with delimiter "," yields two tokens ("a", "b"), not three. Empty fields disappear, which makes strtok wrong for CSV parsing where an empty column is meaningful.
The reentrant alternatives fix the state problem: POSIX strtok_r(str, delim, &saveptr) and Windows/C11 Annex K strtok_s take an explicit context pointer, so nesting and threading work. Use them whenever available.
When empty fields matter, or the input must be preserved, write the split yourself with strchr or strcspn — it is a dozen lines and avoids every one of the above problems.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
/* strtok MODIFIES its input, so copy from a literal into an array. */
char line[] = "root:x:0:0:System Administrator:/root:/bin/bash";
printf("tokens:\n");
for (char *tok = strtok(line, ":"); tok; tok = strtok(NULL, ":"))
printf(" [%s]\n", tok);
/* The original is destroyed: delimiters became '\0'. */
printf("after: \"%s\" (only the first field survives as a string)\n", line);
/* Consecutive delimiters collapse -- empty fields VANISH. */
char csv[] = "a,,b,,,c";
printf("collapsed:");
for (char *t = strtok(csv, ","); t; t = strtok(NULL, ",")) printf(" [%s]", t);
printf(" <- 3 tokens, not 6\n");
/* Reentrant version: explicit state, so nesting and threads work. */
char data[] = "k1=v1;k2=v2;k3=v3";
char *outer_save = NULL, *inner_save = NULL;
for (char *pair = strtok_r(data, ";", &outer_save); pair;
pair = strtok_r(NULL, ";", &outer_save)) {
/* A NESTED tokenization -- impossible with plain strtok. */
char *key = strtok_r(pair, "=", &inner_save);
char *val = strtok_r(NULL, "=", &inner_save);
printf(" key=%s value=%s\n", key ? key : "?", val ? val : "?");
}
/* Hand-rolled split that PRESERVES empty fields and the input. */
const char *rec = "x,,y";
printf("manual split (empty fields kept):");
const char *start = rec;
for (;;) {
const char *comma = strchr(start, ',');
int len = comma ? (int)(comma - start) : (int)strlen(start);
printf(" [%.*s]", len, start); /* %.*s prints a bounded slice */
if (!comma) break;
start = comma + 1;
}
putchar('\n');
return 0;
}
strtok destroys as it goes
before: r o o t : x : 0 : 0 \0
^ ^ ^
after : r o o t \0 x \0 0 \0 0 \0
|------| |-| |-| |-|
returned pointers point INTO your buffer
Hidden static state:
call 1: strtok(line, ":") -> saves position after "root"
call 2: strtok(NULL, ":") -> resumes from the saved position
^^^^^^^^^^^
one global cursor: no nesting,
no concurrency, no second string
strtok | strtok_r | Manual strchr loop | |
|---|---|---|---|
| Modifies input | Yes | Yes | No |
| Thread-safe | No | Yes | Yes |
| Nestable | No | Yes | Yes |
| Empty fields | Collapsed | Collapsed | Preserved |
| Portability | Standard C | POSIX (strtok_s on Windows) | Universal |
Key Takeaways
strtoksplits in place by overwriting delimiters with'\0'and returning pointers into your buffer — never pass it a string literal.- It stores its position in hidden static state, so it cannot be nested, cannot tokenize two strings at once, and is not thread-safe.
- Runs of delimiters collapse, so empty fields disappear — making it unsuitable for CSV where empties are meaningful.
- Use
strtok_r(POSIX) orstrtok_s(C11 Annex K/Windows) with an explicit save pointer whenever available. - Write a
strchr-based split when you must preserve the input or keep empty fields.
🧪 Practice
- Tokenize
"one two three"on spaces withstrtokand print each token; then print the original buffer and explain what changed.- Pass a string literal to
strtokand observe the crash. Fix it by copying into achararray first.- Use
strtok_rto parse"a=1;b=2;c=3"into key/value pairs with a nested inner tokenizer.- Interview-style: "Why can't you use
strtokin a multithreaded program?" Hint: ask where the position between calls is stored, and how many copies of it exist.
Searching with strchr and strstr
Theory
The search functions all follow one convention: they return a pointer to the match, or NULL if there is none. Returning a pointer rather than an index is deliberate — the result is itself a valid C string (the tail from the match onward), so you can print it, search it again, or copy from it directly.
The family:
| Function | Finds | Returns |
|---|---|---|
strchr(s, c) | First occurrence of character c | Pointer to it, or NULL |
strrchr(s, c) | Last occurrence of c | Pointer to it, or NULL |
strstr(h, n) | First occurrence of substring n | Pointer to it, or NULL |
strpbrk(s, set) | First character from a set | Pointer to it, or NULL |
strspn(s, set) | Length of initial run of set chars | A length, not a pointer |
strcspn(s, set) | Length of initial run not in set | A length, not a pointer |
memchr(p, c, n) | Byte in a bounded region | Pointer, or NULL |
Three details repay attention.
strchr's second parameter is an int, converted to char. This means strchr(s, '\0') is well defined and returns a pointer to the terminator — a neat way to find the end of a string. It also means passing a value outside char range is converted, not rejected.
To get an index, subtract. size_t pos = found - s; yields a ptrdiff_t offset. This only makes sense when found is non-null, so check first.
memchr is the bounded cousin. When you have a buffer with a known length that might not be terminated — data from read(), a network packet — memchr is correct and strchr is a buffer overrun waiting to happen.
The strspn/strcspn pair is underused and genuinely handy: strspn(s, " \t") gives the length of leading whitespace (skip it by adding), and strcspn(s, ",") gives the distance to the next comma or to the end — which is exactly the "split preserving empty fields" primitive from the previous topic.
One performance note: strstr in glibc uses a two-way algorithm with linear worst-case behavior, so it is fine for general use. A naive hand-rolled substring search is O(n*m); do not write your own unless you have measured a need.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
const char *path = "/usr/local/share/doc/readme.txt";
/* strchr: first occurrence; the result is itself a valid string. */
const char *first_slash = strchr(path, '/');
printf("first '/' at index %td -> \"%s\"\n", first_slash - path, first_slash);
/* strrchr: last occurrence -- the classic "basename" and "extension" idiom. */
const char *base = strrchr(path, '/');
base = base ? base + 1 : path; /* skip the slash if found */
const char *ext = strrchr(base, '.');
printf("basename = %s\n", base);
printf("extension = %s\n", ext ? ext + 1 : "(none)");
/* Always check for NULL before using the result. */
const char *missing = strchr(path, 'Z');
printf("'Z' found : %s\n", missing ? "yes" : "no");
/* strstr: substring search. */
const char *hit = strstr(path, "share");
if (hit) printf("\"share\" at index %td\n", hit - path);
/* Counting occurrences by advancing past each match. */
int slashes = 0;
for (const char *p = path; (p = strchr(p, '/')) != NULL; p++) slashes++;
printf("slash count = %d\n", slashes);
/* strspn / strcspn: run lengths, useful for trimming and splitting. */
const char *line = " \t hello, world";
size_t ws = strspn(line, " \t"); /* leading whitespace length */
printf("trimmed = \"%s\"\n", line + ws);
const char *field = "name,value";
size_t upto = strcspn(field, ","); /* distance to the comma */
printf("first field = \"%.*s\"\n", (int)upto, field);
/* strpbrk: first character from a set. */
const char *ops = strpbrk("a+b*c", "+-*/");
printf("first operator = %c\n", ops ? *ops : '?');
/* memchr: bounded search in data that may NOT be terminated. */
char raw[8] = {'a','b','c','\n','d','e','f','g'}; /* no '\0' anywhere */
char *nl = memchr(raw, '\n', sizeof raw);
printf("newline at offset %td (strchr here would run off the end)\n",
nl - raw);
return 0;
}
const char *p = "/usr/local/bin";
0 5 11
strchr(p, '/') -> p+0 "/usr/local/bin"
strrchr(p, '/') -> p+10 "/bin"
strstr(p,"local")-> p+5 "local/bin"
strchr(p, 'Z') -> NULL
The return is a POINTER, so:
index = result - p
tail = result (already a valid string)
next match = strchr(result + 1, c)
Key Takeaways
- Search functions return a pointer to the match or
NULL; always test before dereferencing. - The returned pointer is a valid string for the remainder of the input, which makes chaining and printing tails trivial.
- Convert a match to an index by subtracting the base pointer (
found - s), printed with%td. strrchrgives the basename/extension idiom;strspn/strcspngive trimming and field-splitting without modifying the input.- Use
memchrfor buffers with a known length that may not be null-terminated.
🧪 Practice
- Write a function that returns the file extension of a path, or
NULL, usingstrrchr, and handle a path with no dot.- Count all occurrences of a substring in a string using
strstrin a loop, advancing correctly past each match.- Write
char *trim_leading(char *s)usingstrspn, and a splitter usingstrcspnthat preserves empty fields.- Interview-style: "When must you use
memchrinstead ofstrchr?" Hint: think about data read from a socket or a file, and whatstrchrassumes about where the data ends.
Common String Bugs
Theory
String handling is where most C security vulnerabilities live. The bugs are not exotic — the same handful recur for decades — so knowing them by name is most of the defense.
1. Off-by-one on the terminator. Allocating strlen(s) bytes instead of strlen(s) + 1, or looping i <= len. The write of the final '\0' lands one byte past the buffer — a classic heap or stack corruption.
2. Unbounded copy. strcpy, strcat, sprintf, and gets write as much as the source demands. gets is so unfixable it was removed from the standard in C11; use fgets.
3. Missing terminator. From strncpy truncation, from a manually built array, or from data read with read()/recv(), which do not terminate anything. Every subsequent string operation then runs off the end.
4. Comparing with ==. Compares addresses. It sometimes appears to work because identical literals may be merged, which makes the bug intermittent — the worst kind.
5. Writing to a string literal. char *p = "x"; p[0] = 'y'; — undefined behavior, usually a segfault (Chapter 5.4).
6. Returning a pointer to a local buffer. The frame dies at return; the pointer dangles (Chapter 4.1).
7. sizeof on a decayed pointer. Inside a function, sizeof buf is the pointer size, so strncpy(buf, src, sizeof buf) copies 8 bytes. Pass the capacity explicitly.
8. Off-by-one in strncat. Its n is source characters, not total capacity, and it appends a terminator beyond them — so it needs n + 1 free bytes.
9. Overlapping copies. strcpy(s, s + 1) is undefined; use memmove.
10. Signed char with <ctype.h>. toupper(c) where c is a plain char holding a byte above 127 passes a negative value, which is undefined. Cast: toupper((unsigned char)c).
The defenses are systematic rather than clever:
| Defense | Catches |
|---|---|
-Wall -Wextra -Wformat-security | Format bugs, obvious misuse |
-fsanitize=address,undefined | Overflows, use-after-free, off-by-ones, at run time |
-D_FORTIFY_SOURCE=2 -O2 | Some overflows in libc calls, cheaply |
| Valgrind | Invalid reads/writes, leaks |
snprintf everywhere | Unbounded writes and missing terminators |
Static analyzers (clang --analyze, cppcheck) | Cross-function reasoning |
The habit that prevents most of this: carry the capacity alongside every buffer, treat every external input as unterminated until you terminate it yourself, and use snprintf as the default tool for building strings.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
/* BUG 1: off-by-one -- no room for the terminator. */
char *dup_broken(const char *s)
{
char *p = malloc(strlen(s)); /* MISSING the +1 */
if (p) strcpy(p, s); /* writes one byte past the block */
return p;
}
char *dup_fixed(const char *s)
{
size_t n = strlen(s) + 1; /* +1 for '\0' */
char *p = malloc(n);
if (p) memcpy(p, s, n); /* length known: memcpy is clearest */
return p;
}
/* BUG 7: sizeof on a parameter measures the POINTER. */
void copy_broken(char *dst, const char *src)
{
strncpy(dst, src, sizeof dst); /* 8 bytes, whatever dst really is */
/* -Wsizeof-pointer-memaccess warns about exactly this */
}
void copy_fixed(char *dst, size_t cap, const char *src)
{
if (cap == 0) return;
snprintf(dst, cap, "%s", src); /* always terminates, never overflows */
}
/* BUG 6: returning a pointer into a dead frame. */
/* const char *greet_broken(void) { char b[16]; strcpy(b, "hi"); return b; } */
/* BUG 10: plain char passed to ctype functions. */
void upcase_fixed(char *s)
{
for (; *s; s++)
*s = (char)toupper((unsigned char)*s); /* the cast is REQUIRED */
}
int main(void)
{
/* BUG 4: == compares addresses, not contents. */
char a[] = "hello";
const char *b = "hello";
printf("a == b : %s (comparing ADDRESSES -- always wrong)\n",
(a == b) ? "true" : "false");
printf("strcmp : %s (comparing CONTENTS -- correct)\n",
strcmp(a, b) == 0 ? "equal" : "different");
/* BUG 3: data from a bounded read is NOT terminated for you. */
char buf[8];
memset(buf, 'A', sizeof buf); /* simulate a full read() */
/* printf("%s", buf); would run off the end */
buf[sizeof buf - 1] = '\0'; /* terminate it yourself */
printf("terminated: %s\n", buf);
/* BUG 9: overlapping regions need memmove. */
char ov[] = "abcdef";
memmove(ov, ov + 1, strlen(ov)); /* shift left, terminator included */
printf("shifted : %s\n", ov);
char *ok = dup_fixed("safe copy");
if (ok) { printf("dup : %s\n", ok); free(ok); }
char out[6];
copy_fixed(out, sizeof out, "truncate me");
printf("bounded : \"%s\"\n", out);
char word[] = "mixed Case";
upcase_fixed(word);
printf("upcased : %s\n", word);
return 0;
}
The off-by-one, drawn
malloc(strlen("hi")) = 2 bytes
+---+---+
| h | i | \0 <- written HERE: one byte past the allocation
+---+---+ ^^
heap metadata or the next block: corrupted
malloc(strlen("hi") + 1) = 3 bytes
+---+---+----+
| h | i | \0 | correct
+---+---+----+
# The single highest-value habit for string code:
gcc -g -Wall -Wextra -fsanitize=address,undefined -o prog prog.c
./prog
# ==1234==ERROR: AddressSanitizer: heap-buffer-overflow
# WRITE of size 1 at 0x602000000012 thread T0
# #0 strcpy
# #1 dup_broken prog.c:11
Key Takeaways
- Allocate
strlen(s) + 1; the terminator's byte is the most commonly forgotten one in C. - Never use
gets, and treatstrcpy/strcat/sprintfas unsafe unless you have already proven the capacity. - Data from
read,recv, andstrncpymay be unterminated — terminate it yourself before any string function touches it. - Compare with
strcmp, not==;sizeofon a parameter gives the pointer size, so pass capacities explicitly. - Cast to
unsigned charbefore calling<ctype.h>functions, usememmovefor overlapping regions, and build strings withsnprintf. - Compile with
-Wall -Wextra -fsanitize=address,undefinedduring development; it finds these bugs mechanically.
🧪 Practice
- Write
dup_brokenabove, run it under AddressSanitizer, and read the exact report. Then fix it and confirm the report disappears.- Write a function that takes
char *dstand usessizeof dstfor its bound; compile with-Walland explain the warning.- Read a line with
fgets, strip the trailing newline correctly (handling the case where none is present), and print the result.- Interview-style: "You are reviewing C code and see
strcpy(dest, src). What do you need to verify before approving it?" Hint: name the two quantities that must be compared, and where each one comes from.