Pointers¶
Learning Objectives: After going through this material, you will know how to use pointers in C and understand the significant benefits of their use in memory management for low-level programming.
Let's start with a brief review. When we introduce a variable in our program, for example,
int8_t x = 42;, the following happens:- The compiler allocates a memory location, the size of which depends on the variable type. In this case, one byte.
- The compiler stores the initialization value of the variable, in this case, the number 42, into the allocated memory location.
- The compiler keeps track of where the variable's value is stored and generates machine-code instructions for accessing it. Depending on the program and the architecture, the value may be stored in memory or temporarily in a processor register. When the variable is stored in memory, it has a memory address that can be accessed using a pointer. We will examine the details later in the computer architecture part of the course.
Pointer Variables¶
Pointers are a way to reference memory locations used by variables in a program. Previously, we worked with variables directly by assigning and manipulating their values. With pointers, instead of handling values directly, we can store and reference the memory address where a value is located. This allows us to access and modify the value at that memory address, providing more control over the program’s memory.
Let's clarify this with a hypothetical example where we have introduced and initialized two variables in the program,
a and addressof_a.uint8_t a = 0x42;
uint8_t *addressof_a = &a; // &-operator
Okay, so far, there's nothing unfamiliar to us, two variables
a and addressof_a, each with its own memory location (hypothetical memory addresses 0x8E and 0x8F):| Variable Type | Variable Name | Memory Address | Value |
| uint8_t | a | 0x8E | 0x42 |
| uint8_t* | addressof_a | 0x8F | 0x8E |
Now, the difference arises in how the values of these variables are interpreted:
- The value of the variable
a0x42is understood in the program as it is. - The value of the pointer variable
addressof_a0x8Eis interpreted as a memory address using the star operator*.
By examining the example, we see that the value of the pointer variable
addressof_a is actually the memory address of the variable a! This is called pointing / referring to the variable a. Pointing means that, when using the pointer variable, you "jump" to the memory location indicated by its value. We can still handle the variable a as a "regular" variable; nothing changes in that regard.Example. Let's see what happens in the following:
#include <stdio.h>
#include <inttypes.h> // Derived data types from here
int main() {
uint8_t a = 0x42;
uint8_t *addressof_a = &a;
printf("The value of variable a is %x\n",a);
printf("Pointer addressof_a points to the value %x\n",*addressof_a);
// Change the value of a with assignment
a = 0x56;
printf("Pointer addressof_a points to the value %x\n",*addressof_a);
// Change the value of a through the pointer with assignment
*addressof_a = 0x78;
printf("The value of variable a is %x\n",a);
return 0;
}
And this prints:
The value of variable a is 42 Pointer addressof_a points to the value 42 Pointer addressof_a points to the value 56 The value of variable a is 78
Now, pointer variables provide an indirect way to reference memory allocated for another variable. As we will see, this is extremely useful for writing more efficient and compact code, which is particularly important in embedded programming due to resource constraints.
A pointer must contain a valid memory address for the operation we want to perform. Usually this address comes from another variable or object in the program. In embedded systems, pointers can also be used to access specific hardware addresses, such as memory-mapped peripheral registers.
We will spend the rest of this lecture explaining the benefits of such "sorcery".
Pointer Operators¶
But first, let's go over some C syntax. The language provides two operators for working with pointer variables. These operators can be used to determine the address of any variable, initialize pointer variables to a desired address, and retrieve the values of the pointed memory locations.
Operator &¶
The
& operator (address-of operator) is used to query the memory address of any variable. The syntax for the operator is &variable_name. Let's look at a code example on the lecturer's tax-deductible home PC:int8_t a = 12;
printf("The value of a is %d and the memory address is %p",a,&a);
...which outputs,
The value of a is 12 and the memory address is 000000000023FE47 (64-bit architecture on the processor).Operator *¶
The symbol
* appears in two important contexts when working with pointers.1. In a declaration,
* indicates that a variable is a pointer:int8_t *addressof_a = &a;
Here,
addressof_a is declared as a pointer to an int8_t value.ATTENTION: If a pointer is declared but not initialized, its value is undefined. It does not automatically point to a valid memory location. Dereferencing such a pointer can therefore lead to undefined behaviour (that is, the C language does not define what the program will do; it may crash, produce unexpected results, or appear to work).
int main() {
int8_t *pointer_a; // Declared but not initialized
// pointer_a does not contain a valid address that we can safely use
return 0;
}
If we do not yet have a valid address for the pointer when defining it, we should initialize it to
NULL:int8_t *pointer_a = NULL;
A
NULL pointer explicitly indicates that the pointer is not currently pointing to a valid object. It must not be dereferenced.2. In an expression,
* is the dereference operator. It accesses the object stored at the memory address contained in the pointer.Dereferencing can be used both to modify the pointed object and to retrieve its value.
For example, we can modify a variable through a pointer:
int8_t a = 47;
int8_t *addressof_a = &a;
*addressof_a = 23; // change the value of a through the pointer
Here,
*addressof_a refers to the variable a through its memory address. The assignment operator = then assigns the value 23 to that object. Now a contains the value 23.In contrast, assigning a new address to the pointer itself changes where the pointer points:
int8_t a = 47;
int8_t b = 23;
int8_t *addressof_a = &a;
addressof_a = &b;
After this,
addressof_a points to b instead of a.Dereferencing can also be used to retrieve the value stored in the pointed memory location:
uint16_t x = 0xBEEF;
uint16_t *addressof_x = &x;
printf("x=%x\n", *addressof_x);
This prints:
x=beef
In summary:
pointercontains a memory address.*pointeraccesses the object located at that address.&variableobtains the memory address of a variable.
Pointer Variable Type¶
We can observe from the examples above that a pointer variable is always given a data type when it is declared. But why? Isn't a pointer just a memory address? The data type tells the compiler what type of variable or value the pointer points to. This determines, among other things, how much data is accessed when the pointer is dereferenced.
For example:
uint8_t byte = 0x78;
uint16_t word = 0x5678;
uint32_t longword = 0x12345678;
uint8_t *pointer_byte = &byte; // pointer to an 8-bit value
uint16_t *pointer_word = &word; // pointer to a 16-bit value
uint32_t *pointer_longword = &longword; // pointer to a 32-bit value
printf("%x\n", *pointer_byte);
printf("%x\n", *pointer_word);
printf("%x\n", *pointer_longword);
This prints:
78 5678 12345678
Each pointer accesses the data at the pointed memory address according to the pointer's data type. Therefore, the pointer type is important: it tells the compiler how the pointed memory should be interpreted when the pointer is dereferenced. Normally, a pointer should point to data of a compatible type. For example, a
uint16_t * should normally point to a uint16_t object.Note! When compiling code where a pointer is initialized with the address of an incompatible type, the compiler will normally produce a warning such as:
warning: initialization from incompatible pointer type
There is, however, one particularly useful case where we may want to look at the same object one byte at a time. A multi-byte variable is stored in memory as a sequence of individual bytes. C allows us to inspect this byte representation using an
unsigned char * pointer. Because an unsigned char occupies one byte, each element accessed through this pointer corresponds to one byte of the original variable.For example:
uint32_t value = 0x12345678;
unsigned char *bytes = (unsigned char *)&value;
printf("%x\n", bytes[0]);
printf("%x\n", bytes[1]);
printf("%x\n", bytes[2]);
printf("%x\n", bytes[3]);
Here:
&valuegives the starting address of the 32-bit variable.- The address is converted to an
unsigned char *. bytes[0]accesses the first byte occupied byvalue.bytes[1]accesses the second byte, and so on.
The order of these bytes depends on the processor architecture's byte order (endianness). On a little-endian architecture, the variable:
0x12345678
is stored in memory, starting from the lowest address, as:
Address Value -------- ----- bytes[0] 0x78 bytes[1] 0x56 bytes[2] 0x34 bytes[3] 0x12
On a big-endian architecture, the same value would be stored as:
Address Value -------- ----- bytes[0] 0x12 bytes[1] 0x34 bytes[2] 0x56 bytes[3] 0x78
Thus,
bytes[0] does not mean that the 32-bit value is converted into an 8-bit integer. Instead, we are directly examining the first byte of the variable's representation in memory.This is one reason why byte order becomes important in low-level programming, for example when working with hardware registers, communication protocols, files, or data received from another system.
Pointer Variable in Memory¶
Of course, like all variables, a pointer variable requires its own memory location. Let's revisit the previous example code with some modifications. Notice the placeholder
%p, which can be used to print memory addresses.#include <stdio.h>
#include <inttypes.h>
int main() {
uint8_t a = 0x12;
uint8_t *pointer_a = &a;
// new operator: sizeof
printf("Memory address of variable a: %p\n",&a);
printf("Size of variable in bytes: %z\n",sizeof(a)); //%z is placeholder for size_t, type used for defining types of variables in C.
printf("Memory address of the pointer variable: %p\n",&pointer_a);
printf("Size of the pointer variable in bytes: %z\n",sizeof(pointer_a));//%z is placeholder for size_t, type used for defining types of variables in C.
return 0;
}
Which prints (with real memory addresses):
Memory address of variable a: 0x7ffd6232a93f Size of variable in bytes: 1 Memory address of the pointer variable: 0x7ffd6232a940 Size of the pointer variable in bytes: 8
From the output of the example, we see that each pointer variable has its own address in memory. Using the
sizeof operator, we can also determine the size of the pointer variable itself. On this 64-bit computer, the pointer occupies 8 bytes. The important thing to notice is that the size of the pointer itself does not depend on the size of the variable it points to. Pointer size depends on the architecture. For example, a pointer is typically 8 bytes on a 64-bit architecture and 4 bytes on a 32-bit architecture.Using Pointers¶
Okay, great. But why do we need pointer variables?
Pointers and Array Variables¶
Let’s examine the close relationship between pointer variables and arrays.
Since pointers allow us to freely access memory allocated to the program, we can, of course, also initialize pointer variables to point to elements of an array.
char string[] = "XYZ";
char *ptr_1 = &string[0]; // now points to 'X'
char *ptr_2 = &string[1]; // now points to 'Y'
char *ptr_3 = &string[2]; // now points to 'Z'
printf("%c%c%c\n", *ptr_1, *ptr_3, *ptr_2);
This prints:
XZY
There’s more interesting stuff when initializing pointers with arrays.
int main() {
char string[] = "XYZ";
char *method_1 = &string[0]; // initialization method 1
char *method_2 = string; // initialization method 2. Using Array Decay
// Print the string
printf("%s\n", string);
printf("%s\n", method_1);
printf("%s\n", method_2);
return 0;
}
Since all two initialization methods point to the same memory address, the string output is the same:
XYZ XYZ XYZ
We can observe that, in C, when an array is used in an expression, the name of the array decays into a pointer to its first element. This observation will be useful for us soon...
However, arrays and pointers are not identical; arrays are a block of contiguous memory, while a pointer holds the address of some memory location. Importantly, you cannot change an array's name to point to another location, unlike pointers, which can be reassigned.
Now, let’s consider what happens when you pass an array to a function. When you pass an array to a function, only the pointer to the first element is passed (array decay), so the function has no idea how large the array is unless you explicitly tell it.
Here’s an example of a function that prints an array of integers, and we must pass both the array and its size:
#include <stdio.h>
// Function prototype: Takes a pointer to the first array element and the array size
void print_array(int *array, int size);
int main() {
int numbers[] = {10, 20, 30, 40, 50}; // Declare an array of integers
int size = sizeof(numbers) / sizeof(numbers[0]); // Calculate array size
// Call the function with the array and its size
print_array(numbers, size);
return 0;
}
// Function to print array elements
void print_array(int *array, int size) {
for (int i = 0; i < size; i++) {
printf("%d ", array[i]);
}
printf("\n");
}
Attention:
sizeof does not return the number of elements in an array but rather the total memory occupied by the array in bytes. To correctly calculate the number of elements, you should divide the total size of the array by the size of one element, like this:int size = sizeof(numbers) / sizeof(numbers[0]); // Correctly calculate the size here
You can always define the size of the array (e.g. a buffer) as a constant, and use it as an argument in
size. Pointer Arithmetic¶
Since pointer variables contain memory addresses, C allows some arithmetic operations with pointers. For example, we can increment or decrement a pointer, or add or subtract an integer value to move between memory locations.
For example:
int main() {
char string[] = "ABCD";
char *ptr = string;
// print each character in a loop..
for (ptr = string; *ptr != 0; ptr++) {
printf("%c",*ptr);
}
return 0;
}
The example code prints to the screen:
ABCD
Now is a good time to explain this example a bit:
- In the initialization statement
ptr = string, we set the value of the pointer variableptrto the starting address of the string (remember array decays). - In the condition
*ptr != 0, we check that the pointer is not pointing to the null terminator (\0) (see previous material on C variables). The loop runs as long as the value pointed to by*ptris not zero. - In the assignment statement
ptr++, we increment the pointer so that it points to the next element of the same type.
The amount by which the memory address changes depends on the data type the pointer points to.
Example: In the following example, the
++ operator moves the pointer to the next element in the assignment statement ptr++.#include <stdio.h>
#include <inttypes.h>
int main() {
uint16_t array[] = { 0x1234, 0x5678, 0x9ABC, 0x0000 };
uint16_t *ptr = array;
for (ptr = array; *ptr != 0; ptr++) {
printf("%lx ",*ptr);
}
return 0;
}
This prints:
1234 5678 9abc
When you increment a pointer, the amount by which it increases depends on the size of the data type it points to. For example, incrementing a char * pointer moves it by 1 byte (since char is 1 byte), while incrementing a uint16_t * pointer moves it by 2 bytes (because uint16_t is 2 bytes).
This also highlights how C is truly a low-level, hardware-oriented language. Pointers allow us to access memory directly and move between memory locations, for example between elements of an array. As Uncle Ben (Spider-Man's uncle) reminds us, "With great power comes great responsibility." This power must be used carefully. A pointer must always refer to valid memory before it is dereferenced. If pointer arithmetic moves it outside the memory area that belongs to the variable, array, or allocated memory block, dereferencing it may access unrelated memory, corrupt data, or cause the program to crash.
As a result, a C programmer must be very careful not to break anything while working with pointers. For example, we might accidentally point to a memory location outside the variable’s allocated memory space, inadvertently modifying another variable’s value (overflow), or even outside the memory space allocated for the entire embedded program, which could lead to crashes.
Note! In the example above, we used the value
0x0000 as a marker indicating the end of the array. Another common approach is to determine the number of elements in an array using the sizeof operator.uint16_t array[] = { 0x1234, 0x5678, 0x9ABC };
int number_of_elements = sizeof(array) / sizeof(array[0]);
Here:
sizeof(array)returns the total size of the array in bytes.sizeof(array[0])returns the size of one array element in bytes.- Dividing these values gives the number of elements in the array.
In this example, a
uint16_t occupies 2 bytes, so:sizeof(array) = 6 bytes sizeof(array[0]) = 2 bytes 6 / 2 = 3 elements
We can then use the number of elements as the loop limit:
for (int i = 0; i < number_of_elements; i++) {
printf("%x ", array[i]);
}
As Function Arguments¶
Just like any other variable type, pointers can also be used as function parameters. In this case, the memory address stored in the pointer is passed to the function, and the function parameter receives a copy of that address. This is extremely useful, as we will soon see...
Let’s start with an example... the function
swap below does not work. Why?#include <stdio.h>
void swap(int8_t local_a, int8_t local_b); // prototype
int main() {
int8_t a = 14;
int8_t b = 68;
printf("Before: a=%d and b=%d\n", a, b);
swap(a, b);
printf("After: a=%d and b=%d\n", a, b);
}
void swap(int8_t local_a, int8_t local_b) {
int8_t temp = local_a;
local_a = local_b;
local_b = temp;
}
This prints:
Before: a=14 and b=68 After: a=68 and b=14
The explanation for this can be found in the Functions in C material from earlier lectures. We remember that in C, a function creates copies of the arguments in local variables. In the above code, we are only swapping the values of the copies (the variables
local_a and local_b), not the values of the original variables a and b.No worries. This issue can be fixed by using pointers as function arguments. Now, in the function, the pointer
local_a points to the memory address of a passed as an argument.#include <stdio.h>
void swap(int8_t *a, int8_t *b); // pointer prototype
int main() {
int8_t a = 14;
int8_t b = 68;
printf("Before: a=%d and b=%d\n", a, b);
swap(&a, &b); // Note the use of the & operator
printf("After: a=%d and b=%d\n", a, b);
}
void swap(int8_t *local_a, int8_t *local_b) {
int8_t temp = *local_a; // save the value pointed to by a
*local_a = *local_b; // set the value pointed to by a to the value of b
*local_b = temp; // set the value of b to the original value of a
}
Now the program prints:
Before: main_a=14 and main_b=68 After: main_a=14 and main_b=68
Memory Savings¶
In the same way, pointers can also significantly save memory!
Let’s recall an example from earlier material, where we passed a
struct with a large array as an argument to a function, and the poor function tried to obediently make a copy of it each time.// Define a struct for the message
typedef struct {
char destination_address[4]; // 4-byte address
char message[2048]; // 2-kilobyte message
} Message;
...
// Declare a message struct and fill it with data
Message message_home = {"ABCD", "This is the message content..."};
...
// Call the function with the struct as an argument
send_message(message_home);
...
This issue can be conveniently fixed by passing the address of the struct as an argument:
// Define a struct for the message
typedef struct {
char destination_address[4]; // 4-byte address
char message[2048]; // 2-kilobyte message
} Message;
// Function prototype
void send_message(Message *message_home);
int main() {
// Declare a message struct and fill it with data
Message message_home = {"ABCD", "This is the message content..."};
// Pass the address of the struct.
// Only the pointer value (the memory address) is copied to the function.
// This simulates pass-by-reference: the function can access and modify the original struct through the pointer.
send_message(&message_home);
return 0;
}
Note!: C does not have true pass-by-reference: all function arguments are passed by value. However, by passing a pointer to a variable, we can achieve behaviour similar to pass-by-reference in other programming languages, because the function can access and modify the original variable through its memory address.
Note! Of course, the function makes a copy of the pointer variable, but the pointer is much smaller than the structure itself (for example, 4 bytes on a 32-bit architecture).
Returning from a Function¶
Let’s return briefly to the previously discussed
swap function, as it reveals another useful property of pointers:void swap(int8_t *a, int8_t *b) {
int8_t temp = *a;
*a = *b;
*b = temp;
}
The function itself does not return any value, as indicated by its
void return type.However, by using pointers, the function can modify more than one variable outside the function.In this example, swap modifies both original variables through their memory addresses. This provides a convenient way to produce multiple results from a function even though a C function can directly return only one value using return. Super-useful!!!Note! We can also return a memory address from a function. But, make sure the pointer refers to valid memory (e.g., dynamically allocated memory or a global variable). Returning a pointer to a local variable in a function is unsafe, as the memory becomes invalid once the function exits.
String Handling¶
One of the key benefits of pointers is evident in handling strings.
As part of the standard library header string.h defines a set of useful functions for working with strings:
- Determining the length of a string with
strlen - Comparing strings (characters) with
strncmp - Searching for a character with
strchror a substring withstrstr - Copying strings or parts of them with
strncpy - Concatenating strings with
strncat - Even splitting a string into parts!
strtok - ...and much more.
#include <stdio.h>
#include <string.h>
int main() {
char name[] = "Judge Dredd";
printf("The length of the name %s is %zu characters\n", name, strlen(name));
return 0;
}
Most of these library functions receive strings through pointers. But remember: a pointer only stores a memory address; it does not contain information about the size of the array. So how do functions from
<string.h> know when they have reached the end of a string and should stop accessing memory? C strings use a special null character '\0' to mark the end of the string. Functions that operate on null-terminated strings therefore continue processing characters until they encounter this null character, and they do not need the total size of the array as a parameter.This is why every C string must be correctly terminated with
'\0'. If the null character is missing, a function may continue reading memory beyond the intended array. So be careful!!!Convenience for Low-Level Programming¶
Finally, here's an example of the
strtok function, because it is extremely useful in embedded systems.As noted earlier, data structures and wireless messages often follow the CSV format, where different fields in the message are separated by commas.
For example:
1234567890,temperature,27,C, where the first field is the timestamp (i.e., when the sensor value was measured), followed by the sensor type (temperature), measurement value (27), and measurement unit (Celsius).Now, breaking down such a CSV-formatted string into parts is easily done with the
strtok function.#include <string.h>
#include <stdio.h>
int main () {
char str[] = "1234567890,temperature,27,C";
const char sep[] = ","; // Separator is a comma
char *token; // Placeholder pointer
// Extract the first part of the message
token = strtok(str, sep);
// Loop to extract the remaining parts
while( token != NULL ) {
printf("%s\n",token);
// Call the function again, continue from the placeholder
token = strtok(NULL, sep);
}
return(0);
}
Let’s see what the example prints!
1234567890 temperature 27 C
Note: The function
strtok modifies the original string while splitting it. It replaces the separator characters with '\0' and returns pointers to the resulting parts of the same character array. The first call:token = strtok(str, sep);
starts processing
str. The following calls use NULL:token = strtok(NULL, sep);
to tell
strtok to continue from where it stopped during the previous call.For this reason,
strtok may initially seem somewhat unintuitive. You can find a more detailed explanation of how it works here.In the example, it's important to note that the separated parts are still strings. To process the numerical values in the message as numbers, we need to convert the strings to integer or floating-point variable types. For this, the standard library stdlib.h provides functions such as
atoi for integers, atol for long integers, and atof for floating-point numbers. These functions provide a simple way to convert strings into numerical values. The standard library also provides functions such as strtol and strtod. They offer more control over the conversion and make it possible to detect invalid input or other conversion errors. For simple examples, however, functions such as atol are sufficient.#include <stdio.h>
#include <stdlib.h>
int main () {
char str[] = "1234567890";
long value = atol(str);
printf("%ld\n",value);
return(0);
}
However, in embedded development environments, these standard library functions might be replaced by other functions, or there might be limitations in their implementation.
In Conclusion¶
This material provided a sufficient introduction to pointers in C for the course. They have more secrets... such as command line arguments, (multi-dimensional) pointer arrays, and function pointers, which we won't cover in the course. For those interested in learning more about pointers, additional information can be found in textbooks.
Give feedback on this content
Comments about this material