Pointers in C

Subpage of C Deep Dive

A close look at the C language.

Here is why we use pointers:

  • Instead of passing around a whole copy of the data, you can just pass a pointer.
  • You might want two pieces of code to work on the same piece of data rather than a separate copy. Passing variables to a function is a good example.

Pointers are mapped to addresses in memory. It is useful to think of pointers as notes with an address of the box in memory. The address is given by the operating system.

This image is useful because it tells us that the pointer is an entirely separate variable in itself. It is also a box.

Memory

The C compiler, in collaboration with the linker and the operating system, partitions a program's virtual memory space into several distinct segments. Here are the key segments you should know about:

  • Text (Code) Segment: This segment stores the compiled machine code instructions of the program.

    It is typically read-only to prevent accidental modification of the program's logic during execution.

  • Data Segment: It stores global and static variables of the program.
    • Initialized Data Segment: This area contains global and static variables that have been explicitly assigned an initial value by the programmer.

      It is further divided into a read-write area for modifiable variables and a read-only area for constants (e.g., string literals or variables declared with const).

    • Uninitialized Data Segment (BSS): Also known as the BSS (Block Started by Symbol) segment, this area reserves space for all uninitialized global and static variables.

      Before the program begins execution, the kernel or program loader initializes the memory in this segment to zero or null pointers.

  • Heap: The heap is used for dynamic memory allocation during program runtime. The programmer manually requests and frees memory from the heap using library functions like malloc()calloc(), and free().

    This segment allows for flexible memory management when the required size of data is not known at compile time.

  • Stack: The stack is used for automatic memory allocation (static memory allocation in the context of the C memory model). It stores local variables, function arguments, and function return addresses. Memory on the stack is managed automatically in a LIFO (last-in, first-out) manner; a new stack frame is created when a function is called, and it is destroyed when the function returns.

    A stack frame is often also called an activation record. It stores all the information necessary for that function's execution and is deallocated when the function returns.

Accessing Memory

For all of these spaces in memory, if you want to find out the memory address of any variable, use the & operator. This is sometimes called the reference operator.

The “opposite” of the & operator is the * operator which retrieves the data stored in the memory address. The * operator is sometimes called the dereference operator.

Together, here is a demonstration of how they work:

int x = 4;
printf("x lives at %p\n", &x); // This prints out the value of the pointer
int *address_of_x = &x;
int value_stored = *address_of_x; // This sets value_stored to 4

This outlines the basic steps that are always followed in creating pointers:

  1. Declare the variable
  2. Declare the pointer

    The declaration of the pointer int *address_of_x can be read from right to left as “the address_of_x is a pointer to a variable of type int".

  3. Assign the pointer to the variable using int *address_of_x = &x;

    Alternatively, we could initialize the variable directly using int *address_of_a = &a;

Note that the pointer is not stored as an int but rather as a pointer type.

Examples of pointer types include int *char *float *.

Be very careful to distinguish the role of the * here from what we will see later. The star is not an indirection operator but rather the pointer type.

Note that the size of a pointer in C depends on the architecture (bit system) of the machine, not the data type it points to:

  • On a 32-bit system, all pointers typically occupy 4 bytes.
  • On a 64-bit system, all pointers typically occupy 8 bytes.

The pointer type encodes what kind of object it points to, so int * points to int, char * points to char, and void * is a generic pointer type that can be converted to and from other object pointers.

This discussion of pointer types continues in Pointer Types.

If you have a pointer variable and you want to change the data at the address where the variable’s pointing, you can just use the * operator again. But this time you need to use it on the left side of an assignment:

*address_of_x = 99; // This is equivalent to x = 99;

Substituting *address_of_x with x is a good problem solving strategy. However, ensure that address_of_x is a pointer variable otherwise there will be a compilation error.

In summary:

*: pointer → object (indirection/dereferencing operator)

&: object → pointer

If we don’t initialise a pointer, it is pointing to a random location in memory.

int *n;
*n = 123; // Oops!

If we try to dereference this memory location, it has the same effect has trying to access a random location in memory that might not even be allocated to the program. This is why the operation will be blocked by the CPU, leading to a segmentation fault.

Passing Pointers

This piece of code leads to strange behaviour:

#include <stdio.h>

void fortune_cookie(char msg[])
{
  printf("Message reads: %s\n", msg);
  printf("Message Occupies %li bytes\n", sizeof(msg));
}


int main()
{
  char quote[] = "Cookies make you fat"; // Line A
  printf("Message Occupies %li bytes\n", sizeof(quote));
  fortune_cookie(quote);
  return 0;
}

/**
Output:
	Message Occupies 21 bytes
	Message reads: Cookies make you fat
	Message Occupies 8 bytes
/*

Here is a line by line breakdown of why the code behaves this way:

  • The fortune_cookie function declaration contains a parameter char msg[], which looks like “array of char”.
  • However, in reality, char msg[] is not actually an array. It is treated by the compiler as:
    void fortune_cookie(char *msg)

    This is the key concept: in C, when an array is passed to a function, it automatically "decays" (or is implicitly converted) into a pointer to its first element.

    So inside the function:

    • msg is a pointer to char: it holds the address of the first character of the string.
    • size(msg) gives you the size of that pointer (e.g., 8 bytes on a 64-bit system), not the length of the string, and not the size of the original array in .

    In general, decay happens whenever an array variable is assigned to a pointer. The loss of information that happens is called decay.

  • Inside main, quote is declared as a character array (a contiguous block of memory).

    When sizeof(quote) is used in main, the compiler knows quote is a complete array. Thus, It calculates the actual size of the entire array block.

This behavior is why you often cannot reliably determine the length of a string inside a C function using sizeof on the pointer argument; you must use the strlen() function, which counts characters until it finds the null terminator (\(\backslash 0\)).

The reason why the string is nonetheless printed inside fortune_cookie is because we use the %s format specifier. This tells the compiler to look through characters until it find the null terminator.

If instead we used the %p format specifier as follows:

printf("Message reads: %p\n", msg); // Inside fortune_cookie
// Output: Message reads: 0x7ffe5e7787c0

the memory address of the first character in quote is printed out.

Hence, although the array variable behaves like a pointer, it is not a pointer. Consider two variables s and t in the code below. We will compare their behaviour:

char s[] = "How big is it?";
char *t = s;
  • When the sizeof operator is applied to s and t, we get different results. This is because:
    • the compiler knows that s is an array so it returns the size of the array
    • t is just a pointer so it returns the size of the pointer (either 4 or 8)
printf("s is %li\n", sizeof(s)); 
// s is 15
printf("t is %li\n", sizeof(t));
// t is 8
  • A pointer variable is just a variable that stores a memory address. So it must be true that &t != t. But if you use the & operator on an array variable, the result equals the array variable itself. So &s == s.
  • You cannot reassign the array variable to anything else because they don’t have allocated storage. So you can’t do something like s = t but t = s is legal.

Note that t = s leads to decay because the pointer variable will only contain the address of the array. The pointer doesn’t know anything about the size of the array, so a little information has been lost.

Pointer Arithmetic

A key rule for pointer arithmetic is that adding an integer to a pointer advances it by that integer times the size of the pointed-to type. For example, if p is an int * and sizeof(int) == 4, then p + 1 advances the address by 4 bytes.

Arrays and pointers are closely related.

An array variable typically decays to a pointer to its first element, so arr[i] is defined as *(arr + i). This means that we have two ways to read elements from an array:

int drinks[] = {4, 2, 3};
drinks[0] == *drinks;
drinks[2] == *(drinks + 2);

That’s why arrays begin with index 0. The index is just the number that’s added to the pointer to find the location of the element.

There is one more detail that is important about the subscript ([]) operator.

left[right] is defined *(left + right) so the notation is fully commutative.

This leads to symmetric syntax for indexing: drinks[2] = 2[drinks].

Thus, the following are all equivalent:

drinks[3] == *(drinks + 3) == *(3 + drinks) == 3[drinks]

The only other operation that you can do to pointers in subtraction (no multiplication) but be careful that you don’t go back before the start of the allocated space in the array.

Pointer arithmetic is possible because array variables are not stored separately in C. Instead, the array is identified by the pointers to the elements.

Pointer Types

The type of p is “pointer to int”, usually written int *, and this type determines what operations such as dereference, arithmetic, and conversions are allowed on p.

The reason why we need to different pointer types (as opposed to just a general pointer variable) is pointer arithmetic needs to remain consistent. If you add 1 to a pointer, it must point to the next memory address. However, the position of the next memory address depends on the space occupied by the current type.

Different pointer types have different sizes, as determined by sizeof. sizeof yields a value in bytes that is implementation-defined for each type.

There is a common standard for the sizes of pointer types but it depends on the architecture.

Thus, if we have a pointer T *p, we can define p + n as an operation that advances the pointer by n * sizeof(T) bytes in memory.

Globals

A global variable is one that lives outside any particular function. Global variables are available to all of the functions in the program.

If a variable is declared outside a function, it will be in global scope.

#include <stdio.h>

// Global variable declaration and definition
int global_count = 0;

void increment() {
    global_count++; // Access and modify the global variable
}

int main() {
    printf("Initial count: %d\n", global_count); // Output: Initial count: 0
    increment();
    printf("Updated count after increment: %d\n", global_count); // Output: Updated count after increment: 1
    return 0;
}

These are some of the important characteristics of global variables:

  • Lifetime: They persist in memory for the entire duration of the program's execution, from start-up to termination.
  • Memory: Global variables are stored in the data segment (specifically, the initialized data segment or BSS for uninitialized variables) of the memory, not on the stack.
  • Default Value: Uninitialized global variables are automatically initialized to zero by the compiler, unlike local variables which hold garbage values.
  • Accessibility: Any function can access and modify a global variable's value, and the updated value persists across all function calls.

While useful, global variables should be used sparingly and with careful design to avoid bugs and maintain code clarity. Use the const qualifier for global constants to prevent accidental modification, and consider alternatives like passing parameters or encapsulating data in structures with access functions (getters/setters). 

More about pointers

Here is a compact example showing allocation, pointer arithmetic, and function pointers:

#include <stdio.h>
#include <stdlib.h>

int add(int a, int b) { return a + b; }

int main(void) {
    int n = 5;
    int *arr = malloc(n * sizeof *arr);  /* allocate array of n ints */
    if (!arr) return 1;
    for (int i = 0; i < n; ++i) arr[i] = i * i; /* uses arr[i] which is *(arr + i) */

    int *p = arr + 2;             /* pointer arithmetic: points to arr[2] */
    printf("%d\n", *p);           /* prints arr[2] */

    int (*op)(int,int) = add;     /* function pointer */
    printf("sum: %d\n", op(3,4)); /* call via function pointer */

    free(arr);                    /* release memory */
    arr = NULL;                   /* avoid dangling pointer */
}

Pointers can be to other pointers (int **) enabling multi-level indirection, which is useful for building linked lists, trees, or returning allocated pointers from functions via out-parameters. Function pointers encode a function’s signature and can be stored in arrays to implement callback tables.

Pointer pitfalls include dereferencing uninitialized pointers, using pointers after free, assuming pointer representation (pointer to function and pointer to data may differ on some platforms), and violating effective type rules by accessing an object through an incompatible pointer type (the strict aliasing rule), which can cause unpredictable behavior because compilers assume aliasing rules to optimize code.

void * is a byte-addressable generic pointer useful for memory functions; arithmetic on void * is not allowed without a cast because void has no size.

const pointers

Const correctness is subtle with pointers.

  • const int *p means *p cannot be modified via p
  • int * const p means p cannot be changed to point elsewhere
  • const int * const p applies both.

A useful reading trick is to start from the name and move outward:

  • const int *p → “p is a pointer to const int.”
  • int * const p → “p is a const pointer to int.”

/ Continue

Follow the technical trail.

Use the dense notes as the source material, then move through the guided route, writing, or project proof when you want a cleaner entry point.