Arrays and Pointer Arithmetic
Fixed-size arrays and their initialization, how arrays decay to pointers, pointer arithmetic, the array length idiom, multidimensional and variable-length arrays, and why C never checks bounds.
What an array is in general, and how it differs from a dynamic array or a linked list, is explained on data structures. This page assumes you know pointers, because in C the two are inseparable.
Declaring and initializing arrays
int readings[5]; reserves room for five
int values in one block of memory, numbered 0 to 4. The
size must be known when the array is created and cannot change
afterward; a list that grows needs
dynamic memory. An initializer
list fills the array from the start; elements it does not mention
become 0. Leave out the size and the compiler counts the list, and
since C99 a designated initializer sets specific positions:
#include <stdio.h>
int main(void)
{
int readings[5] = {120, 95, 130}; /* the rest becomes 0 */
int hours[] = {0, 6, 12, 18}; /* size taken from the list */
int flags[8] = {[2] = 1, [7] = 1}; /* designated (C99) */
printf("sizeof readings = %zu bytes, %zu elements\n",
sizeof readings, sizeof readings / sizeof readings[0]);
printf("hours has %zu elements\n", sizeof hours / sizeof hours[0]);
for (int i = 0; i < 5; i++) {
printf("readings[%d] = %d\n", i, readings[i]);
}
for (int i = 0; i < 8; i++) {
printf("%d", flags[i]);
}
printf("\n");
return 0;
}
sizeof readings = 20 bytes, 5 elements hours has 4 elements readings[0] = 120 readings[1] = 95 readings[2] = 130 readings[3] = 0 readings[4] = 0 00100001
sizeof readings / sizeof readings[0], the total size
divided by the size of one element, is the standard way to get the
number of elements; many projects wrap it in a macro such as
ARRAY_SIZE(a). A local array without an
initializer is not zeroed: its elements hold whatever was in memory
before, so write = {0} when you need a clean start.
Arrays cannot be assigned or compared as a whole.
yesterday = today; does not compile, and
today == yesterday compares two addresses, not the
contents. Copy with a loop or with memcpy:
#include <stdio.h>
int main(void)
{
int today[3] = {120, 95, 130};
int yesterday[3];
yesterday = today;
printf("%d\n", yesterday[0]);
return 0;
}
example.c:8:15: error: assignment to expression with array type
#include <stdio.h>
#include <string.h>
int main(void)
{
int today[3] = {120, 95, 130};
int yesterday[3];
memcpy(yesterday, today, sizeof today);
today[0] = 999;
printf("today[0] = %d, yesterday[0] = %d\n", today[0], yesterday[0]);
return 0;
}
today[0] = 999, yesterday[0] = 120
C never checks bounds
Indexing an array compiles to a simple address calculation: start address plus index times element size. Nothing checks that the index is inside the array. An out-of-bounds access is a read or write through an index or pointer that lies outside the object it belongs to. It is undefined behavior: the program reads or overwrites whatever happens to be next in memory, such as another variable, the bookkeeping of the heap or a return address.
#include <stdio.h>
int main(void)
{
int readings[3] = {120, 95, 130};
int index = 3; /* one past the last valid index */
printf("reading: %d\n", readings[index]);
return 0;
}
SUMMARY: AddressSanitizer: stack-buffer-overflow example.c:8 in main
Built normally (gcc 12, -O0) on our test machine, a
64-bit Linux machine (ARM64), the same program printed
reading: 3: the value of index, which
happened to be stored right after the array. No crash, no warning, a
believable number. A different compiler, optimization level or
machine gives a different answer. With the sanitizers on, the
program stops at the bad read instead. Before AddressSanitizer's
report, UndefinedBehaviorSanitizer prints a line that names the
mistake exactly:
example.c:8:37: runtime error: index 3 out of bounds for type 'int [3]'
The compiler sees some cases too, but only when it can work out the
index: with -O2, gcc's
-Warray-bounds reports a constant index past the end
(array subscript 3 is above array bounds of ‘int[3]’);
at -O0 it says nothing. Off-by-one loops are the most
common cause; see
control flow and loops.
Array decay
In almost every expression, the name of an array is converted into a
pointer to its first element. This
array decay is why
int *p = readings; works without an &,
and why p then equals &readings[0].
There are three exceptions: the operand of
sizeof (which measures the whole array), the operand of
& (which gives a pointer to the whole array, with
the same address but a different type) and a string literal used to
initialize a char array. (C23 adds
typeof as a fourth.)
Decay also happens when an array is passed to a function. The
function receives only the pointer, so it cannot know how many
elements there are. A parameter written as
int values[] or even int values[4] is
really int *values, and sizeof on it gives
the size of a pointer. gcc warns about exactly this mistake:
#include <stdio.h>
int total(int values[], int count)
{
int sum = 0;
for (size_t i = 0; i < sizeof values / sizeof values[0]; i++) {
sum += values[i];
}
(void)count;
return sum;
}
int main(void)
{
int readings[4] = {120, 95, 130, 110};
printf("%d\n", total(readings, 4));
return 0;
}
example.c:6:35: error: ‘sizeof’ on array function parameter ‘values’ will return size of ‘int *’ [-Werror=sizeof-array-argument]
The fix is the C convention: pass the pointer and the number of
elements together, as a size_t, and mark the pointer
const when the function only reads:
#include <stddef.h>
#include <stdio.h>
static void show(const int *values, size_t count)
{
printf("in show: sizeof values = %zu (a pointer)\n", sizeof values);
for (size_t i = 0; i < count; i++) {
printf(" %d", values[i]);
}
printf("\n");
}
int main(void)
{
int readings[4] = {120, 95, 130, 110};
int *p = readings; /* the array decays to &readings[0] */
printf("in main: sizeof readings = %zu (the whole array)\n", sizeof readings);
printf("p == &readings[0]: %d\n", p == &readings[0]);
show(readings, sizeof readings / sizeof readings[0]);
return 0;
}
in main: sizeof readings = 16 (the whole array) p == &readings[0]: 1 in show: sizeof values = 8 (a pointer) 120 95 130 110
Because the function works on the caller's elements through the
pointer, it can also change them: arrays are the one argument that
behaves as if it were passed by reference. Decay is also why the
length idiom fails on a pointer outside a function parameter, for
instance on memory from malloc. gcc and clang catch the
plain case with -Wsizeof-pointer-div (part of
-Wall), but not a disguised one, for example when
sizeof p is first stored in a variable, so keep the
count yourself.
Pointer arithmetic
Pointer arithmetic is
adding an integer to a pointer, subtracting one, or subtracting two
pointers into the same array. It counts in elements, not bytes: if
p points at an int,
p + 1 points at the next int, 4 bytes
further on this machine. The subscript operator is defined in terms
of it: a[i] means exactly *(a + i). The
difference of two pointers has the type ptrdiff_t,
printed with %td.
#include <stddef.h>
#include <stdio.h>
int main(void)
{
int readings[5] = {120, 95, 130, 110, 105};
int *p = readings;
printf("*p = %d\n", *p);
printf("*(p + 2) = %d\n", *(p + 2));
printf("p[2] = %d\n", p[2]);
printf("2[p] = %d\n", 2[p]);
int *last = &readings[4];
ptrdiff_t distance = last - p;
printf("elements between: %td\n", distance);
printf("bytes between: %td\n", (char *)last - (char *)p);
return 0;
}
*p = 120 *(p + 2) = 130 p[2] = 130 2[p] = 130 elements between: 4 bytes between: 16
The order in 2[p] is legal only because
*(2 + p) is the same as *(p + 2); it
appears in puzzles, never in real code. The cast to
char * in the last line is how you count bytes: a
char is one byte by definition.
Walking an array with a pointer
Instead of an index, a loop can move a pointer from the first
element to the end. C allows a pointer to the position just past the
last element, so readings + 5 is a valid end marker to
compare with, as long as it is never dereferenced. Any pointer
further out, or before the first element, is undefined behavior even
if it is never used.
#include <stdio.h>
int main(void)
{
int readings[5] = {120, 95, 130, 110, 105};
const int *end = readings + 5; /* one past the last element */
int total = 0;
for (const int *p = readings; p < end; p++) {
total += *p;
}
printf("total %d\n", total);
return 0;
}
total 560
Pointer loops show up in older code and in code that walks through strings and buffers, like the telegram scanner on control flow and loops. With today's optimizing compilers the index version is just as fast, and usually easier to read.
*p++ and friends
Increment and dereference are often combined, and the precedence
decides what moves: the pointer or the value it points to.
*p++ reads the current element and then moves the
pointer; (*p)++ increases the element and leaves the
pointer alone:
#include <stdio.h>
int main(void)
{
int kwh[] = {12, 15, 11};
int *p = kwh;
int a = *p++; /* read kwh[0], then move p */
int b = (*p)++; /* read kwh[1], then add 1 to it */
int c = *++p; /* move p, then read kwh[2] */
printf("a=%d b=%d c=%d, kwh = {%d, %d, %d}\n", a, b, c, kwh[0], kwh[1], kwh[2]);
return 0;
}
a=12 b=15 c=11, kwh = {12, 16, 11}
Sentinel values
Since an array does not carry its length, code needs another way to
know where the data stops. Besides passing a count, the other
classic answer is a
sentinel value: a special
value that can never be real data, placed after the last element to
mark the end. The best-known sentinel in C is the zero byte that
ends every string (see
strings and buffer overflows); NULL ends the argv array.
#include <stdio.h>
#define END_OF_DATA (-1)
static int sum_until_sentinel(const int *values)
{
int sum = 0;
for (const int *p = values; *p != END_OF_DATA; p++) {
sum += *p;
}
return sum;
}
int main(void)
{
int monday[] = {120, 95, 130, END_OF_DATA};
int tuesday[] = {110, END_OF_DATA};
printf("monday %d, tuesday %d\n",
sum_until_sentinel(monday), sum_until_sentinel(tuesday));
return 0;
}
monday 345, tuesday 110
A sentinel only works if it is really there. Forget it, and the loop runs on through memory until it happens to find a −1, or crashes. Built normally, this version crashed with a segmentation fault on our test machine; with the sanitizers it stops at the first read past the array:
#include <stdio.h>
static int sum_until_sentinel(const int *values)
{
int sum = 0;
for (const int *p = values; *p != -1; p++) {
sum += *p;
}
return sum;
}
int main(void)
{
int monday[] = {120, 95, 130}; /* sentinel forgotten */
printf("monday %d\n", sum_until_sentinel(monday));
return 0;
}
SUMMARY: AddressSanitizer: stack-buffer-overflow example.c:6 in sum_until_sentinel
The sentinel must also be a value that real data can never take. −1 is safe for energy readings that are never negative, but a meter that exports solar power can report negative net values. A separate count avoids both problems, which is why most modern C interfaces take a pointer and a length.
Multidimensional arrays
A
multidimensional array
in C is an array whose elements are arrays.
int usage[3][4] is an array of 3 rows, each an array of
4 int. The rows are stored one after another in a
single block (row-major order), so usage[1][0] comes
right after usage[0][3]. Passed to a function, the
outer array decays to a pointer to its first row, and the function
must know the length of a row to find the next one: only the first
dimension may be left out of the parameter.
#include <stdio.h>
#define HOMES 3
#define QUARTERS 4
/* The column count must be part of the parameter type. */
static int peak(int usage[][QUARTERS], int homes)
{
int max = usage[0][0];
for (int h = 0; h < homes; h++) {
for (int q = 0; q < QUARTERS; q++) {
if (usage[h][q] > max) {
max = usage[h][q];
}
}
}
return max;
}
int main(void)
{
int usage[HOMES][QUARTERS] = {
{12, 15, 11, 14},
{13, 41, 12, 10},
{11, 12, 55, 13},
};
printf("sizeof usage = %zu, sizeof usage[0] = %zu\n", sizeof usage, sizeof usage[0]);
printf("row 1 starts %td bytes after row 0\n",
(char *)usage[1] - (char *)usage[0]);
printf("peak: %d\n", peak(usage, HOMES));
return 0;
}
sizeof usage = 48, sizeof usage[0] = 16 row 1 starts 16 bytes after row 0 peak: 55
Loop over the last index in the inner loop, as here: that visits the
memory in order, which is faster on any machine with a cache. An
array of pointers to separate rows, int *rows[3], looks
the same when indexed (rows[1][2]) but is a different
structure: the rows can have different lengths and live anywhere in
memory.
Variable-length arrays
A
variable-length array
(VLA) is an array whose size is a run-time value, such as
int kwh[quarters]; where quarters is a
variable. VLAs were added in C99 and became optional in C11: a
compiler that leaves them out defines the macro
__STDC_NO_VLA__. (C23 made variably modified types,
such as pointers to VLAs, mandatory again, but VLAs on the stack are
still optional.) gcc supports them, and
-Wall -Wextra do not complain:
#include <stdio.h>
static double average(int count, const int values[count])
{
long sum = 0;
for (int i = 0; i < count; i++) {
sum += values[i];
}
return (double)sum / count;
}
int main(void)
{
int quarters = 4; /* known only at run time */
int kwh[quarters]; /* a variable-length array */
for (int i = 0; i < quarters; i++) {
kwh[i] = 100 + 10 * i;
}
printf("sizeof kwh = %zu\n", sizeof kwh);
printf("average %.1f\n", average(quarters, kwh));
return 0;
}
sizeof kwh = 16 average 115.0
sizeof on a VLA is computed at run time. The parameter
const int values[count] documents the expected length,
but it still decays to a pointer; nothing is checked.
The problem is where a VLA lives: on the stack, with no way to
report failure. A size that comes from input, or simply a large one,
can run past the end of the stack, and the program crashes or
silently overwrites other memory. On a microcontroller with a few
kilobytes of stack per task that happens quickly. Many projects and
embedded coding standards therefore forbid VLAs, and gcc's
-Wvla flags every one. Recorded with that flag:
vla.c:15:5: warning: ISO C90 forbids variable length array ‘kwh’ [-Wvla]
15 | int kwh[quarters]; /* a variable-length array */
| ^~~
Prefer a fixed maximum size with a checked count, as firmware does
with its
fixed-size buffers, or allocate on the heap with malloc and check the
result (see dynamic memory).