Arrays, Strings, Pointers, and Memory
The question: which elements may this code access?
Section titled “The question: which elements may this code access?”C gives programs very direct and efficient ways to refer to memory, and in exchange it expects the programmer to preserve the boundary between an object and whatever else happens to be sitting next to it. Ordinary C array access has no automatic runtime bounds check. A compiler may warn about a mistake it can recognize, but a clean build doesn’t establish that every access is valid. A program can compile perfectly cleanly and still read or write well outside the object it was supposed to be using.
So we need a model precise enough to reason about that risk. An object is a region of storage holding a value, and every object has a type and a size. An array is one object containing a fixed number of consecutive elements, all of the same type. A pointer is a value used to refer to an object or location. Its C type tells the compiler how large a pointer step is and how to interpret the object it points to; the type isn’t extra information stored beside the address.
An address on its own carries no array length whatsoever. When a function receives a pointer to the first element, the caller usually has to supply the element count separately. Losing that count is exactly how a perfectly ordinary-looking loop turns into a memory bug.
Create a dedicated directory and memory_walk.c:
mkdir -p ~/underdunn-course/exercises/c-memory-walkcd ~/underdunn-course/exercises/c-memory-walk#include <ctype.h>#include <stddef.h>#include <stdio.h>
static void print_numbers(const int *values, size_t count) { for (size_t index = 0; index < count; index++) { printf("value[%zu]=%d offset=%zu\n", index, values[index], index * sizeof values[0]); }}
static void print_bytes(const char *text, size_t count) { for (size_t index = 0; index < count; index++) { printf("byte[%zu]=%u\n", index, (unsigned int)(unsigned char)text[index]); }}
static void uppercase_ascii(char *text, size_t capacity) { for (size_t index = 0; index < capacity && text[index] != '\0'; index++) { text[index] = (char)toupper((unsigned char)text[index]); }}
int main(void) { int values[] = {4, 7, 9}; size_t value_count = sizeof values / sizeof values[0]; char word[] = "map"; size_t word_size = sizeof word;
print_numbers(values, value_count); print_bytes(word, word_size); uppercase_ascii(word, word_size); printf("word=%s size=%zu\n", word, word_size); return 0;}There are two arrays in this program. values holds three int elements, and word holds four char elements, because that string literal contributes an m, an a, a p, and then a terminating zero byte on the end. Both printing functions receive a pointer plus a count. uppercase_ascii receives a pointer plus the array’s total capacity, and it also stops the moment it meets the string terminator.
Those casts around toupper are deliberate. The character-classification functions require either EOF or a value representable as an unsigned char, and converting through unsigned char is what stops a negative plain char from violating that contract on the many implementations where char happens to be signed. Our sample uses plain ASCII letters, so the cast changes nothing you can actually see here. Writing the call correctly now is what keeps it working on the day your input contains a byte outside the ASCII range.
Predict the boundary evidence
Section titled “Predict the boundary evidence”Before building, predict:
- Valid indexes for
values. - The byte offsets printed for its three elements.
- The numeric bytes for
word, including its terminator. - The final word and
word_size.
Build and run:
gcc -std=c11 -Wall -Wextra -Wpedantic -O0 -g memory_walk.c -o memory_walk./memory_walkExpected output:
value[0]=4 offset=0value[1]=7 offset=4value[2]=9 offset=8byte[0]=109byte[1]=97byte[2]=112byte[3]=0word=MAP size=4The supported x86-64 environment uses four-byte int objects (which is a property of this build, not a law). Notice the program prints the measured sizeof relationship rather than quietly assuming it.
Arrays and the one-past boundary
Section titled “Arrays and the one-past boundary”values contains three consecutive int elements, so the valid indexes are 0, 1, and 2. The expression values + value_count forms a pointer one element past the end of the array. C permits you to form that boundary pointer and to compare against it, and it does not permit you to dereference it.
Why allow a pointer you’re not allowed to dereference? Because it gives loops an end marker to compare against. A loop can advance a pointer until it equals that one-past value, in exactly the way an index loop advances until index == count. The boundary describes where iteration stops, and it does not conjure up a fourth element.
int *pointer = &values[0] stores the first address. Adding one advances by one int.
Those printed offsets establish the spacing between element starts, and nothing more than that. They don’t reveal any fixed virtual addresses, and another run may well place the whole array somewhere else.
On this build each int occupies four bytes, so the element starts sit four bytes apart. Pointer arithmetic does that scaling for you automatically, and values + 1 refers to the next int, while adding one to a byte pointer would advance a single byte. The source expression counts elements, and the type is what determines the byte displacement.
Strings include a terminator
Section titled “Strings include a terminator”char word[] = "map" allocates four elements in total, which is three visible characters plus the terminating zero byte that nobody typed. Any function treating that array as a C string stops at the zero.
A C string isn’t a separate built-in object storing its own length. It’s a convention, and the convention is a sequence of characters ending at the first zero byte. The array has a known size at the point it’s declared, and a function receiving only a char * sees an address rather than the declaration that created it.
print_bytes receives an explicit count, so it prints the terminator too (and that terminator row is the whole point of the exercise), while printf("%s") receives only a pointer and relies entirely on the terminator to find the end.
If that terminator goes missing inside the valid object, %s has no boundary information left to rescue it. It carries on reading straight past the end of the array, and the program’s behavior becomes undefined the moment that happens. We’re not going to manufacture that failure here. The lesson worth taking away is to preserve capacity, to guarantee termination, and to use bounded interfaces wherever the surrounding code needs them.
A pointer carries an element type
Section titled “A pointer carries an element type”Inside print_numbers, values[index] is equivalent to *(values + index). Adding one to an int * advances by a whole int, and not by one byte, because the element type supplies the step size pointer arithmetic uses.
The function also receives count, and that parameter really isn’t decoration. A pointer on its own never preserves the array length, so the caller and the function have to maintain that boundary between them, as an explicit part of their interface.
When an array expression gets passed to a function it usually converts into a pointer to its first element, and people often say the array “decays” into a pointer, which makes it sound a great deal more dramatic than it is. The storage doesn’t go anywhere, and the function parameter simply no longer carries the array’s element count. That’s why sizeof values works as an array-size calculation inside main, while applying sizeof to the pointer parameter inside print_numbers would just measure the pointer.
The array, pointer, and string sections of GNU C pointers and arrays separate the storage from the operations allowed on it.
Change one array
Section titled “Change one array”Copy the source and change only word from "map" to "heap":
cp memory_walk.c memory_walk_heap.cPredict the byte-row count, the terminator index, the uppercase output, and word_size. Then build and run it:
gcc -std=c11 -Wall -Wextra -Wpedantic -O0 -g memory_walk_heap.c -o memory_walk_heap./memory_walk_heapThe integer-array rows should be completely unchanged. The string now runs to five bytes including its terminator, and the final line reads word=HEAP size=5.
Practice: preserve the numeric boundary
Section titled “Practice: preserve the numeric boundary”In another copy, add 12 to values. Predict the new valid index, its byte offset, and the total number of numeric rows you expect to see. Build and verify all of that without touching print_numbers at all.
Then explain why changing the call to print_numbers(values, value_count + 1) would violate the function’s boundary even though it compiles perfectly happily. Don’t run that out-of-bounds version.
Common failures and recovery
Section titled “Common failures and recovery”- Warnings from
toupper: keep the cast throughunsigned char, and restore the complete baseline if you need to. - No zero-byte row appears: pass
sizeof wordtoprint_bytes, rather than the visible string length. - The offsets aren’t what you expected: check the source still uses
int values[], and inspectsizeof values[0]from this particular build. - Practice changes are affecting unrelated rows: compare with
diff -u, and make one source change at a time. - A stale binary runs: rebuild the named output after every edit.
- You created an out-of-bounds version: don’t try to diagnose its runtime behavior here. Delete it, and restore the bounded baseline.
Check your result
Section titled “Check your result”For an array with n elements, the valid indices end at n - 1. Check that the count passed to the function matches the available elements and that a string has room for its terminator. Keep your source files for further experiments.