Pointers
This chapter addresses the implementation in C of modifiable parameters. The C language gives access to the storage addresses of variables (pointers) and provides a dedicated type that allows defining address-type variables as a basic type. Before addressing pointers and in order to better understand their necessity, the notion of the execution schema will be introduced, which is a simplified view of what happens in memory during the execution of a program (and more particularly during function calls).
Execution Schema
We first present a simplified model (which will be completed in semester 6 in the Advanced Programming course) and then expose the problem related to the nature of parameters.
Memory Model
Recall that the memory of a computer is a vector of n-bit words (where n depends on the hardware architecture; the most common are 32-bit and 64-bit). The smallest entity that can be stored in memory is the byte (see chapter 3 on basic types), which corresponds to one memory cell. Each cell is accessible via a (unique) address usually written in hexadecimal. Thus for a 32-bit architecture, the beginning of memory is:
| Address | Byte 1 | Byte 2 | Byte 3 | Byte 4 |
|---|---|---|---|---|
0x0000 | ||||
0x0004 | ||||
0x0008 | ||||
0x000c | ||||
0x0010 | ||||
... |
where the first cell has address 0x0000, the second 0x0001… the fifth 0x0004… As mentioned in chapter 3 (variables), each declared variable occupies a certain amount of memory defined by its type and the identifier allows accessing the variable in memory without having to memorize its address.
int x = 0;
char str[] = "ima2a";
char c = 'a';
bool b = false
For example, the instructions above imply that a portion of memory will be reserved as follows:
| Address | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Variable |
|---|---|---|---|---|---|
... | |||||
0x1000 | (green) | (green) | (green) | (green) | x (int, 4 bytes) |
0x1004 | (orange) | (orange) | (orange) | (orange) | str[0-3] |
0x1008 | (orange) | (orange) | (red) | (blue) | str[4-5], c, b |
... |
the green cells store x (of type int, encoded on 4 bytes), the orange cells store str (5 + 1 characters, so 6 bytes), the blue cell stores c (1 char, so 1 byte) and the red cell stores b (1 bool is stored on one byte in C).
The starting address 0x1000 is purely arbitrary; it depends on a large number of parameters (architecture, system state…) and cannot be determined in advance.
In C, when a function is called, its actual parameters (see chapter 5 on procedures/functions), its return value, and its local variables are allocated (stored in memory). Its instructions are executed until the first return encountered. The returned value is copied into the variable of the calling function and the memory used by the function is then freed (to maximize available memory space). The function dummy described below:
int dummy(int paramA, int paramB)
{
double f;
int s;
// [... instructions ...]
return s;
}
implies the following memory usage:
| Address | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Variable |
|---|---|---|---|---|---|
... | |||||
0x1000 | (green) | (green) | (green) | (green) | paramA |
0x1004 | (orange) | (orange) | (orange) | (orange) | paramB |
0x1008 | (navy) | (navy) | (navy) | (navy) | return value |
0x100c | (blue) | (blue) | (blue) | (blue) | f (double, 8 bytes) |
0x1010 | (blue) | (blue) | (blue) | (blue) | f (continued) |
0x1014 | (red) | (red) | (red) | (red) | s |
... |
which corresponds to the fact that the:
- green cells store
paramA - orange cells store
paramB - navy cells store the return value of
dummy - blue cells store
f - red cells store
s
Simple Example
Consider the algorithm below:
int add_one(int a)
{
return a+1;
}
int main()
{
int x = 12;
int y = add_one(x);
return 0;
}
The execution schema of this program is as follows (assuming main has no parameters) using the diagram below:
- Launching the program triggers a call to the
mainfunction which reserves 12 bytes (4 bytes for the return code, 4 forxand 4 fory). Recall that 12 equalsCin hexadecimal and fits in one byte (hence the000Ccorresponding tox). - The call in
mainto the functionadd_oneimplies that memory space is reserved for the variables of the functionadd_one. We choose (arbitrarily) to store starting at address0x2000. There are 8 bytes reserved: the first 4 forawhich equals 12 (000Cin hexadecimal on 4 bytes) and the next 4 for the return code of the function. - When
returnis encountered the return code is copied into the memory zone reserved for this purpose (address0x2004). The value is 13 (000Din hexadecimal). - The return code value is copied into the variable of the calling function, here
y(address0x1008) and the memory zone used byadd_oneis freed. - The
returnofmainis encountered and0is copied into the memory zone reserved for the return code…
This operation of passing parameters to functions (where the actual values of the parameters are passed) is called pass by value, which is the parameter-passing mode of the C language.
Pass by Value / Pass by Address
Pass by value becomes problematic when you have multiple variables you want to return, or one (or more) variables you want to modify. If we reformulate the previous example with procedures, we get the following program:
void incr(int a)
{
a = a+1;
}
int main()
{
int y = 12;
incr(y);
return 0;
}
For the C program to work as expected, the actual parameters passed to the called function would need to be their addresses rather than their values. This mechanism exists in some languages (for example Pascal) but not in C (cf pass by value). However, the C language offers a solution to this problem by having a address basic type that can be passed to functions. These addresses allow accessing the data we want to modify.
Addresses and C
The C language allows manipulation of addresses via two operators and operations that can be performed on addresses.
Address Operator
The address operator & allows obtaining the address of an object in memory. Of course, this operator only works on l-values. Here are some examples:
int i = 5;
float f = 5.0f;
char c[10] = "toto";
printf("%p\n", &i); // prints the address of i
printf("%p\n", &f); // prints the address of f
printf("%p\n", &(c[3])); // prints the address of the 4th cell of c
printf("%p\n", &(i+1)); // ERROR! (i+1) is not an l-value
Pointer Type
To manipulate addresses, the C language provides a dedicated type called pointer that allows storing addresses. Pointers also integrate the type of the addresses they store in order to differentiate addresses of integer variables, floating-point variables… Thus pointers are declared as follows:
int *p_i; // declaration of an integer pointer
double r;
double *p_r = &r; // declaration and initialization of a double pointer
If we represent the memory usage following these three instructions, we will have:
| Address | Byte 1 | Byte 2 | Byte 3 | Byte 4 | Variable |
|---|---|---|---|---|---|
... | |||||
0x1000 | (green) | (green) | (green) | (green) | p_i |
0x1004 | (blue) | (blue) | (blue) | (blue) | r (double, 8 bytes) |
0x1008 | (blue) | (blue) | (blue) | (blue) | r (continued) |
0x100C | (orange) | (orange) | (orange) | (orange) | p_r (contains 0x1004) |
... |
where the green cells store p_i, the blue cells r and the orange cells p_r. It is important to note that the memory space occupied by a pointer is independent of the pointed type, i.e., a pointer to a char takes the same space as a pointer to a double since they both store an address (the space occupied only depends on the hardware architecture).
The pointer type is important since it ensures that the addresses stored are of the pointer’s type. For example:
int main ()
{
float f;
int * p_i;
p_i = &f;
return 0;
}
compiled as:
jdequidt@weppes:~$ clang -Wall -o 012_pointeur_type.c
012_pointeur_type.c:5:6: warning: incompatible pointer types
assigning to 'int *' from 'float *'
[-Wincompatible-pointer-types]
p_i = &f;
^ ~~
1 warning generated.
jdequidt@weppes:~$
It is possible to work around this mechanism but it requires a thorough understanding of memory representation as it is the source of many segmentation errors. This mechanism will be covered later.
Dereference Operator
The dereference operator * allows accessing the content stored at a valid memory address.
int x = 10;
int * p_x = &x; // declaration and initialization of an integer pointer
printf("%d\n", *p_x); // --> Prints 10
*p_x = 4; // x now equals 4
Pointer Operations
A number of operations are possible with pointers. Notably equality or inequality tests, as well as arithmetic and logical operations (use with caution). Here are some examples:
#include <stdio.h>
int main ()
{
char c[] = "hello world !";
char *p1 = &(c[0]);
char *p2 = &(c[1]);
printf ("%p %p\n", p1, p2);
if (p1 == p2) printf ("Les pointeurs sont égaux\n");
if (p1 != p2) printf ("Les pointeurs sont différents\n");
printf ("%c %c\n", *p1, *p2);
p1 = p1 + 1;
printf ("%p %p\n", p1, p2);
if (p1 == p2) printf ("Les pointeurs sont égaux\n");
printf ("%c %c\n", *p1, *p2);
return 0;
}
The comparison operations are straightforward; the one that requires a bit more explanation is the line p1 = p1 + 1. p1 initially points to the first cell of the character array; it is then incremented so that it points to the element that follows (i.e., the second cell of the array). We verify by compiling and executing the program.
jdequidt@weppes:~$ clang -Wall -o 013_pointeurs_operations.c
jdequidt@weppes:~$ ./a.out
0xd596 0xd597
The pointers are different
h e
0xd597 0xd597
The pointers are equal
e e
jdequidt@weppes:~$
Incrementing pointers is possible with arrays since the cells are guaranteed to be all adjacent in memory. This is absolutely not guaranteed with other variables. For example, in the instruction double f,g; nothing guarantees that g is adjacent to f in memory, and therefore the following instruction: double * pf = &f; pf = pf+1 does not guarantee that pf will point to g. The same applies to other pointer operations; they must be used with great care.
Modify the previous program to work with integer arrays. By how much is p1 incremented after the instruction p1+1? Does this seem consistent given the types being manipulated?
Using Pointers
Classic Examples
Using pointers, it is possible to write in C the code of the algorithm from the previous section since we can pass the addresses of the variables we want to modify:
void incr(int * p_b)
{
*p_b = *p_b + 1;
}
int main()
{
int y = 12;
incr(&y);
return 0;
}
Detailing the execution schema of this program, we get the following steps:
- Launching the program triggers a call to the
mainfunction which reserves 8 bytes (4 for the return code, 4 fory). - The call in
mainto the procedureincrcauses the reservation of a 4-byte memory zone (only 4 bytes forp_bsince it is a procedure, there is no return code). When callingp_b, the actual parameters (i.e., address of y) are stored in the formal parameters. - The instruction
*p_b = *p_b + 1;implies modifying the data stored at addressp_b(here fixed at0x1004). - The procedure terminates and its context is destroyed. But
xhas indeed been modified via the previous step. - The
returnofmainis encountered and0is copied into the memory zone reserved for the return code…
Pointers are also used for swap operations. For example:
void permuter (int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int main ()
{
int a = 1, b = 3;
permuter (&a, &b);
printf ("%d %d\n", a, b);
return 0;
}
Whenever a subprogram involves multiple results, pointers are used, as for Euclidean division for example (computing the quotient and the remainder):
void int_div(int a, int b, int *p_q, int *p_r)
{
*p_q = a / b;
*p_r = a % b;
}
int main()
{
int x = 127, y = 9, q, r;
int_div(x, y, &q, &r);
return 0;
}
:::tip Pointer Arithmetic
The called function int_div uses pointers. The calling function must provide valid pointers (here the addresses of variables q and r)!
:::
Another very common example is scanf. Indeed when using scanf you pass the address of the variable that will receive what is typed at the keyboard, which is why this was written in chapter 4.
Pointer Validity
Errors in manipulating pointer variables have more serious consequences than on basic-type variables: an uninitialized integer used in a calculation will produce a wrong result, while an uninitialized pointer can corrupt parts of memory or generate segmentation errors. Therefore it is important that the pointers we manipulate are initialized.
There is a special pointer value which is NULL (or 0) that is very often used to represent undefined values. It does not correspond to any memory cell and therefore its dereference is impossible! A NULL pointer is often returned as an error value in a function that manipulates addresses. For example, this value can be used in tests:
void int_div(int a, int b, int * p_q, int * p_r)
{
if (NULL != p_q)
*p_q = a / b;
if (NULL != p_r)
*p_r = a % b;
}
In general, the * operator can only be used on valid pointers, namely:
- pointers to a global variable
- pointers to an existing local variable
By extension, invalid pointers are:
NULLpointers (or0)- uninitialized pointers
- pointers outside array bounds
- pointers to a destroyed local variable
The example below illustrates cases of valid or invalid pointers:
const int MAX_SIZE = 50;
int * dummy(int a)
{
int b = 5;
int * c = &b; // valid PTR
int * d; // invalid PTR
int * e = NULL; // invalid PTR
e = &(MAX_SIZE); // e, valid PTR
return c; // invalid PTR
}
int main()
{
int x = 5;
dummy(x);
int tab[MAX_SIZE];
int * ptr = &(tab[0]); // valid PTR
ptr = &(tab[2 * MAX_SIZE]); // ptr becomes invalid!
return 0;
}
Case of Arrays
As you have (or have not) noticed in the chapter on sorting, arrays were modified without using pointers. This is explained by the fact that arrays are potentially large in memory and memory copying is a costly operation that we seek to minimize. Therefore, unlike basic types, array-type parameters have their address passed as the parameter. The address of an array being its first cell means that the address of the array equals a pointer to the first cell. In general, in an expression (except for sizeof, &, and initialization) any one-dimensional array is replaced by a pointer (non l-value) to its first element.
:::warning Array and Pointer An array is not a pointer! :::
This mechanism allows easily traversing an array since if p points to a cell of the array, p+i points to the ith cell after and p-i to the ith cell before. Of course increments of type ++ and -- work. Similarly, pointer values allow determining the distance between pointed cells.
Ultimately the [] operator used to access a particular cell of the array is a convenience since in fact the compiler transforms it into a dereferenced address. For example int tab[5]; int c = t[2];, t[2] is in fact replaced by *(t+2). This explains why the notation 2[t] is valid since it is replaced by *(2+t).
Below are examples of array traversal with pointers (integer array then character array):
#include <stdio.h>
const int N = 50;
void imprime (int t[N])
{
for (int *p_i = t; p_i < &t[N]; p_i++)
{
printf ("%d ", *p_i);
}
printf ("\n");
}
void imprime_char (char t[])
{
char *ptr = t;
while (*t != '\0')
{
printf ("%c", *ptr);
ptr++;
}
printf ("\n");
}
int main ()
{
int tab[N];
char tab2[] = "Hello World !";
imprime (tab);
imprime_char (tab2);
return 0;
}
Regarding character arrays, there are useful functions already present in the standard library. They require including string.h and include for example:
strlen: for the length of a stringstrcmp: for comparing two stringsstrcat: for concatenating stringsstrtok: for searching for a pattern in a string- …
In Summary
Pointers are used for:
- passing the address of variables
- returning multiple values
- traversing arrays (see practicals)
- dynamically managing memory (semester 6)
- implementing complex data structures (linked lists, trees…)
- passing functions as parameters