Chapter 27: C Programming Refresher: Pointers and Memory Addresses

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the relationship between memory, addresses, and data.
  • Declare, initialize, and use pointers to manipulate variables.
  • Explain and correctly use the address-of (&) and dereference (*) operators.
  • Implement pointer arithmetic to navigate arrays and memory blocks.
  • Apply pointers in function arguments to achieve pass-by-reference behavior.
  • Identify and debug common pointer-related errors in C programs.

Introduction

In the world of high-level, managed programming languages, the raw, underlying architecture of the computer is often hidden from view. Memory is allocated and garbage-collected automatically, and direct interaction with hardware is abstracted away. Embedded systems programming, however, operates on a different philosophy. To write efficient, powerful, and precise code for devices that interact with the physical world, a developer must engage directly with the machine’s hardware. The C programming language provides the primary tool for this level of control: the pointer.

A pointer is, in essence, a variable that holds the memory address of another variable. This simple concept is the key to unlocking a vast range of capabilities that are indispensable in embedded Linux development. Whether you are writing a kernel module, a device driver that communicates with a sensor over I2C, manipulating data in a network buffer, or optimizing a time-critical algorithm, you will be using pointers. They allow for efficient data passing between functions, dynamic memory allocation, and the construction of complex data structures. More importantly, they provide the mechanism to read from and write to specific memory-mapped hardware registers, which is the fundamental way software controls hardware. This chapter will revisit these core concepts, moving from the theoretical foundation of memory addresses to the practical application of pointer arithmetic and manipulation on your Raspberry Pi 5. A mastery of pointers is not optional; it is the bedrock upon which professional embedded programming is built.

Technical Background

To truly understand pointers, one must first understand the landscape in which they operate: the computer’s memory. Imagine the system’s Random Access Memory (RAM) as a vast, linear array of storage cells, much like a street with a very long line of houses. Each house has a unique address that allows the postal service to find it. Similarly, every single byte in your computer’s memory has a unique numerical address. This address allows the Central Processing Unit (CPU) to locate, read, and write data with precision.

When you declare a simple variable in C, for instance int num = 42;, the compiler and operating system work together to find an unused block of memory, reserve it for your variable, and place the value 42 into that location. For an int on a typical 32-bit or 64-bit system, this block might be 4 bytes in size. The crucial point is that this 4-byte block has a starting address. It is this address that a pointer is designed to hold.

A pointer is not the data itself, but the location of the data. It’s the difference between having $100 in your hand and having a piece of paper with the address of a safe where $100 is stored. To declare a pointer, we use the asterisk (*) symbol. For example, to declare a pointer that can hold the address of an integer, we would write int *p_num;. This statement tells the compiler, “p_num is a variable that will store the address of an integer.” The type int * is read as “pointer to an integer.” This type-checking is a critical safety feature of C. The compiler knows that p_num should only point to memory locations that are intended to hold integer data, preventing you from, for example, accidentally pointing an integer pointer to a floating-point number and misinterpreting the data.

The Address-Of and Dereference Operators

Once we have a pointer variable, we need two fundamental operators to make it useful. The first is the address-of operator (&). This is a unary operator that returns the memory address of a variable. To make our pointer p_num point to our integer num, we use the & operator in an assignment:

p_num = #

After this line of code executes, the variable p_num now contains the memory address where the value of num is stored. If you were to print the value of p_num, you wouldn’t see 42. Instead, you would see a hexadecimal number representing a location in memory, such as 0x7ffc9a7b2e4c.

This leads to the second fundamental operator: the dereference operator (*). This is the counterpart to the & operator. When used on a pointer variable, it “follows” the address stored in the pointer and accesses the value at that memory location. It can be read as “the value pointed to by.” For example:

int value_from_pointer = *p_num;

In this statement, the program takes the address stored in p_num, goes to that location in memory, reads the value stored there (which is 42), and assigns it to the new integer variable value_from_pointer. The dereference operator can also be used on the left side of an assignment to change the value at the pointed-to address:

*p_num = 100;

This line of code effectively changes the value of the original num variable to 100. By dereferencing p_num, we gained indirect access to the memory location of num. This ability to indirectly modify variables is one of the most powerful features of pointers, especially when passing them to functions.

graph TD
    subgraph "Scenario: Interacting with Variable 'num'"
        A[<b>Start:</b><br>Declare <i>int num = 42;</i>]
        B{Need to get the<br>memory address of 'num'?}
        C[Use Address-Of Operator:<br><code>p_num = #</code>]
        D{Need to access the value<br>using the pointer 'p_num'?}
        E[Use Dereference Operator:<br><code>value = *p_num;</code>]
        F[<b>Result:</b><br>Pointer 'p_num' now holds the address of 'num'.<br>e.g., 0x7ffc9a7b2e4c]
        G[<b>Result:</b><br>Variable 'value' now holds the integer 42.]
        H{Need to change the original<br>value via the pointer?}
        I[Use Dereference for Assignment:<br><code>*p_num = 100;</code>]
        J[<b>Result:</b><br>The original variable 'num' is now 100.]
        K[<b>End</b>]
    end

    A --> B;
    B -- Yes --> C;
    C --> F;
    F --> D;
    B -- No --> D;
    D -- Yes --> E;
    E --> G;
    G --> H;
    D -- No --> H;
    H -- Yes --> I;
    I --> J;
    J --> K;
    H -- No --> K;

    %% Styling
    classDef startNode fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff;
    classDef endNode fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff;
    classDef decisionNode fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff;
    classDef processNode fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff;
    classDef resultNode fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff;

    class A startNode;
    class K endNode;
    class B,D,H decisionNode;
    class C,E,I processNode;
    class F,G,J resultNode;

Pointers and Functions

In C, function arguments are passed by value. This means that when you pass a variable to a function, a copy of that variable’s value is created and given to the function. Any modifications the function makes are to this local copy, and the original variable in the calling code remains unchanged. This is safe and predictable, but often we want a function to modify an original variable. A classic example is a function designed to swap the values of two variables.

Without pointers, this is impossible. If you pass two integers to a swap function, it will only swap its local copies. The original variables will be unaffected. Pointers solve this elegantly. Instead of passing the variables themselves, we pass pointers to the variables. The function then receives the addresses of the original variables. By dereferencing these pointers, the function can directly access and modify the data in the caller’s scope. This technique is known as pass-by-reference, and it is a cornerstone of C programming for creating functions that have a lasting effect on the program’s state.

sequenceDiagram
    actor User as User/OS
    participant M as main()
    participant S as swap(int *p_a, int *p_b)

    User->>M: Executes program
    M->>M: int x = 10;
    M->>M: int y = 20;
    note right of M: Variables 'x' and 'y' are created<br>in main()'s stack frame.

    M->>S: Calls swap(&x, &y)
    note left of S: Passes the memory addresses of x and y,<br><b>not</b> their values.
    
    activate S
    S->>S: Creates pointer p_a = &x
    S->>S: Creates pointer p_b = &y
    note right of S: 'p_a' and 'p_b' now point directly<br>to 'x' and 'y' in main()'s memory.
    
    S->>S: int temp = *p_a; 
    note right of S: Dereferences p_a to get x's value (temp = 10).
    
    S->>S: *p_a = *p_b; 
    note right of S: Dereferences p_b, gets y's value,<br>and writes it to the address p_a points to (x is now 20).
    
    S->>S: *p_b = temp; 
    note right of S: Writes 'temp' to the address p_b points to (y is now 10).
    
    S-->>M: Returns to caller
    deactivate S
    
    note right of M: The original 'x' and 'y' have<br>been successfully modified.
    M->>User: Prints "x = 20, y = 10"

Pointer Arithmetic

The true power of pointers in embedded systems becomes apparent when we discuss pointer arithmetic. This is not the same as regular integer arithmetic. When you add an integer to a pointer, you are not simply adding to the numerical address value. Instead, the compiler performs scaled arithmetic. The value you add is multiplied by the size of the data type the pointer points to.

Consider an array: int my_array[5];. In C, an array name like my_array can be used as a pointer to the first element of the array. So, the address of my_array[0] is the same as the value of my_array. Now, if we have a pointer int *p = my_array;, what does p + 1 mean?

Since p is a pointer to an int, and an int takes up 4 bytes (on this hypothetical system), p + 1 does not add 1 to the memory address. It adds 1 * sizeof(int), or 4 bytes. The expression p + 1 therefore evaluates to the address of the next integer in the array, which is &my_array[1]. Similarly, p + 2 would yield the address of my_array[2].

This scaled arithmetic is profoundly useful. It allows us to iterate through arrays and other contiguous blocks of memory in a way that is both efficient and hardware-agnostic. The programmer doesn’t need to know the exact size of a struct or a long double; the compiler handles the scaling automatically. This is fundamental to processing data buffers, parsing communication protocols, or traversing any data structure laid out in a predictable memory pattern. You can also subtract pointers of the same type, which yields the number of elements (not bytes) between them. This can be useful for calculating the size of a section of an array that has been processed.

It is this low-level, yet type-aware, memory manipulation that makes C and pointers the language of choice for performance-critical embedded applications. It provides a “close to the metal” level of control that is simply not available in most other languages.

Practical Examples

Theory is essential, but the real understanding of pointers comes from writing and running code. The following examples are designed to be compiled and executed on your Raspberry Pi 5. You can use any text editor (like nano or vim) to write the code and the GCC compiler to build it.

Example 1: The Basics of Declaration, Address-Of, and Dereferencing

This first program demonstrates the fundamental operations: declaring a pointer, assigning it an address, and then using it to read and write to the original variable.

File: basic_pointers.c

C
#include <stdio.h>

int main() {
    // 1. Declare an integer variable and initialize it.
    int score = 92;

    // 2. Declare a pointer to an integer.
    // It's good practice to initialize pointers to NULL.
    // NULL is a special value that represents "pointing to nothing".
    int *p_score = NULL;

    // 3. Print the initial state.
    printf("--- Initial State ---\n");
    printf("Value of score: %d\n", score);
    printf("Address of score: %p\n", (void *)&score);
    printf("Value of p_score (should be NULL): %p\n\n", (void *)p_score);

    // 4. Assign the address of 'score' to the pointer 'p_score'.
    p_score = &score;

    printf("--- After Assignment ---\n");
    printf("Value of score: %d\n", score);
    printf("Value of p_score (now holds address of score): %p\n", (void *)p_score);

    // 5. Dereference the pointer to get the value it points to.
    printf("Value pointed to by p_score (*p_score): %d\n\n", *p_score);

    // 6. Use the pointer to modify the original variable's value.
    printf("--- Modifying Value via Pointer ---\n");
    printf("Changing value through pointer: *p_score = 100;\n");
    *p_score = 100;

    // The original 'score' variable is now changed.
    printf("New value of score: %d\n", score);
    printf("New value pointed to by p_score (*p_score): %d\n", *p_score);

    return 0;
}

Build and Run Steps:

1. Save the code above into a file named basic_pointers.c.

2. Open a terminal on your Raspberry Pi 5.

3. Compile the program using GCC:

Bash
gcc -o basic_pointers basic_pointers.c -Wall


The -o basic_pointers flag names the output executable file, and -Wall enables all compiler warnings, which is a very good practice.

4. Execute the program:

Bash
./basic_pointers

Expected Output:

The memory addresses (%p format) will vary each time you run the program.

Plaintext
--- Initial State ---
Value of score: 92
Address of score: 0x7ffc3a8b4f2c
Value of p_score (should be NULL): (nil)

--- After Assignment ---
Value of score: 92
Value of p_score (now holds address of score): 0x7ffc3a8b4f2c
Value pointed to by p_score (*p_score): 92

--- Modifying Value via Pointer ---
Changing value through pointer: *p_score = 100;
New value of score: 100
New value pointed to by p_score (*p_score): 100

This output clearly demonstrates the entire lifecycle: the pointer starts as NULL, then holds the address of score, and is finally used to both read and write score‘s value.

Example 2: Pointer Arithmetic with an Array

This example shows how to use pointer arithmetic to iterate through an array, a common and efficient alternative to using array indices.

File: array_pointers.c

C
#include <stdio.h>

#define ARRAY_SIZE 5

int main() {
    int data[ARRAY_SIZE] = {10, 20, 30, 40, 50};

    // Create a pointer that points to the beginning of the array.
    // The name of an array decays to a pointer to its first element.
    int *p_data = data;

    printf("--- Traversing Array with Pointer Arithmetic ---\n");

    for (int i = 0; i < ARRAY_SIZE; i++) {
        // p_data + i: calculates the address of the i-th element.
        // *(p_data + i): dereferences that address to get the value.
        printf("Address: %p | Value: %d\n", (void *)(p_data + i), *(p_data + i));
    }

    printf("\n--- Alternative Traversal ---\n");
    // Another common idiom is to increment the pointer itself.
    p_data = data; // Reset pointer to the start
    for (int i = 0; i < ARRAY_SIZE; i++) {
        printf("Address: %p | Value: %d\n", (void *)p_data, *p_data);
        p_data++; // Move pointer to the next integer element
    }

    return 0;
}

Build and Run Steps:

1. Save the code as array_pointers.c.

2. Compile it:

Bash
gcc -o array_pointers array_pointers.c -Wall

3. Run it:

Bash
./array_pointers

Expected Output:

The starting address will vary, but the subsequent addresses will be contiguous, incrementing by sizeof(int) (usually 4 bytes).

Plaintext
--- Traversing Array with Pointer Arithmetic ---
Address: 0x7ffee1b9d9e0 | Value: 10
Address: 0x7ffee1b9d9e4 | Value: 20
Address: 0x7ffee1b9d9e8 | Value: 30
Address: 0x7ffee1b9d9ec | Value: 40
Address: 0x7ffee1b9d9f0 | Value: 50

--- Alternative Traversal ---
Address: 0x7ffee1b9d9e0 | Value: 10
Address: 0x7ffee1b9d9e4 | Value: 20
Address: 0x7ffee1b9d9e8 | Value: 30
Address: 0x7ffee1b9d9ec | Value: 40
Address: 0x7ffee1b9d9f0 | Value: 50

Notice how the addresses increment by 4 bytes each time. This is pointer arithmetic in action. The compiler knows p_data is an int * and correctly calculates the address of the next element.

Example 3: Pointers and Functions (Pass-by-Reference)

This final example implements the classic swap function to demonstrate how pointers allow a function to modify its caller’s variables.

File: function_pointers.c

C
#include <stdio.h>

// This function takes two integer pointers as arguments.
// It will modify the values at the addresses they point to.
void swap(int *p_a, int *p_b) {
    printf("  [Inside swap] Received addresses: p_a=%p, p_b=%p\n", (void *)p_a, (void *)p_b);
    
    // To swap, we need a temporary variable to hold one of the values.
    // We must dereference the pointers to get the values.
    int temp = *p_a;
    *p_a = *p_b;
    *p_b = temp;
    
    printf("  [Inside swap] Values have been swapped.\n");
}

int main() {
    int x = 10;
    int y = 20;

    printf("--- Before Swap ---\n");
    printf("x = %d (at address %p)\n", x, (void *)&x);
    printf("y = %d (at address %p)\n\n", y, (void *)&y);

    printf("--- Calling swap(&x, &y) ---\n");
    // We pass the ADDRESSES of x and y to the function.
    swap(&x, &y);
    printf("--- Returned from swap ---\n\n");

    printf("--- After Swap ---\n");
    printf("x = %d\n", x);
    printf("y = %d\n", y);

    return 0;
}

Build and Run Steps:

1. Save the code as function_pointers.c.

2. Compile it:

Bash
gcc -o function_pointers function_pointers.c -Wall

3. Run it:

Bash
./function_pointers

Expected Output:

Plaintext
--- Before Swap ---
x = 10 (at address 0x7ffda4e8c568)
y = 20 (at address 0x7ffda4e8c56c)

--- Calling swap(&x, &y) ---
  [Inside swap] Received addresses: p_a=0x7ffda4e8c568, p_b=0x7ffda4e8c56c
  [Inside swap] Values have been swapped.
--- Returned from swap ---

--- After Swap ---
x = 20
y = 10

As you can see, the swap function successfully modified x and y. This was only possible because we passed their addresses, giving the function direct access to their storage locations in memory.

Common Mistakes & Troubleshooting

Pointers are powerful, but that power comes with responsibility. A mistake with a pointer can lead to notoriously difficult-to-debug issues, from corrupted data to program crashes (segmentation faults). Here are some of the most common pitfalls.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Dereferencing a NULL Pointer
Attempting to access *ptr when ptr is NULL.
Immediate program crash, typically with a Segmentation Fault error message. The OS protects address zero from being accessed. Always check for NULL before dereferencing. This is critical when a pointer is returned from a function that might fail (e.g., malloc).
if (ptr != NULL) { *ptr = value; }
Using an Uninitialized Pointer
A declared pointer that hasn’t been assigned a valid address holds a random, “garbage” value.
Unpredictable behavior. It might corrupt data in another part of your program, or it might cause a Segmentation Fault. These are often called “dangling pointers” and are very hard to debug. Always initialize pointers upon declaration. If you don’t have a valid address yet, initialize it to NULL. This makes the state predictable and checkable.
int *p_sensor = NULL;
Memory Leaks
Forgetting to free() memory that was allocated with malloc().
No immediate error. The program’s memory usage will grow over time. In long-running embedded systems, this can eventually consume all available RAM and cause a crash or system failure. Maintain strict discipline: for every malloc(), ensure there is a corresponding free(). Use tools like Valgrind to analyze your program and detect memory leaks during development.
Buffer Overflow / Out-of-Bounds Access
Using pointer arithmetic to access memory beyond the allocated bounds of an array or buffer.
Data corruption of adjacent variables in memory. Can be exploited for security vulnerabilities. May also cause a Segmentation Fault if you access protected memory. Be meticulous with loop bounds and size calculations. When iterating an array of size N, valid indices are 0 to N-1. Use i < N, not i <= N. Compile with -Wall -Wextra -Werror to catch potential issues.
Incorrect Pointer Type
Casting a pointer to the wrong type and then dereferencing it. E.g., reading a float through an int*.
The data read from memory will be misinterpreted. A 4-byte floating-point number has a completely different binary representation than a 4-byte integer. This leads to nonsensical values. Ensure pointer types always match the data type of the memory they point to. Be extremely careful with void* pointers; cast them back to their original, correct type before dereferencing.

Tip: Compile with all warnings enabled (-Wall -Wextra) and treat warnings as errors (-Werror). The GCC compiler is very good at spotting potential pointer misuse, such as using an uninitialized variable. Heeding its advice will save you hours of debugging.

Exercises

  1. Swap Two Numbers. Write a complete C program that declares two integers in main(), a and b. Write a function void swap(int *ptr_a, int *ptr_b) that takes two integer pointers and swaps the values of the variables they point to. Call this function from main() to swap a and b, and print their values before and after the swap to verify your function works correctly.
  2. String Length Function. The standard C library has a function strlen() that calculates the length of a string. Your task is to write your own version, int my_strlen(char *str). A C-style string is a sequence of characters terminated by a special null character (\0). Your function should take a character pointer (char *) as its argument. It should use pointer arithmetic to traverse the string character by character until it finds the null terminator. It should return the number of characters in the string, not including the null terminator.
  3. Array Summation. Write a function int sum_array(int *arr, int size) that calculates the sum of all elements in an integer array. The function should accept a pointer to the first element of the array and the total number of elements in the array. Inside the function, use a for loop and pointer arithmetic (i.e., *(arr + i) or by incrementing the pointer arr++) to access each element. Return the total sum. In main(), declare an array, call your function, and print the result.
  4. Reverse an Array In-Place. This is a more challenging exercise. Write a function void reverse_array(int *arr, int size) that reverses the elements of an array in-place (meaning you cannot use a second, temporary array). The key is to use two pointers. One pointer, start, should initially point to the first element of the array. A second pointer, end, should point to the last element. In a loop, swap the values pointed to by start and end, then increment start and decrement end. The loop should continue until start and end meet or cross each other.

Summary

This chapter re-established the fundamental concepts of pointers, a critical tool for any embedded C programmer. A solid grasp of these ideas is essential for the more advanced topics of device driver development and kernel interaction that lie ahead.

  • Memory and Addresses: All data resides in memory at a unique numerical address.
  • Pointers: A pointer is a special variable that stores the memory address of another piece of data.
  • Declaration: Pointers are declared with an asterisk, e.g., data_type *pointer_name;.
  • Address-Of Operator (&): This operator retrieves the memory address of a variable (e.g., p = &var;).
  • Dereference Operator (*): This operator accesses the value at the address a pointer holds (e.g., value = *p;). It can also be used to modify the value (*p = 100;).
  • Pointer Arithmetic: Adding or subtracting an integer from a pointer scales the operation by the size of the pointer's base type. This is the key to navigating arrays and data buffers efficiently.
  • Pass-by-Reference: Passing pointers to functions allows those functions to modify the original variables in the calling scope, a technique essential for efficient and powerful C programming.
  • Pointer Safety: Common errors like dereferencing NULL, using uninitialized pointers, and causing buffer overflows must be diligently avoided through careful initialization, boundary checks, and heeding compiler warnings.

Further Reading

  1. Kernighan, B. W., & Ritchie, D. M. (1988). The C Programming Language (2nd ed.). Prentice Hall. — The definitive book on C, co-authored by the language's creator. The chapter on pointers is canonical.
  2. GNU Compiler Collection (GCC) Documentation. https://gcc.gnu.org/onlinedocs/ — The official manual for the compiler you are using. Understanding its options and warnings is crucial.
  3. C++ Reference - C Language Section. https://en.cppreference.com/w/c — Despite the name, this is an excellent and meticulously detailed reference for the C language, including libraries and syntax.
  4. Patterson, D. A., & Hennessy, J. L. (2017). Computer Organization and Design RISC-V Edition. Morgan Kaufmann. — For a deeper understanding of how memory, addresses, and CPUs work at the hardware level.
  5. Eli Bendersky's Blog. https://eli.thegreenplace.net/tag/c — A well-respected technical blog with many deep-dive articles on C, pointers, and low-level programming concepts.

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top